From e1dd86ec0233252cb9412a0f41ec3beb13e737d5 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:39:02 +0200 Subject: [PATCH] [WIP] 0.3.0 mtp update --- Cargo.lock | 342 +++--- client/Cargo.toml | 2 +- client/src/client_connection.rs | 253 +--- communities/src/community_connection.rs | 1 + communities/src/interactables/text_chat.rs | 1 + communities/src/interactables/voice_chat.rs | 1 + iota-cli/Cargo.toml | 2 +- iota-cli/src/controls/dialog.rs | 8 +- iota-cli/src/controls/header.rs | 44 +- iota-cli/src/screens/settings.rs | 3 +- iota-cli/src/screens/users.rs | 29 +- iota-cli/src/ui.rs | 16 +- iota-connection/Cargo.toml | 5 +- iota-connection/src/lib.rs | 1 + iota-connection/src/message_common.rs | 79 +- iota-connection/src/message_handlers.rs | 174 ++- iota-connection/src/relay.rs | 350 ++++++ iota-daemon-lib/src/command_router.rs | 131 ++- iota-daemon/src/main.rs | 10 +- iota-ipc/src/lib.rs | 7 +- iota-ipc/src/protocol.rs | 17 +- iota-logger/src/lib.rs | 31 +- iota-storage/src/users/user_manager.rs | 69 +- iota-storage/src/util/db.rs | 101 +- iota-storage/src/util/e2ee_storage.rs | 120 +- iota-storage/src/util/mod.rs | 2 + iota-storage/src/util/relay_queue.rs | 135 +++ iota-storage/src/util/relay_replay.rs | 152 +++ iota-util/Cargo.toml | 2 +- iota-util/src/crypto_util.rs | 82 +- iota-util/src/file_util.rs | 14 +- iota-util/src/lib.rs | 2 + iota-util/src/mtp_compat.rs | 67 ++ iota-util/src/route_target.rs | 44 + iota-util/src/tu.rs | 39 +- iota/src/cli_color.rs | 6 +- iota/src/main.rs | 157 +-- iota/src/startup_error.rs | 52 +- omikron-connector/Cargo.toml | 1 + omikron-connector/src/omikron_connection.rs | 1147 ++++++++----------- omikron-connector/src/user_ops.rs | 168 ++- web-server/Cargo.toml | 2 +- 42 files changed, 2431 insertions(+), 1438 deletions(-) create mode 100644 iota-connection/src/relay.rs create mode 100644 iota-storage/src/util/relay_queue.rs create mode 100644 iota-storage/src/util/relay_replay.rs create mode 100644 iota-util/src/mtp_compat.rs create mode 100644 iota-util/src/route_target.rs diff --git a/Cargo.lock b/Cargo.lock index 1003ddf..7405bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.2" +version = "3.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53200bd1513e569e6e644181c922cec072c121a27db5b38d45e88e630c369366" +checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" dependencies = [ "actix-codec", "actix-rt", @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.14.0" +version = "4.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +checksum = "58356675d8c86d2e720480645a0316808471a62d0073f6a3b98810a5e0ca0e73" dependencies = [ "actix-codec", "actix-http", @@ -410,6 +410,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -422,7 +434,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -451,9 +463,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -559,6 +571,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -657,9 +678,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.1" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -1493,9 +1514,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "finl_unicode" @@ -1564,9 +1585,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1579,9 +1600,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1589,15 +1610,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1606,38 +1627,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1736,9 +1757,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1957,9 +1978,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2000,7 +2021,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.15", + "h2 0.4.16", "http 1.5.0", "http-body", "httparse", @@ -2078,9 +2099,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2092,9 +2113,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2105,9 +2126,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2119,16 +2140,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2139,15 +2161,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -2371,6 +2393,7 @@ dependencies = [ "iota-storage", "iota-util", "mtp", + "tokio", ] [[package]] @@ -2526,7 +2549,7 @@ dependencies = [ "serde_yaml", "sha2 0.11.0", "sysinfo", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "uuid", "walkdir", @@ -2659,7 +2682,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2708,9 +2731,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -2731,7 +2754,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2791,9 +2814,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libsqlite3-sys" -version = "0.38.1" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "pkg-config", "vcpkg", @@ -2816,9 +2839,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -3013,8 +3036,8 @@ dependencies = [ [[package]] name = "mtp" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-client", "mtp-codec", @@ -3029,8 +3052,8 @@ dependencies = [ [[package]] name = "mtp-client" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-codec", "mtp-common", @@ -3042,8 +3065,8 @@ dependencies = [ [[package]] name = "mtp-codec" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "base64 0.23.1", "byteorder", @@ -3051,25 +3074,27 @@ dependencies = [ "mtp-crypto", "mtp-type-map", "rand 0.10.2", + "thiserror 2.0.20", ] [[package]] name = "mtp-common" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "quinn", "rustls", - "thiserror 2.0.19", + "thiserror 2.0.20", "wtransport", ] [[package]] name = "mtp-crypto" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ - "base64 0.23.1", + "argon2", + "base64 0.22.1", "chacha20poly1305", "ed25519-dalek 3.0.0", "getrandom 0.4.3", @@ -3077,36 +3102,37 @@ dependencies = [ "ml-dsa", "mlkem-tls", "rand 0.10.2", - "rand_core 0.10.1", + "rand_core 0.6.4", "rustls", "serde", "sha2 0.11.0", - "thiserror 2.0.19", + "thiserror 1.0.69", "tokio", "zeroize", ] [[package]] name = "mtp-files" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-crypto", "rand 0.10.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "zeroize", ] [[package]] name = "mtp-host" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", "rand 0.10.2", + "thiserror 2.0.20", "tokio", "tracing", "wtransport", @@ -3114,13 +3140,14 @@ dependencies = [ [[package]] name = "mtp-transport" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "async-trait", "mtp-codec", "mtp-common", "mtp-crypto", + "rand 0.10.2", "rcgen", "rustls", "rustls-native-certs", @@ -3128,12 +3155,13 @@ dependencies = [ "tokio", "tracing", "wtransport", + "zeroize", ] [[package]] name = "mtp-type-map" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "serde", "serde_yaml", @@ -3141,8 +3169,8 @@ dependencies = [ [[package]] name = "mtp-webserver" -version = "0.2.0" -source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8" dependencies = [ "async-trait", "bytes", @@ -3159,9 +3187,8 @@ dependencies = [ "mtp-host", "mtp-transport", "quinn", - "rand 0.10.2", "rustls", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -3252,9 +3279,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -3561,6 +3588,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3598,9 +3636,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -3608,9 +3646,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -3618,9 +3656,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -3631,9 +3669,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] @@ -3738,9 +3776,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "pnet" @@ -3858,15 +3896,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -3916,7 +3954,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3924,9 +3962,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -3941,7 +3979,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -4087,7 +4125,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4158,9 +4196,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" dependencies = [ "aws-lc-rs", "pem", @@ -4225,7 +4263,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "h2 0.4.15", + "h2 0.4.16", "http 1.5.0", "http-body", "http-body-util", @@ -4274,14 +4312,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "rusqlite" -version = "0.40.1" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags 2.13.1", "fallible-iterator", @@ -4405,9 +4443,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -5006,11 +5044,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -5026,9 +5064,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -5069,9 +5107,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -5277,7 +5315,7 @@ dependencies = [ "native-tls", "rand 0.10.2", "sha1 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5387,9 +5425,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5481,9 +5519,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5494,9 +5532,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -5504,9 +5542,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5514,9 +5552,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -5527,9 +5565,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -5549,9 +5587,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -5941,15 +5979,15 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wtransport" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" +checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" dependencies = [ "bytes", "pem", @@ -5960,7 +5998,7 @@ dependencies = [ "rustls-pki-types", "sha2 0.11.0", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -5971,13 +6009,13 @@ dependencies = [ [[package]] name = "wtransport-proto" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" +checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -6019,7 +6057,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -6119,9 +6157,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6130,9 +6168,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -6141,13 +6179,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/client/Cargo.toml b/client/Cargo.toml index 174b5fa..d63431f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client"] } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client", "crypto"] } iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 512cc31..a90df86 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -1,14 +1,14 @@ use dashmap::DashMap; use iota_connection::message_common::*; use iota_connection::message_handlers; +use iota_connection::relay::message_security_class; use iota_logger::{log_cv_in, log_cv_out, log_t}; -use iota_storage::util::chat_files::{self, MessageState, change_message_state}; use iota_storage::util::config_util::CONFIG; -use iota_storage::util::e2ee_storage::{self, StoredChatSecret}; use iota_util::crypto_helper::keyring_from_base64; use iota_util::crypto_util::{self}; use mtp::client::{Receiver, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::Keyring; use std::sync::Arc; use std::time::Duration; @@ -30,6 +30,7 @@ pub struct ClientConnection { pub waiting_tasks: DashMap, CommunicationValue) -> bool + Send + Sync>>, shutdown: Arc>, + keyring: Arc>>>, } impl ClientConnection { @@ -53,9 +54,31 @@ impl ClientConnection { shutdown_tx, waiting_tasks, shutdown, + keyring: Arc::new(RwLock::new(None)), } } + pub async fn set_keyring(&self, keyring: Arc) { + *self.keyring.write().await = Some(keyring); + } + + async fn local_keyring(&self) -> Result, String> { + if let Some(keyring) = self.keyring.read().await.as_ref().cloned() { + return Ok(keyring); + } + + let keyring_data = CONFIG + .load() + .keyring + .clone() + .ok_or_else(|| "Iota keyring is not configured".to_string())?; + let keyring = keyring_from_base64(&keyring_data) + .ok_or_else(|| "Iota keyring is invalid".to_string())?; + let keyring = Arc::new(keyring); + *self.keyring.write().await = Some(keyring.clone()); + Ok(keyring) + } + pub fn start(self: Arc) { let self_clone = self.clone(); tokio::spawn(async move { @@ -96,6 +119,27 @@ impl ClientConnection { pub async fn handle_message(self: Arc, cv: CommunicationValue) { log_cv_in!(&cv); + if cv.is_type(CommunicationType::Relay) { + log_t!( + "relay_from_client_rejected", + "legacy client path has no Relay router".to_string() + ); + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + + if matches!( + message_security_class(&cv), + iota_connection::relay::MessageSecurityClass::RelayOnly + ) { + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + let _msg_id = cv.get_id(); if cv.is_type(CommunicationType::Challenge) { @@ -103,82 +147,12 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::SetChatSecret) { - let sender_id = cv.get_sender().to_string(); - let recipients = match chat_secret_recipients(&cv) { - Some(recipients) => recipients, - None => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let now = now_millis_i64(); - let chat_id = data_string(&cv, DataType::ChatId); - let secret_id = data_string(&cv, DataType::SecretId); - let version = data_i64(&cv, DataType::VersionNumber); - let wrapping_scheme = data_string(&cv, DataType::WrappingScheme); - let created_at = data_i64(&cv, DataType::CreatedAt).unwrap_or(now); - - let Some((((chat_id, secret_id), version), wrapping_scheme)) = - chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) - else { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - for recipient in recipients.iter().filter(|item| item.user_id == sender_id) { - if e2ee_storage::put_chat_secret(StoredChatSecret { - user_id: recipient.user_id.clone(), - chat_id: chat_id.clone(), - secret_id: secret_id.clone(), - version, - encrypted_secret: recipient.encrypted_secret.clone(), - kem_ciphertext: recipient.kem_ciphertext.clone(), - wrapping_scheme: wrapping_scheme.clone(), - created_at, - updated_at: now, - }) - .is_err() - { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - } - - for recipient in recipients.iter().filter(|item| item.user_id != sender_id) { - self.send_message(&set_chat_secret_cv_for_recipient(&cv, recipient)) - .await; - } - - self.send_message(&error_response(&cv, CommunicationType::Success)) - .await; - return; - } - if cv.is_type(CommunicationType::GetChatSecret) { self.send_message(&message_handlers::handle_get_chat_secret(&cv)) .await; return; } - if cv.is_type(CommunicationType::ChatSecretForward) { - let sender_id = cv.get_sender().to_string(); - let recipient_id = data_string(&cv, DataType::RecipientUserId).unwrap_or_default(); - if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) - || recipient_id.is_empty() - { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - self.send_message(&cv.with_receiver(recipient_id.parse::().unwrap_or(0))) - .await; - return; - } - if cv.is_type(CommunicationType::SaveAppData) { let sender_id = cv.get_sender(); let _app_data = cv @@ -270,137 +244,6 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::MessageOtherIota) { - let sender_id = &cv.get_sender(); - let receiver_id = &cv.get_receiver(); - - // parse send_time safely (number or string), fallback to now - let send_time_val = cv.get_data(DataType::SendTime); - let now_i64 = now_millis_i64(); - let timestamp = if let Some(n) = send_time_val.as_number() { - n as i64 - } else if let Some(s) = send_time_val.as_str() { - s.parse::().unwrap_or(now_i64) - } else { - now_i64 - }; - - // content may be missing or non-string; default to empty string - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - - chat_files::add_message( - timestamp as u128, - false, - *receiver_id as i64, - *sender_id as i64, - &content, - height, - reply_to, - ); - - // Build user_forward using the parsed numeric timestamp and safe content string - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(cv.get_id()) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default(DataType::Message, { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.clone())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - typed_container(msg_fields) - }); - - let user_resp = self - .clone() - .await_response(&user_forward, Some(Duration::from_secs(10))) - .await; - - if let Ok(user_resp) = user_resp { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - let _ = change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - ms.clone(), - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } else { - // Delivery timed out/failed — update stored state and notify sender with numeric timestamp - let _ = chat_files::change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - MessageState::Sent, - ); - - self.send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - return; - } - if cv.is_type(CommunicationType::MessagesGet) { self.send_message(&message_handlers::handle_messages_get(&cv)) .await; @@ -503,9 +346,7 @@ impl ClientConnection { } async fn handle_challenge(&self, cv: &CommunicationValue) { - let kr_str = CONFIG.load().keyring.clone().unwrap(); - - let Some(keyring) = keyring_from_base64(&kr_str) else { + let Ok(keyring) = self.local_keyring().await else { return; }; diff --git a/communities/src/community_connection.rs b/communities/src/community_connection.rs index 36a23c0..685d9d8 100644 --- a/communities/src/community_connection.rs +++ b/communities/src/community_connection.rs @@ -2,6 +2,7 @@ use crate::auth::auth_user::AuthUser; use crate::communities::community::Community; use crate::communities::interactables::interactable::Interactable; use crate::users::user_manager::get_user; +use iota_util::mtp_compat::CommunicationValueCompat; use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use futures::SinkExt; diff --git a/communities/src/interactables/text_chat.rs b/communities/src/interactables/text_chat.rs index 967f27b..6b69baa 100644 --- a/communities/src/interactables/text_chat.rs +++ b/communities/src/interactables/text_chat.rs @@ -8,6 +8,7 @@ use crate::{ }; use async_trait::async_trait; use json::{JsonValue, array, object}; +use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; use std::fs; use std::path::Path; use std::sync::Arc; diff --git a/communities/src/interactables/voice_chat.rs b/communities/src/interactables/voice_chat.rs index 5e83181..7e20022 100644 --- a/communities/src/interactables/voice_chat.rs +++ b/communities/src/interactables/voice_chat.rs @@ -1,6 +1,7 @@ use crate::communities::{community::Community, interactables::interactable::Interactable}; use async_trait::async_trait; use json::JsonValue; +use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; use std::sync::Arc; use std::{any::Any, sync::RwLock}; use uuid::Uuid; diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 4c3d508..4489cdf 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -23,7 +23,7 @@ iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } omikron-connector = { path = "../omikron-connector", optional = true } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true } actix-web = { version = "4", features = ["rustls-0_23"] } diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs index eb750ef..3756641 100644 --- a/iota-cli/src/controls/dialog.rs +++ b/iota-cli/src/controls/dialog.rs @@ -189,9 +189,7 @@ impl Screen for ConfirmDialog { let button_widths: Vec = self .buttons .iter() - .map(|b| { - crate::controls::button::button_minimum_width(&b.label) - }) + .map(|b| crate::controls::button::button_minimum_width(&b.label)) .collect(); let total_width: u16 = button_widths.iter().sum(); @@ -200,9 +198,7 @@ impl Screen for ConfirmDialog { let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2; let mut x = start_x; - for (i, (button_config, &width)) in - self.buttons.iter().zip(&button_widths).enumerate() - { + for (i, (button_config, &width)) in self.buttons.iter().zip(&button_widths).enumerate() { let button_area = Rect { x, y: buttons_area.y, diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index 09a589f..c38eda4 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -11,12 +11,17 @@ use ratatui::{ widgets::Paragraph, }; -fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) { +fn connection_badge( + state: &IpcConnectionState, + theme: &ResolvedTheme, +) -> (&'static str, ratatui::style::Style) { match state { IpcConnectionState::Connected => ("OK", theme.status.success), IpcConnectionState::Connecting => ("..", theme.status.warning), IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { + ("FAIL", theme.status.error) + } IpcConnectionState::Disconnected => ("WARN", theme.status.warning), } } @@ -52,7 +57,8 @@ pub fn render_header( format!(" v{}", daemon.version) }; - let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); + let rows = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); let cells = Layout::horizontal([ Constraint::Min(28), Constraint::Length(12), @@ -94,10 +100,34 @@ pub fn render_header( hits.register(brand_area, AppAction::OpenMain); for (index, (top, _bottom, label, intent, action)) in [ - (cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview), - (cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers), - (cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings), - (cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit), + ( + cells[1], + cells2[1], + "Overview", + ButtonIntent::Primary, + AppAction::OpenOverview, + ), + ( + cells[2], + cells2[2], + "Users", + ButtonIntent::Neutral, + AppAction::OpenUsers, + ), + ( + cells[3], + cells2[3], + "Settings", + ButtonIntent::Neutral, + AppAction::OpenSettings, + ), + ( + cells[4], + cells2[4], + "Quit", + ButtonIntent::Destructive, + AppAction::Quit, + ), ] .into_iter() .enumerate() diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index 56293c5..a0a37d8 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -186,8 +186,7 @@ impl Screen for SettingsScreen { let inner = header_block.inner(area); frame.render_widget(header_block, area); - let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]) - .split(inner); + let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); frame.render_widget( Paragraph::new(format!( diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index 93d4b13..1ff2689 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -122,10 +122,7 @@ impl UsersScreen { if self.loading { const SPINNERS: &[u8] = b"|/-\\"; let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; - f.render_widget( - Paragraph::new(format!("{ch} Loading users…")), - inner, - ); + f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner); return; } if visible_indices.is_empty() { @@ -149,9 +146,20 @@ impl UsersScreen { let user = &self.users[*user_index]; ( *user_index, - format!("{:>6} {} {}{}", user.user_id, user.username, - match user.state { iota_ipc::LocalUserState::Managed => "managed", iota_ipc::LocalUserState::Released => "released" }, - if user.data_present { "" } else { ", data purged" }), + format!( + "{:>6} {} {}{}", + user.user_id, + user.username, + match user.state { + iota_ipc::LocalUserState::Managed => "managed", + iota_ipc::LocalUserState::Released => "released", + }, + if user.data_present { + "" + } else { + ", data purged" + } + ), ) }) .collect(); @@ -533,11 +541,14 @@ impl Screen for UsersScreen { match result { Ok(()) => { self.pending_dialog = None; - if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) { + if let Some(user) = + self.users.iter_mut().find(|user| user.user_id == user_id) + { user.state = iota_ipc::LocalUserState::Released; user.credential_present = false; } - self.message = Some(format!("Released user {user_id}; hosted data retained.")); + self.message = + Some(format!("Released user {user_id}; hosted data retained.")); } Err(error) => { self.dialog = self.pending_dialog.take(); diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index dad4c80..ece8697 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -306,7 +306,10 @@ impl UI { } pub async fn notifications(&self) -> Vec { - self.notifications.lock().map(|n| n.clone()).unwrap_or_default() + self.notifications + .lock() + .map(|n| n.clone()) + .unwrap_or_default() } pub async fn set_screen(&self, screen: Box) { @@ -611,7 +614,9 @@ impl UI { Ok(iota_ipc::ResponseResult::Error(error)) => { Err(format!("Cannot load users: {error}")) } - Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Ok(_) => { + Err("Daemon returned an unexpected response while loading users.".into()) + } Err(error) => Err(format!("Cannot load users: {error}")), } }; @@ -705,7 +710,12 @@ impl UI { width: 40.min(rows[1].width), height: 3.min(rows[1].height), }; - render_notification_area(f, notification_area, ¬ifications, context.theme); + render_notification_area( + f, + notification_area, + ¬ifications, + context.theme, + ); } } })?; diff --git a/iota-connection/Cargo.toml b/iota-connection/Cargo.toml index 65eafad..0148648 100644 --- a/iota-connection/Cargo.toml +++ b/iota-connection/Cargo.toml @@ -6,4 +6,7 @@ edition = "2024" [dependencies] iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["crypto"] } + +[dev-dependencies] +tokio = { version = "1.50.0", features = ["macros", "rt"] } diff --git a/iota-connection/src/lib.rs b/iota-connection/src/lib.rs index 601ca7b..db303ab 100644 --- a/iota-connection/src/lib.rs +++ b/iota-connection/src/lib.rs @@ -1,3 +1,4 @@ pub mod connection_handler; pub mod message_common; pub mod message_handlers; +pub mod relay; diff --git a/iota-connection/src/message_common.rs b/iota-connection/src/message_common.rs index 2ab9733..fff5bf6 100644 --- a/iota-connection/src/message_common.rs +++ b/iota-connection/src/message_common.rs @@ -2,6 +2,8 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::type_map::TypeMap; use std::time::{SystemTime, UNIX_EPOCH}; +pub use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt}; + pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { use mtp::type_map::{DataTypeId, TypeMap}; let tm = TypeMap::latest(); @@ -15,22 +17,34 @@ pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue { pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) - .as_str() + .and_then(DataValue::as_str) .map(|s| s.to_string()) - .or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string())) - .or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string())) + .or_else(|| { + cv.get_data(dt) + .and_then(DataValue::as_number) + .map(|n| n.to_string()) + }) + .or_else(|| { + cv.get_data(dt) + .and_then(DataValue::as_signed_number) + .map(|n| n.to_string()) + }) } pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option { cv.get_data(dt) - .as_number() + .and_then(DataValue::as_number) .and_then(|n| i64::try_from(n).ok()) .or_else(|| { cv.get_data(dt) - .as_signed_number() + .and_then(DataValue::as_signed_number) .and_then(|n| i64::try_from(n).ok()) }) - .or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::().ok())) + .or_else(|| { + cv.get_data(dt) + .and_then(DataValue::as_str) + .and_then(|s| s.parse::().ok()) + }) } #[derive(Debug, Clone)] @@ -67,7 +81,7 @@ pub fn recipient_from_value(value: &DataValue) -> Option { } pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option> { - let recipients = cv.get_data(DataType::Recipients).as_array()?; + let recipients = cv.get_data(DataType::Recipients)?.as_array()?; let parsed = recipients .iter() .map(recipient_from_value) @@ -80,49 +94,6 @@ pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option CommunicationValue { - let recipient_value = typed_container(vec![ - (DataType::UserId, DataValue::Str(recipient.user_id.clone())), - ( - DataType::EncryptedSecret, - DataValue::Bytes(recipient.encrypted_secret.clone()), - ), - ( - DataType::KemCiphertext, - DataValue::Bytes(recipient.kem_ciphertext.clone()), - ), - ]); - - CommunicationValue::new(CommunicationType::SetChatSecret) - .with_id(source.get_id()) - .with_sender(source.get_sender()) - .with_receiver(recipient.user_id.parse::().unwrap_or(0)) - .add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone()) - .add_typed_default( - DataType::SecretId, - source.get_data(DataType::SecretId).clone(), - ) - .add_typed_default( - DataType::VersionNumber, - source.get_data(DataType::VersionNumber).clone(), - ) - .add_typed_default( - DataType::WrappingScheme, - source.get_data(DataType::WrappingScheme).clone(), - ) - .add_typed_default( - DataType::CreatedAt, - source.get_data(DataType::CreatedAt).clone(), - ) - .add_typed_default( - DataType::Recipients, - DataValue::Array(vec![recipient_value]), - ) -} - pub fn now_millis_i64() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -131,7 +102,9 @@ pub fn now_millis_i64() -> i64 { } pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue { - CommunicationValue::new(ty) - .with_id(request.get_id()) - .with_receiver(request.get_sender()) + let mut response = CommunicationValue::new(ty).with_id(request.id().unwrap_or_default()); + if let Some(sender) = request.sender() { + response = response.with_receiver(sender); + } + response } diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index dc1d4dc..4d7144f 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -4,7 +4,11 @@ use iota_storage::util::chats_util::{self, get_user, mod_user}; use iota_storage::util::communities_util::CommunitiesUtil; use iota_storage::util::e2ee_storage::{self, ChatSecretQuery}; use iota_storage::util::settings; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent, +}; + +use crate::relay::VerifiedRelayContext; pub struct MessageMutation { pub sender_id: i64, @@ -33,6 +37,170 @@ pub fn success_response(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::Success) } +fn relay_field<'a>( + payload: &'a DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Option<&'a DataValue> { + payload.get_field(data_type.try_to_id(type_map)?) +} + +fn relay_string<'a>( + payload: &'a DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Option<&'a str> { + relay_field(payload, data_type, type_map)?.as_str() +} + +fn relay_number(payload: &DataValue, data_type: DataType, type_map: &TypeMap) -> Option { + relay_field(payload, data_type, type_map)?.as_number() +} + +fn relay_identity( + payload: &DataValue, + data_type: DataType, + type_map: &TypeMap, +) -> Result, String> { + let Some(value) = relay_field(payload, data_type, type_map) else { + return Ok(None); + }; + if let Some(number) = value.as_number() { + return u64::try_from(number) + .map(Some) + .map_err(|_| format!("Relay {data_type:?} is outside the user ID range")); + } + if let Some(text) = value.as_str() { + return text + .parse::() + .map(Some) + .map_err(|_| format!("Relay {data_type:?} is not a user ID")); + } + Err(format!("Relay {data_type:?} has an invalid user ID value")) +} + +fn validate_relay_identity( + context: &VerifiedRelayContext, + payload: &DataValue, +) -> Result<(), String> { + if relay_identity(payload, DataType::SenderId, &context.type_map)? + .is_some_and(|sender_id| sender_id != context.signer_id) + { + return Err("Relay SenderId does not match the authenticated signer".into()); + } + if relay_identity(payload, DataType::ReceiverId, &context.type_map)? + .is_some_and(|receiver_id| receiver_id != context.final_recipient_id) + { + return Err("Relay ReceiverId does not match the authenticated recipient".into()); + } + Ok(()) +} + +/* + * Apply only operations whose actor and recipient can be taken from verified + * Relay metadata. The raw Relay frame never enters these handlers, so outer + * routing fields cannot become application identity. + */ +pub fn apply_verified_relay_content( + context: &VerifiedRelayContext, + content: &VerifiedRelayContent, +) -> Result<(), String> { + validate_relay_identity(context, &content.content)?; + let sender_id = i64::try_from(context.signer_id) + .map_err(|_| "Relay signer ID exceeds the local storage range".to_string())?; + let recipient_id = i64::try_from(context.final_recipient_id) + .map_err(|_| "Relay recipient ID exceeds the local storage range".to_string())?; + let created_at = i64::try_from(context.created_at) + .map_err(|_| "Relay creation time exceeds the local storage range".to_string())?; + + match content.message_type.as_str() { + "MessageSend" => { + let message = relay_string(&content.content, DataType::Content, &context.type_map) + .ok_or_else(|| "Relay MessageSend is missing Content".to_string())?; + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or(created_at); + let height = relay_number(&content.content, DataType::Height, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .unwrap_or_default(); + let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map) + .and_then(|value| i64::try_from(value).ok()); + chat_files::add_message( + u128::try_from(send_time) + .map_err(|_| "Relay MessageSend has a negative SendTime".to_string())?, + false, + recipient_id, + sender_id, + message, + height, + reply_to, + ); + Ok(()) + } + "MessageEdit" => { + let message = relay_string(&content.content, DataType::Content, &context.type_map) + .ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?; + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay MessageEdit is missing SendTime".to_string())?; + chat_files::apply_remote_edit(recipient_id, sender_id, send_time, sender_id, message) + .map_err(|error| error.to_string()) + } + "MessageReactionAdd" | "MessageReactionRemove" => { + let reaction = relay_string(&content.content, DataType::Reaction, &context.type_map) + .filter(|value| !value.is_empty() && value.len() <= 64) + .ok_or_else(|| "Relay reaction is invalid".to_string())?; + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay reaction is missing SendTime".to_string())?; + let result = if content.message_type == "MessageReactionAdd" { + chat_files::add_reaction(recipient_id, sender_id, send_time, sender_id, reaction) + } else { + chat_files::remove_reaction(recipient_id, sender_id, send_time, sender_id, reaction) + }; + result.map_err(|error| error.to_string()) + } + "MessageDeleteLive" => { + let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| "Relay MessageDeleteLive is missing SendTime".to_string())?; + chat_files::apply_remote_delete(recipient_id, sender_id, send_time, sender_id) + .map_err(|error| error.to_string()) + } + "SetChatSecret" => { + let frame = CommunicationValue::new(CommunicationType::SetChatSecret) + .with_payload(content.content.clone()); + let recipients = chat_secret_recipients(&frame) + .ok_or_else(|| "Relay SetChatSecret has no recipients".to_string())?; + let recipient = recipients + .into_iter() + .find(|value| value.user_id == context.final_recipient_id.to_string()) + .ok_or_else(|| "Relay SetChatSecret recipient mismatch".to_string())?; + let chat_id = data_string(&frame, DataType::ChatId) + .ok_or_else(|| "Relay SetChatSecret is missing ChatId".to_string())?; + let secret_id = data_string(&frame, DataType::SecretId) + .ok_or_else(|| "Relay SetChatSecret is missing SecretId".to_string())?; + let version = data_i64(&frame, DataType::VersionNumber) + .ok_or_else(|| "Relay SetChatSecret is missing VersionNumber".to_string())?; + let wrapping_scheme = data_string(&frame, DataType::WrappingScheme) + .ok_or_else(|| "Relay SetChatSecret is missing WrappingScheme".to_string())?; + e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { + user_id: context.final_recipient_id.to_string(), + chat_id, + secret_id, + version, + encrypted_secret: recipient.encrypted_secret, + kem_ciphertext: recipient.kem_ciphertext, + wrapping_scheme, + created_at, + updated_at: now_millis_i64(), + }) + .map_err(|error| error.to_string()) + } + _ => Ok(()), + } +} + pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { let mutation = match message_mutation(cv) { Ok(mutation) => mutation, @@ -346,7 +514,9 @@ mod presence_tests { fn sync_error(cv: &CommunicationValue) -> CommunicationValue { error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default( DataType::SessionId, - cv.get_data(DataType::SessionId).clone(), + cv.get_data(DataType::SessionId) + .cloned() + .unwrap_or(DataValue::Null), ) } diff --git a/iota-connection/src/relay.rs b/iota-connection/src/relay.rs new file mode 100644 index 0000000..298a0df --- /dev/null +++ b/iota-connection/src/relay.rs @@ -0,0 +1,350 @@ +use iota_util::route_target::RouteTarget; +use mtp::codec::{ + CommunicationValue, ProtectionPolicy, RelayError, SignaturePolicy, TypeMap, + VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame, + open_relay_content_with_keyrings, open_relay_metadata_with, relay_metadata_claimed_signer_id, +}; +use mtp::crypto::{Keyring, PublicKeyBundle}; +use std::fmt; + +pub const RELAY_PROTECTION_POLICY: ProtectionPolicy = ProtectionPolicy { + signature: SignaturePolicy::Dual, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageSecurityClass { + RelayOnly, + AuthenticatedPeerControl, + AuthenticatedLocalRequest, +} + +pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClass { + const RELAY_ONLY_TYPES: &[mtp::codec::CommunicationType] = &[ + mtp::codec::CommunicationType::MessageSend, + mtp::codec::CommunicationType::MessageLive, + mtp::codec::CommunicationType::MessageState, + mtp::codec::CommunicationType::MessageEdit, + mtp::codec::CommunicationType::MessageEditLive, + mtp::codec::CommunicationType::MessageReactionAdd, + mtp::codec::CommunicationType::MessageReactionRemove, + mtp::codec::CommunicationType::MessageReactionLive, + mtp::codec::CommunicationType::MessageDelete, + mtp::codec::CommunicationType::MessageDeleteLive, + mtp::codec::CommunicationType::MessageOtherIota, + mtp::codec::CommunicationType::SetChatSecret, + mtp::codec::CommunicationType::SendChat, + mtp::codec::CommunicationType::SettingsSave, + mtp::codec::CommunicationType::GlobalSettingsSave, + mtp::codec::CommunicationType::AddConversation, + mtp::codec::CommunicationType::AddCommunity, + mtp::codec::CommunicationType::RemoveCommunity, + ]; + + if RELAY_ONLY_TYPES.iter().any(|kind| frame.is_type(*kind)) { + MessageSecurityClass::RelayOnly + } else if frame.is_type(mtp::codec::CommunicationType::GetChatSecret) + || frame.is_type(mtp::codec::CommunicationType::MessageGet) + || frame.is_type(mtp::codec::CommunicationType::MessagesGet) + { + MessageSecurityClass::AuthenticatedPeerControl + } else { + MessageSecurityClass::AuthenticatedLocalRequest + } +} + +#[derive(Debug, Clone)] +pub struct UserIdentity { + pub user_id: u64, + pub iota_id: u64, + pub signing_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRelayContext { + pub signer_id: u64, + pub final_recipient_id: u64, + pub message_id: String, + pub created_at: u64, + pub type_map: TypeMap, +} + +#[derive(Debug, Clone)] +pub struct VerifiedRelay { + pub metadata: VerifiedRelayMetadata, + pub context: VerifiedRelayContext, + pub signing_keys: Vec, +} + +#[derive(Debug)] +pub enum RelayValidationError { + WrongNextHop { expected: u64, actual: Option }, + OuterSenderNotAllowed, + MissingSigningKeys(u64), + MissingTypeMap, + InvalidRouteTarget(u64), + KeyLookup(String), + Relay(RelayError), +} + +impl fmt::Display for RelayValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongNextHop { expected, actual } => { + write!( + formatter, + "relay next hop {:?} does not match Iota {expected}", + actual + ) + } + Self::OuterSenderNotAllowed => formatter.write_str("relay has an outer sender"), + Self::MissingSigningKeys(signer_id) => { + write!(formatter, "no trusted signing keys for user {signer_id}") + } + Self::MissingTypeMap => formatter.write_str("relay has no negotiated type map"), + Self::InvalidRouteTarget(target) => { + write!(formatter, "relay has invalid route target {target}") + } + Self::KeyLookup(error) => write!(formatter, "trusted signer lookup failed: {error}"), + Self::Relay(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for RelayValidationError {} + +impl From for RelayValidationError { + fn from(error: RelayError) -> Self { + Self::Relay(error) + } +} + +/* + * Relay metadata is opened only after the claimed signer selects trusted key + * history. Replay reservation happens after verification and durable + * acceptance, so a failed delivery can be retried without losing the frame. + */ +pub async fn verify_relay_metadata( + frame: &CommunicationValue, + local_iota_id: u64, + keyring: &Keyring, + resolve_signing_keys: F, +) -> Result +where + F: FnOnce(u64) -> Fut, + Fut: Future, RelayValidationError>>, +{ + let expected_next_hop = RouteTarget::Iota(local_iota_id) + .wire_id() + .ok_or(RelayValidationError::InvalidRouteTarget(local_iota_id))?; + if frame.receiver() != Some(expected_next_hop) { + return Err(RelayValidationError::WrongNextHop { + expected: expected_next_hop, + actual: frame.receiver(), + }); + } + if frame.sender().is_some() { + return Err(RelayValidationError::OuterSenderNotAllowed); + } + + let claimed_signer = relay_metadata_claimed_signer_id(frame, &[keyring])?; + let signing_keys = resolve_signing_keys(claimed_signer).await?; + if signing_keys.is_empty() { + return Err(RelayValidationError::MissingSigningKeys(claimed_signer)); + } + + let resolver_keys = signing_keys.clone(); + let type_map = frame + .type_map() + .cloned() + .ok_or(RelayValidationError::MissingTypeMap)?; + let metadata = open_relay_metadata_with( + frame, + &[keyring], + Some(claimed_signer), + move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()), + RELAY_PROTECTION_POLICY, + None, + )?; + + let context = VerifiedRelayContext { + signer_id: metadata.signer_id(), + final_recipient_id: metadata.final_recipient_id(), + message_id: metadata.message_id().to_owned(), + created_at: metadata.created_at(), + type_map, + }; + + Ok(VerifiedRelay { + metadata, + context, + signing_keys, + }) +} + +pub fn open_verified_relay_content( + relay: &VerifiedRelay, + keyrings: &[&Keyring], + expected_recipient_id: u64, +) -> Result { + Ok(open_relay_content_with_keyrings( + &relay.metadata, + keyrings, + &relay.signing_keys, + Some(expected_recipient_id), + RELAY_PROTECTION_POLICY, + )?) +} + +pub fn forward_verified_relay( + frame: &CommunicationValue, + target: RouteTarget, +) -> Result { + let next_hop_id = target + .wire_id() + .ok_or(RelayValidationError::InvalidRouteTarget(target.id()))?; + Ok(forward_relay_frame(frame, next_hop_id)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp::codec::SealedRelayBuilder; + use mtp::crypto::{DualSigner, Ed25519Signer, Keyring}; + + fn relay(message_id: &str) -> Result<(Keyring, Keyring, CommunicationValue), String> { + let signer_keyring = Keyring::generate(); + let recipient_keyring = Keyring::generate(); + let signer = DualSigner::new( + &signer_keyring.sig_cl_secret_key, + &signer_keyring.sig_pq_secret_key, + &signer_keyring.sig_pq_public_key, + ) + .map_err(|error| error.to_string())?; + let frame = SealedRelayBuilder::new( + "MessageSend", + mtp::codec::DataValue::Str("payload".into()), + 7, + 42, + RouteTarget::Iota(99) + .wire_id() + .ok_or("invalid test target")?, + &signer, + ) + .message_id(message_id) + .created_at(123) + .metadata_recipients(vec![recipient_keyring.public_key_bundle()]) + .content_recipients(vec![recipient_keyring.public_key_bundle()]) + .build() + .map_err(|error| error.to_string())?; + Ok((signer_keyring, recipient_keyring, frame)) + } + + #[tokio::test] + async fn verifies_metadata_with_trusted_signing_key() -> Result<(), String> { + let (signer, recipient, frame) = relay("accepted")?; + let trusted_key = signer.public_key_bundle(); + let verified = verify_relay_metadata(&frame, 99, &recipient, move |signer_id| async move { + (signer_id == 7) + .then_some(vec![trusted_key]) + .ok_or(RelayValidationError::MissingSigningKeys(signer_id)) + }) + .await + .map_err(|error| error.to_string())?; + + assert_eq!(verified.context.signer_id, 7); + assert_eq!(verified.context.final_recipient_id, 42); + assert_eq!(verified.context.message_id, "accepted"); + Ok(()) + } + + #[tokio::test] + async fn rejects_metadata_signed_by_untrusted_key() -> Result<(), String> { + let (_signer, recipient, frame) = relay("wrong-key")?; + let wrong_signer = Keyring::generate(); + let trusted_key = wrong_signer.public_key_bundle(); + let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move { + Ok(vec![trusted_key]) + }) + .await; + + assert!(matches!(result, Err(RelayValidationError::Relay(_)))); + Ok(()) + } + + #[tokio::test] + async fn rejects_classical_only_relay_under_dual_policy() -> Result<(), String> { + let signer_keyring = Keyring::generate(); + let recipient_keyring = Keyring::generate(); + let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key) + .map_err(|error| error.to_string())?; + let frame = SealedRelayBuilder::new( + "MessageSend", + mtp::codec::DataValue::Str("payload".into()), + 7, + 42, + RouteTarget::Iota(99) + .wire_id() + .ok_or("invalid test target")?, + &signer, + ) + .message_id("classical-only") + .created_at(123) + .metadata_recipients(vec![recipient_keyring.public_key_bundle()]) + .content_recipients(vec![recipient_keyring.public_key_bundle()]) + .build() + .map_err(|error| error.to_string())?; + let trusted_key = signer_keyring.public_key_bundle(); + let result = verify_relay_metadata(&frame, 99, &recipient_keyring, move |_| async move { + Ok(vec![trusted_key]) + }) + .await; + + assert!(matches!(result, Err(RelayValidationError::Relay(_)))); + Ok(()) + } + + #[tokio::test] + async fn rejects_outer_sender_before_key_lookup() -> Result<(), String> { + let (_signer, recipient, frame) = relay("outer-sender")?; + let frame = frame.with_sender(501); + let result = verify_relay_metadata(&frame, 99, &recipient, |_| async { + Err(RelayValidationError::MissingSigningKeys(7)) + }) + .await; + + assert!(matches!( + result, + Err(RelayValidationError::OuterSenderNotAllowed) + )); + Ok(()) + } + + #[tokio::test] + async fn verification_does_not_commit_replay_state() -> Result<(), String> { + let (signer, recipient, frame) = relay("duplicate")?; + let trusted_key = signer.public_key_bundle(); + + for _ in 0..2 { + let trusted_key = trusted_key.clone(); + let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move { + Ok(vec![trusted_key]) + }) + .await; + let _ = result.map_err(|error| error.to_string())?; + } + Ok(()) + } + + #[test] + fn forwarding_preserves_sealed_payload() -> Result<(), String> { + let (_signer, _recipient, frame) = relay("forwarding")?; + let forwarded = forward_verified_relay(&frame, RouteTarget::User(100)) + .map_err(|error| error.to_string())?; + + assert_eq!(frame.sender(), None); + assert_eq!(forwarded.sender(), None); + assert_eq!(forwarded.receiver(), RouteTarget::User(100).wire_id()); + assert_eq!(frame.payload(), forwarded.payload()); + Ok(()) + } +} diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 74f5f96..7f9f1c7 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -100,10 +100,25 @@ impl CommandRouter { .into_iter() .map(|user| UserSummary { credential_present: user.state == user_manager::LocalUserState::Managed - && user_manager::get_user(user.user_id).is_some_and(|profile| iota_util::file_util::read_user_credential_with_legacy(user.user_id, &profile.username).ok().flatten().is_some()), + && user_manager::get_user(user.user_id).is_some_and(|profile| { + iota_util::file_util::read_user_credential_with_legacy( + user.user_id, + &profile.username, + ) + .ok() + .flatten() + .is_some() + }), user_id: user.user_id, username: user.username, - state: match user.state { user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, user_manager::LocalUserState::Released => iota_ipc::LocalUserState::Released }, + state: match user.state { + user_manager::LocalUserState::Managed => { + iota_ipc::LocalUserState::Managed + } + user_manager::LocalUserState::Released => { + iota_ipc::LocalUserState::Released + } + }, data_present: user.data_present, }) .collect(); @@ -145,7 +160,8 @@ impl CommandRouter { } } } - LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) { + LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) + { Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }), Err(error) => { log!("User data purge failed for {user_id}: {error}"); @@ -153,25 +169,51 @@ impl CommandRouter { } }, LocalRequest::AttachUserFromTu { credential } => { - match omikron_connector::user_ops::attach_user_from_tu(self.services.omikron.as_ref(), &credential.0).await { - Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Added {} ({}) to this Iota", user.username, user.user_id) }), + match omikron_connector::user_ops::attach_user_from_tu( + self.services.omikron.as_ref(), + &credential.0, + ) + .await + { + Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Added {} ({}) to this Iota", user.username, user.user_id), + }), Err(error) => { log!("Credential attach failed: {error:?}"); ResponseResult::Error(IpcErrorCode::Unauthorized) } } } - LocalRequest::CompleteDeleteUser { user_id, credential } => { + LocalRequest::CompleteDeleteUser { + user_id, + credential, + } => { let contents = match credential { Some(value) => Ok(value.0), None => user_manager::get_user(user_id) .ok_or(()) - .and_then(|user| iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).map_err(|_| ())) + .and_then(|user| { + iota_util::file_util::read_user_credential_with_legacy( + user_id, + &user.username, + ) + .map_err(|_| ()) + }) .and_then(|value| value.ok_or(())), }; - let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); }; - match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }), + let Ok(contents) = contents else { + return ResponseResult::Error(IpcErrorCode::Unauthorized); + }; + match omikron_connector::user_ops::complete_delete_user_with_tu( + self.services.omikron.as_ref(), + &contents, + user_id, + ) + .await + { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Deleted Tensamin account {user_id}"), + }), Err(error) => { log!("Credential deletion failed for {user_id}: {error:?}"); ResponseResult::Error(IpcErrorCode::Unauthorized) @@ -185,19 +227,34 @@ impl CommandRouter { } let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) .add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into())); - match self.services.omikron.await_response(&request, Duration::from_secs(20)).await { - Ok(response) if response.is_type(CommunicationType::Success) => match user_manager::release_user(user_id) { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Released user {user_id}; hosted data was retained"), - }), - Err(error) => { - log!("Remote release succeeded but local cleanup failed for {user_id}: {error}"); - ResponseResult::Error(IpcErrorCode::StorageFailure) + match self + .services + .omikron + .await_response(&request, Duration::from_secs(20)) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + match user_manager::release_user(user_id) { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!( + "Released user {user_id}; hosted data was retained" + ), + }), + Err(error) => { + log!( + "Remote release succeeded but local cleanup failed for {user_id}: {error}" + ); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } } - }, - Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => ResponseResult::Error(IpcErrorCode::Unauthorized), + } + Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => { + ResponseResult::Error(IpcErrorCode::Unauthorized) + } Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict), - Err(omikron_connector::OmikronError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout), + Err(omikron_connector::OmikronError::Timeout(_)) => { + ResponseResult::Error(IpcErrorCode::Timeout) + } Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } } @@ -294,18 +351,28 @@ impl CommandRouter { } LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { Some(user) => { - let credential_present = iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).ok().flatten().is_some(); + let credential_present = + iota_util::file_util::read_user_credential_with_legacy( + user_id, + &user.username, + ) + .ok() + .flatten() + .is_some(); ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { - user_id: user.user_id, - username: user.username, - display_name: user.display_name, - created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), - state: iota_ipc::LocalUserState::Managed, - data_present: user_manager::get_residency().iter().find(|entry| entry.user_id == user_id).is_none_or(|entry| entry.data_present), - credential_present, - })) - }, + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + created_at: user.created_at, + trusted_apps: user.trusted_apps.keys().cloned().collect(), + state: iota_ipc::LocalUserState::Managed, + data_present: user_manager::get_residency() + .iter() + .find(|entry| entry.user_id == user_id) + .is_none_or(|entry| entry.data_present), + credential_present, + })) + } None => ResponseResult::Error(IpcErrorCode::NotFound), }, LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 76eeaf0..cfe0337 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -215,8 +215,14 @@ async fn main() -> ExitCode { .spawn_tracked("user-lifecycle-reconciliation", async move { let mut states = omikron_reconcile.connection_state(); loop { - if matches!(*states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. }) { - omikron_connector::user_ops::reconcile_managed_users(omikron_reconcile.as_ref()).await; + if matches!( + *states.borrow(), + omikron_connector::omikron_connection::ConnectionState::Connected { .. } + ) { + omikron_connector::user_ops::reconcile_managed_users( + omikron_reconcile.as_ref(), + ) + .await; } tokio::select! { changed = states.changed() => if changed.is_err() { break }, diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index bd3d102..af695f2 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -6,9 +6,10 @@ pub use protocol::{ ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, - LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, - ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, - SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState, + LocalUserState, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, + RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, SecretString, StartupPhase, + StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, UpdateStatusResponse, + UserDetailResponse, UserSummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 2e2be1a..01cd14e 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -163,11 +163,20 @@ pub enum ResponsePayload { Status(StatusResponse), Tasks(Vec), Users(Vec), - UserCreated { user_id: i64, username: String }, + UserCreated { + user_id: i64, + username: String, + }, /// Retained only for wire compatibility. New lifecycle code never emits it. - UserRemoved { user_id: i64 }, - UserDataPurged { user_id: i64 }, - Acknowledged { message: String }, + UserRemoved { + user_id: i64, + }, + UserDataPurged { + user_id: i64, + }, + Acknowledged { + message: String, + }, DaemonStatus(DaemonStatusResponse), Config(ConfigResponse), OmikronStatus(OmikronStatusResponse), diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index fe3ed8e..d7d336a 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -318,28 +318,29 @@ pub fn log_cv_internal( pub fn format_cv(cv: &CommunicationValue) -> String { let mut parts = Vec::new(); - let sender = cv.get_sender(); - let receiver = cv.get_receiver(); - - if sender > 0 && receiver > 0 { - parts.push(format!("{} > {}", sender, receiver)); - } else if sender > 0 { - parts.push(format!("{}", sender)); - } else if receiver > 0 { - parts.push(format!("> {}", receiver)); + match (cv.sender(), cv.receiver()) { + (Some(sender), Some(receiver)) => parts.push(format!("{} > {}", sender, receiver)), + (Some(sender), None) => parts.push(sender.to_string()), + (None, Some(receiver)) => parts.push(format!("> {}", receiver)), + (None, None) => {} } let comm_type = cv .get_comm_type_enum() .map(|kind| kind.to_string()) .unwrap_or_else(|| cv.get_type().to_string()); - parts.push(format!("{} (id={})", comm_type, cv.get_id())); + let id = cv + .id() + .map_or_else(|| "none".to_string(), |value| value.to_string()); + parts.push(format!("{} (id={})", comm_type, id)); - let data = cv.data(); - - let formated_data = format_data_container( - data.iter().map(|(k, v)| (*k, v.clone())).collect(), - Version(1, 0), + let version = cv + .type_map() + .map(|type_map| type_map.version.clone()) + .unwrap_or_else(|| Version(3, 0)); + let formated_data = cv.data().map_or_else( + || "".to_string(), + |data| format_data_container(data.to_vec(), version), ); parts.push(format!("{}", formated_data)); diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index ccd3c9e..ed2294f 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -19,7 +19,10 @@ pub struct UserResidency { } fn now_millis() -> i64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64 + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 } pub fn add_user(user: UserProfile) { @@ -235,7 +238,10 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr })?; db::with_db(|conn| { let tx = conn.unchecked_transaction()?; - tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; tx.execute( r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at) @@ -246,7 +252,8 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr tx.commit()?; Ok(()) })?; - remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + remove_user_credential(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } /// Authoritative hosted-data erasure used by local purge and future Omega @@ -256,14 +263,35 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage let tx = conn.unchecked_transaction()?; tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?; - tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?; - tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?; - tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?; + tx.execute( + "DELETE FROM messages WHERE storage_owner = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM contacts WHERE storage_owner = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM communities WHERE storage_owner = ?1", + params![user_id], + )?; tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?; - tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + tx.execute( + "DELETE FROM sync_events WHERE user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM sync_heads WHERE user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM client_sync_state WHERE user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; tx.execute( "UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1", params![user_id, now_millis()], @@ -273,7 +301,8 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage })?; crate::util::e2ee_storage::purge_user(user_id) .map_err(crate::storage_error::StorageError::Other)?; - delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + delete_user_directory(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } /// Complete local erasure is idempotent and is the target for a durable @@ -281,12 +310,19 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> { purge_user_data(user_id)?; db::with_db(|conn| { - conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?; + conn.execute( + "DELETE FROM trusted_apps WHERE user_id = ?1", + params![user_id], + )?; conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; - conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?; + conn.execute( + "DELETE FROM user_residency WHERE user_id = ?1", + params![user_id], + )?; Ok(()) })?; - remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) + remove_user_credential(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } pub fn get_residency() -> Vec { @@ -306,14 +342,15 @@ pub fn get_residency() -> Vec { pub fn clear() { if let Err(e) = db::with_db(|conn| { - conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?; + conn.execute_batch( + "DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;", + )?; Ok(()) }) { eprintln!("Failed to clear users: {}", e); } } - pub fn save_users() { // No-op: users are auto-saved via SQLite. } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 82968e7..01ae3a5 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -77,12 +77,22 @@ fn add_column_if_missing( column: &str, definition: &str, ) -> Result<(), StorageError> { - let mut statement = - conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; + add_table_column_if_missing(conn, "messages", column, definition) +} + +fn add_table_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + definition: &str, +) -> Result<(), StorageError> { + let mut statement = conn.prepare(&format!( + "SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1" + ))?; let exists = statement.exists([column])?; if !exists { - conn.execute_batch(&format!("ALTER TABLE messages ADD COLUMN {definition};"))?; + conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {definition};"))?; } Ok(()) @@ -266,6 +276,72 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 8 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS relay_replay ( + signer_id INTEGER NOT NULL, + message_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (signer_id, message_id) + ); + CREATE INDEX IF NOT EXISTS idx_relay_replay_created_at + ON relay_replay (created_at); + CREATE TABLE IF NOT EXISTS pending_relays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + destination_id INTEGER NOT NULL, + target_kind INTEGER NOT NULL DEFAULT 0, + frame BLOB NOT NULL, + created_at INTEGER NOT NULL, + frame_id INTEGER NOT NULL DEFAULT 0, + UNIQUE(destination_id, frame) + ); + CREATE INDEX IF NOT EXISTS idx_pending_relays_destination + ON pending_relays (destination_id, id); + PRAGMA user_version = 8; + "#, + )?; + } + + if current_version < 9 { + add_table_column_if_missing( + conn, + "pending_relays", + "target_kind", + "target_kind INTEGER NOT NULL DEFAULT 0", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "type_map_version", + "type_map_version TEXT NOT NULL DEFAULT '1.0'", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "frame_id", + "frame_id INTEGER NOT NULL DEFAULT 0", + )?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS relay_inbox ( + signer_id INTEGER NOT NULL, + message_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + destination_id INTEGER NOT NULL, + frame BLOB NOT NULL, + type_map_version TEXT NOT NULL, + frame_id INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('received', 'applied', 'queued', 'delivered', 'rejected')), + PRIMARY KEY (signer_id, message_id) + ); + CREATE INDEX IF NOT EXISTS idx_relay_inbox_state + ON relay_inbox (state, created_at); + PRAGMA user_version = 9; + "#, + )?; + } + Ok(()) } @@ -335,7 +411,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 7); + assert_eq!(version, 9); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -352,8 +428,16 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 7); - for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] { + assert_eq!(version, 9); + for table in [ + "sync_heads", + "sync_events", + "client_sync_state", + "user_residency", + "relay_replay", + "pending_relays", + "relay_inbox", + ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], @@ -361,6 +445,11 @@ mod tests { )?; assert_eq!(exists, 1); } + for column in ["frame_id", "target_kind", "type_map_version"] { + let mut statement = + conn.prepare("SELECT 1 FROM pragma_table_info('pending_relays') WHERE name = ?1")?; + assert!(statement.exists([column])?); + } Ok(()) } } diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index 6b13018..55cf8cd 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -24,19 +24,6 @@ pub struct ChatSecretQuery { pub secret_id: Option, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PendingChatSecretForward { - pub recipient_user_id: String, - pub chat_id: String, - pub sender_user_id: String, - pub secret_id: String, - pub version: i64, - pub encrypted_secret: Vec, - pub kem_ciphertext: Vec, - pub wrapping_scheme: String, - pub created_at: i64, -} - static E2EE_DB: LazyLock>> = LazyLock::new(|| { db::create_shared_connection( "e2ee", @@ -63,21 +50,6 @@ static E2EE_DB: LazyLock>> = LazyLock::new(|| { CREATE INDEX IF NOT EXISTS idx_chat_secrets_owner ON chat_secrets (user_id, chat_id, secret_id); - CREATE TABLE IF NOT EXISTS pending_chat_secret_forwards ( - recipient_user_id TEXT NOT NULL, - chat_id TEXT NOT NULL, - sender_user_id TEXT NOT NULL, - secret_id TEXT NOT NULL, - version INTEGER NOT NULL, - encrypted_secret BLOB NOT NULL, - kem_ciphertext BLOB NOT NULL, - wrapping_scheme TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (recipient_user_id, chat_id, secret_id) - ); - - CREATE INDEX IF NOT EXISTS idx_pending_chat_secret_forwards_recipient - ON pending_chat_secret_forwards (recipient_user_id, created_at); "#, ) .expect("Failed to create or initialize E2EE DB") @@ -115,89 +87,15 @@ pub fn put_chat_secret(record: StoredChatSecret) -> Result<(), StorageError> { }) } -pub fn put_pending_chat_secret_forward( - record: PendingChatSecretForward, -) -> Result<(), StorageError> { - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - INSERT INTO pending_chat_secret_forwards ( - recipient_user_id, chat_id, sender_user_id, secret_id, version, - encrypted_secret, kem_ciphertext, wrapping_scheme, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(recipient_user_id, chat_id, secret_id) DO UPDATE SET - sender_user_id = excluded.sender_user_id, - version = excluded.version, - encrypted_secret = excluded.encrypted_secret, - kem_ciphertext = excluded.kem_ciphertext, - wrapping_scheme = excluded.wrapping_scheme, - created_at = excluded.created_at - "#, - params![ - record.recipient_user_id, - record.chat_id, - record.sender_user_id, - record.secret_id, - record.version, - record.encrypted_secret, - record.kem_ciphertext, - record.wrapping_scheme, - record.created_at, - ], - )?; - Ok(()) - }) -} - -pub fn get_pending_chat_secret_forwards( - limit: i64, -) -> Result, StorageError> { - db::with_conn(&E2EE_DB, |conn| { - let mut stmt = conn.prepare( - r#" - SELECT recipient_user_id, chat_id, sender_user_id, secret_id, version, - encrypted_secret, kem_ciphertext, wrapping_scheme, created_at - FROM pending_chat_secret_forwards - ORDER BY created_at ASC - LIMIT ?1 - "#, - )?; - let rows = stmt.query_map(params![limit.clamp(1, 500)], pending_forward_from_row)?; - let mut out = Vec::new(); - for row in rows { - out.push(row?); - } - Ok(out) - }) -} - -pub fn delete_pending_chat_secret_forward( - recipient_user_id: &str, - chat_id: &str, - secret_id: &str, -) -> Result<(), StorageError> { - db::with_conn(&E2EE_DB, |conn| { - conn.execute( - r#" - DELETE FROM pending_chat_secret_forwards - WHERE recipient_user_id = ?1 AND chat_id = ?2 AND secret_id = ?3 - "#, - params![recipient_user_id, chat_id, secret_id], - )?; - Ok(()) - }) -} - -/// Erase every E2EE record owned by, or queued for, a user. The operation is +/// Erase every E2EE record owned by a user. The operation is /// intentionally idempotent so it can be retried after an interrupted remote /// erasure request. pub fn purge_user(user_id: i64) -> Result<(), StorageError> { let user_id = user_id.to_string(); db::with_conn(&E2EE_DB, |conn| { let tx = conn.unchecked_transaction()?; - tx.execute("DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id])?; tx.execute( - "DELETE FROM pending_chat_secret_forwards WHERE recipient_user_id = ?1 OR sender_user_id = ?1", + "DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id], )?; tx.commit()?; @@ -242,17 +140,3 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { - Ok(PendingChatSecretForward { - recipient_user_id: row.get(0)?, - chat_id: row.get(1)?, - sender_user_id: row.get(2)?, - secret_id: row.get(3)?, - version: row.get(4)?, - encrypted_secret: row.get(5)?, - kem_ciphertext: row.get(6)?, - wrapping_scheme: row.get(7)?, - created_at: row.get(8)?, - }) -} diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 7412c7b..2a1eec6 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -4,5 +4,7 @@ pub mod communities_util; pub mod config_util; pub mod db; pub mod e2ee_storage; +pub mod relay_queue; +pub mod relay_replay; pub mod settings; pub mod sync; diff --git a/iota-storage/src/util/relay_queue.rs b/iota-storage/src/util/relay_queue.rs new file mode 100644 index 0000000..835ebcd --- /dev/null +++ b/iota-storage/src/util/relay_queue.rs @@ -0,0 +1,135 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use iota_util::route_target::RouteTarget; +use rusqlite::params; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingRelay { + pub id: i64, + pub target: RouteTarget, + pub frame: Vec, + pub created_at: i64, + pub frame_id: u32, + pub type_map_version: String, +} + +pub fn enqueue( + target: RouteTarget, + frame: &[u8], + created_at: i64, + frame_id: u32, + type_map_version: &str, +) -> Result<(), StorageError> { + let destination_id = i64::try_from(target.id()) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + let target_kind = match target { + RouteTarget::User(_) => 0_i64, + RouteTarget::Iota(_) => 1_i64, + }; + db::with_db(|connection| { + connection.execute( + "INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + destination_id, + target_kind, + frame, + created_at, + i64::from(frame_id), + type_map_version + ], + )?; + Ok(()) + }) +} + +pub fn list(limit: i64) -> Result, StorageError> { + db::with_db(|connection| { + let mut statement = connection.prepare( + "SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version FROM pending_relays ORDER BY id LIMIT ?1", + )?; + let rows = statement.query_map(params![limit.clamp(1, 500)], |row| { + let destination_id = row.get::<_, i64>(1)?; + let target_kind = row.get::<_, i64>(2)?; + let destination_id = u64::try_from(destination_id).map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Integer, + "negative relay destination ID".into(), + ) + })?; + let target = match target_kind { + 0 => RouteTarget::User(destination_id), + 1 => RouteTarget::Iota(destination_id), + _ => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Integer, + "invalid relay target kind".into(), + )); + } + }; + Ok(PendingRelay { + id: row.get(0)?, + target, + frame: row.get(3)?, + created_at: row.get(4)?, + frame_id: u32::try_from(row.get::<_, i64>(5)?).map_err(|_| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Integer, + "negative relay frame ID".into(), + ) + })?, + type_map_version: row.get(6)?, + }) + })?; + rows.collect::, _>>().map_err(Into::into) + }) +} + +pub fn acknowledge(destination_id: u64, frame_id: u32) -> Result { + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + let changed = connection.execute( + "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2", + params![destination_id, i64::from(frame_id)], + )?; + Ok(changed == 1) + }) +} + +pub fn acknowledge_iota(destination_id: u64, frame_id: u32) -> Result { + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + let changed = connection.execute( + "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", + params![destination_id, i64::from(frame_id)], + )?; + Ok(changed == 1) + }) +} + +pub fn remove_for_frame(target: RouteTarget, frame_id: u32) -> Result { + let destination_id = i64::try_from(target.id()) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + let target_kind = match target { + RouteTarget::User(_) => 0_i64, + RouteTarget::Iota(_) => 1_i64, + }; + db::with_db(|connection| { + let changed = connection.execute( + "DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = ?2 AND frame_id = ?3", + params![destination_id, target_kind, i64::from(frame_id)], + )?; + Ok(changed == 1) + }) +} + +pub fn delete(id: i64) -> Result<(), StorageError> { + db::with_db(|connection| { + connection.execute("DELETE FROM pending_relays WHERE id = ?1", params![id])?; + Ok(()) + }) +} diff --git a/iota-storage/src/util/relay_replay.rs b/iota-storage/src/util/relay_replay.rs new file mode 100644 index 0000000..bc3bda7 --- /dev/null +++ b/iota-storage/src/util/relay_replay.rs @@ -0,0 +1,152 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use rusqlite::params; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RelayReservation { + New, + Existing { state: String, frame_matches: bool }, +} + +pub fn reserve( + signer_id: u64, + message_id: &str, + created_at: u64, + destination_id: u64, + frame: &[u8], + frame_id: u32, + type_map_version: &str, +) -> Result { + let signer_id = i64::try_from(signer_id) + .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + let created_at = i64::try_from(created_at) + .map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?; + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + + db::with_db(|connection| { + let inserted = connection.execute( + "INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'received')", + params![ + signer_id, + message_id, + created_at, + destination_id, + frame, + i64::from(frame_id), + type_map_version + ], + )?; + if inserted == 1 { + return Ok(RelayReservation::New); + } + + let (state, existing_destination_id, existing_frame, existing_type_map_version): + (String, i64, Vec, String) = connection.query_row( + "SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_id = ?1 AND message_id = ?2", + params![signer_id, message_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get::<_, Vec>(2)?, + row.get(3)?, + )) + }, + )?; + Ok(RelayReservation::Existing { + state, + frame_matches: existing_destination_id == destination_id + && existing_frame == frame + && existing_type_map_version == type_map_version, + }) + }) +} + +pub fn mark_delivered_for_frame(destination_id: u64, frame_id: u32) -> Result<(), StorageError> { + let destination_id = i64::try_from(destination_id) + .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + connection.execute( + "UPDATE relay_inbox SET state = 'delivered' WHERE destination_id = ?1 AND frame_id = ?2", + params![destination_id, i64::from(frame_id)], + )?; + Ok(()) + }) +} + +pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), StorageError> { + if !matches!( + state, + "received" | "applied" | "queued" | "delivered" | "rejected" + ) { + return Err(StorageError::Other("invalid relay inbox state".into())); + } + let signer_id = i64::try_from(signer_id) + .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + db::with_db(|connection| { + connection.execute( + "UPDATE relay_inbox SET state = ?3 WHERE signer_id = ?1 AND message_id = ?2", + params![signer_id, message_id, state], + )?; + Ok(()) + }) +} + +pub fn prune_completed(before_created_at: i64) -> Result<(), StorageError> { + db::with_db(|connection| { + connection.execute( + "DELETE FROM relay_inbox WHERE created_at < ?1 AND state IN ('delivered', 'rejected')", + params![before_created_at], + )?; + connection.execute( + "DELETE FROM relay_replay WHERE created_at < ?1", + params![before_created_at], + )?; + Ok(()) + }) +} + +pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result { + let signer_id = i64::try_from(signer_id) + .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; + let created_at = i64::try_from(created_at) + .map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?; + + db::with_db(|connection| { + let inserted = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![signer_id, message_id, created_at], + )?; + Ok(inserted == 1) + }) +} + +#[cfg(test)] +mod tests { + use rusqlite::{Connection, params}; + + #[test] + fn replay_identity_uses_signer_and_message_id() -> Result<(), rusqlite::Error> { + let connection = Connection::open_in_memory()?; + connection.execute_batch( + "CREATE TABLE relay_replay (signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_id, message_id));", + )?; + + let first = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![7_i64, "message", 1_i64], + )?; + let duplicate = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![7_i64, "message", 2_i64], + )?; + let other_signer = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![8_i64, "message", 2_i64], + )?; + + assert_eq!((first, duplicate, other_signer), (1, 0, 1)); + Ok(()) + } +} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index e9907eb..49bfe51 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] iota-paths = { path = "../iota-paths" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "crypto" ] } diff --git a/iota-util/src/crypto_util.rs b/iota-util/src/crypto_util.rs index 64056d9..bbb884d 100644 --- a/iota-util/src/crypto_util.rs +++ b/iota-util/src/crypto_util.rs @@ -1,5 +1,50 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; -use mtp::crypto::{EncryptionType, Keyring, PublicKeyBundle, decrypt_with, encrypt_for}; +use mtp::crypto::{ + EncryptionType, Keyring, MultiEncryptedMessage, PublicKeyBundle, decrypt_multi_for, + encrypt_multi_for, +}; + +const CHALLENGE_PURPOSE: u8 = 0x01; +const LEGACY_AAD_DOMAIN: &[u8] = b"IOTA-MTP-AAD-1"; + +fn bind_aad(plaintext: &[u8], aad: &[u8]) -> Result, String> { + let aad_len = u32::try_from(aad.len()) + .map_err(|_| "associated data is too large to encode".to_string())?; + let mut bound = Vec::with_capacity( + LEGACY_AAD_DOMAIN + .len() + .saturating_add(4) + .saturating_add(aad.len()) + .saturating_add(plaintext.len()), + ); + bound.extend_from_slice(LEGACY_AAD_DOMAIN); + bound.extend_from_slice(&aad_len.to_be_bytes()); + bound.extend_from_slice(aad); + bound.extend_from_slice(plaintext); + Ok(bound) +} + +fn unbind_aad(bound: &[u8], aad: &[u8]) -> Result, String> { + let header_len = LEGACY_AAD_DOMAIN.len() + 4; + if bound.len() < header_len || &bound[..LEGACY_AAD_DOMAIN.len()] != LEGACY_AAD_DOMAIN { + return Err("associated-data binding is invalid".to_string()); + } + let length_start = LEGACY_AAD_DOMAIN.len(); + let length_end = length_start + 4; + let aad_len = u32::from_be_bytes( + bound[length_start..length_end] + .try_into() + .map_err(|_| "associated-data length is invalid".to_string())?, + ) as usize; + let aad_start = length_end; + let aad_end = aad_start + .checked_add(aad_len) + .ok_or_else(|| "associated-data length overflows".to_string())?; + if aad_end > bound.len() || &bound[aad_start..aad_end] != aad { + return Err("associated data does not match".to_string()); + } + Ok(bound[aad_end..].to_vec()) +} #[derive(Clone, Copy, Debug)] pub enum DataFormat { @@ -13,17 +58,25 @@ pub fn encrypt( aad: &[u8], recipient_pub_key_bundle: &PublicKeyBundle, ) -> Result, String> { - encrypt_for( + let bound_plaintext = bind_aad(plaintext, aad)?; + let encrypted = encrypt_multi_for( EncryptionType::MlKemChaCha20Poly1305, - recipient_pub_key_bundle, - plaintext, - aad, + CHALLENGE_PURPOSE, + &bound_plaintext, + std::slice::from_ref(recipient_pub_key_bundle), ) - .map_err(|e| format!("encryption error: {:?}", e)) + .map_err(|e| format!("encryption error: {e:?}"))?; + encrypted + .to_bytes() + .map_err(|e| format!("encryption encoding error: {e:?}")) } pub fn decrypt(ciphertext: &[u8], aad: &[u8], keyring: &Keyring) -> Result, String> { - decrypt_with(ciphertext, keyring, aad).map_err(|e| format!("decryption error: {:?}", e)) + let message = MultiEncryptedMessage::from_bytes(ciphertext) + .map_err(|e| format!("decryption envelope error: {e:?}"))?; + let bound_plaintext = decrypt_multi_for(&message, CHALLENGE_PURPOSE, keyring) + .map_err(|e| format!("decryption error: {e:?}"))?; + unbind_aad(&bound_plaintext, aad) } pub fn encrypt_challenge( @@ -51,3 +104,18 @@ pub fn export(data: &[u8], format: DataFormat) -> Result { DataFormat::Hex => Ok(hex::encode(data)), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encrypt_decrypt_binds_associated_data() -> Result<(), String> { + let keyring = Keyring::generate(); + let ciphertext = encrypt(b"challenge", b"context", &keyring.public_key_bundle())?; + + assert_eq!(decrypt(&ciphertext, b"context", &keyring)?, b"challenge"); + assert!(decrypt(&ciphertext, b"other-context", &keyring).is_err()); + Ok(()) + } +} diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 8412b10..b70f7cc 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -44,7 +44,9 @@ pub fn delete_user_directory(user_id: i64) -> io::Result<()> { } pub fn credential_path(user_id: i64) -> PathBuf { - storage_directory().join("credentials").join(format!("{user_id}.tu")) + storage_directory() + .join("credentials") + .join(format!("{user_id}.tu")) } pub fn read_user_credential(user_id: i64) -> io::Result> { @@ -58,7 +60,10 @@ pub fn read_user_credential(user_id: i64) -> io::Result> { /// Resolve a credential by immutable account id. A valid legacy /// `.tu` is migrated atomically the first time it is encountered. -pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result> { +pub fn read_user_credential_with_legacy( + user_id: i64, + username: &str, +) -> io::Result> { if let Some(credential) = read_user_credential(user_id)? { return Ok(Some(credential)); } @@ -71,7 +76,10 @@ pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Res let parsed = crate::tu::TuCredential::parse(&credential) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; if parsed.user_id != user_id { - return Err(io::Error::new(io::ErrorKind::InvalidData, "legacy credential user id mismatch")); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "legacy credential user id mismatch", + )); } write_user_credential(user_id, &parsed.to_canonical_string())?; fs::remove_file(legacy)?; diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 5d32aef..1fdc888 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,4 +1,6 @@ pub mod crypto_helper; pub mod crypto_util; pub mod file_util; +pub mod mtp_compat; +pub mod route_target; pub mod tu; diff --git a/iota-util/src/mtp_compat.rs b/iota-util/src/mtp_compat.rs new file mode 100644 index 0000000..02aced4 --- /dev/null +++ b/iota-util/src/mtp_compat.rs @@ -0,0 +1,67 @@ +use mtp::codec::{CommunicationValue, DataValue}; +use mtp::type_map::DataTypeId; + +/* + * Keep legacy control-plane handlers source-compatible while they migrate to + * MTP's explicit optional routing fields. Relay handlers must use sender() and + * receiver() directly so an absent outer sender cannot become an identity. + */ +pub trait CommunicationValueCompat { + fn get_id(&self) -> u32; + fn get_sender(&self) -> u64; + fn get_receiver(&self) -> u64; +} + +impl CommunicationValueCompat for CommunicationValue { + fn get_id(&self) -> u32 { + self.id().unwrap_or_default() + } + + fn get_sender(&self) -> u64 { + self.sender().unwrap_or_default() + } + + fn get_receiver(&self) -> u64 { + self.receiver().unwrap_or_default() + } +} + +pub trait OptionalDataValueExt<'a> { + fn as_bool(self) -> Option; + fn as_str(self) -> Option<&'a str>; + fn as_string(self) -> Option; + fn as_number(self) -> Option; + fn as_signed_number(self) -> Option; + fn as_array(self) -> Option>; + fn as_container(self) -> Option>; +} + +impl<'a> OptionalDataValueExt<'a> for Option<&'a DataValue> { + fn as_bool(self) -> Option { + self.and_then(DataValue::as_bool) + } + + fn as_str(self) -> Option<&'a str> { + self.and_then(DataValue::as_str) + } + + fn as_string(self) -> Option { + self.and_then(DataValue::as_string) + } + + fn as_number(self) -> Option { + self.and_then(DataValue::as_number) + } + + fn as_signed_number(self) -> Option { + self.and_then(DataValue::as_signed_number) + } + + fn as_array(self) -> Option> { + self.and_then(DataValue::as_array) + } + + fn as_container(self) -> Option> { + self.and_then(DataValue::as_container) + } +} diff --git a/iota-util/src/route_target.rs b/iota-util/src/route_target.rs new file mode 100644 index 0000000..43e4249 --- /dev/null +++ b/iota-util/src/route_target.rs @@ -0,0 +1,44 @@ +const TARGET_KIND_MASK: u64 = 0xC000_0000_0000_0000; +const TARGET_ID_MASK: u64 = (1_u64 << 48) - 1; +const USER_TARGET_KIND: u64 = 0x4000_0000_0000_0000; +const IOTA_TARGET_KIND: u64 = 0x8000_0000_0000_0000; + +/* + * Relay receivers carry their namespace in the wire identity. This prevents + * a user ID and an Iota ID with the same numeric value from selecting the + * wrong connection at an Omikron. + */ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteTarget { + User(u64), + Iota(u64), +} + +impl RouteTarget { + pub fn wire_id(self) -> Option { + let (kind, id) = match self { + Self::User(id) => (USER_TARGET_KIND, id), + Self::Iota(id) => (IOTA_TARGET_KIND, id), + }; + (id > 0 && id <= TARGET_ID_MASK).then_some(kind | id) + } + + pub fn from_wire_id(value: u64) -> Option { + let id = value & TARGET_ID_MASK; + if id == 0 || value & !(TARGET_KIND_MASK | TARGET_ID_MASK) != 0 { + return None; + } + + match value & TARGET_KIND_MASK { + USER_TARGET_KIND => Some(Self::User(id)), + IOTA_TARGET_KIND => Some(Self::Iota(id)), + _ => None, + } + } + + pub const fn id(self) -> u64 { + match self { + Self::User(id) | Self::Iota(id) => id, + } + } +} diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs index e1a8334..159bea6 100644 --- a/iota-util/src/tu.rs +++ b/iota-util/src/tu.rs @@ -47,7 +47,10 @@ impl fmt::Debug for TuCredential { impl TuCredential { pub fn parse(input: &str) -> Result { - let (identity, encoded_keyring) = input.trim().split_once("::").ok_or(TuError::InvalidFormat)?; + let (identity, encoded_keyring) = input + .trim() + .split_once("::") + .ok_or(TuError::InvalidFormat)?; if encoded_keyring.is_empty() || encoded_keyring.contains("::") { return Err(TuError::InvalidFormat); } @@ -60,7 +63,11 @@ impl TuCredential { return Err(TuError::InvalidUserId); } let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; - Ok(Self { user_id, omega_host: omega_host.trim().to_owned(), keyring }) + Ok(Self { + user_id, + omega_host: omega_host.trim().to_owned(), + keyring, + }) } pub fn public_key_bundle(&self) -> PublicKeyBundle { @@ -68,7 +75,12 @@ impl TuCredential { } pub fn to_canonical_string(&self) -> String { - format!("{}@{}::{}", self.user_id, self.omega_host, keyring_to_base64(&self.keyring)) + format!( + "{}@{}::{}", + self.user_id, + self.omega_host, + keyring_to_base64(&self.keyring) + ) } } @@ -79,16 +91,31 @@ mod tests { #[test] fn round_trip_is_canonical() { - let credential = TuCredential { user_id: 42, omega_host: "omega.example:443".into(), keyring: generate_keyring() }; + let credential = TuCredential { + user_id: 42, + omega_host: "omega.example:443".into(), + keyring: generate_keyring(), + }; let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap(); assert_eq!(parsed.user_id, 42); assert_eq!(parsed.omega_host, "omega.example:443"); - assert_eq!(parsed.to_canonical_string(), credential.to_canonical_string()); + assert_eq!( + parsed.to_canonical_string(), + credential.to_canonical_string() + ); } #[test] fn rejects_malformed_credentials() { - for value in ["", "1@omega", "@omega::abc", "0@omega::abc", "281474976710656@omega::abc", "1@::abc", "1@omega::abc::def"] { + for value in [ + "", + "1@omega", + "@omega::abc", + "0@omega::abc", + "281474976710656@omega::abc", + "1@::abc", + "1@omega::abc::def", + ] { assert!(TuCredential::parse(value).is_err(), "{value}"); } } diff --git a/iota/src/cli_color.rs b/iota/src/cli_color.rs index 2f7cb38..c893805 100644 --- a/iota/src/cli_color.rs +++ b/iota/src/cli_color.rs @@ -13,10 +13,8 @@ impl Default for ColorConfig { impl ColorConfig { pub fn new() -> Self { - let enabled = env::var("NO_COLOR").is_err() - && env::var("TERM") - .map(|t| t != "dumb") - .unwrap_or(true); + let enabled = + env::var("NO_COLOR").is_err() && env::var("TERM").map(|t| t != "dumb").unwrap_or(true); Self { enabled } } diff --git a/iota/src/main.rs b/iota/src/main.rs index e05b63f..d5c79d8 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -386,17 +386,11 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { fn print_help() { let color = cli_color::ColorConfig::new(); - println!( - "{}", - cli_color::heading(&color, "Iota Operator Console") - ); + println!("{}", cli_color::heading(&color, "Iota Operator Console")); println!(); println!("Usage: iota [OPTIONS] [COMMAND]"); println!(); - println!( - "{}", - cli_color::info(&color, "Commands:") - ); + println!("{}", cli_color::info(&color, "Commands:")); println!(" (no command) Launch the interactive dashboard"); println!(" status Show daemon status"); println!(" tasks List active tasks"); @@ -432,10 +426,7 @@ fn print_help() { println!(" completions Generate shell completions"); println!(" man Show the man page"); println!(); - println!( - "{}", - cli_color::info(&color, "Options:") - ); + println!("{}", cli_color::info(&color, "Options:")); println!(" --theme Theme: monospace, binary, ansi, surface"); println!(" --output Output format: text, json, yaml, table"); println!(" --color Color: auto, always, never"); @@ -445,10 +436,7 @@ fn print_help() { println!(" -h, --help Show help"); println!(" -V, --version Show version"); println!(); - println!( - "{}", - cli_color::info(&color, "Examples:") - ); + println!("{}", cli_color::info(&color, "Examples:")); println!(" iota Launch the interactive dashboard"); println!(" iota status Show daemon status"); println!(" iota users list --output=json List users in JSON format"); @@ -458,19 +446,13 @@ fn print_help() { println!(" iota logs --limit 50 Show last 50 log entries"); println!(" iota completions bash Generate bash completions"); println!(); - println!( - "{}", - cli_color::info(&color, "Exit Codes:") - ); + println!("{}", cli_color::info(&color, "Exit Codes:")); println!(" 0 Success"); println!(" 1 General error"); println!(" 2 Invalid command or arguments"); println!(" 130 Interrupted (Ctrl+C)"); println!(); - println!( - "{}", - cli_color::muted(&color, "Environment Variables:") - ); + println!("{}", cli_color::muted(&color, "Environment Variables:")); println!(" NO_COLOR Disable colored output when set"); println!(" TERM Terminal type (dumb disables colors)"); println!(" IOTA_THEME Default theme override"); @@ -566,13 +548,26 @@ async fn run_command( Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username }, - Command::UsersAdd { username: None, tu: Some(path) } => { - let contents = std::fs::read_to_string(&path) - .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; - iota_util::tu::TuCredential::parse(&contents) - .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; - LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) } + Command::UsersAdd { + username: Some(username), + tu: None, + } => LocalRequest::CreateUser { username }, + Command::UsersAdd { + username: None, + tu: Some(path), + } => { + let contents = std::fs::read_to_string(&path).map_err(|error| { + StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())) + })?; + iota_util::tu::TuCredential::parse(&contents).map_err(|error| { + StartupError::InvalidCommand(format!( + "Invalid credential {}: {error}", + path.display() + )) + })?; + LocalRequest::AttachUserFromTu { + credential: iota_ipc::SecretString(contents), + } } Command::UsersAdd { .. } => { return Err(StartupError::InvalidCommand( @@ -583,22 +578,43 @@ async fn run_command( user_id, confirmed: true, } => LocalRequest::ReleaseUser { user_id }, - Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id }, - Command::UsersCompleteDelete { user_id, tu, confirmed: true } => { + Command::UsersPurgeData { + user_id, + confirmed: true, + } => LocalRequest::PurgeUserData { user_id }, + Command::UsersCompleteDelete { + user_id, + tu, + confirmed: true, + } => { let credential = match tu { Some(path) => { - let contents = std::fs::read_to_string(&path) - .map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?; - let parsed = iota_util::tu::TuCredential::parse(&contents) - .map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?; + let contents = std::fs::read_to_string(&path).map_err(|error| { + StartupError::InvalidCommand(format!( + "Cannot read {}: {error}", + path.display() + )) + })?; + let parsed = + iota_util::tu::TuCredential::parse(&contents).map_err(|error| { + StartupError::InvalidCommand(format!( + "Invalid credential {}: {error}", + path.display() + )) + })?; if parsed.user_id != user_id { - return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into())); + return Err(StartupError::InvalidCommand( + "credential user ID does not match complete-delete target".into(), + )); } Some(iota_ipc::SecretString(contents)) } None => None, }; - LocalRequest::CompleteDeleteUser { user_id, credential } + LocalRequest::CompleteDeleteUser { + user_id, + credential, + } } Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -621,8 +637,12 @@ async fn run_command( Command::UsersRelease { confirmed: false, .. } - | Command::UsersPurgeData { confirmed: false, .. } - | Command::UsersCompleteDelete { confirmed: false, .. } + | Command::UsersPurgeData { + confirmed: false, .. + } + | Command::UsersCompleteDelete { + confirmed: false, .. + } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -715,14 +735,14 @@ async fn run_command( ); } ResponsePayload::UserRemoved { user_id } => { - println!( - "{} {}", - cli_color::warning(&color, "Removed user"), - user_id - ); + println!("{} {}", cli_color::warning(&color, "Removed user"), user_id); } ResponsePayload::UserDataPurged { user_id } => { - println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id); + println!( + "{} hosted data for {}. Account remains managed by this Iota.", + cli_color::success(&color, "Purged"), + user_id + ); } ResponsePayload::Acknowledged { message } => { println!("{}", message); @@ -740,11 +760,7 @@ async fn run_command( status.connected ); if let Some(id) = status.iota_id { - println!( - "{}: {}", - cli_color::info(&color, "Iota ID"), - id - ); + println!("{}: {}", cli_color::info(&color, "Iota ID"), id); } } ResponsePayload::Components(components) => { @@ -756,9 +772,7 @@ async fn run_command( } else { for comp in &components { let (status_str, style) = match comp.status { - iota_ipc::HealthStatus::Healthy => { - ("healthy", cli_color::SUCCESS) - } + iota_ipc::HealthStatus::Healthy => ("healthy", cli_color::SUCCESS), iota_ipc::HealthStatus::Degraded => { ("degraded", cli_color::WARNING) } @@ -811,15 +825,9 @@ async fn run_command( } ResponsePayload::UpdateStatus(status) => { if status.available { - println!( - "{}", - cli_color::success(&color, "Update available.") - ); + println!("{}", cli_color::success(&color, "Update available.")); } else { - println!( - "{}", - cli_color::info(&color, "Up to date.") - ); + println!("{}", cli_color::info(&color, "Up to date.")); } } ResponsePayload::Communities(communities) => { @@ -827,11 +835,7 @@ async fn run_command( println!("{}", cli_color::muted(&color, "No communities.")); } else { for c in &communities { - println!( - "{} ({})", - cli_color::heading(&color, &c.title), - c.name - ); + println!("{} ({})", cli_color::heading(&color, &c.title), c.name); } } } @@ -905,7 +909,12 @@ fn render_table(payload: &ResponsePayload) { iota_ipc::HealthStatus::Failed => "failed", }; let message = comp.message.as_deref().unwrap_or("-"); - println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message); + println!( + "{:<20} {:<10} {}", + format!("{:?}", comp.id), + status_str, + message + ); } } ResponsePayload::Communities(communities) => { @@ -924,8 +933,14 @@ fn render_table(payload: &ResponsePayload) { println!("No log entries."); return; } - println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE"); - println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------"); + println!( + "{:<20} {:<6} {:<12} {}", + "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE" + ); + println!( + "{:<20} {:<6} {:<12} {}", + "--------", "--------", "--------", "--------" + ); for entry in &logs.entries { let level = if entry.is_error { "ERR" } else { "INF" }; println!( diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs index 583c281..4c46c32 100644 --- a/iota/src/startup_error.rs +++ b/iota/src/startup_error.rs @@ -43,34 +43,24 @@ impl StartupError { Self::SystemPermissionDenied(_) => { Some("Run with appropriate privileges or use a user-level daemon instead.") } - Self::SocketPermissionDenied(_) => { - Some( - "Check file permissions on the socket or ensure the daemon is running as your user.", - ) - } - Self::IpcTimedOut(_) => { - Some( - "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", - ) - } - Self::ProtocolMismatch { .. } => { - Some("Update your CLI or daemon to match versions.") - } - Self::DaemonExited { .. } => { - Some("Restart the daemon with `iota daemon restart`.") - } - Self::IpcBindUnavailable(_) => { - Some("Another instance may be running. Stop it first or use a different socket path.") - } + Self::SocketPermissionDenied(_) => Some( + "Check file permissions on the socket or ensure the daemon is running as your user.", + ), + Self::IpcTimedOut(_) => Some( + "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", + ), + Self::ProtocolMismatch { .. } => Some("Update your CLI or daemon to match versions."), + Self::DaemonExited { .. } => Some("Restart the daemon with `iota daemon restart`."), + Self::IpcBindUnavailable(_) => Some( + "Another instance may be running. Stop it first or use a different socket path.", + ), Self::Terminal(_) => { Some("Use a terminal that supports interactive mode, or run commands headlessly.") } - Self::Consent(_) => { - Some("Run `iota terms accept` in an interactive terminal to review and accept terms.") - } - Self::InvalidCommand(_) => { - Some("Run `iota --help` to see available commands.") - } + Self::Consent(_) => Some( + "Run `iota terms accept` in an interactive terminal to review and accept terms.", + ), + Self::InvalidCommand(_) => Some("Run `iota --help` to see available commands."), _ => None, } } @@ -149,8 +139,16 @@ mod tests { } #[test] fn most_errors_have_suggestions() { - assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some()); - assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some()); + assert!( + StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")) + .suggestion() + .is_some() + ); + assert!( + StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")) + .suggestion() + .is_some() + ); assert!(StartupError::Cancelled.suggestion().is_none()); } } diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 1e3e21d..31796b0 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -14,6 +14,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [ "client", "crypto", "files", + "raw", ] } dashmap = "6.2.1" diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index a555398..1e1f5c7 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,9 +1,9 @@ use dashmap::{DashMap, DashSet}; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_state::AppState; -use iota_storage::util::chat_files::{self, MessageState, change_message_state}; +use iota_storage::util::chat_files; use iota_storage::util::config_util::{CONFIG, modify_config}; -use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret}; +use iota_storage::util::{relay_queue, relay_replay}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; @@ -11,7 +11,10 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::env; use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock}; +use std::sync::{ + Arc, LazyLock, + atomic::{AtomicU32, Ordering}, +}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; @@ -24,68 +27,11 @@ use crate::omega_discovery; use iota_connection::message_common::*; use iota_connection::message_handlers; - -fn pending_chat_secret_forward_from_cv( - cv: &CommunicationValue, -) -> Option { - let recipient = chat_secret_recipients(cv)?.into_iter().next()?; - Some(PendingChatSecretForward { - recipient_user_id: recipient.user_id, - chat_id: data_string(cv, DataType::ChatId)?, - sender_user_id: cv.get_sender().to_string(), - secret_id: data_string(cv, DataType::SecretId)?, - version: data_i64(cv, DataType::VersionNumber)?, - encrypted_secret: recipient.encrypted_secret, - kem_ciphertext: recipient.kem_ciphertext, - wrapping_scheme: data_string(cv, DataType::WrappingScheme)?, - created_at: data_i64(cv, DataType::CreatedAt).unwrap_or_else(now_millis_i64), - }) -} - -fn chat_secret_forward_cv(record: &PendingChatSecretForward) -> CommunicationValue { - let recipient = typed_container(vec![ - ( - DataType::UserId, - DataValue::Str(record.recipient_user_id.clone()), - ), - ( - DataType::EncryptedSecret, - DataValue::Bytes(record.encrypted_secret.clone()), - ), - ( - DataType::KemCiphertext, - DataValue::Bytes(record.kem_ciphertext.clone()), - ), - ]); - - CommunicationValue::new(CommunicationType::SetChatSecret) - .with_sender(record.sender_user_id.parse::().unwrap_or(0)) - .with_receiver(record.recipient_user_id.parse::().unwrap_or(0)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id.clone())) - .add_typed_default( - DataType::SenderUserId, - DataValue::Str(record.sender_user_id.clone()), - ) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id.clone())) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(record.version as i128), - ) - .add_typed_default( - DataType::WrappingScheme, - DataValue::Str(record.wrapping_scheme.clone()), - ) - .add_typed_default( - DataType::CreatedAt, - DataValue::SignedNumber(record.created_at as i128), - ) - .add_typed_default(DataType::Recipients, DataValue::Array(vec![recipient])) -} - -// Helper function to check if read receipts are enabled globally -async fn is_read_receipts_enabled() -> bool { - CONFIG.load().read_receipts_enabled -} +use iota_connection::relay::{ + RelayValidationError, forward_verified_relay, open_verified_relay_content, + verify_relay_metadata, +}; +use iota_util::route_target::RouteTarget; // ============================================================================ // Configuration @@ -113,6 +59,8 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(5); const TASK_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); const TASK_MAX_AGE: Duration = Duration::from_secs(60); const MAX_CONCURRENT_HANDLERS: usize = 20; +const RELAY_RETENTION_MILLIS: i64 = 30 * 24 * 60 * 60 * 1000; +static NEXT_RELAY_FRAME_ID: AtomicU32 = AtomicU32::new(1); // ============================================================================ // Waiting Task System @@ -171,6 +119,7 @@ pub struct OmikronConnection { shutdown_tx: Arc>>>, reconnect_on_close: Arc>, auth_failure: Arc>>, + keyring: Arc>>>, pub app_challenges: Arc>, pub app_sessions: Arc>, handler_semaphore: Arc, @@ -179,6 +128,14 @@ pub struct OmikronConnection { pub(crate) app: Arc>, } +fn ensure_relay_frame_id(frame: CommunicationValue) -> CommunicationValue { + if frame.id().is_some_and(|id| id != 0) { + return frame; + } + let id = NEXT_RELAY_FRAME_ID.fetch_add(1, Ordering::Relaxed).max(1); + frame.with_id(id) +} + impl OmikronConnection { pub fn new(active_tasks: Arc>, app: Arc>) -> Self { Self::with_cancellation(CancellationToken::new(), active_tasks, app) @@ -203,6 +160,7 @@ impl OmikronConnection { shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))), reconnect_on_close: Arc::new(RwLock::new(true)), auth_failure: Arc::new(RwLock::new(None)), + keyring: Arc::new(RwLock::new(None)), app_challenges: Arc::new(DashMap::new()), app_sessions: Arc::new(DashMap::new()), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), @@ -328,7 +286,8 @@ impl OmikronConnection { self.set_state(ConnectionState::Connecting).await; log_t!("omikron_connecting"); - let keyring = self.load_or_migrate_keyring().await; + let keyring = Arc::new(self.load_or_migrate_keyring().await); + *self.keyring.write().await = Some(keyring.clone()); let existing_iota_id = CONFIG.load().iota_id; @@ -595,6 +554,15 @@ impl OmikronConnection { let result = connection.receive().await; match result { Ok(cv) => { + if cv.is_type(CommunicationType::Relay) { + let permit = self.handler_semaphore.clone().acquire_owned().await; + let self_clone = self.clone(); + tokio::spawn(async move { + let _permit = permit; + self_clone.handle_relay(cv).await; + }); + continue; + } let msg_id = cv.get_id(); if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { if (task.task)(cv.clone()) { @@ -650,188 +618,452 @@ impl OmikronConnection { self.app.lock().unwrap().push_ping_val(ping_ms as f64); } - self.flush_pending_chat_secret_forwards().await; + self.flush_pending_relays().await; + if let Err(error) = relay_replay::prune_completed( + now_millis_i64().saturating_sub(RELAY_RETENTION_MILLIS), + ) { + log!("Relay replay cleanup failed: {}", error); + } } } - async fn forward_chat_secret(&self, cv: &CommunicationValue) -> bool { - self.await_response(cv, Some(Duration::from_secs(10))) + async fn resolve_relay_signing_keys( + &self, + signer_id: u64, + ) -> Result, RelayValidationError> { + if let Some(user) = iota_storage::users::user_manager::get_user(signer_id as i64) { + let key = iota_util::crypto_helper::public_key_bundle_from_base64(&user.public_key) + .ok_or_else(|| { + RelayValidationError::KeyLookup("stored user key is invalid".into()) + })?; + return Ok(vec![key]); + } + + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::UnsignedNumber(signer_id as u128), + ); + let response = self + .await_response(&request, Some(Duration::from_secs(10))) .await - .is_ok() + .map_err(RelayValidationError::KeyLookup)?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(RelayValidationError::KeyLookup( + "Omega returned an unexpected user lookup response".into(), + )); + } + let public_key = response + .get_data(DataType::PublicKey) + .and_then(|value| value.as_str()) + .ok_or_else(|| RelayValidationError::KeyLookup("Omega returned no user key".into()))?; + let key = iota_util::crypto_helper::public_key_bundle_from_base64(public_key).ok_or_else( + || RelayValidationError::KeyLookup("Omega returned an invalid user key".into()), + )?; + Ok(vec![key]) } - async fn store_pending_chat_secret_forward(&self, cv: &CommunicationValue) { - let Some(record) = pending_chat_secret_forward_from_cv(cv) else { + pub async fn hosting_iota_for_user(&self, user_id: u64) -> Result { + if iota_storage::users::user_manager::get_user(user_id as i64).is_some() { + return CONFIG + .load() + .iota_id + .ok_or_else(|| "Iota identity is not configured".into()); + } + + let request = CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::UserId, DataValue::UnsignedNumber(user_id as u128)); + let response = self + .await_response(&request, Some(Duration::from_secs(10))) + .await?; + response + .get_data(DataType::IotaId) + .and_then(|value| value.as_number()) + .and_then(|value| u64::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| "Omega returned no hosting Iota for the user".into()) + } + + async fn send_relay_response(&self, frame_id: Option, response_type: CommunicationType) { + if let Some(frame_id) = frame_id { + let response = CommunicationValue::new(response_type).with_id(frame_id); + if let Err(error) = self.send_message(&response).await { + log!("Relay response could not be sent: {}", error); + } + } + } + + async fn handle_relay(self: Arc, frame: CommunicationValue) { + let frame = ensure_relay_frame_id(frame); + let incoming_frame_id = frame.id(); + let Some(local_iota_id) = CONFIG.load().iota_id else { + log!("Rejecting Relay because this Iota has no registered identity"); + self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInvalidData) + .await; return; }; - let _ = e2ee_storage::put_pending_chat_secret_forward(record); - } - - async fn flush_pending_chat_secret_forwards(&self) { - let Ok(records) = e2ee_storage::get_pending_chat_secret_forwards(100) else { + let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { + log!("Rejecting Relay because the Iota keyring is unavailable"); + self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInternal) + .await; return; }; + let resolver_connection = self.clone(); + let verified = verify_relay_metadata( + &frame, + local_iota_id, + &keyring, + move |signer_id| async move { + resolver_connection + .resolve_relay_signing_keys(signer_id) + .await + }, + ) + .await; + + let verified = match verified { + Ok(value) => value, + Err(error) => { + log!("Relay metadata verification failed: {}", error); + self.send_relay_response(incoming_frame_id, CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let signer_is_local = + iota_storage::users::user_manager::get_user(verified.context.signer_id as i64) + .is_some(); + let recipient_is_local = + iota_storage::users::user_manager::get_user(verified.context.final_recipient_id as i64) + .is_some(); + if !signer_is_local && !recipient_is_local { + log!( + "Rejecting Relay with no local origin or destination: signer {}, recipient {}", + verified.context.signer_id, + verified.context.final_recipient_id, + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + + let frame_bytes = match frame.clone().without_id().to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!( + "Relay could not be serialized for durable acceptance: {}", + error + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + let type_map_version = verified.context.type_map.version.to_string(); + let frame_id = frame.id().unwrap_or_default(); + let reservation = match relay_replay::reserve( + verified.context.signer_id, + &verified.context.message_id, + verified.context.created_at, + verified.context.final_recipient_id, + &frame_bytes, + frame_id, + &type_map_version, + ) { + Ok(value) => value, + Err(error) => { + log!("Relay durable acceptance failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + let already_applied = match reservation { + relay_replay::RelayReservation::New => false, + relay_replay::RelayReservation::Existing { + frame_matches: false, + .. + } => { + log!( + "Rejecting Relay identity collision for signer {} and message {}", + verified.context.signer_id, + verified.context.message_id + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + relay_replay::RelayReservation::Existing { ref state, .. } if state == "delivered" => { + self.send_relay_response(frame.id(), CommunicationType::Success) + .await; + return; + } + relay_replay::RelayReservation::Existing { ref state, .. } + if state == "applied" || state == "queued" => + { + true + } + relay_replay::RelayReservation::Existing { ref state, .. } if state == "rejected" => { + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + relay_replay::RelayReservation::Existing { .. } => false, + }; + + if signer_is_local && !recipient_is_local { + let router = match self + .hosting_iota_for_user(verified.context.final_recipient_id) + .await + { + Ok(destination_iota) => destination_iota, + Err(error) => { + log!("Relay origin route lookup failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorNoIota) + .await; + return; + } + }; + let forwarded = match forward_verified_relay(&frame, RouteTarget::Iota(router)) { + Ok(value) => value, + Err(error) => { + log!("Relay origin forwarding validation failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let bytes = match forwarded.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!("Relay origin retry could not be serialized: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + if let Err(error) = relay_queue::enqueue( + RouteTarget::Iota(router), + &bytes, + now_millis_i64(), + frame_id, + &type_map_version, + ) { + log!("Relay origin retry queue failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "queued", + ) { + log!("Relay origin state update failed: {}", error); + } + match self + .await_response(&forwarded, Some(Duration::from_secs(20))) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + if let Err(error) = relay_queue::acknowledge_iota(router, frame_id) { + log!( + "Relay origin acknowledgement could not clear the queue: {}", + error + ); + } + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "delivered", + ) { + log!("Relay origin delivery state update failed: {}", error); + } + self.send_relay_response(frame.id(), CommunicationType::Success) + .await; + } + Ok(response) => { + log!("Relay origin route returned {}", response.get_type()); + self.send_relay_response( + frame.id(), + response + .get_comm_type_enum() + .unwrap_or(CommunicationType::ErrorInternal), + ) + .await; + } + Err(error) => { + log!("Relay origin forwarding failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + } + } + return; + } + + let destination = verified.context.final_recipient_id; + let forwarded = match forward_verified_relay(&frame, RouteTarget::User(destination)) { + Ok(value) => value, + Err(error) => { + log!("Relay forwarding validation failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + let bytes = match forwarded.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!( + "Relay could not be serialized for client delivery: {}", + error + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + }; + if let Err(error) = relay_queue::enqueue( + RouteTarget::User(destination), + &bytes, + now_millis_i64(), + forwarded.id().unwrap_or_default(), + &type_map_version, + ) { + log!("Relay could not be queued for client delivery: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + + if !already_applied { + let content = match open_verified_relay_content( + &verified, + &[&keyring], + verified.context.final_recipient_id, + ) { + Ok(value) => value, + Err(error) => { + log!("Relay content verification failed: {}", error); + if let Err(queue_error) = + relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) + { + log!( + "Relay invalid-content queue cleanup failed: {}", + queue_error + ); + } + let _ = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "rejected", + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + }; + if let Err(error) = + message_handlers::apply_verified_relay_content(&verified.context, &content) + { + log!("Relay application dispatch failed: {}", error); + if let Err(queue_error) = + relay_queue::remove_for_frame(RouteTarget::User(destination), frame_id) + { + log!("Relay application queue cleanup failed: {}", queue_error); + } + let _ = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "rejected", + ); + self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) + .await; + return; + } + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "applied", + ) { + log!("Relay application state update failed: {}", error); + self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) + .await; + return; + } + } + + if let Err(error) = relay_replay::mark_state( + verified.context.signer_id, + &verified.context.message_id, + "queued", + ) { + log!("Relay queue state update failed: {}", error); + } + self.send_relay_response(frame.id(), CommunicationType::Success) + .await; + if let Err(error) = self.send_message(&forwarded).await { + log!("Relay delivery to local client failed: {}", error); + } + } + + async fn flush_pending_relays(&self) { + let Ok(records) = relay_queue::list(100) else { + return; + }; for record in records { - let Ok(recipient) = record.recipient_user_id.parse::() else { - let _ = e2ee_storage::delete_pending_chat_secret_forward( - &record.recipient_user_id, - &record.chat_id, - &record.secret_id, + let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else { + log!( + "Retaining pending Relay {} with invalid type-map version {}", + record.id, + record.type_map_version ); continue; }; - - let message = chat_secret_forward_cv(&record).with_receiver(recipient); - if self.forward_chat_secret(&message).await { - let _ = e2ee_storage::delete_pending_chat_secret_forward( - &record.recipient_user_id, - &record.chat_id, - &record.secret_id, + let type_map = mtp::codec::TypeMap::new(version); + let Ok(frame) = CommunicationValue::from_bytes_with(&record.frame, &type_map) else { + log!("Retaining pending Relay {} with invalid frame", record.id); + continue; + }; + let Ok(forwarded) = forward_verified_relay(&frame, record.target) else { + log!( + "Retaining pending Relay {} with invalid route target", + record.id ); - } - } - } - - async fn forward_message_live( - &self, - message_id: u32, - receiver_id: u64, - sender_id: i64, - timestamp: i64, - content: &str, - height: i64, - reply_to: Option, - ) -> Option { - let mut msg_fields = vec![ - (DataType::Content, DataValue::Str(content.to_string())), - ( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ), - (DataType::Height, DataValue::SignedNumber(height as i128)), - ]; - if let Some(rt) = reply_to { - msg_fields.push(( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - )); - } - - let user_forward = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(message_id) - .with_receiver(receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ) - .add_typed_default(DataType::Message, typed_container(msg_fields)); - - match self - .await_response(&user_forward, Some(Duration::from_secs(3))) - .await - { - Ok(user_resp) => { - let ms_raw = user_resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - Some(MessageState::from_str(&ms_raw).upgrade(MessageState::Received)) - } - Err(_) => None, - } - } - - async fn forward_to_remote_iota( - &self, - cv: &CommunicationValue, - sender_id: i64, - receiver_id: i64, - timestamp: i64, - content: &str, - height: i64, - reply_to: Option, - ) { - let mut fw_msg = CommunicationValue::new(CommunicationType::MessageOtherIota) - .with_id(cv.get_id()) - .with_receiver(receiver_id as u64) - .with_sender(sender_id as u64) - .add_typed_default(DataType::Height, DataValue::SignedNumber(height as i128)) - .add_typed_default(DataType::Content, DataValue::Str(content.to_string())) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ); - if let Some(rt) = reply_to { - fw_msg = fw_msg.add_typed_default( - DataType::ReplyId, - DataValue::UnsignedNumber(rt as u64 as u128), - ); - } - - match self - .await_response(&fw_msg, Some(Duration::from_secs(10))) - .await - { - Ok(resp) => { - let ms_raw = resp - .get_data(DataType::MessageState) - .as_string() - .unwrap_or_else(|| "".to_string()); - let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received); - - let _ = - chat_files::change_message_state(timestamp, sender_id, receiver_id, ms.clone()); - - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } - Err(_) => { - let _ = chat_files::change_message_state( - timestamp, - sender_id, - receiver_id, - MessageState::Sent, - ); - - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; + continue; + }; + match record.target { + RouteTarget::Iota(destination_iota) => { + match self + .await_response(&forwarded, Some(Duration::from_secs(20))) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + if let Err(error) = + relay_queue::acknowledge_iota(destination_iota, record.frame_id) + { + log!( + "Pending Relay {} acknowledgement could not clear the queue: {}", + record.id, + error + ); + } + } + Ok(response) => log!( + "Pending Relay {} route returned {}", + record.id, + response.get_type() + ), + Err(error) => { + log!("Pending Relay {} delivery failed: {}", record.id, error) + } + } + } + RouteTarget::User(_) => { + if let Err(error) = self.send_message(&forwarded).await { + log!("Pending Relay {} delivery failed: {}", record.id, error); + } + } } } } @@ -843,6 +1075,27 @@ impl OmikronConnection { pub async fn handle_message(self: Arc, cv: CommunicationValue) { log_cv_in!(&cv); + if cv.is_type(CommunicationType::Success) + && let Some(frame_id) = cv.id() + && let Some(destination_id) = cv + .get_data(DataType::UserId) + .as_number() + .and_then(|value| u64::try_from(value).ok()) + { + match relay_queue::acknowledge(destination_id, frame_id) { + Ok(true) => { + if let Err(error) = + relay_replay::mark_delivered_for_frame(destination_id, frame_id) + { + log!("Relay delivery state update failed: {}", error); + } + return; + } + Ok(false) => {} + Err(error) => log!("Relay delivery acknowledgement failed: {}", error), + } + } + let msg_id = cv.get_id(); if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) { @@ -855,6 +1108,22 @@ impl OmikronConnection { } async fn handle_message_impl(self: Arc, cv: CommunicationValue) { + if cv.is_type(CommunicationType::Relay) { + self.handle_relay(cv).await; + return; + } + + if matches!( + iota_connection::relay::message_security_class(&cv), + iota_connection::relay::MessageSecurityClass::RelayOnly + ) { + log!("Rejecting sender-based application mutation outside Relay"); + let _ = self + .send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) + .await; + return; + } + macro_rules! dispatch { ($ty:ident, $method:ident) => { if cv.is_type(CommunicationType::$ty) { @@ -864,9 +1133,7 @@ impl OmikronConnection { }; } - dispatch!(SetChatSecret, handle_set_chat_secret); dispatch!(GetChatSecret, handle_get_chat_secret); - dispatch!(ChatSecretForward, handle_chat_secret_forward); dispatch!(AppIdentification, handle_app_identification); dispatch!(AppChallengeResponse, handle_app_challenge_response); dispatch!(SaveAppData, handle_save_app_data); @@ -876,14 +1143,12 @@ impl OmikronConnection { dispatch!(ClientConnected, handle_client_connected); dispatch!(ClientStateAck, handle_client_state_ack); dispatch!(MessageState, handle_message_state); - dispatch!(MessageSend, handle_message_send); dispatch!(MessageEdit, handle_message_edit); dispatch!(MessageEditLive, handle_message_edit_live); dispatch!(MessageReactionAdd, handle_message_reaction_add); dispatch!(MessageReactionRemove, handle_message_reaction_remove); dispatch!(MessageReactionLive, handle_message_reaction_live); dispatch!(MessageDeleteLive, handle_message_delete_live); - dispatch!(MessageOtherIota, handle_message_other_iota); dispatch!(MessageGet, handle_message_get); dispatch!(MessagesGet, handle_messages_get); dispatch!(GetChats, handle_get_chats); @@ -931,123 +1196,12 @@ impl OmikronConnection { let _ = self.send_message(&acknowledgement).await; } - async fn handle_set_chat_secret(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender().to_string(); - let recipients = match chat_secret_recipients(cv) { - Some(recipients) => recipients, - None => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let now = now_millis_i64(); - let chat_id = data_string(cv, DataType::ChatId); - let secret_id = data_string(cv, DataType::SecretId); - let version = data_i64(cv, DataType::VersionNumber); - let wrapping_scheme = data_string(cv, DataType::WrappingScheme); - let created_at = data_i64(cv, DataType::CreatedAt).unwrap_or(now); - - let Some((((chat_id, secret_id), version), wrapping_scheme)) = - chat_id.zip(secret_id).zip(version).zip(wrapping_scheme) - else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - }; - - let mut non_local_forwards: Vec = Vec::new(); - - for recipient in &recipients { - let recipient_id = recipient.user_id.parse::().unwrap_or(0); - let is_local = iota_storage::users::user_manager::get_user(recipient_id).is_some(); - - if is_local { - if e2ee_storage::put_chat_secret(StoredChatSecret { - user_id: recipient.user_id.clone(), - chat_id: chat_id.clone(), - secret_id: secret_id.clone(), - version, - encrypted_secret: recipient.encrypted_secret.clone(), - kem_ciphertext: recipient.kem_ciphertext.clone(), - wrapping_scheme: wrapping_scheme.clone(), - created_at, - updated_at: now, - }) - .is_err() - { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - continue; - } - - if recipient.user_id != sender_id { - non_local_forwards.push(set_chat_secret_cv_for_recipient(cv, recipient)); - } - } - - if !non_local_forwards.is_empty() { - let mut handles = Vec::new(); - for forward in &non_local_forwards { - let self_clone = self.clone(); - let fwd = forward.clone(); - handles.push(tokio::spawn(async move { - self_clone.forward_chat_secret(&fwd).await - })); - } - - for (forward, handle) in non_local_forwards.into_iter().zip(handles) { - match handle.await { - Ok(true) => {} - _ => self.store_pending_chat_secret_forward(&forward).await, - } - } - } - - let _ = self - .send_message(&error_response(cv, CommunicationType::Success)) - .await; - } - async fn handle_get_chat_secret(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_get_chat_secret(cv)) .await; } - async fn handle_chat_secret_forward(self: Arc, cv: &CommunicationValue) { - let sender_id = cv.get_sender().to_string(); - let recipient_user_id = data_string(cv, DataType::RecipientUserId).unwrap_or_default(); - if data_string(cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str()) - || recipient_user_id.is_empty() - || pending_chat_secret_forward_from_cv(cv).is_none() - { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - - let forward = cv - .clone() - .with_receiver(recipient_user_id.parse::().unwrap_or(0)); - if self.forward_chat_secret(&forward).await { - let _ = self - .send_message(&error_response(cv, CommunicationType::Success)) - .await; - } else { - self.store_pending_chat_secret_forward(cv).await; - let _ = self - .send_message(&error_response(cv, CommunicationType::Success)) - .await; - } - } - async fn handle_app_identification(self: Arc, cv: &CommunicationValue) { let sender_id = cv.get_sender(); let app_identifier = cv @@ -1113,7 +1267,7 @@ impl OmikronConnection { async fn handle_app_challenge_response(self: Arc, cv: &CommunicationValue) { let sender_id = cv.get_sender(); if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { - if let DataValue::Str(response) = cv.get_data(DataType::Challenge) { + if let Some(DataValue::Str(response)) = cv.get_data(DataType::Challenge) { if expected_challenge == *response { let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) .with_id(cv.get_id()) @@ -1448,301 +1602,6 @@ impl OmikronConnection { let _ = self.send_message(&live).await; } - async fn handle_message_send(self: Arc, cv: &CommunicationValue) { - let sender_id: u64 = cv.get_sender(); - - let receiver_id: i64 = if let Some(n) = cv.get_data(DataType::ReceiverId).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::ReceiverId).as_str() { - s.parse::().unwrap_or(0) - } else { - 0 - }; - - let timestamp_i64 = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { - s.parse::().unwrap_or_else(|_| now_millis_i64()) - } else { - now_millis_i64() - }; - let timestamp_u128 = timestamp_i64 as u128; - - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - - if let Some(reply_to) = reply_to { - match chat_files::get_message(sender_id as i64, reply_to, Some(receiver_id)) { - Ok(Some(_)) => {} - Ok(None) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorNotFound)) - .await; - return; - } - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - } - } - - let is_local = iota_storage::users::user_manager::get_user(receiver_id).is_some(); - - if is_local { - chat_files::add_message( - timestamp_u128, - false, - receiver_id as i64, - sender_id as i64, - &content, - height, - reply_to, - ); - } - - chat_files::add_message( - timestamp_u128, - true, - sender_id as i64, - receiver_id as i64, - &content, - height, - reply_to, - ); - - let conf_msg = CommunicationValue::new(CommunicationType::MessageSend) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64); - let _ = self.send_message(&conf_msg).await; - - if !is_local { - self.forward_to_remote_iota( - cv, - sender_id as i64, - receiver_id, - timestamp_i64, - &content, - height, - reply_to, - ) - .await; - } else { - match self - .forward_message_live( - cv.get_id(), - receiver_id as u64, - sender_id as i64, - timestamp_i64, - &content, - height, - reply_to, - ) - .await - { - Some(ms) => { - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id, - sender_id as i64, - ms.clone(), - ); - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id, - ms.clone(), - ); - if is_read_receipts_enabled().await { - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } - } - None => { - let _ = chat_files::change_message_state( - timestamp_i64, - receiver_id, - sender_id as i64, - MessageState::Sent, - ); - let _ = chat_files::change_message_state( - timestamp_i64, - sender_id as i64, - receiver_id, - MessageState::Sent, - ); - let push_msg = CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(receiver_id as u64) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(sender_id as i128), - ); - let _ = self.send_message(&push_msg).await; - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(sender_id as u64) - .with_sender(receiver_id as u64) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp_i64 as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - } - } - } - - async fn handle_message_other_iota(self: Arc, cv: &CommunicationValue) { - let sender_id = &cv.get_sender(); - let receiver_id = &cv.get_receiver(); - - let timestamp = if let Some(n) = cv.get_data(DataType::SendTime).as_number() { - n as i64 - } else if let Some(s) = cv.get_data(DataType::SendTime).as_str() { - s.parse::().unwrap_or_else(|_| now_millis_i64()) - } else { - now_millis_i64() - }; - - let content = cv - .get_data(DataType::Content) - .as_str() - .unwrap_or("") - .to_string(); - - let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64; - let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64); - - chat_files::add_message( - timestamp as u128, - false, - *receiver_id as i64, - *sender_id as i64, - &content, - height, - reply_to, - ); - - match self - .forward_message_live( - cv.get_id(), - *receiver_id, - *sender_id as i64, - timestamp, - &content, - height, - reply_to, - ) - .await - { - Some(ms) => { - let _ = change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - ms.clone(), - ); - - if is_read_receipts_enabled().await { - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*sender_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(ms.as_str().to_string()), - ), - ) - .await; - } - } - None => { - let _ = chat_files::change_message_state( - timestamp, - *receiver_id as i64, - *sender_id as i64, - MessageState::Sent, - ); - - let push_msg = CommunicationValue::new(CommunicationType::PushNotification) - .with_receiver(*receiver_id) - .add_typed_default( - DataType::SenderId, - DataValue::SignedNumber(*sender_id as i128), - ); - let _ = self.send_message(&push_msg).await; - - let _ = self - .send_message( - &CommunicationValue::new(CommunicationType::MessageState) - .with_id(cv.get_id()) - .with_receiver(*sender_id) - .with_sender(*receiver_id) - .add_typed_default( - DataType::SendTime, - DataValue::SignedNumber(timestamp as i128), - ) - .add_typed_default( - DataType::ChatPartnerId, - DataValue::SignedNumber(*receiver_id as i128), - ) - .add_typed_default( - DataType::MessageState, - DataValue::Str(MessageState::Sent.as_str().to_string()), - ), - ) - .await; - } - } - } - async fn handle_messages_get(self: Arc, cv: &CommunicationValue) { let _ = self .send_message(&message_handlers::handle_messages_get(cv)) @@ -2154,6 +2013,7 @@ impl OmikronClient for OmikronConnection { shutdown_tx: self.shutdown_tx.clone(), reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), + keyring: self.keyring.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), handler_semaphore: self.handler_semaphore.clone(), @@ -2177,6 +2037,7 @@ impl OmikronClient for OmikronConnection { shutdown_tx: self.shutdown_tx.clone(), reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), + keyring: self.keyring.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), handler_semaphore: self.handler_semaphore.clone(), diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 241fd46..3008120 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -5,6 +5,7 @@ use iota_storage::users::user_profile::UserProfile; use iota_storage::util::config_util::CONFIG; use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64}; use iota_util::file_util::write_user_credential; +use iota_util::mtp_compat::OptionalDataValueExt; use iota_util::tu::TuCredential; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; @@ -33,7 +34,9 @@ pub enum LifecycleUserError { } impl From for LifecycleUserError { - fn from(value: crate::OmikronError) -> Self { Self::Transport(value) } + fn from(value: crate::OmikronError) -> Self { + Self::Transport(value) + } } fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec { @@ -46,21 +49,31 @@ fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> V } fn configured_iota_id() -> Result { - CONFIG.load().iota_id + CONFIG + .load() + .iota_id .and_then(|id| i64::try_from(id).ok()) .filter(|id| *id > 0) - .ok_or_else(|| LifecycleUserError::InvalidCredential("Iota identity is not registered".into())) + .ok_or_else(|| { + LifecycleUserError::InvalidCredential("Iota identity is not registered".into()) + }) } -fn sign_lifecycle_payload(credential: &TuCredential, payload: &[u8]) -> Result<(Vec, Vec), LifecycleUserError> { +fn sign_lifecycle_payload( + credential: &TuCredential, + payload: &[u8], +) -> Result<(Vec, Vec), LifecycleUserError> { let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? .sign(payload) .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - let pq = MlDsaSigner::new(&credential.keyring.sig_pq_secret_key, &credential.keyring.sig_pq_public_key) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? - .sign(payload) - .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + let pq = MlDsaSigner::new( + &credential.keyring.sig_pq_secret_key, + &credential.keyring.sig_pq_public_key, + ) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))? + .sign(payload) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; Ok((classical, pq)) } @@ -71,15 +84,25 @@ async fn inspect_credential_account( if credential.omega_host != omega_discovery::omega_host() { return Err(LifecycleUserError::OmegaHostMismatch); } - let request = CommunicationValue::new(CommunicationType::GetUserData) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); - let response = connection.await_response(&request, Duration::from_secs(20)).await?; + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ); + let response = connection + .await_response(&request, Duration::from_secs(20)) + .await?; if !response.is_type(CommunicationType::GetUserData) { return Err(LifecycleUserError::RemoteRejected); } - let username = response.get_data(DataType::Username).as_str().map(str::to_owned) + let username = response + .get_data(DataType::Username) + .as_str() + .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; - let public_key = response.get_data(DataType::PublicKey).as_str().map(str::to_owned) + let public_key = response + .get_data(DataType::PublicKey) + .as_str() + .map(str::to_owned) .ok_or(LifecycleUserError::RemoteRejected)?; if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) { return Err(LifecycleUserError::RemoteRejected); @@ -96,54 +119,121 @@ async fn credential_proof( domain: &[u8], ) -> Result<(), LifecycleUserError> { let iota_id = configured_iota_id()?; - let begin_request = CommunicationValue::new(begin) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())); - let challenge_response = connection.await_response(&begin_request, Duration::from_secs(20)).await?; + let begin_request = CommunicationValue::new(begin).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ); + let challenge_response = connection + .await_response(&begin_request, Duration::from_secs(20)) + .await?; if !challenge_response.is_type(challenge) { return Err(LifecycleUserError::RemoteRejected); } - let nonce = challenge_response.get_data(DataType::ServerNonce).as_signed_number() + let nonce = challenge_response + .get_data(DataType::ServerNonce) + .as_signed_number() .and_then(|value| u64::try_from(value).ok()) .ok_or(LifecycleUserError::RemoteRejected)?; - let (signature, pq_signature) = sign_lifecycle_payload(credential, &lifecycle_payload(domain, credential.user_id, iota_id, nonce))?; + let (signature, pq_signature) = sign_lifecycle_payload( + credential, + &lifecycle_payload(domain, credential.user_id, iota_id, nonce), + )?; let complete_request = CommunicationValue::new(complete) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into())) + .add_typed_default( + DataType::UserId, + DataValue::SignedNumber(credential.user_id.into()), + ) .add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into())) .add_typed_default(DataType::Signature, DataValue::Bytes(signature)) .add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); - let response = connection.await_response(&complete_request, Duration::from_secs(20)).await?; - if response.is_type(CommunicationType::Success) { Ok(()) } else { Err(LifecycleUserError::RemoteRejected) } + let response = connection + .await_response(&complete_request, Duration::from_secs(20)) + .await?; + if response.is_type(CommunicationType::Success) { + Ok(()) + } else { + Err(LifecycleUserError::RemoteRejected) + } } /// Attach or migrate an existing account. Local state is written only after /// Omega has accepted the credential proof and changed its assignment. -pub async fn attach_user_from_tu(connection: &dyn OmikronClient, contents: &str) -> Result { - let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; +pub async fn attach_user_from_tu( + connection: &dyn OmikronClient, + contents: &str, +) -> Result { + let credential = TuCredential::parse(contents) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; let (username, public_key) = inspect_credential_account(connection, &credential).await?; - credential_proof(connection, &credential, CommunicationType::AttachUserBegin, CommunicationType::AttachUserChallenge, CommunicationType::AttachUserComplete, b"tensamin:user-attach:v1\0").await?; - let profile = UserProfile::new(credential.user_id, username, None, public_key, hex_hash(contents), String::new()); + credential_proof( + connection, + &credential, + CommunicationType::AttachUserBegin, + CommunicationType::AttachUserChallenge, + CommunicationType::AttachUserComplete, + b"tensamin:user-attach:v1\0", + ) + .await?; + let profile = UserProfile::new( + credential.user_id, + username, + None, + public_key, + hex_hash(contents), + String::new(), + ); write_user_credential(profile.user_id, &credential.to_canonical_string()) .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; - try_add_user(profile.clone()).map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; + try_add_user(profile.clone()) + .map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?; Ok(profile) } -pub async fn complete_delete_user_with_tu(connection: &dyn OmikronClient, contents: &str, expected_user_id: i64) -> Result<(), LifecycleUserError> { - let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; - if credential.user_id != expected_user_id { return Err(LifecycleUserError::InvalidCredential("credential user ID does not match deletion target".into())); } +pub async fn complete_delete_user_with_tu( + connection: &dyn OmikronClient, + contents: &str, + expected_user_id: i64, +) -> Result<(), LifecycleUserError> { + let credential = TuCredential::parse(contents) + .map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?; + if credential.user_id != expected_user_id { + return Err(LifecycleUserError::InvalidCredential( + "credential user ID does not match deletion target".into(), + )); + } inspect_credential_account(connection, &credential).await?; - credential_proof(connection, &credential, CommunicationType::DeleteUserCredentialBegin, CommunicationType::DeleteUserCredentialChallenge, CommunicationType::DeleteUserCredentialComplete, b"tensamin:user-delete:v1\0").await + credential_proof( + connection, + &credential, + CommunicationType::DeleteUserCredentialBegin, + CommunicationType::DeleteUserCredentialChallenge, + CommunicationType::DeleteUserCredentialComplete, + b"tensamin:user-delete:v1\0", + ) + .await } /// Repair local management state after a release or migration committed in /// Omega but local cleanup was interrupted. Hosted data is retained. pub async fn reconcile_managed_users(connection: &dyn OmikronClient) { - let Ok(local_iota_id) = configured_iota_id() else { return; }; + let Ok(local_iota_id) = configured_iota_id() else { + return; + }; for user in iota_storage::users::user_manager::get_users() { - let request = CommunicationValue::new(CommunicationType::GetUserData) - .add_typed_default(DataType::UserId, DataValue::SignedNumber(user.user_id.into())); - let Ok(response) = connection.await_response(&request, Duration::from_secs(10)).await else { continue; }; - let remote_iota_id = response.get_data(DataType::IotaId).as_signed_number().and_then(|value| i64::try_from(value).ok()); + let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( + DataType::UserId, + DataValue::SignedNumber(user.user_id.into()), + ); + let Ok(response) = connection + .await_response(&request, Duration::from_secs(10)) + .await + else { + continue; + }; + let remote_iota_id = response + .get_data(DataType::IotaId) + .as_signed_number() + .and_then(|value| i64::try_from(value).ok()); if remote_iota_id != Some(local_iota_id) { let _ = iota_storage::users::user_manager::release_user(user.user_id); } @@ -268,7 +358,12 @@ pub async fn create_user( log!("Created User"); write_user_credential( user_id, - &format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64), + &format!( + "{}@{}::{}", + user_id, + omega_discovery::omega_host(), + keyring_b64 + ), ) .map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?; @@ -282,6 +377,7 @@ mod tests { use super::{CreateUserError, request_user_id, valid_username}; use crate::{OmikronClient, OmikronError}; use async_trait::async_trait; + use iota_util::mtp_compat::CommunicationValueCompat; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::time::Duration; diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index be6b65a..ad49fba 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -5,7 +5,7 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["web-server"] } bytes = "1" http = "1" iota-logger = { path = "../iota-logger" }