diff --git a/.cargo/config.toml b/.cargo/config.toml index 46adaad..20aba5a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [env] MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true } + +[net] +git-fetch-with-cli = true diff --git a/Cargo.lock b/Cargo.lock index 71512c0..19f0d62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,11 +4,11 @@ version = 4 [[package]] name = "actix-codec" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e" +checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-sink", @@ -30,7 +30,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "brotli", "bytes", "bytestring", @@ -85,9 +85,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.13.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1" +checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" dependencies = [ "futures-core", "tokio", @@ -95,13 +95,12 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.9.1" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9" +checksum = "aec21d555b23ee78a1c0e4ed61667935de7d61664caba878226a081b1ac20a74" dependencies = [ "actix-rt", "actix-service", - "actix-utils", "futures-core", "futures-util", "mio", @@ -122,15 +121,15 @@ dependencies = [ [[package]] name = "actix-tls" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +checksum = "21d0a5de50893252dfa9d7c4bf83761bcc1920651916cd9da24c974cf8ee1b92" dependencies = [ "actix-rt", "actix-service", "actix-utils", "futures-core", - "impl-more 0.1.9", + "impl-more", "pin-project-lite", "rustls-pki-types", "tokio", @@ -174,7 +173,7 @@ dependencies = [ "foldhash", "futures-core", "futures-util", - "impl-more 0.3.7", + "impl-more", "itoa", "language-tags", "log", @@ -511,9 +510,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2" @@ -622,9 +621,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" dependencies = [ "find-msvc-tools", "jobserver", @@ -747,14 +746,21 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "client" version = "0.1.0" dependencies = [ + "async-trait", "dashmap", + "iota-auth", "iota-connection", + "iota-identity", "iota-logger", "iota-storage", "iota-util", "mtp", + "other-iota", + "rcgen", + "tempfile", "tokio", "uuid", + "web-server", ] [[package]] @@ -907,9 +913,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -932,10 +938,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix", @@ -1284,9 +1291,9 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "encoding_rs" -version = "0.8.40" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", "core_detect", @@ -1828,9 +1835,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "ctutils", "typenum", @@ -2032,12 +2039,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "impl-more" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" - [[package]] name = "impl-more" version = "0.3.7" @@ -2101,13 +2102,20 @@ dependencies = [ "serde_json", "serde_yaml", "tokio", + "tokio-util", ] [[package]] name = "iota-auth" version = "0.1.0" dependencies = [ - "json", + "async-trait", + "dashmap", + "iota-identity", + "mtp", + "rand_core 0.6.4", + "tokio", + "uuid", ] [[package]] @@ -2122,6 +2130,7 @@ dependencies = [ "iota-terms", "once_cell", "open", + "ratatool", "ratatui", "serde", "serde_yaml", @@ -2135,9 +2144,16 @@ dependencies = [ name = "iota-connection" version = "0.1.0" dependencies = [ + "async-trait", + "base64 0.22.1", + "iota-auth", + "iota-identity", + "iota-logger", "iota-storage", "iota-util", "mtp", + "serde", + "serde_json", "tokio", ] @@ -2162,7 +2178,10 @@ dependencies = [ name = "iota-daemon" version = "0.1.0" dependencies = [ + "client", + "iota-connection", "iota-daemon-lib", + "iota-identity", "iota-ipc", "iota-logger", "iota-paths", @@ -2170,6 +2189,7 @@ dependencies = [ "iota-terms", "iota-util", "omikron-connector", + "other-iota", "tokio", "web-server", ] @@ -2179,6 +2199,9 @@ name = "iota-daemon-lib" version = "0.1.0" dependencies = [ "async-trait", + "iota-auth", + "iota-connection", + "iota-identity", "iota-ipc", "iota-logger", "iota-state", @@ -2188,6 +2211,7 @@ dependencies = [ "libc", "mtp", "omikron-connector", + "other-iota", "serde_json", "serde_yaml", "sysinfo", @@ -2197,6 +2221,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "iota-identity" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "mtp", + "serde", + "serde_json", +] + [[package]] name = "iota-installer" version = "0.1.0" @@ -2261,17 +2296,22 @@ name = "iota-storage" version = "0.1.0" dependencies = [ "arc-swap", + "async-trait", "base64 0.22.1", + "iota-identity", "iota-logger", "iota-paths", "iota-util", "json", + "mtp", "once_cell", "r2d2", "rand 0.8.8", "rusqlite", "serde", + "serde_json", "serde_yaml", + "tempfile", "thiserror 2.0.20", "tokio", ] @@ -2311,6 +2351,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "hex", + "iota-identity", "iota-paths", "mtp", "reqwest", @@ -2535,7 +2576,7 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -2734,7 +2775,7 @@ dependencies = [ [[package]] name = "mtp" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "mtp-client", "mtp-codec", @@ -2751,7 +2792,7 @@ dependencies = [ [[package]] name = "mtp-client" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "mtp-codec", "mtp-common", @@ -2764,7 +2805,7 @@ dependencies = [ [[package]] name = "mtp-codec" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "base64 0.23.1", "byteorder", @@ -2778,7 +2819,7 @@ dependencies = [ [[package]] name = "mtp-common" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "rand 0.10.2", "thiserror 2.0.20", @@ -2787,7 +2828,7 @@ dependencies = [ [[package]] name = "mtp-core" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "async-trait", "mtp-codec", @@ -2799,7 +2840,7 @@ dependencies = [ [[package]] name = "mtp-crypto" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "argon2", "base64 0.22.1", @@ -2822,7 +2863,7 @@ dependencies = [ [[package]] name = "mtp-files" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "mtp-crypto", "rand 0.10.2", @@ -2833,7 +2874,7 @@ dependencies = [ [[package]] name = "mtp-h3" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "async-trait", "bytes", @@ -2850,7 +2891,7 @@ dependencies = [ [[package]] name = "mtp-host" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "mtp-codec", "mtp-common", @@ -2867,7 +2908,7 @@ dependencies = [ [[package]] name = "mtp-native" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "async-trait", "mtp-codec", @@ -2882,13 +2923,14 @@ dependencies = [ "tokio", "tracing", "wtransport", + "x509-parser", "zeroize", ] [[package]] name = "mtp-type-map" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "serde", "serde_yaml", @@ -2897,7 +2939,7 @@ dependencies = [ [[package]] name = "mtp-webserver" version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a135d0f0c2b35147011905f8ee0fc37050f69a6c#a135d0f0c2b35147011905f8ee0fc37050f69a6c" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=1f19a0d897c265d1e3f590a876f95e766ff99318#1f19a0d897c265d1e3f590a876f95e766ff99318" dependencies = [ "bytes", "h3", @@ -2925,24 +2967,23 @@ dependencies = [ [[package]] name = "multiversion" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" dependencies = [ "multiversion-macros", - "target-features", ] [[package]] name = "multiversion-macros" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "target-features", + "rustversion", + "syn 3.0.5", ] [[package]] @@ -2957,7 +2998,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3049,7 +3090,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3084,7 +3125,9 @@ dependencies = [ "async-trait", "base64 0.22.1", "dashmap", + "iota-auth", "iota-connection", + "iota-identity", "iota-logger", "iota-state", "iota-storage", @@ -3118,9 +3161,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.4.3" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "is-wsl", "libc", @@ -3144,6 +3187,20 @@ dependencies = [ [[package]] name = "other-iota" version = "0.1.0" +dependencies = [ + "async-trait", + "iota-connection", + "iota-identity", + "iota-storage", + "iota-util", + "mtp", + "rcgen", + "reqwest", + "serde", + "tempfile", + "tokio", + "web-server", +] [[package]] name = "palette" @@ -3678,6 +3735,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ratatool" +version = "0.1.0" +source = "git+https://git.methanium.net/methanium/ratatool.git#f94b8a25ffc5c196db2852ff576593df5ad96938" +dependencies = [ + "crossterm", + "futures-util", + "ratatui", + "tokio", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -3700,7 +3770,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "compact_str", "critical-section", "hashbrown 0.17.1", @@ -3765,7 +3835,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "hashbrown 0.17.1", "indoc", "instability", @@ -3800,7 +3870,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3840,11 +3910,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -3864,6 +3934,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -3906,7 +3978,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3945,7 +4017,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -4089,7 +4161,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4337,9 +4409,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" @@ -4507,7 +4579,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4522,12 +4594,6 @@ dependencies = [ "libc", ] -[[package]] -name = "target-features" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" - [[package]] name = "tempfile" version = "3.27.0" @@ -4547,7 +4613,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "parking_lot", "rustix", "signal-hook", @@ -4583,7 +4649,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -4799,7 +4865,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.5.0", @@ -4962,9 +5028,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.3", @@ -5086,10 +5152,14 @@ dependencies = [ name = "web-server" version = "0.1.0" dependencies = [ + "async-trait", "bytes", "http 1.5.0", + "iota-identity", "iota-logger", "mtp", + "serde", + "serde_json", "tokio", "tokio-util", ] @@ -5561,18 +5631,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 8caa07e..214e8a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "iota-identity", "iota-storage", "iota-connection", "client", diff --git a/Plan.md b/Plan.md new file mode 100644 index 0000000..0625a46 --- /dev/null +++ b/Plan.md @@ -0,0 +1,2077 @@ +# Iota Decentralization Implementation Guide + +## Scope + +The implementation should proceed in three stages: + +1. Separate Iota responsibilities so decentralized routing, foreign users, and Communities can use common identity, authentication, session, storage, and routing services. +2. Implement decentralized operation without requiring an Omega or Omikron. +3. Compose centralized and decentralized providers into hybrid mode. + +This guide does not include: + +* UI changes. +* Add Conversation UI changes. +* Browser bootstrap changes. +* Static webclient hosting on the Iota. +* Reimplementation of Communities themselves. + +The networking endpoint required for a client to connect directly to an Iota is in scope because decentralized mode requires it. Serving the webclient assets from that endpoint is not. + +The old `iota/communities` crate should be treated as a prototype, not as the implementation base. It is excluded from the workspace and its authentication and storage model do not fit the new architecture. + +Look at MTP when deciding protocol / connection details. + +--- + +# Stage 1: Separate the Iota architecture + +## Goal + +After Stage 1, the Iota should still operate in centralized mode, but core Iota logic should no longer depend directly on `OmikronConnection`. + +The architectural target is: + +```text + +--------------------+ + | Daemon / IPC | + +----------+---------+ + | + +-----------------+-----------------+ + | | + +-------v--------+ +-------v--------+ + | AccountService | | SessionManager | + +-------+--------+ +-------+--------+ + | | + +-------v--------+ +-------v--------+ + | IdentityService|<---------------->| AuthService | + +-------+--------+ +----------------+ + | + +---------+----------+ + | | ++-------v-------+ +-------v-------+ +| Local users | | Foreign users | +| and accounts | | and principals| ++---------------+ +---------------+ + + +-------------------+ + | RelayService | + +---------+---------+ + | + +---------v---------+ + | PeerRouter | + +---------+---------+ + | + +------------------+------------------+ + | | + +-------v---------+ +------v------+ + | Omikron adapter | | future peer | + | centralized | | transports | + +-----------------+ +-------------+ +``` + +`OmikronConnection` becomes one provider attached to these abstractions. It stops implementing identity resolution, account lifecycle, relay processing, routing, and client delivery itself. + +## 1. Introduce canonical identity types + +The current system treats numeric `user_id` values as globally meaningful because Omega allocates them. + +That assumption must stop at the domain layer. + +Create a small identity module or crate, preferably `iota-identity`, containing types with no network or database dependencies. + +Suggested concepts: + +```rust +pub struct IotaNodeId(/* hash/fingerprint of Iota public identity */); + +pub struct AuthorityId(/* stable cryptographic authority identity */); + +pub enum AuthorityKind { + Iota, + Omega, +} + +pub struct PrincipalId { + pub authority: AuthorityId, + pub user_id: u64, +} + +pub enum UserSelector { + UserId(u64), + Username(String), +} + +pub struct UserAddress { + pub selector: UserSelector, + pub public_key_pin: Option, + pub authority: Option, +} +``` + +A username is an alias used for lookup. It must not become the network identity. + +A public key supplied through an address is a verification pin. It must not replace the authority and user ID pair as the canonical identity because keys need to be rotatable. + +For example: + +```text +alice@example.org +``` + +might resolve to: + +```text +AuthorityId = Iota abc123... +UserId = 51 +Username = alice +Key = K1 +``` + +The canonical identity is: + +```text +abc123... / 51 +``` + +If `alice` later changes display name or rotates keys, existing chats and community memberships still reference the same principal. + +### Numeric IDs must become local identifiers + +Keep the existing local `UserProfile.user_id`. + +It remains useful as the local account identifier. + +Do not use it as a network-wide user identity after this boundary. + +Code should make the distinction visible: + +```rust +pub struct LocalUserId(pub i64); +pub struct PrincipalHandle(pub i64); +``` + +Using separate Rust types is preferable to aliases because accidentally passing a local user ID into a federated lookup should become a compile-time error where practical. + +## 2. Add a principal directory separate from hosted users + +Do not place users from another Iota or Omega into the existing `users` table. + +Current code uses: + +```rust +user_manager::get_user(id).is_some() +``` + +as a locality test in relay handling. + +If foreign users are inserted into that table, the Iota will incorrectly conclude that they are hosted locally. + +Create a separate principal directory. + +A useful model is: + +```text +principals +---------- +principal_pk +authority_kind +authority_id +remote_user_id +username +display_name +current_public_key +descriptor_revision +descriptor_valid_until +last_resolved_at + +UNIQUE(authority_id, remote_user_id) +``` + +Key history should be stored separately if rotation is supported: + +```text +principal_keys +-------------- +principal_pk +public_key +valid_from +valid_until +source_revision +``` + +Aliases can also be separate: + +```text +principal_aliases +----------------- +principal_pk +authority_locator +username +``` + +`principal_pk` is an Iota-local database handle. It exists so existing tables do not have to carry large cryptographic authority identifiers everywhere. + +Then expose distinct storage services: + +```rust +trait LocalUserStore { + fn get_local_user(...); + fn get_local_user_by_username(...); + fn is_hosted_here(...); +} + +trait PrincipalStore { + fn get_principal(...); + fn get_by_canonical_id(...); + fn upsert_remote_descriptor(...); + fn signing_keys(...); +} +``` + +A locally hosted account should also have a corresponding principal record. + +That produces one identity namespace for chats, relays, Communities, ACLs, and foreign connections without pretending every principal is hosted locally. + +## 3. Migrate references to remote users toward `PrincipalHandle` + +Several current tables use numeric remote user IDs directly. + +The main examples include: + +* `contacts.user_id` +* `messages.external_user` +* relay replay state +* relay inbox state +* queued relay identity +* blocked users +* receipt policy +* chat secrets and other per-peer state + +Do not attempt a complete database rewrite in one commit. + +Introduce repository APIs that accept `PrincipalHandle`, then migrate consumers behind those APIs. + +For centralized users already stored by numeric Omega user ID, Stage 1 migration can create principal records representing: + +```text +(current Omega authority, existing numeric user ID) +``` + +This preserves existing behavior while removing the assumption that the number alone identifies the user. + +`storage_owner` can remain a local user ID because only a locally hosted account owns local account storage. + +The important separation is: + +```text +storage_owner -> LocalUserId +external_user -> PrincipalHandle +``` + +## 4. Build `IdentityService` + +Move all user identity lookup behind one service. + +Suggested interface: + +```rust +#[async_trait] +pub trait IdentityResolver: Send + Sync { + async fn resolve_address( + &self, + address: &UserAddress, + context: &ResolutionContext, + ) -> Result; + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result; + + async fn signing_keys( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError>; +} +``` + +`ResolvedPrincipal` should contain at least: + +```rust +pub struct ResolvedPrincipal { + pub principal: PrincipalId, + pub handle: PrincipalHandle, + pub username: Option, + pub public_keys: Vec, + pub home: PrincipalHome, +} +``` + +During Stage 1, implement: + +```text +LocalIdentityResolver +OmegaIdentityResolver +``` + +`OmegaIdentityResolver` wraps the behavior currently embedded in: + +```text +OmikronConnection::resolve_relay_signing_keys +OmikronConnection::hosting_iota_for_user +GetUserData +``` + +The rest of the Iota must stop issuing `GetUserData` itself. + +## 5. Separate account lifecycle from Omega + +The daemon command router currently calls: + +```text +omikron_connector::user_ops::create_user +omikron_connector::user_ops::attach_user_from_tu +omikron_connector::user_ops::inspect_tu_credential +omikron_connector::user_ops::get_remote_user_assignment +``` + +directly. + +Replace that dependency with an account service. + +For example: + +```rust +#[async_trait] +pub trait AccountAuthority: Send + Sync { + async fn create_user(&self, request: CreateUserRequest) + -> Result; + + async fn inspect_credential(&self, credential: &[u8]) + -> Result; + + async fn attach_user(&self, credential: &[u8]) + -> Result; + + async fn reconcile_user(&self, user: LocalUserId) + -> Result; + + async fn release_user(&self, user: LocalUserId) + -> Result<(), AccountError>; +} +``` + +Stage 1 provides: + +```text +OmegaAccountAuthority +``` + +which internally calls the current `omikron_connector::user_ops`. + +Stage 2 will add: + +```text +LocalIotaAccountAuthority +``` + +The IPC command router should depend only on `AccountAuthority`. + +This removes a large amount of mode-specific logic from `iota-daemon-lib/src/command_router.rs`. + +## 6. Extract relay processing from `OmikronConnection` + +This is the most important Stage 1 refactor. + +`OmikronConnection::handle_relay()` currently performs several unrelated responsibilities: + +* relay metadata verification +* signer lookup +* local/remote user classification +* replay reservation +* policy validation +* content opening +* message application +* outgoing history updates +* relay queue insertion +* forwarding +* local client event generation +* success/error responses + +Move these into a transport-independent `RelayService`. + +Conceptually: + +```rust +pub struct RelayService { + identities: Arc, + local_users: Arc, + principals: Arc, + router: Arc, + sessions: Arc, + storage: Arc, + iota_identity: Arc, +} +``` + +Ingress becomes: + +```rust +relay_service + .accept_relay(IngressSource::Omikron(...), frame) + .await; +``` + +Later: + +```rust +relay_service + .accept_relay(IngressSource::Peer(...), frame) + .await; +``` + +and: + +```rust +relay_service + .accept_relay(IngressSource::HostedClient(...), frame) + .await; +``` + +The handler should not care how the relay reached the Iota. + +### Preserve the existing relay machinery + +Do not rewrite the following unless the identity migration requires it: + +* durable pending relay queue +* relay inbox +* replay lifecycle +* delivery acknowledgements +* sealed relay verification +* message application +* outgoing message history policy + +The current implementation already has the difficult storage and retry behavior. + +The purpose of Stage 1 is to move it behind a reusable service. + +## 7. Separate relay transport from relay semantics + +`ConnectionHandler` already describes itself as supporting Omikron, direct, and future modes, but its current return-position `impl Future` API is inconvenient for heterogeneous dynamically selected connections. + +Change it to an object-safe asynchronous interface. + +For example: + +```rust +#[async_trait] +pub trait ConnectionHandler: Send + Sync { + async fn send_message( + &self, + value: &CommunicationValue, + ) -> Result<(), ConnectionError>; + + async fn await_response( + &self, + value: &CommunicationValue, + timeout: Option, + ) -> Result; + + async fn is_connected(&self) -> bool; + + async fn is_identified(&self) -> bool; + + async fn stop(&self); +} +``` + +Then introduce the higher-level routing interface separately: + +```rust +#[async_trait] +pub trait PeerRouter: Send + Sync { + async fn send_to_iota( + &self, + destination: &IotaNodeId, + frame: CommunicationValue, + ) -> Result; +} +``` + +`ConnectionHandler` represents one connection. + +`PeerRouter` answers the question: + +```text +How do I reach this Iota? +``` + +Do not combine those two responsibilities. + +## 8. Replace mandatory Omikron in `DaemonServices` + +Current: + +```rust +pub struct DaemonServices { + pub omikron: Arc, + ... +} +``` + +Target: + +```rust +pub struct DaemonServices { + pub accounts: Arc, + pub identities: Arc, + pub principals: Arc, + pub local_users: Arc, + pub relay: Arc, + pub router: Arc, + pub sessions: Arc, + pub auth: Arc, + + pub centralized: Option>, +} +``` + +`OmikronClient` can remain available inside the centralized adapter for Omega-specific administration. + +Core handlers should not receive it. + +Daemon startup must also stop treating construction of an Omikron connection as a prerequisite for normal Iota construction. + +During Stage 1, centralized mode still creates it, but service composition should look like: + +```text +construct storage +construct identity services +construct auth +construct session manager +construct Omikron adapter +construct centralized peer router +construct relay service +construct account service +start daemon +``` + +instead of: + +```text +connect Omikron +build everything around Omikron +``` + +## 9. Build one session model for hosted and foreign clients + +Communities require users from other authorities to connect directly to an Iota without becoming hosted accounts on that Iota. + +Create a general session manager rather than another community-specific connection implementation. + +Suggested distinction: + +```rust +pub enum SessionIdentity { + Hosted { + local_user: LocalUserId, + principal: PrincipalHandle, + }, + Foreign { + principal: PrincipalHandle, + }, +} +``` + +A connection then has: + +```rust +pub struct AuthenticatedSession { + pub connection_id: Uuid, + pub identity: SessionIdentity, + pub capabilities: SessionCapabilities, +} +``` + +Capabilities should determine what the connection can do. + +A hosted client could receive capabilities such as: + +```text +AccountData +Messaging +Settings +LocalStorage +Communities +``` + +A foreign client connected to a Community Iota might receive only: + +```text +Community() +``` + +The foreign session must not gain access to: + +* local account state +* local user settings +* hosted user message history +* local user management +* other Communities +* arbitrary relay origination unless explicitly allowed + +This makes Communities an authorization problem rather than a parallel networking stack. + +## 10. Repurpose `iota-auth` + +`iota-auth` currently appears detached from the working architecture. + +Use it for reusable principal authentication. + +It should support: + +```text +HostedAccountAuthenticator +ForeignPrincipalAuthenticator +IotaPeerAuthenticator +``` + +### Foreign principal authentication + +A foreign user should be able to connect to the Iota hosting a Community without becoming a user hosted by that Iota. + +The flow should be: + +```text +client -> identify as PrincipalId or UserAddress +Iota -> load cached principal or resolve authority +Iota -> obtain trusted public key history +Iota -> issue challenge +client -> prove possession of matching private key +Iota -> create Foreign session +``` + +The existing MTP authentication facilities can be used where appropriate. + +Do not make Communities implement another hand-written WebSocket challenge protocol like the old excluded prototype. + +### Authentication without contacting the home authority every time + +The foreign user's home Iota or Omega should not have to be online for every Community connection. + +On initial resolution, store an authority-authenticated user descriptor and key material locally. + +Subsequent authentication can use the cached trusted key while that descriptor remains valid under its authority rules. + +If there is no trusted cached descriptor and the authority cannot be reached, authentication must fail closed. + +This gives Communities the required property: + +```text +Foreign user connects directly to Community Iota + | + +-> Community Iota verifies their existing identity + | + +-> user is not hosted on Community Iota +``` + +## 11. Prepare Communities around principals + +Do not implement Community behavior yet. + +Only establish interfaces Communities will later consume. + +Community membership should eventually reference: + +```text +PrincipalHandle +``` + +not: + +```text +UserProfile +LocalUserId +raw username +``` + +Community ACL logic should be able to ask: + +```rust +session.principal() +community_membership.has_permission(principal, permission) +``` + +A Community handler should not need to know: + +* which Omega the user belongs to +* which Iota hosts the user +* whether the current connection came through centralized or decentralized networking +* how the user's public key was resolved + +That information belongs below the Community layer. + +## 12. Stage 1 acceptance criteria + +Stage 1 is complete when all of the following hold: + +* [x] Existing centralized users continue to work. +* [x] Existing Omikron relay traffic enters `RelayService`. +* [x] `RelayService` does not depend on `OmikronConnection`. +* [x] User creation from the daemon goes through `AccountAuthority`. +* [x] Core identity lookup goes through `IdentityResolver`. +* [x] Remote users are represented independently from hosted users. +* [x] A foreign principal can be authenticated without being inserted into the hosted `users` table. +* [x] Session authorization distinguishes hosted and foreign clients. +* [x] Two users with the same numeric user ID under different authorities are different principals. +* [x] Daemon service construction no longer exposes Omikron as the primary general-purpose service. +* [x] The future Communities implementation can identify an authenticated remote participant using only a `PrincipalHandle`. + +At the end of Stage 1, behavior should still be centralized. The architecture should no longer be centralized. + +--- + +# Stage 2: Implement decentralized mode + +## Goal + +A deployment consisting of: + +```text +Client A +Iota A + +Client B +Iota B +``` + +must be able to create local accounts, authenticate clients, resolve remote users, and exchange messages without either Iota belonging to an Omega or connecting to an Omikron. + +If direct Iota-to-Iota connectivity is impossible, an independent relay/router may be used. + +That relay is infrastructure, not an Omega or Omikron. + +## 1. Give every Iota a standalone cryptographic identity + +The existing Omega-assigned `iota_id` cannot be the decentralized node identity. + +Create: + +```rust +pub struct IotaNodeId(...); +``` + +derived from the persistent Iota public identity. + +The exact representation should be stable across restarts and independent from IP address, hostname, Omega membership, or Omikron assignment. + +An Iota can then simultaneously have: + +```text +IotaNodeId +legacy Omega Iota ID +network endpoints +relay endpoints +``` + +Only `IotaNodeId` is fundamental. + +The Omega-assigned ID becomes centralized compatibility metadata. + +Rotating the node keyring changes `IotaNodeId`. The decentralized protocol must +treat this as creating a new Iota identity, not as rotating operational keys +under an existing node identity. + +Implementation status: + +* [x] Persist the Iota keyring through `LocalNodeIdentity`, outside the Omikron connector. +* [x] Derive a versioned `IotaNodeId` from the canonical public key bundle. +* [x] Derive the decentralized `AuthorityId` from `IotaNodeId`. +* [x] Reject arbitrary unversioned `IotaNodeId` strings. +* [x] Load `LocalNodeIdentity` during standalone daemon startup. + +## 2. Define decentralized user identity + +A locally created decentralized user becomes: + +```text +PrincipalId { + authority: this Iota's AuthorityId, + user_id: locally allocated ID +} +``` + +Two Iotas can both contain local user `42` without collision: + +```text +Iota A / 42 +Iota B / 42 +``` + +The user's public key is attached to the principal descriptor. + +It is not necessary for local numeric IDs themselves to be globally unique. + +### Finish application storage migration + +Before decentralized relays enter normal chat storage, contacts and messages +must use canonical principals as their authoritative external identity. + +Migrate contact uniqueness from: + +```text +(storage_owner, user_id) +``` + +to: + +```text +(storage_owner, principal_handle) +``` + +Migrate message, receipt, reaction, block, chat-secret, notification, and sync +lookups toward `PrincipalHandle` or `PrincipalId`. Keep remote numeric user IDs +only as legacy protocol metadata. + +The existing client may continue to use an Iota-local stable contact or chat ID. +The backend maps that local ID to `PrincipalHandle`; it must not expose a remote +authority's numeric user ID as the cross-authority identity. + +Implementation status: + +* [x] Store contacts uniquely by `PrincipalHandle`. +* [x] Treat `messages.external_principal` as authoritative. +* [x] Migrate the remaining persisted peer-identity lookups. +* [x] Verify that two authorities can each provide numeric user ID `7` to one local user. + +## 3. Implement the address parser + +Support a generic form: + +```text +[::][@] +``` + +Implementation status: + +* [x] Parse username and numeric selectors with optional key pins and authorities. +* [x] Normalize domain names, IP literals, ports, and bracketed IPv6 authorities. +* [x] Reject empty fields, malformed key pins, and extra authority delimiters. +* [x] Discover authority identity and service type through `/.well-known/tensamin`. + +Examples: + +```text +alice +51 + +alice@192.0.2.20 +51@iota.example.org + +alice::KEY@iota.example.org +51::KEY@omega.example.org + +alice@omega.example.org +``` + +The selector is: + +```text +username +or +numeric user ID +``` + +The optional public key is a pin. + +The optional authority selects the resolver. + +### No `@` + +If no `@` is supplied, use the account's default authority. + +For a decentralized account: + +```text +own Iota +``` + +For a centralized account: + +```text +own Omega +``` + +Hybrid behavior is handled in Stage 3. + +### Literal IP + +Treat a literal IP authority as an Iota endpoint. + +The connection must still authenticate the remote Iota key and obtain its `IotaNodeId`. + +### Domain + +A domain needs service discovery because: + +```text +alice@example.org +``` + +does not itself reveal whether `example.org` is an Iota or Omega. + +Define a small discovery document, for example: + +```text +/.well-known/tensamin +``` + +An Iota response should expose: + +```text +service type +protocol versions +IotaNodeId +public key +direct endpoints +relay hints +``` + +An Omega response should identify itself as an Omega and expose its public authority identity and supported lookup protocols. + +The authority type is discovered, not guessed from the username syntax. + +### Public-key pin + +For: + +```text +51::KEY@omega.example.org +``` + +resolution should: + +```text +query authority +resolve user 51 +obtain trusted key +compare against KEY +reject on mismatch +``` + +The supplied key must not silently override authority data. + +## 4. Define signed user descriptors + +Remote user resolution needs a transferable authenticated record. + +For example: + +```rust +pub struct UserDescriptor { + pub principal: PrincipalId, + pub username: String, + pub display_name: Option, + pub public_keys: Vec, + pub home_iota: IotaNodeId, + pub revision: u64, + pub valid_until: Option, + pub authority_signature: Signature, +} +``` + +For an Iota-native user, the hosting Iota signs the descriptor. + +For an Omega-native user, the Omega signs the descriptor and identifies the user's hosting Iota. + +The local Iota verifies the descriptor before inserting it into `PrincipalStore`. + +Keep the untrusted signed wire type separate from the verified descriptor stored +by `PrincipalStore`. Network code must not construct the trusted storage type +without signature, authority, revision, and validity checks. + +This descriptor becomes the basis for: + +* remote key lookup +* foreign Community authentication +* route lookup +* contact information +* key rotation +* cached offline authentication + +Key rotation will use a separate authority-signed key-history record. Principal +descriptors contain only keys valid for new signatures at descriptor issue +time. `PrincipalStore` closes prior key validity when it accepts a newer +descriptor, so old keys are not trusted indefinitely. Add the signed history +record before accepting signatures created before the current descriptor. + +## 5. Implement direct client-to-Iota sessions + +The current `client/src/client_connection.rs` already contains much of the client-side server logic, but it explicitly rejects: + +```text +Relay +AccountStateRequest +AccountStateApplied +MessageSend +``` + +where centralized infrastructure currently owns those paths. + +Stage 2 should make these call the common services introduced in Stage 1. + +A hosted client connection should become: + +```text +MTP transport + | + v +hosted-user authentication + | + v +SessionManager + | + +-> account state + +-> RelayService + +-> message handlers + +-> settings/storage +``` + +Implement a dedicated network gateway for this purpose. + +Do not make static webclient serving part of this work. + +The gateway can share MTP infrastructure with existing components, but its responsibility is only: + +```text +accept network connection +authenticate +create session +forward messages into Iota services +``` + +## 6. Implement local account creation + +Add: + +```text +LocalIotaAccountAuthority +``` + +User creation becomes: + +```text +validate username +allocate local user ID +generate user keyring +persist UserProfile +create local Principal record +persist credential +return account +``` + +No Omega request occurs. + +The account's authority is the local Iota. + +### Version `.tu` credentials + +Current `.tu` credentials contain Omega-specific assumptions. + +Introduce a versioned credential format capable of representing: + +```text +credential version +PrincipalId +authority descriptor +user keyring +``` + +Legacy Omega credentials must remain parseable. + +Do not reinterpret existing credentials as decentralized credentials. + +A credential needs to state which authority owns the account. + +## 7. Implement `other-iota` + +`iota/other-iota` currently provides the peer connection and router interfaces, +an in-memory `DirectPeerRouter`, and a `PeerRelayAdapter`. It does not yet create +network connections. + +Its responsibility should be Iota peer connectivity, not general message semantics. + +Suggested components: + +```text +PeerManager +PeerConnection +PeerAuthenticator +PeerDiscovery +PeerConnectionPool +``` + +The handshake should authenticate the remote Iota using its node key. + +A successful peer connection becomes keyed by: + +```text +IotaNodeId +``` + +not hostname or legacy numeric Iota ID. + +Before implementing the handshake, define relay provenance rules. A direct peer +that originates a user relay should authenticate as the signer's home Iota. +Infrastructure that forwards opaque frames needs a separate ingress role and +authorization policy. + +For example: + +```rust +pub enum PeerIngressSource { + DirectPeer { node_id: IotaNodeId }, + RelayRouter { router_id: RelayRouterId }, +} +``` + +A valid user signature proves authorship. It does not prove that the connected +Iota is authorized to originate that network hop. + +### Direct path + +The basic path becomes: + +```text +Iota A + | + | authenticated peer connection + v +Iota B +``` + +The receiving peer passes relay frames into: + +```text +RelayService +``` + +It does not contain another copy of relay handling logic. + +## 8. Replace numeric decentralized relay identities + +The current relay format uses bare numeric: + +```text +signer_id +final_recipient_id +Iota route ID +``` + +That is insufficient once multiple independent authorities exist. + +Introduce a federated relay version containing canonical principals. + +Conceptually: + +```rust +pub struct FederatedRelayIdentity { + pub signer: PrincipalId, + pub recipient: PrincipalId, +} +``` + +The route must identify an Iota using: + +```text +IotaNodeId +``` + +not the existing Omega-assigned numeric Iota ID. + +Do not truncate a key fingerprint to fit the existing numeric route field. + +Version the protocol instead. + +### Compatibility + +Keep legacy relay parsing for centralized traffic. + +Normalize both forms into an internal representation: + +```rust +pub struct VerifiedRelayEnvelope { + pub signer: ResolvedPrincipal, + pub recipient: ResolvedPrincipal, + pub message_id: RelayMessageId, + pub created_at: Timestamp, + pub content: VerifiedRelayContent, + pub compatibility: RelayCompatibilityData, +} +``` + +The common representation must not require `legacy_iota_id` or read canonical +identity from V1 fields such as `verified.context.signer_id` and +`verified.context.final_recipient_id`. `LegacyRelayDecoder` owns those fields and +places any required numeric data in optional compatibility metadata. + +The common service returns domain results rather than encoded V1 frames: + +```rust +pub struct RelayOutcome { + pub ingress_response: RelayResponse, + pub local_deliveries: Vec, +} +``` + +The ingress adapter encodes `ingress_response`. A local client-event sink or +adapter sends `local_deliveries`. A peer adapter must not call `into_frames()` and +send local client events back to the remote Iota. + +For a legacy relay: + +```text +numeric user ID + configured Omega authority +``` + +becomes a canonical `PrincipalId`. + +For a decentralized relay, the canonical identity is already encoded. + +From that point onward, storage and application logic should use the normalized representation. + +## 9. Migrate replay protection + +Current replay protection effectively keys on: + +```text +signer_id +message_id +``` + +This becomes unsafe with independent user namespaces. + +Change it to: + +```text +PrincipalId +message_id +``` + +or its stable local `PrincipalHandle`. + +The same applies to: + +* `relay_inbox` +* `RelayIdentity` +* pending relay ownership +* outgoing relay bookkeeping + +This migration is required before two authorities can safely contain the same numeric user ID. + +Implementation status: + +* [x] Key replay identity by signer principal and message ID. +* [x] Store principal-aware relay identity and decentralized queue destinations. +* [x] Complete the application-storage migration described under decentralized user identity. + +## 10. Implement decentralized routing + +`PeerRouter` should resolve a destination principal to its home Iota: + +```text +PrincipalId + | + v +IdentityService + | + v +UserDescriptor.home_iota + | + v +PeerRouter +``` + +Then the router selects a transport: + +```text +direct connection +or +external relay/router +``` + +The application layer should not know which was selected. + +Move durable retry execution out of `OmikronConnection` into a common +`PendingRelayDispatcher`. It consumes `RelayTarget` and uses `PeerRouter` for +both legacy Omega Iotas and decentralized `IotaNodeId` destinations. + +The `RelayTarget::Iota(_)` branch must deliver queued relays and preserve restart +recovery semantics before direct peer messaging is complete. + +Implementation status: + +* [x] Resolve `PrincipalHome` into centralized or decentralized route destinations. +* [x] Persist `IotaNodeId` and legacy Omega Iota destinations separately. +* [x] Route through the `PeerRouter` abstraction. +* [x] Dispatch pending decentralized relays through `PeerRouter`. + +## 11. Add an independent Iota relay/router + +Two Iotas behind networks without inbound connectivity cannot guarantee direct communication using only those two machines. + +Provide a separate relay service. + +Its responsibility should be intentionally narrow: + +```text +authenticate Iota nodes +associate IotaNodeId with active connection +accept opaque frame for destination IotaNodeId +forward frame +``` + +It should not: + +* allocate users +* resolve usernames +* host user data +* issue user identities +* decrypt sealed user relays +* become an Omega +* become an Omikron + +The topology becomes: + +```text +Iota A ---- outbound ----+ + | + v + Relay/router + | + +---- active connection ---- Iota B +``` + +Both Iotas can establish outbound connections to it. + +The existing origin Iota relay queue should remain responsible for durable retries. + +The router can remain primarily a live forwarding service. + +### Multiple routers + +An Iota descriptor can advertise multiple relay hints. + +A PeerRouter can attempt: + +```text +existing direct connection +known direct endpoint +configured/advised relay +``` + +without changing the message handler. + +Implementation status: + +* [x] Authenticate router and Iota connections with pinned public keys. +* [x] Register live Iota connections by `IotaNodeId`. +* [x] Forward opaque Relay V2 frames and return destination acknowledgements. +* [x] Mark destination ingress as `IngressSource::RelayRouter`. +* [x] Provide direct-first routing with relay fallback. +* [x] Add deployment configuration for explicit router endpoint, public-key, and TLS certificate pins, and publish configured endpoints as signed relay hints. + +## 12. Support Omega addresses without requiring a local Omega + +A decentralized Iota should be able to resolve: + +```text +alice@omega.example.org +``` + +without itself being registered with that Omega. + +Add an `OmegaAuthorityResolver`. + +Its responsibility is only to query the explicitly addressed Omega. + +For example: + +```text +local decentralized Iota + | + | public identity query + v +omega.example.org + | + v +signed UserDescriptor +``` + +The remote Omega must expose enough information to obtain: + +```text +remote user's canonical Omega principal +remote user's public keys +hosting Iota +hosting Iota decentralized identity/endpoints +``` + +This does not make Omega mandatory for decentralized mode. + +It means an address explicitly naming an Omega depends on that remote authority for initial resolution. + +If the descriptor is cached, future identity verification can use the cached material according to its validity rules. + +## 13. Fetch and cache remote public user data + +Remote user fetching should use `IdentityResolver`, not message handlers. + +A lookup can populate: + +```text +PrincipalId +username +display name +public key history +home Iota +descriptor revision +descriptor validity +``` + +Do not cache arbitrary private account data from another Iota. + +The distinction should remain: + +```text +PrincipalStore = public identity information +LocalUserStore = accounts hosted by this Iota +``` + +## 14. Foreign client connections + +Stage 2 should also complete the foreign-session path prepared for Communities. + +Example: + +```text +User Alice hosted by Iota A + +Alice's client + | + | connects directly + v +Iota B hosting a Community +``` + +Iota B performs: + +```text +parse Alice's principal +load or resolve Alice's trusted descriptor +issue authentication challenge +verify proof using Alice's public key +create Foreign session +``` + +Alice does not become a hosted user on Iota B. + +Iota A does not need to proxy the connection. + +If Iota B already has a valid cached descriptor, Iota A does not need to participate in that login. + +This provides the identity/session foundation Communities need before Communities themselves are implemented. + +## 15. Decentralized startup + +The daemon must start normally with: + +```text +no omikron_host +no omikron_port +no omikron_id +no Omega-issued iota_id +``` + +The mandatory identity becomes the local Iota keypair and derived `IotaNodeId`. + +Startup should construct: + +```text +LocalIotaAccountAuthority +LocalIdentityResolver +RemoteIotaResolver +OmegaAuthorityResolver +DirectPeerTransport +RelayPeerTransport +RelayService +SessionManager +``` + +The Omikron connector should not be constructed. + +## 16. Stage 2 acceptance tests + +The following integration tests should exist before calling decentralized mode complete. + +### Independent namespaces + +Create: + +```text +Iota A / user 1 +Iota B / user 1 +``` + +Verify that the users remain separate throughout: + +```text +contacts +relay verification +messages +blocking +replay protection +foreign sessions +``` + +### Direct messaging + +Run two Iotas without Omega or Omikron. + +Create one local user on each. + +Resolve the remote address. + +Exchange messages over direct Iota-to-Iota transport. + +Restart both Iotas and verify stored identity and relay state remain valid. + +### Relay messaging + +Place the Iotas behind conditions where the test only exposes outbound connectivity through the independent relay service. + +Verify the same relay reaches the destination. + +No application handler should change between direct and relayed delivery. + +### Address resolution + +Test: + +```text +username@iota-ip +userid@iota-domain +username@iota-domain +userid::public-key@iota-domain +username@omega-domain +userid::public-key@omega-domain +``` + +Test local inference without `@`. + +### Key mismatch + +Resolve a valid user while supplying the wrong public-key pin. + +The operation must fail. + +### Foreign authentication + +Cache a foreign user's signed descriptor. + +Disconnect the foreign user's authority. + +Authenticate the foreign client directly to another Iota. + +Verify the session is foreign and cannot access hosted-account functionality. + +### Queue recovery + +Disconnect the destination Iota. + +Send a relay. + +Restart the origin Iota. + +Reconnect the destination. + +Verify the durable relay queue delivers the original relay without generating a second application event. + +### No centralized dependencies + +Run the Iota with no reachable Omega or Omikron. + +Local account creation, local login, remote Iota resolution, peer routing, and messaging must remain functional. + +--- + +# Stage 3: Optional hybrid mode + +## Goal + +Hybrid mode should not become another set of handlers. + +A hybrid Iota should simply construct more providers. + +Application code should not need: + +```rust +if decentralized { + ... +} else if centralized { + ... +} else if hybrid { + ... +} +``` + +The mode should affect service composition and route selection, not business logic. + +## 1. Composite identity resolution + +Build: + +```text +CompositeIdentityResolver +``` + +It dispatches based on the address authority. + +Examples: + +```text +alice@iota.example + -> IotaAuthorityResolver + +alice@omega.example + -> OmegaAuthorityResolver + +alice + -> account's configured default authority +``` + +Do not blindly try Omega and then Iota for the same unresolved address. + +The namespace must remain deterministic. + +## 2. Store account origin + +Every hosted account should know who owns its identity. + +For example: + +```rust +pub enum AccountOrigin { + LocalIota { + authority: AuthorityId, + }, + Omega { + authority: AuthorityId, + }, +} +``` + +This controls account lifecycle operations. + +Creating or deleting a locally authoritative user calls: + +```text +LocalIotaAccountAuthority +``` + +An Omega-managed user calls: + +```text +OmegaAccountAuthority +``` + +Messaging code does not need to care. + +## 3. Composite routing + +Implement route providers: + +```text +DirectPeerRoute +RelayPeerRoute +OmikronRoute +``` + +The router should return a route plan for the destination. + +For a decentralized-capable remote Iota: + +```text +direct +then independent relay +``` + +For a legacy centralized destination: + +```text +Omikron +``` + +For an Omega user whose Iota advertises decentralized capability: + +```text +direct/relay peer transport +with Omikron available as compatibility fallback +``` + +The exact preference should live in route policy. + +No relay handler should contain transport preference logic. + +## 4. Normalize ingress before processing + +All ingress paths should produce the same internal object: + +```text +Omikron + | + +------+ + | +Peer -----+--> normalized verified relay --> RelayService + | +Client ---+ +``` + +The source transport is metadata used for policy and acknowledgements. + +It must not select a separate message implementation. + +## 5. Use one replay namespace + +A message received through a direct Iota connection and later through Omikron must be recognized as the same logical relay. + +Replay identity therefore has to use: + +```text +canonical PrincipalId +canonical message ID +``` + +not transport-specific IDs. + +This prevents hybrid mode from applying one message twice after route failover. + +## 6. Keep centralized administration isolated + +Functions that only make sense for Omega-managed accounts should remain inside the centralized adapter: + +```text +Omega invitations +Omikron registration +Iota registration with Omega +Omega assignment reconciliation +Omega-specific credential operations +``` + +They should not leak back into: + +```text +RelayService +IdentityService +SessionManager +Communities +message handlers +``` + +## 7. Hybrid acceptance criteria + +Hybrid mode is complete when: + +* One Iota can host both local-Iota-authoritative and Omega-authoritative accounts. +* Both account types use the same local message/session handlers. +* A remote principal resolves to the same canonical identity regardless of transport. +* Direct and Omikron delivery cannot apply the same relay twice. +* Omikron disconnect does not affect decentralized users. +* Direct peer failure does not affect centralized users. +* Communities only see authenticated principals and are independent of the route used to authenticate or contact the user's home network. + +--- + +# Recommended repository boundaries + +The current workspace already contains many crates, so avoid splitting every trait into a new crate. + +A practical distribution is: + +```text +iota-identity + PrincipalId + AuthorityId + UserAddress + UserDescriptor + resolver traits + principal normalization + +iota-auth + hosted authentication + foreign principal authentication + peer authentication + challenge/proof logic + +iota-storage + LocalUserStore + PrincipalStore + relay persistence + account data + +iota-connection + wire-level relay parsing + relay verification primitives + normalized connection errors + transport connection traits + +iota-routing + RelayService + PeerRouter + route planning + relay ingress normalization + +client + authenticated client connection + session binding + client message dispatch + +other-iota + PeerManager + PeerConnection + peer discovery + direct peer transport + external relay transport + +omikron-connector + Omikron transport + Omega resolver adapter + Omega account authority + centralized administration + +iota-daemon-lib + service composition + IPC + lifecycle + +communities + future implementation consuming: + PrincipalId + SessionManager + AuthService + PrincipalStore + PeerRouter +``` + +The key rule is that `omikron-connector` should depend inward on common abstractions where necessary. Core identity, relay, session, and Communities code should not depend outward on `omikron-connector`. + +--- + +# Suggested implementation sequence + +### Stage 1A: Identity foundation + +Implement: + +```text +PrincipalId +AuthorityId +UserAddress +PrincipalHandle +PrincipalStore +LocalUserStore wrapper +``` + +Add tests for identical numeric IDs under different authorities. + +Do not change network behavior. + +### Stage 1B: Service extraction + +Implement: + +```text +IdentityResolver +AccountAuthority +PeerRouter +SessionManager +``` + +Wrap existing centralized behavior. + +Change daemon and command-router consumers to use the new interfaces. + +### Stage 1C: Relay extraction + +Move `handle_relay` logic from `OmikronConnection` into `RelayService`. + +Keep Omikron as the only transport initially. + +Make the centralized integration tests pass through the new service. + +### Stage 1D: Foreign sessions + +Implement foreign principal caching and authentication. + +Add session capabilities. + +Prove that a foreign principal can authenticate without entering `users`. + +This completes the architectural work required by future Communities. + +The Stage 2 sections describe components. Implement the remaining work in this +dependency order. + +### Stage 2A: Principal-native application storage + +Make contact uniqueness and message-side identity use `PrincipalHandle`. +Migrate receipts, reactions, blocks, chat secrets, notifications, and sync keys +that still identify a remote user by a bare numeric ID. + +### Stage 2B: Protocol-neutral relay domain + +Replace `VerifiedNormalizedRelay` with a representation that contains canonical +resolved principals and optional V1 compatibility data. Split ingress responses +from local client deliveries. + +### Stage 2C: Federated relay protocol + +Add canonical signer and recipient principals plus `IotaNodeId` routing to MTP. +Keep V1 decoding and encoding in transport adapters. + +### Stage 2D: Signed descriptors + +Define signed node and user descriptor wire types, verification, revision rules, +and cache validity. + +### Stage 2E: Authority discovery + +Implement raw-IP Iota bootstrap and `/.well-known/tensamin` authority discovery. +Raw-IP bootstrap pins both the authenticated `IotaNodeId` and the observed TLS +certificate. Later direct MTP connections load that certificate pin instead of +using system roots or disabling certificate verification. + +### Stage 2F: Local accounts and credentials + +Implement `LocalIotaAccountAuthority` and versioned `.tu` credentials. Preserve +the V1 Omega credential parser. + +Iota-native `.tu` V2 credentials authenticate accounts already hosted by the +same Iota authority. They do not reconstruct a deleted account. `attach_user()` +must find the matching local account and descriptor state, and it must reject a +credential bound to another `IotaNodeId`. Account backup and deleted-account +restoration require a separate authority-side backup format. + +### Stage 2G: Common pending-relay dispatch + +Move retry execution out of `OmikronConnection`. Dispatch legacy and +decentralized queue targets through `PeerRouter`, including restart recovery for +`RelayTarget::Iota(_)`. + +### Stage 2H: Direct Iota peers + +Define peer ingress provenance, then implement mutual node authentication, +listener, dialer, connection pool, duplicate-connection policy, liveness, +reconnection, and `DirectPeerRouter` registration. + +### Stage 2I: Federated relay path + +Connect two Iotas and exchange one federated relay through `RelayService`. Verify +that the peer receives only the ingress response and the hosted client receives +the local delivery. + +### Stage 2J: Direct client gateway + +Implement the MTP client gateway and hosted-client relay origination. + +### Stage 2K: Standalone daemon composition + +Load `LocalNodeIdentity` during daemon startup and compose decentralized +providers without constructing `OmikronConnection`. + +### Stage 2L: Independent relay/router + +Implement the independent opaque relay/router after direct peer delivery works. + +### Stage 2M: Remote Omega resolution + +Add direct remote-Omega resolution without requiring a local Omikron connection. + +### Stage 2N: Integration suite + +Run the Stage 2 acceptance tests. Include two remote authorities that both use +the same numeric user ID, durable queue recovery, key-pin mismatch, cached +foreign authentication, direct delivery, and relay-only delivery. + +### Stage 3: Hybrid composition + +Add: + +```text +CompositeIdentityResolver +CompositeAccountAuthority +HybridPeerRouter +route policy +``` + +Do not introduce hybrid-specific message handlers. + +--- + +# Architecture required before Communities + +Communities should not be started until the following interfaces are stable: + +```text +PrincipalId +PrincipalStore +IdentityResolver +AuthService +AuthenticatedSession +SessionCapabilities +IotaNodeId +PeerRouter +``` + +Once those are available, the Community architecture becomes straightforward: + +```text +foreign/local client + | + v +AuthService + | + v +AuthenticatedSession + | + v +Community authorization + | + v +Community handlers +``` + +A Community only needs to answer: + +```text +Who is this principal? +Are they a member? +What are they allowed to do? +``` + +It should not need to answer: + +```text +Which Omega owns them? +Which Iota hosts them? +How do I fetch their key? +Did they arrive through Omikron? +Are they a local user? +How do I establish a peer connection? +``` + +Those questions belong to the infrastructure built in Stages 1 and 2. + +The resulting separation also gives decentralized and hybrid mode the same property: identity and application semantics remain stable while the transport and authority providers can change underneath them. diff --git a/client/Cargo.toml b/client/Cargo.toml index f125a07..3995595 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,11 +4,20 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = ["client", "crypto"] } +async-trait = "0.1.89" +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["client", "crypto", "pipes", "web-server"] } +iota-auth = { path = "../iota-auth" } +iota-identity = { path = "../iota-identity" } iota-connection = { path = "../iota-connection" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-storage = { path = "../iota-storage" } +other-iota = { path = "../other-iota" } +web-server = { path = "../web-server" } dashmap = "6.1.0" tokio = { version = "1.50.0", features = ["full"] } uuid = { version = "*", features = ["v4"] } + +[dev-dependencies] +rcgen = "0.14" +tempfile = "3" diff --git a/client/src/client_connection_manager.rs b/client/src/client_connection_manager.rs index e69de29..8df4740 100644 --- a/client/src/client_connection_manager.rs +++ b/client/src/client_connection_manager.rs @@ -0,0 +1,316 @@ +use async_trait::async_trait; +use dashmap::DashMap; +use iota_auth::{ + AuthenticatedSession, HostedSessionRegistrar, SessionCapability, SessionIdentity, + SessionManager, +}; +use iota_connection::message_common::CommunicationResponseExt; +use iota_connection::message_handlers; +use iota_connection::relay_service::{IngressSource, RelayService}; +use iota_identity::{ + LocalUserId, LocalUserStore, PrincipalHandle, PrincipalStore, PublicKeyBundle, +}; +use iota_util::crypto_helper::public_key_bundle_from_base64; +use iota_util::mtp_compat::OptionalDataValueExt; +use mtp::codec::{CommunicationType, CommunicationValue}; +use mtp::codec::{DataType, DataValue}; +use other_iota::LocalDeliverySink; +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +pub struct HostedAndPeerKeyResolver { + local_users: Arc, +} + +impl HostedAndPeerKeyResolver { + pub fn new(local_users: Arc) -> Self { + Self { local_users } + } +} + +#[async_trait] +impl web_server::ClientPublicKeyResolver for HostedAndPeerKeyResolver { + async fn public_key(&self, client_id: u64) -> Option { + if client_id & (1_u64 << 63) != 0 { + return iota_storage::node_directory::SqliteNodeDirectory + .key_for_authentication_hint(client_id) + .ok() + .flatten(); + } + let local_user = i64::try_from(client_id).ok().map(LocalUserId)?; + self.local_users + .get_local_user(local_user) + .ok() + .flatten() + .and_then(|user| public_key_bundle_from_base64(&user.public_key)) + } +} + +pub struct ClientConnectionManager { + local_users: Arc, + principals: Arc, + registrar: Arc, + sessions: Arc, + relay: Arc, + active: DashMap>, +} + +impl ClientConnectionManager { + pub fn new( + local_users: Arc, + principals: Arc, + registrar: Arc, + sessions: Arc, + relay: Arc, + ) -> Self { + Self { + local_users, + principals, + registrar, + sessions, + relay, + active: DashMap::new(), + } + } + + pub fn is_connected(&self, principal: PrincipalHandle) -> bool { + self.active.contains_key(&principal) + } + + async fn handle( + &self, + session: &AuthenticatedSession, + frame: CommunicationValue, + ) -> Vec { + if let Some(relay) = client_relay_frame(&frame) { + let request = frame.clone(); + return match self + .relay + .accept_relay( + IngressSource::HostedClient { + session: session.clone(), + }, + relay, + ) + .await + { + Ok(outcome) => { + let (response, deliveries) = outcome.into_parts(); + for delivery in deliveries { + self.deliver(delivery.recipient, delivery.frame).await; + } + response.into_iter().collect() + } + Err(_) => vec![ + CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_request_id(&request), + ], + }; + } + let request = frame.clone(); + let Some(frame) = authorized_hosted_frame(session, frame) else { + return vec![ + CommunicationValue::new(CommunicationType::ErrorNotAuthenticated) + .with_request_id(&request), + ]; + }; + if frame.is_type(CommunicationType::AccountStateRequest) { + return vec![message_handlers::handle_account_state_request(&frame)]; + } + if frame.is_type(CommunicationType::AccountStateApplied) { + return vec![message_handlers::handle_account_state_applied(&frame)]; + } + if frame.is_type(CommunicationType::MessagesGet) { + return vec![message_handlers::handle_messages_get(&frame)]; + } + if frame.is_type(CommunicationType::MessageGet) { + return vec![message_handlers::handle_message_get(&frame)]; + } + vec![CommunicationValue::new(CommunicationType::ErrorInvalidData).with_request_id(&frame)] + } +} + +fn authorized_hosted_frame( + session: &AuthenticatedSession, + frame: CommunicationValue, +) -> Option { + let SessionIdentity::Hosted { local_user, .. } = &session.identity else { + return None; + }; + let capability = if frame.is_type(CommunicationType::AccountStateRequest) + || frame.is_type(CommunicationType::AccountStateApplied) + { + SessionCapability::AccountData + } else if frame.is_type(CommunicationType::MessagesGet) + || frame.is_type(CommunicationType::MessageGet) + { + SessionCapability::Messaging + } else { + return None; + }; + if !session.allows(&capability) { + return None; + } + let sender = u64::try_from(local_user.0).ok()?; + Some(frame.with_sender(sender)) +} + +fn client_relay_frame(frame: &CommunicationValue) -> Option { + if frame.is_type(CommunicationType::Relay) { + return Some(frame.clone()); + } + if !frame.is_type(CommunicationType::MessageSend) + || frame.get_data(DataType::VersionNumber).as_number() != Some(2) + { + return None; + } + let frame_id = frame.id()?; + let payload = match frame.get_data(DataType::SecurePayload)? { + DataValue::Bytes(payload) => payload.clone(), + _ => return None, + }; + Some( + CommunicationValue::new(CommunicationType::Relay) + .with_id(frame_id) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2)) + .add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload)), + ) +} + +#[async_trait] +impl other_iota::LocalDeliverySink for ClientConnectionManager { + async fn deliver(&self, recipient: PrincipalHandle, frame: CommunicationValue) { + let senders = self + .active + .get(&recipient) + .map(|connections| connections.values().cloned().collect::>()) + .unwrap_or_default(); + for sender in senders { + let _ = sender.send(&frame).await; + } + } +} + +#[async_trait] +impl web_server::MtpConnectionHandler for ClientConnectionManager { + async fn accept(&self, connection: mtp::webserver::WebMTPConnection) { + if connection.auth_state != mtp::host::AuthState::Authenticated + || connection.client_id & (1_u64 << 63) != 0 + { + return; + } + let Ok(local_user_id) = i64::try_from(connection.client_id) else { + return; + }; + let local_user = LocalUserId(local_user_id); + if !self.local_users.is_hosted_here(local_user).unwrap_or(false) { + return; + } + let Ok(Some(principal)) = self.local_users.principal_for_local_user(local_user) else { + return; + }; + if self + .principals + .get_principal(principal) + .ok() + .flatten() + .is_none() + { + return; + } + let connection_id = Uuid::new_v4(); + let session = self + .registrar + .authenticate(connection_id, local_user, principal); + self.active + .entry(principal) + .or_default() + .insert(connection_id, connection.sender.clone()); + while let Ok(frame) = connection.receive().await { + for response in self.handle(&session, frame).await { + if connection.sender.send(&response).await.is_err() { + break; + } + } + } + if let dashmap::mapref::entry::Entry::Occupied(mut connections) = + self.active.entry(principal) + { + connections.get_mut().remove(&connection_id); + if connections.get().is_empty() { + connections.remove(); + } + } + self.sessions.remove(connection_id); + } +} + +pub struct ConnectionGateway { + clients: Arc, + peers: Arc, +} + +impl ConnectionGateway { + pub fn new(clients: Arc, peers: Arc) -> Self { + Self { clients, peers } + } +} + +#[async_trait] +impl web_server::MtpConnectionHandler for ConnectionGateway { + async fn accept(&self, connection: mtp::webserver::WebMTPConnection) { + if connection.client_id & (1_u64 << 63) != 0 { + self.peers.accept(connection).await; + } else { + self.clients.accept(connection).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iota_auth::SessionCapabilities; + + #[test] + fn signed_message_send_enters_relay_v2_path() { + let message = CommunicationValue::new(CommunicationType::MessageSend) + .with_id(7) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2)) + .add_typed_default(DataType::SecurePayload, DataValue::Bytes(vec![1, 2, 3])); + + let relay = client_relay_frame(&message).unwrap(); + assert!(relay.is_type(CommunicationType::Relay)); + assert_eq!(relay.id(), Some(7)); + assert_eq!( + relay.get_data(DataType::SecurePayload), + Some(&DataValue::Bytes(vec![1, 2, 3])) + ); + } + + #[test] + fn unsigned_message_send_does_not_enter_relay_path() { + assert!( + client_relay_frame(&CommunicationValue::new(CommunicationType::MessageSend)).is_none() + ); + } + + #[test] + fn hosted_session_replaces_untrusted_request_sender() { + let session = AuthenticatedSession { + connection_id: Uuid::new_v4(), + identity: SessionIdentity::Hosted { + local_user: LocalUserId(7), + principal: PrincipalHandle(9), + }, + capabilities: SessionCapabilities::hosted(), + }; + let frame = CommunicationValue::new(CommunicationType::AccountStateRequest).with_sender(99); + + assert_eq!( + authorized_hosted_frame(&session, frame).unwrap().sender(), + Some(7) + ); + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index f3e6b6c..0519254 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,2 +1,6 @@ mod client_connection; +mod client_connection_manager; pub use client_connection::ClientConnection; +pub use client_connection_manager::{ + ClientConnectionManager, ConnectionGateway, HostedAndPeerKeyResolver, +}; diff --git a/client/tests/direct_gateway.rs b/client/tests/direct_gateway.rs new file mode 100644 index 0000000..3ff31cd --- /dev/null +++ b/client/tests/direct_gateway.rs @@ -0,0 +1,296 @@ +use async_trait::async_trait; +use client::ClientConnectionManager; +use iota_auth::{HostedSessionRegistrar, SessionManager}; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::federated_relay::FederatedRelayV2; +use iota_connection::relay_service::{RelayNodeIdentity, RelayService}; +use iota_identity::{ + AuthorityKind, IdentityError, IdentityResolver, IotaNodeId, LocalNodeIdentity, + LocalUserDescriptor, LocalUserId, LocalUserStore, PrincipalDescriptor, PrincipalHome, + PrincipalId, PrincipalStore, PublicKeyBundle, ResolutionContext, ResolvedPrincipal, + SignedPrincipalDescriptor, UserAddress, +}; +use iota_storage::identity::{SqliteLocalUserStore, SqlitePrincipalStore}; +use iota_storage::util::e2ee_storage; +use iota_util::mtp_compat::OptionalDataValueExt; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::Keyring; +use mtp::host::{AuthenticationPolicy, HostConfig}; +use mtp::webserver::{MTPWebServer, WebServerConfig}; +use other_iota::LocalDeliverySink; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::{Arc, Mutex}; + +struct StoreResolver; + +#[async_trait] +impl IdentityResolver for StoreResolver { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + SqlitePrincipalStore + .get_by_canonical_id(principal)? + .ok_or(IdentityError::NotFound) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + SqlitePrincipalStore.signing_keys(principal) + } +} + +struct TestNodeIdentity(LocalNodeIdentity); + +#[async_trait] +impl RelayNodeIdentity for TestNodeIdentity { + async fn keyring(&self) -> Option> { + Some(self.0.keyring()) + } + + fn node_id(&self) -> Option { + Some(self.0.node_id().clone()) + } +} + +#[derive(Default)] +struct AcceptingRouter(Mutex>); + +#[async_trait] +impl PeerRouter for AcceptingRouter { + async fn route( + &self, + _: &RouteDestination, + frame: CommunicationValue, + ) -> Result { + self.0.lock().unwrap().push(frame); + Ok(RouteOutcome::Accepted { + relay_message_id: "client-relay".into(), + destination_accepted_at: 20, + }) + } +} + +fn certificate() -> (Vec, Vec) { + let key_pair = rcgen::KeyPair::generate().unwrap(); + let params = + rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]).unwrap(); + let certificate = params.self_signed(&key_pair).unwrap(); + ( + certificate.pem().into_bytes(), + key_pair.serialize_pem().into_bytes(), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn hosted_client_loads_state_originates_relay_and_receives_delivery() { + let storage = tempfile::tempdir().unwrap(); + iota_util::file_util::configure_storage_directory(storage.path().to_owned()); + iota_storage::util::db::initialize_database().unwrap(); + + let local_node = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let remote_node = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let local_key = Keyring::generate(); + let remote_key = Keyring::generate(); + let local_user = LocalUserDescriptor { + id: LocalUserId(1), + username: "alice".into(), + display_name: None, + public_key: local_key.public_key_bundle().try_to_base64().unwrap(), + }; + iota_storage::util::db::with_db(|connection| { + connection.execute( + "INSERT INTO users (user_id, username, public_key, created_at) VALUES (1, 'alice', ?1, 1)", + [&local_user.public_key], + )?; + Ok(()) + }) + .unwrap(); + let now = iota_storage::util::sync::now_millis(); + let local_handle = SqlitePrincipalStore + .ensure_local_principal( + local_node.authority_id(), + AuthorityKind::Iota, + &local_user, + PrincipalHome::Iota(local_node.node_id().clone()), + now, + ) + .unwrap(); + let remote_descriptor = SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: PrincipalId { + authority: remote_node.authority_id().clone(), + user_id: 1, + }, + authority_kind: AuthorityKind::Iota, + username: Some("bob".into()), + display_name: None, + public_keys: vec![remote_key.public_key_bundle()], + home: PrincipalHome::Iota(remote_node.node_id().clone()), + revision: 1, + valid_until: Some(now + 60_000), + issued_at: now, + }, + &remote_node.keyring(), + ) + .unwrap() + .verify( + remote_node.authority_id(), + &remote_node.public_keys(), + None, + now, + ) + .unwrap(); + let remote_handle = SqlitePrincipalStore + .upsert_remote_descriptor(&remote_descriptor) + .unwrap(); + let chat_id = e2ee_storage::principal_chat_id(local_handle, remote_handle).unwrap(); + e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { + user_id: "1".into(), + secret_id: e2ee_storage::principal_secret_id(&chat_id), + chat_id, + version: 1, + encrypted_secret: vec![1], + kem_ciphertext: vec![2], + wrapping_scheme: "test".into(), + created_at: 1, + updated_at: 1, + }) + .unwrap(); + + let router = Arc::new(AcceptingRouter::default()); + let local_users: Arc = Arc::new(SqliteLocalUserStore); + let relay = Arc::new( + RelayService::new(local_users.clone(), router.clone(), None).with_federation( + Arc::new(StoreResolver), + Arc::new(TestNodeIdentity(local_node.clone())), + ), + ); + let sessions = Arc::new(SessionManager::default()); + let manager = Arc::new(ClientConnectionManager::new( + local_users, + Arc::new(SqlitePrincipalStore), + Arc::new(HostedSessionRegistrar::new(sessions.clone())), + sessions, + relay, + )); + + let (certificate, private_key) = certificate(); + let client_public_key = local_key.public_key_bundle(); + let host_keyring = Keyring::from_bytes(&local_node.keyring().try_to_bytes().unwrap()).unwrap(); + let host = HostConfig::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 0, + certificate.clone(), + private_key, + ) + .with_authentication( + host_keyring, + Box::new(move |client_id, _| { + let key = (client_id == 1).then(|| client_public_key.clone()); + Box::pin(async move { key }) + }), + Box::new(|_, _| Box::pin(async { 0 })), + ) + .with_authentication_policy(AuthenticationPolicy::ForceAuthentication); + let mut server = MTPWebServer::new(host, WebServerConfig::new()) + .await + .unwrap(); + let address = server.local_addr(); + let server_manager = manager.clone(); + let server_task = tokio::spawn(async move { + if let Some(connection) = server.accept().await.unwrap() { + web_server::MtpConnectionHandler::accept(server_manager.as_ref(), connection).await; + } + }); + let client = mtp::client::MTPClient::auth_connect( + mtp::client::ClientConfig::new(format!("https://{address}/")) + .with_client_id(1) + .with_pinned_pem(certificate), + &local_key, + &local_node.public_keys(), + ) + .await + .unwrap(); + + let snapshot = client + .request( + &CommunicationValue::new(CommunicationType::AccountStateRequest) + .with_id(3) + .with_sender(99), + None, + ) + .await + .unwrap(); + assert!(snapshot.is_type(CommunicationType::AccountStateSnapshot)); + assert_eq!(snapshot.receiver(), Some(1)); + + let content = CommunicationValue::new(CommunicationType::MessageSend) + .add_typed_default(DataType::Content, DataValue::Str("ciphertext".into())) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(10)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)); + let relay = FederatedRelayV2::sign( + PrincipalId { + authority: local_node.authority_id().clone(), + user_id: 1, + }, + remote_descriptor.descriptor().principal.clone(), + remote_node.node_id().clone(), + "client-relay".into(), + 10, + content, + &local_key, + ) + .unwrap() + .into_frame(4) + .unwrap(); + let payload = match relay.get_data(DataType::SecurePayload).unwrap() { + DataValue::Bytes(payload) => payload.clone(), + _ => panic!("Relay V2 payload has wrong type"), + }; + let response = client + .request( + &CommunicationValue::new(CommunicationType::MessageSend) + .with_id(4) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2)) + .add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload)), + None, + ) + .await + .unwrap(); + assert_eq!( + response.get_comm_type_enum(), + Some(CommunicationType::Success), + "unexpected relay response: {response:?}" + ); + assert_eq!( + response.get_data(DataType::RelayMessageId).as_str(), + Some("client-relay") + ); + assert_eq!(router.0.lock().unwrap().len(), 1); + + LocalDeliverySink::deliver( + manager.as_ref(), + local_handle, + CommunicationValue::new(CommunicationType::MessageLive).with_id(8), + ) + .await; + let delivery = tokio::time::timeout(std::time::Duration::from_secs(5), client.receive()) + .await + .unwrap() + .unwrap(); + assert!(delivery.is_type(CommunicationType::MessageLive)); + + server_task.abort(); +} diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index f35935a..719be00 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -4,4 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] -json = "*" +async-trait = "0.1.89" +dashmap = "6.2.1" +iota-identity = { path = "../iota-identity" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto"] } +rand_core = { version = "0.6", features = ["getrandom", "std"] } +uuid = { version = "*", features = ["v4"] } + +[dev-dependencies] +tokio = { version = "1.50.0", features = ["macros", "rt"] } diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs index 8b13789..bf6e334 100644 --- a/iota-auth/src/lib.rs +++ b/iota-auth/src/lib.rs @@ -1 +1,11 @@ +mod principal_auth; +mod session; +pub use principal_auth::{ + AuthChallenge, AuthError, ForeignPrincipalAuthenticator, HostedSessionRegistrar, + IotaPeerAuthenticator, +}; +pub use session::{ + AuthenticatedSession, CommunityId, SessionCapabilities, SessionCapability, SessionIdentity, + SessionManager, +}; diff --git a/iota-auth/src/principal_auth.rs b/iota-auth/src/principal_auth.rs new file mode 100644 index 0000000..dd87295 --- /dev/null +++ b/iota-auth/src/principal_auth.rs @@ -0,0 +1,465 @@ +use dashmap::DashMap; +use iota_identity::{ + IdentityError, IdentityResolver, IotaNodeId, LocalUserId, NodeIdentityResolver, + PrincipalHandle, PrincipalId, +}; +use mtp::crypto::{PublicKeyBundle, SigAlgorithm, verify_ed25519, verify_ml_dsa}; +use rand_core::{OsRng, RngCore}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +use crate::{AuthenticatedSession, SessionCapabilities, SessionIdentity, SessionManager}; + +const CHALLENGE_LIFETIME: Duration = Duration::from_secs(60); +const OFFLINE_IDENTITY_TRUST: Duration = Duration::from_secs(24 * 60 * 60); +const CHALLENGE_DOMAIN: &[u8] = b"tensamin.foreign-principal-auth.v1\0"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthChallenge { + pub id: Uuid, + pub signed_payload: Vec, + pub expires_at: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AuthError { + Identity(IdentityError), + ChallengeNotFound, + ChallengeExpired, + ChallengeConnectionMismatch, + InvalidProof, +} + +impl From for AuthError { + fn from(error: IdentityError) -> Self { + Self::Identity(error) + } +} + +struct PendingChallenge { + principal: PrincipalHandle, + keys: Vec, + connection_id: Uuid, + signed_payload: Vec, + expires_at: u64, +} + +pub struct ForeignPrincipalAuthenticator { + identities: Arc, + sessions: Arc, + pending: DashMap, +} + +impl ForeignPrincipalAuthenticator { + pub fn new(identities: Arc, sessions: Arc) -> Self { + Self { + identities, + sessions, + pending: DashMap::new(), + } + } + + pub async fn issue_challenge( + &self, + connection_id: Uuid, + server: &IotaNodeId, + principal: PrincipalId, + ) -> Result { + let resolution_context = iota_identity::ResolutionContext { + allow_network: true, + offline_policy: iota_identity::OfflineResolutionPolicy::AllowUnexpired { + max_staleness: OFFLINE_IDENTITY_TRUST, + }, + }; + let resolved = self + .identities + .resolve_principal_with_context(&principal, &resolution_context) + .await?; + if resolved.public_keys.is_empty() { + return Err(AuthError::Identity(IdentityError::InvalidDescriptor( + "principal has no current signing keys".into(), + ))); + } + let now = now_seconds(); + self.pending + .retain(|_, challenge| challenge.expires_at > now); + let mut nonce = vec![0_u8; 32]; + OsRng.fill_bytes(&mut nonce); + let id = Uuid::new_v4(); + let expires_at = now.saturating_add(CHALLENGE_LIFETIME.as_secs()); + let signed_payload = + challenge_payload(id, connection_id, server, &principal, &nonce, expires_at); + let challenge = AuthChallenge { + id, + signed_payload: signed_payload.clone(), + expires_at, + }; + self.pending.insert( + challenge.id, + PendingChallenge { + principal: resolved.handle, + keys: resolved.public_keys, + connection_id, + signed_payload, + expires_at, + }, + ); + Ok(challenge) + } + + pub async fn authenticate( + &self, + connection_id: Uuid, + challenge_id: Uuid, + signature: &[u8], + ) -> Result { + let (_, pending) = self + .pending + .remove(&challenge_id) + .ok_or(AuthError::ChallengeNotFound)?; + if pending.connection_id != connection_id { + return Err(AuthError::ChallengeConnectionMismatch); + } + if pending.expires_at <= now_seconds() { + return Err(AuthError::ChallengeExpired); + } + if !pending + .keys + .iter() + .any(|key| verify_dual(key, &pending.signed_payload, signature)) + { + return Err(AuthError::InvalidProof); + } + let session = AuthenticatedSession { + connection_id, + identity: SessionIdentity::Foreign { + principal: pending.principal, + }, + capabilities: SessionCapabilities::foreign_authenticated(), + }; + self.sessions.insert(session.clone()); + Ok(session) + } +} + +pub struct HostedSessionRegistrar { + sessions: Arc, +} + +impl HostedSessionRegistrar { + pub fn new(sessions: Arc) -> Self { + Self { sessions } + } + + pub fn authenticate( + &self, + connection_id: Uuid, + local_user: LocalUserId, + principal: PrincipalHandle, + ) -> AuthenticatedSession { + let session = AuthenticatedSession { + connection_id, + identity: SessionIdentity::Hosted { + local_user, + principal, + }, + capabilities: SessionCapabilities::hosted(), + }; + self.sessions.insert(session.clone()); + session + } +} + +pub struct IotaPeerAuthenticator { + identities: Arc, +} + +impl IotaPeerAuthenticator { + pub fn new(identities: Arc) -> Self { + Self { identities } + } + + pub async fn trusted_keys( + &self, + identity: &IotaNodeId, + ) -> Result, AuthError> { + self.identities + .resolve_node(identity) + .await + .map(|resolved| resolved.public_keys) + .map_err(Into::into) + } +} + +fn now_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn challenge_payload( + challenge_id: Uuid, + connection_id: Uuid, + server: &IotaNodeId, + principal: &PrincipalId, + nonce: &[u8], + expires_at: u64, +) -> Vec { + let mut payload = Vec::with_capacity( + CHALLENGE_DOMAIN.len() + + 16 + + 16 + + server.as_str().len() + + principal.authority.as_str().len() + + nonce.len() + + 32, + ); + payload.extend_from_slice(CHALLENGE_DOMAIN); + payload.extend_from_slice(challenge_id.as_bytes()); + payload.extend_from_slice(connection_id.as_bytes()); + append_field(&mut payload, server.as_str().as_bytes()); + append_field(&mut payload, principal.authority.as_str().as_bytes()); + payload.extend_from_slice(&principal.user_id.to_be_bytes()); + append_field(&mut payload, nonce); + payload.extend_from_slice(&expires_at.to_be_bytes()); + payload +} + +fn append_field(target: &mut Vec, field: &[u8]) { + target.extend_from_slice(&(field.len() as u64).to_be_bytes()); + target.extend_from_slice(field); +} + +fn verify_dual(key: &PublicKeyBundle, message: &[u8], signature: &[u8]) -> bool { + let Some(classical_length) = SigAlgorithm::length(SigAlgorithm::ED25519) else { + return false; + }; + let Some(post_quantum_length) = SigAlgorithm::length(SigAlgorithm::ML_DSA_65) else { + return false; + }; + if signature.len() != classical_length + post_quantum_length { + return false; + } + verify_ed25519( + &key.sig_cl_public_key, + message, + &signature[..classical_length], + ) + .and_then(|_| { + verify_ml_dsa( + &key.sig_pq_public_key, + message, + &signature[classical_length..], + ) + }) + .is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use iota_identity::LocalNodeIdentity; + use iota_identity::{ + AuthorityId, IdentityResolver, PrincipalHome, ResolutionContext, ResolvedPrincipal, + UserAddress, + }; + use mtp::crypto::{DualSigner, Keyring, SignatureScheme}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CachedIdentity { + principal: PrincipalId, + key: PublicKeyBundle, + } + + struct OneShotIdentity { + resolved: ResolvedPrincipal, + calls: AtomicUsize, + } + + #[async_trait] + impl IdentityResolver for OneShotIdentity { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + _: &PrincipalId, + ) -> Result { + Err(IdentityError::Unavailable("authority is offline".into())) + } + + async fn resolve_principal_with_context( + &self, + _: &PrincipalId, + _: &ResolutionContext, + ) -> Result { + if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(self.resolved.clone()) + } else { + Err(IdentityError::Unavailable("authority is offline".into())) + } + } + + async fn signing_keys( + &self, + _: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + Err(IdentityError::Unavailable("authority is offline".into())) + } + } + + #[async_trait] + impl IdentityResolver for CachedIdentity { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + self.resolve_principal(&self.principal).await + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + if principal != &self.principal { + return Err(IdentityError::NotFound); + } + Ok(ResolvedPrincipal { + principal: principal.clone(), + handle: PrincipalHandle(44), + username: Some("foreign-user".into()), + public_keys: vec![self.key.clone()], + home: PrincipalHome::Unknown, + descriptor_revision: 1, + valid_until: None, + resolved_at: i64::MAX, + }) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + self.resolve_principal(principal) + .await + .map(|resolved| resolved.public_keys) + } + } + + #[tokio::test] + async fn cached_foreign_principal_authenticates_without_a_hosted_account() { + let keyring = Keyring::generate(); + let principal = PrincipalId { + authority: AuthorityId::new("omega:remote").unwrap(), + user_id: 17, + }; + let identities = Arc::new(CachedIdentity { + principal: principal.clone(), + key: keyring.public_key_bundle(), + }); + let sessions = Arc::new(SessionManager::default()); + let authenticator = ForeignPrincipalAuthenticator::new(identities, sessions); + let connection_id = Uuid::new_v4(); + let server = LocalNodeIdentity::from_keyring(Keyring::generate()) + .unwrap() + .node_id() + .clone(); + let challenge = authenticator + .issue_challenge(connection_id, &server, principal.clone()) + .await + .unwrap(); + let signer = DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .unwrap(); + let signature = signer.sign(&challenge.signed_payload).unwrap(); + let session = authenticator + .authenticate(connection_id, challenge.id, &signature) + .await + .unwrap(); + + assert_eq!( + session.identity, + SessionIdentity::Foreign { + principal: PrincipalHandle(44) + } + ); + assert!(!session.allows(&crate::SessionCapability::LocalStorage)); + assert!( + !session.allows(&crate::SessionCapability::Community(crate::CommunityId( + "remote-community".into() + ))) + ); + + let other_challenge = authenticator + .issue_challenge(connection_id, &server, principal) + .await + .unwrap(); + assert_eq!( + authenticator + .authenticate(Uuid::new_v4(), other_challenge.id, &[]) + .await, + Err(AuthError::ChallengeConnectionMismatch) + ); + } + + #[tokio::test] + async fn challenge_uses_the_key_set_resolved_when_it_was_issued() { + let keyring = Keyring::generate(); + let principal = PrincipalId { + authority: AuthorityId::new("omega:remote").unwrap(), + user_id: 18, + }; + let identities = Arc::new(OneShotIdentity { + resolved: ResolvedPrincipal { + principal: principal.clone(), + handle: PrincipalHandle(45), + username: None, + public_keys: vec![keyring.public_key_bundle()], + home: PrincipalHome::Unknown, + descriptor_revision: 1, + valid_until: None, + resolved_at: i64::MAX, + }, + calls: AtomicUsize::new(0), + }); + let sessions = Arc::new(SessionManager::default()); + let authenticator = ForeignPrincipalAuthenticator::new(identities.clone(), sessions); + let connection_id = Uuid::new_v4(); + let server = LocalNodeIdentity::from_keyring(Keyring::generate()) + .unwrap() + .node_id() + .clone(); + let challenge = authenticator + .issue_challenge(connection_id, &server, principal) + .await + .unwrap(); + let signer = DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .unwrap(); + let signature = signer.sign(&challenge.signed_payload).unwrap(); + + authenticator + .authenticate(connection_id, challenge.id, &signature) + .await + .unwrap(); + + assert_eq!(identities.calls.load(Ordering::SeqCst), 1); + } +} diff --git a/iota-auth/src/session.rs b/iota-auth/src/session.rs new file mode 100644 index 0000000..97e0dbb --- /dev/null +++ b/iota-auth/src/session.rs @@ -0,0 +1,189 @@ +use dashmap::DashMap; +use iota_identity::{LocalUserId, PrincipalHandle}; +use std::collections::HashSet; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CommunityId(pub String); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SessionCapability { + AccountData, + Messaging, + Settings, + LocalStorage, + Communities, + Community(CommunityId), + RelayOrigination, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SessionCapabilities(HashSet); + +impl SessionCapabilities { + pub fn hosted() -> Self { + Self(HashSet::from([ + SessionCapability::AccountData, + SessionCapability::Messaging, + SessionCapability::Settings, + SessionCapability::LocalStorage, + SessionCapability::Communities, + SessionCapability::RelayOrigination, + ])) + } + + pub fn for_community(community: CommunityId) -> Self { + Self(HashSet::from([SessionCapability::Community(community)])) + } + + pub fn foreign_authenticated() -> Self { + Self::default() + } + + pub fn grant(&mut self, capability: SessionCapability) { + self.0.insert(capability); + } + + pub fn allows(&self, capability: &SessionCapability) -> bool { + self.0.contains(capability) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionIdentity { + Hosted { + local_user: LocalUserId, + principal: PrincipalHandle, + }, + Foreign { + principal: PrincipalHandle, + }, +} + +impl SessionIdentity { + pub fn principal(&self) -> PrincipalHandle { + match self { + Self::Hosted { principal, .. } | Self::Foreign { principal } => *principal, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthenticatedSession { + pub connection_id: Uuid, + pub identity: SessionIdentity, + pub capabilities: SessionCapabilities, +} + +impl AuthenticatedSession { + pub fn principal(&self) -> PrincipalHandle { + self.identity.principal() + } + + pub fn allows(&self, capability: &SessionCapability) -> bool { + self.capabilities.allows(capability) + } +} + +#[derive(Default)] +pub struct SessionManager { + sessions: DashMap, +} + +impl SessionManager { + pub fn insert(&self, session: AuthenticatedSession) { + self.sessions.insert(session.connection_id, session); + } + + pub fn get(&self, connection_id: Uuid) -> Option { + self.sessions + .get(&connection_id) + .map(|session| session.clone()) + } + + pub fn remove(&self, connection_id: Uuid) -> Option { + self.sessions + .remove(&connection_id) + .map(|(_, session)| session) + } + + pub fn grant_community( + &self, + connection_id: Uuid, + community: CommunityId, + ) -> Result { + let mut session = self + .sessions + .get_mut(&connection_id) + .ok_or(SessionAuthorizationError::UnknownSession)?; + session + .capabilities + .grant(SessionCapability::Community(community)); + Ok(session.clone()) + } + + pub fn authorize( + &self, + connection_id: Uuid, + capability: &SessionCapability, + ) -> Result { + let session = self + .get(connection_id) + .ok_or(SessionAuthorizationError::UnknownSession)?; + if !session.allows(capability) { + return Err(SessionAuthorizationError::CapabilityDenied); + } + Ok(session) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionAuthorizationError { + UnknownSession, + CapabilityDenied, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn foreign_community_session_cannot_access_hosted_data() { + let manager = SessionManager::default(); + let connection_id = Uuid::new_v4(); + manager.insert(AuthenticatedSession { + connection_id, + identity: SessionIdentity::Foreign { + principal: PrincipalHandle(12), + }, + capabilities: SessionCapabilities::for_community(CommunityId("community-a".into())), + }); + + assert!( + manager + .authorize(connection_id, &SessionCapability::AccountData) + .is_err() + ); + assert!( + manager + .authorize( + connection_id, + &SessionCapability::Community(CommunityId("community-a".into())) + ) + .is_ok() + ); + } + + #[test] + fn hosted_session_exposes_its_principal() { + let session = AuthenticatedSession { + connection_id: Uuid::new_v4(), + identity: SessionIdentity::Hosted { + local_user: LocalUserId(4), + principal: PrincipalHandle(9), + }, + capabilities: SessionCapabilities::hosted(), + }; + assert_eq!(session.principal(), PrincipalHandle(9)); + } +} diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index 87b3954..23365db 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -15,10 +15,11 @@ iota-paths = { path = "../iota-paths" } chrono = "0.4.43" -crossterm = "*" +crossterm = "0.29.0" once_cell = "1.21.3" open = "5.3.3" -ratatui = "0.30.0" +ratatool = { git = "https://git.methanium.net/methanium/ratatool.git", features = ["tokio"] } +ratatui = "0.30.2" serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-cli/src/controls/action.rs b/iota-cli/src/controls/action.rs deleted file mode 100644 index 4fb3c27..0000000 --- a/iota-cli/src/controls/action.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ControlAction { - FocusNext, - FocusPrevious, - Select, - Activate, -} diff --git a/iota-cli/src/controls/checkbox_group.rs b/iota-cli/src/controls/checkbox_group.rs deleted file mode 100644 index f985510..0000000 --- a/iota-cli/src/controls/checkbox_group.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; -use std::{collections::HashSet, hash::Hash}; - -pub struct CheckboxItem { - pub value: T, - pub label: String, - pub description: Option, - pub enabled: bool, - pub disabled_reason: Option, -} -pub struct CheckboxGroup { - items: Vec>, - selected: HashSet, - focused_index: usize, - focus_policy: DisabledFocusPolicy, - wrap_navigation: bool, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CheckboxGroupError { - Empty, - DuplicateValue, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CheckboxChange { - Selected(T), - Deselected(T), - IgnoredDisabled(T), - NoItem, -} -impl CheckboxGroup { - pub fn new( - items: Vec>, - selected: impl IntoIterator, - ) -> Result { - let mut values = HashSet::new(); - if items.iter().any(|item| !values.insert(item.value.clone())) { - return Err(CheckboxGroupError::DuplicateValue); - } - let selected = selected - .into_iter() - .filter(|value| values.contains(value)) - .collect(); - let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0); - Ok(Self { - items, - selected, - focused_index, - focus_policy: DisabledFocusPolicy::Skip, - wrap_navigation: true, - }) - } - pub fn items(&self) -> &[CheckboxItem] { - &self.items - } - pub fn selected(&self) -> &HashSet { - &self.selected - } - pub fn focused_item(&self) -> Option<&CheckboxItem> { - self.items.get(self.focused_index) - } - pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) { - self.focus_policy = policy; - } - pub fn set_wrap_navigation(&mut self, wrap: bool) { - self.wrap_navigation = wrap; - } - pub fn focus_next(&mut self) { - self.move_focus(true); - } - pub fn focus_previous(&mut self) { - self.move_focus(false); - } - fn move_focus(&mut self, forward: bool) { - if self.items.is_empty() { - return; - } - for step in 1..=self.items.len() { - let current = self.focused_index as isize; - let delta = if forward { - step as isize - } else { - -(step as isize) - }; - let raw = current + delta; - let next = if self.wrap_navigation { - raw.rem_euclid(self.items.len() as isize) as usize - } else if raw < 0 || raw >= self.items.len() as isize { - return; - } else { - raw as usize - }; - if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled { - self.focused_index = next; - return; - } - } - } - pub fn toggle_focused(&mut self) -> CheckboxChange { - let Some(item) = self.items.get(self.focused_index) else { - return CheckboxChange::NoItem; - }; - let value = item.value.clone(); - if !item.enabled { - return CheckboxChange::IgnoredDisabled(value); - } - if self.selected.remove(&value) { - CheckboxChange::Deselected(value) - } else { - self.selected.insert(value.clone()); - CheckboxChange::Selected(value) - } - } - pub fn set_enabled(&mut self, value: &T, enabled: bool) { - if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) { - item.enabled = enabled; - } - } - pub fn set_selected(&mut self, value: T, selected: bool) { - if selected { - self.selected.insert(value); - } else { - self.selected.remove(&value); - } - } - pub fn visual_state(&self, value: &T) -> ChoiceVisualState { - let item = self.items.iter().position(|item| &item.value == value); - ChoiceVisualState { - selected: self.selected.contains(value), - focused: item == Some(self.focused_index), - enabled: item - .and_then(|index| self.items.get(index)) - .is_some_and(|item| item.enabled), - } - } -} diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs deleted file mode 100644 index 3756641..0000000 --- a/iota-cli/src/controls/dialog.rs +++ /dev/null @@ -1,267 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, KeyHint, Screen, UiEvent}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DialogButton { - Cancel, - Confirm, - Custom(usize), -} - -pub struct ConfirmDialog { - title: String, - message: Vec, - buttons: Vec, - focused_button: usize, - on_confirm: Option InteractionResult + Send + Sync>>, - on_cancel: Option InteractionResult + Send + Sync>>, -} - -struct DialogButtonConfig { - label: String, - intent: ButtonIntent, - enabled: bool, -} - -impl ConfirmDialog { - pub fn new(title: impl Into, message: impl Into) -> Self { - Self { - title: title.into(), - message: vec![message.into()], - buttons: vec![ - DialogButtonConfig { - label: "Cancel".to_owned(), - intent: ButtonIntent::Cancel, - enabled: true, - }, - DialogButtonConfig { - label: "Confirm".to_owned(), - intent: ButtonIntent::Primary, - enabled: true, - }, - ], - focused_button: 0, - on_confirm: None, - on_cancel: None, - } - } - - pub fn destructive(title: impl Into, message: impl Into) -> Self { - Self { - title: title.into(), - message: vec![message.into()], - buttons: vec![ - DialogButtonConfig { - label: "Cancel".to_owned(), - intent: ButtonIntent::Cancel, - enabled: true, - }, - DialogButtonConfig { - label: "Delete".to_owned(), - intent: ButtonIntent::Destructive, - enabled: true, - }, - ], - focused_button: 0, - on_confirm: None, - on_cancel: None, - } - } - - pub fn with_message_line(mut self, line: impl Into) -> Self { - self.message.push(line.into()); - self - } - - pub fn with_button(mut self, label: impl Into, intent: ButtonIntent) -> Self { - self.buttons.push(DialogButtonConfig { - label: label.into(), - intent, - enabled: true, - }); - self - } - - pub fn with_confirm_action InteractionResult + Send + Sync + 'static>( - mut self, - action: F, - ) -> Self { - self.on_confirm = Some(Box::new(action)); - self - } - - pub fn with_cancel_action InteractionResult + Send + Sync + 'static>( - mut self, - action: F, - ) -> Self { - self.on_cancel = Some(Box::new(action)); - self - } - - fn activate(&self) -> InteractionResult { - match self.focused_button { - 0 => { - if let Some(action) = &self.on_cancel { - action() - } else { - InteractionResult::CloseScreen - } - } - 1 => { - if let Some(action) = &self.on_confirm { - action() - } else { - InteractionResult::CloseScreen - } - } - _ => InteractionResult::CloseScreen, - } - } - - fn next_button(&mut self) { - self.focused_button = (self.focused_button + 1) % self.buttons.len(); - } - - fn prev_button(&mut self) { - if self.focused_button == 0 { - self.focused_button = self.buttons.len() - 1; - } else { - self.focused_button -= 1; - } - } -} - -impl Screen for ConfirmDialog { - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn std::any::Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let area = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 50, - height: (self.message.len() + 8) as u16, - }, - ); - - f.render_widget(Clear, area); - let block = Block::default() - .title(format!(" {} ", self.title)) - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - - let inner = block.inner(area); - f.render_widget(block, area); - - let rows = Layout::vertical([ - Constraint::Min(self.message.len() as u16), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - - let lines: Vec = self - .message - .iter() - .map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal))) - .collect(); - f.render_widget(Paragraph::new(lines), rows[0]); - - let buttons_area = rows[2]; - let button_widths: Vec = self - .buttons - .iter() - .map(|b| crate::controls::button::button_minimum_width(&b.label)) - .collect(); - - let total_width: u16 = button_widths.iter().sum(); - let spacing = self.buttons.len().saturating_sub(1) as u16; - let available = buttons_area.width; - 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() { - let button_area = Rect { - x, - y: buttons_area.y, - width, - height: 1, - }; - x = x.saturating_add(width + 1); - - render_button( - f, - button_area, - ActionButton { - label: &button_config.label, - intent: button_config.intent, - focused: self.focused_button == i, - enabled: button_config.enabled, - }, - context.theme, - ); - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Esc => InteractionResult::CloseScreen, - KeyCode::Tab => { - self.next_button(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_button(); - InteractionResult::Handled - } - KeyCode::Left => { - self.prev_button(); - InteractionResult::Handled - } - KeyCode::Right => { - self.next_button(); - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Tab", - action: "Switch button", - }, - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } -} diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index c898ac8..6b0759e 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -2,7 +2,7 @@ use crate::ipc_client::{DaemonStatus, IpcConnectionState}; use crate::theme::ResolvedTheme; use crate::{ controls::button::ButtonIntent, - screens::screens::{AppAction, HitMap}, + screens::screens::{AppAction, HitMap, IotaMessage}, }; use ratatui::{ Frame, @@ -119,7 +119,7 @@ pub fn render_header( Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar), brand_area, ); - hits.register(brand_area, AppAction::OpenMain); + hits.register(brand_area, IotaMessage::Action(AppAction::OpenMain)); for (index, item) in HEADER_ITEMS.iter().enumerate() { let top = cells[index + 1]; @@ -150,6 +150,6 @@ pub fn render_header( ]), button_area, ); - hits.register(button_area, item.action); + hits.register(button_area, IotaMessage::Action(item.action)); } } diff --git a/iota-cli/src/controls/menu.rs b/iota-cli/src/controls/menu.rs deleted file mode 100644 index 2739040..0000000 --- a/iota-cli/src/controls/menu.rs +++ /dev/null @@ -1,119 +0,0 @@ -use crossterm::event::KeyCode; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MenuItem { - pub label: String, - pub description: Option, - pub value: T, - pub enabled: bool, - pub disabled_reason: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MenuState { - items: Vec>, - selected: Option, -} - -impl MenuState { - pub fn new(items: Vec>) -> Self { - let selected = items.iter().position(|item| item.enabled); - Self { items, selected } - } - - pub fn items(&self) -> &[MenuItem] { - &self.items - } - pub fn selected_index(&self) -> Option { - self.selected - } - pub fn selected_item(&self) -> Option<&MenuItem> { - self.selected.and_then(|index| self.items.get(index)) - } - - pub fn move_next(&mut self) { - self.move_by(1); - } - pub fn move_previous(&mut self) { - self.move_by(-1); - } - pub fn first(&mut self) { - self.selected = self.items.iter().position(|item| item.enabled); - } - pub fn last(&mut self) { - self.selected = self.items.iter().rposition(|item| item.enabled); - } - - pub fn handle_key(&mut self, key: KeyCode) -> bool { - match key { - KeyCode::Up => self.move_previous(), - KeyCode::Down => self.move_next(), - KeyCode::Home => self.first(), - KeyCode::End => self.last(), - _ => return false, - } - true - } - - fn move_by(&mut self, delta: isize) { - let Some(current) = self.selected else { - return; - }; - for offset in 1..=self.items.len() { - let next = (current as isize + delta * offset as isize) - .rem_euclid(self.items.len() as isize) as usize; - if self.items[next].enabled { - self.selected = Some(next); - return; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn navigation_skips_disabled_items() { - let mut menu = MenuState::new(vec![ - MenuItem { - label: "A".into(), - description: None, - value: 1, - enabled: true, - disabled_reason: None, - }, - MenuItem { - label: "B".into(), - description: None, - value: 2, - enabled: false, - disabled_reason: Some("Unavailable".into()), - }, - MenuItem { - label: "C".into(), - description: None, - value: 3, - enabled: true, - disabled_reason: None, - }, - ]); - menu.move_next(); - assert_eq!(menu.selected_index(), Some(2)); - } - - #[test] - fn empty_enabled_set_is_safe() { - let mut menu = MenuState::new(vec![MenuItem { - label: "A".into(), - description: None, - value: (), - enabled: false, - disabled_reason: None, - }]); - menu.move_next(); - menu.last(); - assert_eq!(menu.selected_index(), None); - } -} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 6169155..cbf1e40 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -1,12 +1,4 @@ -pub mod action; pub mod button; -pub mod checkbox_group; pub mod choice; -pub mod dialog; pub mod header; -pub mod menu; -pub mod navigation; pub mod panel; -pub mod radio_group; -pub mod scroll; -pub mod text_input; diff --git a/iota-cli/src/controls/navigation.rs b/iota-cli/src/controls/navigation.rs deleted file mode 100644 index 0f292c5..0000000 --- a/iota-cli/src/controls/navigation.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum DisabledFocusPolicy { - Include, - #[default] - Skip, -} diff --git a/iota-cli/src/controls/radio_group.rs b/iota-cli/src/controls/radio_group.rs deleted file mode 100644 index 9aaacb5..0000000 --- a/iota-cli/src/controls/radio_group.rs +++ /dev/null @@ -1,194 +0,0 @@ -use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; - -pub struct RadioItem { - pub value: T, - pub label: String, - pub description: Option, - pub enabled: bool, - pub disabled_reason: Option, -} -pub struct RadioGroup { - items: Vec>, - selected: T, - default: T, - focused_index: usize, - focus_policy: DisabledFocusPolicy, - wrap_navigation: bool, - disabled_selection_policy: DisabledSelectionPolicy, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadioGroupError { - Empty, - DefaultMissing, - DefaultDisabled, - NoEnabledItems, - SelectedItemDisabled, -} -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadioChange { - Changed { previous: T, selected: T }, - Unchanged(T), - IgnoredDisabled(T), -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DisabledSelectionPolicy { - UseConfiguredDefault, - UseFirstEnabled, - ReturnError, -} -impl RadioGroup { - pub fn new( - items: Vec>, - observed: Option, - default: T, - ) -> Result { - if items.is_empty() { - return Err(RadioGroupError::Empty); - } - let default_item = items - .iter() - .find(|item| item.value == default) - .ok_or(RadioGroupError::DefaultMissing)?; - if !default_item.enabled { - return Err(RadioGroupError::DefaultDisabled); - } - let focused_index = items - .iter() - .position(|item| item.enabled) - .ok_or(RadioGroupError::NoEnabledItems)?; - let selected = observed - .filter(|value| { - items - .iter() - .any(|item| item.enabled && item.value == *value) - }) - .unwrap_or_else(|| default.clone()); - Ok(Self { - items, - selected, - default, - focused_index, - focus_policy: DisabledFocusPolicy::Skip, - wrap_navigation: true, - disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault, - }) - } - pub fn items(&self) -> &[RadioItem] { - &self.items - } - pub fn selected(&self) -> &T { - &self.selected - } - pub fn focused_item(&self) -> &RadioItem { - &self.items[self.focused_index] - } - pub fn focus_next(&mut self) { - self.move_focus(true); - } - pub fn focus_previous(&mut self) { - self.move_focus(false); - } - fn move_focus(&mut self, forward: bool) { - for step in 1..=self.items.len() { - let raw = self.focused_index as isize - + if forward { - step as isize - } else { - -(step as isize) - }; - let next = if self.wrap_navigation { - raw.rem_euclid(self.items.len() as isize) as usize - } else if raw < 0 || raw >= self.items.len() as isize { - return; - } else { - raw as usize - }; - if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled { - self.focused_index = next; - return; - } - } - } - pub fn select_focused(&mut self) -> RadioChange { - let item = self.focused_item(); - let enabled = item.enabled; - let value = item.value.clone(); - if !enabled { - return RadioChange::IgnoredDisabled(value); - } - if value == self.selected { - RadioChange::Unchanged(self.selected.clone()) - } else { - let previous = std::mem::replace(&mut self.selected, value); - RadioChange::Changed { - previous, - selected: self.selected.clone(), - } - } - } - pub fn visual_state(&self, value: &T) -> ChoiceVisualState { - let item = self.items.iter().position(|item| &item.value == value); - ChoiceVisualState { - selected: &self.selected == value, - focused: item == Some(self.focused_index), - enabled: item - .and_then(|index| self.items.get(index)) - .is_some_and(|item| item.enabled), - } - } - pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) { - self.disabled_selection_policy = policy; - } - pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) { - self.focus_policy = policy; - } - pub fn set_wrap_navigation(&mut self, wrap: bool) { - self.wrap_navigation = wrap; - } - pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> { - let Some(index) = self.items.iter().position(|item| &item.value == value) else { - return Ok(()); - }; - if self.items[index].enabled == enabled { - return Ok(()); - } - if !enabled - && self - .items - .iter() - .enumerate() - .all(|(other, item)| other == index || !item.enabled) - { - return Err(RadioGroupError::NoEnabledItems); - } - if !enabled && self.selected == *value { - let replacement = match self.disabled_selection_policy { - DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self - .items - .iter() - .find(|item| item.enabled && item.value == self.default) - .map(|item| item.value.clone()), - DisabledSelectionPolicy::UseConfiguredDefault => None, - DisabledSelectionPolicy::UseFirstEnabled => self - .items - .iter() - .enumerate() - .find(|(other, item)| *other != index && item.enabled) - .map(|(_, item)| item.value.clone()), - DisabledSelectionPolicy::ReturnError => { - return Err(RadioGroupError::SelectedItemDisabled); - } - }; - self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?; - } - self.items[index].enabled = enabled; - if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip - { - self.focus_next(); - } - Ok(()) - } - pub fn default(&self) -> &T { - &self.default - } -} diff --git a/iota-cli/src/controls/scroll.rs b/iota-cli/src/controls/scroll.rs deleted file mode 100644 index 585bf3c..0000000 --- a/iota-cli/src/controls/scroll.rs +++ /dev/null @@ -1,53 +0,0 @@ -use ratatui::{ - Frame, - layout::Rect, - widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, -}; - -/// Reusable viewport policy for long, vertically stacked terminal content. -#[derive(Clone, Copy, Debug)] -pub struct ScrollOptions { - pub show_scrollbar: bool, - pub render_partial_components: bool, -} -impl Default for ScrollOptions { - fn default() -> Self { - Self { - show_scrollbar: true, - render_partial_components: true, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct ScrollField { - pub offset: u16, - pub options: ScrollOptions, -} -impl ScrollField { - pub fn up(&mut self, amount: u16) { - self.offset = self.offset.saturating_sub(amount); - } - pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { - self.offset = (self.offset.saturating_add(amount)) - .min(content_height.saturating_sub(viewport_height)); - } - pub fn render( - &self, - frame: &mut Frame, - area: Rect, - content: Paragraph<'_>, - content_height: u16, - ) { - frame.render_widget(content.scroll((self.offset, 0)), area); - if self.options.show_scrollbar && content_height > area.height { - let mut state = - ScrollbarState::new(content_height as usize).position(self.offset as usize); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight), - area, - &mut state, - ); - } - } -} diff --git a/iota-cli/src/controls/text_input.rs b/iota-cli/src/controls/text_input.rs deleted file mode 100644 index 1ee8fb1..0000000 --- a/iota-cli/src/controls/text_input.rs +++ /dev/null @@ -1,99 +0,0 @@ -use crossterm::event::KeyCode; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct TextInput { - value: String, - cursor: usize, - label: Option, - placeholder: Option, - validation: Option, - secret: bool, -} - -impl TextInput { - pub fn new(label: impl Into) -> Self { - Self { - label: Some(label.into()), - ..Self::default() - } - } - pub fn value(&self) -> &str { - &self.value - } - pub fn display_value(&self) -> String { - if self.secret { - "•".repeat(self.value.chars().count()) - } else { - self.value.clone() - } - } - pub fn set_placeholder(&mut self, value: impl Into) { - self.placeholder = Some(value.into()); - } - pub fn placeholder(&self) -> Option<&str> { - self.placeholder.as_deref() - } - pub fn label(&self) -> Option<&str> { - self.label.as_deref() - } - pub fn validation(&self) -> Option<&str> { - self.validation.as_deref() - } - pub fn set_validation(&mut self, value: Option) { - self.validation = value; - } - pub fn set_secret(&mut self, secret: bool) { - self.secret = secret; - } - pub fn handle_key(&mut self, key: KeyCode) -> bool { - match key { - KeyCode::Backspace => { - if self.cursor > 0 { - let start = self.value[..self.cursor] - .char_indices() - .last() - .map(|(index, _)| index) - .unwrap_or(0); - self.value.drain(start..self.cursor); - self.cursor = start; - } - } - KeyCode::Delete => { - if self.cursor < self.value.len() { - let end = self.value[self.cursor..] - .char_indices() - .nth(1) - .map(|(index, _)| self.cursor + index) - .unwrap_or(self.value.len()); - self.value.drain(self.cursor..end); - } - } - KeyCode::Left => { - if self.cursor > 0 { - self.cursor = self.value[..self.cursor] - .char_indices() - .last() - .map(|(index, _)| index) - .unwrap_or(0); - } - } - KeyCode::Right => { - if self.cursor < self.value.len() { - self.cursor = self.value[self.cursor..] - .char_indices() - .nth(1) - .map(|(index, _)| self.cursor + index) - .unwrap_or(self.value.len()); - } - } - KeyCode::Home => self.cursor = 0, - KeyCode::End => self.cursor = self.value.len(), - KeyCode::Char(character) => { - self.value.insert(self.cursor, character); - self.cursor += character.len_utf8(); - } - _ => return false, - } - true - } -} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index db861e6..db7a40b 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -13,11 +13,11 @@ use std::{ }; use tokio::time::Instant; +use crate::theme::ResolvedTheme; use crate::{ elements::elements::{Element, InteractableElement, JoinableElement}, - interaction_result::InteractionResult, ipc_client::IpcClient, - render_context::RenderContext, + screens::screens::IotaInteraction, util::borders::draw_block_joins, }; @@ -300,18 +300,13 @@ impl Element for ConsoleCard { self } - fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { - if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - let inner = crate::controls::panel::render_panel( - f, - r, - &self.title, - self.focused, - context.theme, - ); + fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme) { + if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) { + let inner = + crate::controls::panel::render_panel(f, r, &self.title, self.focused, theme); f.render_widget( - Paragraph::new(Line::from(self.render_cursor_spans(context.theme))) - .style(context.theme.console.text), + Paragraph::new(Line::from(self.render_cursor_spans(theme))) + .style(theme.console.text), inner, ); return; @@ -319,15 +314,15 @@ impl Element for ConsoleCard { let block = Block::default() .borders(self.borders) .title(self.title.clone()) - .title_style(context.theme.console.title) + .title_style(theme.console.title) .border_style(if self.focused { - context.theme.console.focused_border + theme.console.focused_border } else { - context.theme.console.border + theme.console.border }) - .style(context.theme.console.text); + .style(theme.console.text); - let spans = self.render_cursor_spans(context.theme); + let spans = self.render_cursor_spans(theme); let par = Paragraph::new(Line::from(spans)) .block(block) .scroll((0, 0)); @@ -338,9 +333,9 @@ impl Element for ConsoleCard { self.borders, self.joins, if self.focused { - context.theme.borders.focused + theme.borders.focused } else { - context.theme.borders.normal + theme.borders.normal }, ); } @@ -389,7 +384,7 @@ impl InteractableElement for ConsoleCard { self } - fn interact(&mut self, key: KeyEvent) -> InteractionResult { + fn interact(&mut self, key: KeyEvent) -> IotaInteraction { // Check if a previously failed command should be restored. if let Some(restored) = self.pending_restore.lock().unwrap().take() { self.content = restored; @@ -401,26 +396,26 @@ impl InteractableElement for ConsoleCard { if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) { self.dispatch_command(command); } - return InteractionResult::Handled; + return IotaInteraction::Changed; } match key.code { KeyCode::Enter => { if self.content.is_empty() { - return InteractionResult::Handled; + return IotaInteraction::Changed; } let command = self.content.clone(); if let Some(error) = iota_ipc::text_commands::validation_error(&command) { self.message = Some(error); - return InteractionResult::Handled; + return IotaInteraction::Changed; } if command.trim_start_matches('/').trim() == "help" { self.message = Some(format!( "Commands: {}", iota_ipc::text_commands::COMMANDS.join(", ") )); - return InteractionResult::Handled; + return IotaInteraction::Changed; } if self.history.last() != Some(&command) { self.history.push(command.clone()); @@ -434,12 +429,12 @@ impl InteractableElement for ConsoleCard { } else { self.dispatch_command(command); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Backspace => { self.message = None; self.delete_at_cursor(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Delete => { self.message = None; @@ -454,41 +449,41 @@ impl InteractableElement for ConsoleCard { .unwrap_or(self.content.len()); self.content.replace_range(start..end, ""); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Left => { self.move_cursor_left(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Right => { self.move_cursor_right(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Home => { self.cursor_position = 0; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::End => { self.cursor_position = self.content.chars().count(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Up => { self.history_previous(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Down => { self.history_next(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Tab if !self.content.is_empty() => { if self.complete() { - InteractionResult::Handled + IotaInteraction::Changed } else { self.message = Some("No command completion.".into()); - InteractionResult::Handled + IotaInteraction::Changed } } - KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, + KeyCode::Tab | KeyCode::BackTab => IotaInteraction::Unhandled, _ => { if !key .modifiers @@ -497,10 +492,10 @@ impl InteractableElement for ConsoleCard { if let Some(c) = key.code.as_char() { self.insert_at_cursor(c); self.message = None; - return InteractionResult::Handled; + return IotaInteraction::Changed; } } - InteractionResult::Unhandled + IotaInteraction::Unhandled } } } diff --git a/iota-cli/src/elements/elements.rs b/iota-cli/src/elements/elements.rs index 1af14cc..05192be 100644 --- a/iota-cli/src/elements/elements.rs +++ b/iota-cli/src/elements/elements.rs @@ -3,16 +3,15 @@ use std::any::Any; use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect, widgets::Borders}; -use crate::{ - interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen, -}; +use crate::screens::screens::{IotaInteraction, IotaScreen}; +use crate::theme::ResolvedTheme; #[allow(unused)] pub trait Element: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>); + fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme); } #[allow(unused)] @@ -33,7 +32,7 @@ pub trait InfoElement: Send + Sync + Any { fn as_element(&self) -> &dyn Element; fn as_element_mut(&mut self) -> &mut dyn Element; - fn get_info_screen(&self) -> Box; + fn get_info_screen(&self) -> Box; } #[allow(unused)] @@ -43,7 +42,7 @@ pub trait InteractableElement: Send + Sync + Any { fn as_element(&self) -> &dyn Element; fn as_element_mut(&mut self) -> &mut dyn Element; - fn interact(&mut self, key: KeyEvent) -> InteractionResult; + fn interact(&mut self, key: KeyEvent) -> IotaInteraction; fn can_focus(&self) -> bool; fn is_focused(&self) -> bool; diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index f47aab6..2b19edc 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -1,4 +1,4 @@ -use std::{any::Any, sync::Arc}; +use std::any::Any; use crossterm::event::KeyEvent; use iota_state::ClientState; @@ -11,11 +11,10 @@ use ratatui::{ }, }; +use crate::theme::ResolvedTheme; use crate::{ elements::elements::{Element, InteractableElement, JoinableElement}, - interaction_result::InteractionResult, - render_context::RenderContext, - ui::UI, + screens::screens::IotaInteraction, util::borders::draw_block_joins, }; @@ -67,7 +66,6 @@ impl GRAPHS { #[allow(unused)] pub struct GraphCard { - ui: Arc, state: ClientState, graph_type: GRAPHS, @@ -82,9 +80,8 @@ pub struct GraphCard { } impl GraphCard { - pub fn new(ui: Arc, state: ClientState, graph_type: GRAPHS, title: String) -> Self { + pub fn new(state: ClientState, graph_type: GRAPHS, title: String) -> Self { Self { - ui, state, graph_type, focused: false, @@ -113,7 +110,7 @@ impl Element for GraphCard { self } - fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme) { if self.open { let graph = self.graph_type.get_graph(&self.state, self.sample_width); if graph.is_empty() { @@ -121,13 +118,13 @@ impl Element for GraphCard { .title(format!(" {} ", self.title)) .borders(self.borders) .border_style(if self.focused { - context.theme.graphs.focused_border + theme.graphs.focused_border } else { - context.theme.graphs.border + theme.graphs.border }); f.render_widget( ratatui::widgets::Paragraph::new("No metric samples yet.") - .style(context.theme.text.muted) + .style(theme.text.muted) .block(block), r, ); @@ -149,7 +146,7 @@ impl Element for GraphCard { GRAPHS::Ping => (max_y * 1.2).max(10.0), }; - let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces); + let surface = matches!(theme.chrome, crate::theme::ChromeMode::Surfaces); let title = format!( "{}: {}{} {}min/{}max", self.title, @@ -159,7 +156,7 @@ impl Element for GraphCard { max_y as i64 ); let plot_area = if surface { - crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) + crate::controls::panel::render_panel(f, r, &title, self.focused, theme) } else { r }; @@ -178,9 +175,9 @@ impl Element for GraphCard { }) .borders(if surface { Borders::NONE } else { self.borders }) .border_style(if self.focused { - context.theme.graphs.focused_border + theme.graphs.focused_border } else { - context.theme.graphs.border + theme.graphs.border }); let canvas = Canvas::default() @@ -194,7 +191,7 @@ impl Element for GraphCard { y1: 0.0, x2: *x, y2: *y, - color: self.graph_type.get_color(context.theme), + color: self.graph_type.get_color(theme), }); } }); @@ -204,22 +201,22 @@ impl Element for GraphCard { .title("") .borders(self.borders) .border_style(if self.focused { - context.theme.graphs.focused_border + theme.graphs.focused_border } else { - context.theme.graphs.border + theme.graphs.border }); f.render_widget(block, r); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + if !matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( f, r, self.borders, self.joins, if self.focused { - context.theme.borders.focused + theme.borders.focused } else { - context.theme.borders.normal + theme.borders.normal }, ); } @@ -269,8 +266,8 @@ impl InteractableElement for GraphCard { self } - fn interact(&mut self, _key: KeyEvent) -> InteractionResult { - InteractionResult::Handled + fn interact(&mut self, _key: KeyEvent) -> IotaInteraction { + IotaInteraction::Changed } fn can_focus(&self) -> bool { diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index a7d72eb..7c7200d 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,6 +1,6 @@ use crate::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::util::borders::draw_block_joins; -use crate::{interaction_result::InteractionResult, render_context::RenderContext}; +use crate::{screens::screens::IotaInteraction, theme::ResolvedTheme}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use iota_state::{ClientState, UiLogEntry}; use ratatui::{ @@ -270,7 +270,7 @@ impl LogCard { } } - fn scroll_up(&mut self) { + pub fn scroll_older(&mut self) { let max_offset = self .last_total_lines .load(Ordering::Relaxed) @@ -278,7 +278,7 @@ impl LogCard { self.scroll_offset = (self.scroll_offset + 1).min(max_offset); } - fn scroll_down(&mut self) { + pub fn scroll_newer(&mut self) { self.scroll_offset = self.scroll_offset.saturating_sub(1); } @@ -318,25 +318,19 @@ impl Element for LogCard { self } - fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { + fn render(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) { let entries = self.get_logs(); - let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel( - f, - area, - &self.build_title(), - self.focused, - context.theme, - ) + let inner_area = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, theme) } else { let block = Block::default() .title(self.build_title()) .borders(self.borders) .border_style(if self.focused { - context.theme.logs.focused_border + theme.logs.focused_border } else { - context.theme.logs.border + theme.logs.border }); let inner = block.inner(area); f.render_widget(block, area); @@ -350,9 +344,9 @@ impl Element for LogCard { self.borders, self.joins, if self.focused { - context.theme.borders.focused + theme.borders.focused } else { - context.theme.borders.normal + theme.borders.normal }, ); return; @@ -376,24 +370,21 @@ impl Element for LogCard { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); - let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + let prefix = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix }; if !prefix.is_empty() { - spans.push(Span::styled( - prefix.to_string(), - source.style(context.theme), - )); + spans.push(Span::styled(prefix.to_string(), source.style(theme))); } let (content, timestamp) = Self::split_timestamp_suffix(rest); let text_style = if *is_error { - context.theme.logs.error + theme.logs.error } else { - context.theme.logs.text + theme.logs.text }; if !content.is_empty() { @@ -401,10 +392,7 @@ impl Element for LogCard { } if !timestamp.is_empty() { - spans.push(Span::styled( - timestamp.to_string(), - context.theme.logs.timestamp, - )); + spans.push(Span::styled(timestamp.to_string(), theme.logs.timestamp)); } Line::from(spans) @@ -421,16 +409,16 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + if !matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( f, area, self.borders, self.joins, if self.focused { - context.theme.borders.focused + theme.borders.focused } else { - context.theme.borders.normal + theme.borders.normal }, ); } @@ -480,7 +468,7 @@ impl InteractableElement for LogCard { &mut *self } - fn interact(&mut self, key: KeyEvent) -> InteractionResult { + fn interact(&mut self, key: KeyEvent) -> IotaInteraction { if self.filtering { match key.code { KeyCode::Esc => { @@ -501,7 +489,7 @@ impl InteractableElement for LogCard { _ => {} } self.scroll_offset = 0; - return InteractionResult::Handled; + return IotaInteraction::Changed; } let entries = self.get_logs(); let width = self.last_width.load(Ordering::Relaxed).max(1); @@ -517,51 +505,51 @@ impl InteractableElement for LogCard { self.filtering = true; self.filter.clear(); self.scroll_offset = 0; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter | KeyCode::Char(' ') => { self.selected = !self.selected; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('j') | KeyCode::Char('J') => { let (can_up, _) = self.get_title_hints(); if can_up { - self.scroll_up(); + self.scroll_older(); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('k') | KeyCode::Char('K') => { let (_, can_down) = self.get_title_hints(); if can_down { - self.scroll_down(); + self.scroll_newer(); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Up if self.selected => { let (can_up, _) = self.get_title_hints(); if can_up { - self.scroll_up(); + self.scroll_older(); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Down if self.selected => { let (_, can_down) = self.get_title_hints(); if can_down { - self.scroll_down(); + self.scroll_newer(); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Home => { if total_lines > visible_height { self.scroll_offset = total_lines - visible_height; } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::End => { self.scroll_offset = 0; - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } diff --git a/iota-cli/src/help_overlay.rs b/iota-cli/src/help_overlay.rs index 007c223..f9375a3 100644 --- a/iota-cli/src/help_overlay.rs +++ b/iota-cli/src/help_overlay.rs @@ -7,9 +7,7 @@ use ratatui::{ }; use crate::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, + screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent}, theme::ResolvedTheme, }; @@ -139,19 +137,11 @@ impl HelpOverlay { } } -impl Screen for HelpOverlay { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let area = crate::layout::fit::centered_rect( +impl IotaScreen for HelpOverlay { + fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) { + let area = ratatool::layout::centered_rect( rect, - crate::layout::fit::RequiredSize { + ratatool::layout::RequiredSize { width: 52, height: 40, }, @@ -161,44 +151,43 @@ impl Screen for HelpOverlay { let block = Block::default() .title(" Keyboard Shortcuts (?) ") .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); + .border_style(theme.borders.focused) + .style(theme.surfaces.overlay); let inner = block.inner(area); f.render_widget(block, area); - let lines = self.build_lines(context.theme); + let lines = self.build_lines(theme); let paragraph = Paragraph::new(lines) .scroll((self.scroll as u16, 0)) - .style(context.theme.text.normal); + .style(theme.text.normal); f.render_widget(paragraph, inner); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; match key.code { - KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { - InteractionResult::CloseScreen - } + KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => IotaInteraction::Pop, KeyCode::Down | KeyCode::Char('j') => { self.scroll = self.scroll.saturating_add(1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Up | KeyCode::Char('k') => { self.scroll = self.scroll.saturating_sub(1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::PageDown => { self.scroll = self.scroll.saturating_add(10); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::PageUp => { self.scroll = self.scroll.saturating_sub(10); - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } @@ -215,5 +204,3 @@ impl Screen for HelpOverlay { ] } } - -use std::any::Any; diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs deleted file mode 100644 index 842f89c..0000000 --- a/iota-cli/src/input_handler.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::{screens::screens::UiEvent, ui::UI}; -use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; - -pub fn setup_input_handler(ui: Arc) -> JoinHandle> { - tokio::spawn(async move { - let cancellation = ui.cancellation_token(); - let (tx, mut rx) = mpsc::unbounded_channel(); - let worker_cancellation = cancellation.clone(); - let worker = tokio::task::spawn_blocking(move || -> Result<(), String> { - while !worker_cancellation.is_cancelled() { - if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? { - tx.send(read().map_err(|e| e.to_string())?) - .map_err(|_| "input session closed".to_string())?; - } - } - Ok(()) - }); - loop { - if ui.is_shutdown() { - break; - } - - tokio::select! { - event = rx.recv() => match event { - Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await, - Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await, - Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await, - Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await, - Some(_) => {}, - None => break, - }, - _ = cancellation.cancelled() => break, - } - } - let result = match worker.await { - Ok(result) => result, - Err(error) if error.is_cancelled() => Ok(()), - Err(error) => Err(format!("input worker failed: {error}")), - }; - if result.is_err() { - ui.request_shutdown(); - } - result - }) -} - -pub async fn handle_input(key: KeyEvent, ui: Arc) { - if matches!( - key.code, - crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c') - ) && key.modifiers.contains(KeyModifiers::CONTROL) - { - ui.request_shutdown(); - } else { - ui.handle_event(UiEvent::Key(key)).await; - } -} diff --git a/iota-cli/src/interaction_result.rs b/iota-cli/src/interaction_result.rs deleted file mode 100644 index 8afad00..0000000 --- a/iota-cli/src/interaction_result.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::fmt::{Debug, Formatter}; -use std::future::Future; -use std::pin::Pin; - -use crate::screens::screens::{Screen, UiEvent}; - -#[allow(unused)] -pub enum InteractionResult { - CloseScreen, - OpenScreen { - screen: Box, - }, - OpenFutureScreen { - screen: Pin> + Send>>, - }, - AppTask { - task: Pin + Send>>, - }, - Handled, - Unhandled, -} - -impl Debug for InteractionResult { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), - InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), - InteractionResult::AppTask { task: _ } => write!(f, "AppTask"), - InteractionResult::CloseScreen => write!(f, "CloseScreen"), - InteractionResult::Handled => write!(f, "Handled"), - InteractionResult::Unhandled => write!(f, "Unhandled"), - } - } -} - -impl PartialEq for InteractionResult { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - ( - InteractionResult::OpenScreen { screen: _ }, - InteractionResult::OpenScreen { screen: _ }, - ) => true, - (InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => { - true - } - ( - InteractionResult::OpenFutureScreen { screen: _ }, - InteractionResult::OpenFutureScreen { screen: _ }, - ) => true, - (InteractionResult::CloseScreen, InteractionResult::CloseScreen) => true, - (InteractionResult::Handled, InteractionResult::Handled) => true, - (InteractionResult::Unhandled, InteractionResult::Unhandled) => true, - _ => false, - } - } -} diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index bd1dd21..2a32219 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -76,6 +76,7 @@ pub struct IpcClient { pending: Mutex>, connection_state: watch::Sender, daemon_status: watch::Sender, + ui_updates: watch::Sender, path: PathBuf, reconnector_started: AtomicBool, cancellation: CancellationToken, @@ -110,6 +111,7 @@ impl IpcClient { }; let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected); let (daemon_status_tx, _) = watch::channel(initial_status); + let (ui_updates_tx, _) = watch::channel(0); let client = Arc::new(Self { state: ClientState::new(), writer: Mutex::new(Some(ActiveWriter { @@ -122,6 +124,7 @@ impl IpcClient { pending: Mutex::new(HashMap::new()), connection_state: conn_state_tx, daemon_status: daemon_status_tx, + ui_updates: ui_updates_tx, path: path.clone(), reconnector_started: AtomicBool::new(false), cancellation: CancellationToken::new(), @@ -415,6 +418,10 @@ impl IpcClient { self.daemon_status.subscribe() } + pub fn ui_updates(&self) -> watch::Receiver { + self.ui_updates.subscribe() + } + /// Stop the IPC reader/reconnector and release the socket writer. This /// is deliberately bounded so UI shutdown cannot hang on a peer. pub async fn shutdown(&self) { @@ -903,5 +910,8 @@ impl IpcClient { }); } } + self.ui_updates.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); } } diff --git a/iota-cli/src/layout/fit.rs b/iota-cli/src/layout/fit.rs deleted file mode 100644 index 3908272..0000000 --- a/iota-cli/src/layout/fit.rs +++ /dev/null @@ -1,52 +0,0 @@ -use ratatui::layout::Rect; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RequiredSize { - pub width: u16, - pub height: u16, -} -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FitLevel { - Preferred, - Compact, - Fallback, -} -pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel { - if area.width >= preferred.width && area.height >= preferred.height { - FitLevel::Preferred - } else if area.width >= compact.width && area.height >= compact.height { - FitLevel::Compact - } else { - FitLevel::Fallback - } -} -pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect { - let width = area.width.min(maximum.width); - let height = area.height.min(maximum.height); - Rect { - x: area.x.saturating_add(area.width.saturating_sub(width) / 2), - y: area - .y - .saturating_add(area.height.saturating_sub(height) / 2), - width, - height, - } -} -pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option { - let height = area.height.checked_sub(top)?.checked_sub(bottom)?; - Some(Rect { - x: area.x, - y: area.y.checked_add(top)?, - width: area.width, - height, - }) -} -pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option { - let width = area.width.checked_sub(horizontal.checked_mul(2)?)?; - let height = area.height.checked_sub(vertical.checked_mul(2)?)?; - Some(Rect { - x: area.x.checked_add(horizontal)?, - y: area.y.checked_add(vertical)?, - width, - height, - }) -} diff --git a/iota-cli/src/layout/mod.rs b/iota-cli/src/layout/mod.rs deleted file mode 100644 index f629920..0000000 --- a/iota-cli/src/layout/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod fit; -pub mod text_measure; diff --git a/iota-cli/src/layout/text_measure.rs b/iota-cli/src/layout/text_measure.rs deleted file mode 100644 index 0ba471e..0000000 --- a/iota-cli/src/layout/text_measure.rs +++ /dev/null @@ -1,10 +0,0 @@ -use unicode_width::UnicodeWidthStr; -pub fn wrapped_line_count(text: &str, width: u16) -> u16 { - if width == 0 { - return 0; - } - text.split('\n') - .map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize) - .sum::() - .min(u16::MAX as usize) as u16 -} diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index bd2e44d..83e3584 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -24,12 +24,7 @@ pub mod util { pub mod app_state; pub mod controls; pub mod help_overlay; -pub mod input_handler; -pub mod interaction_result; pub mod ipc_client; -pub mod layout; -pub mod notification; -pub mod render_context; pub mod theme; pub mod ui; pub use ui::TuiSession; diff --git a/iota-cli/src/notification.rs b/iota-cli/src/notification.rs deleted file mode 100644 index 0fd3cb7..0000000 --- a/iota-cli/src/notification.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::time::{Duration, Instant}; - -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::Paragraph, -}; - -use crate::theme::ResolvedTheme; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NotificationKind { - Success, - Warning, - Error, - Info, -} - -#[derive(Clone)] -pub struct Notification { - pub message: String, - pub kind: NotificationKind, - pub created_at: Instant, - pub duration: Duration, -} - -impl Notification { - pub fn success(message: impl Into) -> Self { - Self::new(message, NotificationKind::Success, Duration::from_secs(3)) - } - - pub fn warning(message: impl Into) -> Self { - Self::new(message, NotificationKind::Warning, Duration::from_secs(4)) - } - - pub fn error(message: impl Into) -> Self { - Self::new(message, NotificationKind::Error, Duration::from_secs(5)) - } - - pub fn info(message: impl Into) -> Self { - Self::new(message, NotificationKind::Info, Duration::from_secs(3)) - } - - fn new(message: impl Into, kind: NotificationKind, duration: Duration) -> Self { - Self { - message: message.into(), - kind, - created_at: Instant::now(), - duration, - } - } - - pub fn is_expired(&self) -> bool { - self.created_at.elapsed() >= self.duration - } - - pub fn remaining(&self) -> Duration { - self.duration.saturating_sub(self.created_at.elapsed()) - } - - pub fn progress(&self) -> f64 { - let elapsed = self.created_at.elapsed().as_secs_f64(); - let total = self.duration.as_secs_f64(); - (elapsed / total).min(1.0) - } -} - -pub fn render_notification( - frame: &mut Frame, - area: Rect, - notification: &Notification, - theme: &ResolvedTheme, -) { - let (prefix, style) = match notification.kind { - NotificationKind::Success => ("✓ ", theme.status.success), - NotificationKind::Warning => ("âš  ", theme.status.warning), - NotificationKind::Error => ("✗ ", theme.status.error), - NotificationKind::Info => ("ℹ ", theme.status.info), - }; - - let remaining = notification.remaining().as_secs(); - let progress = notification.progress(); - - let mut spans = vec![ - Span::styled(prefix, style), - Span::styled(¬ification.message, theme.text.normal), - ]; - - if remaining > 0 { - let bar_width = 10; - let filled = ((1.0 - progress) * bar_width as f64) as usize; - let empty = bar_width - filled; - let bar: String = "â–ˆ".repeat(filled) + &"â–‘".repeat(empty); - spans.push(Span::styled( - format!(" [{bar}] {remaining}s"), - theme.text.muted, - )); - } - - let paragraph = Paragraph::new(Line::from(spans)); - frame.render_widget(paragraph, area); -} - -pub fn render_notification_area( - frame: &mut Frame, - area: Rect, - notifications: &[Notification], - theme: &ResolvedTheme, -) { - if notifications.is_empty() { - return; - } - - let visible_height = area.height as usize; - let start = notifications.len().saturating_sub(visible_height); - let visible = ¬ifications[start..]; - - for (i, notification) in visible.iter().enumerate() { - let row = Rect { - x: area.x, - y: area.y + i as u16, - width: area.width, - height: 1, - }; - render_notification(frame, row, notification, theme); - } -} diff --git a/iota-cli/src/render_context.rs b/iota-cli/src/render_context.rs deleted file mode 100644 index 8b7d3c9..0000000 --- a/iota-cli/src/render_context.rs +++ /dev/null @@ -1,6 +0,0 @@ -use crate::theme::ResolvedTheme; - -/// Immutable state shared by every component during one render pass. -pub struct RenderContext<'a> { - pub theme: &'a ResolvedTheme, -} diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs index b9cae99..269771f 100644 --- a/iota-cli/src/screens/daemon_setup.rs +++ b/iota-cli/src/screens/daemon_setup.rs @@ -1,44 +1,36 @@ +use crate::theme::ResolvedTheme; use crate::{ controls::{ button::{ActionButton, ButtonIntent, render_button}, - choice::{ChoiceKind, render_choice_line}, - radio_group::{RadioGroup, RadioItem}, + choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, }, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, + screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent}, }; use crossterm::event::KeyCode; +use ratatool::{DisabledFocusPolicy, RadioGroup, RadioGroupError, RadioItem}; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, text::{Line, Text}, widgets::{Block, Borders, Paragraph, Wrap}, }; -use std::any::Any; use tokio::sync::oneshot; /// Kept on screen while the launcher waits for the daemon's IPC hello. The /// setup choice screen is intentionally closed before its decision is sent, /// so without this the terminal would otherwise be blank during startup. pub struct DaemonStartingScreen; -impl Screen for DaemonStartingScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn render( +impl IotaScreen for DaemonStartingScreen { + fn render_legacy( &self, frame: &mut Frame, area: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, _hits: &mut HitMap, ) { - let popup = crate::layout::fit::centered_rect( + let popup = ratatool::layout::centered_rect( area, - crate::layout::fit::RequiredSize { + ratatool::layout::RequiredSize { width: 48, height: 5, }, @@ -52,13 +44,13 @@ impl Screen for DaemonStartingScreen { Block::default() .title(" Iota daemon ") .borders(Borders::ALL) - .border_style(context.theme.borders.normal), + .border_style(theme.borders.normal), ), popup, ); } - fn handle_event(&mut self, _: UiEvent) -> InteractionResult { - InteractionResult::Handled + fn handle_event(&mut self, _: &UiEvent) -> IotaInteraction { + IotaInteraction::Changed } } @@ -99,7 +91,7 @@ impl DaemonSetupScreen { options: Vec, message: impl Into, sender: oneshot::Sender, - ) -> Result { + ) -> Result { let items: Vec> = options .into_iter() .map(|o| RadioItem { @@ -119,9 +111,9 @@ impl DaemonSetupScreen { .iter() .find(|item| item.enabled) .map(|item| item.value) - .ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?; + .ok_or(RadioGroupError::NoEnabledItems)?; let mut choices = RadioGroup::new(items, None, default)?; - choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include); + choices.set_focus_policy(DisabledFocusPolicy::Include); Ok(Self { choices, focus: Focus::Options, @@ -134,15 +126,15 @@ impl DaemonSetupScreen { let _ = tx.send(d); } } - fn activate(&mut self) -> InteractionResult { + fn activate(&mut self) -> IotaInteraction { match self.focus { Focus::Options => { self.choices.select_focused(); - InteractionResult::Handled + IotaInteraction::Changed } Focus::Exit => { self.complete(DaemonSetupDecision::Exit); - InteractionResult::CloseScreen + IotaInteraction::Pop } Focus::Action => { let choice = *self.choices.selected(); @@ -154,9 +146,9 @@ impl DaemonSetupScreen { .is_some_and(|i| i.enabled) { self.complete(DaemonSetupDecision::Start(choice)); - InteractionResult::CloseScreen + IotaInteraction::Pop } else { - InteractionResult::Handled + IotaInteraction::Changed } } } @@ -190,23 +182,17 @@ impl DaemonSetupScreen { }; } } -impl Screen for DaemonSetupScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn render( +impl IotaScreen for DaemonSetupScreen { + fn render_legacy( &self, frame: &mut Frame, area: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, _hits: &mut HitMap, ) { - let popup = crate::layout::fit::centered_rect( + let popup = ratatool::layout::centered_rect( area, - crate::layout::fit::RequiredSize { + ratatool::layout::RequiredSize { width: 68, height: 16, }, @@ -216,14 +202,18 @@ impl Screen for DaemonSetupScreen { lines.push(render_choice_line( &item.label, ChoiceKind::Radio, - self.choices.visual_state(&item.value), - context.theme, + { + let state = self.choices.visual_state(&item.value); + ChoiceVisualState { + selected: state.selected, + focused: state.focused, + enabled: state.enabled, + } + }, + theme, )); if let Some(reason) = &item.description { - lines.push(Line::styled( - format!(" {reason}"), - context.theme.text.muted, - )); + lines.push(Line::styled(format!(" {reason}"), theme.text.muted)); } } let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup); @@ -248,7 +238,7 @@ impl Screen for DaemonSetupScreen { focused: self.focus == Focus::Exit, enabled: true, }, - context.theme, + theme, ); let selected = *self.choices.selected(); let enabled = self @@ -275,28 +265,29 @@ impl Screen for DaemonSetupScreen { focused: self.focus == Focus::Action, enabled, }, - context.theme, + theme, ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; match event.code { KeyCode::Esc => { self.complete(DaemonSetupDecision::Exit); - InteractionResult::CloseScreen + IotaInteraction::Pop } KeyCode::Down | KeyCode::Right | KeyCode::Tab => { self.next(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Up | KeyCode::Left | KeyCode::BackTab => { self.previous(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } } diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 53e68b3..0914f90 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -1,3 +1,4 @@ +use crate::theme::ResolvedTheme; use crate::{ controls::button::{ActionButton, ButtonIntent, render_button}, elements::{ @@ -6,97 +7,97 @@ use crate::{ graph_card::{GRAPHS, GraphCard}, log_card::LogCard, }, - interaction_result::InteractionResult, - ipc_client::{DaemonStatus, IpcConnectionState}, - render_context::RenderContext, - screens::{ - overview::OverviewScreen, - screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent}, + screens::screens::{ + AppAction, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint, + NavDirection, UiEvent, }, - ui::UI, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, MouseEventKind}; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, widgets::Borders, }; -use tokio::sync::watch; - -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicU16, Ordering}, }; pub struct MainScreen { elements: Vec>, - nav_grid: Vec>>, - selected_coords: (usize, usize), + focus_graph: ratatool::FocusGraph, + focus: MainFocus, graphs_open: bool, - connection_status_rx: watch::Receiver, - daemon_status_rx: watch::Receiver, layout_width: AtomicU16, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +enum MainFocus { + #[default] + Logs, + Console, + Ram, + Cpu, + Ping, +} + +impl MainFocus { + const ORDER: [Self; 5] = [Self::Logs, Self::Ram, Self::Cpu, Self::Console, Self::Ping]; + + const fn element_index(self) -> usize { + match self { + Self::Logs => 0, + Self::Console => 1, + Self::Ram => 2, + Self::Cpu => 3, + Self::Ping => 4, + } + } + + const fn is_metric(self) -> bool { + matches!(self, Self::Ram | Self::Cpu | Self::Ping) + } +} + impl MainScreen { - pub fn connection_status(&self) -> watch::Receiver { - self.connection_status_rx.clone() - } - pub fn daemon_status(&self) -> watch::Receiver { - self.daemon_status_rx.clone() - } - pub async fn new(ui: Arc) -> Self { + pub fn new(ipc: Arc) -> Self { let mut elements: Vec> = Vec::new(); - let nav_grid = vec![ - vec![Some(0), Some(2)], - vec![Some(0), Some(3)], - vec![Some(1), Some(4)], - ]; + let mut focus_graph = ratatool::FocusGraph::default(); + focus_graph.connect_bidirectional(MainFocus::Logs, NavDirection::Down, MainFocus::Console); + focus_graph.connect_bidirectional(MainFocus::Logs, NavDirection::Right, MainFocus::Ram); + focus_graph.connect_bidirectional(MainFocus::Ram, NavDirection::Down, MainFocus::Cpu); + focus_graph.connect_bidirectional(MainFocus::Cpu, NavDirection::Down, MainFocus::Ping); + focus_graph.connect_bidirectional(MainFocus::Console, NavDirection::Right, MainFocus::Ping); - let state = ui - .client_state() - .await - .expect("MainScreen requires an attached daemon"); + let state = ipc.state(); let mut log_card = LogCard::new(state.clone()); log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT)); - let ipc = ui - .ipc() - .await - .expect("MainScreen requires an attached daemon"); let mut console_card = ConsoleCard::new("Console", "", ipc.clone()); console_card.set_joins(Borders::TOP); elements.push(Box::new(log_card)); elements.push(Box::new(console_card)); - let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()); + let mut ram_graph = GraphCard::new(state.clone(), GRAPHS::Ram, "RAM".into()); ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); elements.push(Box::new(ram_graph)); - let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()); + let mut cpu_graph = GraphCard::new(state.clone(), GRAPHS::Cpu, "CPU".into()); cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT)); cpu_graph.set_joins(Borders::TOP); elements.push(Box::new(cpu_graph)); - let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into()); + let mut ping_graph = GraphCard::new(state, GRAPHS::Ping, "Ping".into()); ping_graph.set_joins(Borders::TOP); elements.push(Box::new(ping_graph)); let graphs_open = true; - let connection_status_rx = ipc.connection_status(); - let daemon_status_rx = ipc.daemon_status(); - let mut screen = MainScreen { elements, - nav_grid, - selected_coords: (1, 0), + focus_graph, + focus: MainFocus::Logs, graphs_open, - connection_status_rx, - daemon_status_rx, layout_width: AtomicU16::new(0), }; screen.focus_current(); @@ -104,106 +105,43 @@ impl MainScreen { } fn focus_current(&mut self) { - let (y, x) = self.selected_coords; - if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) { - if let Some(element) = self.elements.get_mut(*index) { - if element.can_focus() { - element.focus(true); - } - } + if let Some(element) = self.elements.get_mut(self.focus.element_index()) + && element.can_focus() + { + element.focus(true); } } - fn unfocus_current(&mut self, y: usize, x: usize) { - if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) { - if let Some(element) = self.elements.get_mut(*index) { - element.focus(false); - } + fn unfocus_current(&mut self) { + if let Some(element) = self.elements.get_mut(self.focus.element_index()) { + element.focus(false); } } fn navigate(&mut self, direction: NavDirection) { - let (current_row, current_col) = self.selected_coords; - let current_element = self.nav_grid[current_row][current_col]; - - self.unfocus_current(current_row, current_col); - - let (delta_row, delta_col) = match direction { - NavDirection::Up => (-1isize, 0), - NavDirection::Down => (1, 0), - NavDirection::Left => (0, -1), - NavDirection::Right => (0, 1), - _ => (0, 0), + let Some(target) = self.focus_graph.target(&self.focus, direction).copied() else { + return; }; - - let mut next_row = current_row as isize; - let mut next_col = current_col as isize; - - loop { - next_row += delta_row; - next_col += delta_col; - - if next_row < 0 || next_col < 0 { - self.selected_coords = ( - (next_row - delta_row) as usize, - (next_col - delta_col) as usize, - ); - break; - } - let next_row_u = next_row as usize; - let next_col_u = next_col as usize; - - if next_row_u >= self.nav_grid.len() { - self.selected_coords = ( - (next_row - delta_row) as usize, - (next_col - delta_col) as usize, - ); - break; - } - - if let Some(row) = self.nav_grid.get(next_row_u) { - if next_col_u >= row.len() { - self.selected_coords = ( - (next_row - delta_row) as usize, - (next_col - delta_col) as usize, - ); - break; - } - - if let Some(next_element) = row[next_col_u] { - if Some(next_element) != current_element { - self.selected_coords = (next_row_u, next_col_u); - self.focus_current(); - return; - } - } - } + if target.is_metric() + && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) + { + return; } - + self.unfocus_current(); + self.focus = target; self.focus_current(); } /// Cycle focus between unique elements in the navigation grid. fn navigate_focus(&mut self, forward: bool) { - // Collect unique elements in grid order. - let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col) - let mut seen: Vec> = Vec::new(); - for (y, row) in self.nav_grid.iter().enumerate() { - for (x, elem_opt) in row.iter().enumerate() { - if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) { - continue; - } - if elem_opt.is_some() && !seen.contains(elem_opt) { - seen.push(*elem_opt); - positions.push((y, x)); - } - } - } - - let current = self.selected_coords; - let current_pos = positions - .iter() - .position(|&(r, c)| r == current.0 && c == current.1); + let positions = MainFocus::ORDER + .into_iter() + .filter(|focus| { + !focus.is_metric() + || (self.graphs_open && self.layout_width.load(Ordering::Relaxed) >= 70) + }) + .collect::>(); + let current_pos = positions.iter().position(|focus| *focus == self.focus); let next_pos = if let Some(idx) = current_pos { if forward { @@ -215,25 +153,17 @@ impl MainScreen { 0 }; - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = positions[next_pos]; + self.unfocus_current(); + self.focus = positions[next_pos]; self.focus_current(); } } -impl Screen for MainScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { +impl IotaScreen for MainScreen { + fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, hits: &mut HitMap) { self.layout_width.store(rect.width, Ordering::Relaxed); f.render_widget( - ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), + ratatui::widgets::Block::default().style(theme.surfaces.canvas), rect, ); let inner = rect; @@ -252,9 +182,9 @@ impl Screen for MainScreen { let left_area = horizontal_chunks[0]; let right_area = horizontal_chunks[1]; - hits.register(left_area, AppAction::FocusLogs); + hits.register(left_area, IotaMessage::Action(AppAction::FocusLogs)); if metrics_visible { - hits.register(right_area, AppAction::FocusMetrics); + hits.register(right_area, IotaMessage::Action(AppAction::FocusMetrics)); } if inner.width >= 70 { @@ -277,21 +207,24 @@ impl Screen for MainScreen { focused: false, enabled: true, }, - context.theme, + theme, + ); + hits.register( + metrics_button, + IotaMessage::Action(AppAction::ToggleMetrics), ); - hits.register(metrics_button, AppAction::ToggleMetrics); } let left_rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); - hits.register(left_rows[1], AppAction::FocusConsole); + hits.register(left_rows[1], IotaMessage::Action(AppAction::FocusConsole)); - if let Some(log) = self.elements.get(0) { - log.as_element().render(f, left_rows[0], context); + if let Some(log) = self.elements.first() { + log.as_element().render(f, left_rows[0], theme); } if let Some(console) = self.elements.get(1) { - console.as_element().render(f, left_rows[1], context); + console.as_element().render(f, left_rows[1], theme); } let graph_elements: Vec<_> = self @@ -310,107 +243,114 @@ impl Screen for MainScreen { .split(right_area); for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) { - el.as_element().render(f, *area, context); + el.as_element().render(f, *area, theme); } } } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); + if let UiEvent::Pointer { + event, + hit: Some(IotaMessage::Action(AppAction::FocusLogs)), + } = &event + { + let Some(log) = self + .elements + .get_mut(0) + .and_then(|element| element.as_any_mut().downcast_mut::()) + else { + return IotaInteraction::Unhandled; + }; + match event.kind { + MouseEventKind::ScrollUp => log.scroll_older(), + MouseEventKind::ScrollDown => log.scroll_newer(), + _ => return IotaInteraction::Unhandled, + } + self.unfocus_current(); + self.focus = MainFocus::Logs; + self.focus_current(); + return IotaInteraction::Changed; + } if let UiEvent::Paste(text) = &event { - if self.selected_coords == (2, 0) { - if let Some(console) = self + if self.focus == MainFocus::Console + && let Some(console) = self .elements .get_mut(1) .and_then(|element| element.as_any_mut().downcast_mut::()) - { - console.handle_paste(text); - return InteractionResult::Handled; - } + { + console.handle_paste(text); + return IotaInteraction::Changed; } - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; } - if let UiEvent::Resize(width, _) = &event { + if let UiEvent::Resize { width, .. } = &event { self.layout_width.store(*width, Ordering::Relaxed); - if *width < 70 && self.selected_coords.1 == 1 { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 0); + if *width < 70 && self.focus.is_metric() { + self.unfocus_current(); + self.focus = MainFocus::Logs; self.focus_current(); } - return InteractionResult::Handled; + return IotaInteraction::Changed; } let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; // A focused console consumes text and cursor keys before dashboard // shortcuts; commands such as `users` must remain typeable. - if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab) + if self.focus == MainFocus::Console + && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab) + && let Some(console) = self.elements.get_mut(1) { - if let Some(console) = self.elements.get_mut(1) { - return console.interact(event); - } + return console.interact(event); } match event.code { KeyCode::Tab => { self.navigate_focus(true); - return InteractionResult::Handled; + return IotaInteraction::Changed; } KeyCode::BackTab => { self.navigate_focus(false); - return InteractionResult::Handled; + return IotaInteraction::Changed; } KeyCode::Char('o') | KeyCode::Char('O') => { - let conn_rx = self.connection_status_rx.clone(); - let daemon_rx = self.daemon_status_rx.clone(); - return InteractionResult::OpenScreen { - screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)), - }; + return IotaInteraction::Effect(IotaEffect::OpenOverview); } KeyCode::Char('u') | KeyCode::Char('U') => { - return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(crate::screens::screens::AppEvent::OpenUsers) - }), - }; + return IotaInteraction::Effect(IotaEffect::OpenUsers); } KeyCode::Char('m') | KeyCode::Char('M') => { - return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics) - }), - }; + return IotaInteraction::Effect(IotaEffect::OpenMetrics); } - KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { + KeyCode::Enter | KeyCode::Char(' ') if self.focus.is_metric() => { self.graphs_open = !self.graphs_open; for element in self.elements.iter_mut() { if let Some(graph) = element.as_any_mut().downcast_mut::() { graph.set_open(self.graphs_open); } } - return InteractionResult::Handled; + return IotaInteraction::Changed; } _ => { - let (y, x) = self.selected_coords; - if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) { - if let Some(el) = self.elements.get_mut(*index) { - let result = el.interact(event); - if matches!(result, InteractionResult::Unhandled) { - match event.code { - KeyCode::Up => self.navigate(NavDirection::Up), - KeyCode::Down => self.navigate(NavDirection::Down), - KeyCode::Left => self.navigate(NavDirection::Left), - KeyCode::Right => self.navigate(NavDirection::Right), - _ => {} - } + if let Some(el) = self.elements.get_mut(self.focus.element_index()) { + let result = el.interact(event); + if matches!(result, IotaInteraction::Unhandled) { + match event.code { + KeyCode::Up => self.navigate(NavDirection::Up), + KeyCode::Down => self.navigate(NavDirection::Down), + KeyCode::Left => self.navigate(NavDirection::Left), + KeyCode::Right => self.navigate(NavDirection::Right), + _ => {} } - return result; } + return result; } } } - InteractionResult::Handled + IotaInteraction::Changed } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { + fn handle_action(&mut self, action: AppAction) -> IotaInteraction { match action { AppAction::ToggleMetrics => { self.graphs_open = !self.graphs_open; @@ -419,37 +359,33 @@ impl Screen for MainScreen { graph.set_open(self.graphs_open); } } - InteractionResult::Handled - } - AppAction::OpenOverview => { - self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o')))) - } - AppAction::OpenUsers => { - self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u')))) + IotaInteraction::Changed } + AppAction::OpenOverview => IotaInteraction::Effect(IotaEffect::OpenOverview), + AppAction::OpenUsers => IotaInteraction::Effect(IotaEffect::OpenUsers), AppAction::FocusLogs => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 0); + self.unfocus_current(); + self.focus = MainFocus::Logs; self.focus_current(); - InteractionResult::Handled + IotaInteraction::Changed } AppAction::FocusConsole => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (2, 0); + self.unfocus_current(); + self.focus = MainFocus::Console; self.focus_current(); - InteractionResult::Handled + IotaInteraction::Changed } AppAction::FocusMetrics => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 1); + self.unfocus_current(); + self.focus = MainFocus::Ram; self.focus_current(); - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } fn key_hints(&self) -> Vec { - if self.selected_coords == (2, 0) { + if self.focus == MainFocus::Console { vec![ KeyHint { keys: "Enter", @@ -468,7 +404,7 @@ impl Screen for MainScreen { action: "Header", }, ] - } else if self.selected_coords == (0, 0) { + } else if self.focus == MainFocus::Logs { vec![ KeyHint { keys: "J/K", diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index 8c46d3d..8eae8cc 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -1,18 +1,13 @@ -use crossterm::event::{self, Event, KeyCode}; +use crate::{ + screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent}, + theme::{ResolvedTheme, TextSemantics}, +}; +use crossterm::event::KeyCode; use ratatui::{ - DefaultTerminal, prelude::*, text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; -use std::{any::Any, time::Duration}; - -use crate::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, - theme::{ResolvedTheme, TextSemantics, ThemeName}, -}; pub struct FileViewer { title: String, @@ -21,25 +16,19 @@ pub struct FileViewer { scroll_x: u16, } -impl Screen for FileViewer { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self +impl IotaScreen for FileViewer { + fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) { + self.draw(f, rect, theme); } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - self.draw(f, rect, context.theme); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; match event.code { KeyCode::Char('q') | KeyCode::Esc => { - return InteractionResult::CloseScreen; + return IotaInteraction::Pop; } KeyCode::Down => self.scroll = self.scroll.saturating_add(1), @@ -52,7 +41,7 @@ impl Screen for FileViewer { _ => {} } - InteractionResult::Unhandled + IotaInteraction::Unhandled } } @@ -65,26 +54,6 @@ impl FileViewer { scroll_x: 0, } } - pub fn force_popup(mut self, mut terminal: DefaultTerminal) -> DefaultTerminal { - loop { - terminal - .draw(|f| { - let area = f.area(); - self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi)); - }) - .unwrap(); - - if event::poll(Duration::from_millis(100)).unwrap() { - let ev = event::read().unwrap(); - self.handle_event(&ev); - - if matches!(ev, Event::Key(k) if k.code == KeyCode::Char('q')) { - break; - } - } - } - terminal - } fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) { use ratatui::text::Text; @@ -147,20 +116,6 @@ impl FileViewer { f.render_widget(paragraph, area); } - - pub fn handle_event(&mut self, event: &Event) { - if let Event::Key(key) = event { - match key.code { - KeyCode::Down => self.scroll = self.scroll.saturating_add(1), - KeyCode::Up => self.scroll = self.scroll.saturating_sub(1), - KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10), - KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10), - KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2), - KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2), - _ => {} - } - } - } } fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { let mut lines_vec = Vec::new(); diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs index 1243979..1eb9b2e 100644 --- a/iota-cli/src/screens/metrics.rs +++ b/iota-cli/src/screens/metrics.rs @@ -1,5 +1,3 @@ -use std::any::Any; - use crossterm::event::KeyCode; use ratatui::{ Frame, @@ -7,15 +5,15 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; +use crate::theme::ResolvedTheme; use crate::{ elements::{ elements::Element, graph_card::{GRAPHS, GraphCard}, }, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, - ui::UI, + screens::screens::{ + AppAction, HitMap, IotaInteraction, IotaMessage, IotaScreen, KeyHint, UiEvent, + }, }; const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")]; @@ -26,18 +24,17 @@ pub struct MetricsScreen { } impl MetricsScreen { - pub async fn new(ui: std::sync::Arc) -> Option { - let state = ui.client_state().await?; + pub fn from_state(state: iota_state::ClientState) -> Self { let mut screen = Self { graphs: vec![ - GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()), - GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()), - GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()), + GraphCard::new(state.clone(), GRAPHS::Ram, "RAM".into()), + GraphCard::new(state.clone(), GRAPHS::Cpu, "CPU".into()), + GraphCard::new(state, GRAPHS::Ping, "Ping".into()), ], range_index: 0, }; screen.apply_range(); - Some(screen) + screen } fn apply_range(&mut self) { @@ -54,26 +51,18 @@ impl MetricsScreen { } } -impl Screen for MetricsScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render( +impl IotaScreen for MetricsScreen { + fn render_legacy( &self, frame: &mut Frame, area: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, hits: &mut HitMap, ) { let block = Block::default() .title(" Metrics ") .borders(Borders::ALL) - .border_style(context.theme.borders.normal); + .border_style(theme.borders.normal); let inner = block.inner(area); frame.render_widget(block, area); let rows = Layout::vertical([ @@ -88,32 +77,31 @@ impl Screen for MetricsScreen { "Range: {} ({} samples) Left/Right to change", RANGES[self.range_index].1, RANGES[self.range_index].0 )) - .style(context.theme.text.heading), + .style(theme.text.heading), rows[0], ); for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) { - graph.render(frame, *graph_area, context); + graph.render(frame, *graph_area, theme); } - hits.register(rows[0], AppAction::OpenMetrics); + hits.register(rows[0], IotaMessage::Action(AppAction::OpenMetrics)); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; match key.code { KeyCode::Left => { self.change_range(-1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Right => { self.change_range(1); - InteractionResult::Handled + IotaInteraction::Changed } - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - _ => InteractionResult::Unhandled, + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop, + _ => IotaInteraction::Unhandled, } } diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs index 6f32393..cf2b3a5 100644 --- a/iota-cli/src/screens/overview.rs +++ b/iota-cli/src/screens/overview.rs @@ -1,9 +1,10 @@ +use crate::theme::ResolvedTheme; use crate::{ controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, ipc_client::{DaemonStatus, IpcConnectionState}, - render_context::RenderContext, - screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, + screens::screens::{ + AppAction, HitMap, IotaInteraction, IotaMessage, IotaScreen, KeyHint, UiEvent, + }, }; use crossterm::event::KeyCode; use ratatui::{ @@ -12,10 +13,7 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Wrap}, }; -use std::{ - any::Any, - sync::atomic::{AtomicUsize, Ordering}, -}; +use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::watch; pub struct OverviewScreen { @@ -175,22 +173,15 @@ fn truncate_id(id: &str) -> String { } } -impl Screen for OverviewScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { +impl IotaScreen for OverviewScreen { + fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) { let block = Block::default() .title(" Overview ") .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) + .border_style(theme.borders.normal) + .title_style(theme.borders.title); + let inner = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) { + crate::controls::panel::render_panel(f, rect, "Overview", false, theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); @@ -202,7 +193,7 @@ impl Screen for OverviewScreen { ]) .split(inner); - let lines = self.build_lines(context.theme); + let lines = self.build_lines(theme); self.content_height.store(lines.len(), Ordering::Relaxed); self.viewport_height .store(rows[0].height as usize, Ordering::Relaxed); @@ -219,30 +210,29 @@ impl Screen for OverviewScreen { focused: self._focus == Focus::Back, enabled: true, }, - context.theme, + theme, ); - _hits.register(rows[1], AppAction::Back); + _hits.register(rows[1], IotaMessage::Action(AppAction::Back)); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; match event.code { - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop, KeyCode::Down | KeyCode::Char('j') => { let max = self .content_height .load(Ordering::Relaxed) .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Up | KeyCode::Char('k') => { self.scroll_offset = self.scroll_offset.saturating_sub(1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::PageDown => { let page = self.viewport_height.load(Ordering::Relaxed).max(1); @@ -251,32 +241,32 @@ impl Screen for OverviewScreen { .load(Ordering::Relaxed) .saturating_sub(page); self.scroll_offset = self.scroll_offset.saturating_add(page).min(max); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::PageUp => { let page = self.viewport_height.load(Ordering::Relaxed).max(1); self.scroll_offset = self.scroll_offset.saturating_sub(page); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Home => { self.scroll_offset = 0; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::End => { self.scroll_offset = self .content_height .load(Ordering::Relaxed) .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { + fn handle_action(&mut self, action: AppAction) -> IotaInteraction { if action == AppAction::Back { - InteractionResult::CloseScreen + IotaInteraction::Pop } else { - InteractionResult::Unhandled + IotaInteraction::Unhandled } } fn key_hints(&self) -> Vec { diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index a5e658c..8dd6c8e 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -1,45 +1,21 @@ -use std::any::Any; +use std::{fmt, path::PathBuf}; -use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::{Frame, layout::Rect}; -use crate::{interaction_result::InteractionResult, render_context::RenderContext}; +use crate::theme::{CliOutputFormat, ResolvedTheme, TerminalPolicy, ThemeName}; -/// All terminal input that can affect the UI. Keeping this as one type makes -/// it impossible for screens to accidentally ignore a newly supported event. -#[derive(Debug, Clone)] -pub enum UiEvent { - Key(KeyEvent), - Mouse(MouseEvent), - Paste(String), - Resize(u16, u16), - App(AppEvent), -} - -/// Completion of background UI work. Keeping it in the regular event stream -/// gives screens an explicit success/failure path instead of detached tasks. #[derive(Debug, Clone)] pub enum AppEvent { - OpenUsers, - OpenMetrics, - ApplyTheme { - theme: crate::theme::ThemeName, - persist: bool, - }, - SaveSettings { - theme: crate::theme::ThemeName, - color: crate::theme::TerminalPolicy, - unicode: crate::theme::TerminalPolicy, - cli_output: crate::theme::CliOutputFormat, - cli_require_confirmation: bool, - }, ThemeSaved(Result<(), String>), UsersLoaded(Result, String>), TuInspected(Result), UserOperationFinished(Result), CredentialExportFinished(Result), - RegenerateKeysRequested, KeysRegenerated(Result<(), String>), + TermsLoaded { + kind: iota_terms::TermsType, + result: Result, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -65,61 +41,122 @@ pub enum AppAction { RegenerateKeys, } +#[derive(Clone, Debug)] +pub enum IotaEffect { + OpenUsers, + OpenMetrics, + OpenOverview, + ApplyTheme { + theme: ThemeName, + persist: bool, + }, + SaveSettings { + theme: ThemeName, + color: TerminalPolicy, + unicode: TerminalPolicy, + cli_output: CliOutputFormat, + cli_require_confirmation: bool, + }, + RegenerateKeys, + InspectTu { + credential: iota_ipc::SecretString, + }, + AttachUserFromTu { + credential: iota_ipc::SecretString, + }, + RunUserOperation { + action: crate::screens::users::model::UserAction, + user_id: i64, + }, + ExportCredential { + user_id: i64, + destination: PathBuf, + }, + CreateUser { + username: String, + }, + CreateInvitation { + authority: iota_ipc::InvitationAuthority, + }, + RefreshUsers, + LoadNewestTerms { + kind: iota_terms::TermsType, + }, +} + +#[derive(Clone, Debug)] +pub enum IotaMessage { + Action(AppAction), + Effect(IotaEffect), + Event(AppEvent), + Invalidate, +} + +pub type UiEvent = ratatool::UiEvent; +pub type HitMap = ratatool::HitMap; +pub type NavDirection = ratatool::Direction; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct KeyHint { pub keys: &'static str, pub action: &'static str, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HitRegion { - pub area: Rect, - pub action: AppAction, +pub enum IotaInteraction { + Unhandled, + Changed, + Push { + screen: Box, + presentation: ratatool::ScreenPresentation, + }, + Pop, + Effect(IotaEffect), + Exit, } -#[derive(Debug, Default, Clone)] -pub struct HitMap { - regions: Vec, -} - -impl HitMap { - pub fn register(&mut self, area: Rect, action: AppAction) { - self.regions.push(HitRegion { area, action }); - } - pub fn action_at(&self, column: u16, row: u16) -> Option { - self.regions - .iter() - .rev() - .find(|region| { - column >= region.area.x - && column < region.area.x.saturating_add(region.area.width) - && row >= region.area.y - && row < region.area.y.saturating_add(region.area.height) - }) - .map(|region| region.action) +impl fmt::Debug for IotaInteraction { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unhandled => formatter.write_str("Unhandled"), + Self::Changed => formatter.write_str("Changed"), + Self::Push { presentation, .. } => { + formatter.debug_tuple("Push").field(presentation).finish() + } + Self::Pop => formatter.write_str("Pop"), + Self::Effect(effect) => formatter.debug_tuple("Effect").field(effect).finish(), + Self::Exit => formatter.write_str("Exit"), + } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NavDirection { - Up, - Down, - Left, - Right, - - Next, - Prev, -} - -pub trait Screen: Send + Sync + Any { - fn as_any(&self) -> &dyn Any; - fn as_any_mut(&mut self) -> &mut dyn Any; - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap); - fn handle_event(&mut self, event: UiEvent) -> InteractionResult; - fn handle_action(&mut self, _action: AppAction) -> InteractionResult { - InteractionResult::Unhandled +pub trait IotaScreen: Send { + fn render( + &mut self, + ui: &mut ratatool::UiFrame<'_, '_, IotaMessage>, + area: Rect, + theme: &ResolvedTheme, + ) { + let mut hits = HitMap::new(); + self.render_legacy(ui.frame(), area, theme, &mut hits); + for region in hits.regions() { + ui.hit_map().register(region.area, region.action.clone()); + } } + + fn render_legacy( + &self, + frame: &mut Frame, + area: Rect, + theme: &ResolvedTheme, + hits: &mut HitMap, + ); + + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction; + + fn handle_action(&mut self, _action: AppAction) -> IotaInteraction { + IotaInteraction::Unhandled + } + fn key_hints(&self) -> Vec { vec![ KeyHint { @@ -141,3 +178,7 @@ pub trait Screen: Send + Sync + Any { ] } } + +pub fn action_message(action: AppAction) -> IotaMessage { + IotaMessage::Action(action) +} diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index a0a37d8..f39f9e1 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -1,5 +1,3 @@ -use std::any::Any; - use crossterm::event::KeyCode; use ratatui::{ Frame, @@ -8,11 +6,13 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; +use crate::theme::ResolvedTheme; use crate::{ controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, + screens::screens::{ + AppAction, AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint, + UiEvent, + }, theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig}, }; @@ -68,11 +68,9 @@ impl SettingsScreen { ThemeName::ALL[self.selected] } - fn apply(&self, persist: bool) -> InteractionResult { + fn apply(&self, persist: bool) -> IotaInteraction { let theme = self.selected_theme(); - InteractionResult::AppTask { - task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }), - } + IotaInteraction::Effect(IotaEffect::ApplyTheme { theme, persist }) } fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { @@ -103,18 +101,16 @@ impl SettingsScreen { }; } - fn activate(&mut self) -> InteractionResult { + fn activate(&mut self) -> IotaInteraction { if self.pending { - return InteractionResult::Handled; + return IotaInteraction::Changed; } if let Some(dialog) = self.dialog.take() { match dialog { Dialog::ConfirmRegenerateKeys => { self.pending = true; self.message = "Regenerating keys…".into(); - return InteractionResult::AppTask { - task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }), - }; + return IotaInteraction::Effect(IotaEffect::RegenerateKeys); } } } @@ -124,7 +120,7 @@ impl SettingsScreen { } Focus::CliOutput => { self.message = "Output format updated.".into(); - return InteractionResult::Handled; + return IotaInteraction::Changed; } Focus::CliConfirm => { self.cli_require_confirmation = !self.cli_require_confirmation; @@ -136,53 +132,41 @@ impl SettingsScreen { "Off" }, ); - return InteractionResult::Handled; + return IotaInteraction::Changed; } Focus::RegenerateKeys => { self.dialog = Some(Dialog::ConfirmRegenerateKeys); - return InteractionResult::Handled; + return IotaInteraction::Changed; } - Focus::Back => return InteractionResult::CloseScreen, + Focus::Back => return IotaInteraction::Pop, } let theme = self.selected_theme(); let color = self.color; let unicode = self.unicode; let cli_output = self.cli_output; let cli_require_confirmation = self.cli_require_confirmation; - InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - cli_output, - cli_require_confirmation, - }) - }), - } + IotaInteraction::Effect(IotaEffect::SaveSettings { + theme, + color, + unicode, + cli_output, + cli_require_confirmation, + }) } } -impl Screen for SettingsScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render( +impl IotaScreen for SettingsScreen { + fn render_legacy( &self, frame: &mut Frame, area: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, hits: &mut HitMap, ) { let header_block = Block::default() .title(" Settings ") .borders(Borders::ALL) - .border_style(context.theme.borders.focused); + .border_style(theme.borders.focused); let inner = header_block.inner(area); frame.render_widget(header_block, area); @@ -200,7 +184,7 @@ impl Screen for SettingsScreen { self.color, self.unicode, )) - .style(context.theme.text.heading), + .style(theme.text.heading), sections[0], ); @@ -218,8 +202,8 @@ impl Screen for SettingsScreen { Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]); let lines = vec![ - Line::from(Span::styled(&self.message, context.theme.text.normal)), - Line::from(Span::styled(&cli_line, context.theme.text.normal)), + Line::from(Span::styled(&self.message, theme.text.normal)), + Line::from(Span::styled(&cli_line, theme.text.normal)), Line::from("Preview"), Line::from("[OK] Healthy"), Line::from("[WARN] Degraded"), @@ -227,7 +211,7 @@ impl Screen for SettingsScreen { Line::from("> Focused action <"), ]; frame.render_widget( - Paragraph::new(lines).style(context.theme.text.normal), + Paragraph::new(lines).style(theme.text.normal), bottom_rows[0], ); @@ -247,9 +231,9 @@ impl Screen for SettingsScreen { focused: self.focus == Focus::Back && self.dialog.is_none(), enabled: true, }, - context.theme, + theme, ); - hits.register(buttons_area[0], AppAction::Back); + hits.register(buttons_area[0], IotaMessage::Action(AppAction::Back)); render_button( frame, @@ -260,15 +244,18 @@ impl Screen for SettingsScreen { focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(), enabled: !self.pending, }, - context.theme, + theme, + ); + hits.register( + buttons_area[1], + IotaMessage::Action(AppAction::RegenerateKeys), ); - hits.register(buttons_area[1], AppAction::RegenerateKeys); if self.dialog.is_some() { - frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area); - let popup = crate::layout::fit::centered_rect( + frame.render_widget(Block::default().style(theme.surfaces.overlay), area); + let popup = ratatool::layout::centered_rect( area, - crate::layout::fit::RequiredSize { + ratatool::layout::RequiredSize { width: 42, height: 7, }, @@ -276,14 +263,14 @@ impl Screen for SettingsScreen { let block = Block::default() .title(" Confirm ") .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); + .border_style(theme.borders.focused) + .style(theme.surfaces.overlay); let popup_inner = block.inner(popup); frame.render_widget(block, popup); let dialog_rows = Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); frame.render_widget( - Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal), + Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(theme.text.normal), dialog_rows[0], ); let dialog_buttons = @@ -298,7 +285,7 @@ impl Screen for SettingsScreen { focused: false, enabled: true, }, - context.theme, + theme, ); render_button( frame, @@ -309,16 +296,23 @@ impl Screen for SettingsScreen { focused: true, enabled: true, }, - context.theme, + theme, + ); + hits.register( + dialog_buttons[0], + IotaMessage::Action(AppAction::CancelDialog), + ); + hits.register( + dialog_buttons[1], + IotaMessage::Action(AppAction::ConfirmDialog), ); - hits.register(dialog_buttons[0], AppAction::CancelDialog); - hits.register(dialog_buttons[1], AppAction::ConfirmDialog); } } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let event = match event { - UiEvent::App(AppEvent::ThemeSaved(result)) => { + UiEvent::Message(IotaMessage::Event(AppEvent::ThemeSaved(result))) => { match result { Ok(()) => { self.saved = self.selected_theme(); @@ -326,36 +320,36 @@ impl Screen for SettingsScreen { } Err(error) => self.message = error, } - return InteractionResult::Handled; + return IotaInteraction::Changed; } - UiEvent::App(AppEvent::KeysRegenerated(result)) => { + UiEvent::Message(IotaMessage::Event(AppEvent::KeysRegenerated(result))) => { self.pending = false; self.dialog = None; match result { Ok(()) => self.message = "Keys regenerated successfully.".into(), Err(error) => self.message = error, } - return InteractionResult::Handled; + return IotaInteraction::Changed; } event => event, }; if self.dialog.is_some() { let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; return match key.code { KeyCode::Esc => { self.dialog = None; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => self.activate(), - _ => InteractionResult::Handled, + _ => IotaInteraction::Changed, }; } let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; match key.code { KeyCode::Left => { @@ -363,7 +357,7 @@ impl Screen for SettingsScreen { self.selected = self.selected.saturating_sub(1); self.apply(false) } else { - InteractionResult::Handled + IotaInteraction::Changed } } KeyCode::Right => { @@ -371,30 +365,30 @@ impl Screen for SettingsScreen { self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1); self.apply(false) } else { - InteractionResult::Handled + IotaInteraction::Changed } } KeyCode::Enter | KeyCode::Char(' ') => self.activate(), KeyCode::Tab => { self.next_focus(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::BackTab => { self.prev_focus(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('c') | KeyCode::Char('C') => { self.color = Self::next_policy(self.color); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('u') | KeyCode::Char('U') => { self.unicode = Self::next_policy(self.unicode); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('l') | KeyCode::Char('L') => { self.cli_output = self.cli_output.next(); self.message = format!("CLI output: {:?}", self.cli_output); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('k') | KeyCode::Char('K') => { self.cli_require_confirmation = !self.cli_require_confirmation; @@ -406,18 +400,16 @@ impl Screen for SettingsScreen { "Off" }, ); - InteractionResult::Handled + IotaInteraction::Changed } - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - _ => InteractionResult::Unhandled, + KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop, + _ => IotaInteraction::Unhandled, } } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { + fn handle_action(&mut self, action: AppAction) -> IotaInteraction { match action { - AppAction::Back => InteractionResult::CloseScreen, + AppAction::Back => IotaInteraction::Pop, AppAction::RegenerateKeys => { self.focus = Focus::RegenerateKeys; self.activate() @@ -425,9 +417,9 @@ impl Screen for SettingsScreen { AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), AppAction::CancelDialog if self.dialog.is_some() => { self.dialog = None; - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index ace6628..6e0d99f 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -1,10 +1,9 @@ +use crate::theme::ResolvedTheme; use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, - interaction_result::InteractionResult, - render_context::RenderContext, screens::{ md_viewer::FileViewer, - screens::{HitMap, Screen, UiEvent}, + screens::{HitMap, IotaInteraction, IotaScreen, UiEvent}, }, util::{buttons::draw_buttons, terms_focus::Focus}, }; @@ -16,7 +15,6 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; -use std::{any::Any, pin::Pin}; use tokio::sync::oneshot; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -50,15 +48,8 @@ impl TermsCheckerScreen { } } -impl Screen for TermsCheckerScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { +impl IotaScreen for TermsCheckerScreen { + fn render_legacy(&self, f: &mut Frame, size: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -176,7 +167,7 @@ impl Screen for TermsCheckerScreen { focused: self.focus == Focus::Eula, enabled: true, }, - context.theme, + theme, ), render_choice_line( tos_text, @@ -186,7 +177,7 @@ impl Screen for TermsCheckerScreen { focused: self.focus == Focus::Tos, enabled: self.eula, }, - context.theme, + theme, ), render_choice_line( pp_text, @@ -196,7 +187,7 @@ impl Screen for TermsCheckerScreen { focused: self.focus == Focus::Pp, enabled: self.eula, }, - context.theme, + theme, ), Line::from(""), Line::from("¹ Necessary– required to run the program"), @@ -217,19 +208,19 @@ impl Screen for TermsCheckerScreen { if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - context.theme.status.success + theme.status.success } else if size.width >= 60 { - context.theme.status.warning + theme.status.warning } else { - context.theme.status.error + theme.status.error }; let height_style = if size.height > 19 { - context.theme.status.success + theme.status.success } else if size.height >= 13 { - context.theme.status.warning + theme.status.warning } else { - context.theme.status.error + theme.status.error }; let warning_text = Text::from(vec![ @@ -271,13 +262,14 @@ impl Screen for TermsCheckerScreen { true, false, true, - context.theme, + theme, ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; @@ -293,21 +285,21 @@ impl Screen for TermsCheckerScreen { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::Deny); } - InteractionResult::CloseScreen + IotaInteraction::Pop } KeyCode::Up | KeyCode::Left => { self.focus.prev(&possible_states); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Down | KeyCode::Right | KeyCode::Tab => { self.focus.next(&possible_states); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('q') | KeyCode::Char('Q') => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::Deny); } - InteractionResult::CloseScreen + IotaInteraction::Pop } KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { @@ -322,77 +314,75 @@ impl Screen for TermsCheckerScreen { .iter() .find(|document| document.kind() == terms_type) .cloned(); - let fut: Pin> + Send>> = - Box::pin(async move { - if let Some(document) = document { - let screen: FileViewer = - FileViewer::new(terms_type.to_string(), document.content()); - Box::new(screen) as Box - } else { - let screen: FileViewer = FileViewer::new( - "Error".to_string(), - "The fetched legal document is unavailable.", - ); - Box::new(screen) as Box - } - }); - InteractionResult::OpenFutureScreen { screen: fut } + let screen = match document { + Some(document) => { + FileViewer::new(terms_type.to_string(), document.content()) + } + None => FileViewer::new( + "Error".to_string(), + "The fetched legal document is unavailable.", + ), + }; + IotaInteraction::Push { + screen: Box::new(screen), + presentation: ratatool::ScreenPresentation::Replace, + } } else { - InteractionResult::Unhandled + IotaInteraction::Unhandled } } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { let _ = open::that(get_link(TermsType::EULA)); - InteractionResult::Handled + IotaInteraction::Changed } Focus::Tos => { let _ = open::that(get_link(TermsType::TOS)); - InteractionResult::Handled + IotaInteraction::Changed } Focus::Pp => { let _ = open::that(get_link(TermsType::PP)); - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, }, KeyCode::Enter | KeyCode::Char(' ') => match self.focus { Focus::Eula => { self.eula = !self.eula; self.tos = false; self.pp = false; - InteractionResult::Handled + IotaInteraction::Changed } Focus::Tos if self.eula => { self.tos = !self.tos; - InteractionResult::Handled + IotaInteraction::Changed } Focus::Pp if self.eula => { self.pp = !self.pp; - InteractionResult::Handled + IotaInteraction::Changed } Focus::Cancel => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::Deny); } - InteractionResult::CloseScreen + IotaInteraction::Pop } Focus::Continue if self.eula => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::AcceptEULA); } - InteractionResult::CloseScreen + IotaInteraction::Pop } Focus::ContinueAll if self.eula && self.tos && self.pp => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::AcceptAll); } - InteractionResult::CloseScreen + IotaInteraction::Pop } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, }, - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } } diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index 21e3247..6995881 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -1,24 +1,24 @@ use crate::screens::terms_checker::UserChoice; +use crate::theme::ResolvedTheme; use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, - interaction_result::InteractionResult, - render_context::RenderContext, screens::{ md_viewer::FileViewer, - screens::{HitMap, Screen, UiEvent}, + screens::{ + AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, UiEvent, + }, }, util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; use crossterm::event::KeyCode; -use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; +use iota_terms::{Doc, TermsType, get_newest_link}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph}, }; -use std::any::Any; use tokio::sync::oneshot; #[derive(Debug, Clone)] @@ -115,16 +115,8 @@ impl TermsUpdaterScreen { } } } -impl Screen for TermsUpdaterScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { +impl IotaScreen for TermsUpdaterScreen { + fn render_legacy(&self, f: &mut Frame, size: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) { let checkbox = |label, selected, focused, enabled| { render_choice_line( label, @@ -134,7 +126,7 @@ impl Screen for TermsUpdaterScreen { focused, enabled, }, - context.theme, + theme, ) }; let mut needed_height = 5; @@ -536,19 +528,19 @@ impl Screen for TermsUpdaterScreen { }; if size.width < 60 || size.height < needed_height as u16 { let width_style = if size.width > 76 { - context.theme.status.success + theme.status.success } else if size.width >= 60 { - context.theme.status.warning + theme.status.warning } else { - context.theme.status.error + theme.status.error }; let height_style = if size.height > 20 { - context.theme.status.success + theme.status.success } else if size.height >= (header_lines as u16 + 10) { - context.theme.status.warning + theme.status.warning } else { - context.theme.status.error + theme.status.error }; let warning_text = Text::from(vec![ @@ -592,13 +584,27 @@ impl Screen for TermsUpdaterScreen { self.update_needed, downgrade_scenario, self.pp_needed || self.tos_needed, - context.theme, + theme, ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); + let event = match event { + UiEvent::Message(IotaMessage::Event(AppEvent::TermsLoaded { kind, result })) => { + let screen = match result { + Ok(content) => FileViewer::new(kind.to_string(), &content), + Err(error) => FileViewer::new("Error".to_string(), &error), + }; + return IotaInteraction::Push { + screen: Box::new(screen), + presentation: ratatool::ScreenPresentation::Replace, + }; + } + event => event, + }; let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; }; let mut possible_states = Vec::new(); @@ -626,42 +632,42 @@ impl Screen for TermsUpdaterScreen { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::Deny); } - return InteractionResult::CloseScreen; + return IotaInteraction::Pop; } KeyCode::Enter | KeyCode::Char(' ') => match self.focus { Focus::Eula => { self.eula = !self.eula; self.tos = !self.tos_needed; self.pp = !self.pp_needed; - InteractionResult::Handled + IotaInteraction::Changed } Focus::Tos if self.eula => { self.tos = !self.tos; - InteractionResult::Handled + IotaInteraction::Changed } Focus::Pp if self.eula => { self.pp = !self.pp; - InteractionResult::Handled + IotaInteraction::Changed } Focus::Cancel => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::Deny); } - InteractionResult::CloseScreen + IotaInteraction::Pop } Focus::Continue if self.eula => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::AcceptEULA); } - InteractionResult::CloseScreen + IotaInteraction::Pop } Focus::ContinueAll if self.eula && self.tos && self.pp => { if let Some(sender) = self.sender.take() { let _ = sender.send(UserChoice::AcceptAll); } - InteractionResult::CloseScreen + IotaInteraction::Pop } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, }, KeyCode::Char('o') | KeyCode::Char('O') => { let terms_type = match self.focus { @@ -672,47 +678,37 @@ impl Screen for TermsUpdaterScreen { }; if let Some(terms_type) = terms_type { - let fut = Box::pin(async move { - if let Some(content) = get_terms(terms_type.clone()).await { - Box::new(FileViewer::new(terms_type.to_string(), &content)) - as Box - } else { - Box::new(FileViewer::new( - "Error".to_string(), - "Could not connect to the legal endpoint to fetch the document. Please check your internet connection.", - )) as Box - } + return IotaInteraction::Effect(IotaEffect::LoadNewestTerms { + kind: terms_type, }); - - return InteractionResult::OpenFutureScreen { screen: fut }; } else { - InteractionResult::Unhandled + IotaInteraction::Unhandled } } KeyCode::Char('l') | KeyCode::Char('L') => match self.focus { Focus::Eula => { let _ = open::that(get_newest_link(TermsType::EULA)); - InteractionResult::Handled + IotaInteraction::Changed } Focus::Tos => { let _ = open::that(get_newest_link(TermsType::TOS)); - InteractionResult::Handled + IotaInteraction::Changed } Focus::Pp => { let _ = open::that(get_newest_link(TermsType::PP)); - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, }, KeyCode::Up | KeyCode::Left => { self.focus.prev(&possible_states); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Down | KeyCode::Right | KeyCode::Tab => { self.focus.next(&possible_states); - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } } diff --git a/iota-cli/src/screens/users/action_menu.rs b/iota-cli/src/screens/users/action_menu.rs index 3baf2d0..70f01a4 100644 --- a/iota-cli/src/screens/users/action_menu.rs +++ b/iota-cli/src/screens/users/action_menu.rs @@ -1,5 +1,5 @@ use super::model::{UserAction, UserActionGroup}; -use crate::controls::menu::{MenuItem, MenuState}; +use ratatool::{MenuItem, MenuState}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct UserActionMenu { diff --git a/iota-cli/src/screens/users/add_flow.rs b/iota-cli/src/screens/users/add_flow.rs index 1b697b0..f238584 100644 --- a/iota-cli/src/screens/users/add_flow.rs +++ b/iota-cli/src/screens/users/add_flow.rs @@ -1,7 +1,4 @@ -use crate::controls::{ - menu::{MenuItem, MenuState}, - text_input::TextInput, -}; +use ratatool::{MenuItem, MenuState, TextInputState}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AddUserMethod { @@ -26,8 +23,8 @@ pub struct AddUserFlow { pub phase: AddUserPhase, pub methods: MenuState, pub invitation_authorities: MenuState, - pub username: TextInput, - pub import_path: TextInput, + pub username: TextInputState, + pub import_path: TextInputState, pub credential: Option, pub preview: Option, pub error: Option, @@ -77,8 +74,8 @@ impl AddUserFlow { disabled_reason: Some("Local invitations are not implemented yet".into()), }, ]), - username: TextInput::new("Username"), - import_path: TextInput::new("TU path"), + username: TextInputState::default(), + import_path: TextInputState::default(), credential: None, preview: None, error: None, diff --git a/iota-cli/src/screens/users/credential_export.rs b/iota-cli/src/screens/users/credential_export.rs index 390dbf4..a65b8e9 100644 --- a/iota-cli/src/screens/users/credential_export.rs +++ b/iota-cli/src/screens/users/credential_export.rs @@ -1,5 +1,5 @@ use super::UserEntry; -use crate::controls::text_input::TextInput; +use ratatool::TextInputState; use std::{ fs::{self, OpenOptions}, io::{self, Write}, @@ -9,7 +9,7 @@ use std::{ #[derive(Clone, Debug)] pub struct CredentialExportState { pub user: UserEntry, - pub destination: TextInput, + pub destination: TextInputState, pub error: Option, } @@ -17,7 +17,7 @@ impl CredentialExportState { pub fn new(user: UserEntry) -> Self { Self { user, - destination: TextInput::new("Destination"), + destination: TextInputState::default(), error: None, } } diff --git a/iota-cli/src/screens/users/invitations.rs b/iota-cli/src/screens/users/invitations.rs index 9f8e29d..17d0be7 100644 --- a/iota-cli/src/screens/users/invitations.rs +++ b/iota-cli/src/screens/users/invitations.rs @@ -13,11 +13,11 @@ pub fn empty_message(supported: bool) -> &'static str { pub fn render( frame: &mut ratatui::Frame, area: ratatui::layout::Rect, - context: &crate::render_context::RenderContext<'_>, + theme: &crate::theme::ResolvedTheme, supported: bool, ) { frame.render_widget( - ratatui::widgets::Paragraph::new(empty_message(supported)).style(context.theme.text.muted), + ratatui::widgets::Paragraph::new(empty_message(supported)).style(theme.text.muted), area, ); } diff --git a/iota-cli/src/screens/users/mod.rs b/iota-cli/src/screens/users/mod.rs index a80d226..da14ca6 100644 --- a/iota-cli/src/screens/users/mod.rs +++ b/iota-cli/src/screens/users/mod.rs @@ -7,17 +7,19 @@ pub mod invitations; pub mod list; pub mod model; +use crate::theme::ResolvedTheme; use crate::{ controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, ipc_client::IpcClient, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, + screens::screens::{ + AppAction, AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint, + UiEvent, + }, }; use action_menu::UserActionMenu; use add_flow::{AddUserFlow, AddUserPhase}; use confirmations::UserConfirmation; -use credential_export::{CredentialExportState, write_private_export}; +use credential_export::CredentialExportState; use crossterm::event::{KeyCode, KeyModifiers}; use model::{FocusZone, UserAction, UserAdminTab, available_action_groups}; use ratatui::{ @@ -26,12 +28,9 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, }; -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicU8, AtomicUsize, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicU8, AtomicUsize, Ordering}, }; #[derive(Clone, Debug)] @@ -150,18 +149,18 @@ impl UsersScreen { .map(available_action_groups) .unwrap_or_default() } - fn begin_action(&mut self, action: UserAction) -> InteractionResult { + fn begin_action(&mut self, action: UserAction) -> IotaInteraction { if action == UserAction::ExportTu && !self.credential_export_supported() { self.message = Some("Credential export is not supported by the connected daemon.".into()); - return InteractionResult::Handled; + return IotaInteraction::Changed; } if let Some(user) = self.selected_user().cloned() { if action == UserAction::ExportTu { self.overlay = Some(UsersOverlay::CredentialExport(CredentialExportState::new( user, ))); - return InteractionResult::Handled; + return IotaInteraction::Changed; } if action.requires_confirmation() { self.overlay = Some(UsersOverlay::Confirm(UserConfirmation { user, action })); @@ -169,7 +168,7 @@ impl UsersScreen { return self.start_operation(action, user.user_id); } } - InteractionResult::Handled + IotaInteraction::Changed } fn credential_export_supported(&self) -> bool { self.ipc @@ -197,290 +196,59 @@ impl UsersScreen { capabilities.iter().any(|value| value == "tu_inspection_v1"), ) } - fn start_tu_inspection(&mut self, credential: iota_ipc::SecretString) -> InteractionResult { - let ipc = self.ipc.clone(); + fn start_tu_inspection(&mut self, credential: iota_ipc::SecretString) -> IotaInteraction { if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() { flow.phase = AddUserPhase::InspectingTu; flow.credential = Some(credential.clone()); flow.error = None; } - InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::InspectTuCredential { credential }) - .await - { - Ok(iota_ipc::ResponseResult::Ok( - iota_ipc::ResponsePayload::TuCredentialPreview(preview), - )) => Ok(preview), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Credential inspection failed: {error}")) - } - Ok(_) => Err( - "Credential inspection failed: daemon returned an unexpected response." - .into(), - ), - Err(error) => Err(format!("Credential inspection failed: {error}")), - }; - UiEvent::App(AppEvent::TuInspected(result)) - }), - } + IotaInteraction::Effect(IotaEffect::InspectTu { credential }) } - fn start_tu_attachment(&mut self, credential: iota_ipc::SecretString) -> InteractionResult { + fn start_tu_attachment(&mut self, credential: iota_ipc::SecretString) -> IotaInteraction { self.pending = true; self.overlay = None; self.message = Some("Attaching existing user…".into()); - let ipc = self.ipc.clone(); - InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::AttachUserFromTu { credential }) - .await - { - Ok(iota_ipc::ResponseResult::Ok(_)) => Ok("Attached existing user.".into()), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Attach existing user failed: {error}")) - } - Err(error) => Err(format!("Attach existing user failed: {error}")), - }; - UiEvent::App(AppEvent::UserOperationFinished(result)) - }), - } + IotaInteraction::Effect(IotaEffect::AttachUserFromTu { credential }) } - fn start_operation(&mut self, action: UserAction, user_id: i64) -> InteractionResult { + fn start_operation(&mut self, action: UserAction, user_id: i64) -> IotaInteraction { self.pending = true; self.overlay = None; self.message = Some(format!("{}…", action.label())); - let ipc = self.ipc.clone(); - InteractionResult::AppTask { - task: Box::pin(async move { - let response = match action { - UserAction::Release => { - ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id }) - .await - } - UserAction::PurgeData => { - ipc.send_request(iota_ipc::LocalRequest::PurgeUserData { user_id }) - .await - } - UserAction::ExportTu => { - return UiEvent::App(AppEvent::UserOperationFinished(Err( - "Credential export requires a destination path.".into(), - ))); - } - UserAction::DeleteAccount => { - ipc.send_request(iota_ipc::LocalRequest::CompleteDeleteUser { - user_id, - credential: None, - }) - .await - } - UserAction::Reconcile => { - ipc.send_request(iota_ipc::LocalRequest::ReconcileUser { user_id }) - .await - } - UserAction::Diagnostics => { - ipc.send_request(iota_ipc::LocalRequest::GetUserDiagnostics { user_id }) - .await - } - UserAction::ForceDetach => { - ipc.send_request(iota_ipc::LocalRequest::ForceDetachUser { user_id }) - .await - } - UserAction::ForgetResidency => { - ipc.send_request(iota_ipc::LocalRequest::ForgetReleasedUser { user_id }) - .await - } - }; - let result = match response { - Ok(iota_ipc::ResponseResult::Ok( - iota_ipc::ResponsePayload::UserDiagnostics(diagnostics), - )) => Ok(format!( - "Diagnostics: state={:?}, data={}, credential={}, trusted apps={}, pending={}", - diagnostics.local_state, - if diagnostics.data_present { - "present" - } else { - "empty" - }, - context::credential_status_label(diagnostics.credential_status), - diagnostics.trusted_app_count, - diagnostics - .pending_operation - .unwrap_or_else(|| "none".into()), - )), - Ok(iota_ipc::ResponseResult::Ok( - iota_ipc::ResponsePayload::UserReconciled(result), - )) => Ok(format!("Reconciliation result: {:?}.", result.action)), - Ok(iota_ipc::ResponseResult::Ok(_)) => { - Ok(format!("{} completed.", action.label())) - } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("{} failed: {error}", action.label())) - } - Err(error) => Err(format!("{} failed: {error}", action.label())), - }; - UiEvent::App(AppEvent::UserOperationFinished(result)) - }), - } + IotaInteraction::Effect(IotaEffect::RunUserOperation { action, user_id }) } fn start_credential_export( &mut self, user_id: i64, destination: std::path::PathBuf, - ) -> InteractionResult { + ) -> IotaInteraction { self.pending = true; self.overlay = None; self.message = Some("Exporting credential…".into()); - let ipc = self.ipc.clone(); - InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::ExportUserCredential { user_id }) - .await - { - Ok(iota_ipc::ResponseResult::Ok( - iota_ipc::ResponsePayload::UserCredentialExport { - user_id: response_user_id, - username, - credential, - }, - )) if response_user_id == user_id => { - write_private_export(&destination, credential.0.as_bytes()) - .map(|()| { - format!( - "Exported credential for {username} to {}.", - destination.display() - ) - }) - .map_err(|error| { - format!( - "Credential export failed for {}: {error}", - destination.display() - ) - }) - } - Ok(iota_ipc::ResponseResult::Ok(_)) => Err( - "Credential export failed: daemon returned an unexpected response.".into(), - ), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Credential export failed: {error}")) - } - Err(error) => Err(format!("Credential export failed: {error}")), - }; - UiEvent::App(AppEvent::CredentialExportFinished(result)) - }), - } + IotaInteraction::Effect(IotaEffect::ExportCredential { + user_id, + destination, + }) } - fn start_create(&mut self, username: String) -> InteractionResult { + fn start_create(&mut self, username: String) -> IotaInteraction { self.pending = true; self.message = Some("Creating user…".into()); self.overlay = None; - let ipc = self.ipc.clone(); - InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::CreateUser { username }) - .await - { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { - username, - user_id, - })) => Ok(format!("Created user {username} ({user_id}).")), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Create user failed: {error}")) - } - Ok(_) => { - Err("Create user failed: daemon returned an unexpected response.".into()) - } - Err(error) => Err(format!("Create user failed: {error}")), - }; - UiEvent::App(AppEvent::UserOperationFinished(result)) - }), - } + IotaInteraction::Effect(IotaEffect::CreateUser { username }) } - fn start_invitation(&mut self, authority: iota_ipc::InvitationAuthority) -> InteractionResult { + fn start_invitation(&mut self, authority: iota_ipc::InvitationAuthority) -> IotaInteraction { self.pending = true; self.message = Some("Creating invitation…".into()); self.overlay = None; - let ipc = self.ipc.clone(); - InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::CreateInvitation { - authority, - lifetime_seconds: 7 * 24 * 60 * 60, - password: None, - label: None, - }) - .await - { - Ok(iota_ipc::ResponseResult::Ok( - iota_ipc::ResponsePayload::InvitationCreated(invitation), - )) => Ok(format!( - "Invitation {} created. Token: {} Link: {}{}", - invitation.invitation_id, - invitation.raw_token.0, - invitation.short_url.as_deref().unwrap_or("unavailable"), - if invitation.mirror_synced { - "" - } else { - " Local metadata is still synchronizing." - } - )), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Create invitation failed: {error}")) - } - Ok(_) => Err( - "Create invitation failed: daemon returned an unexpected response.".into(), - ), - Err(error) => Err(format!("Create invitation failed: {error}")), - }; - UiEvent::App(AppEvent::UserOperationFinished(result)) - }), - } + IotaInteraction::Effect(IotaEffect::CreateInvitation { authority }) } - fn refresh(&self) -> InteractionResult { - let ipc = self.ipc.clone(); - InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|user| UserEntry { - user_id: user.user_id, - username: user.username, - state: user.state, - data_present: user.data_present, - credential_status: user.credential_status, - pending_operation: user.pending_operation, - }) - .collect()) - } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot reload users: {error}")) - } - Ok(_) => { - Err("Cannot reload users: daemon returned an unexpected response.".into()) - } - Err(error) => Err(format!("Cannot reload users: {error}")), - }; - UiEvent::App(AppEvent::UsersLoaded(result)) - }), - } + fn refresh(&self) -> IotaInteraction { + IotaInteraction::Effect(IotaEffect::RefreshUsers) } - fn render_list( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - hits: &mut HitMap, - ) { + fn render_list(&self, frame: &mut Frame, area: Rect, theme: &ResolvedTheme, hits: &mut HitMap) { let block = Block::default() .title(" Users ") .borders(Borders::ALL) - .border_style(context.theme.borders.normal); + .border_style(theme.borders.normal); let inner = block.inner(area); frame.render_widget(block, area); self.viewport_height @@ -533,7 +301,7 @@ impl UsersScreen { width: inner.width, height: 1, }, - AppAction::SelectUser(user.user_id), + IotaMessage::Action(AppAction::SelectUser(user.user_id)), ); } frame.render_widget(Paragraph::new(lines), inner); @@ -542,13 +310,13 @@ impl UsersScreen { &self, frame: &mut Frame, area: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, hits: &mut HitMap, ) { let block = Block::default() .title(" Selected user ") .borders(Borders::ALL) - .border_style(context.theme.borders.normal); + .border_style(theme.borders.normal); let inner = block.inner(area); frame.render_widget(block, area); let Some(user) = self.selected_user() else { @@ -560,10 +328,7 @@ impl UsersScreen { }; let groups = self.groups(); let details = vec![ - Line::from(Span::styled( - user.username.as_str(), - context.theme.text.normal, - )), + Line::from(Span::styled(user.username.as_str(), theme.text.normal)), Line::from(format!("State {}", context::state_label(user))), Line::from(format!( "Hosted data {}", @@ -601,10 +366,13 @@ impl UsersScreen { focused, enabled: !self.pending, }, - context.theme, + theme, ); if !self.pending { - hits.register(buttons[index], AppAction::OpenUserGroup(entry.group)); + hits.register( + buttons[index], + IotaMessage::Action(AppAction::OpenUserGroup(entry.group)), + ); } } } @@ -612,15 +380,15 @@ impl UsersScreen { &self, frame: &mut Frame, rect: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, hits: &mut HitMap, ) { let Some(overlay) = &self.overlay else { return; }; - let area = crate::layout::fit::centered_rect( + let area = ratatool::layout::centered_rect( rect, - crate::layout::fit::RequiredSize { + ratatool::layout::RequiredSize { width: 56, height: 12, }, @@ -638,8 +406,8 @@ impl UsersScreen { UsersOverlay::CredentialExport(_) => " Export TU ", }) .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); + .border_style(theme.borders.focused) + .style(theme.surfaces.overlay); let inner = block.inner(area); frame.render_widget(block, area); match overlay { @@ -669,7 +437,7 @@ impl UsersScreen { frame.render_widget(Paragraph::new(lines), inner); } AddUserPhase::ConfigureCreate => { - let value = flow.username.display_value(); + let value = flow.username.value(); let message = flow .error .as_deref() @@ -680,7 +448,7 @@ impl UsersScreen { ); } AddUserPhase::ConfigureImport => { - let value = flow.import_path.display_value(); + let value = flow.import_path.value(); let message = flow .error .as_deref() @@ -777,7 +545,7 @@ impl UsersScreen { focused: false, enabled: true, }, - context.theme, + theme, ); render_button( frame, @@ -788,13 +556,13 @@ impl UsersScreen { focused: true, enabled: !self.pending, }, - context.theme, + theme, ); - hits.register(buttons[0], AppAction::CancelDialog); - hits.register(buttons[1], AppAction::ConfirmDialog); + hits.register(buttons[0], IotaMessage::Action(AppAction::CancelDialog)); + hits.register(buttons[1], IotaMessage::Action(AppAction::ConfirmDialog)); } UsersOverlay::CredentialExport(export) => { - let value = export.destination.display_value(); + let value = export.destination.value(); let message = export .error .as_deref() @@ -811,24 +579,18 @@ impl UsersScreen { } } -impl Screen for UsersScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - fn render( +impl IotaScreen for UsersScreen { + fn render_legacy( &self, frame: &mut Frame, rect: Rect, - context: &RenderContext<'_>, + theme: &ResolvedTheme, hits: &mut HitMap, ) { let outer = Block::default() .title(" User management ") .borders(Borders::ALL) - .border_style(context.theme.borders.normal); + .border_style(theme.borders.normal); let inner = outer.inner(rect); frame.render_widget(outer, rect); let rows = Layout::vertical([ @@ -860,10 +622,13 @@ impl Screen for UsersScreen { focused: self.active_tab == tab, enabled: !self.pending, }, - context.theme, + theme, ); if !self.pending { - hits.register(header[index], AppAction::SetUserAdminTab(tab)); + hits.register( + header[index], + IotaMessage::Action(AppAction::SetUserAdminTab(tab)), + ); } } frame.render_widget( @@ -873,9 +638,9 @@ impl Screen for UsersScreen { "Pending, redeemed, revoked, and expired invitations".into() }) .style(if self.filtering { - context.theme.text.normal + theme.text.normal } else { - context.theme.text.muted + theme.text.muted }), header[2], ); @@ -894,10 +659,10 @@ impl Screen for UsersScreen { focused: self.focus_zone == FocusZone::Toolbar && self.toolbar_index == 0, enabled: add_enabled, }, - context.theme, + theme, ); if add_enabled { - hits.register(header[3], AppAction::AddUser); + hits.register(header[3], IotaMessage::Action(AppAction::AddUser)); } let panes = if rows[1].width >= 70 { Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)]) @@ -906,24 +671,25 @@ impl Screen for UsersScreen { Layout::horizontal([Constraint::Percentage(100), Constraint::Length(0)]).split(rows[1]) }; if self.active_tab == UserAdminTab::Users { - self.render_list(frame, panes[0], context, hits); + self.render_list(frame, panes[0], theme, hits); if panes[1].width > 0 { - self.render_context(frame, panes[1], context, hits); + self.render_context(frame, panes[1], theme, hits); } } else { - invitations::render(frame, rows[1], context, self.invitation_supported()); + invitations::render(frame, rows[1], theme, self.invitation_supported()); } if let Some(message) = &self.message { frame.render_widget( - Paragraph::new(message.as_str()).style(context.theme.text.muted), + Paragraph::new(message.as_str()).style(theme.text.muted), rows[2], ); } - self.render_overlay(frame, rect, context, hits); + self.render_overlay(frame, rect, theme, hits); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction { + let event = event.clone(); match event { - UiEvent::App(AppEvent::UsersLoaded(result)) => { + UiEvent::Message(IotaMessage::Event(AppEvent::UsersLoaded(result))) => { self.loading = false; match result { Ok(users) => { @@ -932,9 +698,9 @@ impl Screen for UsersScreen { } Err(error) => self.message = Some(error), } - return InteractionResult::Handled; + return IotaInteraction::Changed; } - UiEvent::App(AppEvent::UserOperationFinished(result)) => { + UiEvent::Message(IotaMessage::Event(AppEvent::UserOperationFinished(result))) => { self.pending = false; self.message = Some(match result { Ok(message) => message, @@ -942,15 +708,15 @@ impl Screen for UsersScreen { }); return self.refresh(); } - UiEvent::App(AppEvent::CredentialExportFinished(result)) => { + UiEvent::Message(IotaMessage::Event(AppEvent::CredentialExportFinished(result))) => { self.pending = false; self.message = Some(match result { Ok(message) => message, Err(error) => error, }); - return InteractionResult::Handled; + return IotaInteraction::Changed; } - UiEvent::App(AppEvent::TuInspected(result)) => { + UiEvent::Message(IotaMessage::Event(AppEvent::TuInspected(result))) => { if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() { match result { Ok(preview) => { @@ -961,7 +727,7 @@ impl Screen for UsersScreen { } } } - return InteractionResult::Handled; + return IotaInteraction::Changed; } UiEvent::Paste(text) => { if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() { @@ -972,74 +738,77 @@ impl Screen for UsersScreen { _ => None, }; if let Some(input) = input { - for character in sanitized.chars() { - input.handle_key(KeyCode::Char(character)); - } - return InteractionResult::Handled; + input.insert_text(&sanitized); + return IotaInteraction::Changed; } } if let Some(UsersOverlay::CredentialExport(export)) = self.overlay.as_mut() { - for character in text.replace(['\r', '\n'], " ").chars() { - export.destination.handle_key(KeyCode::Char(character)); - } - return InteractionResult::Handled; + export + .destination + .insert_text(&text.replace(['\r', '\n'], " ")); + return IotaInteraction::Changed; } if self.filtering { self.filter.push_str(&text.replace(['\r', '\n'], " ")); self.restore_selection(); - return InteractionResult::Handled; + return IotaInteraction::Changed; } - return InteractionResult::Unhandled; + return IotaInteraction::Unhandled; } UiEvent::Key(key) => { if let Some(overlay) = &mut self.overlay { match overlay { UsersOverlay::Add(flow) => match flow.phase { AddUserPhase::ChooseMethod => { - if flow.methods.handle_key(key.code) { - return InteractionResult::Handled; + if matches!( + flow.methods.handle_key(key.code), + ratatool::MenuResult::Moved + | ratatool::MenuResult::Unchanged + | ratatool::MenuResult::NoSelection + ) { + return IotaInteraction::Changed; } match key.code { KeyCode::Enter => { flow.choose(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Esc => { self.overlay = None; - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Handled, + _ => IotaInteraction::Changed, } } AddUserPhase::ConfigureCreate => match key.code { KeyCode::Esc => { flow.phase = AddUserPhase::ChooseMethod; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { let username = flow.username.value().trim().to_owned(); if username.is_empty() { flow.error = Some("A username is required.".into()); - InteractionResult::Handled + IotaInteraction::Changed } else { self.start_create(username) } } _ => { - flow.username.handle_key(key.code); - InteractionResult::Handled + flow.username.handle_key(key); + IotaInteraction::Changed } }, AddUserPhase::ConfigureImport => match key.code { KeyCode::Esc => { flow.phase = AddUserPhase::ChooseMethod; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { let path = flow.import_path.value().trim(); if path.is_empty() { flow.error = Some("A TU path is required.".into()); - InteractionResult::Handled + IotaInteraction::Changed } else { match std::fs::read_to_string(path) { Ok(contents) => self.start_tu_inspection( @@ -1049,24 +818,29 @@ impl Screen for UsersScreen { flow.error = Some(format!( "Cannot read credential {path}: {error}" )); - InteractionResult::Handled + IotaInteraction::Changed } } } } _ => { - flow.import_path.handle_key(key.code); - InteractionResult::Handled + flow.import_path.handle_key(key); + IotaInteraction::Changed } }, AddUserPhase::ConfigureInvitation => { - if flow.invitation_authorities.handle_key(key.code) { - return InteractionResult::Handled; + if matches!( + flow.invitation_authorities.handle_key(key.code), + ratatool::MenuResult::Moved + | ratatool::MenuResult::Unchanged + | ratatool::MenuResult::NoSelection + ) { + return IotaInteraction::Changed; } match key.code { KeyCode::Esc => { flow.phase = AddUserPhase::ChooseMethod; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { let authority = flow @@ -1076,16 +850,16 @@ impl Screen for UsersScreen { if let Some(authority) = authority { self.start_invitation(authority) } else { - InteractionResult::Handled + IotaInteraction::Changed } } - _ => InteractionResult::Handled, + _ => IotaInteraction::Changed, } } AddUserPhase::ReviewImport => match key.code { KeyCode::Esc => { flow.phase = AddUserPhase::ConfigureImport; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { if let Some(credential) = flow.credential.clone() { @@ -1095,56 +869,64 @@ impl Screen for UsersScreen { flow.error = Some("Credential must be inspected again.".into()); flow.phase = AddUserPhase::ConfigureImport; - InteractionResult::Handled + IotaInteraction::Changed } } - _ => InteractionResult::Handled, + _ => IotaInteraction::Changed, }, _ => { self.overlay = None; - InteractionResult::Handled + IotaInteraction::Changed } }, UsersOverlay::UserMenu(menu) => { - if menu.menu.handle_key(key.code) { - return InteractionResult::Handled; + match menu.menu.handle_key(key.code) { + ratatool::MenuResult::Moved + | ratatool::MenuResult::Unchanged + | ratatool::MenuResult::NoSelection => { + return IotaInteraction::Changed; + } + ratatool::MenuResult::Activated(action) => { + return self.begin_action(action); + } + ratatool::MenuResult::Unhandled => {} } match key.code { KeyCode::Esc => { self.overlay = None; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { let action = menu.menu.selected_item().map(|item| item.value); - action.map_or(InteractionResult::Handled, |action| { + action.map_or(IotaInteraction::Changed, |action| { self.begin_action(action) }) } - _ => InteractionResult::Handled, + _ => IotaInteraction::Changed, } } UsersOverlay::Confirm(confirm) => match key.code { KeyCode::Esc => { self.overlay = None; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { let action = confirm.action; let user_id = confirm.user.user_id; self.start_operation(action, user_id) } - _ => InteractionResult::Handled, + _ => IotaInteraction::Changed, }, UsersOverlay::CredentialExport(export) => match key.code { KeyCode::Esc => { self.overlay = None; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter => { let destination = export.destination.value().trim(); if destination.is_empty() { export.error = Some("A destination path is required.".into()); - InteractionResult::Handled + IotaInteraction::Changed } else { let user_id = export.user.user_id; let destination = std::path::PathBuf::from(destination); @@ -1152,29 +934,29 @@ impl Screen for UsersScreen { } } _ => { - export.destination.handle_key(key.code); - InteractionResult::Handled + export.destination.handle_key(key); + IotaInteraction::Changed } }, } } else if self.active_tab == UserAdminTab::Invitations { match key.code { - KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Esc => IotaInteraction::Pop, KeyCode::Char('a') | KeyCode::Char('A') => { if self.invitation_supported() { self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); } else { self.message = Some(invitations::UNSUPPORTED_MESSAGE.into()); } - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('u') | KeyCode::Char('U') => { self.active_tab = UserAdminTab::Users; self.focus_zone = FocusZone::UserList; - InteractionResult::Handled + IotaInteraction::Changed } - KeyCode::Char('i') | KeyCode::Char('I') => InteractionResult::Handled, - _ => InteractionResult::Unhandled, + KeyCode::Char('i') | KeyCode::Char('I') => IotaInteraction::Changed, + _ => IotaInteraction::Unhandled, } } else if self.filtering { match key.code { @@ -1198,54 +980,54 @@ impl Screen for UsersScreen { } _ => {} }; - InteractionResult::Handled + IotaInteraction::Changed } else { match key.code { - KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Esc => IotaInteraction::Pop, KeyCode::Char('/') => { self.filtering = true; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('a') | KeyCode::Char('A') => { self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Char('i') | KeyCode::Char('I') => { self.active_tab = UserAdminTab::Invitations; self.filtering = false; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Down | KeyCode::Char('j') => { self.move_selection(1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Up | KeyCode::Char('k') => { self.move_selection(-1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::PageDown => { self.move_selection( self.viewport_height.load(Ordering::Relaxed) as isize ); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::PageUp => { self.move_selection( -(self.viewport_height.load(Ordering::Relaxed) as isize), ); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Home => { self.selected_user_id = self.visible_users().first().map(|user| user.user_id); self.keep_selected_visible(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::End => { self.selected_user_id = self.visible_users().last().map(|user| user.user_id); self.keep_selected_visible(); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Tab => { self.focus_zone = match self.focus_zone { @@ -1254,20 +1036,20 @@ impl Screen for UsersScreen { FocusZone::UserList => FocusZone::UserActions, FocusZone::UserActions => FocusZone::Toolbar, }; - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Left if self.focus_zone == FocusZone::UserActions => { self.action_group_index = self.action_group_index.saturating_sub(1); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Right if self.focus_zone == FocusZone::UserActions => { let last = self.groups().len().saturating_sub(1); self.action_group_index = (self.action_group_index + 1).min(last); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter if self.focus_zone == FocusZone::Toolbar => { self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); - InteractionResult::Handled + IotaInteraction::Changed } KeyCode::Enter if self.focus_zone == FocusZone::UserActions => { if let Some(entry) = self.groups().get(self.action_group_index) { @@ -1277,16 +1059,16 @@ impl Screen for UsersScreen { self.credential_export_supported(), ))); } - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { + fn handle_action(&mut self, action: AppAction) -> IotaInteraction { match action { AppAction::AddUser => { if self.active_tab == UserAdminTab::Invitations && !self.invitation_supported() { @@ -1294,7 +1076,7 @@ impl Screen for UsersScreen { } else { self.overlay = Some(UsersOverlay::Add(self.new_add_flow())); } - InteractionResult::Handled + IotaInteraction::Changed } AppAction::SetUserAdminTab(tab) if self.overlay.is_none() => { self.active_tab = tab; @@ -1304,13 +1086,13 @@ impl Screen for UsersScreen { } else { FocusZone::Toolbar }; - InteractionResult::Handled + IotaInteraction::Changed } AppAction::SelectUser(id) if self.overlay.is_none() => { self.selected_user_id = Some(id); self.focus_zone = FocusZone::UserList; self.keep_selected_visible(); - InteractionResult::Handled + IotaInteraction::Changed } AppAction::OpenUserGroup(group) if self.overlay.is_none() => { if let Some(entry) = self.groups().into_iter().find(|entry| entry.group == group) { @@ -1320,20 +1102,20 @@ impl Screen for UsersScreen { self.credential_export_supported(), ))); } - InteractionResult::Handled + IotaInteraction::Changed } AppAction::ActivateUserAction(action) => self.begin_action(action), AppAction::ConfirmDialog => match &self.overlay { Some(UsersOverlay::Confirm(confirm)) => { self.start_operation(confirm.action, confirm.user.user_id) } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, }, AppAction::CancelDialog => { self.overlay = None; - InteractionResult::Handled + IotaInteraction::Changed } - _ => InteractionResult::Unhandled, + _ => IotaInteraction::Unhandled, } } fn key_hints(&self) -> Vec { diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index 3154704..be14a82 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -7,6 +7,112 @@ pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; +pub fn ratatool_policy(policy: TerminalPolicy) -> ratatool::CapabilityPolicy { + match policy { + TerminalPolicy::Auto => ratatool::CapabilityPolicy::Auto, + TerminalPolicy::Always => ratatool::CapabilityPolicy::Force, + TerminalPolicy::Never => ratatool::CapabilityPolicy::Disable, + } +} + +pub fn terminal_options( + color: TerminalPolicy, + unicode: TerminalPolicy, +) -> ratatool::TerminalOptions { + ratatool::TerminalOptions { + color: ratatool_policy(color), + unicode: ratatool_policy(unicode), + overrides: Default::default(), + } +} + +pub fn registry() -> ratatool::ThemeRegistry { + let mut registry = ratatool::ThemeRegistry::new(); + let plain = presets::resolve(ThemeName::Monospace); + let styled = presets::resolve(ThemeName::Binary); + let ansi = presets::resolve(ThemeName::Ansi); + for name in ThemeName::ALL { + let selected = presets::resolve(name); + let color = if matches!(name, ThemeName::Surface) { + &ansi + } else { + &selected + }; + registry.register(ratatool::ThemeFamily::new( + name.ratatool_id(), + generic_variant(&selected), + generic_variant(color), + generic_variant(&styled), + generic_variant(&plain), + )); + } + registry +} + +fn generic_variant(theme: &ResolvedTheme) -> ratatool::ThemeVariant { + ratatool::ThemeVariant { + text: ratatool::TextTheme { + normal: theme.text.normal, + muted: theme.text.muted, + emphasis: theme.text.heading, + disabled: theme.borders.disabled, + heading: theme.text.heading, + link: theme.text.link, + code: theme.text.code, + }, + borders: ratatool::BorderTheme { + normal: theme.borders.normal, + focused: theme.borders.focused, + disabled: theme.borders.disabled, + }, + surfaces: ratatool::SurfaceTheme { + background: theme.surfaces.canvas, + panel: theme.surfaces.panel, + panel_focused: theme.surfaces.panel_focused, + panel_header: theme.surfaces.panel_alternate, + raised: theme.surfaces.panel_alternate, + toolbar: theme.surfaces.toolbar, + footer: theme.surfaces.footer, + overlay: theme.surfaces.overlay, + }, + buttons: ratatool::ButtonTheme { + primary: theme.buttons.primary, + primary_focused: theme.buttons.primary_focused, + secondary: theme.buttons.neutral, + secondary_focused: theme.buttons.neutral_focused, + cancel: theme.buttons.cancel, + cancel_focused: theme.buttons.cancel_focused, + destructive: theme.buttons.destructive, + destructive_focused: theme.buttons.destructive, + disabled: theme.buttons.disabled, + }, + status: ratatool::StatusTheme { + info: theme.status.info, + success: theme.status.success, + warning: theme.status.warning, + error: theme.status.error, + }, + input: ratatool::InputTheme { + normal: theme.text.normal, + focused: theme.borders.focused, + placeholder: theme.text.muted, + selection: theme.surfaces.panel_selected, + }, + selection: ratatool::SelectionTheme { + normal: theme.text.normal, + selected: theme.surfaces.panel_selected, + focused: theme.borders.focused, + disabled: theme.borders.disabled, + }, + metrics: ratatool::ThemeMetrics { + panel_presentation: match theme.chrome { + ChromeMode::Bordered => ratatool::PanelPresentation::Bordered, + ChromeMode::Surfaces => ratatool::PanelPresentation::Surface, + }, + }, + } +} + pub fn resolve(name: ThemeName) -> ResolvedTheme { presets::resolve(name) } @@ -34,22 +140,6 @@ pub fn resolve_with_capabilities( theme } -/// Resolve a theme against the terminal's color depth. Surface uses RGB -/// colors, so a portable ANSI preset is selected when truecolor is absent. -pub fn resolve_with_terminal_profile( - name: ThemeName, - color_enabled: bool, - unicode_enabled: bool, - truecolor_enabled: bool, -) -> ResolvedTheme { - let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) { - ThemeName::Ansi - } else { - name - }; - resolve_with_capabilities(effective, color_enabled, unicode_enabled) -} - #[cfg(test)] mod tests { use super::*; @@ -75,9 +165,18 @@ mod tests { } #[test] - fn surface_uses_ansi_fallback_without_truecolor() { - let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false); - assert_eq!(theme.name, ThemeName::Ansi); - assert_eq!(theme.surfaces.panel.bg, None); + fn terminal_profile_changes_preserve_surface_identity() { + let registry = registry(); + let mut state = ratatool::ThemeState::new( + ThemeName::Surface.ratatool_id(), + ratatool::TerminalProfile::truecolor_unicode(), + ®istry, + ) + .unwrap(); + state + .set_profile(ratatool::TerminalProfile::ansi16_unicode(), ®istry) + .unwrap(); + assert_eq!(state.selected(), &ThemeName::Surface.ratatool_id()); + assert_eq!(state.profile().color_depth, ratatool::ColorDepth::Ansi16); } } diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs old mode 100644 new mode 100755 diff --git a/iota-cli/src/theme/name.rs b/iota-cli/src/theme/name.rs index 1fbfeb8..2c49b06 100644 --- a/iota-cli/src/theme/name.rs +++ b/iota-cli/src/theme/name.rs @@ -17,6 +17,10 @@ impl ThemeName { pub fn supported_names() -> &'static str { "monospace, binary, ansi, surface" } + + pub fn ratatool_id(self) -> ratatool::ThemeId { + ratatool::ThemeId::new(self.to_string()) + } } impl fmt::Display for ThemeName { diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 41608d9..0ef7cf2 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,267 +1,239 @@ +use std::{ + io, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; + +use crossterm::event::{KeyCode, KeyModifiers}; +use once_cell::sync::Lazy; +use ratatool::{ + AsyncEventSource, Interaction, NotificationOverlay, Runtime, ScreenPresentation, + SessionOptions, TerminalSession, ThemeState, +}; +use ratatui::layout::{Constraint, Layout, Rect}; +use tokio::{ + sync::{RwLock, mpsc}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + use crate::{ controls::header::{HEADER_ITEMS, render_header}, help_overlay::HelpOverlay, - input_handler::setup_input_handler, - interaction_result::InteractionResult, ipc_client::{DaemonStatus, IpcClient, IpcConnectionState}, - notification::{Notification, render_notification_area}, - render_context::RenderContext, screens::{ - main_screen::MainScreen, metrics::MetricsScreen, overview::OverviewScreen, - screens::{AppAction, AppEvent, HitMap, Screen, UiEvent}, + screens::{ + AppAction, AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, + UiEvent, + }, settings::SettingsScreen, - users::{UserEntry, UsersScreen}, + users::{ + UserEntry, UsersScreen, context::credential_status_label, + credential_export::write_private_export, model::UserAction, + }, }, theme::{self, ResolvedTheme, ThemeName}, }; -use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind, -}; -use once_cell::sync::Lazy; -use ratatui::{ - Terminal, - backend::CrosstermBackend, - layout::{Constraint, Layout, Rect}, -}; -use std::{ - io, - io::Stdout, - panic::PanicHookInfo, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, -}; -use tokio::sync::{Notify, RwLock, mpsc}; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -/// UI state and rendering pub static FPS: Lazy> = Lazy::new(|| RwLock::new((0.0, 0.0))); -pub struct UI { - ipc: RwLock>>, +const UI_EVENT_CAPACITY: usize = 128; +const UI_COMMAND_CAPACITY: usize = 32; +static NEXT_NOTIFICATION_ID: AtomicU64 = AtomicU64::new(1); + +struct DriverThemeConfig { + selected: ThemeName, + color: theme::TerminalPolicy, + unicode: theme::TerminalPolicy, +} + +pub struct DaemonViewState { + pub connection: tokio::sync::watch::Receiver, + pub status: tokio::sync::watch::Receiver, +} + +struct IotaScreenEntry { + screen: Box, + presentation: ScreenPresentation, +} + +pub struct IotaShellState { + screens: Vec, + header_focus: Option, + notifications: NotificationOverlay, + daemon_view: Option, + ipc: Option>, + theme: ResolvedTheme, shutdown_on_empty: bool, +} + +pub type SharedShellState = Arc>; + +pub enum UiCommand { + Push(Box, ScreenPresentation), + ReplaceRoot(Box), + AttachDaemon(Arc), + Notify(ratatool::Notification), + SetTheme(Box), + Shutdown, + Redraw, +} + +#[derive(Clone)] +pub struct UiHandle { + commands: mpsc::Sender, + event_tx: mpsc::Sender, cancellation: CancellationToken, - pub terminal: Arc>>>, - screen_stack: Arc>>>, - theme: RwLock>, - pub(crate) invalidation: Notify, + ipc: Arc>>>, failure: Arc>>, - hits: Mutex, - app_event_tx: mpsc::UnboundedSender, - app_event_rx: Mutex>>, - header_focus: Mutex>, - notifications: Arc>>, +} + +pub struct TuiSession { + ui: Arc, + driver: JoinHandle>, } pub fn start_tui(ipc: Arc) -> io::Result { start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi)) } -pub fn start_tui_with_theme(ipc: Arc, theme: ResolvedTheme) -> io::Result { - start_session(UI::new(Some(ipc), true, theme)?) +pub fn start_tui_with_theme( + ipc: Arc, + initial_theme: ResolvedTheme, +) -> io::Result { + let config = theme::UiConfig::load_or_default(); + start_session(Some(ipc), true, initial_theme, config.color, config.unicode) } pub fn start_bootstrap_tui() -> io::Result { start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi)) } -pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result { - start_session(UI::new(None, false, theme)?) +pub fn start_bootstrap_tui_with_theme(initial_theme: ResolvedTheme) -> io::Result { + let config = theme::UiConfig::load_or_default(); + start_session(None, false, initial_theme, config.color, config.unicode) } -fn start_session(ui: UI) -> io::Result { - let ui = Arc::new(ui); - let mut app_event_rx = ui - .app_event_rx - .lock() - .map_err(|_| io::Error::other("application event queue poisoned"))? - .take() - .ok_or_else(|| io::Error::other("application event queue already started"))?; - let app_ui = ui.clone(); - let app_event_task = tokio::spawn(async move { - loop { - tokio::select! { - _ = app_ui.cancellation.cancelled() => break, - event = app_event_rx.recv() => match event { - Some(event) => app_ui.clone().handle_event(event).await, - None => break, - }, - } - } +pub fn start_bootstrap_tui_with_config( + selected: ThemeName, + color: theme::TerminalPolicy, + unicode: theme::TerminalPolicy, +) -> io::Result { + start_session(None, false, theme::resolve(selected), color, unicode) +} + +fn start_session( + ipc: Option>, + shutdown_on_empty: bool, + initial_theme: ResolvedTheme, + color_policy: theme::TerminalPolicy, + unicode_policy: theme::TerminalPolicy, +) -> io::Result { + let session = TerminalSession::enter(SessionOptions { + mouse_capture: true, + ..SessionOptions::default() + })?; + let (commands, command_rx) = mpsc::channel(UI_COMMAND_CAPACITY); + let (event_tx, event_source) = AsyncEventSource::channel(UI_EVENT_CAPACITY); + let cancellation = CancellationToken::new(); + let selected_theme = initial_theme.name; + let shared_ipc = Arc::new(RwLock::new(ipc.clone())); + let failure = Arc::new(Mutex::new(None)); + let shell = Arc::new(Mutex::new(IotaShellState { + screens: Vec::new(), + header_focus: None, + notifications: NotificationOverlay::default(), + daemon_view: ipc.as_ref().map(daemon_view), + ipc, + theme: initial_theme, + shutdown_on_empty, + })); + let ui = Arc::new(UiHandle { + commands: commands.clone(), + event_tx: event_tx.clone(), + cancellation: cancellation.clone(), + ipc: shared_ipc, + failure: failure.clone(), }); - let uic = ui.clone(); - let renderer_task = tokio::spawn(async move { - let cancellation = uic.cancellation_token(); - let result: io::Result<()> = loop { - tokio::select! { - _ = cancellation.cancelled() => break Ok(()), - _ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } }, - } - }; + let driver = tokio::spawn(async move { + let result = run_driver( + session, + shell, + command_rx, + event_source, + event_tx, + cancellation.clone(), + DriverThemeConfig { + selected: selected_theme, + color: color_policy, + unicode: unicode_policy, + }, + ) + .await; if let Err(error) = &result { - *uic.failure.lock().unwrap() = Some(error.to_string()); - uic.request_shutdown(); + if let Ok(mut slot) = failure.lock() { + *slot = Some(error.to_string()); + } + cancellation.cancel(); } result }); - let input_task = setup_input_handler(ui.clone()); - // Some terminals deliver Ctrl+C as SIGINT even while crossterm is in raw - // mode. Keep this independent of key-event handling for bootstrap work. - let signal_task = { - #[cfg(unix)] - { - let signal_ui = ui.clone(); - Some(tokio::spawn(async move { - if tokio::signal::ctrl_c().await.is_ok() { - signal_ui.request_shutdown(); - } - })) - } - #[cfg(not(unix))] - { - None - } - }; - let previous_hook = Arc::new(Mutex::new(Some(std::panic::take_hook()))); - let hook_for_panic = previous_hook.clone(); - std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| { - ratatui::restore(); - if let Some(hook) = hook_for_panic.lock().unwrap().as_ref() { - hook(info); - } - })); - Ok(TuiSession { - ui, - renderer_task, - input_task, - app_event_task, - signal_task, - restored: AtomicBool::new(false), - previous_hook, - }) + Ok(TuiSession { ui, driver }) } -pub struct TuiSession { - ui: Arc, - renderer_task: JoinHandle>, - input_task: JoinHandle>, - app_event_task: JoinHandle<()>, - signal_task: Option>, - restored: AtomicBool, - previous_hook: Arc) + Send + Sync + 'static>>>>, +fn daemon_view(ipc: &Arc) -> DaemonViewState { + DaemonViewState { + connection: ipc.connection_status(), + status: ipc.daemon_status(), + } } impl TuiSession { - pub fn ui(&self) -> Arc { + pub fn ui(&self) -> Arc { self.ui.clone() } + pub async fn shutdown(mut self) -> Option { self.ui.request_shutdown(); - // Restore raw-mode state before waiting on cooperative tasks. A - // misbehaving task must never leave the invoking shell unusable. - self.restore_terminal_once(); - let renderer = - tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await; - let input = - tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await; - self.app_event_task.abort(); - if renderer.is_err() { - self.renderer_task.abort(); - } - if input.is_err() { - self.input_task.abort(); - } - if let Some(task) = self.signal_task.as_mut() { - task.abort(); - let _ = task.await; - } - self.restore_panic_hook(); - match renderer { - Err(_) => Some("renderer did not stop within 2 seconds".into()), - Ok(Err(error)) => Some(format!("renderer task failed: {error}")), + match tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.driver).await { + Err(_) => { + self.driver.abort(); + Some("UiHandle driver did not stop within 2 seconds".into()) + } + Ok(Err(error)) => Some(format!("UiHandle driver task failed: {error}")), Ok(Ok(Err(error))) => Some(error.to_string()), - Ok(Ok(Ok(()))) => match input { - Err(_) => Some("input handler did not stop within 2 seconds".into()), - Ok(Err(error)) => Some(format!("input handler failed: {error}")), - Ok(Ok(Err(error))) => Some(error), - Ok(Ok(Ok(()))) => None, - }, - } - } - fn restore_terminal_once(&self) { - if !self.restored.swap(true, Ordering::AcqRel) { - let _ = crossterm::execute!(io::stdout(), DisableMouseCapture); - ratatui::restore(); - } - } - fn restore_panic_hook(&self) { - if let Some(hook) = self.previous_hook.lock().unwrap().take() { - std::panic::set_hook(hook); + Ok(Ok(Ok(()))) => self.ui.failure(), } } } + impl Drop for TuiSession { fn drop(&mut self) { self.ui.request_shutdown(); - self.renderer_task.abort(); - self.input_task.abort(); - self.app_event_task.abort(); - if let Some(task) = self.signal_task.as_ref() { - task.abort(); - } - self.restore_panic_hook(); - self.restore_terminal_once(); + self.driver.abort(); } } -impl UI { - pub(crate) fn new( - ipc: Option>, - shutdown_on_empty: bool, - theme: ResolvedTheme, - ) -> io::Result { - let terminal = ratatui::try_init()?; - crossterm::execute!(io::stdout(), EnableMouseCapture)?; - let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); - Ok(Self { - ipc: RwLock::new(ipc), - shutdown_on_empty, - cancellation: CancellationToken::new(), - terminal: Arc::new(Mutex::new(terminal)), - screen_stack: Arc::new(RwLock::new(Vec::new())), - theme: RwLock::new(Arc::new(theme)), - invalidation: Notify::new(), - failure: Arc::new(Mutex::new(None)), - hits: Mutex::new(HitMap::default()), - app_event_tx, - app_event_rx: Mutex::new(Some(app_event_rx)), - header_focus: Mutex::new(None), - notifications: Arc::new(Mutex::new(Vec::new())), - }) - } +impl UiHandle { pub async fn ipc(&self) -> Option> { self.ipc.read().await.clone() } - pub async fn client_state(&self) -> Option { - self.ipc.read().await.as_ref().map(|ipc| ipc.state()) - } - pub async fn attach_daemon(&self, ipc: Arc) { - *self.ipc.write().await = Some(ipc); + *self.ipc.write().await = Some(ipc.clone()); + let _ = self.commands.send(UiCommand::AttachDaemon(ipc)).await; } pub async fn set_theme(&self, theme: ResolvedTheme) { - *self.theme.write().await = Arc::new(theme); - self.invalidate(); - } - pub async fn theme_name(&self) -> ThemeName { - self.theme.read().await.name + let _ = self + .commands + .send(UiCommand::SetTheme(Box::new(theme))) + .await; } pub fn is_shutdown(&self) -> bool { @@ -270,16 +242,17 @@ impl UI { pub fn request_shutdown(&self) { self.cancellation.cancel(); - self.invalidate(); + let _ = self.commands.try_send(UiCommand::Shutdown); } + pub fn invalidate(&self) { - self.invalidation.notify_one(); + let _ = self.event_tx.try_send(IotaMessage::Invalidate); } + pub fn failure(&self) -> Option { - self.failure.lock().ok().and_then(|f| f.clone()) + self.failure.lock().ok().and_then(|failure| failure.clone()) } - /// Lets bootstrap operations race their work against Ctrl+C without - /// blocking the input task or leaving the terminal in raw mode. + pub async fn wait_for_shutdown(&self) { self.cancellation.cancelled().await; } @@ -288,108 +261,679 @@ impl UI { self.cancellation.clone() } - pub async fn push_notification(&self, notification: Notification) { - if let Ok(mut notifications) = self.notifications.lock() { - notifications.push(notification); - self.invalidate(); + pub async fn push_notification(&self, mut notification: ratatool::Notification) { + if notification.id == 0 { + notification.id = NEXT_NOTIFICATION_ID.fetch_add(1, Ordering::Relaxed); + } + let _ = self.commands.send(UiCommand::Notify(notification)).await; + } + + pub async fn set_screen(&self, screen: Box) { + let _ = self + .commands + .send(UiCommand::Push(screen, ScreenPresentation::Replace)) + .await; + } + + pub async fn replace_screen(&self, screen: Box) { + self.set_root_screen(screen).await; + } + + pub async fn set_root_screen(&self, screen: Box) { + let _ = self.commands.send(UiCommand::ReplaceRoot(screen)).await; + } + + pub async fn render(&self) -> io::Result<()> { + self.commands + .send(UiCommand::Redraw) + .await + .map_err(|_| io::Error::other("UiHandle driver is closed")) + } +} + +struct IotaShell { + state: SharedShellState, +} + +impl IotaShell { + fn new(state: SharedShellState) -> Self { + Self { state } + } + + fn route_interaction( + &self, + state: &mut IotaShellState, + interaction: IotaInteraction, + ) -> Interaction { + match interaction { + IotaInteraction::Unhandled => Interaction::Unhandled, + IotaInteraction::Changed => Interaction::changed(), + IotaInteraction::Push { + screen, + presentation, + } => { + state.screens.push(IotaScreenEntry { + screen, + presentation, + }); + Interaction::changed() + } + IotaInteraction::Pop => { + state.screens.pop(); + if state.screens.is_empty() && state.shutdown_on_empty { + Interaction::Exit + } else { + Interaction::changed() + } + } + IotaInteraction::Effect(effect) => Interaction::message(IotaMessage::Effect(effect)), + IotaInteraction::Exit => Interaction::Exit, } } - pub async fn clear_expired_notifications(&self) { - if let Ok(mut notifications) = self.notifications.lock() { - let before = notifications.len(); - notifications.retain(|n| !n.is_expired()); - if notifications.len() != before { - self.invalidate(); + fn dispatch_action( + &self, + state: &mut IotaShellState, + action: AppAction, + ) -> Interaction { + let interaction = match action { + AppAction::Quit => IotaInteraction::Exit, + AppAction::OpenMain => { + state.screens.truncate(1); + IotaInteraction::Changed } + AppAction::OpenOverview => match &state.daemon_view { + Some(view) => IotaInteraction::Push { + screen: Box::new(OverviewScreen::new( + view.connection.clone(), + view.status.clone(), + )), + presentation: ScreenPresentation::Replace, + }, + None => IotaInteraction::Unhandled, + }, + AppAction::OpenUsers => IotaInteraction::Effect(IotaEffect::OpenUsers), + AppAction::OpenSettings => IotaInteraction::Push { + screen: Box::new(SettingsScreen::new(state.theme.name)), + presentation: ScreenPresentation::Replace, + }, + AppAction::OpenMetrics => match &state.ipc { + Some(ipc) => IotaInteraction::Push { + screen: Box::new(MetricsScreen::from_state(ipc.state())), + presentation: ScreenPresentation::Replace, + }, + None => IotaInteraction::Unhandled, + }, + action => state + .screens + .last_mut() + .map_or(IotaInteraction::Unhandled, |entry| { + entry.screen.handle_action(action) + }), + }; + self.route_interaction(state, interaction) + } +} + +impl ratatool::Screen for IotaShell { + fn render(&mut self, ui: &mut ratatool::UiFrame<'_, '_, IotaMessage>, area: Rect) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + let theme = state.theme.clone(); + let (connection, daemon) = state + .daemon_view + .as_ref() + .map(|view| { + ( + view.connection.borrow().clone(), + view.status.borrow().clone(), + ) + }) + .unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default())); + + let mut header_hits = HitMap::new(); + render_header( + ui.frame(), + rows[0], + &connection, + &daemon, + &theme, + &mut header_hits, + state.header_focus, + ); + for region in header_hits.regions() { + ui.hit_map().register(region.area, region.action.clone()); } + + let first_visible = state + .screens + .iter() + .rposition(|entry| entry.presentation == ScreenPresentation::Replace) + .unwrap_or(0); + for entry in &mut state.screens[first_visible..] { + if entry.presentation == ScreenPresentation::ModalOverlay { + ui.hit_map().clear(); + } + entry.screen.render(ui, rows[1], &theme); + } + + let hints = if state.header_focus.is_some() { + " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() + } else { + let mut hints = state + .screens + .last() + .map(|entry| entry.screen.key_hints()) + .unwrap_or_default() + .into_iter() + .map(|hint| format!("{}: {}", hint.keys, hint.action)) + .collect::>(); + if !hints.iter().any(|hint| hint.contains('?')) { + hints.push("?: Help".into()); + } + format!(" {}", hints.join(" ")) + }; + let footer_style = ui.theme().surfaces.footer.patch(ui.theme().text.muted); + ui.frame().render_widget( + ratatui::widgets::Paragraph::new(hints).style(footer_style), + rows[2], + ); + ui.notifications(rows[1], &state.notifications); } - pub async fn notifications(&self) -> Vec { - self.notifications - .lock() - .map(|n| n.clone()) - .unwrap_or_default() + fn handle_event(&mut self, event: &ratatool::UiEvent) -> Interaction { + let Ok(mut state) = self.state.lock() else { + return Interaction::Unhandled; + }; + match event { + ratatool::UiEvent::Message(IotaMessage::Action(action)) => { + self.dispatch_action(&mut state, *action) + } + ratatool::UiEvent::Message(IotaMessage::Effect(effect)) => { + Interaction::message(IotaMessage::Effect(effect.clone())) + } + ratatool::UiEvent::Message(IotaMessage::Event(event)) => { + let interaction = + state + .screens + .last_mut() + .map_or(IotaInteraction::Unhandled, |entry| { + entry + .screen + .handle_event(&UiEvent::Message(IotaMessage::Event(event.clone()))) + }); + self.route_interaction(&mut state, interaction) + } + ratatool::UiEvent::Message(IotaMessage::Invalidate) => Interaction::changed(), + ratatool::UiEvent::Tick => { + if state.notifications.expire(Instant::now()) { + Interaction::changed() + } else { + Interaction::unchanged() + } + } + ratatool::UiEvent::Key(key) => { + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('q') | KeyCode::Char('c')) + { + return Interaction::Exit; + } + if key.code == KeyCode::F(6) { + state.header_focus = if state.header_focus.is_some() { + None + } else { + Some(0) + }; + return Interaction::changed(); + } + if key.code == KeyCode::Char('?') { + state.screens.push(IotaScreenEntry { + screen: Box::new(HelpOverlay::new()), + presentation: ScreenPresentation::ModalOverlay, + }); + return Interaction::changed(); + } + if let Some(index) = state.header_focus { + match key.code { + KeyCode::Left | KeyCode::BackTab => { + state.header_focus = + Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len()) + } + KeyCode::Right | KeyCode::Tab => { + state.header_focus = Some((index + 1) % HEADER_ITEMS.len()) + } + KeyCode::Enter | KeyCode::Char(' ') => { + let action = HEADER_ITEMS.get(index).map(|item| item.action); + state.header_focus = None; + if let Some(action) = action { + return self.dispatch_action(&mut state, action); + } + } + KeyCode::Esc => state.header_focus = None, + _ => {} + } + return Interaction::changed(); + } + let interaction = state + .screens + .last_mut() + .map_or(IotaInteraction::Unhandled, |entry| { + entry.screen.handle_event(&UiEvent::Key(*key)) + }); + self.route_interaction(&mut state, interaction) + } + ratatool::UiEvent::Paste(text) => { + let interaction = state + .screens + .last_mut() + .map_or(IotaInteraction::Unhandled, |entry| { + entry.screen.handle_event(&UiEvent::Paste(text.clone())) + }); + self.route_interaction(&mut state, interaction) + } + ratatool::UiEvent::Resize { width, height } => { + let interaction = + state + .screens + .last_mut() + .map_or(IotaInteraction::Unhandled, |entry| { + entry.screen.handle_event(&UiEvent::Resize { + width: *width, + height: *height, + }) + }); + self.route_interaction(&mut state, interaction) + } + ratatool::UiEvent::Pointer { event, hit } => { + let interaction = + state + .screens + .last_mut() + .map_or(IotaInteraction::Unhandled, |entry| { + entry.screen.handle_event(&UiEvent::Pointer { + event: *event, + hit: hit.clone(), + }) + }); + self.route_interaction(&mut state, interaction) + } + ratatool::UiEvent::Mouse(_) => Interaction::Unhandled, + } } +} - pub async fn set_screen(&self, screen: Box) { - self.screen_stack.write().await.push(screen); - self.invalidate(); - } - pub async fn replace_screen(&self, screen: Box) { - let mut stack = self.screen_stack.write().await; - stack.clear(); - stack.push(screen); - self.invalidate(); - } - pub async fn set_root_screen(&self, screen: Box) { - let mut stack = self.screen_stack.write().await; - stack.clear(); - stack.push(screen); - self.invalidate(); - } - pub async fn handle_input(self: Arc, key_event: KeyEvent) { - self.handle_event(UiEvent::Key(key_event)).await; - } - pub async fn handle_event(self: Arc, event: UiEvent) { - if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) { - self.open_users().await; - return; - } - if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) { - if let Some(screen) = MetricsScreen::new(self.clone()).await { - self.set_screen(Box::new(screen)).await; +async fn run_driver( + mut session: TerminalSession, + shell: SharedShellState, + mut command_rx: mpsc::Receiver, + mut event_source: AsyncEventSource, + event_tx: mpsc::Sender, + cancellation: CancellationToken, + theme_config: DriverThemeConfig, +) -> io::Result<()> { + let registry = theme::registry(); + let profile = ratatool::detect_current(&theme::terminal_options( + theme_config.color, + theme_config.unicode, + )); + let mut theme_state = ThemeState::new(theme_config.selected.ratatool_id(), profile, ®istry) + .map_err(|error| io::Error::other(error.to_string()))?; + let mut runtime = Runtime::new(IotaShell::new(shell.clone())); + + while runtime.is_running() { + runtime.draw_if_due(session.terminal_mut(), &theme_state)?; + let deadline = runtime.next_redraw(); + tokio::select! { + result = event_source.next(deadline) => { + let Some(event) = result? else { break }; + if let Some(message) = runtime.dispatch(event) { + handle_runtime_message(message, &mut runtime, &shell, &event_tx, &mut theme_state, ®istry).await?; + } } - return; - } - if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event { - self.set_theme(theme::resolve(*theme)).await; - if *persist { - let mut config = theme::UiConfig::load().unwrap_or_default(); - config.theme = *theme; - let result = config - .save() - .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::ThemeSaved(result))); + command = command_rx.recv() => { + let Some(command) = command else { break }; + apply_ui_command(command, &shell, &mut runtime, &event_tx, &mut theme_state, ®istry).await?; } - return; + _ = cancellation.cancelled() => runtime.request_exit(), + _ = ctrl_c() => runtime.request_exit(), } - if let UiEvent::App(AppEvent::SaveSettings { - theme, + } + cancellation.cancel(); + Ok(()) +} + +#[cfg(unix)] +async fn ctrl_c() { + let _ = tokio::signal::ctrl_c().await; +} + +#[cfg(not(unix))] +async fn ctrl_c() { + std::future::pending::<()>().await; +} + +async fn apply_ui_command( + command: UiCommand, + shell: &SharedShellState, + runtime: &mut Runtime, + event_tx: &mpsc::Sender, + theme_state: &mut ThemeState, + registry: &ratatool::ThemeRegistry, +) -> io::Result<()> { + match command { + UiCommand::Push(screen, presentation) => { + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .screens + .push(IotaScreenEntry { + screen, + presentation, + }); + runtime.request_redraw(); + } + UiCommand::ReplaceRoot(screen) => { + let mut state = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))?; + state.screens.clear(); + state.screens.push(IotaScreenEntry { + screen, + presentation: ScreenPresentation::Replace, + }); + runtime.request_redraw(); + } + UiCommand::AttachDaemon(ipc) => { + let mut state = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))?; + state.daemon_view = Some(daemon_view(&ipc)); + state.ipc = Some(ipc.clone()); + drop(state); + bridge_daemon_updates(ipc, event_tx.clone()); + runtime.request_redraw(); + } + UiCommand::Notify(notification) => { + let deadline = { + let mut state = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))?; + state.notifications.push(notification); + state.notifications.next_expiration() + }; + if let Some(deadline) = deadline { + runtime.request_redraw_at(deadline); + } + runtime.request_redraw(); + } + UiCommand::SetTheme(theme) => { + theme_state + .select_theme(theme.name.ratatool_id(), registry) + .map_err(|error| io::Error::other(error.to_string()))?; + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .theme = *theme; + runtime.request_redraw(); + } + UiCommand::Shutdown => runtime.request_exit(), + UiCommand::Redraw => runtime.request_redraw(), + } + Ok(()) +} + +async fn handle_runtime_message( + message: IotaMessage, + runtime: &mut Runtime, + shell: &SharedShellState, + event_tx: &mpsc::Sender, + theme_state: &mut ThemeState, + registry: &ratatool::ThemeRegistry, +) -> io::Result<()> { + match message { + IotaMessage::Action(action) => { + let interaction = { + let mut state = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))?; + let shell_screen = IotaShell::new(shell.clone()); + shell_screen.dispatch_action(&mut state, action) + }; + match interaction { + Interaction::Exit => runtime.request_exit(), + Interaction::Handled { + changed: true, + message, + } => { + runtime.request_redraw(); + if let Some(message) = message { + Box::pin(handle_runtime_message( + message, + runtime, + shell, + event_tx, + theme_state, + registry, + )) + .await?; + } + } + Interaction::Handled { + message: Some(message), + .. + } => { + Box::pin(handle_runtime_message( + message, + runtime, + shell, + event_tx, + theme_state, + registry, + )) + .await? + } + _ => {} + } + } + IotaMessage::Effect(effect) => { + run_effect(effect, runtime, shell, event_tx, theme_state, registry).await? + } + IotaMessage::Event(_) => { + let _ = event_tx.send(message).await; + } + IotaMessage::Invalidate => runtime.request_redraw(), + } + Ok(()) +} + +async fn run_effect( + effect: IotaEffect, + runtime: &mut Runtime, + shell: &SharedShellState, + event_tx: &mpsc::Sender, + theme_state: &mut ThemeState, + registry: &ratatool::ThemeRegistry, +) -> io::Result<()> { + match effect { + IotaEffect::OpenUsers => { + let ipc = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .ipc + .clone(); + if let Some(ipc) = ipc { + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .screens + .push(IotaScreenEntry { + screen: Box::new(UsersScreen::loading(ipc.clone())), + presentation: ScreenPresentation::Replace, + }); + runtime.request_redraw(); + spawn_users_load(ipc, event_tx.clone()); + } + } + IotaEffect::OpenMetrics => { + let ipc = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .ipc + .clone(); + if let Some(ipc) = ipc { + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .screens + .push(IotaScreenEntry { + screen: Box::new(MetricsScreen::from_state(ipc.state())), + presentation: ScreenPresentation::Replace, + }); + runtime.request_redraw(); + } + } + IotaEffect::OpenOverview => { + let view = { + let state = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))?; + state + .daemon_view + .as_ref() + .map(|view| (view.connection.clone(), view.status.clone())) + }; + if let Some((connection, status)) = view { + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .screens + .push(IotaScreenEntry { + screen: Box::new(OverviewScreen::new(connection, status)), + presentation: ScreenPresentation::Replace, + }); + runtime.request_redraw(); + } + } + IotaEffect::ApplyTheme { + theme: name, + persist, + } => { + theme_state + .select_theme(name.ratatool_id(), registry) + .map_err(|error| io::Error::other(error.to_string()))?; + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .theme = theme::resolve(name); + runtime.request_redraw(); + if persist { + let tx = event_tx.clone(); + tokio::spawn(async move { + let mut config = theme::UiConfig::load_or_default(); + config.theme = name; + let result = config + .save() + .map_err(|error| format!("Could not save UiHandle settings: {error}")); + let _ = tx + .send(IotaMessage::Event(AppEvent::ThemeSaved(result))) + .await; + }); + } + } + IotaEffect::SaveSettings { + theme: name, color, unicode, cli_output, cli_require_confirmation, - }) = &event - { - self.set_theme(theme::resolve(*theme)).await; - let mut config = theme::UiConfig::load().unwrap_or_default(); - config.theme = *theme; - config.color = *color; - config.unicode = *unicode; - config.cli_output = *cli_output; - config.cli_require_confirmation = *cli_require_confirmation; - let result = config - .save() - .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::ThemeSaved(result))); - return; - } - if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { - let Some(ipc) = self.ipc().await else { - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::KeysRegenerated(Err( - "Not connected to daemon.".into(), - )))); - return; - }; - let sender = self.app_event_tx.clone(); + } => { + let options = theme::terminal_options(color, unicode); + let profile = ratatool::detect_current(&options); + theme_state + .select_theme(name.ratatool_id(), registry) + .map_err(|error| io::Error::other(error.to_string()))?; + theme_state + .set_profile(profile, registry) + .map_err(|error| io::Error::other(error.to_string()))?; + shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .theme = theme::resolve_with_capabilities( + name, + !matches!(profile.color_depth, ratatool::ColorDepth::None), + matches!(profile.charset, ratatool::Charset::Unicode), + ); + runtime.request_redraw(); + let tx = event_tx.clone(); tokio::spawn(async move { + let mut config = theme::UiConfig::load_or_default(); + config.theme = name; + config.color = color; + config.unicode = unicode; + config.cli_output = cli_output; + config.cli_require_confirmation = cli_require_confirmation; + let result = config + .save() + .map_err(|error| format!("Could not save UiHandle settings: {error}")); + let _ = tx + .send(IotaMessage::Event(AppEvent::ThemeSaved(result))) + .await; + }); + } + other => spawn_ipc_effect(other, shell, event_tx.clone())?, + } + Ok(()) +} + +fn spawn_users_load(ipc: Arc, tx: mpsc::Sender) { + tokio::spawn(async move { + let result = list_users(&ipc).await; + let _ = tx + .send(IotaMessage::Event(AppEvent::UsersLoaded(result))) + .await; + }); +} + +async fn list_users(ipc: &IpcClient) -> Result, String> { + match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => Ok(users + .into_iter() + .map(|user| UserEntry { + user_id: user.user_id, + username: user.username, + state: user.state, + data_present: user.data_present, + credential_status: user.credential_status, + pending_operation: user.pending_operation, + }) + .collect()), + Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")), + Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Err(error) => Err(format!("Cannot load users: {error}")), + } +} + +fn spawn_ipc_effect( + effect: IotaEffect, + shell: &SharedShellState, + tx: mpsc::Sender, +) -> io::Result<()> { + let ipc = shell + .lock() + .map_err(|_| io::Error::other("shell state poisoned"))? + .ipc + .clone(); + let Some(ipc) = ipc else { return Ok(()) }; + tokio::spawn(async move { + let event = match effect { + IotaEffect::RegenerateKeys => { let result = match ipc .send_request(iota_ipc::LocalRequest::RotateIotaIdentity) .await @@ -400,327 +944,234 @@ impl UI { } Err(error) => Err(format!("Cannot regenerate keys: {error}")), }; - let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result))); - }); - return; - } - if let UiEvent::Key(key) = &event { - let header_is_focused = self - .header_focus - .lock() - .map(|focus| focus.is_some()) - .unwrap_or(false); - if key.code == KeyCode::F(6) { - if let Ok(mut focus) = self.header_focus.lock() { - *focus = if focus.is_some() { None } else { Some(0) }; - } - self.invalidate(); - return; + AppEvent::KeysRegenerated(result) } - if key.code == KeyCode::Char('?') { - let has_help_overlay = self - .screen_stack - .read() + IotaEffect::RefreshUsers => AppEvent::UsersLoaded(list_users(&ipc).await), + IotaEffect::LoadNewestTerms { kind } => { + let result = iota_terms::get_terms(kind) .await - .iter() - .any(|s| s.as_any().downcast_ref::().is_some()); - if !has_help_overlay { - self.set_screen(Box::new(HelpOverlay::new())).await; - } - return; + .ok_or_else(|| "Could not fetch the legal document.".to_owned()); + AppEvent::TermsLoaded { kind, result } } - if header_is_focused { - let mut action = None; - if let Ok(mut focus) = self.header_focus.lock() { - let index = focus.unwrap_or(0).min(HEADER_ITEMS.len().saturating_sub(1)); - match key.code { - KeyCode::Left | KeyCode::BackTab => { - *focus = Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len()) - } - KeyCode::Right | KeyCode::Tab => { - *focus = Some((index + 1) % HEADER_ITEMS.len()) - } - KeyCode::Enter | KeyCode::Char(' ') => { - action = HEADER_ITEMS.get(index).map(|item| item.action); - *focus = None; - } - KeyCode::Esc => *focus = None, - _ => {} - } - } - if let Some(action) = action { - self.dispatch_action(action).await; - } else { - self.invalidate(); - } - return; - } - } - if let UiEvent::Mouse(mouse) = &event { - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) { - let action = self - .hits - .lock() - .ok() - .and_then(|hits| hits.action_at(mouse.column, mouse.row)); - if action == Some(AppAction::FocusLogs) { - self.dispatch_action(AppAction::FocusLogs).await; - let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) { - KeyCode::Up - } else { - KeyCode::Down - }; - // Log scrolling is a local, handled interaction; route it - // directly rather than recursively constructing another - // async UI event future. - if let Some(screen) = self.screen_stack.write().await.last_mut() { - let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key))); - } - self.invalidate(); - return; - } - } - if matches!( - mouse.kind, - MouseEventKind::Down(crossterm::event::MouseButton::Left) - ) { - if let Some(action) = self - .hits - .lock() - .ok() - .and_then(|hits| hits.action_at(mouse.column, mouse.row)) + IotaEffect::InspectTu { credential } => { + let result = match ipc + .send_request(iota_ipc::LocalRequest::InspectTuCredential { credential }) + .await { - self.dispatch_action(action).await; - return; - } - } - } - let result = { - let mut stack = self.screen_stack.write().await; - if let Some(screen) = stack.last_mut() { - screen.handle_event(event) - } else { - return; - } - }; - match result { - InteractionResult::OpenScreen { screen } => { - self.set_screen(screen).await; - } - InteractionResult::OpenFutureScreen { screen: fut } => { - let ui = self.clone(); - tokio::select! { - screen = fut => ui.set_screen(screen).await, - _ = ui.cancellation.cancelled() => return, - } - } - InteractionResult::AppTask { task } => { - let sender = self.app_event_tx.clone(); - tokio::spawn(async move { - let event = task.await; - let _ = sender.send(event); - }); - } - InteractionResult::CloseScreen => { - let mut stack = self.screen_stack.write().await; - stack.pop(); - - if stack.is_empty() && self.shutdown_on_empty { - self.request_shutdown(); - } - } - InteractionResult::Handled => {} - InteractionResult::Unhandled => {} - } - self.invalidate(); - } - - async fn dispatch_action(self: &Arc, action: AppAction) { - match action { - AppAction::Quit => self.request_shutdown(), - AppAction::OpenMain => { - let mut stack = self.screen_stack.write().await; - if stack.len() > 1 { - stack.truncate(1); - } - drop(stack); - self.invalidate(); - } - AppAction::OpenOverview => { - let status = { - let stack = self.screen_stack.read().await; - stack - .iter() - .rev() - .find_map(|s| s.as_any().downcast_ref::()) - .map(|main| (main.connection_status(), main.daemon_status())) + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::TuCredentialPreview(preview), + )) => Ok(preview), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Credential inspection failed: {error}")) + } + Ok(_) => Err( + "Credential inspection failed: daemon returned an unexpected response." + .into(), + ), + Err(error) => Err(format!("Credential inspection failed: {error}")), }; - if let Some((connection, daemon)) = status { - self.set_screen(Box::new(OverviewScreen::new(connection, daemon))) - .await; - } + AppEvent::TuInspected(result) } - AppAction::OpenUsers => self.open_users().await, - AppAction::OpenSettings => { - let current = self.theme_name().await; - self.set_screen(Box::new(SettingsScreen::new(current))) - .await; - } - AppAction::OpenMetrics => { - if let Some(screen) = MetricsScreen::new(self.clone()).await { - self.set_screen(Box::new(screen)).await; - } - } - action => { - let result = { - let mut stack = self.screen_stack.write().await; - stack.last_mut().map(|screen| screen.handle_action(action)) + IotaEffect::AttachUserFromTu { credential } => { + let result = match ipc + .send_request(iota_ipc::LocalRequest::AttachUserFromTu { credential }) + .await + { + Ok(iota_ipc::ResponseResult::Ok(_)) => Ok("Attached existing user.".into()), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Attach existing user failed: {error}")) + } + Err(error) => Err(format!("Attach existing user failed: {error}")), }; - if matches!(result, Some(InteractionResult::CloseScreen)) { - let mut stack = self.screen_stack.write().await; - stack.pop(); - } - self.invalidate(); + AppEvent::UserOperationFinished(result) } - } - } - async fn open_users(self: &Arc) { - let Some(ipc) = self.ipc().await else { return }; - self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) - .await; - let sender = self.app_event_tx.clone(); - let ui = self.clone(); - tokio::spawn(async move { - let load = async { - match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|u| UserEntry { - user_id: u.user_id, - username: u.username, - state: u.state, - data_present: u.data_present, - credential_status: u.credential_status, - pending_operation: u.pending_operation, - }) - .collect()) + IotaEffect::RunUserOperation { action, user_id } => { + let response = match action { + UserAction::Release => { + ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id }) + .await + } + UserAction::PurgeData => { + ipc.send_request(iota_ipc::LocalRequest::PurgeUserData { user_id }) + .await + } + UserAction::ExportTu => { + let event = AppEvent::UserOperationFinished(Err( + "Credential export requires a destination path.".into(), + )); + let _ = tx.send(IotaMessage::Event(event)).await; + return; + } + UserAction::DeleteAccount => { + ipc.send_request(iota_ipc::LocalRequest::CompleteDeleteUser { + user_id, + credential: None, + }) + .await + } + UserAction::Reconcile => { + ipc.send_request(iota_ipc::LocalRequest::ReconcileUser { user_id }) + .await + } + UserAction::Diagnostics => { + ipc.send_request(iota_ipc::LocalRequest::GetUserDiagnostics { user_id }) + .await + } + UserAction::ForceDetach => { + ipc.send_request(iota_ipc::LocalRequest::ForceDetachUser { user_id }) + .await + } + UserAction::ForgetResidency => { + ipc.send_request(iota_ipc::LocalRequest::ForgetReleasedUser { user_id }) + .await + } + }; + let result = match response { + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::UserDiagnostics(diagnostics), + )) => Ok(format!( + "Diagnostics: state={:?}, data={}, credential={}, trusted apps={}, pending={}", + diagnostics.local_state, + if diagnostics.data_present { + "present" + } else { + "empty" + }, + credential_status_label(diagnostics.credential_status), + diagnostics.trusted_app_count, + diagnostics + .pending_operation + .unwrap_or_else(|| "none".into()), + )), + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::UserReconciled(result), + )) => Ok(format!("Reconciliation result: {:?}.", result.action)), + Ok(iota_ipc::ResponseResult::Ok(_)) => { + Ok(format!("{} completed.", action.label())) } Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot load users: {error}")) + Err(format!("{} failed: {error}", action.label())) + } + Err(error) => Err(format!("{} failed: {error}", action.label())), + }; + AppEvent::UserOperationFinished(result) + } + IotaEffect::ExportCredential { + user_id, + destination, + } => { + let result = match ipc + .send_request(iota_ipc::LocalRequest::ExportUserCredential { user_id }) + .await + { + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::UserCredentialExport { + user_id: response_user_id, + username, + credential, + }, + )) if response_user_id == user_id => { + write_private_export(&destination, credential.0.as_bytes()) + .map(|()| { + format!( + "Exported credential for {username} to {}.", + destination.display() + ) + }) + .map_err(|error| { + format!( + "Credential export failed for {}: {error}", + destination.display() + ) + }) + } + Ok(iota_ipc::ResponseResult::Ok(_)) => Err( + "Credential export failed: daemon returned an unexpected response.".into(), + ), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Credential export failed: {error}")) + } + Err(error) => Err(format!("Credential export failed: {error}")), + }; + AppEvent::CredentialExportFinished(result) + } + IotaEffect::CreateUser { username } => { + let result = match ipc + .send_request(iota_ipc::LocalRequest::CreateUser { username }) + .await + { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { + username, + user_id, + })) => Ok(format!("Created user {username} ({user_id}).")), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Create user failed: {error}")) } Ok(_) => { - Err("Daemon returned an unexpected response while loading users.".into()) + Err("Create user failed: daemon returned an unexpected response.".into()) } - Err(error) => Err(format!("Cannot load users: {error}")), - } - }; - tokio::pin!(load); - let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200)); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - let result = loop { - tokio::select! { - result = &mut load => break result, - _ = ticker.tick() => { - ui.invalidate(); - } - } - }; - let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); - }); - } - - pub async fn render(&self) -> io::Result<()> { - self.clear_expired_notifications().await; - let theme = self.theme.read().await.clone(); - let context = RenderContext { - theme: theme.as_ref(), - }; - // The renderer is the only task that takes the terminal lock. Screen - // mutations use the stack lock briefly before invalidating a frame. - let stack_guard = self.screen_stack.read().await; - let (connection, daemon) = stack_guard - .iter() - .find_map(|item| item.as_any().downcast_ref::()) - .map(|main| { - ( - main.connection_status().borrow().clone(), - main.daemon_status().borrow().clone(), - ) - }) - .unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default())); - if let Some(screen) = stack_guard.last() { - let mut terminal = self - .terminal - .lock() - .map_err(|_| io::Error::other("terminal mutex poisoned"))?; - let mut hits = HitMap::default(); - terminal.draw(|f| { - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(f.area()); - let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); - render_header( - f, - rows[0], - &connection, - &daemon, - context.theme, - &mut hits, - header_focus, - ); - let hints = if header_focus.is_some() { - " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() - } else { - let mut screen_hints: Vec = screen - .key_hints() - .into_iter() - .map(|hint| format!("{}: {}", hint.keys, hint.action)) - .collect(); - if !screen_hints.iter().any(|h| h.contains("?")) { - screen_hints.push("?: Help".to_owned()); - } - screen_hints.join(" ") + Err(error) => Err(format!("Create user failed: {error}")), }; - f.render_widget( - ratatui::widgets::Paragraph::new(format!(" {hints}")).style( - context - .theme - .surfaces - .footer - .patch(context.theme.text.muted), - ), - rows[2], - ); - screen.render(f, rows[1], &context, &mut hits); - - if let Ok(notifications) = self.notifications.try_lock() { - if !notifications.is_empty() { - let notification_area = Rect { - x: rows[1].x + rows[1].width.saturating_sub(40), - y: rows[1].y, - width: 40.min(rows[1].width), - height: 3.min(rows[1].height), - }; - render_notification_area( - f, - notification_area, - ¬ifications, - context.theme, - ); + AppEvent::UserOperationFinished(result) + } + IotaEffect::CreateInvitation { authority } => { + let result = match ipc + .send_request(iota_ipc::LocalRequest::CreateInvitation { + authority, + lifetime_seconds: 7 * 24 * 60 * 60, + password: None, + label: None, + }) + .await + { + Ok(iota_ipc::ResponseResult::Ok( + iota_ipc::ResponsePayload::InvitationCreated(invitation), + )) => Ok(format!( + "Invitation {} created. Token: {} Link: {}{}", + invitation.invitation_id, + invitation.raw_token.0, + invitation.short_url.as_deref().unwrap_or("unavailable"), + if invitation.mirror_synced { + "" + } else { + " Local metadata is still synchronizing." + }, + )), + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Create invitation failed: {error}")) } - } - })?; - if let Ok(mut current) = self.hits.lock() { - *current = hits; + Ok(_) => Err( + "Create invitation failed: daemon returned an unexpected response.".into(), + ), + Err(error) => Err(format!("Create invitation failed: {error}")), + }; + AppEvent::UserOperationFinished(result) + } + IotaEffect::OpenUsers + | IotaEffect::OpenMetrics + | IotaEffect::OpenOverview + | IotaEffect::ApplyTheme { .. } + | IotaEffect::SaveSettings { .. } => return, + }; + let _ = tx.send(IotaMessage::Event(event)).await; + }); + Ok(()) +} + +fn bridge_daemon_updates(ipc: Arc, tx: mpsc::Sender) { + let mut connection = ipc.connection_status(); + let mut status = ipc.daemon_status(); + let mut updates = ipc.ui_updates(); + tokio::spawn(async move { + loop { + let changed = tokio::select! { + result = connection.changed() => result, + result = status.changed() => result, + result = updates.changed() => result, + }; + if changed.is_err() || tx.send(IotaMessage::Invalidate).await.is_err() { + break; } } - Ok(()) - } + }); } diff --git a/iota-cli/tests/button_layout.rs b/iota-cli/tests/button_layout.rs deleted file mode 100644 index ad230fd..0000000 --- a/iota-cli/tests/button_layout.rs +++ /dev/null @@ -1,15 +0,0 @@ -use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths}; - -#[test] -fn width_allocation_handles_exact_spare_and_insufficient_space() { - assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4])); - assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5])); - assert_eq!(horizontal_button_widths(6, &[3, 4]), None); - assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new())); -} - -#[test] -fn minimum_width_uses_terminal_columns() { - assert_eq!(button_minimum_width("é"), 3); - assert_eq!(button_minimum_width("界"), 4); -} diff --git a/iota-cli/tests/control_state.rs b/iota-cli/tests/control_state.rs deleted file mode 100644 index 3089bdb..0000000 --- a/iota-cli/tests/control_state.rs +++ /dev/null @@ -1,109 +0,0 @@ -use iota_cli::controls::{ - checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem}, - navigation::DisabledFocusPolicy, - radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem}, -}; - -fn checkbox(value: u8, enabled: bool) -> CheckboxItem { - CheckboxItem { - value, - label: value.to_string(), - description: None, - enabled, - disabled_reason: None, - } -} - -fn radio(value: u8, enabled: bool) -> RadioItem { - RadioItem { - value, - label: value.to_string(), - description: None, - enabled, - disabled_reason: None, - } -} - -#[test] -fn checkbox_selection_and_disabled_focus_are_independent() { - let mut group = CheckboxGroup::new( - vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)], - [1, 99], - ) - .unwrap(); - assert_eq!( - group.selected().iter().copied().collect::>(), - vec![1] - ); - assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1)); - group.focus_next(); - assert_eq!(group.focused_item().unwrap().value, 3); - group.set_focus_policy(DisabledFocusPolicy::Include); - group.focus_previous(); - assert_eq!(group.focused_item().unwrap().value, 2); - assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2)); -} - -#[test] -fn checkbox_non_wrapping_navigation_stops_at_the_edge() { - let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap(); - group.set_wrap_navigation(false); - group.focus_previous(); - assert_eq!(group.focused_item().unwrap().value, 1); -} - -#[test] -fn radio_validates_default_and_preserves_one_selection() { - assert!(matches!( - RadioGroup::new(Vec::>::new(), None, 1), - Err(RadioGroupError::Empty) - )); - assert!(matches!( - RadioGroup::new(vec![radio(1, true)], None, 2), - Err(RadioGroupError::DefaultMissing) - )); - assert!(matches!( - RadioGroup::new(vec![radio(1, false)], None, 1), - Err(RadioGroupError::DefaultDisabled) - )); - - let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap(); - assert_eq!(group.selected(), &2); - group.focus_next(); - assert_eq!(group.selected(), &2); - assert_eq!(group.select_focused(), RadioChange::Unchanged(2)); - group.focus_previous(); - assert_eq!( - group.select_focused(), - RadioChange::Changed { - previous: 2, - selected: 1 - } - ); - assert_eq!(group.selected(), &1); -} - -#[test] -fn groups_initially_focus_the_first_enabled_item() { - let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap(); - assert_eq!(checkboxes.focused_item().unwrap().value, 2); - let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap(); - assert_eq!(radios.focused_item().value, 2); -} - -#[test] -fn disabling_a_selected_radio_obeys_the_configured_policy() { - let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap(); - group.set_enabled(&2, false).unwrap(); - assert_eq!(group.selected(), &1); - - group.set_enabled(&2, true).unwrap(); - group.focus_next(); - group.select_focused(); - group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError); - assert_eq!( - group.set_enabled(&2, false), - Err(RadioGroupError::SelectedItemDisabled) - ); - assert_eq!(group.selected(), &2); -} diff --git a/iota-cli/tests/layout_fit.rs b/iota-cli/tests/layout_fit.rs deleted file mode 100644 index f8a60aa..0000000 --- a/iota-cli/tests/layout_fit.rs +++ /dev/null @@ -1,45 +0,0 @@ -use iota_cli::layout::fit::{ - FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level, -}; -use ratatui::layout::Rect; - -#[test] -fn selects_fit_by_both_dimensions() { - let preferred = RequiredSize { - width: 80, - height: 20, - }; - let compact = RequiredSize { - width: 50, - height: 12, - }; - assert_eq!( - select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact), - FitLevel::Preferred - ); - assert_eq!( - select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact), - FitLevel::Compact - ); - assert_eq!( - select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact), - FitLevel::Fallback - ); -} - -#[test] -fn rectangle_helpers_do_not_underflow() { - let zero = Rect::new(4, 5, 0, 0); - assert_eq!( - centered_rect( - zero, - RequiredSize { - width: 10, - height: 10 - } - ), - zero - ); - assert_eq!(reserve_vertical(zero, 1, 0), None); - assert_eq!(inset_checked(zero, 1, 1), None); -} diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs index 666bffe..53d0e3c 100644 --- a/iota-cli/tests/settings_snapshot.rs +++ b/iota-cli/tests/settings_snapshot.rs @@ -1,9 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use iota_cli::{ - interaction_result::InteractionResult, - render_context::RenderContext, screens::{ - screens::{AppEvent, HitMap, Screen, UiEvent}, + screens::{HitMap, IotaEffect, IotaInteraction, IotaScreen, UiEvent}, settings::SettingsScreen, }, theme::{ThemeName, resolve}, @@ -20,35 +18,21 @@ fn buffer_text(terminal: &Terminal) -> String { .collect() } -#[tokio::test] -async fn settings_preview_and_save_emit_typed_application_events() { +#[test] +fn settings_preview_and_save_emit_typed_effects() { let mut screen = SettingsScreen::new(ThemeName::Ansi); - let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right))); - let InteractionResult::AppTask { task } = preview else { - panic!("theme preview should emit an application task"); + let preview = screen.handle_event(&UiEvent::Key(KeyEvent::from(KeyCode::Right))); + let IotaInteraction::Effect(IotaEffect::ApplyTheme { theme, persist }) = preview else { + panic!("theme preview should emit an effect"); }; - assert!(matches!( - task.await, - UiEvent::App(AppEvent::ApplyTheme { - theme: ThemeName::Surface, - persist: false - }) - )); + assert_eq!(theme, ThemeName::Surface); + assert!(!persist); - let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter))); - let InteractionResult::AppTask { task } = save else { - panic!("theme save should emit an application task"); + let save = screen.handle_event(&UiEvent::Key(KeyEvent::from(KeyCode::Enter))); + let IotaInteraction::Effect(IotaEffect::SaveSettings { theme, .. }) = save else { + panic!("theme save should emit an effect"); }; - assert!(matches!( - task.await, - UiEvent::App(AppEvent::SaveSettings { - theme: ThemeName::Surface, - color: _, - unicode: _, - cli_output: _, - cli_require_confirmation: _ - }) - )); + assert_eq!(theme, ThemeName::Surface); } #[test] @@ -60,12 +44,7 @@ fn settings_is_readable_in_every_theme_and_layout() { let screen = SettingsScreen::new(theme_name); terminal .draw(|frame| { - screen.render( - frame, - frame.area(), - &RenderContext { theme: &theme }, - &mut HitMap::default(), - ); + screen.render_legacy(frame, frame.area(), &theme, &mut HitMap::new()); }) .unwrap(); let rendered = buffer_text(&terminal); diff --git a/iota-connection/Cargo.toml b/iota-connection/Cargo.toml index 2d27a9e..8e03415 100644 --- a/iota-connection/Cargo.toml +++ b/iota-connection/Cargo.toml @@ -4,9 +4,17 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = "0.1.89" +base64 = "0.22.1" +iota-auth = { path = "../iota-auth" } +iota-identity = { path = "../iota-identity" } +iota-logger = { path = "../iota-logger" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = ["crypto"] } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1.50.0", features = ["rt"] } [dev-dependencies] tokio = { version = "1.50.0", features = ["macros", "rt"] } diff --git a/iota-connection/src/connection_handler.rs b/iota-connection/src/connection_handler.rs index 6a20bb1..2f022f7 100644 --- a/iota-connection/src/connection_handler.rs +++ b/iota-connection/src/connection_handler.rs @@ -1,36 +1,89 @@ -use mtp::codec::CommunicationValue; -use std::future::Future; +use async_trait::async_trait; +use iota_identity::{AuthorityId, IotaNodeId}; +use mtp::codec::{CommunicationType, CommunicationValue}; +use std::fmt; use std::time::Duration; -/// Unified interface for all connection types (Omikron, Direct, future modes). -/// -/// Provides the common messaging API that the rest of the codebase uses, -/// regardless of whether the connection goes through Omikron or is direct. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ConnectionError { + Disconnected(String), + Timeout(String), + Protocol(String), +} + +impl fmt::Display for ConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disconnected(message) | Self::Timeout(message) | Self::Protocol(message) => { + formatter.write_str(message) + } + } + } +} + +impl std::error::Error for ConnectionError {} + +#[async_trait] pub trait ConnectionHandler: Send + Sync { - /// Send a message to the remote end. - fn send_message( - &self, - cv: &CommunicationValue, - ) -> impl Future> + Send; - - /// Send a message and wait for a correlated response. - /// - /// The implementation correlates requests/responses by message ID and - /// enforces the given `timeout`. Returns an error on timeout or if the - /// connection drops while waiting. - fn await_response( + async fn send_message(&self, value: &CommunicationValue) -> Result<(), ConnectionError>; + + async fn await_response( &self, - cv: &CommunicationValue, + value: &CommunicationValue, timeout: Option, - ) -> impl Future> + Send; + ) -> Result; + + async fn is_connected(&self) -> bool; - /// Returns `true` when the connection is alive and ready for traffic. - fn is_connected(&self) -> impl Future + Send; + async fn is_identified(&self) -> bool; - /// Returns `true` when the connection has completed identification / - /// registration and is fully operational. - fn is_identified(&self) -> impl Future + Send; + async fn stop(&self); +} - /// Gracefully tear down the connection. - fn stop(&self) -> impl Future + Send; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RouteDestination { + Iota(IotaNodeId), + LegacyOmegaIota { omega: AuthorityId, iota_id: u64 }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RouteOutcome { + Accepted { + relay_message_id: String, + destination_accepted_at: i64, + }, + Rejected { + response_type: CommunicationType, + }, + Retryable { + reason: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RouteError { + NoRoute(IotaNodeId), + Delivery(String), +} + +impl fmt::Display for RouteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoRoute(destination) => { + write!(formatter, "no route to Iota {}", destination.as_str()) + } + Self::Delivery(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for RouteError {} + +#[async_trait] +pub trait PeerRouter: Send + Sync { + async fn route( + &self, + destination: &RouteDestination, + frame: CommunicationValue, + ) -> Result; } diff --git a/iota-connection/src/federated_relay.rs b/iota-connection/src/federated_relay.rs new file mode 100644 index 0000000..0c1ba1d --- /dev/null +++ b/iota-connection/src/federated_relay.rs @@ -0,0 +1,438 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use iota_identity::{ + IdentityError, IdentityResolver, IotaNodeId, PrincipalId, ResolutionContext, ResolvedPrincipal, + verify_dual_signature, +}; +use iota_util::mtp_compat::OptionalDataValueExt; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap}; +use mtp::crypto::{Keyring, SignatureScheme}; +use serde::{Deserialize, Serialize}; + +const SIGNING_DOMAIN: &[u8] = b"tensamin-federated-relay:v2"; + +#[derive(Clone, Debug)] +pub struct FederatedRelayV2 { + pub signer: PrincipalId, + pub recipient: PrincipalId, + pub destination: IotaNodeId, + pub message_id: String, + pub created_at: u64, + pub content: CommunicationValue, + pub signature: Vec, +} + +#[derive(Clone, Debug)] +pub struct VerifiedFederatedRelayV2 { + pub signer: ResolvedPrincipal, + pub recipient: ResolvedPrincipal, + pub destination: IotaNodeId, + pub message_id: String, + pub created_at: u64, + pub content: CommunicationValue, + pub frame: CommunicationValue, +} + +#[derive(Serialize, Deserialize)] +struct FederatedRelayWireV2 { + version: u32, + signer: PrincipalId, + recipient: PrincipalId, + destination: IotaNodeId, + message_id: String, + created_at: u64, + type_map_version: String, + content: String, + signature: String, +} + +impl FederatedRelayV2 { + pub fn sign( + signer: PrincipalId, + recipient: PrincipalId, + destination: IotaNodeId, + message_id: String, + created_at: u64, + content: CommunicationValue, + keyring: &Keyring, + ) -> Result { + validate_fields(&signer, &recipient, &message_id, &content)?; + let signer_impl = mtp::crypto::DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let signature = signer_impl + .sign(&canonical_bytes( + &signer, + &recipient, + &destination, + &message_id, + created_at, + &content, + )?) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + Ok(Self { + signer, + recipient, + destination, + message_id, + created_at, + content, + signature, + }) + } + + pub fn into_frame(self, frame_id: u32) -> Result { + let type_map = self.content.type_map().ok_or_else(|| { + IdentityError::InvalidDescriptor("federated relay content has no type map".into()) + })?; + let content = self + .content + .to_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let wire = FederatedRelayWireV2 { + version: 2, + signer: self.signer, + recipient: self.recipient, + destination: self.destination, + message_id: self.message_id, + created_at: self.created_at, + type_map_version: type_map.version.to_string(), + content: STANDARD.encode(content), + signature: STANDARD.encode(self.signature), + }; + let payload = serde_json::to_vec(&wire) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + Ok(CommunicationValue::new(CommunicationType::Relay) + .with_id(frame_id) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2)) + .add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload))) + } +} + +pub async fn decode_and_verify( + frame: CommunicationValue, + identities: &dyn IdentityResolver, + local_node: &IotaNodeId, +) -> Result { + decode_and_verify_for_ingress(frame, identities, Some(local_node)).await +} + +pub async fn decode_and_verify_for_ingress( + frame: CommunicationValue, + identities: &dyn IdentityResolver, + expected_destination: Option<&IotaNodeId>, +) -> Result { + if !frame.is_type(CommunicationType::Relay) + || frame.get_data(DataType::VersionNumber).as_number() != Some(2) + { + return Err(IdentityError::InvalidDescriptor( + "frame is not Federated Relay V2".into(), + )); + } + let payload = frame + .get_data(DataType::SecurePayload) + .and_then(|value| match value { + DataValue::Bytes(bytes) => Some(bytes.as_slice()), + _ => None, + }) + .ok_or_else(|| IdentityError::InvalidDescriptor("Relay V2 payload is missing".into()))?; + let wire: FederatedRelayWireV2 = serde_json::from_slice(payload) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + if wire.version != 2 + || expected_destination.is_some_and(|expected| &wire.destination != expected) + { + return Err(IdentityError::InvalidDescriptor( + "Relay V2 destination does not match this Iota".into(), + )); + } + let type_map_version = + mtp::type_map::Version::parse(&wire.type_map_version).ok_or_else(|| { + IdentityError::InvalidDescriptor("Relay V2 type-map version is invalid".into()) + })?; + let content_bytes = STANDARD + .decode(&wire.content) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let content = + CommunicationValue::from_bytes_with(&content_bytes, &TypeMap::new(type_map_version)) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + validate_fields(&wire.signer, &wire.recipient, &wire.message_id, &content)?; + let signature = STANDARD + .decode(&wire.signature) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let context = ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }; + let signer = identities + .resolve_principal_with_context(&wire.signer, &context) + .await?; + let recipient = identities + .resolve_principal_with_context(&wire.recipient, &context) + .await?; + if !matches!( + &recipient.home, + iota_identity::PrincipalHome::Iota(home) if home == &wire.destination + ) { + return Err(IdentityError::InvalidDescriptor( + "Relay V2 destination does not match recipient home".into(), + )); + } + let bytes = canonical_bytes( + &wire.signer, + &wire.recipient, + &wire.destination, + &wire.message_id, + wire.created_at, + &content, + )?; + if !signer + .public_keys + .iter() + .any(|key| verify_dual_signature(key, &bytes, &signature).is_ok()) + { + return Err(IdentityError::InvalidDescriptor( + "Relay V2 user signature is invalid".into(), + )); + } + Ok(VerifiedFederatedRelayV2 { + signer, + recipient, + destination: wire.destination, + message_id: wire.message_id, + created_at: wire.created_at, + content, + frame, + }) +} + +fn validate_fields( + signer: &PrincipalId, + recipient: &PrincipalId, + message_id: &str, + content: &CommunicationValue, +) -> Result<(), IdentityError> { + if signer == recipient { + return Err(IdentityError::InvalidDescriptor( + "Relay V2 signer and recipient are identical".into(), + )); + } + if message_id.is_empty() || message_id.len() > 256 { + return Err(IdentityError::InvalidDescriptor( + "Relay V2 message ID is invalid".into(), + )); + } + if content.type_map().is_none() || content.is_type(CommunicationType::Relay) { + return Err(IdentityError::InvalidDescriptor( + "Relay V2 content is invalid".into(), + )); + } + Ok(()) +} + +fn canonical_bytes( + signer: &PrincipalId, + recipient: &PrincipalId, + destination: &IotaNodeId, + message_id: &str, + created_at: u64, + content: &CommunicationValue, +) -> Result, IdentityError> { + let mut bytes = SIGNING_DOMAIN.to_vec(); + push(&mut bytes, signer.authority.as_str().as_bytes())?; + bytes.extend_from_slice(&signer.user_id.to_be_bytes()); + push(&mut bytes, recipient.authority.as_str().as_bytes())?; + bytes.extend_from_slice(&recipient.user_id.to_be_bytes()); + push(&mut bytes, destination.as_str().as_bytes())?; + push(&mut bytes, message_id.as_bytes())?; + bytes.extend_from_slice(&created_at.to_be_bytes()); + push( + &mut bytes, + &content + .to_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?, + )?; + Ok(bytes) +} + +fn push(output: &mut Vec, value: &[u8]) -> Result<(), IdentityError> { + let length = u32::try_from(value.len()) + .map_err(|_| IdentityError::InvalidDescriptor("Relay V2 field is too large".into()))?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use iota_identity::{ + AuthorityId, PrincipalHandle, PrincipalHome, PublicKeyBundle, ResolvedPrincipal, + UserAddress, + }; + + struct Resolver { + principals: Vec, + } + + #[async_trait] + impl IdentityResolver for Resolver { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + self.principals + .iter() + .find(|candidate| &candidate.principal == principal) + .cloned() + .ok_or(IdentityError::NotFound) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + self.resolve_principal(principal) + .await + .map(|principal| principal.public_keys) + } + } + + fn resolved( + authority: &AuthorityId, + user_id: u64, + handle: i64, + key: PublicKeyBundle, + home: IotaNodeId, + ) -> ResolvedPrincipal { + ResolvedPrincipal { + principal: PrincipalId { + authority: authority.clone(), + user_id, + }, + handle: PrincipalHandle(handle), + username: None, + public_keys: vec![key], + home: PrincipalHome::Iota(home), + descriptor_revision: 1, + valid_until: None, + resolved_at: 1, + } + } + + fn relay() -> ( + CommunicationValue, + Resolver, + IotaNodeId, + AuthorityId, + AuthorityId, + ) { + let signer_node = + iota_identity::LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let recipient_node = + iota_identity::LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let signer = Keyring::generate(); + let recipient = Keyring::generate(); + let signer_principal = PrincipalId { + authority: signer_node.authority_id().clone(), + user_id: 7, + }; + let recipient_principal = PrincipalId { + authority: recipient_node.authority_id().clone(), + user_id: 7, + }; + let content = CommunicationValue::new(CommunicationType::MessageSend) + .add_typed_default(DataType::Content, DataValue::Str("ciphertext".into())) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(10)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)); + let frame = FederatedRelayV2::sign( + signer_principal.clone(), + recipient_principal.clone(), + recipient_node.node_id().clone(), + "same-id".into(), + 10, + content, + &signer, + ) + .unwrap() + .into_frame(5) + .unwrap(); + let resolver = Resolver { + principals: vec![ + resolved( + signer_node.authority_id(), + 7, + 1, + signer.public_key_bundle(), + signer_node.node_id().clone(), + ), + resolved( + recipient_node.authority_id(), + 7, + 2, + recipient.public_key_bundle(), + recipient_node.node_id().clone(), + ), + ], + }; + ( + frame, + resolver, + recipient_node.node_id().clone(), + signer_node.authority_id().clone(), + recipient_node.authority_id().clone(), + ) + } + + #[tokio::test] + async fn equal_user_ids_from_different_iotas_verify_as_distinct_principals() { + let (frame, resolver, node, signer_authority, recipient_authority) = relay(); + let verified = decode_and_verify(frame, &resolver, &node).await.unwrap(); + assert_eq!(verified.signer.principal.user_id, 7); + assert_eq!(verified.recipient.principal.user_id, 7); + assert_eq!(verified.signer.principal.authority, signer_authority); + assert_eq!(verified.recipient.principal.authority, recipient_authority); + } + + #[tokio::test] + async fn changing_either_authority_invalidates_relay_signature() { + for field in ["signer", "recipient"] { + let (mut frame, mut resolver, node, _, _) = relay(); + let payload = frame + .get_data(DataType::SecurePayload) + .and_then(|value| match value { + DataValue::Bytes(bytes) => Some(bytes.clone()), + _ => None, + }) + .unwrap(); + let mut wire: FederatedRelayWireV2 = serde_json::from_slice(&payload).unwrap(); + let changed = AuthorityId::new("authority:iota:v1:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff").unwrap(); + if field == "signer" { + resolver.principals[0].principal.authority = changed.clone(); + wire.signer.authority = changed; + } else { + resolver.principals[1].principal.authority = changed.clone(); + wire.recipient.authority = changed; + } + frame = CommunicationValue::new(CommunicationType::Relay) + .with_id(5) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(2)) + .add_typed_default( + DataType::SecurePayload, + DataValue::Bytes(serde_json::to_vec(&wire).unwrap()), + ); + assert!(decode_and_verify(frame, &resolver, &node).await.is_err()); + } + } +} diff --git a/iota-connection/src/lib.rs b/iota-connection/src/lib.rs index db303ab..cbaa654 100644 --- a/iota-connection/src/lib.rs +++ b/iota-connection/src/lib.rs @@ -1,4 +1,6 @@ pub mod connection_handler; +pub mod federated_relay; pub mod message_common; pub mod message_handlers; pub mod relay; +pub mod relay_service; diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 886d527..f3ed19e 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -25,6 +25,8 @@ fn next_notification_id() -> u32 { pub struct MessageMutation { pub sender_id: i64, pub partner_id: i64, + pub partner_principal: iota_identity::PrincipalHandle, + pub sender_principal: iota_identity::PrincipalHandle, pub send_time: i64, } @@ -77,10 +79,20 @@ pub fn message_mutation(cv: &CommunicationValue) -> Result 0) .ok_or_else(|| error_response(cv, CommunicationType::ErrorInvalidData))?; + let partner_principal = chats_util::get_user(sender_id, partner_id) + .map_err(|_| error_response(cv, CommunicationType::ErrorInternal))? + .and_then(|contact| contact.principal) + .ok_or_else(|| error_response(cv, CommunicationType::ErrorNotFound))?; + let sender_principal = iota_storage::identity::SqlitePrincipalStore + .principal_for_local_user(iota_identity::LocalUserId(sender_id)) + .map_err(|_| error_response(cv, CommunicationType::ErrorInternal))? + .ok_or_else(|| error_response(cv, CommunicationType::ErrorNotFound))?; Ok(MessageMutation { sender_id, partner_id, + partner_principal, + sender_principal, send_time, }) } @@ -169,6 +181,8 @@ fn validate_relay_identity( pub fn apply_verified_relay_content( context: &VerifiedRelayContext, content: &VerifiedRelayContent, + signer_principal: iota_identity::PrincipalHandle, + recipient_principal: iota_identity::PrincipalHandle, accepted_at: i64, storage_owner: i64, sent_by_self: bool, @@ -205,6 +219,7 @@ pub fn apply_verified_relay_content( return chat_files::change_message_state_by_relay_id( storage_owner, recipient_id, + recipient_principal, relay_message_id, state, ) @@ -213,8 +228,10 @@ pub fn apply_verified_relay_content( chat_files::record_message_receipt( storage_owner, recipient_id, + recipient_principal, relay_message_id, sender_id, + signer_principal, &context.message_id, state, event_at, @@ -251,15 +268,25 @@ pub fn apply_verified_relay_content( "Relay MessageSend identity does not match its protected message ID".into(), ); } - let chat_id = if sender_id < recipient_id { + let chat_id = e2ee_storage::principal_chat_id(signer_principal, recipient_principal) + .ok_or_else(|| "Relay MessageSend has an invalid principal pair".to_string())?; + let legacy_chat_id = if sender_id < recipient_id { format!("{sender_id}:{recipient_id}") } else { format!("{recipient_id}:{sender_id}") }; + e2ee_storage::migrate_chat_secret_namespace( + &storage_owner.to_string(), + &legacy_chat_id, + &format!("chat:{legacy_chat_id}:main"), + &chat_id, + &e2ee_storage::principal_secret_id(&chat_id), + ) + .map_err(|error| error.to_string())?; let latest_secret = e2ee_storage::get_chat_secret(ChatSecretQuery { user_id: storage_owner.to_string(), chat_id: chat_id.clone(), - secret_id: Some(format!("chat:{chat_id}:main")), + secret_id: Some(e2ee_storage::principal_secret_id(&chat_id)), version: None, }) .map_err(|error| error.to_string())? @@ -269,6 +296,7 @@ pub fn apply_verified_relay_content( } chat_files::add_message(chat_files::NewMessage { relay_signer_id: sender_id, + relay_signer_principal: signer_principal, relay_message_id: &context.message_id, authored_at: created_at, send_time, @@ -278,6 +306,11 @@ pub fn apply_verified_relay_content( } else { sender_id }, + external_principal: if sent_by_self { + recipient_principal + } else { + signer_principal + }, sent_by_self, content: message, height, @@ -305,18 +338,25 @@ pub fn apply_verified_relay_content( } else { sender_id }; + let external_principal = if sent_by_self { + recipient_principal + } else { + signer_principal + }; let result = if sent_by_self { - chat_files::edit_message( + chat_files::edit_message_for_principal( storage_owner, external_user, + external_principal, send_time, sender_id, message, ) } else { - chat_files::apply_remote_edit( + chat_files::apply_remote_edit_for_principal( storage_owner, external_user, + external_principal, send_time, sender_id, message, @@ -336,20 +376,29 @@ pub fn apply_verified_relay_content( } else { sender_id }; + let external_principal = if sent_by_self { + recipient_principal + } else { + signer_principal + }; let result = if content.message_type == CommunicationType::MessageReactionAdd { - chat_files::add_reaction( + chat_files::add_reaction_for_principal( storage_owner, external_user, + external_principal, send_time, sender_id, + signer_principal, reaction, ) } else { - chat_files::remove_reaction( + chat_files::remove_reaction_for_principal( storage_owner, external_user, + external_principal, send_time, sender_id, + signer_principal, reaction, ) }; @@ -364,10 +413,26 @@ pub fn apply_verified_relay_content( } else { sender_id }; - let result = if sent_by_self { - chat_files::delete_message(storage_owner, external_user, send_time) + let external_principal = if sent_by_self { + recipient_principal } else { - chat_files::apply_remote_delete(storage_owner, external_user, send_time, sender_id) + signer_principal + }; + let result = if sent_by_self { + chat_files::delete_message_for_principal( + storage_owner, + external_user, + external_principal, + send_time, + ) + } else { + chat_files::apply_remote_delete_for_principal( + storage_owner, + external_user, + external_principal, + send_time, + sender_id, + ) }; result.map_err(|error| error.to_string()) } @@ -434,10 +499,23 @@ pub fn apply_verified_relay_content( if recipient_ids != expected_recipient_ids { return Err("Relay SetChatSecret recipients do not match relay identities".into()); } + let principal_chat_id = + e2ee_storage::principal_chat_id(signer_principal, recipient_principal).ok_or_else( + || "Relay SetChatSecret has an invalid principal pair".to_string(), + )?; + let principal_secret_id = e2ee_storage::principal_secret_id(&principal_chat_id); + e2ee_storage::migrate_chat_secret_namespace( + &storage_owner.to_string(), + &chat_id, + &secret_id, + &principal_chat_id, + &principal_secret_id, + ) + .map_err(|error| error.to_string())?; e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { user_id: storage_owner.to_string(), - chat_id, - secret_id, + chat_id: principal_chat_id, + secret_id: principal_secret_id, version, encrypted_secret: recipient.encrypted_secret.clone(), kem_ciphertext: recipient.kem_ciphertext.clone(), @@ -520,9 +598,10 @@ pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue { return error_response(cv, CommunicationType::ErrorInvalidData); }; - match chat_files::edit_message( + match chat_files::edit_message_for_principal( mutation.sender_id, mutation.partner_id, + mutation.partner_principal, mutation.send_time, mutation.sender_id, content, @@ -545,19 +624,23 @@ pub fn handle_message_reaction(cv: &CommunicationValue, add: bool) -> Communicat } let result = if add { - chat_files::add_reaction( + chat_files::add_reaction_for_principal( mutation.sender_id, mutation.partner_id, + mutation.partner_principal, mutation.send_time, mutation.sender_id, + mutation.sender_principal, reaction, ) } else { - chat_files::remove_reaction( + chat_files::remove_reaction_for_principal( mutation.sender_id, mutation.partner_id, + mutation.partner_principal, mutation.send_time, mutation.sender_id, + mutation.sender_principal, reaction, ) }; @@ -577,7 +660,12 @@ pub fn handle_message_delete(cv: &CommunicationValue) -> CommunicationValue { Err(response) => return response, }; - match chat_files::delete_message(mutation.sender_id, mutation.partner_id, mutation.send_time) { + match chat_files::delete_message_for_principal( + mutation.sender_id, + mutation.partner_id, + mutation.partner_principal, + mutation.send_time, + ) { Ok(()) => success_response(cv), Err(_) => error_response(cv, CommunicationType::ErrorNotFound), } @@ -738,18 +826,92 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { return error_response(cv, CommunicationType::ErrorInvalidData); }; + let mut participants = match chat_id + .split(':') + .map(str::parse::) + .collect::, _>>() + { + Ok(participants) => participants, + Err(_) => return error_response(cv, CommunicationType::ErrorInvalidData), + }; + if participants.len() != 2 + || participants.iter().any(|participant| *participant <= 0) + || participants[0] == participants[1] + || !participants.contains(&sender_id) + { + return error_response(cv, CommunicationType::ErrorInvalidData); + } + participants.sort_unstable(); + if chat_id != format!("{}:{}", participants[0], participants[1]) { + return error_response(cv, CommunicationType::ErrorInvalidData); + } + let partner_id = if participants[0] == sender_id { + participants[1] + } else { + participants[0] + }; + let partner_principal = match chats_util::get_user(sender_id, partner_id) { + Ok(Some(contact)) => match contact.principal { + Some(principal) => principal, + None => return error_response(cv, CommunicationType::ErrorNotFound), + }, + Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; + let owner_principal = match iota_storage::identity::SqlitePrincipalStore + .principal_for_local_user(iota_identity::LocalUserId(sender_id)) + { + Ok(Some(principal)) => principal, + Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; + let Some(principal_chat_id) = + e2ee_storage::principal_chat_id(owner_principal, partner_principal) + else { + return error_response(cv, CommunicationType::ErrorInvalidData); + }; + let requested_secret_id = data_string(cv, DataType::SecretId); + let legacy_main_secret_id = format!("chat:{chat_id}:main"); + if e2ee_storage::migrate_chat_secret_namespace( + &user_id, + &chat_id, + &legacy_main_secret_id, + &principal_chat_id, + &e2ee_storage::principal_secret_id(&principal_chat_id), + ) + .is_err() + { + return error_response(cv, CommunicationType::ErrorInternal); + } + let principal_secret_id = requested_secret_id.as_ref().map(|secret_id| { + if secret_id == &legacy_main_secret_id { + e2ee_storage::principal_secret_id(&principal_chat_id) + } else { + format!("{principal_chat_id}:{secret_id}") + } + }); + match e2ee_storage::get_chat_secret(ChatSecretQuery { user_id, - chat_id, - secret_id: data_string(cv, DataType::SecretId), + chat_id: principal_chat_id.clone(), + secret_id: principal_secret_id, version: data_i64(cv, DataType::VersionNumber), }) { Ok(Some(record)) => CommunicationValue::new(CommunicationType::ChatSecretResponse) .with_request_id(cv) .with_receiver(sender_wire_id(sender_id)) .add_typed_default(DataType::UserId, DataValue::Str(record.user_id)) - .add_typed_default(DataType::ChatId, DataValue::Str(record.chat_id)) - .add_typed_default(DataType::SecretId, DataValue::Str(record.secret_id)) + .add_typed_default(DataType::ChatId, DataValue::Str(chat_id)) + .add_typed_default( + DataType::SecretId, + DataValue::Str(requested_secret_id.unwrap_or_else(|| { + if record.secret_id == e2ee_storage::principal_secret_id(&principal_chat_id) { + legacy_main_secret_id + } else { + record.secret_id + } + })), + ) .add_typed_default( DataType::VersionNumber, DataValue::SignedNumber(record.version as i128), @@ -1030,7 +1192,7 @@ pub fn handle_account_state_request(cv: &CommunicationValue) -> CommunicationVal DataValue::Array( blocked_users .into_iter() - .map(|id| DataValue::SignedNumber(id.into())) + .map(|blocked| DataValue::SignedNumber(blocked.blocked_user_id.into())) .collect(), ), ); @@ -1153,7 +1315,7 @@ pub fn handle_message_get(cv: &CommunicationValue) -> CommunicationValue { Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), Err(_) => return error_response(cv, CommunicationType::ErrorInternal), }, - None => match chat_files::get_message(owner, send_time, None) { + None => match chat_files::get_message(owner, send_time, None, None) { Ok(Some(message)) => (message, None), Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), Err(_) => return error_response(cv, CommunicationType::ErrorInternal), @@ -1262,7 +1424,20 @@ pub fn handle_read_notification(cv: &CommunicationValue) -> NotificationMutation }; }; - match chats_util::read_notifications(owner, partner_id, through) { + let Ok(Some(contact)) = chats_util::get_user(owner, partner_id) else { + return NotificationMutation { + response: error_response(cv, CommunicationType::ErrorNotFound), + changed: None, + }; + }; + let Some(principal) = contact.principal else { + return NotificationMutation { + response: error_response(cv, CommunicationType::ErrorInternal), + changed: None, + }; + }; + + match chats_util::read_notifications(owner, principal, through) { Ok(Some(contact)) => NotificationMutation { response: notification_response( CommunicationType::ReadNotification, @@ -2161,7 +2336,19 @@ pub fn handle_user_block(cv: &CommunicationValue) -> PolicyMutation { changed: None, }; }; - match blocked_users::block(user_id, blocked_user_id) { + let Ok(Some(contact)) = chats_util::get_user(user_id, blocked_user_id) else { + return PolicyMutation { + response: error_response(cv, CommunicationType::ErrorNotFound), + changed: None, + }; + }; + let Some(blocked_principal) = contact.principal else { + return PolicyMutation { + response: error_response(cv, CommunicationType::ErrorInternal), + changed: None, + }; + }; + match blocked_users::block(user_id, blocked_principal) { Ok(record) => PolicyMutation { response: CommunicationValue::new(CommunicationType::UserBlock) .with_request_id(cv) @@ -2209,7 +2396,19 @@ pub fn handle_user_unblock(cv: &CommunicationValue) -> PolicyMutation { changed: None, }; }; - match blocked_users::unblock(user_id, blocked_user_id) { + let Ok(Some(contact)) = chats_util::get_user(user_id, blocked_user_id) else { + return PolicyMutation { + response: error_response(cv, CommunicationType::ErrorNotFound), + changed: None, + }; + }; + let Some(blocked_principal) = contact.principal else { + return PolicyMutation { + response: error_response(cv, CommunicationType::ErrorInternal), + changed: None, + }; + }; + match blocked_users::unblock(user_id, blocked_principal) { Ok(mutation) => PolicyMutation { response: CommunicationValue::new(CommunicationType::UserUnblock) .with_request_id(cv) @@ -2253,7 +2452,7 @@ pub fn handle_blocked_users_get(cv: &CommunicationValue) -> CommunicationValue { DataValue::Array( users .into_iter() - .map(|id| DataValue::SignedNumber(id.into())) + .map(|blocked| DataValue::SignedNumber(blocked.blocked_user_id.into())) .collect(), ), ), @@ -2422,7 +2621,15 @@ pub fn handle_user_block_check(cv: &CommunicationValue) -> CommunicationValue { let Some(receiver_id) = data_i64(cv, DataType::ReceiverId).filter(|id| *id > 0) else { return error_response(cv, CommunicationType::ErrorInvalidData); }; - match blocked_users::is_blocked(receiver_id, sender_id) { + let sender_principal = match chats_util::get_user(receiver_id, sender_id) { + Ok(Some(contact)) => match contact.principal { + Some(principal) => principal, + None => return error_response(cv, CommunicationType::ErrorNotFound), + }, + Ok(None) => return error_response(cv, CommunicationType::ErrorNotFound), + Err(_) => return error_response(cv, CommunicationType::ErrorInternal), + }; + match blocked_users::is_principal_blocked(receiver_id, sender_principal) { Ok(blocked) => CommunicationValue::new(CommunicationType::UserBlockCheck) .with_request_id(cv) .add_typed_default(DataType::IsBlocked, DataValue::Bool(blocked)), @@ -2439,9 +2646,11 @@ mod stored_message_tests { #[test] fn relay_message_value_has_a_single_sender_id() { let message = StoredMessage { + external_principal: None, id: 1, external_user: 9, relay_signer_id: Some(9), + relay_signer_principal: None, relay_message_id: Some("relay-1".to_string()), message_time: 2, authored_at: None, @@ -2549,3 +2758,47 @@ mod synced_settings_tests { assert!(response.is_err()); } } +#[derive(Clone, Debug)] +pub struct RelayApplicationContext { + pub signer: iota_identity::PrincipalHandle, + pub recipient: iota_identity::PrincipalHandle, + pub signer_principal: iota_identity::PrincipalId, + pub recipient_principal: iota_identity::PrincipalId, + pub hosted_sender: Option, + pub hosted_recipient: Option, + pub legacy: Option, +} + +#[derive(Clone, Debug)] +pub struct LegacyRelayApplicationData { + pub signer_id: u64, + pub recipient_id: u64, +} + +pub fn apply_relay_application_content( + application: &RelayApplicationContext, + context: &VerifiedRelayContext, + content: &VerifiedRelayContent, + accepted_at: i64, +) -> Result<(), String> { + if application.signer_principal.user_id != context.signer_id + || application.recipient_principal.user_id != context.final_recipient_id + { + return Err("Relay application identity does not match verified content".into()); + } + let (storage_owner, sent_by_self) = + match (application.hosted_sender, application.hosted_recipient) { + (Some(sender), _) => (sender.0, true), + (None, Some(recipient)) => (recipient.0, false), + (None, None) => return Err("Relay application has no hosted principal".into()), + }; + apply_verified_relay_content( + context, + content, + application.signer, + application.recipient, + accepted_at, + storage_owner, + sent_by_self, + ) +} diff --git a/iota-connection/src/relay_service.rs b/iota-connection/src/relay_service.rs new file mode 100644 index 0000000..511156e --- /dev/null +++ b/iota-connection/src/relay_service.rs @@ -0,0 +1,1875 @@ +use async_trait::async_trait; +use iota_auth::{AuthenticatedSession, SessionCapability, SessionIdentity}; +#[cfg(test)] +use iota_identity::PrincipalHandle; +use iota_identity::{ + AuthorityId, IdentityError, IdentityResolver, IotaNodeId, LocalUserStore, PrincipalHome, + PrincipalId, RelayRouterId, ResolvedPrincipal, +}; +use iota_logger::log; +use iota_storage::util::{chat_files, chats_util, outgoing_relay, relay_queue, relay_replay}; +use iota_util::mtp_compat::OptionalDataValueExt; +use iota_util::route_target::RouteTarget; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::Keyring; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::connection_handler::{PeerRouter, RouteDestination, RouteOutcome}; +use crate::message_common::typed_container; +use crate::message_handlers; +use crate::relay::{ + RelayValidationError, forward_verified_relay, open_verified_relay_content, + verify_relay_metadata, +}; + +static NEXT_CLIENT_EVENT_ID: AtomicU32 = AtomicU32::new(1); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IngressSource { + Omikron { connection_id: String }, + DirectPeer { node_id: IotaNodeId }, + RelayRouter { router_id: RelayRouterId }, + HostedClient { session: AuthenticatedSession }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RelayServiceError { + Rejected(String), + Internal(String), +} + +#[derive(Clone, Debug)] +pub struct VerifiedRelayEnvelope { + pub signer: ResolvedPrincipal, + pub recipient: ResolvedPrincipal, + pub message_id: String, + pub created_at: u64, + compatibility: RelayCompatibilityData, +} + +#[derive(Clone, Debug)] +struct RelayCompatibilityData { + legacy: Option, + federated: Option, +} + +#[derive(Clone, Debug)] +struct LegacyRelayCompatibility { + verified: crate::relay::VerifiedRelay, + frame: CommunicationValue, + keyring: Arc, + local_iota_id: u64, +} + +#[derive(Clone, Debug)] +struct FederatedRelayCompatibility { + frame: CommunicationValue, + content: CommunicationValue, +} + +pub fn route_destination(home: &PrincipalHome) -> Option { + match home { + PrincipalHome::Iota(node) => Some(RouteDestination::Iota(node.clone())), + PrincipalHome::LegacyOmegaIota { omega, iota_id } => { + Some(RouteDestination::LegacyOmegaIota { + omega: omega.clone(), + iota_id: *iota_id, + }) + } + PrincipalHome::Omega(_) | PrincipalHome::Unknown => None, + } +} + +#[derive(Clone, Debug)] +pub struct ClientDelivery { + pub recipient: iota_identity::PrincipalHandle, + pub frame: CommunicationValue, +} + +#[derive(Clone, Debug)] +pub enum RelayOutcome { + Accepted { + ingress_response: CommunicationValue, + local_deliveries: Vec, + }, + Rejected { + response: CommunicationValue, + }, + NoRoute { + response: CommunicationValue, + }, + RetryLater { + response: CommunicationValue, + }, + Ignored, +} + +impl RelayOutcome { + pub fn into_parts(self) -> (Option, Vec) { + match self { + Self::Accepted { + ingress_response, + local_deliveries, + } => (Some(ingress_response), local_deliveries), + Self::Rejected { response } + | Self::NoRoute { response } + | Self::RetryLater { response } => (Some(response), Vec::new()), + Self::Ignored => (None, Vec::new()), + } + } +} + +#[async_trait] +pub trait RelayNodeIdentity: Send + Sync { + async fn keyring(&self) -> Option>; + fn node_id(&self) -> Option; +} + +#[async_trait] +pub trait LegacyRelayIdentity: RelayNodeIdentity { + fn legacy_iota_id(&self) -> Option; +} + +pub struct LegacyRelayDecoder { + identities: Arc, + node_identity: Arc, + authority: AuthorityId, +} + +impl LegacyRelayDecoder { + pub fn new( + identities: Arc, + node_identity: Arc, + authority: AuthorityId, + ) -> Self { + Self { + identities, + node_identity, + authority, + } + } + + async fn resolve(&self, user_id: u64) -> Result { + self.identities + .resolve_principal(&PrincipalId { + authority: self.authority.clone(), + user_id, + }) + .await + .map_err(|error| RelayValidationError::KeyLookup(error.to_string())) + } + + pub async fn decode( + self: &Arc, + frame: CommunicationValue, + ) -> Result { + let local_iota_id = self.node_identity.legacy_iota_id().ok_or_else(|| { + RelayValidationError::KeyLookup("legacy Iota ID is unavailable".into()) + })?; + let keyring = + self.node_identity.keyring().await.ok_or_else(|| { + RelayValidationError::KeyLookup("Iota keyring is unavailable".into()) + })?; + let decoder = self.clone(); + let verified = verify_relay_metadata(&frame, local_iota_id, &keyring, move |signer_id| { + let decoder = decoder.clone(); + async move { + decoder + .resolve(signer_id) + .await + .map(|principal| principal.public_keys) + } + }) + .await?; + let signer = self.resolve(verified.context.signer_id).await?; + let recipient = self.resolve(verified.context.final_recipient_id).await?; + Ok(VerifiedRelayEnvelope { + signer, + recipient, + message_id: verified.context.message_id.clone(), + created_at: verified.context.created_at, + compatibility: RelayCompatibilityData { + legacy: Some(LegacyRelayCompatibility { + verified, + frame, + keyring, + local_iota_id, + }), + federated: None, + }, + }) + } +} + +pub struct RelayService { + local_users: Arc, + router: Arc, + legacy_decoder: Option>, + federated_identities: Option>, + federated_node_identity: Option>, +} + +pub struct PendingRelayDispatcher { + router: Arc, +} + +impl PendingRelayDispatcher { + pub fn new(router: Arc) -> Self { + Self { router } + } + + pub async fn dispatch_ready(&self, limit: i64) -> Result { + let records = relay_queue::list_active(limit) + .map_err(|error| RelayServiceError::Internal(error.to_string()))?; + let mut delivered = 0; + for record in records { + let version = + mtp::type_map::Version::parse(&record.type_map_version).ok_or_else(|| { + RelayServiceError::Internal(format!( + "pending relay {} has invalid type-map version", + record.id + )) + })?; + let type_map = mtp::codec::TypeMap::new(version); + let frame = + CommunicationValue::from_bytes_with(&record.frame, &type_map).map_err(|_| { + RelayServiceError::Internal(format!( + "pending relay {} contains an invalid frame", + record.id + )) + })?; + let destination = match &record.target { + relay_queue::RelayTarget::Iota(node) => RouteDestination::Iota(node.clone()), + relay_queue::RelayTarget::LegacyOmegaIota { omega, iota_id } => { + RouteDestination::LegacyOmegaIota { + omega: omega.clone(), + iota_id: *iota_id, + } + } + relay_queue::RelayTarget::User(_) => continue, + }; + let frame = match &record.target { + relay_queue::RelayTarget::LegacyOmegaIota { iota_id, .. } => { + forward_verified_relay(&frame, RouteTarget::Iota(*iota_id)) + .map_err(|error| RelayServiceError::Internal(error.to_string()))? + } + relay_queue::RelayTarget::Iota(_) => frame, + relay_queue::RelayTarget::User(_) => continue, + }; + match self.router.route(&destination, frame).await { + Ok(RouteOutcome::Accepted { + relay_message_id, + destination_accepted_at, + }) if record.relay_message_id.as_deref() == Some(&relay_message_id) => { + let signer = record.signer_principal.ok_or_else(|| { + RelayServiceError::Internal(format!( + "pending relay {} has no signer principal", + record.id + )) + })?; + iota_storage::util::downstream_relay::acknowledge_iota_delivery( + record.frame_id, + signer, + record.relay_signer_id, + &relay_message_id, + destination_accepted_at, + ) + .map_err(|error| RelayServiceError::Internal(error.to_string()))?; + delivered += 1; + } + Ok(RouteOutcome::Rejected { .. }) => { + if let (Some(signer), Some(message_id)) = + (record.signer_principal, record.relay_message_id.as_deref()) + { + iota_storage::util::downstream_relay::reject_iota_delivery( + record.frame_id, + signer, + record.relay_signer_id, + message_id, + "destination_rejected", + ) + .map_err(|error| RelayServiceError::Internal(error.to_string()))?; + } + } + Ok(RouteOutcome::Accepted { .. }) => { + relay_queue::quarantine_for_frame( + record.frame_id, + "destination returned a different RelayMessageId", + ) + .map_err(|error| RelayServiceError::Internal(error.to_string()))?; + } + Ok(RouteOutcome::Retryable { reason }) => { + relay_queue::record_retry( + record.id, + crate::message_common::now_millis_i64(), + &reason, + ) + .map_err(|error| RelayServiceError::Internal(error.to_string()))?; + } + Err(error) => { + relay_queue::record_retry( + record.id, + crate::message_common::now_millis_i64(), + &error.to_string(), + ) + .map_err(|error| RelayServiceError::Internal(error.to_string()))?; + } + } + } + Ok(delivered) + } +} + +impl RelayService { + pub fn new( + local_users: Arc, + router: Arc, + legacy_decoder: Option>, + ) -> Self { + Self { + local_users, + router, + legacy_decoder, + federated_identities: None, + federated_node_identity: None, + } + } + + pub fn with_federation( + mut self, + identities: Arc, + node_identity: Arc, + ) -> Self { + self.federated_identities = Some(identities); + self.federated_node_identity = Some(node_identity); + self + } + + fn is_hosted_principal(&self, principal: &ResolvedPrincipal) -> Result { + self.local_users + .local_user_for_principal(principal.handle) + .map(|local_user| local_user.is_some()) + } + + async fn await_relay_response( + &self, + destination: &RouteDestination, + frame: &CommunicationValue, + ) -> Result { + match self + .router + .route(destination, frame.clone()) + .await + .map_err(|error| error.to_string())? + { + RouteOutcome::Accepted { + relay_message_id, + destination_accepted_at, + } => Ok(CommunicationValue::new(CommunicationType::Success) + .add_typed_default(DataType::RelayMessageId, DataValue::Str(relay_message_id)) + .add_typed_default( + DataType::RelayAcceptedAt, + DataValue::SignedNumber(destination_accepted_at.into()), + )), + RouteOutcome::Rejected { response_type } => Ok(CommunicationValue::new(response_type)), + RouteOutcome::Retryable { reason } => Err(reason), + } + } + + fn relay_response(frame_id: Option, response_type: CommunicationType) -> RelayOutcome { + let Some(frame_id) = frame_id else { + return RelayOutcome::Ignored; + }; + let response = CommunicationValue::new(response_type).with_id(frame_id); + match response_type { + CommunicationType::Success => RelayOutcome::Accepted { + ingress_response: response, + local_deliveries: Vec::new(), + }, + CommunicationType::ErrorNoIota => RelayOutcome::NoRoute { response }, + CommunicationType::ErrorInternal => RelayOutcome::RetryLater { response }, + _ => RelayOutcome::Rejected { response }, + } + } + + fn relay_success( + frame_id: Option, + iota_id: u64, + relay_message_id: &str, + accepted_at: i64, + include_origin_timestamp: bool, + local_deliveries: Vec, + ) -> RelayOutcome { + let Some(frame_id) = frame_id else { + return RelayOutcome::Ignored; + }; + let response = CommunicationValue::new(CommunicationType::Success) + .with_id(frame_id) + .add_typed_default(DataType::IotaId, DataValue::UnsignedNumber(iota_id.into())) + .add_typed_default( + DataType::RelayMessageId, + DataValue::Str(relay_message_id.to_string()), + ) + .add_typed_default( + DataType::RelayAcceptedAt, + DataValue::SignedNumber(accepted_at.into()), + ); + let response = if include_origin_timestamp { + response + .add_typed_default( + DataType::OriginIotaReceivedAt, + DataValue::SignedNumber(accepted_at.into()), + ) + .add_typed_default( + DataType::DestinationIotaReceivedAt, + DataValue::SignedNumber(accepted_at.into()), + ) + } else { + response + }; + RelayOutcome::Accepted { + ingress_response: response, + local_deliveries, + } + } + + async fn process_relay_frame( + self: Arc, + normalized: VerifiedRelayEnvelope, + ) -> RelayOutcome { + if normalized.compatibility.federated.is_some() { + return self.process_federated_relay(normalized).await; + } + let Some(legacy) = normalized.compatibility.legacy else { + return RelayOutcome::Rejected { + response: CommunicationValue::new(CommunicationType::ErrorInvalidData), + }; + }; + let frame = legacy.frame; + let Some(incoming_frame_id) = frame.id() else { + log!("Rejecting Relay without a message id"); + return RelayOutcome::Ignored; + }; + let local_iota_id = legacy.local_iota_id; + let keyring = legacy.keyring; + let verified = legacy.verified; + let signer_principal = normalized.signer; + let recipient_principal = normalized.recipient; + let accepted_at = crate::message_common::now_millis_i64(); + let signer_id = match i64::try_from(verified.context.signer_id) { + Ok(id) => id, + Err(_) => { + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ); + } + }; + let recipient_id = match i64::try_from(verified.context.final_recipient_id) { + Ok(id) => id, + Err(_) => { + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ); + } + }; + let signer_is_local = match self.is_hosted_principal(&signer_principal) { + Ok(is_local) => is_local, + Err(error) => { + log!( + "Relay locality lookup failed for signer {}: {}", + signer_id, + error + ); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInternal, + ); + } + }; + let recipient_is_local = match self.is_hosted_principal(&recipient_principal) { + Ok(is_local) => is_local, + Err(error) => { + log!( + "Relay locality lookup failed for recipient {}: {}", + recipient_id, + error + ); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInternal, + ); + } + }; + 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, + ); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ); + } + + /* Evaluate recipient policy before reserving relay replay state or + * persisting the frame, so blocked traffic leaves no durable trace. */ + if recipient_block_policy_applies(recipient_is_local, recipient_id, signer_id) { + match iota_storage::util::blocked_users::is_principal_blocked( + recipient_id, + signer_principal.handle, + ) { + Ok(true) => { + log!( + "Rejecting Relay from blocked signer {} to recipient {}", + signer_id, + recipient_id + ); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorNotAuthenticated, + ); + } + Ok(false) => {} + Err(error) => { + log!( + "Relay block policy lookup failed for recipient {}: {}", + recipient_id, + error + ); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInternal, + ); + } + } + } + + /* An origin Iota is authoritative for receipt disclosure. Inspect + * local-origin relay content before reserving or queuing the frame. */ + if signer_is_local { + let content = match open_verified_relay_content(&verified, &[&keyring]) { + Ok(content) => content, + Err(error) => { + log!("Relay origin content verification failed: {}", error); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ); + } + }; + if let Err(error) = message_handlers::validate_outgoing_receipt_policy( + signer_id, + &verified.context, + &content, + ) { + log!("Rejecting local receipt relay: {}", error); + return Self::relay_response( + Some(incoming_frame_id), + CommunicationType::ErrorInvalidData, + ); + } + } + + 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 + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + }; + let type_map_version = verified.context.type_map.version.to_string(); + let frame_id = incoming_frame_id; + let reservation = match relay_replay::reserve(relay_replay::RelayReservationRequest { + signer_principal: signer_principal.handle, + signer_id: verified.context.signer_id, + message_id: &verified.context.message_id, + created_at: verified.context.created_at, + accepted_at, + destination_id: verified.context.final_recipient_id, + frame: &frame_bytes, + frame_id, + type_map_version: &type_map_version, + }) { + Ok(value) => value, + Err(error) => { + log!("Relay durable acceptance failed: {}", error); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + }; + 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 + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + relay_replay::RelayReservation::Existing { ref state, .. } if state == "delivered" => { + return Self::relay_response(frame.id(), CommunicationType::Success); + } + relay_replay::RelayReservation::Existing { ref state, .. } + if state == "applied" || state == "queued" => + { + true + } + relay_replay::RelayReservation::Existing { ref state, .. } if state == "rejected" => { + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + relay_replay::RelayReservation::Existing { .. } => false, + }; + + /* A shared Iota owns both independent replicas before delivering to its + * local recipient. The destination path below writes the recipient copy. */ + if signer_is_local && recipient_is_local && !already_applied { + let content = match open_verified_relay_content(&verified, &[&keyring]) { + Ok(value) => value, + Err(error) => { + log!( + "Relay shared-Iota origin content verification failed: {}", + error + ); + let _ = relay_replay::mark_rejected( + signer_principal.handle, + &verified.context.message_id, + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + let owner = match i64::try_from(verified.context.signer_id) { + Ok(value) => value, + Err(_) => { + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + signer_principal.handle, + recipient_principal.handle, + accepted_at, + owner, + true, + ) { + log!("Relay shared-Iota origin application failed: {}", error); + let _ = relay_replay::mark_rejected( + signer_principal.handle, + &verified.context.message_id, + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + if let Err(error) = chat_files::record_destination_iota_received( + owner, + signer_principal.handle, + &verified.context.message_id, + accepted_at, + ) { + log!( + "Relay shared-Iota destination timestamp storage failed: {}", + error + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + } + + if signer_is_local && !recipient_is_local { + /* Chat-secret versions are immutable. Apply them locally only after + * the peer has accepted the same relay, so a peer conflict cannot + * leave a newly generated origin version behind. */ + let defer_chat_secret_commit = frame.is_type(CommunicationType::SetChatSecret); + if !already_applied && !defer_chat_secret_commit { + let content = match open_verified_relay_content(&verified, &[&keyring]) { + Ok(value) => value, + Err(error) => { + log!("Relay origin content verification failed: {}", error); + let _ = relay_replay::mark_rejected( + signer_principal.handle, + &verified.context.message_id, + ); + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + } + }; + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + signer_principal.handle, + recipient_principal.handle, + accepted_at, + i64::try_from(verified.context.signer_id).unwrap_or_default(), + true, + ) { + log!("Relay origin application failed: {}", error); + let _ = relay_replay::mark_rejected( + signer_principal.handle, + &verified.context.message_id, + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + } + let Some(route) = route_destination(&recipient_principal.home) else { + log!("Relay recipient has no Iota route"); + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "destination_iota_not_found", + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorNoIota); + }; + let forwarded = match &route { + RouteDestination::LegacyOmegaIota { iota_id, .. } => { + forward_verified_relay(&frame, RouteTarget::Iota(*iota_id)) + } + RouteDestination::Iota(_) => Ok(frame.clone()), + }; + let forwarded = match forwarded { + Ok(value) => value, + Err(error) => { + log!("Relay origin forwarding validation failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "forwarding_validation_failed", + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + let bytes = match forwarded.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!("Relay origin retry could not be serialized: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "serialization_failed", + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + }; + let relay_identity = match ( + i64::try_from(verified.context.signer_id), + i64::try_from(verified.context.final_recipient_id), + ) { + (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { + signer: signer_principal.handle, + recipient: recipient_principal.handle, + message_id: verified.context.message_id.clone(), + legacy_signer_id: Some(signer_id), + legacy_recipient_id: Some(destination_user_id), + }, + _ => { + log!("Relay identity exceeds the storage range"); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + if let Err(error) = + outgoing_relay::commit_outgoing_relay(outgoing_relay::OutgoingRelay { + target: match &route { + RouteDestination::LegacyOmegaIota { omega, iota_id } => { + relay_queue::RelayTarget::LegacyOmegaIota { + omega: omega.clone(), + iota_id: *iota_id, + } + } + RouteDestination::Iota(node) => { + relay_queue::RelayTarget::Iota(node.clone()) + } + }, + identity: &relay_identity, + frame: &bytes, + created_at: crate::message_common::now_millis_i64(), + frame_id, + type_map_version: &type_map_version, + }) + { + log!("Relay origin retry queue failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "queue_failed", + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + match self.await_relay_response(&route, &forwarded).await { + Ok(response) if response.is_type(CommunicationType::Success) => { + let returned_id = response.get_data(DataType::RelayMessageId).as_str(); + let destination_accepted_at = response + .get_data(DataType::RelayAcceptedAt) + .as_number() + .and_then(|value| i64::try_from(value).ok()); + if returned_id != Some(verified.context.message_id.as_str()) { + log!("Relay acknowledgement returned a different RelayMessageId"); + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + } + let Some(destination_accepted_at) = destination_accepted_at else { + log!("Relay acknowledgement is missing RelayAcceptedAt"); + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + }; + let Ok(signer_id) = i64::try_from(verified.context.signer_id) else { + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + }; + if defer_chat_secret_commit && !already_applied { + let content = match open_verified_relay_content(&verified, &[&keyring]) { + Ok(value) => value, + Err(error) => { + log!("Relay origin content verification failed: {}", error); + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + } + }; + let Ok(origin_id) = i64::try_from(verified.context.signer_id) else { + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + }; + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + signer_principal.handle, + recipient_principal.handle, + accepted_at, + origin_id, + true, + ) { + log!("Relay origin chat-secret application failed: {}", error); + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInternal, + ); + } + } + if let Err(error) = + iota_storage::util::downstream_relay::acknowledge_iota_delivery( + frame_id, + signer_principal.handle, + Some(signer_id), + &verified.context.message_id, + destination_accepted_at, + ) + { + log!( + "Relay destination acknowledgement storage failed: {}", + error + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + let response = response + .add_typed_default( + DataType::OriginIotaReceivedAt, + DataValue::SignedNumber(accepted_at.into()), + ) + .add_typed_default( + DataType::DestinationIotaReceivedAt, + DataValue::SignedNumber(destination_accepted_at.into()), + ) + .with_id(frame_id); + return RelayOutcome::Accepted { + ingress_response: response, + local_deliveries: Vec::new(), + }; + } + Ok(response) => { + log!("Relay origin route returned {}", response.get_type()); + if response.is_type(CommunicationType::ErrorInternal) + && !defer_chat_secret_commit + { + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "destination_internal_error", + ); + return Self::relay_success( + frame.id(), + local_iota_id, + &verified.context.message_id, + accepted_at, + true, + Vec::new(), + ); + } else if let Ok(signer_id) = i64::try_from(verified.context.signer_id) { + if let Err(error) = + iota_storage::util::downstream_relay::reject_iota_delivery( + frame_id, + signer_principal.handle, + Some(signer_id), + &verified.context.message_id, + "destination_rejected", + ) + { + log!("Relay rejection cleanup failed: {}", error); + } + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "destination_rejected", + ); + } + return Self::relay_response( + frame.id(), + response + .get_comm_type_enum() + .unwrap_or(CommunicationType::ErrorInternal), + ); + } + Err(error) => { + log!("Relay origin forwarding failed: {}", error); + record_origin_delivery_failure( + verified.context.signer_id, + signer_principal.handle, + &verified.context.message_id, + "destination_unreachable", + ); + if defer_chat_secret_commit { + if let Ok(signer_id) = i64::try_from(verified.context.signer_id) + && let Err(queue_error) = + iota_storage::util::downstream_relay::reject_iota_delivery( + frame_id, + signer_principal.handle, + Some(signer_id), + &verified.context.message_id, + "destination_unreachable", + ) + { + log!("Chat-secret relay retry cleanup failed: {}", queue_error); + } + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + return Self::relay_success( + frame.id(), + local_iota_id, + &verified.context.message_id, + accepted_at, + true, + Vec::new(), + ); + } + } + } + + 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); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + let bytes = match forwarded.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!( + "Relay could not be serialized for client delivery: {}", + error + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + }; + let relay_identity = match ( + i64::try_from(verified.context.signer_id), + i64::try_from(verified.context.final_recipient_id), + ) { + (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { + signer: signer_principal.handle, + recipient: recipient_principal.handle, + message_id: verified.context.message_id.clone(), + legacy_signer_id: Some(signer_id), + legacy_recipient_id: Some(destination_user_id), + }, + _ => { + log!("Relay identity exceeds the storage range"); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + if let Err(error) = relay_queue::enqueue( + relay_queue::RelayTarget::User(destination), + &relay_identity, + &bytes, + crate::message_common::now_millis_i64(), + frame_id, + &type_map_version, + ) { + log!("Relay could not be queued for client delivery: {}", error); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + + let mut client_event = None; + if !already_applied { + let content = match open_verified_relay_content(&verified, &[&keyring]) { + 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_rejected( + signer_principal.handle, + &verified.context.message_id, + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + }; + if let Err(error) = message_handlers::apply_verified_relay_content( + &verified.context, + &content, + signer_principal.handle, + recipient_principal.handle, + accepted_at, + match i64::try_from(destination) { + Ok(value) => value, + Err(_) => { + log!("Relay destination ID exceeds storage range"); + return Self::relay_response( + frame.id(), + CommunicationType::ErrorInvalidData, + ); + } + }, + false, + ) { + 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_rejected( + signer_principal.handle, + &verified.context.message_id, + ); + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + } + client_event = client_event_from_relay( + content.message_type, + &content.content, + verified.context.signer_id, + destination, + &verified.context.message_id, + accepted_at, + ); + if let Err(error) = + relay_replay::mark_applied(signer_principal.handle, &verified.context.message_id) + { + log!("Relay application state update failed: {}", error); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + } + + if signer_is_local && recipient_is_local { + let Ok(owner) = i64::try_from(verified.context.signer_id) else { + return Self::relay_response(frame.id(), CommunicationType::ErrorInvalidData); + }; + if let Err(error) = outgoing_relay::apply_outgoing_history_policy( + owner, + signer_principal.handle, + &verified.context.message_id, + ) { + log!("Shared-Iota outgoing history policy failed: {}", error); + return Self::relay_response(frame.id(), CommunicationType::ErrorInternal); + } + } + + if let Err(error) = + relay_replay::mark_queued(signer_principal.handle, &verified.context.message_id) + { + log!("Relay queue state update failed: {}", error); + } + Self::relay_success( + frame.id(), + local_iota_id, + &verified.context.message_id, + accepted_at, + signer_is_local, + client_event + .into_iter() + .map(|frame| ClientDelivery { + recipient: recipient_principal.handle, + frame, + }) + .collect(), + ) + } + + async fn process_federated_relay( + self: Arc, + normalized: VerifiedRelayEnvelope, + ) -> RelayOutcome { + let Some(federated) = normalized.compatibility.federated else { + return RelayOutcome::Rejected { + response: CommunicationValue::new(CommunicationType::ErrorInvalidData), + }; + }; + let Some(frame_id) = federated.frame.id() else { + return RelayOutcome::Ignored; + }; + let signer_local = match self + .local_users + .local_user_for_principal(normalized.signer.handle) + { + Ok(user) => user, + Err(error) => { + log!("Relay V2 signer locality lookup failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + }; + let recipient_local = match self + .local_users + .local_user_for_principal(normalized.recipient.handle) + { + Ok(user) => user, + Err(error) => { + log!("Relay V2 recipient locality lookup failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + }; + if signer_local.is_none() && recipient_local.is_none() { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + } + let accepted_at = crate::message_common::now_millis_i64(); + if let Some(recipient) = recipient_local { + match iota_storage::util::blocked_users::is_principal_blocked( + recipient.0, + normalized.signer.handle, + ) { + Ok(true) => { + return Self::relay_response( + Some(frame_id), + CommunicationType::ErrorNotAuthenticated, + ); + } + Ok(false) => {} + Err(error) => { + log!("Relay V2 block policy lookup failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + } + } + let Some(type_map) = federated.content.type_map().cloned() else { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + }; + let Some(message_type) = federated.content.get_comm_type_enum() else { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + }; + let context = crate::relay::VerifiedRelayContext { + signer_id: normalized.signer.principal.user_id, + final_recipient_id: normalized.recipient.principal.user_id, + message_id: normalized.message_id.clone(), + created_at: normalized.created_at, + type_map: type_map.clone(), + }; + let content = mtp::codec::VerifiedRelayContent { + signer_id: context.signer_id, + final_recipient_id: context.final_recipient_id, + message_type, + content: federated.content.payload().clone(), + }; + let application = message_handlers::RelayApplicationContext { + signer: normalized.signer.handle, + recipient: normalized.recipient.handle, + signer_principal: normalized.signer.principal.clone(), + recipient_principal: normalized.recipient.principal.clone(), + hosted_sender: signer_local, + hosted_recipient: recipient_local, + legacy: None, + }; + let frame_bytes = match federated.frame.clone().without_id().to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + log!("Relay V2 durable encoding failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + }; + let reservation = match relay_replay::reserve(relay_replay::RelayReservationRequest { + signer_principal: normalized.signer.handle, + signer_id: normalized.signer.principal.user_id, + message_id: &normalized.message_id, + created_at: normalized.created_at, + accepted_at, + destination_id: normalized.recipient.principal.user_id, + frame: &frame_bytes, + frame_id, + type_map_version: &type_map.version.to_string(), + }) { + Ok(reservation) => reservation, + Err(error) => { + log!("Relay V2 durable acceptance failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + }; + match &reservation { + relay_replay::RelayReservation::Existing { + frame_matches: false, + .. + } => { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + } + relay_replay::RelayReservation::Existing { state, .. } if state == "delivered" => { + return federated_success(frame_id, &normalized.message_id, accepted_at); + } + relay_replay::RelayReservation::Existing { state, .. } if state == "rejected" => { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + } + _ => {} + } + let already_applied = matches!( + reservation, + relay_replay::RelayReservation::Existing { ref state, .. } + if state == "applied" || state == "queued" + ); + + if let Some(recipient) = recipient_local { + if !already_applied + && let Err(error) = message_handlers::apply_relay_application_content( + &application, + &context, + &content, + accepted_at, + ) + { + log!("Relay V2 destination application failed: {error}"); + let _ = + relay_replay::mark_rejected(normalized.signer.handle, &normalized.message_id); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + } + let relay_identity = relay_queue::RelayIdentity { + signer: normalized.signer.handle, + recipient: normalized.recipient.handle, + message_id: normalized.message_id.clone(), + legacy_signer_id: i64::try_from(normalized.signer.principal.user_id).ok(), + legacy_recipient_id: Some(recipient.0), + }; + if let Err(error) = relay_queue::enqueue( + relay_queue::RelayTarget::User(recipient.0 as u64), + &relay_identity, + &frame_bytes, + accepted_at, + frame_id, + &type_map.version.to_string(), + ) { + log!("Relay V2 local delivery queue failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + let _ = relay_replay::mark_queued(normalized.signer.handle, &normalized.message_id); + let delivery = client_event_from_relay( + message_type, + &content.content, + normalized.signer.principal.user_id, + normalized.recipient.principal.user_id, + &normalized.message_id, + accepted_at, + ); + return match federated_success(frame_id, &normalized.message_id, accepted_at) { + RelayOutcome::Accepted { + ingress_response, .. + } => RelayOutcome::Accepted { + ingress_response, + local_deliveries: delivery + .into_iter() + .map(|frame| ClientDelivery { + recipient: normalized.recipient.handle, + frame, + }) + .collect(), + }, + outcome => outcome, + }; + } + + let Some(signer) = signer_local else { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + }; + if !already_applied + && let Err(error) = message_handlers::apply_relay_application_content( + &application, + &context, + &content, + accepted_at, + ) + { + log!("Relay V2 origin application failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData); + } + let Some(route) = route_destination(&normalized.recipient.home) else { + return Self::relay_response(Some(frame_id), CommunicationType::ErrorNoIota); + }; + let relay_identity = relay_queue::RelayIdentity { + signer: normalized.signer.handle, + recipient: normalized.recipient.handle, + message_id: normalized.message_id.clone(), + legacy_signer_id: Some(signer.0), + legacy_recipient_id: i64::try_from(normalized.recipient.principal.user_id).ok(), + }; + if let Err(error) = outgoing_relay::commit_outgoing_relay(outgoing_relay::OutgoingRelay { + target: match &route { + RouteDestination::Iota(node) => relay_queue::RelayTarget::Iota(node.clone()), + RouteDestination::LegacyOmegaIota { omega, iota_id } => { + relay_queue::RelayTarget::LegacyOmegaIota { + omega: omega.clone(), + iota_id: *iota_id, + } + } + }, + identity: &relay_identity, + frame: &frame_bytes, + created_at: accepted_at, + frame_id, + type_map_version: &type_map.version.to_string(), + }) { + log!("Relay V2 retry queue failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + match self.router.route(&route, federated.frame).await { + Ok(RouteOutcome::Accepted { + relay_message_id, + destination_accepted_at, + }) if relay_message_id == normalized.message_id => { + if let Err(error) = iota_storage::util::downstream_relay::acknowledge_iota_delivery( + frame_id, + normalized.signer.handle, + Some(signer.0), + &relay_message_id, + destination_accepted_at, + ) { + log!("Relay V2 acknowledgement storage failed: {error}"); + return Self::relay_response(Some(frame_id), CommunicationType::ErrorInternal); + } + federated_success(frame_id, &relay_message_id, destination_accepted_at) + } + Ok(RouteOutcome::Accepted { .. }) => { + let _ = iota_storage::util::relay_queue::quarantine_for_frame( + frame_id, + "destination returned a different RelayMessageId", + ); + Self::relay_response(Some(frame_id), CommunicationType::ErrorInvalidData) + } + Ok(RouteOutcome::Rejected { response_type }) => { + Self::relay_response(Some(frame_id), response_type) + } + Ok(RouteOutcome::Retryable { .. }) | Err(_) => { + federated_success(frame_id, &normalized.message_id, accepted_at) + } + } + } + + pub async fn accept_relay( + self: &Arc, + source: IngressSource, + frame: CommunicationValue, + ) -> Result { + if frame.get_data(DataType::VersionNumber).as_number() == Some(2) { + let identities = self.federated_identities.as_ref().ok_or_else(|| { + RelayServiceError::Internal("federated identity resolver is unavailable".into()) + })?; + let node = self + .federated_node_identity + .as_ref() + .and_then(|identity| identity.node_id()) + .ok_or_else(|| { + RelayServiceError::Internal("local Iota node identity is unavailable".into()) + })?; + let expected_destination = match &source { + IngressSource::HostedClient { .. } => None, + _ => Some(&node), + }; + let verified = crate::federated_relay::decode_and_verify_for_ingress( + frame, + identities.as_ref(), + expected_destination, + ) + .await + .map_err(|error| RelayServiceError::Rejected(error.to_string()))?; + return self + .accept_verified_relay( + source, + VerifiedRelayEnvelope { + signer: verified.signer, + recipient: verified.recipient, + message_id: verified.message_id, + created_at: verified.created_at, + compatibility: RelayCompatibilityData { + legacy: None, + federated: Some(FederatedRelayCompatibility { + frame: verified.frame, + content: verified.content, + }), + }, + }, + ) + .await; + } + if matches!( + source, + IngressSource::DirectPeer { .. } | IngressSource::RelayRouter { .. } + ) { + return Err(RelayServiceError::Rejected( + "legacy relay wire format is not valid for peer ingress".into(), + )); + } + let frame_id = frame.id(); + let decoder = self.legacy_decoder.as_ref().ok_or_else(|| { + RelayServiceError::Internal("legacy relay decoder is unavailable".into()) + })?; + let normalized = match decoder.decode(frame).await { + Ok(normalized) => normalized, + Err(error) => { + log!("Relay metadata verification failed: {error}"); + return Ok(Self::relay_response( + frame_id, + CommunicationType::ErrorInvalidData, + )); + } + }; + self.accept_verified_relay(source, normalized).await + } + + pub async fn accept_verified_relay( + self: &Arc, + source: IngressSource, + normalized: VerifiedRelayEnvelope, + ) -> Result { + match source { + IngressSource::Omikron { connection_id } if !connection_id.is_empty() => {} + IngressSource::DirectPeer { node_id } + if matches!( + &normalized.signer.home, + PrincipalHome::Iota(home) if home == &node_id + ) => {} + IngressSource::DirectPeer { .. } => { + return Err(RelayServiceError::Rejected( + "authenticated peer is not the signer home Iota".into(), + )); + } + IngressSource::RelayRouter { .. } => {} + IngressSource::HostedClient { session } => { + if !matches!(session.identity, SessionIdentity::Hosted { .. }) + || !session.allows(&SessionCapability::RelayOrigination) + || session.principal() != normalized.signer.handle + { + return Err(RelayServiceError::Rejected( + "hosted client session cannot originate this relay".into(), + )); + } + } + IngressSource::Omikron { .. } => { + return Err(RelayServiceError::Rejected( + "Omikron ingress has no authenticated connection ID".into(), + )); + } + } + Ok(self.clone().process_relay_frame(normalized).await) + } +} + +fn federated_success(frame_id: u32, message_id: &str, accepted_at: i64) -> RelayOutcome { + RelayOutcome::Accepted { + ingress_response: CommunicationValue::new(CommunicationType::Success) + .with_id(frame_id) + .add_typed_default( + DataType::RelayMessageId, + DataValue::Str(message_id.to_string()), + ) + .add_typed_default( + DataType::RelayAcceptedAt, + DataValue::SignedNumber(accepted_at.into()), + ), + local_deliveries: Vec::new(), + } +} + +fn next_client_event_id() -> u32 { + NEXT_CLIENT_EVENT_ID.fetch_add(1, Ordering::Relaxed).max(1) +} + +fn record_origin_delivery_failure( + signer_id: u64, + signer_principal: iota_identity::PrincipalHandle, + relay_message_id: &str, + failure: &str, +) { + let Ok(storage_owner) = i64::try_from(signer_id) else { + return; + }; + if let Err(error) = chat_files::record_delivery_failure( + storage_owner, + signer_principal, + relay_message_id, + failure, + crate::message_common::now_millis_i64(), + ) { + log!("Relay delivery failure storage failed: {error}"); + } +} + +fn client_event_from_relay( + message_type: CommunicationType, + payload: &DataValue, + signer_id: u64, + recipient_id: u64, + relay_message_id: &str, + accepted_at: i64, +) -> Option { + let sender = DataValue::UnsignedNumber(u128::from(signer_id)); + let receiver = recipient_id; + let event_id = next_client_event_id(); + + match message_type { + CommunicationType::MessageSend => { + let frame = CommunicationValue::new(CommunicationType::MessageSend) + .with_payload(payload.clone()); + let content = frame.get_data(DataType::Content)?.clone(); + let send_time = frame.get_data(DataType::SendTime)?.clone(); + let version = frame.get_data(DataType::VersionNumber)?.clone(); + let mut message = vec![ + (DataType::Content, content), + (DataType::SendTime, send_time), + (DataType::VersionNumber, version), + ( + DataType::RelayMessageId, + DataValue::Str(relay_message_id.to_string()), + ), + (DataType::MessageState, DataValue::Str("sent".to_string())), + ]; + if let Some(reply_id) = frame.get_data(DataType::ReplyId) { + message.push((DataType::ReplyId, reply_id.clone())); + } + let mut event = CommunicationValue::new(CommunicationType::MessageLive) + .with_id(event_id) + .with_sender(signer_id) + .with_receiver(receiver) + .add_typed_default(DataType::SenderId, sender) + .add_typed_default(DataType::Message, typed_container(message)) + .add_typed_default( + DataType::LastMessageAt, + DataValue::SignedNumber(accepted_at.into()), + ); + if let (Ok(owner), Ok(partner)) = + (i64::try_from(recipient_id), i64::try_from(signer_id)) + && let Ok(Some(contact)) = chats_util::get_user(owner, partner) + { + event = event.add_typed_default( + DataType::Notifications, + DataValue::SignedNumber(contact.notifications.into()), + ); + if let Some(last_message_at) = contact.last_message_at { + event = event.add_typed_default( + DataType::LastMessageAt, + DataValue::SignedNumber(last_message_at.into()), + ); + } + } + Some(event) + } + CommunicationType::SetChatSecret => { + let frame = CommunicationValue::new(CommunicationType::SetChatSecret) + .with_payload(payload.clone()); + let chat_id = frame.get_data(DataType::ChatId)?.clone(); + let secret_id = frame.get_data(DataType::SecretId)?.clone(); + let version = frame.get_data(DataType::VersionNumber)?.clone(); + Some( + CommunicationValue::new(CommunicationType::ChatSecretForward) + .with_id(event_id) + .with_sender(signer_id) + .with_receiver(receiver) + .add_typed_default(DataType::ChatId, chat_id) + .add_typed_default( + DataType::SenderUserId, + DataValue::Str(signer_id.to_string()), + ) + .add_typed_default( + DataType::RecipientUserId, + DataValue::Str(recipient_id.to_string()), + ) + .add_typed_default(DataType::SecretId, secret_id) + .add_typed_default(DataType::VersionNumber, version) + .add_typed_default(DataType::Payload, DataValue::Str("available".to_string())), + ) + } + CommunicationType::MessageEdit => { + let frame = CommunicationValue::new(CommunicationType::MessageEdit) + .with_payload(payload.clone()); + Some( + CommunicationValue::new(CommunicationType::MessageEditLive) + .with_id(event_id) + .with_sender(signer_id) + .with_receiver(receiver) + .add_typed_default( + DataType::Content, + frame.get_data(DataType::Content)?.clone(), + ) + .add_typed_default( + DataType::SendTime, + frame.get_data(DataType::SendTime)?.clone(), + ) + .add_typed_default( + DataType::VersionNumber, + frame.get_data(DataType::VersionNumber)?.clone(), + ) + .add_typed_default(DataType::ChatPartnerId, sender.clone()), + ) + } + CommunicationType::MessageReactionAdd | CommunicationType::MessageReactionRemove => { + let frame = CommunicationValue::new(message_type).with_payload(payload.clone()); + Some( + CommunicationValue::new(CommunicationType::MessageReactionLive) + .with_id(event_id) + .with_sender(signer_id) + .with_receiver(receiver) + .add_typed_default( + DataType::Reaction, + frame.get_data(DataType::Reaction)?.clone(), + ) + .add_typed_default( + DataType::SendTime, + frame.get_data(DataType::SendTime)?.clone(), + ) + .add_typed_default(DataType::ChatPartnerId, sender.clone()) + .add_typed_default(DataType::SenderId, sender) + .add_typed_default( + DataType::Accepted, + DataValue::Bool(message_type == CommunicationType::MessageReactionAdd), + ), + ) + } + CommunicationType::MessageDelete | CommunicationType::MessageDeleteLive => { + let frame = CommunicationValue::new(CommunicationType::MessageDelete) + .with_payload(payload.clone()); + Some( + CommunicationValue::new(CommunicationType::MessageDeleteLive) + .with_id(event_id) + .with_sender(signer_id) + .with_receiver(receiver) + .add_typed_default( + DataType::SendTime, + frame.get_data(DataType::SendTime)?.clone(), + ) + .add_typed_default(DataType::ChatPartnerId, sender), + ) + } + CommunicationType::AddConversation if recipient_id < signer_id => { + let chat_id = format!("{recipient_id}:{signer_id}"); + Some( + CommunicationValue::new(CommunicationType::ChatSecretForward) + .with_id(event_id) + .with_sender(signer_id) + .with_receiver(receiver) + .add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone())) + .add_typed_default( + DataType::SenderUserId, + DataValue::Str(signer_id.to_string()), + ) + .add_typed_default( + DataType::RecipientUserId, + DataValue::Str(recipient_id.to_string()), + ) + .add_typed_default( + DataType::SecretId, + DataValue::Str(format!("chat:{chat_id}:main")), + ) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)) + .add_typed_default(DataType::Payload, DataValue::Str("init".to_string())), + ) + } + _ => None, + } +} + +/* + * Owns relay policy and storage semantics. Transport adapters supply identity + * material and deliver the returned RelayOutcome on their own connection. + */ + +fn recipient_block_policy_applies( + recipient_is_local: bool, + recipient_id: i64, + signer_id: i64, +) -> bool { + recipient_is_local && recipient_id != signer_id +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connection_handler::{RouteError, RouteOutcome}; + use iota_identity::{LocalUserDescriptor, LocalUserId, ResolutionContext, UserAddress}; + + struct UnusedDependencies; + + #[async_trait] + impl IdentityResolver for UnusedDependencies { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + _: &PrincipalId, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn signing_keys( + &self, + _: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + Err(IdentityError::NotFound) + } + } + + impl LocalUserStore for UnusedDependencies { + fn get_local_user( + &self, + _: LocalUserId, + ) -> Result, IdentityError> { + Ok(None) + } + + fn get_local_user_by_username( + &self, + _: &str, + ) -> Result, IdentityError> { + Ok(None) + } + + fn is_hosted_here(&self, _: LocalUserId) -> Result { + Ok(false) + } + + fn local_user_for_principal( + &self, + _: PrincipalHandle, + ) -> Result, IdentityError> { + Ok(None) + } + + fn principal_for_local_user( + &self, + _: LocalUserId, + ) -> Result, IdentityError> { + Ok(None) + } + } + + #[async_trait] + impl PeerRouter for UnusedDependencies { + async fn route( + &self, + destination: &RouteDestination, + _: CommunicationValue, + ) -> Result { + match destination { + RouteDestination::Iota(node) => Err(RouteError::NoRoute(node.clone())), + RouteDestination::LegacyOmegaIota { .. } => Ok(RouteOutcome::Retryable { + reason: "unused".into(), + }), + } + } + } + + #[async_trait] + impl RelayNodeIdentity for UnusedDependencies { + async fn keyring(&self) -> Option> { + None + } + + fn node_id(&self) -> Option { + None + } + } + + fn service() -> Arc { + let dependencies = Arc::new(UnusedDependencies); + Arc::new(RelayService::new( + dependencies.clone(), + dependencies.clone(), + None, + )) + } + + #[tokio::test] + async fn peer_ingress_rejects_legacy_wire_frames() { + let node = iota_identity::LocalNodeIdentity::from_keyring(Keyring::generate()) + .expect("generated identity is valid") + .node_id() + .clone(); + let outcome = service() + .accept_relay( + IngressSource::DirectPeer { node_id: node }, + CommunicationValue::new(CommunicationType::Relay), + ) + .await; + assert!(matches!(outcome, Err(RelayServiceError::Rejected(_)))); + } + + #[test] + fn normalized_legacy_identity_scopes_numeric_ids_to_authority() { + let first = PrincipalId { + authority: AuthorityId::new("omega:a").expect("static authority is valid"), + user_id: 7, + }; + let second = PrincipalId { + authority: AuthorityId::new("iota:b").expect("static authority is valid"), + user_id: 7, + }; + assert_ne!(first, second); + } + + #[test] + fn principal_home_selects_direct_or_legacy_route() { + let node = iota_identity::LocalNodeIdentity::from_keyring(Keyring::generate()) + .expect("generated identity is valid") + .node_id() + .clone(); + assert_eq!( + route_destination(&PrincipalHome::Iota(node.clone())), + Some(RouteDestination::Iota(node)) + ); + assert!(route_destination(&PrincipalHome::Unknown).is_none()); + } + + #[test] + fn recipient_block_policy_skips_self_delivery() { + assert!(!recipient_block_policy_applies(true, 42, 42)); + assert!(recipient_block_policy_applies(true, 42, 43)); + assert!(!recipient_block_policy_applies(false, 42, 43)); + } +} diff --git a/iota-connection/tests/pending_restart.rs b/iota-connection/tests/pending_restart.rs new file mode 100644 index 0000000..3bc009e --- /dev/null +++ b/iota-connection/tests/pending_restart.rs @@ -0,0 +1,116 @@ +use async_trait::async_trait; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::relay_service::PendingRelayDispatcher; +use iota_identity::{IotaNodeId, PrincipalHandle}; +use iota_storage::util::relay_queue::{self, RelayIdentity, RelayTarget}; +use mtp::codec::{CommunicationType, CommunicationValue}; +use mtp::crypto::Keyring; +use std::path::Path; +use std::process::Command; + +struct AcceptingRouter; + +#[async_trait] +impl PeerRouter for AcceptingRouter { + async fn route( + &self, + destination: &RouteDestination, + _: CommunicationValue, + ) -> Result { + assert!(matches!(destination, RouteDestination::Iota(_))); + Ok(RouteOutcome::Accepted { + relay_message_id: "restart-relay".into(), + destination_accepted_at: 10, + }) + } +} + +fn initialize(storage: &Path) { + iota_util::file_util::configure_storage_directory(storage.to_owned()); + iota_storage::util::db::initialize_database().unwrap(); +} + +fn seed(storage: &Path) { + initialize(storage); + let (signer, recipient) = iota_storage::util::db::with_db(|connection| { + connection.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:first', 1, 1, 1)", + [], + )?; + let signer = connection.last_insert_rowid(); + connection.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:second', 1, 1, 1)", + [], + )?; + Ok((signer, connection.last_insert_rowid())) + }) + .unwrap(); + let frame = CommunicationValue::new(CommunicationType::Relay).with_id(7); + let version = frame.type_map().unwrap().version.to_string(); + let bytes = frame.to_bytes().unwrap(); + relay_queue::enqueue( + RelayTarget::Iota( + IotaNodeId::from_public_keys(&Keyring::generate().public_key_bundle()).unwrap(), + ), + &RelayIdentity { + signer: PrincipalHandle(signer), + recipient: PrincipalHandle(recipient), + message_id: "restart-relay".into(), + legacy_signer_id: None, + legacy_recipient_id: None, + }, + &bytes, + 0, + 7, + &version, + ) + .unwrap(); + assert_eq!(relay_queue::list_active(10).unwrap().len(), 1); +} + +async fn deliver(storage: &Path) { + initialize(storage); + let dispatcher = PendingRelayDispatcher::new(std::sync::Arc::new(AcceptingRouter)); + assert_eq!(dispatcher.dispatch_ready(10).await.unwrap(), 1); + assert_eq!(dispatcher.dispatch_ready(10).await.unwrap(), 0); + assert!(relay_queue::list(10).unwrap().is_empty()); +} + +#[tokio::test] +async fn pending_relay_survives_process_restart_and_delivers_once() { + let storage = + std::env::var_os("TENSAMIN_PENDING_RESTART_STORAGE").map(std::path::PathBuf::from); + match std::env::var("TENSAMIN_PENDING_RESTART_PHASE").as_deref() { + Ok("seed") => { + seed(storage.as_deref().unwrap()); + return; + } + Ok("deliver") => { + deliver(storage.as_deref().unwrap()).await; + return; + } + _ => {} + } + + let storage = std::env::temp_dir().join(format!( + "tensamin-pending-restart-{}-{}", + std::process::id(), + iota_storage::util::sync::now_millis() + )); + std::fs::create_dir(&storage).unwrap(); + let executable = std::env::current_exe().unwrap(); + for phase in ["seed", "deliver"] { + let status = Command::new(&executable) + .args([ + "--exact", + "pending_relay_survives_process_restart_and_delivers_once", + "--nocapture", + ]) + .env("TENSAMIN_PENDING_RESTART_PHASE", phase) + .env("TENSAMIN_PENDING_RESTART_STORAGE", &storage) + .status() + .unwrap(); + assert!(status.success(), "{phase} child process failed"); + } + std::fs::remove_dir_all(&storage).unwrap(); +} diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index a5d7b6b..35a4b37 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice}; -use iota_cli::ui::UI; +use iota_cli::ui::UiHandle; use iota_terms::{Doc, LegalDocument, TermsType as Type, get_current_docs}; use iota_util::file_util::{load_file, save_file}; use tokio::sync::oneshot; @@ -13,7 +13,7 @@ pub struct ConsentCheck { pub documents: [LegalDocument; 3], } -pub async fn check(ui: Arc) -> Result { +pub async fn check(ui: Arc) -> Result { let mut state = ConsentState::load_state(); let documents = ensure_initial_consent(ui.clone(), &mut state).await?; @@ -49,7 +49,7 @@ pub fn non_interactive_consent() -> NonInteractiveConsent { } async fn ensure_initial_consent( - ui: Arc, + ui: Arc, state: &mut ConsentState, ) -> Result<[LegalDocument; 3], String> { let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| { @@ -99,7 +99,7 @@ async fn ensure_initial_consent( } } /* -async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), String> { +async fn ensure_updates(ui: Arc, state: &mut ConsentState) -> Result<(), String> { let Some((eula_update, tos_update, privacy_update)) = get_updates().await else { return Ok(()); }; diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index ed86b24..ff6ce86 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -35,7 +35,7 @@ async fn main() { let ipc = IpcClient::connect(socket) .await .expect("iota-daemon must be running before starting iota-core"); - let session = start_tui(ipc).expect("interactive terminal initialization failed"); + let session = start_tui(ipc.clone()).expect("interactive terminal initialization failed"); let ui = session.ui(); let consent = match consent_state::check(ui.clone()).await { @@ -83,7 +83,7 @@ async fn main() { check_update(); iota_state::setup(&state); - let main_screen = MainScreen::new(ui.clone()).await; + let main_screen = MainScreen::new(ipc); ui.set_screen(Box::new(main_screen)).await; // LANGUAGE PACK diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index aa544ab..1cdad75 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -6,13 +6,17 @@ edition = "2024" [dependencies] async-trait = "0.1.89" iota-ipc = { path = "../iota-ipc" } +iota-auth = { path = "../iota-auth" } +iota-connection = { path = "../iota-connection" } +iota-identity = { path = "../iota-identity" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c" } +other-iota = { path = "../other-iota" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318" } libc = "0.2" sysinfo = "0.38.0" serde_yaml = "0.9" diff --git a/iota-daemon-lib/src/accounts.rs b/iota-daemon-lib/src/accounts.rs new file mode 100644 index 0000000..21c6133 --- /dev/null +++ b/iota-daemon-lib/src/accounts.rs @@ -0,0 +1,451 @@ +use async_trait::async_trait; +use iota_identity::{ + AuthorityKind, LocalNodeIdentity, LocalUserDescriptor, LocalUserId, PrincipalHome, +}; +use iota_storage::users::pending_operations::{ + self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, +}; +use iota_storage::users::user_manager; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use omikron_connector::{OmikronClient, OmikronError}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateUserRequest { + pub username: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalAccount { + pub user: LocalUserId, + pub username: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CredentialPreview { + pub user: LocalUserId, + pub username: String, + pub assigned_iota_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReconcileResult { + pub remote_iota_id: Option, + pub released_locally: bool, +} + +#[derive(Debug)] +pub enum AccountError { + InvalidRequest(String), + Unauthorized(String), + NotFound, + Conflict(String), + Timeout(String), + Unavailable(String), + Storage(String), + Internal(String), +} + +#[async_trait] +pub trait AccountAuthority: Send + Sync { + async fn is_available(&self) -> bool; + + async fn create_user(&self, request: CreateUserRequest) -> Result; + + async fn inspect_credential( + &self, + credential: &[u8], + ) -> Result; + + async fn attach_user(&self, credential: &[u8]) -> Result; + + async fn reconcile_user(&self, user: LocalUserId) -> Result; + + async fn release_user(&self, user: LocalUserId) -> Result<(), AccountError>; + + async fn delete_user(&self, user: LocalUserId, credential: &[u8]) -> Result<(), AccountError>; + + async fn reconcile_managed_users(&self) -> Result<(), AccountError>; +} + +pub struct OmegaAccountAuthority { + client: Arc, +} + +pub struct LocalIotaAccountAuthority { + identity: LocalNodeIdentity, + creation_lock: Mutex<()>, +} + +impl LocalIotaAccountAuthority { + pub fn new(identity: LocalNodeIdentity) -> Self { + Self { + identity, + creation_lock: Mutex::new(()), + } + } +} + +#[async_trait] +impl AccountAuthority for LocalIotaAccountAuthority { + async fn is_available(&self) -> bool { + true + } + + async fn create_user(&self, request: CreateUserRequest) -> Result { + if !valid_username(&request.username) { + return Err(AccountError::InvalidRequest("invalid username".into())); + } + let _creation = self + .creation_lock + .lock() + .map_err(|_| AccountError::Internal("local account lock is poisoned".into()))?; + if user_manager::get_user_by_username(&request.username) + .map_err(|error| AccountError::Storage(error.to_string()))? + .is_some() + { + return Err(AccountError::Conflict("username is unavailable".into())); + } + let user_id = user_manager::allocate_local_user_id(iota_util::tu::MAX_PROTOCOL_ID) + .map_err(|error| AccountError::Storage(error.to_string()))?; + let keyring = iota_util::crypto_helper::generate_keyring(); + let public_key = + iota_util::crypto_helper::public_key_bundle_to_base64(&keyring.public_key_bundle()); + if public_key.is_empty() { + return Err(AccountError::Internal( + "generated user public key could not be encoded".into(), + )); + } + let credential = iota_util::tu::TuCredential { + user_id, + authority: iota_util::tu::TuAuthority::Iota(self.identity.node_id().clone()), + keyring, + }; + iota_util::file_util::write_user_credential( + &request.username, + &credential.to_canonical_string(), + ) + .map_err(|error| AccountError::Storage(error.to_string()))?; + let profile = iota_storage::users::user_profile::UserProfile::new( + user_id, + request.username.clone(), + None, + public_key.clone(), + None, + None, + ); + if let Err(error) = user_manager::try_add_user(profile) { + let _ = iota_util::file_util::remove_user_credential(user_id, Some(&request.username)); + return Err(AccountError::Storage(error.to_string())); + } + let principal = iota_storage::identity::SqlitePrincipalStore.ensure_local_principal( + self.identity.authority_id(), + AuthorityKind::Iota, + &LocalUserDescriptor { + id: LocalUserId(user_id), + username: request.username.clone(), + display_name: None, + public_key, + }, + PrincipalHome::Iota(self.identity.node_id().clone()), + now_millis(), + ); + if let Err(error) = principal { + let _ = user_manager::remove_user(user_id); + let _ = iota_util::file_util::remove_user_credential(user_id, Some(&request.username)); + return Err(AccountError::Storage(error.to_string())); + } + Ok(LocalAccount { + user: LocalUserId(user_id), + username: request.username, + }) + } + + async fn inspect_credential( + &self, + credential: &[u8], + ) -> Result { + let credential = parse_local_credential(credential, &self.identity)?; + let user = user_manager::get_user(credential.user_id) + .map_err(|error| AccountError::Storage(error.to_string()))? + .ok_or(AccountError::NotFound)?; + if user.public_key + != iota_util::crypto_helper::public_key_bundle_to_base64( + &credential.public_key_bundle(), + ) + { + return Err(AccountError::Unauthorized( + "credential key does not match local account".into(), + )); + } + Ok(CredentialPreview { + user: LocalUserId(user.user_id), + username: user.username, + assigned_iota_id: None, + }) + } + + async fn attach_user(&self, credential: &[u8]) -> Result { + let preview = self.inspect_credential(credential).await?; + Ok(LocalAccount { + user: preview.user, + username: preview.username, + }) + } + + async fn reconcile_user(&self, user: LocalUserId) -> Result { + if user_manager::get_user(user.0) + .map_err(|error| AccountError::Storage(error.to_string()))? + .is_none() + { + return Err(AccountError::NotFound); + } + Ok(ReconcileResult { + remote_iota_id: None, + released_locally: false, + }) + } + + async fn release_user(&self, _: LocalUserId) -> Result<(), AccountError> { + Err(AccountError::InvalidRequest( + "local Iota accounts cannot be released to another authority".into(), + )) + } + + async fn delete_user(&self, user: LocalUserId, credential: &[u8]) -> Result<(), AccountError> { + let parsed = parse_local_credential(credential, &self.identity)?; + if parsed.user_id != user.0 { + return Err(AccountError::InvalidRequest( + "credential user ID does not match deletion target".into(), + )); + } + self.inspect_credential(credential).await?; + user_manager::purge_user_data(user.0) + .map_err(|error| AccountError::Storage(error.to_string()))?; + iota_storage::identity::SqlitePrincipalStore + .retire_local_principal(user, now_millis()) + .map_err(|error| AccountError::Storage(error.to_string()))?; + user_manager::remove_user(user.0) + .map_err(|error| AccountError::Storage(error.to_string()))?; + iota_util::file_util::remove_user_credential(user.0, None) + .map_err(|error| AccountError::Storage(error.to_string())) + } + + async fn reconcile_managed_users(&self) -> Result<(), AccountError> { + Ok(()) + } +} + +fn parse_local_credential( + credential: &[u8], + identity: &LocalNodeIdentity, +) -> Result { + let credential = std::str::from_utf8(credential) + .map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?; + let credential = iota_util::tu::TuCredential::parse(credential) + .map_err(|error| AccountError::InvalidRequest(error.to_string()))?; + if credential + .principal() + .map_err(|error| AccountError::InvalidRequest(error.to_string()))? + .authority + != *identity.authority_id() + { + return Err(AccountError::Unauthorized( + "credential belongs to another authority".into(), + )); + } + Ok(credential) +} + +fn valid_username(username: &str) -> bool { + !username.is_empty() + && username.len() <= 15 + && username + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +impl OmegaAccountAuthority { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl AccountAuthority for OmegaAccountAuthority { + async fn is_available(&self) -> bool { + self.client.is_connected().await + } + + async fn create_user(&self, request: CreateUserRequest) -> Result { + omikron_connector::user_ops::create_user(self.client.as_ref(), &request.username) + .await + .map(|user| LocalAccount { + user: LocalUserId(user.user_id), + username: user.username, + }) + .map_err(map_create_error) + } + + async fn inspect_credential( + &self, + credential: &[u8], + ) -> Result { + let credential = std::str::from_utf8(credential) + .map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?; + omikron_connector::user_ops::inspect_tu_credential(self.client.as_ref(), credential) + .await + .map(|preview| CredentialPreview { + user: LocalUserId(preview.user_id), + username: preview.username, + assigned_iota_id: preview.assigned_iota_id, + }) + .map_err(map_lifecycle_error) + } + + async fn attach_user(&self, credential: &[u8]) -> Result { + let credential = std::str::from_utf8(credential) + .map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?; + omikron_connector::user_ops::attach_user_from_tu(self.client.as_ref(), credential) + .await + .map(|user| LocalAccount { + user: LocalUserId(user.user_id), + username: user.username, + }) + .map_err(map_lifecycle_error) + } + + async fn reconcile_user(&self, user: LocalUserId) -> Result { + let residency = user_manager::get_residency_by_id(user.0) + .map_err(|error| AccountError::Storage(error.to_string()))? + .ok_or(AccountError::NotFound)?; + let remote_iota_id = + omikron_connector::user_ops::get_remote_user_assignment(self.client.as_ref(), user.0) + .await + .map_err(map_lifecycle_error)?; + let local_iota_id = iota_storage::util::config_util::CONFIG + .load() + .iota_id + .and_then(|id| i64::try_from(id).ok()); + let released_locally = residency.state == user_manager::LocalUserState::Managed + && remote_iota_id != local_iota_id; + if released_locally { + user_manager::finalize_local_release(user.0, Some(&residency.username)) + .map_err(|error| AccountError::Storage(error.to_string()))?; + } + Ok(ReconcileResult { + remote_iota_id, + released_locally, + }) + } + + async fn release_user(&self, user: LocalUserId) -> Result<(), AccountError> { + let profile = user_manager::get_user(user.0) + .map_err(|error| AccountError::Storage(error.to_string()))? + .ok_or(AccountError::NotFound)?; + pending_operations::upsert(&PendingUserOperation { + user_id: user.0, + operation: PendingUserOperationKind::Release, + username: profile.username, + public_key: None, + private_key_hash: None, + reset_token: None, + registration_token: None, + phase: PendingUserOperationPhase::Prepared, + created_at: now_millis(), + }) + .map_err(|error| AccountError::Storage(error.to_string()))?; + let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota) + .add_typed_default(DataType::UserId, DataValue::SignedNumber(user.0.into())); + match self + .client + .await_response(&request, Duration::from_secs(20)) + .await + { + Ok(response) if response.is_type(CommunicationType::Success) => { + user_manager::release_user(user.0) + .map_err(|error| AccountError::Storage(error.to_string()))?; + pending_operations::remove(user.0) + .map_err(|error| AccountError::Storage(error.to_string())) + } + Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => { + let _ = pending_operations::remove(user.0); + Err(AccountError::Unauthorized( + "release was not authorized".into(), + )) + } + Ok(_) => { + let _ = pending_operations::remove(user.0); + Err(AccountError::Conflict("release was rejected".into())) + } + Err(OmikronError::Timeout(message)) => Err(AccountError::Timeout(message)), + Err(error) => Err(AccountError::Unavailable(error.to_string())), + } + } + + async fn delete_user(&self, user: LocalUserId, credential: &[u8]) -> Result<(), AccountError> { + let credential = std::str::from_utf8(credential) + .map_err(|_| AccountError::InvalidRequest("credential is not UTF-8".into()))?; + omikron_connector::user_ops::complete_delete_user_with_tu( + self.client.as_ref(), + credential, + user.0, + ) + .await + .map_err(map_lifecycle_error) + } + + async fn reconcile_managed_users(&self) -> Result<(), AccountError> { + omikron_connector::user_ops::reconcile_managed_users(self.client.as_ref()).await; + Ok(()) + } +} + +fn map_create_error(error: omikron_connector::user_ops::CreateUserError) -> AccountError { + use omikron_connector::user_ops::CreateUserError; + match error { + CreateUserError::InvalidUsername => AccountError::InvalidRequest("invalid username".into()), + CreateUserError::Transport(OmikronError::Timeout(message)) => { + AccountError::Timeout(message) + } + CreateUserError::Transport(error) => AccountError::Unavailable(error.to_string()), + CreateUserError::RemoteRejected => AccountError::Conflict("username is unavailable".into()), + CreateUserError::LocalFinalizationPending { user_id } => { + AccountError::Storage(format!("local finalization is pending for user {user_id}")) + } + CreateUserError::LocalPersistence(message) => AccountError::Storage(message), + CreateUserError::InvalidResponse => { + AccountError::Internal("Omega returned an invalid create-user response".into()) + } + } +} + +fn map_lifecycle_error(error: omikron_connector::user_ops::LifecycleUserError) -> AccountError { + use omikron_connector::user_ops::LifecycleUserError; + match error { + LifecycleUserError::InvalidCredential(message) => AccountError::Unauthorized(message), + LifecycleUserError::OmegaHostMismatch => { + AccountError::Unauthorized("credential belongs to another Omega".into()) + } + LifecycleUserError::RemoteRejected => { + AccountError::Unauthorized("credential was rejected".into()) + } + LifecycleUserError::Transport(OmikronError::Timeout(message)) => { + AccountError::Timeout(message) + } + LifecycleUserError::Transport(error) => AccountError::Unavailable(error.to_string()), + LifecycleUserError::LocalPersistence(message) => AccountError::Storage(message), + } +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 9ad50c9..bcb9276 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -9,9 +9,7 @@ use iota_ipc::{ UserOperationSummary, UserReconcileResult, UserSummary, }; use iota_logger::{log, log_command}; -use iota_storage::users::pending_operations::{ - self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase, -}; +use iota_storage::users::pending_operations::{self, PendingUserOperationKind}; use iota_storage::users::user_manager; use iota_storage::util::config_util::{self}; use iota_util::mtp_compat::OptionalDataValueExt; @@ -19,7 +17,9 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use crate::accounts::{AccountError, CreateUserRequest}; use crate::daemon_state::{ShutdownReason, StartupPhase}; +use iota_identity::LocalUserId; pub use iota_ipc::IpcRole; @@ -172,7 +172,7 @@ impl CommandRouter { { return ResponseResult::Error(IpcErrorCode::Unauthorized); } - let needs_omikron = matches!( + let needs_account_authority = matches!( request, LocalRequest::CreateUser { .. } | LocalRequest::InspectTuCredential { .. } @@ -180,12 +180,17 @@ impl CommandRouter { | LocalRequest::ReconcileUser { .. } | LocalRequest::ReleaseUser { .. } | LocalRequest::CompleteDeleteUser { .. } - | LocalRequest::CreateInvitation { - authority: InvitationAuthority::Omega, - .. - } ); - if needs_omikron && !self.services.omikron.is_connected().await { + let needs_centralized_provider = matches!( + request, + LocalRequest::CreateInvitation { + authority: InvitationAuthority::Omega, + .. + } + ); + if (needs_account_authority && !self.services.accounts.is_available().await) + || (needs_centralized_provider && self.services.centralized.is_none()) + { return ResponseResult::Error( if self.runtime.current_startup_phase() != StartupPhase::Ready { IpcErrorCode::NotReady @@ -287,6 +292,9 @@ impl CommandRouter { if lifetime_seconds == 0 || lifetime_seconds > 7 * 24 * 60 * 60 { return ResponseResult::Error(IpcErrorCode::InvalidRequest); } + let Some(omikron) = self.services.omikron() else { + return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); + }; let password_protected = password.is_some(); let mut request = CommunicationValue::new(CommunicationType::CreateUserInvitation) .add_typed_default( @@ -309,9 +317,7 @@ impl CommandRouter { DataValue::Str(label.clone()), ); } - let response = match self - .services - .omikron + let response = match omikron .await_response(&request, Duration::from_secs(20)) .await { @@ -387,9 +393,10 @@ impl CommandRouter { } LocalRequest::ListInvitations { authority } => { if authority != Some(InvitationAuthority::Iota) - && self.services.omikron.is_connected().await + && let Some(omikron) = self.services.omikron() + && omikron.is_connected().await { - match self.services.omikron.sync_omega_invitations().await { + match omikron.sync_omega_invitations().await { Ok(()) => {} Err(omikron_connector::OmikronError::Storage(_)) => { return ResponseResult::Error(IpcErrorCode::StorageFailure); @@ -487,11 +494,10 @@ impl CommandRouter { if !changed { return ResponseResult::Error(IpcErrorCode::Conflict); } - if self.services.omikron.is_connected().await { - self.services - .omikron - .flush_pending_invitation_actions() - .await; + if let Some(omikron) = self.services.omikron() + && omikron.is_connected().await + { + omikron.flush_pending_invitation_actions().await; } let invitation = match iota_storage::users::invitations::list() @@ -541,40 +547,41 @@ impl CommandRouter { })) } LocalRequest::CreateUser { username } => { - match omikron_connector::user_ops::create_user( - self.services.omikron.as_ref(), - &username, - ) - .await + match self + .services + .accounts + .create_user(CreateUserRequest { username }) + .await { Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated { - user_id: user.user_id, + user_id: user.user.0, username: user.username, }), Err(error) => { log!("User creation failed: {error:?}"); match error { - omikron_connector::user_ops::CreateUserError::InvalidUsername => { + AccountError::InvalidRequest(_) => { ResponseResult::Error(IpcErrorCode::InvalidRequest) } - omikron_connector::user_ops::CreateUserError::Transport( - omikron_connector::OmikronError::Timeout(_), - ) => ResponseResult::Error(IpcErrorCode::Timeout), - omikron_connector::user_ops::CreateUserError::Transport(_) => { + AccountError::Timeout(_) => { + ResponseResult::Error(IpcErrorCode::Timeout) + } + AccountError::Unavailable(_) => { ResponseResult::Error(IpcErrorCode::OmikronUnavailable) } - omikron_connector::user_ops::CreateUserError::RemoteRejected => { + AccountError::Conflict(_) => { ResponseResult::Error(IpcErrorCode::Conflict) } - omikron_connector::user_ops::CreateUserError::LocalFinalizationPending { .. } => { + AccountError::Storage(_) => { ResponseResult::Error(IpcErrorCode::StorageFailure) } - omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => { - ResponseResult::Error(IpcErrorCode::StorageFailure) - } - omikron_connector::user_ops::CreateUserError::InvalidResponse => { + AccountError::Internal(_) => { ResponseResult::Error(IpcErrorCode::InternalFailure) } + AccountError::Unauthorized(_) => { + ResponseResult::Error(IpcErrorCode::Unauthorized) + } + AccountError::NotFound => ResponseResult::Error(IpcErrorCode::NotFound), } } } @@ -594,14 +601,14 @@ impl CommandRouter { } }, LocalRequest::AttachUserFromTu { credential } => { - match omikron_connector::user_ops::attach_user_from_tu( - self.services.omikron.as_ref(), - &credential.0, - ) - .await + match self + .services + .accounts + .attach_user(credential.0.as_bytes()) + .await { Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Added {} ({}) to this Iota", user.username, user.user_id), + message: format!("Added {} ({}) to this Iota", user.username, user.user.0), }), Err(error) => { log!("Credential attach failed: {error:?}"); @@ -610,15 +617,15 @@ impl CommandRouter { } } LocalRequest::InspectTuCredential { credential } => { - match omikron_connector::user_ops::inspect_tu_credential( - self.services.omikron.as_ref(), - &credential.0, - ) - .await + match self + .services + .accounts + .inspect_credential(credential.0.as_bytes()) + .await { Ok(preview) => ResponseResult::Ok(ResponsePayload::TuCredentialPreview( iota_ipc::TuCredentialPreview { - user_id: preview.user_id, + user_id: preview.user.0, username: preview.username, assigned_iota_id: preview.assigned_iota_id, }, @@ -635,32 +642,20 @@ impl CommandRouter { Ok(None) => return ResponseResult::Error(IpcErrorCode::NotFound), Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), }; - let omega_iota_id = match omikron_connector::user_ops::get_remote_user_assignment( - self.services.omikron.as_ref(), - user_id, - ) - .await + let reconciliation = match self + .services + .accounts + .reconcile_user(LocalUserId(user_id)) + .await { - Ok(assignment) => assignment, + Ok(result) => result, Err(error) => { log!("User reconciliation failed for {user_id}: {error:?}"); return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); } }; - let local_iota_id = config_util::CONFIG - .load() - .iota_id - .and_then(|id| i64::try_from(id).ok()); - let action = if residency.state == user_manager::LocalUserState::Managed - && omega_iota_id != local_iota_id - { - match user_manager::finalize_local_release(user_id, Some(&residency.username)) { - Ok(()) => ReconcileAction::ReleasedLocally, - Err(error) => { - log!("User reconciliation cleanup failed for {user_id}: {error}"); - return ResponseResult::Error(IpcErrorCode::StorageFailure); - } - } + let action = if reconciliation.released_locally { + ReconcileAction::ReleasedLocally } else { ReconcileAction::None }; @@ -672,7 +667,7 @@ impl CommandRouter { iota_ipc::LocalUserState::Released } }, - omega_iota_id, + omega_iota_id: reconciliation.remote_iota_id, action, })) } @@ -787,12 +782,11 @@ impl CommandRouter { 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 + match self + .services + .accounts + .delete_user(LocalUserId(user_id), contents.as_bytes()) + .await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}"), @@ -805,76 +799,41 @@ impl CommandRouter { } LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest), LocalRequest::ReleaseUser { user_id } => { - let user = match user_manager::get_user(user_id) { - Ok(user) => user, - Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure), - }; - let Some(user) = user else { - return ResponseResult::Error(IpcErrorCode::NotFound); - }; - if pending_operations::upsert(&PendingUserOperation { - user_id, - operation: PendingUserOperationKind::Release, - username: user.username, - public_key: None, - private_key_hash: None, - reset_token: None, - registration_token: None, - phase: PendingUserOperationPhase::Prepared, - created_at: now_millis(), - }) - .is_err() - { - return ResponseResult::Error(IpcErrorCode::StorageFailure); - } - 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)) + .accounts + .release_user(LocalUserId(user_id)) .await { - Ok(response) if response.is_type(CommunicationType::Success) => { - match user_manager::release_user(user_id) { - Ok(()) if pending_operations::remove(user_id).is_ok() => { - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!( - "Released user {user_id}; hosted data was retained" - ), - }) - } - Ok(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), - 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) => { - let _ = pending_operations::remove(user_id); + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!("Released user {user_id}; hosted data was retained"), + }), + Err(AccountError::NotFound) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(AccountError::Unauthorized(_)) => { ResponseResult::Error(IpcErrorCode::Unauthorized) } - Ok(_) => { - let _ = pending_operations::remove(user_id); - ResponseResult::Error(IpcErrorCode::Conflict) + Err(AccountError::Conflict(_)) => ResponseResult::Error(IpcErrorCode::Conflict), + Err(AccountError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout), + Err(AccountError::Unavailable(_)) => { + ResponseResult::Error(IpcErrorCode::OmikronUnavailable) } - Err(omikron_connector::OmikronError::Timeout(_)) => { - ResponseResult::Error(IpcErrorCode::Timeout) + Err(AccountError::Storage(_)) => { + ResponseResult::Error(IpcErrorCode::StorageFailure) } - Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + Err(_) => ResponseResult::Error(IpcErrorCode::InternalFailure), } } - LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Reconnected to Omikron server".into(), - }), - Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + LocalRequest::ReconnectOmikron => match self.services.omikron() { + Some(omikron) => match omikron.reconnect().await { + Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: "Reconnected to Omikron server".into(), + }), + Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + }, + None => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), }, - LocalRequest::RotateIotaIdentity => { - match self.services.omikron.rotate_identity().await { + LocalRequest::RotateIotaIdentity => match self.services.omikron() { + Some(omikron) => match omikron.rotate_identity().await { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: "New identity registered with Omikron".into(), }), @@ -882,8 +841,9 @@ impl CommandRouter { log!("Iota identity rotation failed: {}", error); ResponseResult::Error(IpcErrorCode::OmikronUnavailable) } - } - } + }, + None => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), + }, LocalRequest::RequestProcessExit { intent } => { if matches!(intent, ExitIntent::Restart) && !matches!( @@ -938,7 +898,10 @@ impl CommandRouter { Err(_) => ResponseResult::Error(IpcErrorCode::InvalidRequest), }, LocalRequest::GetOmikronStatus => { - let connected = self.services.omikron.is_connected().await; + let connected = match self.services.omikron() { + Some(omikron) => omikron.is_connected().await, + None => false, + }; let iota_id = config_util::CONFIG.load().iota_id; ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse { connected, diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 3afeff8..23b021a 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -1,3 +1,4 @@ +pub mod accounts; pub mod command_router; pub mod daemon_state; pub mod deployment; @@ -7,8 +8,9 @@ pub mod log_buffer; pub mod services; pub mod task_registry; +pub use accounts::{AccountAuthority, LocalIotaAccountAuthority, OmegaAccountAuthority}; pub use command_router::{CommandRouter, IpcRole, PeerContext}; pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase}; pub use ipc_server::IpcServer; -pub use services::DaemonServices; +pub use services::{DaemonServiceComponents, DaemonServices}; pub use task_registry::TaskRegistry; diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs index b63b574..71f6af0 100644 --- a/iota-daemon-lib/src/services.rs +++ b/iota-daemon-lib/src/services.rs @@ -1,72 +1,524 @@ use async_trait::async_trait; +use iota_auth::{ + ForeignPrincipalAuthenticator, HostedSessionRegistrar, IotaPeerAuthenticator, SessionManager, +}; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::relay_service::{ + LegacyRelayDecoder, LegacyRelayIdentity, RelayNodeIdentity, RelayService, +}; +use iota_identity::{ + AuthorityId, AuthorityKind, AuthorityLocator, CompositeIdentityResolver, IdentityError, + IdentityResolver, IotaNodeId, LocalUserStore, NodeDirectory, NodeIdentityResolver, + PrincipalHome, PrincipalId, PrincipalStore, ResolutionContext, ResolvedNodeIdentity, + ResolvedPrincipal, UserAddress, +}; use mtp::codec::CommunicationValue; -use omikron_connector::{OmikronClient, OmikronConnection, OmikronError}; +use omikron_connector::{OmikronClient, OmikronConnection}; use std::sync::Arc; -use std::time::Duration; + +use crate::accounts::{ + AccountAuthority, AccountError, CreateUserRequest, CredentialPreview, LocalAccount, + LocalIotaAccountAuthority, OmegaAccountAuthority, ReconcileResult, +}; #[derive(Default)] pub struct UserService; #[derive(Default)] pub struct ConfigService; -pub struct DaemonServices { +pub struct CentralizedServices { pub omikron: Arc, +} + +pub struct AuthService { + pub hosted_accounts: Arc, + pub foreign_principals: Arc, + pub iota_peers: Arc, +} + +pub struct DaemonServiceComponents { + pub accounts: Arc, + pub identities: Arc, + pub principals: Arc, + pub local_users: Arc, + pub node_identities: Arc, + pub relay: Arc, + pub router: Arc, + pub direct_router: Option>, + pub relay_routers: Option>, + pub centralized: Option>, +} + +pub struct DaemonServices { + pub accounts: Arc, + pub identities: Arc, + pub principals: Arc, + pub local_users: Arc, + pub relay: Arc, + pub router: Arc, + pub direct_router: Option>, + pub relay_routers: Option>, + pub sessions: Arc, + pub auth: Arc, + pub centralized: Option>, pub users: Arc, pub config: Arc, pub active: bool, } impl DaemonServices { - pub fn new(omikron: Arc) -> Arc { + pub fn standalone( + identity: iota_identity::LocalNodeIdentity, + ) -> Result, IdentityError> { + let principals = Arc::new(iota_storage::identity::SqlitePrincipalStore); + let local_users: Arc = + Arc::new(iota_storage::identity::SqliteLocalUserStore); + let local_identity: Arc = + Arc::new(iota_storage::identity::LocalIdentityResolver::new( + identity.authority_id().clone(), + AuthorityKind::Iota, + PrincipalHome::Iota(identity.node_id().clone()), + local_users.clone(), + principals.clone(), + )); + let nodes = Arc::new(iota_storage::node_directory::SqliteNodeDirectory); + let node_directory: Arc = nodes.clone(); + let federation_client: Arc = + Arc::new(other_iota::HttpFederationIdentityClient::default()); + let remote_iota: Arc = Arc::new(other_iota::RemoteIotaResolver::new( + federation_client.clone(), + principals.clone(), + node_directory.clone(), + )); + let remote_omega: Arc = + Arc::new(other_iota::RemoteOmegaResolver::new( + federation_client, + principals.clone(), + node_directory, + )); + let identities: Arc = + Arc::new(CompositeIdentityResolver::new(vec![ + local_identity, + remote_iota, + remote_omega, + ])?); + let direct_router = Arc::new(other_iota::DirectPeerRouter::default()); + let relay_routers = Arc::new(other_iota::relay_router::RelayRouterSet::default()); + let router: Arc = + Arc::new(other_iota::relay_router::DirectThenRelayRouter::new( + direct_router.clone(), + relay_routers.clone(), + )); + let relay_identity: Arc = + Arc::new(StandaloneRelayIdentity(identity.clone())); + let relay = Arc::new( + RelayService::new(local_users.clone(), router.clone(), None) + .with_federation(identities.clone(), relay_identity), + ); + Ok(Self::compose(DaemonServiceComponents { + accounts: Arc::new(LocalIotaAccountAuthority::new(identity.clone())), + identities, + principals, + local_users, + node_identities: nodes, + relay, + router, + direct_router: Some(direct_router), + relay_routers: Some(relay_routers), + centralized: None, + })) + } + + pub fn new(omikron: Arc) -> Result, IdentityError> { + let client: Arc = omikron.clone(); + let authority = + AuthorityId::omega_legacy(&omikron_connector::omega_discovery::omega_host()) + .map_err(|error| IdentityError::InvalidIdentifier(error.to_string()))?; + iota_storage::identity::SqlitePrincipalStore.migrate_legacy_omega_authority(&authority)?; + let router: Arc = Arc::new( + omikron_connector::router::OmikronPeerRouter::new(client.clone(), authority.clone()), + ); + let services = Self::centralized( + client, + Some(router), + Some(omikron.session_manager()), + Some(omikron.clone()), + ); + let _ = omikron.install_relay_service(services.relay.clone()); + Ok(services) + } + + pub fn with_centralized_client(client: Arc) -> Arc { + let inactive = Arc::new(InactiveServices); + Self::centralized(client, None, None, Some(inactive)) + } + + fn centralized( + client: Arc, + router: Option>, + sessions: Option>, + node_identity: Option>, + ) -> Arc { + let principals: Arc = + Arc::new(iota_storage::identity::SqlitePrincipalStore); + let local_users: Arc = + Arc::new(iota_storage::identity::SqliteLocalUserStore); + let omega_host = omikron_connector::omega_discovery::omega_host(); + let authority = AuthorityId::omega_legacy(&omega_host) + .expect("the configured Omega authority ID is valid"); + let locator = AuthorityLocator::new(omega_host) + .expect("the configured Omega host is a valid authority locator"); + let local_identity: Arc = + Arc::new(iota_storage::identity::LocalIdentityResolver::new( + authority.clone(), + AuthorityKind::Omega, + PrincipalHome::Omega(locator.clone()), + local_users.clone(), + Arc::new(iota_storage::identity::SqlitePrincipalStore), + )); + let omega_identity: Arc = + Arc::new(omikron_connector::identity::OmegaIdentityResolver::new( + client.clone(), + authority.clone(), + locator, + principals.clone(), + )); + let identities: Arc = Arc::new( + CompositeIdentityResolver::new(vec![local_identity, omega_identity]) + .expect("the identity resolver chain is not empty"), + ); + let router: Arc = router.unwrap_or_else(|| { + Arc::new(omikron_connector::router::OmikronPeerRouter::new( + client.clone(), + authority.clone(), + )) + }); + let legacy_decoder = node_identity.map(|node_identity| { + Arc::new(LegacyRelayDecoder::new( + identities.clone(), + node_identity, + authority, + )) + }); + let relay = Arc::new(RelayService::new( + local_users.clone(), + router.clone(), + legacy_decoder, + )); + Self::compose_with_sessions( + DaemonServiceComponents { + accounts: Arc::new(OmegaAccountAuthority::new(client.clone())), + identities, + principals, + local_users, + node_identities: Arc::new(InactiveNodeIdentityResolver), + relay, + router, + direct_router: None, + relay_routers: None, + centralized: Some(Arc::new(CentralizedServices { omikron: client })), + }, + sessions.unwrap_or_else(|| Arc::new(SessionManager::default())), + ) + } + + pub fn compose(components: DaemonServiceComponents) -> Arc { + Self::compose_with_sessions(components, Arc::new(SessionManager::default())) + } + + fn compose_with_sessions( + components: DaemonServiceComponents, + sessions: Arc, + ) -> Arc { + let auth = Arc::new(AuthService { + hosted_accounts: Arc::new(HostedSessionRegistrar::new(sessions.clone())), + foreign_principals: Arc::new(ForeignPrincipalAuthenticator::new( + components.identities.clone(), + sessions.clone(), + )), + iota_peers: Arc::new(IotaPeerAuthenticator::new( + components.node_identities.clone(), + )), + }); Arc::new(Self { - omikron, + accounts: components.accounts, + identities: components.identities, + principals: components.principals, + local_users: components.local_users, + relay: components.relay, + router: components.router, + direct_router: components.direct_router, + relay_routers: components.relay_routers, + sessions, + auth, + centralized: components.centralized, users: Arc::new(UserService), config: Arc::new(ConfigService), active: true, }) } - /// Services used while the daemon is awaiting terms acceptance. They can - /// never initiate a connection; the command router exposes status only. pub fn inactive() -> Arc { + let identities: Arc = Arc::new(InactiveIdentityResolver); + let sessions = Arc::new(SessionManager::default()); + let auth = Arc::new(AuthService { + hosted_accounts: Arc::new(HostedSessionRegistrar::new(sessions.clone())), + foreign_principals: Arc::new(ForeignPrincipalAuthenticator::new( + identities.clone(), + sessions.clone(), + )), + iota_peers: Arc::new(IotaPeerAuthenticator::new(Arc::new( + InactiveNodeIdentityResolver, + ))), + }); + let inactive = Arc::new(InactiveServices); Arc::new(Self { - omikron: Arc::new(InactiveOmikron), + accounts: inactive.clone(), + identities: identities.clone(), + principals: inactive.clone(), + local_users: inactive.clone(), + relay: Arc::new(RelayService::new(inactive.clone(), inactive.clone(), None)), + router: inactive, + direct_router: None, + relay_routers: None, + sessions, + auth, + centralized: None, users: Arc::new(UserService), config: Arc::new(ConfigService), active: false, }) } + + pub fn omikron(&self) -> Option<&Arc> { + self.centralized.as_ref().map(|services| &services.omikron) + } } -struct InactiveOmikron; +struct InactiveIdentityResolver; + +struct InactiveNodeIdentityResolver; + +struct StandaloneRelayIdentity(iota_identity::LocalNodeIdentity); #[async_trait] -impl OmikronClient for InactiveOmikron { - async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) +impl RelayNodeIdentity for StandaloneRelayIdentity { + async fn keyring(&self) -> Option> { + Some(self.0.keyring()) } - async fn await_response( + + fn node_id(&self) -> Option { + Some(self.0.node_id().clone()) + } +} + +#[async_trait] +impl NodeIdentityResolver for InactiveNodeIdentityResolver { + async fn resolve_node(&self, _: &IotaNodeId) -> Result { + Err(IdentityError::Unavailable( + "node identity resolution is unavailable".into(), + )) + } +} + +#[async_trait] +impl IdentityResolver for InactiveIdentityResolver { + async fn resolve_address( &self, - _: &CommunicationValue, - _: Duration, - ) -> Result { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::Unavailable("daemon is inactive".into())) } - async fn reconnect(&self) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) + + async fn resolve_principal(&self, _: &PrincipalId) -> Result { + Err(IdentityError::Unavailable("daemon is inactive".into())) } - async fn rotate_identity(&self) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) + + async fn signing_keys( + &self, + _: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + Err(IdentityError::Unavailable("daemon is inactive".into())) } - async fn is_connected(&self) -> bool { +} + +struct InactiveServices; + +#[async_trait] +impl AccountAuthority for InactiveServices { + async fn is_available(&self) -> bool { false + } + + async fn create_user(&self, _: CreateUserRequest) -> Result { + Err(inactive_account()) + } + + async fn inspect_credential(&self, _: &[u8]) -> Result { + Err(inactive_account()) + } + + async fn attach_user(&self, _: &[u8]) -> Result { + Err(inactive_account()) + } + + async fn reconcile_user( + &self, + _: iota_identity::LocalUserId, + ) -> Result { + Err(inactive_account()) + } + + async fn release_user(&self, _: iota_identity::LocalUserId) -> Result<(), AccountError> { + Err(inactive_account()) + } + + async fn delete_user( + &self, + _: iota_identity::LocalUserId, + _: &[u8], + ) -> Result<(), AccountError> { + Err(inactive_account()) + } + + async fn reconcile_managed_users(&self) -> Result<(), AccountError> { + Err(inactive_account()) + } +} + +fn inactive_account() -> AccountError { + AccountError::Unavailable("daemon is inactive".into()) +} + +impl LocalUserStore for InactiveServices { + fn get_local_user( + &self, + _: iota_identity::LocalUserId, + ) -> Result, IdentityError> { + Ok(None) + } + + fn get_local_user_by_username( + &self, + _: &str, + ) -> Result, IdentityError> { + Ok(None) + } + + fn is_hosted_here(&self, _: iota_identity::LocalUserId) -> Result { + Ok(false) + } + + fn local_user_for_principal( + &self, + _: iota_identity::PrincipalHandle, + ) -> Result, IdentityError> { + Ok(None) + } + + fn principal_for_local_user( + &self, + _: iota_identity::LocalUserId, + ) -> Result, IdentityError> { + Ok(None) + } +} + +impl PrincipalStore for InactiveServices { + fn get_principal( + &self, + _: iota_identity::PrincipalHandle, + ) -> Result, IdentityError> { + Ok(None) + } + + fn get_by_canonical_id( + &self, + _: &PrincipalId, + ) -> Result, IdentityError> { + Ok(None) + } + + fn get_by_username( + &self, + _: &AuthorityId, + _: &str, + ) -> Result, IdentityError> { + Ok(None) + } + + fn upsert_remote_descriptor( + &self, + _: &iota_identity::VerifiedPrincipalDescriptor, + ) -> Result { + Err(IdentityError::Unavailable("daemon is inactive".into())) + } + + fn signing_keys( + &self, + _: &PrincipalId, + ) -> Result, IdentityError> { + Err(IdentityError::NotFound) + } +} + +#[async_trait] +impl PeerRouter for InactiveServices { + async fn route( + &self, + destination: &RouteDestination, + _: CommunicationValue, + ) -> Result { + match destination { + RouteDestination::Iota(node) => Err(RouteError::NoRoute(node.clone())), + RouteDestination::LegacyOmegaIota { .. } => Ok(RouteOutcome::Retryable { + reason: "daemon is inactive".into(), + }), + } + } +} + +#[async_trait] +impl RelayNodeIdentity for InactiveServices { + async fn keyring(&self) -> Option> { + None + } + + fn node_id(&self) -> Option { + None + } +} + +impl LegacyRelayIdentity for InactiveServices { + fn legacy_iota_id(&self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_services_can_be_composed_without_omikron() { + let adapters = Arc::new(InactiveServices); + let identities: Arc = Arc::new(InactiveIdentityResolver); + let services = DaemonServices::compose(DaemonServiceComponents { + accounts: adapters.clone(), + identities, + principals: adapters.clone(), + local_users: adapters.clone(), + node_identities: Arc::new(InactiveNodeIdentityResolver), + relay: Arc::new(RelayService::new(adapters.clone(), adapters.clone(), None)), + router: adapters, + direct_router: None, + relay_routers: None, + centralized: None, + }); + + assert!(services.active); + assert!(services.centralized.is_none()); } } diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 99a2aaa..687c434 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -59,12 +59,7 @@ async fn reconnect_uses_the_injected_client() { let fake = Arc::new(FakeOmikron { reconnects: AtomicUsize::new(0), }); - let services = Arc::new(DaemonServices { - omikron: fake.clone(), - users: Default::default(), - config: Default::default(), - active: true, - }); + let services = DaemonServices::with_centralized_client(fake.clone()); let router = CommandRouter::new( Arc::new(DaemonRuntime::new()), services, @@ -85,12 +80,7 @@ async fn identity_rotation_is_available_while_omikron_is_offline() { let fake = Arc::new(FakeOmikron { reconnects: AtomicUsize::new(0), }); - let services = Arc::new(DaemonServices { - omikron: fake.clone(), - users: Default::default(), - config: Default::default(), - active: true, - }); + let services = DaemonServices::with_centralized_client(fake.clone()); let router = CommandRouter::new( Arc::new(DaemonRuntime::new()), services, @@ -111,12 +101,7 @@ async fn read_role_cannot_execute_an_administrative_request() { let fake = Arc::new(FakeOmikron { reconnects: AtomicUsize::new(0), }); - let services = Arc::new(DaemonServices { - omikron: fake.clone(), - users: Default::default(), - config: Default::default(), - active: true, - }); + let services = DaemonServices::with_centralized_client(fake.clone()); let router = CommandRouter::new( Arc::new(DaemonRuntime::new()), services, diff --git a/iota-daemon-lib/tests/ipc_server.rs b/iota-daemon-lib/tests/ipc_server.rs index 7540ea4..209b5cc 100644 --- a/iota-daemon-lib/tests/ipc_server.rs +++ b/iota-daemon-lib/tests/ipc_server.rs @@ -182,12 +182,7 @@ impl OmikronClient for TestOmikron { } fn active_services() -> Arc { - Arc::new(DaemonServices { - omikron: Arc::new(TestOmikron), - users: Default::default(), - config: Default::default(), - active: true, - }) + DaemonServices::with_centralized_client(Arc::new(TestOmikron)) } #[tokio::test] diff --git a/iota-daemon-lib/tests/local_authority.rs b/iota-daemon-lib/tests/local_authority.rs new file mode 100644 index 0000000..5650ac3 --- /dev/null +++ b/iota-daemon-lib/tests/local_authority.rs @@ -0,0 +1,57 @@ +use iota_daemon_lib::accounts::{AccountAuthority, CreateUserRequest, LocalIotaAccountAuthority}; +use iota_identity::{LocalNodeIdentity, LocalUserId, PrincipalStore}; +use iota_storage::identity::SqlitePrincipalStore; + +#[tokio::test] +async fn standalone_accounts_survive_identity_reload_and_do_not_reuse_deleted_ids() { + let root = tempfile::tempdir().unwrap(); + let storage = root.path().join("storage"); + iota_util::file_util::configure_storage_directory(storage); + iota_storage::util::db::initialize_database().unwrap(); + let identity_path = root.path().join("identity/iota.mk"); + let identity = LocalNodeIdentity::load_or_create(&identity_path, None).unwrap(); + let authority = LocalIotaAccountAuthority::new(identity.clone()); + + let alice = authority + .create_user(CreateUserRequest { + username: "alice".into(), + }) + .await + .unwrap(); + let alice_principal = SqlitePrincipalStore + .principal_for_local_user(alice.user) + .unwrap() + .and_then(|handle| SqlitePrincipalStore.get_principal(handle).unwrap()) + .unwrap(); + let credential = iota_util::file_util::read_user_credential("alice") + .unwrap() + .unwrap(); + + let restarted_identity = LocalNodeIdentity::load_or_create(&identity_path, None).unwrap(); + assert_eq!(restarted_identity.node_id(), identity.node_id()); + let restarted = LocalIotaAccountAuthority::new(restarted_identity); + let restored_principal = SqlitePrincipalStore + .principal_for_local_user(LocalUserId(alice.user.0)) + .unwrap() + .and_then(|handle| SqlitePrincipalStore.get_principal(handle).unwrap()) + .unwrap(); + assert_eq!(restored_principal.principal, alice_principal.principal); + + restarted + .delete_user(alice.user, credential.as_bytes()) + .await + .unwrap(); + let bob = restarted + .create_user(CreateUserRequest { + username: "bob".into(), + }) + .await + .unwrap(); + assert!(bob.user.0 > alice.user.0); + assert!( + SqlitePrincipalStore + .get_by_canonical_id(&alice_principal.principal) + .unwrap() + .is_some() + ); +} diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 87a5636..e4ae185 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -4,13 +4,17 @@ version = "0.1.0" edition = "2024" [dependencies] +client = { path = "../client" } iota-daemon-lib = { path = "../iota-daemon-lib" } +iota-connection = { path = "../iota-connection" } iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } +iota-identity = { path = "../iota-identity" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } iota-terms = { path = "../iota-terms" } omikron-connector = { path = "../omikron-connector" } +other-iota = { path = "../other-iota" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index 8f9e965..910a983 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -94,6 +94,14 @@ async fn main() -> ExitCode { eprintln!("Cannot load Iota configuration: {error}"); return ExitCode::FAILURE; } + let node_identity = + match iota_identity::LocalNodeIdentity::load_or_create(&paths.keyring_file(), None) { + Ok(identity) => identity, + Err(error) => { + eprintln!("Cannot load Iota node identity: {error}"); + return ExitCode::FAILURE; + } + }; omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file()); match paths.scope { iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())), @@ -136,94 +144,135 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let omikron = match omikron_connector::omikron_connection::connect_initial( - runtime.cancellation.clone(), - runtime.state.active_tasks.clone(), - runtime.state.app.clone(), - ) - .await - { - Ok(connection) => connection, - Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => { - runtime.set_component_degraded( - iota_ipc::ComponentId::Omikron, - "Omikron connection unavailable; retrying".into(), - ); - connection - } - Err(omikron_connector::OmikronStartupError::Authentication { connection }) => { - runtime.set_component_failed( - iota_ipc::ComponentId::Omikron, - "Omikron authentication failed; inspect the authenticated relay and Omega status before rotating the Iota identity".into(), - ); - // Keep IPC alive: identity rotation is the supported recovery - // action and must remain available after authentication fails. - connection - } - Err(omikron_connector::OmikronStartupError::Construction(error)) => { - eprintln!("Cannot construct Omikron connection: {error}"); - return ExitCode::FAILURE; + let (services, omikron) = if CONFIG.load().omikron_host.is_some() { + let omikron = Arc::new(omikron_connector::OmikronConnection::with_cancellation( + runtime.cancellation.clone(), + runtime.state.active_tasks.clone(), + runtime.state.app.clone(), + )); + let services = match DaemonServices::new(omikron.clone()) { + Ok(services) => services, + Err(error) => { + eprintln!("Cannot initialize daemon identity services: {error}"); + return ExitCode::FAILURE; + } + }; + omikron.connect().await; + if omikron + .await_connection(Some(Duration::from_secs(45))) + .await + .is_err() + { + if omikron.has_auth_failure().await { + runtime.set_component_failed( + iota_ipc::ComponentId::Omikron, + "Omikron authentication failed; inspect authenticated relay and Omega status before rotating Iota identity".into(), + ); + } else { + runtime.set_component_degraded( + iota_ipc::ComponentId::Omikron, + "Omikron connection unavailable; retrying".into(), + ); + } } + (services, Some(omikron)) + } else { + let services = match DaemonServices::standalone(node_identity.clone()) { + Ok(services) => services, + Err(error) => { + eprintln!("Cannot initialize standalone daemon services: {error}"); + return ExitCode::FAILURE; + } + }; + runtime.set_component_healthy( + iota_ipc::ComponentId::Omikron, + Some("disabled in standalone mode".into()), + ); + (services, None) }; - let omikron_health = omikron.clone(); - let omikron_reconcile = omikron.clone(); - let services = DaemonServices::new(omikron); - let health_runtime = runtime.clone(); - runtime - .tasks - .spawn_tracked("omikron-health", async move { - let mut states = omikron_health.connection_state(); - loop { - let state = *states.borrow(); - match state { - omikron_connector::omikron_connection::ConnectionState::Connected { - .. - } => { - let ping_ms = *omikron_health.last_ping.lock().await; - let message = if ping_ms >= 0 { - format!("connected (RTT: {ping_ms} ms)") - } else { - "connected (waiting for RTT sample)".into() - }; - health_runtime - .set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message)); + let lifecycle_services = services.clone(); + let lifecycle_runtime = runtime.clone(); + if omikron.is_none() { + let dispatcher = + iota_connection::relay_service::PendingRelayDispatcher::new(services.router.clone()); + let dispatcher_runtime = runtime.clone(); + runtime + .tasks + .spawn_tracked("pending-relay-dispatch", async move { + loop { + if let Err(error) = dispatcher.dispatch_ready(100).await { + log!("Pending Relay dispatch failed: {error:?}"); } - omikron_connector::omikron_connection::ConnectionState::Connecting => { - health_runtime.set_component_degraded( - iota_ipc::ComponentId::Omikron, - "connecting to Omikron".into(), - ); + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(2)) => {}, + _ = dispatcher_runtime.cancellation.cancelled() => break, } - omikron_connector::omikron_connection::ConnectionState::Disconnected => { - let message = omikron_health - .get_auth_failure() - .await - .unwrap_or_else(|| "disconnected; retrying".into()); - if omikron_health.has_auth_failure().await { - health_runtime - .set_component_failed(iota_ipc::ComponentId::Omikron, message); - } else { - health_runtime - .set_component_degraded(iota_ipc::ComponentId::Omikron, message); + } + Ok(()) + }) + .await; + } + if let Some(omikron_health) = omikron { + let health_runtime = runtime.clone(); + runtime + .tasks + .spawn_tracked("omikron-health", async move { + let mut states = omikron_health.connection_state(); + loop { + let state = *states.borrow(); + match state { + omikron_connector::omikron_connection::ConnectionState::Connected { + .. + } => { + let ping_ms = *omikron_health.last_ping.lock().await; + let message = if ping_ms >= 0 { + format!("connected (RTT: {ping_ms} ms)") + } else { + "connected (waiting for RTT sample)".into() + }; + health_runtime.set_component_healthy( + iota_ipc::ComponentId::Omikron, + Some(message), + ); + } + omikron_connector::omikron_connection::ConnectionState::Connecting => { + health_runtime.set_component_degraded( + iota_ipc::ComponentId::Omikron, + "connecting to Omikron".into(), + ); + } + omikron_connector::omikron_connection::ConnectionState::Disconnected => { + let message = omikron_health + .get_auth_failure() + .await + .unwrap_or_else(|| "disconnected; retrying".into()); + if omikron_health.has_auth_failure().await { + health_runtime + .set_component_failed(iota_ipc::ComponentId::Omikron, message); + } else { + health_runtime.set_component_degraded( + iota_ipc::ComponentId::Omikron, + message, + ); + } } } + tokio::select! { + changed = states.changed() => if changed.is_err() { break }, + // RTT is updated by MTP's heartbeat independently of a + // connection-state transition, so periodically refresh + // the component detail while connected. + _ = tokio::time::sleep(Duration::from_secs(1)) => {}, + _ = health_runtime.cancellation.cancelled() => break, + } } - tokio::select! { - changed = states.changed() => if changed.is_err() { break }, - // RTT is updated by MTP's heartbeat independently of a - // connection-state transition, so periodically refresh - // the component detail while connected. - _ = tokio::time::sleep(Duration::from_secs(1)) => {}, - _ = health_runtime.cancellation.cancelled() => break, - } - } - Ok(()) - }) - .await; + Ok(()) + }) + .await; + } runtime .tasks .spawn_tracked("user-lifecycle-reconciliation", async move { - let mut states = omikron_reconcile.connection_state(); loop { match iota_storage::users::pending_operations::get_all() { Ok(operations) => { @@ -242,18 +291,14 @@ async fn main() -> ExitCode { } Err(error) => log!("Pending purge reconciliation could not read storage: {error}"), } - if matches!( - *states.borrow(), - omikron_connector::omikron_connection::ConnectionState::Connected { .. } - ) { - omikron_connector::user_ops::reconcile_managed_users( - omikron_reconcile.as_ref(), - ) - .await; + if lifecycle_services.accounts.is_available().await + && let Err(error) = lifecycle_services.accounts.reconcile_managed_users().await + { + log!("Managed user reconciliation failed: {error:?}"); } tokio::select! { - changed = states.changed() => if changed.is_err() { break }, _ = tokio::time::sleep(Duration::from_secs(30)) => {}, + _ = lifecycle_runtime.cancellation.cancelled() => break, } } Ok(()) @@ -262,7 +307,7 @@ async fn main() -> ExitCode { let ipc_server = match IpcServer::bind( socket.clone(), runtime.clone(), - services, + services.clone(), log_tx.clone(), log_buffer.clone(), state_rx, @@ -330,7 +375,186 @@ async fn main() -> ExitCode { .await; // --- Web server --- - let web = CONFIG.load().web.clone(); + let config = CONFIG.load(); + let web = config.web.clone(); + let relay_router_settings = config.relay_routers.clone(); + drop(config); + let direct_endpoints = match web + .direct_endpoints + .iter() + .cloned() + .map(iota_identity::AuthorityLocator::new) + .collect::, _>>() + { + Ok(endpoints) => endpoints, + Err(error) => { + eprintln!("Cannot load federation direct endpoints: {error}"); + return ExitCode::FAILURE; + } + }; + let relay_hints = match web + .relay_hints + .iter() + .cloned() + .chain( + relay_router_settings + .iter() + .map(|router| router.endpoint.clone()), + ) + .map(iota_identity::AuthorityLocator::new) + .collect::, _>>() + { + Ok(endpoints) => endpoints, + Err(error) => { + eprintln!("Cannot load federation relay hints: {error}"); + return ExitCode::FAILURE; + } + }; + let mut relay_hints = relay_hints; + relay_hints.sort_by(|left, right| left.as_str().cmp(right.as_str())); + relay_hints.dedup(); + let node_descriptor = match iota_storage::node_directory::SqliteNodeDirectory + .ensure_local_descriptor( + &node_identity, + direct_endpoints, + relay_hints, + iota_storage::util::sync::now_millis(), + ) { + Ok(descriptor) => descriptor, + Err(error) => { + eprintln!("Cannot publish Iota node descriptor: {error}"); + return ExitCode::FAILURE; + } + }; + if services.centralized.is_some() + && let Some(iota_id) = CONFIG.load().iota_id + { + let omega = match iota_identity::AuthorityLocator::new( + omikron_connector::omega_discovery::omega_host(), + ) { + Ok(omega) => omega, + Err(error) => { + eprintln!("Cannot load Omega federation endpoint: {error}"); + return ExitCode::FAILURE; + } + }; + let wire = match node_descriptor.to_wire_v1() { + Ok(wire) => wire, + Err(error) => { + eprintln!("Cannot encode node descriptor for Omega: {error}"); + return ExitCode::FAILURE; + } + }; + if let Err(error) = other_iota::publish_omega_node_descriptor(&omega, iota_id, &wire).await + { + log!("Omega node descriptor publication failed: {error}"); + } + } + let client_connections = Arc::new(client::ClientConnectionManager::new( + services.local_users.clone(), + services.principals.clone(), + services.auth.hosted_accounts.clone(), + services.sessions.clone(), + services.relay.clone(), + )); + if let Some(relay_routers) = &services.relay_routers { + for router in &relay_router_settings { + let endpoint = match iota_identity::AuthorityLocator::new(router.endpoint.clone()) { + Ok(endpoint) => endpoint, + Err(error) => { + eprintln!("Cannot load relay router endpoint: {error}"); + return ExitCode::FAILURE; + } + }; + let public_key = match iota_identity::PublicKeyBundle::from_base64(&router.public_key) { + Ok(public_key) => public_key, + Err(error) => { + eprintln!("Cannot load relay router public key: {error}"); + return ExitCode::FAILURE; + } + }; + let certificate_path = + resolve_config_path(&paths.config_file, &router.certificate, &paths.config_dir); + let certificate = match std::fs::read(&certificate_path) { + Ok(certificate) if !certificate.is_empty() => certificate, + Ok(_) => { + eprintln!( + "Cannot load relay router certificate {}: file is empty", + certificate_path.display() + ); + return ExitCode::FAILURE; + } + Err(error) => { + eprintln!( + "Cannot load relay router certificate {}: {error}", + certificate_path.display() + ); + return ExitCode::FAILURE; + } + }; + let router_runtime = runtime.clone(); + let relay_routers = relay_routers.clone(); + let relay = services.relay.clone(); + let identity = node_identity.clone(); + let deliveries: Arc = client_connections.clone(); + runtime + .tasks + .spawn_tracked("relay-router", async move { + loop { + match other_iota::relay_router::RelayRouterClient::connect( + identity.clone(), + &endpoint, + certificate.clone(), + public_key.clone(), + relay.clone(), + deliveries.clone(), + ) + .await + { + Ok(connected) => { + relay_routers.add(connected.clone()); + tokio::select! { + _ = connected.wait_disconnected() => {}, + _ = router_runtime.cancellation.cancelled() => break, + } + } + Err(error) => { + log!( + "Relay router {} connection failed: {error}", + endpoint.as_str() + ); + } + } + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(5)) => {}, + _ = router_runtime.cancellation.cancelled() => break, + } + } + Ok(()) + }) + .await; + } + } + let mtp_handler: Arc = + if let Some(router) = services.direct_router.clone() { + let nodes: Arc = + Arc::new(iota_storage::node_directory::SqliteNodeDirectory); + let deliveries: Arc = client_connections.clone(); + let peers = Arc::new(other_iota::PeerManager::new( + node_identity.clone(), + nodes, + router.clone(), + services.relay.clone(), + deliveries, + )); + router.attach_manager(&peers); + Arc::new(client::ConnectionGateway::new( + client_connections.clone(), + peers, + )) + } else { + client_connections.clone() + }; let web_config = web_server::WebConfig { mode: match web.mode { iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled, @@ -352,6 +576,33 @@ async fn main() -> ExitCode { key: resolve_config_path(&paths.config_file, &key, &paths.config_dir), }), required: web.required, + authority_discovery: iota_identity::AuthorityDiscoveryDocument { + version: 1, + service: iota_identity::AuthorityKind::Iota, + authority_id: node_identity.authority_id().clone(), + node_id: Some(node_identity.node_id().clone()), + public_key: match node_identity.public_keys().try_to_base64() { + Ok(public_key) => public_key, + Err(error) => { + eprintln!("Cannot encode Iota discovery identity: {error}"); + return ExitCode::FAILURE; + } + }, + protocols: vec!["identity-http-v1".into(), "mtp-relay-v2".into()], + node_descriptor: Some("/federation/v1/node".into()), + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + }, + local_users: services.local_users.clone(), + descriptor_publisher: Arc::new( + iota_storage::identity::SqliteLocalDescriptorPublisher::new(node_identity.clone()), + ), + node_descriptor, + node_identity: node_identity.clone(), + client_keys: Arc::new(client::HostedAndPeerKeyResolver::new( + services.local_users.clone(), + )), + mtp_handler, }; match web_server::start(web_config, runtime.cancellation.clone()).await { Ok(None) => { diff --git a/iota-identity/Cargo.toml b/iota-identity/Cargo.toml new file mode 100644 index 0000000..45b3298 --- /dev/null +++ b/iota-identity/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "iota-identity" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1.89" +base64 = "0.22.1" +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto", "files", "raw"] } +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/iota-identity/src/lib.rs b/iota-identity/src/lib.rs new file mode 100644 index 0000000..468581a --- /dev/null +++ b/iota-identity/src/lib.rs @@ -0,0 +1,1812 @@ +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +pub use mtp::crypto::PublicKeyBundle; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct IotaNodeId(String); + +impl IotaNodeId { + const PREFIX: &'static str = "iota:v1:"; + + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let Some(fingerprint) = value.strip_prefix(Self::PREFIX) else { + return Err(IdentityError::InvalidIdentifier( + "Iota node ID has an unsupported format".into(), + )); + }; + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(IdentityError::InvalidIdentifier( + "Iota node ID fingerprint must be 32 hexadecimal bytes".into(), + )); + } + Ok(Self(format!( + "{}{}", + Self::PREFIX, + fingerprint.to_ascii_lowercase() + ))) + } + + pub fn from_public_keys(keys: &PublicKeyBundle) -> Result { + let bytes = keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidIdentifier(error.to_string()))?; + let fingerprint = mtp::crypto::sha256(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Self::new(format!("{}{fingerprint}", Self::PREFIX)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn mtp_authentication_hint(&self) -> u64 { + let fingerprint = self.0.strip_prefix(Self::PREFIX).unwrap_or_default(); + u64::from_str_radix(&fingerprint[..fingerprint.len().min(16)], 16).unwrap_or_default() + | (1_u64 << 63) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RelayRouterId(String); + +impl RelayRouterId { + const PREFIX: &'static str = "router:v1:"; + + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let Some(fingerprint) = value.strip_prefix(Self::PREFIX) else { + return Err(IdentityError::InvalidIdentifier( + "relay router ID has an unsupported format".into(), + )); + }; + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(IdentityError::InvalidIdentifier( + "relay router ID fingerprint must be 32 hexadecimal bytes".into(), + )); + } + Ok(Self(format!( + "{}{}", + Self::PREFIX, + fingerprint.to_ascii_lowercase() + ))) + } + + pub fn from_public_keys(keys: &PublicKeyBundle) -> Result { + let bytes = keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidIdentifier(error.to_string()))?; + let fingerprint = mtp::crypto::sha256(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Self::new(format!("{}{fingerprint}", Self::PREFIX)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AuthorityId(String); + +impl AuthorityId { + pub fn new(fingerprint: impl Into) -> Result { + let fingerprint = fingerprint.into(); + if fingerprint.trim().is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty authority ID".into(), + )); + } + Ok(Self(fingerprint)) + } + + pub fn omega_legacy(locator: &str) -> Result { + let locator = locator.trim().to_ascii_lowercase(); + if locator.is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty Omega authority locator".into(), + )); + } + Ok(Self(format!("omega:{locator}"))) + } + + pub fn for_omega(keys: &PublicKeyBundle) -> Result { + let bytes = keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidIdentifier(error.to_string()))?; + let fingerprint = mtp::crypto::sha256(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Self::new(format!("omega-key:{fingerprint}")) + } + + pub fn for_iota(node: &IotaNodeId) -> Self { + Self(format!("authority:{}", node.as_str())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug)] +pub enum LocalNodeIdentityError { + Directory { + path: String, + source: std::io::Error, + }, + Storage { + path: String, + source: String, + }, + Verification(String), + InvalidIdentity(IdentityError), +} + +impl fmt::Display for LocalNodeIdentityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Directory { path, source } => { + write!( + formatter, + "failed to create identity directory {path}: {source}" + ) + } + Self::Storage { path, source } => { + write!(formatter, "failed to access node identity {path}: {source}") + } + Self::Verification(error) => formatter.write_str(error), + Self::InvalidIdentity(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for LocalNodeIdentityError {} + +#[derive(Clone)] +pub struct LocalNodeIdentity { + keyring: Arc, + node_id: IotaNodeId, + authority_id: AuthorityId, +} + +impl fmt::Debug for LocalNodeIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LocalNodeIdentity") + .field("node_id", &self.node_id) + .field("authority_id", &self.authority_id) + .finish_non_exhaustive() + } +} + +impl LocalNodeIdentity { + pub fn load_or_create( + path: &Path, + legacy: Option, + ) -> Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent).map_err(|source| LocalNodeIdentityError::Directory { + path: parent.display().to_string(), + source, + })?; + } + let keyring = match mtp::files::load_keyring_raw(path) { + Ok(keyring) => keyring, + Err(mtp::files::FileError::Io(error)) + if error.kind() == std::io::ErrorKind::NotFound => + { + let keyring = legacy.unwrap_or_else(mtp::crypto::Keyring::generate); + Self::save_keyring_verified(&keyring, path)?; + keyring + } + Err(error) => { + return Err(LocalNodeIdentityError::Storage { + path: path.display().to_string(), + source: error.to_string(), + }); + } + }; + Self::from_keyring(keyring) + } + + pub fn from_keyring(keyring: mtp::crypto::Keyring) -> Result { + let node_id = IotaNodeId::from_public_keys(&keyring.public_key_bundle()) + .map_err(LocalNodeIdentityError::InvalidIdentity)?; + let authority_id = AuthorityId::for_iota(&node_id); + Ok(Self { + keyring: Arc::new(keyring), + node_id, + authority_id, + }) + } + + pub fn save_keyring_verified( + keyring: &mtp::crypto::Keyring, + path: &Path, + ) -> Result<(), LocalNodeIdentityError> { + mtp::files::save_keyring_raw(keyring, path).map_err(|error| { + LocalNodeIdentityError::Storage { + path: path.display().to_string(), + source: error.to_string(), + } + })?; + let persisted = mtp::files::load_keyring_raw(path).map_err(|error| { + LocalNodeIdentityError::Storage { + path: path.display().to_string(), + source: error.to_string(), + } + })?; + let expected = keyring + .try_to_bytes() + .map_err(|error| LocalNodeIdentityError::Verification(error.to_string()))?; + let actual = persisted + .try_to_bytes() + .map_err(|error| LocalNodeIdentityError::Verification(error.to_string()))?; + if expected != actual { + return Err(LocalNodeIdentityError::Verification( + "persisted keyring differs from requested node identity".into(), + )); + } + Ok(()) + } + + pub fn keyring(&self) -> Arc { + self.keyring.clone() + } + + pub fn public_keys(&self) -> PublicKeyBundle { + self.keyring.public_key_bundle() + } + + pub fn node_id(&self) -> &IotaNodeId { + &self.node_id + } + + pub fn authority_id(&self) -> &AuthorityId { + &self.authority_id + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorityKind { + Iota, + Omega, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AuthorityDiscoveryDocument { + pub version: u32, + pub service: AuthorityKind, + pub authority_id: AuthorityId, + pub node_id: Option, + pub public_key: String, + pub protocols: Vec, + #[serde(default)] + pub node_descriptor: Option, + #[serde(default)] + pub direct_endpoints: Vec, + #[serde(default)] + pub relay_hints: Vec, +} + +#[derive(Clone, Debug)] +pub struct DiscoveredAuthority { + pub service: AuthorityKind, + pub authority_id: AuthorityId, + pub node_id: Option, + pub public_keys: PublicKeyBundle, + pub protocols: Vec, + pub node_descriptor: Option, + pub direct_endpoints: Vec, + pub relay_hints: Vec, +} + +impl AuthorityDiscoveryDocument { + pub fn verify(self) -> Result { + if self.version != 1 { + return Err(IdentityError::InvalidDescriptor( + "authority discovery version is unsupported".into(), + )); + } + if self.protocols.iter().any(|protocol| protocol.is_empty()) { + return Err(IdentityError::InvalidDescriptor( + "authority discovery contains an empty protocol name".into(), + )); + } + let public_keys = PublicKeyBundle::from_base64(&self.public_key) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + match self.service { + AuthorityKind::Iota => { + let node = self.node_id.as_ref().ok_or_else(|| { + IdentityError::InvalidDescriptor("Iota discovery has no node ID".into()) + })?; + let derived_node = IotaNodeId::from_public_keys(&public_keys)?; + if node != &derived_node || self.authority_id != AuthorityId::for_iota(node) { + return Err(IdentityError::InvalidDescriptor( + "Iota discovery identity does not match its public key".into(), + )); + } + } + AuthorityKind::Omega if self.node_id.is_some() => { + return Err(IdentityError::InvalidDescriptor( + "Omega discovery must not claim an Iota node ID".into(), + )); + } + AuthorityKind::Omega => { + if self.authority_id != AuthorityId::for_omega(&public_keys)? { + return Err(IdentityError::InvalidDescriptor( + "Omega discovery identity does not match its public key".into(), + )); + } + } + } + Ok(DiscoveredAuthority { + service: self.service, + authority_id: self.authority_id, + node_id: self.node_id, + public_keys, + protocols: self.protocols, + node_descriptor: self.node_descriptor, + direct_endpoints: self.direct_endpoints, + relay_hints: self.relay_hints, + }) + } +} + +impl AuthorityKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Iota => "iota", + Self::Omega => "omega", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PrincipalId { + pub authority: AuthorityId, + pub user_id: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UserSelector { + UserId(u64), + Username(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AuthorityLocator(String); + +impl AuthorityLocator { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let value = value.trim(); + if value.is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty authority locator".into(), + )); + } + if value.contains('@') || value.contains('/') || value.chars().any(char::is_whitespace) { + return Err(IdentityError::InvalidIdentifier( + "invalid authority locator".into(), + )); + } + + if let Ok(address) = value.parse::() { + if address.port() == 0 { + return Err(IdentityError::InvalidIdentifier( + "authority port must not be zero".into(), + )); + } + return Ok(Self(address.to_string())); + } + if let Ok(address) = value.parse::() { + return Ok(Self(address.to_string())); + } + if let Some(address) = value + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + let address = address + .parse::() + .map_err(|_| IdentityError::InvalidIdentifier("invalid IPv6 authority".into()))?; + return Ok(Self(format!("[{address}]"))); + } + + let (host, port) = match value.rsplit_once(':') { + Some((host, port)) if !host.contains(':') => { + let port = port.parse::().map_err(|_| { + IdentityError::InvalidIdentifier("invalid authority port".into()) + })?; + if port == 0 { + return Err(IdentityError::InvalidIdentifier( + "authority port must not be zero".into(), + )); + } + (host, Some(port)) + } + Some(_) => { + return Err(IdentityError::InvalidIdentifier( + "IPv6 authorities must use brackets".into(), + )); + } + None => (value, None), + }; + if !valid_domain(host) { + return Err(IdentityError::InvalidIdentifier( + "invalid authority domain".into(), + )); + } + + let host = host.to_ascii_lowercase(); + Ok(Self(match port { + Some(port) => format!("{host}:{port}"), + None => host, + })) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_ip_literal(&self) -> bool { + self.0.parse::().is_ok() + || self.0.parse::().is_ok() + || self + .0 + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .is_some_and(|value| value.parse::().is_ok()) + } +} + +#[derive(Clone, Debug)] +pub struct UserAddress { + pub selector: UserSelector, + pub public_key_pin: Option, + pub authority: Option, +} + +impl UserAddress { + pub fn parse(value: &str) -> Result { + value.parse() + } +} + +impl FromStr for UserAddress { + type Err = IdentityError; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty user address".into(), + )); + } + if value.matches('@').count() > 1 { + return Err(IdentityError::InvalidIdentifier( + "user address contains multiple authority delimiters".into(), + )); + } + + let (local, authority) = match value.split_once('@') { + Some((local, authority)) => { + if authority.is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty authority locator".into(), + )); + } + (local, Some(AuthorityLocator::new(authority)?)) + } + None => (value, None), + }; + let (selector, public_key_pin) = match local.split_once("::") { + Some((selector, pin)) => { + if pin.is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty public-key pin".into(), + )); + } + let pin = PublicKeyBundle::from_base64(pin).map_err(|_| { + IdentityError::InvalidIdentifier("invalid public-key pin".into()) + })?; + (selector, Some(pin)) + } + None => (local, None), + }; + if selector.is_empty() { + return Err(IdentityError::InvalidIdentifier( + "empty user selector".into(), + )); + } + if selector + .chars() + .any(|character| character.is_whitespace() || matches!(character, '@' | ':')) + { + return Err(IdentityError::InvalidIdentifier( + "invalid user selector".into(), + )); + } + + let selector = if selector.bytes().all(|byte| byte.is_ascii_digit()) { + UserSelector::UserId(selector.parse::().map_err(|_| { + IdentityError::InvalidIdentifier("numeric user ID is out of range".into()) + })?) + } else { + UserSelector::Username(selector.into()) + }; + + Ok(Self { + selector, + public_key_pin, + authority, + }) + } +} + +fn valid_domain(host: &str) -> bool { + !host.is_empty() + && host.split('.').all(|label| { + !label.is_empty() + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LocalUserId(pub i64); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PrincipalHandle(pub i64); + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalHome { + Iota(IotaNodeId), + LegacyOmegaIota { omega: AuthorityId, iota_id: u64 }, + Omega(AuthorityLocator), + Unknown, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OfflineResolutionPolicy { + #[default] + RejectStale, + AllowUnexpired { + max_staleness: Duration, + }, +} + +impl OfflineResolutionPolicy { + pub fn allows_cached(self, resolved_at: i64, now: i64) -> bool { + let Self::AllowUnexpired { max_staleness } = self else { + return false; + }; + let max_staleness = i64::try_from(max_staleness.as_millis()).unwrap_or(i64::MAX); + now.saturating_sub(resolved_at) <= max_staleness + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ResolutionContext { + pub allow_network: bool, + pub offline_policy: OfflineResolutionPolicy, +} + +#[derive(Clone, Debug)] +pub struct ResolvedPrincipal { + pub principal: PrincipalId, + pub handle: PrincipalHandle, + pub username: Option, + pub public_keys: Vec, + pub home: PrincipalHome, + pub descriptor_revision: i64, + pub valid_until: Option, + pub resolved_at: i64, +} + +impl ResolvedPrincipal { + pub fn is_valid_at(&self, timestamp: i64) -> bool { + self.valid_until + .is_none_or(|valid_until| valid_until > timestamp) + } +} + +#[derive(Clone, Debug)] +pub struct PrincipalDescriptor { + pub principal: PrincipalId, + pub authority_kind: AuthorityKind, + pub username: Option, + pub display_name: Option, + pub public_keys: Vec, + pub home: PrincipalHome, + pub revision: i64, + pub valid_until: Option, + pub issued_at: i64, +} + +#[derive(Clone, Debug)] +pub struct SignedPrincipalDescriptor { + pub descriptor: PrincipalDescriptor, + pub authority_signature: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PrincipalDescriptorWireV1 { + pub version: u32, + pub principal: PrincipalId, + pub authority_kind: AuthorityKind, + pub username: Option, + pub display_name: Option, + pub public_keys: Vec, + pub home: PrincipalHome, + pub revision: i64, + pub valid_until: Option, + pub issued_at: i64, + pub authority_signature: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home_node: Option, +} + +#[derive(Clone, Debug)] +pub struct VerifiedPrincipalDescriptor { + descriptor: PrincipalDescriptor, + resolved_at: i64, +} + +impl SignedPrincipalDescriptor { + pub fn to_wire_v1(&self) -> Result { + Ok(PrincipalDescriptorWireV1 { + version: 1, + principal: self.descriptor.principal.clone(), + authority_kind: self.descriptor.authority_kind, + username: self.descriptor.username.clone(), + display_name: self.descriptor.display_name.clone(), + public_keys: self + .descriptor + .public_keys + .iter() + .map(|key| { + key.try_to_base64() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string())) + }) + .collect::, _>>()?, + home: self.descriptor.home.clone(), + revision: self.descriptor.revision, + valid_until: self.descriptor.valid_until, + issued_at: self.descriptor.issued_at, + authority_signature: STANDARD.encode(&self.authority_signature), + home_node: None, + }) + } + + pub fn sign( + descriptor: PrincipalDescriptor, + authority: &mtp::crypto::Keyring, + ) -> Result { + use mtp::crypto::SignatureScheme; + + validate_principal_descriptor(&descriptor, descriptor.issued_at)?; + let signer = mtp::crypto::DualSigner::new( + &authority.sig_cl_secret_key, + &authority.sig_pq_secret_key, + &authority.sig_pq_public_key, + ) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let authority_signature = signer + .sign(&principal_descriptor_bytes(&descriptor)?) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + Ok(Self { + descriptor, + authority_signature, + }) + } + + pub fn verify( + self, + expected_authority: &AuthorityId, + authority_keys: &PublicKeyBundle, + current_revision: Option, + now: i64, + ) -> Result { + if &self.descriptor.principal.authority != expected_authority { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor authority mismatch".into(), + )); + } + validate_principal_descriptor(&self.descriptor, now)?; + if current_revision.is_some_and(|revision| revision > self.descriptor.revision) { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor revision is stale".into(), + )); + } + verify_authority_signature( + authority_keys, + &principal_descriptor_bytes(&self.descriptor)?, + &self.authority_signature, + )?; + if self.descriptor.authority_kind == AuthorityKind::Iota { + let PrincipalHome::Iota(home) = &self.descriptor.home else { + return Err(IdentityError::InvalidDescriptor( + "Iota principal descriptor has no Iota home".into(), + )); + }; + let signer_node = IotaNodeId::from_public_keys(authority_keys)?; + if home != &signer_node || expected_authority != &AuthorityId::for_iota(&signer_node) { + return Err(IdentityError::InvalidDescriptor( + "Iota principal descriptor signer does not match its authority".into(), + )); + } + } + Ok(VerifiedPrincipalDescriptor { + descriptor: self.descriptor, + resolved_at: now, + }) + } +} + +impl PrincipalDescriptorWireV1 { + pub fn with_home_node(mut self, home_node: NodeDescriptorWireV1) -> Self { + self.home_node = Some(home_node); + self + } + + pub fn parse(self) -> Result { + if self.version != 1 { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor wire version is unsupported".into(), + )); + } + let public_keys = self + .public_keys + .into_iter() + .map(|key| { + PublicKeyBundle::from_base64(&key) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string())) + }) + .collect::, _>>()?; + let authority_signature = STANDARD + .decode(self.authority_signature) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + Ok(SignedPrincipalDescriptor { + descriptor: PrincipalDescriptor { + principal: self.principal, + authority_kind: self.authority_kind, + username: self.username, + display_name: self.display_name, + public_keys, + home: self.home, + revision: self.revision, + valid_until: self.valid_until, + issued_at: self.issued_at, + }, + authority_signature, + }) + } +} + +pub trait LocalDescriptorPublisher: Send + Sync { + fn principal_descriptor( + &self, + user: LocalUserId, + ) -> Result; +} + +impl VerifiedPrincipalDescriptor { + pub fn from_trusted_authority(descriptor: PrincipalDescriptor) -> Result { + let resolved_at = descriptor.issued_at; + validate_principal_descriptor(&descriptor, resolved_at)?; + Ok(Self { + descriptor, + resolved_at, + }) + } + + pub fn descriptor(&self) -> &PrincipalDescriptor { + &self.descriptor + } + + pub fn into_descriptor(self) -> PrincipalDescriptor { + self.descriptor + } + + pub fn resolved_at(&self) -> i64 { + self.resolved_at + } +} + +fn validate_principal_descriptor( + descriptor: &PrincipalDescriptor, + now: i64, +) -> Result<(), IdentityError> { + if descriptor.revision < 0 { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor revision is negative".into(), + )); + } + if descriptor.public_keys.is_empty() { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor contains no signing keys".into(), + )); + } + for key in &descriptor.public_keys { + key.validate() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + } + if descriptor + .valid_until + .is_some_and(|valid_until| valid_until <= now) + { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor is expired".into(), + )); + } + Ok(()) +} + +fn verify_authority_signature( + keys: &PublicKeyBundle, + payload: &[u8], + signature: &[u8], +) -> Result<(), IdentityError> { + const ED25519_SIGNATURE_LENGTH: usize = 64; + let (classical, post_quantum) = signature + .split_at_checked(ED25519_SIGNATURE_LENGTH) + .ok_or_else(|| { + IdentityError::InvalidDescriptor("authority signature is truncated".into()) + })?; + mtp::crypto::verify_ed25519(&keys.sig_cl_public_key, payload, classical) + .and_then(|_| mtp::crypto::verify_ml_dsa(&keys.sig_pq_public_key, payload, post_quantum)) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string())) +} + +pub fn verify_dual_signature( + keys: &PublicKeyBundle, + payload: &[u8], + signature: &[u8], +) -> Result<(), IdentityError> { + verify_authority_signature(keys, payload, signature) +} + +fn principal_descriptor_bytes(descriptor: &PrincipalDescriptor) -> Result, IdentityError> { + let mut bytes = b"tensamin-principal-descriptor:v1".to_vec(); + push_bytes( + &mut bytes, + descriptor.principal.authority.as_str().as_bytes(), + )?; + bytes.extend_from_slice(&descriptor.principal.user_id.to_be_bytes()); + bytes.push(match descriptor.authority_kind { + AuthorityKind::Iota => 1, + AuthorityKind::Omega => 2, + }); + push_optional_string(&mut bytes, descriptor.username.as_deref())?; + push_optional_string(&mut bytes, descriptor.display_name.as_deref())?; + bytes.extend_from_slice(&descriptor.revision.to_be_bytes()); + push_optional_i64(&mut bytes, descriptor.valid_until); + bytes.extend_from_slice(&descriptor.issued_at.to_be_bytes()); + match &descriptor.home { + PrincipalHome::Iota(node) => { + bytes.push(1); + push_bytes(&mut bytes, node.as_str().as_bytes())?; + } + PrincipalHome::LegacyOmegaIota { omega, iota_id } => { + bytes.push(2); + push_bytes(&mut bytes, omega.as_str().as_bytes())?; + bytes.extend_from_slice(&iota_id.to_be_bytes()); + } + PrincipalHome::Omega(locator) => { + bytes.push(3); + push_bytes(&mut bytes, locator.as_str().as_bytes())?; + } + PrincipalHome::Unknown => bytes.push(4), + } + let key_count = u32::try_from(descriptor.public_keys.len()).map_err(|_| { + IdentityError::InvalidDescriptor("principal descriptor has too many keys".into()) + })?; + bytes.extend_from_slice(&key_count.to_be_bytes()); + for key in &descriptor.public_keys { + push_bytes( + &mut bytes, + &key.try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?, + )?; + } + Ok(bytes) +} + +fn push_bytes(output: &mut Vec, value: &[u8]) -> Result<(), IdentityError> { + let length = u32::try_from(value.len()) + .map_err(|_| IdentityError::InvalidDescriptor("descriptor field is too large".into()))?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value); + Ok(()) +} + +fn push_optional_string(output: &mut Vec, value: Option<&str>) -> Result<(), IdentityError> { + match value { + Some(value) => { + output.push(1); + push_bytes(output, value.as_bytes()) + } + None => { + output.push(0); + Ok(()) + } + } +} + +fn push_optional_i64(output: &mut Vec, value: Option) { + match value { + Some(value) => { + output.push(1); + output.extend_from_slice(&value.to_be_bytes()); + } + None => output.push(0), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalUserDescriptor { + pub id: LocalUserId, + pub username: String, + pub display_name: Option, + pub public_key: String, +} + +pub trait LocalUserStore: Send + Sync { + fn get_local_user(&self, id: LocalUserId) + -> Result, IdentityError>; + + fn get_local_user_by_username( + &self, + username: &str, + ) -> Result, IdentityError>; + + fn is_hosted_here(&self, id: LocalUserId) -> Result; + + fn local_user_for_principal( + &self, + principal: PrincipalHandle, + ) -> Result, IdentityError>; + + fn principal_for_local_user( + &self, + user: LocalUserId, + ) -> Result, IdentityError>; +} + +pub trait PrincipalStore: Send + Sync { + fn get_principal( + &self, + handle: PrincipalHandle, + ) -> Result, IdentityError>; + + fn get_by_canonical_id( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError>; + + fn get_by_username( + &self, + authority: &AuthorityId, + username: &str, + ) -> Result, IdentityError>; + + fn upsert_remote_descriptor( + &self, + descriptor: &VerifiedPrincipalDescriptor, + ) -> Result; + + fn signing_keys(&self, principal: &PrincipalId) -> Result, IdentityError>; +} + +#[derive(Clone, Debug)] +pub struct ResolvedNodeIdentity { + pub node: IotaNodeId, + pub public_keys: Vec, + pub descriptor_revision: i64, + pub valid_until: Option, + pub direct_endpoints: Vec, + pub relay_hints: Vec, + pub last_successful_endpoint: Option, + pub resolved_at: i64, +} + +#[derive(Clone, Debug)] +pub struct NodeDescriptor { + pub node: IotaNodeId, + pub public_keys: PublicKeyBundle, + pub direct_endpoints: Vec, + pub relay_hints: Vec, + pub revision: i64, + pub valid_until: Option, +} + +#[derive(Clone, Debug)] +pub struct SignedNodeDescriptor { + pub descriptor: NodeDescriptor, + pub node_signature: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NodeDescriptorWireV1 { + pub version: u32, + pub node: IotaNodeId, + pub public_key: String, + pub direct_endpoints: Vec, + pub relay_hints: Vec, + pub revision: i64, + pub valid_until: Option, + pub node_signature: String, +} + +#[derive(Clone, Debug)] +pub struct VerifiedNodeDescriptor { + descriptor: NodeDescriptor, + resolved_at: i64, +} + +impl SignedNodeDescriptor { + pub fn to_wire_v1(&self) -> Result { + Ok(NodeDescriptorWireV1 { + version: 1, + node: self.descriptor.node.clone(), + public_key: self + .descriptor + .public_keys + .try_to_base64() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?, + direct_endpoints: self.descriptor.direct_endpoints.clone(), + relay_hints: self.descriptor.relay_hints.clone(), + revision: self.descriptor.revision, + valid_until: self.descriptor.valid_until, + node_signature: STANDARD.encode(&self.node_signature), + }) + } + + pub fn sign( + descriptor: NodeDescriptor, + identity: &LocalNodeIdentity, + ) -> Result { + use mtp::crypto::SignatureScheme; + + validate_node_descriptor(&descriptor, 0)?; + if descriptor.node != *identity.node_id() + || descriptor + .public_keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))? + != identity + .public_keys() + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))? + { + return Err(IdentityError::InvalidDescriptor( + "node descriptor does not match local identity".into(), + )); + } + let keyring = identity.keyring(); + let signer = mtp::crypto::DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let node_signature = signer + .sign(&node_descriptor_bytes(&descriptor)?) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + Ok(Self { + descriptor, + node_signature, + }) + } + + pub fn verify( + self, + current_revision: Option, + now: i64, + ) -> Result { + validate_node_descriptor(&self.descriptor, now)?; + if current_revision.is_some_and(|revision| revision > self.descriptor.revision) { + return Err(IdentityError::InvalidDescriptor( + "node descriptor revision is stale".into(), + )); + } + if IotaNodeId::from_public_keys(&self.descriptor.public_keys)? != self.descriptor.node { + return Err(IdentityError::InvalidDescriptor( + "node descriptor key does not match node ID".into(), + )); + } + verify_authority_signature( + &self.descriptor.public_keys, + &node_descriptor_bytes(&self.descriptor)?, + &self.node_signature, + )?; + Ok(VerifiedNodeDescriptor { + descriptor: self.descriptor, + resolved_at: now, + }) + } +} + +impl NodeDescriptorWireV1 { + pub fn parse(self) -> Result { + if self.version != 1 { + return Err(IdentityError::InvalidDescriptor( + "node descriptor wire version is unsupported".into(), + )); + } + Ok(SignedNodeDescriptor { + descriptor: NodeDescriptor { + node: self.node, + public_keys: PublicKeyBundle::from_base64(&self.public_key) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?, + direct_endpoints: self.direct_endpoints, + relay_hints: self.relay_hints, + revision: self.revision, + valid_until: self.valid_until, + }, + node_signature: STANDARD + .decode(self.node_signature) + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?, + }) + } +} + +impl VerifiedNodeDescriptor { + pub fn descriptor(&self) -> &NodeDescriptor { + &self.descriptor + } + + pub fn into_descriptor(self) -> NodeDescriptor { + self.descriptor + } + + pub fn resolved_at(&self) -> i64 { + self.resolved_at + } +} + +fn validate_node_descriptor(descriptor: &NodeDescriptor, now: i64) -> Result<(), IdentityError> { + if descriptor.revision < 0 { + return Err(IdentityError::InvalidDescriptor( + "node descriptor revision is negative".into(), + )); + } + descriptor + .public_keys + .validate() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + if descriptor + .valid_until + .is_some_and(|valid_until| valid_until <= now) + { + return Err(IdentityError::InvalidDescriptor( + "node descriptor is expired".into(), + )); + } + Ok(()) +} + +fn node_descriptor_bytes(descriptor: &NodeDescriptor) -> Result, IdentityError> { + let mut bytes = b"tensamin-node-descriptor:v1".to_vec(); + push_bytes(&mut bytes, descriptor.node.as_str().as_bytes())?; + push_bytes( + &mut bytes, + &descriptor + .public_keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?, + )?; + bytes.extend_from_slice(&descriptor.revision.to_be_bytes()); + push_optional_i64(&mut bytes, descriptor.valid_until); + push_locators(&mut bytes, &descriptor.direct_endpoints)?; + push_locators(&mut bytes, &descriptor.relay_hints)?; + Ok(bytes) +} + +fn push_locators(output: &mut Vec, locators: &[AuthorityLocator]) -> Result<(), IdentityError> { + let count = u32::try_from(locators.len()) + .map_err(|_| IdentityError::InvalidDescriptor("too many node endpoints".into()))?; + output.extend_from_slice(&count.to_be_bytes()); + for locator in locators { + push_bytes(output, locator.as_str().as_bytes())?; + } + Ok(()) +} + +#[async_trait] +pub trait NodeIdentityResolver: Send + Sync { + async fn resolve_node(&self, node: &IotaNodeId) -> Result; +} + +pub trait NodeDirectory: NodeIdentityResolver { + fn upsert_node_descriptor( + &self, + descriptor: &VerifiedNodeDescriptor, + ) -> Result<(), IdentityError>; + + fn pin_endpoint( + &self, + endpoint: &AuthorityLocator, + node: &IotaNodeId, + now: i64, + ) -> Result<(), IdentityError>; + + fn pinned_node(&self, endpoint: &AuthorityLocator) + -> Result, IdentityError>; + + fn pin_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + certificate: &[u8], + ) -> Result<(), IdentityError>; + + fn pinned_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + ) -> Result>, IdentityError>; + + fn record_successful_endpoint( + &self, + node: &IotaNodeId, + endpoint: &AuthorityLocator, + ) -> Result<(), IdentityError>; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IdentityError { + InvalidIdentifier(String), + NotFound, + Unavailable(String), + InvalidDescriptor(String), + KeyPinMismatch, + Storage(String), +} + +impl fmt::Display for IdentityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidIdentifier(message) + | Self::Unavailable(message) + | Self::InvalidDescriptor(message) + | Self::Storage(message) => formatter.write_str(message), + Self::NotFound => formatter.write_str("principal was not found"), + Self::KeyPinMismatch => formatter.write_str("principal key does not match address pin"), + } + } +} + +impl std::error::Error for IdentityError {} + +#[async_trait] +pub trait IdentityResolver: Send + Sync { + async fn resolve_address( + &self, + address: &UserAddress, + context: &ResolutionContext, + ) -> Result; + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result; + + async fn resolve_principal_with_context( + &self, + principal: &PrincipalId, + _context: &ResolutionContext, + ) -> Result { + self.resolve_principal(principal).await + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result, IdentityError>; +} + +pub struct CompositeIdentityResolver { + resolvers: Vec>, +} + +impl CompositeIdentityResolver { + pub fn new(resolvers: Vec>) -> Result { + if resolvers.is_empty() { + return Err(IdentityError::Unavailable( + "identity resolver chain is empty".into(), + )); + } + Ok(Self { resolvers }) + } +} + +#[async_trait] +impl IdentityResolver for CompositeIdentityResolver { + async fn resolve_address( + &self, + address: &UserAddress, + context: &ResolutionContext, + ) -> Result { + for resolver in &self.resolvers { + match resolver.resolve_address(address, context).await { + Ok(resolved) => return Ok(resolved), + Err(IdentityError::NotFound) => {} + Err(error) => return Err(error), + } + } + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + for resolver in &self.resolvers { + match resolver.resolve_principal(principal).await { + Ok(resolved) => return Ok(resolved), + Err(IdentityError::NotFound) => {} + Err(error) => return Err(error), + } + } + Err(IdentityError::NotFound) + } + + async fn resolve_principal_with_context( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result { + for resolver in &self.resolvers { + match resolver + .resolve_principal_with_context(principal, context) + .await + { + Ok(resolved) => return Ok(resolved), + Err(IdentityError::NotFound) => {} + Err(error) => return Err(error), + } + } + Err(IdentityError::NotFound) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result, IdentityError> { + for resolver in &self.resolvers { + match resolver.signing_keys(principal, context).await { + Ok(keys) => return Ok(keys), + Err(IdentityError::NotFound) => {} + Err(error) => return Err(error), + } + } + Err(IdentityError::NotFound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authority_scopes_equal_numeric_user_ids() { + let first = PrincipalId { + authority: AuthorityId::new("omega:first").unwrap(), + user_id: 7, + }; + let second = PrincipalId { + authority: AuthorityId::new("omega:second").unwrap(), + user_id: 7, + }; + assert_ne!(first, second); + } + + #[test] + fn signed_iota_descriptor_verifies_authority_revision_and_expiry() { + let authority = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let user = mtp::crypto::Keyring::generate(); + let descriptor = PrincipalDescriptor { + principal: PrincipalId { + authority: authority.authority_id().clone(), + user_id: 7, + }, + authority_kind: AuthorityKind::Iota, + username: Some("alice".into()), + display_name: None, + public_keys: vec![user.public_key_bundle()], + home: PrincipalHome::Iota(authority.node_id().clone()), + revision: 3, + valid_until: Some(200), + issued_at: 100, + }; + let signed = SignedPrincipalDescriptor::sign(descriptor, &authority.keyring()).unwrap(); + + let verified = signed + .clone() + .verify( + authority.authority_id(), + &authority.public_keys(), + Some(3), + 150, + ) + .unwrap(); + assert_eq!(verified.descriptor().principal.user_id, 7); + assert_eq!(verified.resolved_at(), 150); + assert!( + signed + .clone() + .verify( + authority.authority_id(), + &authority.public_keys(), + Some(4), + 150, + ) + .is_err() + ); + assert!( + signed + .verify( + authority.authority_id(), + &authority.public_keys(), + None, + 200, + ) + .is_err() + ); + } + + #[test] + fn principal_descriptor_wire_round_trip_preserves_signed_binary_meaning() { + let authority = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let signed = SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: PrincipalId { + authority: authority.authority_id().clone(), + user_id: 7, + }, + authority_kind: AuthorityKind::Iota, + username: Some("alice".into()), + display_name: Some("Alice".into()), + public_keys: vec![mtp::crypto::Keyring::generate().public_key_bundle()], + home: PrincipalHome::Iota(authority.node_id().clone()), + revision: 4, + valid_until: Some(500), + issued_at: 100, + }, + &authority.keyring(), + ) + .unwrap(); + let wire = signed.to_wire_v1().unwrap(); + let json = serde_json::to_vec(&wire).unwrap(); + let parsed: PrincipalDescriptorWireV1 = serde_json::from_slice(&json).unwrap(); + let verified = parsed + .parse() + .unwrap() + .verify( + authority.authority_id(), + &authority.public_keys(), + None, + 200, + ) + .unwrap(); + assert_eq!(verified.descriptor().revision, 4); + assert_eq!(verified.descriptor().display_name.as_deref(), Some("Alice")); + } + + #[test] + fn descriptor_signature_rejects_modified_identity() { + let authority = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let mut signed = SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: PrincipalId { + authority: authority.authority_id().clone(), + user_id: 7, + }, + authority_kind: AuthorityKind::Iota, + username: Some("alice".into()), + display_name: None, + public_keys: vec![mtp::crypto::Keyring::generate().public_key_bundle()], + home: PrincipalHome::Iota(authority.node_id().clone()), + revision: 1, + valid_until: None, + issued_at: 100, + }, + &authority.keyring(), + ) + .unwrap(); + signed.descriptor.principal.user_id = 8; + + assert!( + signed + .verify( + authority.authority_id(), + &authority.public_keys(), + None, + 101, + ) + .is_err() + ); + } + + #[test] + fn signed_node_descriptor_binds_node_id_and_endpoints() { + let identity = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let signed = SignedNodeDescriptor::sign( + NodeDescriptor { + node: identity.node_id().clone(), + public_keys: identity.public_keys(), + direct_endpoints: vec![AuthorityLocator::new("iota.example.org:8443").unwrap()], + relay_hints: vec![AuthorityLocator::new("relay.example.org:9443").unwrap()], + revision: 2, + valid_until: Some(200), + }, + &identity, + ) + .unwrap(); + let verified = signed.clone().verify(Some(2), 150).unwrap(); + assert_eq!( + verified.descriptor().direct_endpoints[0].as_str(), + "iota.example.org:8443" + ); + + let mut modified = signed; + modified.descriptor.direct_endpoints = + vec![AuthorityLocator::new("attacker.example.org:8443").unwrap()]; + assert!(modified.verify(None, 150).is_err()); + } + + #[test] + fn node_descriptor_wire_round_trip_keeps_endpoint_signature_valid() { + let identity = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let signed = SignedNodeDescriptor::sign( + NodeDescriptor { + node: identity.node_id().clone(), + public_keys: identity.public_keys(), + direct_endpoints: vec![AuthorityLocator::new("iota.example.org:1984").unwrap()], + relay_hints: Vec::new(), + revision: 1, + valid_until: Some(500), + }, + &identity, + ) + .unwrap(); + let wire = signed.to_wire_v1().unwrap(); + let json = serde_json::to_vec(&wire).unwrap(); + let parsed: NodeDescriptorWireV1 = serde_json::from_slice(&json).unwrap(); + let verified = parsed.parse().unwrap().verify(None, 200).unwrap(); + assert_eq!(verified.descriptor().revision, 1); + } + + #[test] + fn discovery_document_binds_iota_id_to_public_key() { + let identity = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let document = AuthorityDiscoveryDocument { + version: 1, + service: AuthorityKind::Iota, + authority_id: identity.authority_id().clone(), + node_id: Some(identity.node_id().clone()), + public_key: identity.public_keys().try_to_base64().unwrap(), + protocols: vec!["mtp-relay-v2".into()], + node_descriptor: Some("/federation/v1/node".into()), + direct_endpoints: vec![AuthorityLocator::new("iota.example.org:8443").unwrap()], + relay_hints: Vec::new(), + }; + assert_eq!( + document.verify().unwrap().node_id, + Some(identity.node_id().clone()) + ); + + let mut mismatched = AuthorityDiscoveryDocument { + version: 1, + service: AuthorityKind::Iota, + authority_id: identity.authority_id().clone(), + node_id: Some(identity.node_id().clone()), + public_key: mtp::crypto::Keyring::generate() + .public_key_bundle() + .try_to_base64() + .unwrap(), + protocols: vec!["mtp-relay-v2".into()], + node_descriptor: Some("/federation/v1/node".into()), + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + }; + assert!(mismatched.clone().verify().is_err()); + mismatched.protocols.clear(); + assert!(mismatched.verify().is_err()); + } + + #[test] + fn discovery_document_binds_omega_id_to_public_key() { + let keyring = mtp::crypto::Keyring::generate(); + let public_keys = keyring.public_key_bundle(); + let document = AuthorityDiscoveryDocument { + version: 1, + service: AuthorityKind::Omega, + authority_id: AuthorityId::for_omega(&public_keys).unwrap(), + node_id: None, + public_key: public_keys.try_to_base64().unwrap(), + protocols: vec!["identity-http-v1".into()], + node_descriptor: None, + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + }; + assert!(document.verify().is_ok()); + } + + #[test] + fn identifiers_reject_empty_values() { + assert!(AuthorityId::new(" ").is_err()); + assert!(IotaNodeId::new(String::new()).is_err()); + assert!(IotaNodeId::new("node-a").is_err()); + assert!(AuthorityLocator::new(" ").is_err()); + } + + #[test] + fn parses_user_addresses() { + let key = mtp::crypto::Keyring::generate().public_key_bundle(); + let encoded_key = key.try_to_base64().unwrap(); + let username = UserAddress::parse("alice@IOTA.Example.Org:443").unwrap(); + let numeric = UserAddress::parse("51@192.0.2.20").unwrap(); + let numeric_domain = UserAddress::parse("51@iota.example.org").unwrap(); + let omega = UserAddress::parse("alice@omega.example.org").unwrap(); + let numeric_pinned = + UserAddress::parse(&format!("51::{encoded_key}@omega.example.org")).unwrap(); + let pinned = + UserAddress::parse(&format!("alice::{encoded_key}@[2001:DB8::1]:8443")).unwrap(); + + assert_eq!(username.selector, UserSelector::Username("alice".into())); + assert_eq!(username.authority.unwrap().as_str(), "iota.example.org:443"); + assert_eq!(numeric.selector, UserSelector::UserId(51)); + assert_eq!(numeric.authority.unwrap().as_str(), "192.0.2.20"); + assert_eq!(numeric_domain.selector, UserSelector::UserId(51)); + assert_eq!( + numeric_domain.authority.unwrap().as_str(), + "iota.example.org" + ); + assert_eq!(omega.authority.unwrap().as_str(), "omega.example.org"); + assert_eq!(numeric_pinned.selector, UserSelector::UserId(51)); + assert!(numeric_pinned.public_key_pin.is_some()); + assert_eq!(pinned.authority.unwrap().as_str(), "[2001:db8::1]:8443"); + assert_eq!( + pinned.public_key_pin.unwrap().try_as_bytes().unwrap(), + key.try_as_bytes().unwrap() + ); + } + + #[test] + fn numeric_only_selectors_are_user_ids() { + assert_eq!( + UserAddress::parse("00051").unwrap().selector, + UserSelector::UserId(51) + ); + assert_eq!( + UserAddress::parse("51alice").unwrap().selector, + UserSelector::Username("51alice".into()) + ); + } + + #[test] + fn parses_local_and_bracketed_ipv6_addresses() { + let local = UserAddress::parse("alice").unwrap(); + let ipv6 = UserAddress::parse("alice@[2001:DB8::1]").unwrap(); + let ipv4 = AuthorityLocator::new("192.0.2.10:443").unwrap(); + let domain = AuthorityLocator::new("iota.example:443").unwrap(); + + assert!(local.authority.is_none()); + let ipv6 = ipv6.authority.unwrap(); + assert_eq!(ipv6.as_str(), "[2001:db8::1]"); + assert!(ipv6.is_ip_literal()); + assert!(ipv4.is_ip_literal()); + assert!(!domain.is_ip_literal()); + } + + #[test] + fn rejects_malformed_user_addresses() { + for address in [ + "", + "@example.org", + "alice@", + "alice@@example.org", + "alice::not-a-key@example.org", + "alice::@example.org", + ] { + assert!(UserAddress::parse(address).is_err(), "accepted {address}"); + } + } + + #[test] + fn node_identity_is_stable_across_reload() { + let path = std::env::temp_dir().join(format!( + "iota-node-identity-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let first = LocalNodeIdentity::load_or_create(&path, None).unwrap(); + let second = LocalNodeIdentity::load_or_create(&path, None).unwrap(); + + assert_eq!(first.node_id(), second.node_id()); + assert_eq!(first.authority_id(), second.authority_id()); + assert_eq!( + IotaNodeId::from_public_keys(&first.public_keys()).unwrap(), + *first.node_id() + ); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn legacy_omega_route_is_not_a_cryptographic_iota_identity() { + let authority = AuthorityId::omega_legacy("omega-a.example").unwrap(); + let home = PrincipalHome::LegacyOmegaIota { + omega: authority, + iota_id: 42, + }; + assert!(matches!( + home, + PrincipalHome::LegacyOmegaIota { iota_id: 42, .. } + )); + } + + #[test] + fn descriptor_expiry_is_exclusive() { + let resolved = ResolvedPrincipal { + principal: PrincipalId { + authority: AuthorityId::new("omega:example").unwrap(), + user_id: 1, + }, + handle: PrincipalHandle(1), + username: None, + public_keys: Vec::new(), + home: PrincipalHome::Unknown, + descriptor_revision: 1, + valid_until: Some(100), + resolved_at: 1, + }; + assert!(resolved.is_valid_at(99)); + assert!(!resolved.is_valid_at(100)); + } + + #[test] + fn offline_cache_policy_enforces_its_staleness_limit() { + let policy = OfflineResolutionPolicy::AllowUnexpired { + max_staleness: Duration::from_secs(1), + }; + + assert!(policy.allows_cached(1_000, 2_000)); + assert!(!policy.allows_cached(1_000, 2_001)); + assert!(!OfflineResolutionPolicy::RejectStale.allows_cached(1_000, 1_000)); + } +} diff --git a/iota-logger/Cargo.toml b/iota-logger/Cargo.toml index 1867ce5..6efc703 100644 --- a/iota-logger/Cargo.toml +++ b/iota-logger/Cargo.toml @@ -8,7 +8,7 @@ iota-paths = { path = "../iota-paths" } iota-state = { path = "../iota-state" } iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318" } ratatui = "0.30.0" json = "0.12.4" diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 9f91c52..6eac45b 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = "0.1.89" +iota-identity = { path = "../iota-identity" } iota-logger = { path = "../iota-logger" } iota-util = { path = "../iota-util" } iota-paths = { path = "../iota-paths" } @@ -13,8 +15,13 @@ arc-swap = "1" once_cell = "1.21.3" r2d2 = "0.8" serde = { version = "1", features = ["derive"] } +serde_json = "1" serde_yaml = "0.9" thiserror = "2" rand = "0.8" rusqlite = "0.40.0" tokio = { version = "1.50.0", features = ["full"] } + +[dev-dependencies] +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["crypto"] } +tempfile = "3" diff --git a/iota-storage/src/identity.rs b/iota-storage/src/identity.rs new file mode 100644 index 0000000..c0a209e --- /dev/null +++ b/iota-storage/src/identity.rs @@ -0,0 +1,877 @@ +use iota_identity::{ + AuthorityId, AuthorityKind, IdentityError, LocalDescriptorPublisher, LocalUserDescriptor, + LocalUserId, LocalUserStore, PrincipalDescriptor, PrincipalHandle, PrincipalHome, PrincipalId, + PrincipalStore, PublicKeyBundle, ResolvedPrincipal, SignedPrincipalDescriptor, +}; +use iota_util::crypto_helper::{public_key_bundle_from_base64, public_key_bundle_to_base64}; +use rusqlite::{OptionalExtension, params}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::users::user_manager; +use crate::util::db; + +const LOCAL_DESCRIPTOR_LIFETIME_MILLIS: i64 = 30 * 24 * 60 * 60 * 1_000; +const LOCAL_DESCRIPTOR_RENEWAL_MILLIS: i64 = 7 * 24 * 60 * 60 * 1_000; + +#[derive(Default)] +pub struct SqliteLocalUserStore; + +impl LocalUserStore for SqliteLocalUserStore { + fn get_local_user( + &self, + id: LocalUserId, + ) -> Result, IdentityError> { + user_manager::get_user(id.0) + .map_err(storage_error) + .map(|user| user.map(local_descriptor)) + } + + fn get_local_user_by_username( + &self, + username: &str, + ) -> Result, IdentityError> { + user_manager::get_user_by_username(username) + .map_err(storage_error) + .map(|user| user.map(local_descriptor)) + } + + fn is_hosted_here(&self, id: LocalUserId) -> Result { + self.get_local_user(id).map(|user| user.is_some()) + } + + fn local_user_for_principal( + &self, + principal: PrincipalHandle, + ) -> Result, IdentityError> { + db::with_db(|connection| { + connection + .query_row( + "SELECT local_user_id FROM hosted_principals WHERE principal_handle = ?1", + [principal.0], + |row| row.get::<_, i64>(0).map(LocalUserId), + ) + .optional() + .map_err(Into::into) + }) + .map_err(storage_error) + } + + fn principal_for_local_user( + &self, + user: LocalUserId, + ) -> Result, IdentityError> { + SqlitePrincipalStore.principal_for_local_user(user) + } +} + +fn local_descriptor(user: crate::users::user_profile::UserProfile) -> LocalUserDescriptor { + LocalUserDescriptor { + id: LocalUserId(user.user_id), + username: user.username, + display_name: user.display_name, + public_key: user.public_key, + } +} + +#[derive(Default)] +pub struct SqlitePrincipalStore; + +impl SqlitePrincipalStore { + pub fn principal_for_local_user( + &self, + user: LocalUserId, + ) -> Result, IdentityError> { + db::with_db(|connection| { + connection + .query_row( + "SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1", + [user.0], + |row| row.get::<_, i64>(0).map(PrincipalHandle), + ) + .optional() + .map_err(Into::into) + }) + .map_err(storage_error) + } + + pub fn migrate_legacy_omega_authority( + &self, + authority: &AuthorityId, + ) -> Result<(), IdentityError> { + db::with_immediate_transaction(|transaction| { + transaction.execute( + "INSERT INTO identity_configuration (singleton, omega_authority_id) VALUES (1, ?1) ON CONFLICT(singleton) DO UPDATE SET omega_authority_id = excluded.omega_authority_id", + [authority.as_str()], + )?; + let mut statement = transaction.prepare( + "SELECT principal_pk, remote_user_id FROM principals WHERE authority_id = 'omega:central'", + )?; + let legacy = statement + .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))? + .collect::, _>>()?; + drop(statement); + for (legacy_handle, user_id) in legacy { + let current_handle = transaction + .query_row( + "SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2", + params![authority.as_str(), user_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + if let Some(current_handle) = current_handle { + transaction.execute( + "INSERT OR IGNORE INTO principal_keys (principal_pk, public_key, valid_from, valid_until, source_revision) SELECT ?1, public_key, valid_from, valid_until, source_revision FROM principal_keys WHERE principal_pk = ?2", + params![current_handle, legacy_handle], + )?; + for (table, column) in [ + ("contacts", "principal_handle"), + ("messages", "external_principal"), + ("relay_replay", "signer_principal"), + ("relay_inbox", "signer_principal"), + ("pending_relays", "destination_principal"), + ("blocked_users", "blocked_principal"), + ] { + transaction.execute( + &format!("UPDATE {table} SET {column} = ?1 WHERE {column} = ?2"), + params![current_handle, legacy_handle], + )?; + } + transaction.execute( + "DELETE FROM principals WHERE principal_pk = ?1", + [legacy_handle], + )?; + } else { + transaction.execute( + "UPDATE principals SET authority_id = ?1 WHERE principal_pk = ?2", + params![authority.as_str(), legacy_handle], + )?; + } + } + transaction.execute_batch( + "CREATE TEMP TABLE IF NOT EXISTS observed_omega_users (user_id INTEGER PRIMARY KEY); DELETE FROM observed_omega_users; INSERT OR IGNORE INTO observed_omega_users SELECT user_id FROM users WHERE user_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT user_id FROM contacts WHERE user_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT external_user FROM messages WHERE external_user > 0; INSERT OR IGNORE INTO observed_omega_users SELECT signer_id FROM relay_replay WHERE signer_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT signer_id FROM relay_inbox WHERE signer_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT destination_id FROM relay_inbox WHERE destination_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT relay_signer_id FROM pending_relays WHERE relay_signer_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT relay_destination_user_id FROM pending_relays WHERE relay_destination_user_id > 0; INSERT OR IGNORE INTO observed_omega_users SELECT blocked_user_id FROM blocked_users WHERE blocked_user_id > 0;" + )?; + transaction.execute( + "INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', ?1, user_id, 0, 0 FROM observed_omega_users", + [authority.as_str()], + )?; + for (table, principal_column, user_column) in [ + ("contacts", "principal_handle", "user_id"), + ("messages", "external_principal", "external_user"), + ("relay_replay", "signer_principal", "signer_id"), + ("relay_inbox", "signer_principal", "signer_id"), + ("pending_relays", "destination_principal", "relay_destination_user_id"), + ("blocked_users", "blocked_principal", "blocked_user_id"), + ] { + transaction.execute( + &format!("UPDATE {table} SET {principal_column} = (SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = {table}.{user_column}) WHERE {principal_column} IS NULL AND {user_column} IS NOT NULL"), + [authority.as_str()], + )?; + } + Ok(()) + }) + .map_err(storage_error) + } + + pub fn ensure_local_principal( + &self, + authority: &AuthorityId, + authority_kind: AuthorityKind, + user: &LocalUserDescriptor, + home: PrincipalHome, + resolved_at: i64, + ) -> Result { + let user_id = u64::try_from(user.id.0) + .map_err(|_| IdentityError::InvalidDescriptor("negative local user ID".into()))?; + let key = public_key_bundle_from_base64(&user.public_key) + .ok_or_else(|| IdentityError::InvalidDescriptor("stored user key is invalid".into()))?; + let encoded_home = encode_home(&home); + let current = db::with_db(|connection| { + connection + .query_row( + r#"SELECT p.authority_kind, p.username, p.display_name, p.home, + p.descriptor_revision, p.descriptor_valid_until, + COALESCE(p.descriptor_issued_at, p.last_resolved_at), p.retired_at, + (SELECT public_key FROM principal_keys + WHERE principal_pk = p.principal_pk + AND (valid_until IS NULL OR valid_until > ?3) + ORDER BY source_revision DESC LIMIT 1) + FROM principals p + WHERE p.authority_id = ?1 AND p.remote_user_id = ?2"#, + params![authority.as_str(), user.id.0, resolved_at], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + )) + }, + ) + .optional() + .map_err(Into::into) + }) + .map_err(storage_error)?; + if current.as_ref().is_some_and(|current| current.7.is_some()) { + return Err(IdentityError::InvalidDescriptor( + "retired local principal cannot be published".into(), + )); + } + let signed_fields_match = current.as_ref().is_some_and(|current| { + current.0 == authority_kind.as_str() + && current.1.as_deref() == Some(user.username.as_str()) + && current.2.as_ref() == user.display_name.as_ref() + && current.3.as_ref() == encoded_home.as_ref() + && current.8.as_deref() == Some(user.public_key.as_str()) + }); + let current_valid_until = current.as_ref().and_then(|current| current.5); + let renew_validity = current_valid_until + .is_none_or(|valid_until| valid_until <= resolved_at + LOCAL_DESCRIPTOR_RENEWAL_MILLIS); + let revision = current.as_ref().map_or(0, |current| { + if signed_fields_match && !renew_validity { + current.4 + } else { + current.4.saturating_add(1) + } + }); + let issued_at = current.as_ref().map_or(resolved_at, |current| { + if signed_fields_match && !renew_validity { + current.6 + } else { + resolved_at + } + }); + let valid_until = if signed_fields_match && !renew_validity { + current_valid_until + } else { + Some(resolved_at.saturating_add(LOCAL_DESCRIPTOR_LIFETIME_MILLIS)) + }; + let descriptor = iota_identity::VerifiedPrincipalDescriptor::from_trusted_authority( + PrincipalDescriptor { + principal: PrincipalId { + authority: authority.clone(), + user_id, + }, + authority_kind, + username: Some(user.username.clone()), + display_name: user.display_name.clone(), + public_keys: vec![key], + home, + revision, + valid_until, + issued_at, + }, + )?; + let handle = self.upsert_remote_descriptor(&descriptor)?; + db::with_db(|connection| { + connection.execute( + "INSERT INTO hosted_principals (local_user_id, principal_handle) VALUES (?1, ?2) ON CONFLICT(local_user_id) DO UPDATE SET principal_handle = excluded.principal_handle", + params![user.id.0, handle.0], + )?; + Ok(()) + }) + .map_err(storage_error)?; + Ok(handle) + } + + pub fn local_principal_descriptor( + &self, + user: LocalUserId, + ) -> Result { + db::with_db(|connection| { + let row = connection + .query_row( + r#"SELECT p.authority_kind, p.authority_id, p.remote_user_id, + p.username, p.display_name, p.home, + p.descriptor_revision, p.descriptor_valid_until, + COALESCE(p.descriptor_issued_at, p.last_resolved_at) + FROM hosted_principals h + JOIN principals p ON p.principal_pk = h.principal_handle + WHERE h.local_user_id = ?1 AND p.retired_at IS NULL"#, + [user.0], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, i64>(8)?, + )) + }, + ) + .optional()?; + let Some((kind, authority, remote_user_id, username, display_name, home, revision, valid_until, issued_at)) = row else { + return Ok(None); + }; + let principal_pk = connection.query_row( + "SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1", + [user.0], + |row| row.get::<_, i64>(0), + )?; + let mut statement = connection.prepare( + "SELECT public_key FROM principal_keys WHERE principal_pk = ?1 AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC", + )?; + let encoded = statement + .query_map(params![principal_pk, issued_at], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(Some((kind, authority, remote_user_id, username, display_name, home, revision, valid_until, issued_at, encoded))) + }) + .map_err(storage_error)? + .ok_or(IdentityError::NotFound) + .and_then(|(kind, authority, remote_user_id, username, display_name, home, revision, valid_until, issued_at, encoded)| { + let authority_kind = match kind.as_str() { + "iota" => AuthorityKind::Iota, + "omega" => AuthorityKind::Omega, + _ => return Err(IdentityError::InvalidDescriptor("stored authority kind is invalid".into())), + }; + let public_keys = encoded + .into_iter() + .map(|key| public_key_bundle_from_base64(&key).ok_or_else(|| IdentityError::InvalidDescriptor("stored principal key is invalid".into()))) + .collect::, _>>()?; + Ok(PrincipalDescriptor { + principal: PrincipalId { + authority: AuthorityId::new(authority)?, + user_id: u64::try_from(remote_user_id).map_err(|_| IdentityError::InvalidDescriptor("stored principal user ID is negative".into()))?, + }, + authority_kind, + username, + display_name, + public_keys, + home: decode_home(home.as_deref()).map_err(storage_error)?, + revision, + valid_until, + issued_at, + }) + }) + } + + /* Retire hosted identity after its data is purged. Keeping descriptor + * revision state rejects descriptors issued before account removal. */ + pub fn retire_local_principal( + &self, + user: LocalUserId, + retired_at: i64, + ) -> Result<(), IdentityError> { + db::with_immediate_transaction(|transaction| { + let principal = transaction + .query_row( + "SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1", + [user.0], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| { + crate::storage_error::StorageError::Other( + "hosted principal was not found".into(), + ) + })?; + transaction.execute( + "UPDATE principals SET descriptor_revision = descriptor_revision + 1, descriptor_valid_until = ?2, last_resolved_at = ?2, retired_at = ?2 WHERE principal_pk = ?1", + params![principal, retired_at], + )?; + transaction.execute( + "UPDATE principal_keys SET valid_until = MIN(COALESCE(valid_until, ?2), ?2) WHERE principal_pk = ?1", + params![principal, retired_at], + )?; + transaction.execute( + "DELETE FROM hosted_principals WHERE local_user_id = ?1", + [user.0], + )?; + Ok(()) + }) + .map_err(storage_error) + } +} + +pub struct SqliteLocalDescriptorPublisher { + identity: iota_identity::LocalNodeIdentity, +} + +impl SqliteLocalDescriptorPublisher { + pub fn new(identity: iota_identity::LocalNodeIdentity) -> Self { + Self { identity } + } +} + +impl LocalDescriptorPublisher for SqliteLocalDescriptorPublisher { + fn principal_descriptor( + &self, + user: LocalUserId, + ) -> Result { + let local_user = SqliteLocalUserStore + .get_local_user(user)? + .ok_or(IdentityError::NotFound)?; + SqlitePrincipalStore.ensure_local_principal( + self.identity.authority_id(), + AuthorityKind::Iota, + &local_user, + PrincipalHome::Iota(self.identity.node_id().clone()), + now_millis(), + )?; + SignedPrincipalDescriptor::sign( + SqlitePrincipalStore.local_principal_descriptor(user)?, + &self.identity.keyring(), + ) + } +} + +pub struct LocalIdentityResolver { + authority: AuthorityId, + authority_kind: AuthorityKind, + home: PrincipalHome, + local_users: Arc, + principals: Arc, +} + +impl LocalIdentityResolver { + pub fn new( + authority: AuthorityId, + authority_kind: AuthorityKind, + home: PrincipalHome, + local_users: Arc, + principals: Arc, + ) -> Self { + Self { + authority, + authority_kind, + home, + local_users, + principals, + } + } + + fn resolve_local(&self, user: LocalUserDescriptor) -> Result { + let handle = self.principals.ensure_local_principal( + &self.authority, + self.authority_kind, + &user, + self.home.clone(), + now_millis(), + )?; + self.principals + .get_principal(handle)? + .ok_or(IdentityError::NotFound) + } +} + +#[async_trait::async_trait] +impl iota_identity::IdentityResolver for LocalIdentityResolver { + async fn resolve_address( + &self, + address: &iota_identity::UserAddress, + _: &iota_identity::ResolutionContext, + ) -> Result { + if let Some(address_authority) = &address.authority { + let matches_home = matches!( + &self.home, + PrincipalHome::Omega(locator) if locator == address_authority + ); + if address_authority.as_str() != self.authority.as_str() && !matches_home { + return Err(IdentityError::NotFound); + } + } + let user = match &address.selector { + iota_identity::UserSelector::UserId(user_id) => { + let user_id = i64::try_from(*user_id).map_err(|_| { + IdentityError::InvalidIdentifier("user ID exceeds local storage range".into()) + })?; + self.local_users.get_local_user(LocalUserId(user_id))? + } + iota_identity::UserSelector::Username(username) => { + self.local_users.get_local_user_by_username(username)? + } + } + .ok_or(IdentityError::NotFound)?; + let resolved = self.resolve_local(user)?; + verify_pin(address.public_key_pin.as_ref(), &resolved.public_keys)?; + Ok(resolved) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + if principal.authority != self.authority { + return Err(IdentityError::NotFound); + } + let user_id = i64::try_from(principal.user_id).map_err(|_| { + IdentityError::InvalidIdentifier("user ID exceeds local storage range".into()) + })?; + let user = self + .local_users + .get_local_user(LocalUserId(user_id))? + .ok_or(IdentityError::NotFound)?; + self.resolve_local(user) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + _: &iota_identity::ResolutionContext, + ) -> Result, IdentityError> { + self.resolve_principal(principal) + .await + .map(|resolved| resolved.public_keys) + } +} + +fn verify_pin( + pin: Option<&PublicKeyBundle>, + keys: &[PublicKeyBundle], +) -> Result<(), IdentityError> { + let Some(pin) = pin else { + return Ok(()); + }; + let pin = pin + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let matches = keys.iter().any(|key| { + key.try_as_bytes() + .map(|candidate| candidate == pin) + .unwrap_or(false) + }); + if matches { + Ok(()) + } else { + Err(IdentityError::KeyPinMismatch) + } +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +impl PrincipalStore for SqlitePrincipalStore { + fn get_principal( + &self, + handle: PrincipalHandle, + ) -> Result, IdentityError> { + db::with_db(|connection| load_principal(connection, "p.principal_pk = ?1", handle.0)) + .map_err(storage_error) + } + + fn get_by_canonical_id( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError> { + let remote_user_id = i64::try_from(principal.user_id).map_err(|_| { + IdentityError::InvalidIdentifier("principal user ID exceeds storage range".into()) + })?; + db::with_db(|connection| { + let handle = connection + .query_row( + "SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2", + params![principal.authority.as_str(), remote_user_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + match handle { + Some(handle) => load_principal(connection, "p.principal_pk = ?1", handle), + None => Ok(None), + } + }) + .map_err(storage_error) + } + + fn get_by_username( + &self, + authority: &AuthorityId, + username: &str, + ) -> Result, IdentityError> { + db::with_db(|connection| { + let handle = connection + .query_row( + "SELECT principal_pk FROM principals WHERE authority_id = ?1 AND username = ?2 AND retired_at IS NULL", + params![authority.as_str(), username], + |row| row.get::<_, i64>(0), + ) + .optional()?; + match handle { + Some(handle) => load_principal(connection, "p.principal_pk = ?1", handle), + None => Ok(None), + } + }) + .map_err(storage_error) + } + + fn upsert_remote_descriptor( + &self, + descriptor: &iota_identity::VerifiedPrincipalDescriptor, + ) -> Result { + let resolved_at = descriptor.resolved_at(); + let descriptor = descriptor.descriptor(); + if descriptor.public_keys.is_empty() { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor contains no signing keys".into(), + )); + } + if descriptor.revision < 0 { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor revision is negative".into(), + )); + } + if descriptor + .valid_until + .is_some_and(|valid_until| valid_until <= resolved_at) + { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor is already expired".into(), + )); + } + let remote_user_id = i64::try_from(descriptor.principal.user_id).map_err(|_| { + IdentityError::InvalidIdentifier("principal user ID exceeds storage range".into()) + })?; + let home = encode_home(&descriptor.home); + db::with_immediate_transaction(|transaction| { + let current = transaction + .query_row( + "SELECT principal_pk, descriptor_revision, last_resolved_at, retired_at, authority_kind, username, display_name, home, descriptor_valid_until, descriptor_issued_at FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2", + params![descriptor.principal.authority.as_str(), remote_user_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option>(3)?, row.get::<_, String>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, row.get::<_, Option>(9)?)), + ) + .optional()?; + if let Some((handle, revision, current_resolved_at, retired_at, authority_kind, username, display_name, current_home, valid_until, issued_at)) = ¤t { + if retired_at.is_some() + || *revision > descriptor.revision + || (*revision == descriptor.revision && *current_resolved_at > resolved_at) + { + return Err(crate::storage_error::StorageError::Other( + "stale principal descriptor update was rejected".into(), + )); + } + if *revision == descriptor.revision { + let mut stored_keys = transaction + .prepare("SELECT public_key FROM principal_keys WHERE principal_pk = ?1 AND source_revision = ?2 ORDER BY public_key")? + .query_map(params![handle, revision], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + let mut descriptor_keys = descriptor + .public_keys + .iter() + .map(public_key_bundle_to_base64) + .collect::>(); + stored_keys.sort(); + descriptor_keys.sort(); + if authority_kind != descriptor.authority_kind.as_str() + || username != &descriptor.username + || display_name != &descriptor.display_name + || current_home != &home + || valid_until != &descriptor.valid_until + || issued_at.is_some_and(|issued_at| issued_at != descriptor.issued_at) + || stored_keys != descriptor_keys + { + return Err(crate::storage_error::StorageError::Other( + "conflicting principal descriptor revision was rejected".into(), + )); + } + } + } + transaction.execute( + r#"INSERT INTO principals ( + authority_kind, authority_id, remote_user_id, username, display_name, + home, descriptor_revision, descriptor_valid_until, descriptor_issued_at, + last_resolved_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(authority_id, remote_user_id) DO UPDATE SET + authority_kind = excluded.authority_kind, + username = excluded.username, + display_name = excluded.display_name, + home = excluded.home, + descriptor_revision = excluded.descriptor_revision, + descriptor_valid_until = excluded.descriptor_valid_until, + descriptor_issued_at = excluded.descriptor_issued_at, + last_resolved_at = excluded.last_resolved_at"#, + params![ + descriptor.authority_kind.as_str(), + descriptor.principal.authority.as_str(), + remote_user_id, + descriptor.username, + descriptor.display_name, + home, + descriptor.revision, + descriptor.valid_until, + descriptor.issued_at, + resolved_at, + ], + )?; + let handle = transaction.query_row( + "SELECT principal_pk FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2", + params![descriptor.principal.authority.as_str(), remote_user_id], + |row| row.get::<_, i64>(0), + )?; + transaction.execute( + "UPDATE principal_keys SET valid_until = MIN(COALESCE(valid_until, ?2), ?2) WHERE principal_pk = ?1", + params![handle, resolved_at], + )?; + for key in &descriptor.public_keys { + let encoded = public_key_bundle_to_base64(key); + if encoded.is_empty() { + return Err(crate::storage_error::StorageError::Other( + "principal signing key could not be encoded".into(), + )); + } + transaction.execute( + r#"INSERT INTO principal_keys ( + principal_pk, public_key, valid_from, valid_until, source_revision + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(principal_pk, public_key) DO UPDATE SET + valid_until = excluded.valid_until, + source_revision = excluded.source_revision"#, + params![ + handle, + encoded, + resolved_at, + descriptor.valid_until, + descriptor.revision, + ], + )?; + } + Ok(PrincipalHandle(handle)) + }) + .map_err(storage_error) + } + + fn signing_keys(&self, principal: &PrincipalId) -> Result, IdentityError> { + let resolved = self + .get_by_canonical_id(principal)? + .ok_or(IdentityError::NotFound)?; + if !resolved.is_valid_at(now_millis()) { + return Err(IdentityError::Unavailable( + "cached principal descriptor is expired".into(), + )); + } + if resolved.public_keys.is_empty() { + return Err(IdentityError::InvalidDescriptor( + "principal has no current signing keys".into(), + )); + } + Ok(resolved.public_keys) + } +} + +fn load_principal( + connection: &rusqlite::Connection, + predicate: &str, + value: i64, +) -> Result, crate::storage_error::StorageError> { + let query = format!( + "SELECT p.principal_pk, p.authority_id, p.remote_user_id, p.username, p.home, p.descriptor_revision, p.descriptor_valid_until, p.last_resolved_at FROM principals p WHERE {predicate}" + ); + let row = connection + .query_row(&query, [value], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, i64>(7)?, + )) + }) + .optional()?; + let Some(( + handle, + authority, + remote_user_id, + username, + home, + descriptor_revision, + valid_until, + resolved_at, + )) = row + else { + return Ok(None); + }; + let mut statement = connection.prepare( + "SELECT public_key FROM principal_keys WHERE principal_pk = ?1 AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC, valid_from DESC", + )?; + let encoded = statement + .query_map(params![handle, now_millis()], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + let mut public_keys = Vec::with_capacity(encoded.len()); + for value in encoded { + let key = public_key_bundle_from_base64(&value).ok_or_else(|| { + crate::storage_error::StorageError::Other("stored principal key is invalid".into()) + })?; + public_keys.push(key); + } + let authority = AuthorityId::new(authority) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))?; + let user_id = u64::try_from(remote_user_id).map_err(|_| { + crate::storage_error::StorageError::Other("stored principal user ID is negative".into()) + })?; + Ok(Some(ResolvedPrincipal { + principal: PrincipalId { authority, user_id }, + handle: PrincipalHandle(handle), + username, + public_keys, + home: decode_home(home.as_deref())?, + descriptor_revision, + valid_until, + resolved_at, + })) +} + +fn encode_home(home: &PrincipalHome) -> Option { + match home { + PrincipalHome::Iota(id) => Some(format!("iota:{}", id.as_str())), + PrincipalHome::LegacyOmegaIota { omega, iota_id } => { + Some(format!("legacy_omega_iota:{}:{iota_id}", omega.as_str())) + } + PrincipalHome::Omega(locator) => Some(format!("omega:{}", locator.as_str())), + PrincipalHome::Unknown => None, + } +} + +fn decode_home(home: Option<&str>) -> Result { + let Some(home) = home else { + return Ok(PrincipalHome::Unknown); + }; + if let Some(id) = home.strip_prefix("iota:") { + return iota_identity::IotaNodeId::new(id) + .map(PrincipalHome::Iota) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())); + } + if let Some(value) = home.strip_prefix("legacy_omega_iota:") { + let (omega, iota_id) = value.rsplit_once(':').ok_or_else(|| { + crate::storage_error::StorageError::Other( + "stored legacy Omega Iota home is invalid".into(), + ) + })?; + let omega = AuthorityId::new(omega) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))?; + let iota_id = iota_id.parse::().map_err(|_| { + crate::storage_error::StorageError::Other( + "stored legacy Omega Iota ID is invalid".into(), + ) + })?; + return Ok(PrincipalHome::LegacyOmegaIota { omega, iota_id }); + } + if let Some(locator) = home.strip_prefix("omega:") { + return iota_identity::AuthorityLocator::new(locator) + .map(PrincipalHome::Omega) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())); + } + Err(crate::storage_error::StorageError::Other( + "stored principal home is invalid".into(), + )) +} + +fn storage_error(error: impl std::fmt::Display) -> IdentityError { + IdentityError::Storage(error.to_string()) +} diff --git a/iota-storage/src/lib.rs b/iota-storage/src/lib.rs index a0e5061..1c8813d 100644 --- a/iota-storage/src/lib.rs +++ b/iota-storage/src/lib.rs @@ -1,3 +1,5 @@ +pub mod identity; +pub mod node_directory; pub mod storage_error; pub mod users; pub mod util; diff --git a/iota-storage/src/node_directory.rs b/iota-storage/src/node_directory.rs new file mode 100644 index 0000000..bdae808 --- /dev/null +++ b/iota-storage/src/node_directory.rs @@ -0,0 +1,420 @@ +use async_trait::async_trait; +use iota_identity::{ + AuthorityLocator, IdentityError, IotaNodeId, LocalNodeIdentity, NodeDescriptor, + NodeIdentityResolver, PublicKeyBundle, ResolvedNodeIdentity, SignedNodeDescriptor, + VerifiedNodeDescriptor, +}; +use iota_util::crypto_helper::{public_key_bundle_from_base64, public_key_bundle_to_base64}; +use rusqlite::{OptionalExtension, params}; + +use crate::util::db; + +const DESCRIPTOR_LIFETIME_MILLIS: i64 = 30 * 24 * 60 * 60 * 1_000; +const DESCRIPTOR_RENEWAL_MILLIS: i64 = 7 * 24 * 60 * 60 * 1_000; + +#[derive(Default)] +pub struct SqliteNodeDirectory; + +impl SqliteNodeDirectory { + pub fn key_for_authentication_hint( + &self, + hint: u64, + ) -> Result, IdentityError> { + db::with_db(|connection| { + let mut statement = connection.prepare("SELECT node_id FROM node_directory")?; + let nodes = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + for node in nodes { + let node = IotaNodeId::new(node).map_err(|error| { + crate::storage_error::StorageError::Other(error.to_string()) + })?; + if node.mtp_authentication_hint() != hint { + continue; + } + let key = connection + .query_row( + "SELECT public_key FROM node_keys WHERE node_id = ?1 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC LIMIT 1", + params![node.as_str(), now_millis()], + |row| row.get::<_, String>(0), + ) + .optional()?; + return Ok(key); + } + Ok(None) + }) + .map_err(storage_error)? + .map(|key| { + public_key_bundle_from_base64(&key).ok_or_else(|| { + IdentityError::InvalidDescriptor("stored node key is invalid".into()) + }) + }) + .transpose() + } + + pub fn ensure_local_descriptor( + &self, + identity: &LocalNodeIdentity, + direct_endpoints: Vec, + relay_hints: Vec, + now: i64, + ) -> Result { + let current = self.load(identity.node_id())?; + let fields_match = current.as_ref().is_some_and(|current| { + current.direct_endpoints == direct_endpoints && current.relay_hints == relay_hints + }); + let renew = current + .as_ref() + .and_then(|current| current.valid_until) + .is_none_or(|valid_until| valid_until <= now + DESCRIPTOR_RENEWAL_MILLIS); + let revision = current.as_ref().map_or(0, |current| { + if fields_match && !renew { + current.descriptor_revision + } else { + current.descriptor_revision.saturating_add(1) + } + }); + let valid_until = if fields_match && !renew { + current.and_then(|current| current.valid_until) + } else { + Some(now.saturating_add(DESCRIPTOR_LIFETIME_MILLIS)) + }; + let descriptor = NodeDescriptor { + node: identity.node_id().clone(), + public_keys: identity.public_keys(), + direct_endpoints, + relay_hints, + revision, + valid_until, + }; + let signed = SignedNodeDescriptor::sign(descriptor, identity)?; + let verified = signed + .clone() + .verify(current_revision(&self.load(identity.node_id())?), now)?; + self.upsert(&verified)?; + Ok(signed) + } + + pub fn upsert(&self, verified: &VerifiedNodeDescriptor) -> Result<(), IdentityError> { + let descriptor = verified.descriptor(); + let direct_endpoints = serde_json::to_string(&descriptor.direct_endpoints) + .map_err(|error| IdentityError::Storage(error.to_string()))?; + let relay_hints = serde_json::to_string(&descriptor.relay_hints) + .map_err(|error| IdentityError::Storage(error.to_string()))?; + let public_key = public_key_bundle_to_base64(&descriptor.public_keys); + if public_key.is_empty() { + return Err(IdentityError::InvalidDescriptor( + "node public key could not be encoded".into(), + )); + } + db::with_immediate_transaction(|transaction| { + let current = transaction + .query_row( + "SELECT n.descriptor_revision, n.last_resolved_at, n.descriptor_valid_until, n.direct_endpoints, n.relay_hints, (SELECT public_key FROM node_keys WHERE node_id = n.node_id AND source_revision = n.descriptor_revision LIMIT 1) FROM node_directory n WHERE n.node_id = ?1", + [descriptor.node.as_str()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, Option>(5)?)), + ) + .optional()?; + if let Some((revision, resolved_at, valid_until, current_endpoints, current_relays, current_key)) = current { + if revision > descriptor.revision + || (revision == descriptor.revision && resolved_at > verified.resolved_at()) + { + return Err(crate::storage_error::StorageError::Other( + "stale node descriptor update was rejected".into(), + )); + } + if revision == descriptor.revision + && (valid_until != descriptor.valid_until + || current_endpoints != direct_endpoints + || current_relays != relay_hints + || current_key.as_deref() != Some(public_key.as_str())) + { + return Err(crate::storage_error::StorageError::Other( + "conflicting node descriptor revision was rejected".into(), + )); + } + } + transaction.execute( + r#"INSERT INTO node_directory ( + node_id, descriptor_revision, descriptor_valid_until, + direct_endpoints, relay_hints, last_resolved_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(node_id) DO UPDATE SET + descriptor_revision = excluded.descriptor_revision, + descriptor_valid_until = excluded.descriptor_valid_until, + direct_endpoints = excluded.direct_endpoints, + relay_hints = excluded.relay_hints, + last_resolved_at = excluded.last_resolved_at"#, + params![ + descriptor.node.as_str(), + descriptor.revision, + descriptor.valid_until, + direct_endpoints, + relay_hints, + verified.resolved_at(), + ], + )?; + transaction.execute( + "UPDATE node_keys SET valid_until = MIN(COALESCE(valid_until, ?2), ?2) WHERE node_id = ?1", + params![descriptor.node.as_str(), verified.resolved_at()], + )?; + transaction.execute( + r#"INSERT INTO node_keys ( + node_id, public_key, valid_from, valid_until, source_revision + ) VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(node_id, public_key) DO UPDATE SET + valid_until = excluded.valid_until, + source_revision = excluded.source_revision"#, + params![ + descriptor.node.as_str(), + public_key, + verified.resolved_at(), + descriptor.valid_until, + descriptor.revision, + ], + )?; + Ok(()) + }) + .map_err(storage_error) + } + + pub fn pin_endpoint_record( + &self, + endpoint: &AuthorityLocator, + node: &IotaNodeId, + now: i64, + ) -> Result<(), IdentityError> { + db::with_db(|connection| { + let current = connection + .query_row( + "SELECT node_id FROM node_endpoint_pins WHERE endpoint = ?1", + [endpoint.as_str()], + |row| row.get::<_, String>(0), + ) + .optional()?; + if current.as_deref().is_some_and(|current| current != node.as_str()) { + return Err(crate::storage_error::StorageError::Other( + "endpoint node identity changed from its TOFU pin".into(), + )); + } + connection.execute( + "INSERT OR IGNORE INTO node_endpoint_pins (endpoint, node_id, pinned_at) VALUES (?1, ?2, ?3)", + params![endpoint.as_str(), node.as_str(), now], + )?; + Ok(()) + }) + .map_err(storage_error) + } + + pub fn pinned_node_record( + &self, + endpoint: &AuthorityLocator, + ) -> Result, IdentityError> { + db::with_db(|connection| { + connection + .query_row( + "SELECT node_id FROM node_endpoint_pins WHERE endpoint = ?1", + [endpoint.as_str()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(Into::into) + }) + .map_err(storage_error)? + .map(IotaNodeId::new) + .transpose() + } + + pub fn pin_endpoint_certificate_record( + &self, + endpoint: &AuthorityLocator, + certificate: &[u8], + ) -> Result<(), IdentityError> { + if certificate.is_empty() { + return Err(IdentityError::InvalidDescriptor( + "endpoint TLS certificate is empty".into(), + )); + } + db::with_db(|connection| { + let current = connection + .query_row( + "SELECT tls_certificate FROM node_endpoint_pins WHERE endpoint = ?1", + [endpoint.as_str()], + |row| row.get::<_, Option>>(0), + ) + .optional()? + .ok_or_else(|| { + crate::storage_error::StorageError::Other( + "endpoint must be pinned before its TLS certificate".into(), + ) + })?; + if current + .as_deref() + .is_some_and(|current| current != certificate) + { + return Err(crate::storage_error::StorageError::Other( + "endpoint TLS certificate changed from its TOFU pin".into(), + )); + } + connection.execute( + "UPDATE node_endpoint_pins SET tls_certificate = COALESCE(tls_certificate, ?2) WHERE endpoint = ?1", + params![endpoint.as_str(), certificate], + )?; + Ok(()) + }) + .map_err(storage_error) + } + + pub fn pinned_endpoint_certificate_record( + &self, + endpoint: &AuthorityLocator, + ) -> Result>, IdentityError> { + db::with_db(|connection| { + connection + .query_row( + "SELECT tls_certificate FROM node_endpoint_pins WHERE endpoint = ?1", + [endpoint.as_str()], + |row| row.get::<_, Option>>(0), + ) + .optional() + .map(|certificate| certificate.flatten()) + .map_err(Into::into) + }) + .map_err(storage_error) + } + + pub fn record_successful_endpoint( + &self, + node: &IotaNodeId, + endpoint: &AuthorityLocator, + ) -> Result<(), IdentityError> { + db::with_db(|connection| { + connection.execute( + "UPDATE node_directory SET last_successful_endpoint = ?2 WHERE node_id = ?1", + params![node.as_str(), endpoint.as_str()], + )?; + Ok(()) + }) + .map_err(storage_error) + } + + fn load(&self, node: &IotaNodeId) -> Result, IdentityError> { + db::with_db(|connection| { + let row = connection + .query_row( + "SELECT descriptor_revision, descriptor_valid_until, direct_endpoints, relay_hints, last_successful_endpoint, last_resolved_at FROM node_directory WHERE node_id = ?1", + [node.as_str()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, Option>(4)?, row.get::<_, i64>(5)?)), + ) + .optional()?; + let Some((descriptor_revision, valid_until, direct_endpoints, relay_hints, last_successful_endpoint, resolved_at)) = row else { + return Ok(None); + }; + let mut statement = connection.prepare( + "SELECT public_key FROM node_keys WHERE node_id = ?1 AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) ORDER BY source_revision DESC", + )?; + let encoded = statement + .query_map(params![node.as_str(), now_millis()], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(Some((descriptor_revision, valid_until, direct_endpoints, relay_hints, last_successful_endpoint, resolved_at, encoded))) + }) + .map_err(storage_error)? + .map(|(descriptor_revision, valid_until, direct_endpoints, relay_hints, last_successful_endpoint, resolved_at, encoded)| { + Ok(ResolvedNodeIdentity { + node: node.clone(), + public_keys: encoded + .into_iter() + .map(|key| public_key_bundle_from_base64(&key).ok_or_else(|| IdentityError::InvalidDescriptor("stored node key is invalid".into()))) + .collect::, _>>()?, + descriptor_revision, + valid_until, + direct_endpoints: serde_json::from_str(&direct_endpoints).map_err(|error| IdentityError::Storage(error.to_string()))?, + relay_hints: serde_json::from_str(&relay_hints).map_err(|error| IdentityError::Storage(error.to_string()))?, + last_successful_endpoint: last_successful_endpoint.map(AuthorityLocator::new).transpose()?, + resolved_at, + }) + }) + .transpose() + } +} + +#[async_trait] +impl NodeIdentityResolver for SqliteNodeDirectory { + async fn resolve_node(&self, node: &IotaNodeId) -> Result { + let resolved = self.load(node)?.ok_or(IdentityError::NotFound)?; + if resolved + .valid_until + .is_some_and(|valid_until| valid_until <= now_millis()) + { + return Err(IdentityError::Unavailable( + "cached node descriptor is expired".into(), + )); + } + Ok(resolved) + } +} + +impl iota_identity::NodeDirectory for SqliteNodeDirectory { + fn upsert_node_descriptor( + &self, + descriptor: &VerifiedNodeDescriptor, + ) -> Result<(), IdentityError> { + self.upsert(descriptor) + } + + fn pin_endpoint( + &self, + endpoint: &AuthorityLocator, + node: &IotaNodeId, + now: i64, + ) -> Result<(), IdentityError> { + self.pin_endpoint_record(endpoint, node, now) + } + + fn pinned_node( + &self, + endpoint: &AuthorityLocator, + ) -> Result, IdentityError> { + self.pinned_node_record(endpoint) + } + + fn pin_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + certificate: &[u8], + ) -> Result<(), IdentityError> { + self.pin_endpoint_certificate_record(endpoint, certificate) + } + + fn pinned_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + ) -> Result>, IdentityError> { + self.pinned_endpoint_certificate_record(endpoint) + } + + fn record_successful_endpoint( + &self, + node: &IotaNodeId, + endpoint: &AuthorityLocator, + ) -> Result<(), IdentityError> { + SqliteNodeDirectory::record_successful_endpoint(self, node, endpoint) + } +} + +fn current_revision(node: &Option) -> Option { + node.as_ref().map(|node| node.descriptor_revision) +} + +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +fn storage_error(error: impl std::fmt::Display) -> IdentityError { + IdentityError::Storage(error.to_string()) +} diff --git a/iota-storage/src/users/contact.rs b/iota-storage/src/users/contact.rs index c2d471e..a175d04 100644 --- a/iota-storage/src/users/contact.rs +++ b/iota-storage/src/users/contact.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone)] pub struct Contact { pub user_id: i64, + pub principal: Option, pub user_name: Option, pub created_at: i64, pub last_message_at: Option, @@ -12,6 +13,7 @@ impl Default for Contact { fn default() -> Self { Contact { user_id: 0, + principal: None, user_name: None, created_at: 0, last_message_at: None, @@ -28,7 +30,8 @@ impl Contact { .unwrap_or_default() .as_millis() as i64; Contact { - user_id: user_id, + user_id, + principal: None, user_name: None, created_at, last_message_at: None, @@ -36,6 +39,13 @@ impl Contact { notifications_read_at: 0, } } + + pub fn for_principal(user_id: i64, principal: iota_identity::PrincipalHandle) -> Self { + Self { + principal: Some(principal), + ..Self::new(user_id) + } + } pub fn set_last_message_at(&mut self, p0: i64) { self.last_message_at = Option::from(p0); } diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index a83fcf9..3654793 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -41,6 +41,31 @@ pub fn try_add_user(user: UserProfile) -> Result<(), crate::storage_error::Stora try_add_user_with_credential_origin(user, CredentialOrigin::Local) } +/* Reserve a local identity before account creation. The counter never moves + * backward, so a removed account cannot be assigned its old canonical ID. */ +pub fn allocate_local_user_id(maximum: i64) -> Result { + db::with_immediate_transaction(|tx| { + let next = tx.query_row( + "SELECT next_user_id FROM local_identity_allocator WHERE singleton = 1", + [], + |row| row.get::<_, i64>(0), + )?; + if next < 1 || next > maximum { + return Err(crate::storage_error::StorageError::Other( + "local user ID space is exhausted".into(), + )); + } + let following = next.checked_add(1).ok_or_else(|| { + crate::storage_error::StorageError::Other("local user ID space is exhausted".into()) + })?; + tx.execute( + "UPDATE local_identity_allocator SET next_user_id = ?1 WHERE singleton = 1", + params![following], + )?; + Ok(next) + }) +} + pub fn try_add_user_with_credential_origin( user: UserProfile, credential_origin: CredentialOrigin, @@ -457,15 +482,15 @@ fn purge_database_rows(user_id: i64) -> Result<(), crate::storage_error::Storage params![user_id], )?; tx.execute( - "DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1", + "DELETE FROM pending_relays WHERE relay_signer_id = ?1 OR relay_destination_user_id = ?1 OR signer_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1) OR destination_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1)", params![user_id], )?; tx.execute( - "DELETE FROM relay_replay WHERE EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id AND (relay_inbox.signer_id = ?1 OR relay_inbox.destination_id = ?1))", + "DELETE FROM relay_replay WHERE EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_principal = relay_replay.signer_principal AND relay_inbox.message_id = relay_replay.message_id AND (relay_inbox.signer_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1) OR relay_inbox.destination_id = ?1))", params![user_id], )?; tx.execute( - "DELETE FROM relay_inbox WHERE signer_id = ?1 OR destination_id = ?1", + "DELETE FROM relay_inbox WHERE signer_principal = (SELECT principal_handle FROM hosted_principals WHERE local_user_id = ?1) OR destination_id = ?1", params![user_id], )?; tx.commit()?; diff --git a/iota-storage/src/util/blocked_users.rs b/iota-storage/src/util/blocked_users.rs index 7e22634..23cf8c7 100644 --- a/iota-storage/src/util/blocked_users.rs +++ b/iota-storage/src/util/blocked_users.rs @@ -6,35 +6,48 @@ use rusqlite::{OptionalExtension, params}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BlockMutation { pub blocked_user_id: i64, + pub blocked_principal: iota_identity::PrincipalHandle, pub revision: i64, pub deleted: bool, } -fn validate(user_id: i64, blocked_user_id: i64) -> Result<(), StorageError> { - if user_id <= 0 || blocked_user_id <= 0 || user_id == blocked_user_id { +fn validate( + user_id: i64, + blocked_principal: iota_identity::PrincipalHandle, +) -> Result<(), StorageError> { + if user_id <= 0 || blocked_principal.0 <= 0 { Err(StorageError::Other("invalid block relationship".into())) } else { Ok(()) } } -pub fn block(user_id: i64, blocked_user_id: i64) -> Result { - validate(user_id, blocked_user_id)?; +pub fn block( + user_id: i64, + blocked_principal: iota_identity::PrincipalHandle, +) -> Result { + validate(user_id, blocked_principal)?; db::with_immediate_transaction(|tx| { - let prior = tx.query_row("SELECT id, revision FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", params![user_id, blocked_user_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))).optional()?; + let blocked_user_id = tx.query_row( + "SELECT remote_user_id FROM principals WHERE principal_pk = ?1", + [blocked_principal.0], + |row| row.get::<_, i64>(0), + )?; + let prior = tx.query_row("SELECT id, revision FROM blocked_users WHERE user_id = ?1 AND blocked_principal = ?2", params![user_id, blocked_principal.0], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))).optional()?; if let Some((_, revision)) = prior { return Ok(BlockMutation { blocked_user_id, + blocked_principal, revision, deleted: false, }); } let created_at = sync::now_millis(); - tx.execute("INSERT INTO blocked_users (user_id, blocked_user_id, revision, created_at) VALUES (?1, ?2, 0, ?3)", params![user_id, blocked_user_id, created_at])?; + tx.execute("INSERT INTO blocked_users (user_id, blocked_user_id, blocked_principal, revision, created_at) VALUES (?1, ?2, ?3, 0, ?4)", params![user_id, blocked_user_id, blocked_principal.0, created_at])?; let id = tx.last_insert_rowid(); let revision = sync::record_event( tx, user_id, sync::EntityType::BlockedUser, - id, + blocked_principal.0, sync::Operation::Upsert, )?; tx.execute( @@ -43,41 +56,48 @@ pub fn block(user_id: i64, blocked_user_id: i64) -> Result Result, StorageError> { - validate(user_id, blocked_user_id)?; +pub fn unblock( + user_id: i64, + blocked_principal: iota_identity::PrincipalHandle, +) -> Result, StorageError> { + validate(user_id, blocked_principal)?; db::with_immediate_transaction(|tx| { - let id = tx + let row = tx .query_row( - "SELECT id FROM blocked_users WHERE user_id = ?1 AND blocked_user_id = ?2", - params![user_id, blocked_user_id], - |r| r.get(0), + "SELECT id, blocked_user_id FROM blocked_users WHERE user_id = ?1 AND blocked_principal = ?2", + params![user_id, blocked_principal.0], + |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)), ) .optional()?; - let Some(id) = id else { + let Some((id, blocked_user_id)) = row else { return Ok(None); }; let revision = sync::record_event( tx, user_id, sync::EntityType::BlockedUser, - id, + blocked_principal.0, sync::Operation::Delete, )?; tx.execute("DELETE FROM blocked_users WHERE id = ?1", [id])?; Ok(Some(BlockMutation { blocked_user_id, + blocked_principal, revision, deleted: true, })) }) } pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result { - validate(user_id, candidate_sender_id)?; + if user_id <= 0 || candidate_sender_id <= 0 || user_id == candidate_sender_id { + return Err(StorageError::Other("invalid block relationship".into())); + } db::with_db(|conn| { Ok(conn .query_row( @@ -89,16 +109,42 @@ pub fn is_blocked(user_id: i64, candidate_sender_id: i64) -> Result Result, StorageError> { + +pub fn is_principal_blocked( + user_id: i64, + candidate: iota_identity::PrincipalHandle, +) -> Result { + if user_id <= 0 || candidate.0 <= 0 { + return Err(StorageError::Other("invalid block relationship".into())); + } + db::with_db(|conn| { + Ok(conn + .query_row( + "SELECT 1 FROM blocked_users WHERE user_id = ?1 AND blocked_principal = ?2", + params![user_id, candidate.0], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) +} +pub fn list(user_id: i64) -> Result, StorageError> { if user_id <= 0 { return Err(StorageError::Other("invalid block owner".into())); } db::with_db(|conn| { let mut stmt = conn.prepare( - "SELECT blocked_user_id FROM blocked_users WHERE user_id = ?1 ORDER BY blocked_user_id", + "SELECT blocked_user_id, blocked_principal, revision FROM blocked_users WHERE user_id = ?1 ORDER BY blocked_principal", )?; - stmt.query_map([user_id], |r| r.get(0))? - .collect::, _>>() - .map_err(StorageError::from) + stmt.query_map([user_id], |r| { + Ok(BlockMutation { + blocked_user_id: r.get(0)?, + blocked_principal: iota_identity::PrincipalHandle(r.get(1)?), + revision: r.get(2)?, + deleted: false, + }) + })? + .collect::, _>>() + .map_err(StorageError::from) }) } diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index f4be254..a9d5664 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -50,7 +50,9 @@ impl MessageState { pub struct StoredMessage { pub id: i64, pub external_user: i64, + pub external_principal: Option, pub relay_signer_id: Option, + pub relay_signer_principal: Option, pub relay_message_id: Option, pub message_time: i64, pub authored_at: Option, @@ -74,11 +76,13 @@ pub struct StoredMessage { pub struct NewMessage<'a> { pub relay_signer_id: i64, + pub relay_signer_principal: iota_identity::PrincipalHandle, pub relay_message_id: &'a str, pub authored_at: i64, pub send_time: i64, pub storage_owner: i64, pub external_user: i64, + pub external_principal: iota_identity::PrincipalHandle, pub sent_by_self: bool, pub content: &'a str, pub height: i64, @@ -109,6 +113,26 @@ pub fn edit_message( update_message_content( storage_owner, external_user, + None, + message_time, + editor_id, + new_content, + true, + ) +} + +pub fn edit_message_for_principal( + storage_owner: i64, + external_user: i64, + external_principal: iota_identity::PrincipalHandle, + message_time: i64, + editor_id: i64, + new_content: &str, +) -> Result<(), StorageError> { + update_message_content( + storage_owner, + external_user, + Some(external_principal), message_time, editor_id, new_content, @@ -133,6 +157,31 @@ pub fn apply_remote_edit( update_message_content( storage_owner, external_user, + None, + message_time, + editor_id, + new_content, + false, + ) +} + +pub fn apply_remote_edit_for_principal( + storage_owner: i64, + external_user: i64, + external_principal: iota_identity::PrincipalHandle, + message_time: i64, + editor_id: i64, + new_content: &str, +) -> Result<(), StorageError> { + if editor_id != external_user { + return Err(StorageError::Other( + "Remote editor does not match chat partner".into(), + )); + } + update_message_content( + storage_owner, + external_user, + Some(external_principal), message_time, editor_id, new_content, @@ -143,6 +192,7 @@ pub fn apply_remote_edit( fn update_message_content( storage_owner: i64, external_user: i64, + external_principal: Option, message_time: i64, editor_id: i64, new_content: &str, @@ -153,10 +203,20 @@ fn update_message_content( r#" SELECT id, content, sent_by_self, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + WHERE storage_owner = ?1 + AND message_time = ?3 + AND ( + (?4 IS NULL AND external_user = ?2) + OR external_principal = ?4 + ) ORDER BY id DESC LIMIT 1 "#, - params![storage_owner, external_user, message_time], + params![ + storage_owner, + external_user, + message_time, + external_principal.map(|principal| principal.0) + ], |row| { Ok(( row.get::<_, i64>(0)?, @@ -221,16 +281,35 @@ pub fn hard_delete_message( storage_owner: i64, external_user: i64, message_time: i64, +) -> Result<(), StorageError> { + hard_delete_message_with_principal(storage_owner, external_user, None, message_time) +} + +fn hard_delete_message_with_principal( + storage_owner: i64, + external_user: i64, + external_principal: Option, + message_time: i64, ) -> Result<(), StorageError> { db::with_db(|conn| { let msg_id = conn .query_row( r#" SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + WHERE storage_owner = ?1 + AND message_time = ?3 + AND ( + (?4 IS NULL AND external_user = ?2) + OR external_principal = ?4 + ) ORDER BY id DESC LIMIT 1 "#, - params![storage_owner, external_user, message_time], + params![ + storage_owner, + external_user, + message_time, + external_principal.map(|principal| principal.0) + ], |row| row.get::<_, i64>(0), ) .optional()?; @@ -277,14 +356,14 @@ pub fn remove_message_history_in_tx( storage_owner: i64, message_id: i64, ) -> Result<(), StorageError> { - let message: Option<(i64, i64)> = tx + let message: Option<(i64, Option, i64)> = tx .query_row( - "SELECT external_user, history_deleted FROM messages WHERE id = ?1 AND storage_owner = ?2", + "SELECT external_user, external_principal, history_deleted FROM messages WHERE id = ?1 AND storage_owner = ?2", params![message_id, storage_owner], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; - let Some((external_user, history_deleted)) = message else { + let Some((external_user, external_principal, history_deleted)) = message else { return Ok(()); }; if history_deleted != 0 { @@ -295,7 +374,7 @@ pub fn remove_message_history_in_tx( [message_id], )?; tx.execute("DELETE FROM reactions WHERE message_id = ?1", [message_id])?; - tx.execute("DELETE FROM message_receipts WHERE storage_owner = ?1 AND EXISTS (SELECT 1 FROM messages WHERE id = ?2 AND relay_signer_id = message_receipts.target_signer_id AND relay_message_id = message_receipts.target_message_id)", params![storage_owner, message_id])?; + tx.execute("DELETE FROM message_receipts WHERE storage_owner = ?1 AND EXISTS (SELECT 1 FROM messages WHERE id = ?2 AND relay_signer_principal = message_receipts.target_signer_principal AND relay_message_id = message_receipts.target_message_id)", params![storage_owner, message_id])?; tx.execute("UPDATE messages SET content = '', history_deleted = 1, history_deleted_at = ?2, expires_at = NULL, client_received_at = NULL, client_received_recorded_at = NULL, read_at = NULL, read_recorded_at = NULL WHERE id = ?1", params![message_id, sync::now_millis()])?; sync::record_event( tx, @@ -304,7 +383,12 @@ pub fn remove_message_history_in_tx( message_id, Operation::Delete, )?; - update_contact_last_message_in_tx(tx, storage_owner, external_user)?; + update_contact_last_message_in_tx( + tx, + storage_owner, + external_user, + external_principal.map(iota_identity::PrincipalHandle), + )?; Ok(()) } @@ -312,8 +396,12 @@ fn update_contact_last_message_in_tx( tx: &Transaction<'_>, storage_owner: i64, external_user: i64, + external_principal: Option, ) -> Result<(), StorageError> { - tx.execute("UPDATE contacts SET last_message_at = (SELECT MAX(COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, message_time)) FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0) WHERE storage_owner = ?1 AND user_id = ?2", params![storage_owner, external_user])?; + tx.execute( + "UPDATE contacts SET last_message_at = (SELECT MAX(COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, message_time)) FROM messages WHERE storage_owner = ?1 AND ((?3 IS NULL AND external_user = ?2) OR external_principal = ?3) AND deleted_by_external = 0 AND history_deleted = 0) WHERE storage_owner = ?1 AND ((?3 IS NULL AND user_id = ?2) OR principal_handle = ?3)", + params![storage_owner, external_user, external_principal.map(|principal| principal.0)], + )?; Ok(()) } @@ -322,15 +410,17 @@ pub fn purge_message_in_tx( storage_owner: i64, message_id: i64, ) -> Result<(), StorageError> { - let (external_user, relay_signer_id, relay_message_id) = tx.query_row( - "SELECT external_user, relay_signer_id, relay_message_id FROM messages WHERE id = ?1 AND storage_owner = ?2", + let (external_user, external_principal, relay_signer_principal, relay_message_id) = tx.query_row( + "SELECT external_user, external_principal, relay_signer_principal, relay_message_id FROM messages WHERE id = ?1 AND storage_owner = ?2", params![message_id, storage_owner], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?)), + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?)), )?; - if let (Some(relay_signer_id), Some(relay_message_id)) = (relay_signer_id, relay_message_id) { + if let (Some(relay_signer_principal), Some(relay_message_id)) = + (relay_signer_principal, relay_message_id) + { tx.execute( - "DELETE FROM message_receipts WHERE storage_owner = ?1 AND target_signer_id = ?2 AND target_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], + "DELETE FROM message_receipts WHERE storage_owner = ?1 AND target_signer_principal = ?2 AND target_message_id = ?3", + params![storage_owner, relay_signer_principal, relay_message_id], )?; } tx.execute( @@ -346,7 +436,12 @@ pub fn purge_message_in_tx( message_id, Operation::Delete, )?; - update_contact_last_message_in_tx(tx, storage_owner, external_user)?; + update_contact_last_message_in_tx( + tx, + storage_owner, + external_user, + external_principal.map(iota_identity::PrincipalHandle), + )?; Ok(()) } @@ -356,8 +451,50 @@ pub fn delete_message( external_user: i64, message_time: i64, ) -> Result<(), StorageError> { - match ensure_message_direction(storage_owner, external_user, message_time, true) { - Ok(()) => hard_delete_message(storage_owner, external_user, message_time), + delete_message_with_principal(storage_owner, external_user, None, message_time, true) +} + +pub fn delete_message_for_principal( + storage_owner: i64, + external_user: i64, + external_principal: iota_identity::PrincipalHandle, + message_time: i64, +) -> Result<(), StorageError> { + delete_message_with_principal( + storage_owner, + external_user, + Some(external_principal), + message_time, + true, + ) +} + +fn delete_message_with_principal( + storage_owner: i64, + external_user: i64, + external_principal: Option, + message_time: i64, + sent_by_self: bool, +) -> Result<(), StorageError> { + match ensure_message_direction( + storage_owner, + external_user, + external_principal, + message_time, + sent_by_self, + ) { + Ok(()) if sent_by_self => hard_delete_message_with_principal( + storage_owner, + external_user, + external_principal, + message_time, + ), + Ok(()) => flag_deleted_by_external_with_principal( + storage_owner, + external_user, + external_principal, + message_time, + ), Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()), Err(error) => Err(error), } @@ -375,16 +512,34 @@ pub fn apply_remote_delete( "Remote sender does not match chat partner".into(), )); } - match ensure_message_direction(storage_owner, external_user, message_time, false) { - Ok(()) => flag_deleted_by_external(storage_owner, external_user, message_time), - Err(StorageError::Db(rusqlite::Error::QueryReturnedNoRows)) => Ok(()), - Err(error) => Err(error), + delete_message_with_principal(storage_owner, external_user, None, message_time, false) +} + +pub fn apply_remote_delete_for_principal( + storage_owner: i64, + external_user: i64, + external_principal: iota_identity::PrincipalHandle, + message_time: i64, + sender_id: i64, +) -> Result<(), StorageError> { + if sender_id != external_user { + return Err(StorageError::Other( + "Remote sender does not match chat partner".into(), + )); } + delete_message_with_principal( + storage_owner, + external_user, + Some(external_principal), + message_time, + false, + ) } fn ensure_message_direction( storage_owner: i64, external_user: i64, + external_principal: Option, message_time: i64, expected_sent_by_self: bool, ) -> Result<(), StorageError> { @@ -392,10 +547,20 @@ fn ensure_message_direction( let sent_by_self: i64 = conn.query_row( r#" SELECT sent_by_self FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + WHERE storage_owner = ?1 + AND message_time = ?3 + AND ( + (?4 IS NULL AND external_user = ?2) + OR external_principal = ?4 + ) ORDER BY id DESC LIMIT 1 "#, - params![storage_owner, external_user, message_time], + params![ + storage_owner, + external_user, + message_time, + external_principal.map(|principal| principal.0) + ], |row| row.get(0), )?; if (sent_by_self != 0) != expected_sent_by_self { @@ -415,6 +580,15 @@ pub fn flag_deleted_by_external( storage_owner: i64, external_user: i64, message_time: i64, +) -> Result<(), StorageError> { + flag_deleted_by_external_with_principal(storage_owner, external_user, None, message_time) +} + +fn flag_deleted_by_external_with_principal( + storage_owner: i64, + external_user: i64, + external_principal: Option, + message_time: i64, ) -> Result<(), StorageError> { db::with_db(|conn| { let tx = conn.unchecked_transaction()?; @@ -422,14 +596,28 @@ pub fn flag_deleted_by_external( r#" UPDATE messages SET deleted_by_external = 1 - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + WHERE storage_owner = ?1 + AND message_time = ?3 + AND ( + (?4 IS NULL AND external_user = ?2) + OR external_principal = ?4 + ) "#, - params![storage_owner, external_user, message_time], + params![ + storage_owner, + external_user, + message_time, + external_principal.map(|principal| principal.0) + ], )?; if affected == 0 { return Err(StorageError::Other("Message not found".into())); } - let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?; + let msg_id: i64 = tx.query_row( + "SELECT id FROM messages WHERE storage_owner = ?1 AND message_time = ?3 AND ((?4 IS NULL AND external_user = ?2) OR external_principal = ?4) ORDER BY id DESC LIMIT 1", + params![storage_owner, external_user, message_time, external_principal.map(|principal| principal.0)], + |r| r.get(0), + )?; sync::record_event( &tx, storage_owner, @@ -437,7 +625,7 @@ pub fn flag_deleted_by_external( msg_id, Operation::Delete, )?; - update_contact_last_message_in_tx(&tx, storage_owner, external_user)?; + update_contact_last_message_in_tx(&tx, storage_owner, external_user, external_principal)?; tx.commit()?; Ok(()) }) @@ -490,15 +678,65 @@ pub fn add_reaction( message_time: i64, user_id: i64, reaction: &str, +) -> Result<(), StorageError> { + add_reaction_with_principal( + storage_owner, + external_user, + None, + message_time, + user_id, + None, + reaction, + ) +} + +pub fn add_reaction_for_principal( + storage_owner: i64, + external_user: i64, + external_principal: iota_identity::PrincipalHandle, + message_time: i64, + user_id: i64, + user_principal: iota_identity::PrincipalHandle, + reaction: &str, +) -> Result<(), StorageError> { + add_reaction_with_principal( + storage_owner, + external_user, + Some(external_principal), + message_time, + user_id, + Some(user_principal), + reaction, + ) +} + +fn add_reaction_with_principal( + storage_owner: i64, + external_user: i64, + external_principal: Option, + message_time: i64, + user_id: i64, + user_principal: Option, + reaction: &str, ) -> Result<(), StorageError> { db::with_immediate_transaction(|tx| { let (msg_id, history_deleted): (i64, i64) = tx.query_row( r#" SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + WHERE storage_owner = ?1 + AND message_time = ?3 + AND ( + (?4 IS NULL AND external_user = ?2) + OR external_principal = ?4 + ) ORDER BY id DESC LIMIT 1 "#, - params![storage_owner, external_user, message_time], + params![ + storage_owner, + external_user, + message_time, + external_principal.map(|principal| principal.0) + ], |row| Ok((row.get(0)?, row.get(1)?)), )?; if history_deleted != 0 { @@ -528,10 +766,16 @@ pub fn add_reaction( let inserted = tx.execute( r#" - INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) - VALUES (?1, ?2, ?3, ?4) + INSERT OR IGNORE INTO reactions (message_id, user_id, user_principal, reaction, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) "#, - params![msg_id, user_id, reaction, now], + params![ + msg_id, + user_id, + user_principal.map(|principal| principal.0), + reaction, + now + ], )?; if inserted > 0 { sync::record_event( @@ -554,15 +798,65 @@ pub fn remove_reaction( message_time: i64, user_id: i64, reaction: &str, +) -> Result<(), StorageError> { + remove_reaction_with_principal( + storage_owner, + external_user, + None, + message_time, + user_id, + None, + reaction, + ) +} + +pub fn remove_reaction_for_principal( + storage_owner: i64, + external_user: i64, + external_principal: iota_identity::PrincipalHandle, + message_time: i64, + user_id: i64, + user_principal: iota_identity::PrincipalHandle, + reaction: &str, +) -> Result<(), StorageError> { + remove_reaction_with_principal( + storage_owner, + external_user, + Some(external_principal), + message_time, + user_id, + Some(user_principal), + reaction, + ) +} + +fn remove_reaction_with_principal( + storage_owner: i64, + external_user: i64, + external_principal: Option, + message_time: i64, + user_id: i64, + user_principal: Option, + reaction: &str, ) -> Result<(), StorageError> { db::with_db(|conn| { let (msg_id, history_deleted): (i64, i64) = conn.query_row( r#" SELECT id, history_deleted FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 + WHERE storage_owner = ?1 + AND message_time = ?3 + AND ( + (?4 IS NULL AND external_user = ?2) + OR external_principal = ?4 + ) ORDER BY id DESC LIMIT 1 "#, - params![storage_owner, external_user, message_time], + params![ + storage_owner, + external_user, + message_time, + external_principal.map(|principal| principal.0) + ], |row| Ok((row.get(0)?, row.get(1)?)), )?; if history_deleted != 0 { @@ -571,8 +865,13 @@ pub fn remove_reaction( let tx = conn.unchecked_transaction()?; tx.execute( - "DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3", - params![msg_id, user_id, reaction], + "DELETE FROM reactions WHERE message_id = ?1 AND ((?3 IS NULL AND user_id = ?2) OR user_principal = ?3) AND reaction = ?4", + params![ + msg_id, + user_id, + user_principal.map(|principal| principal.0), + reaction + ], )?; sync::record_event( &tx, @@ -589,11 +888,13 @@ pub fn remove_reaction( pub fn add_message(message: NewMessage<'_>) -> Result { let NewMessage { relay_signer_id, + relay_signer_principal, relay_message_id, authored_at, send_time, storage_owner, external_user, + external_principal, sent_by_self, content, height, @@ -620,8 +921,9 @@ pub fn add_message(message: NewMessage<'_>) -> Result { INSERT INTO messages ( storage_owner, external_user, message_time, content, sent_by_self, message_state, height, key_version, reply_to, relay_signer_id, relay_message_id, - authored_at, origin_iota_received_at, destination_iota_received_at, stored_at, expires_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + authored_at, origin_iota_received_at, destination_iota_received_at, stored_at, + expires_at, external_principal, relay_signer_principal + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) "#, params![ storage_owner, @@ -640,6 +942,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result { destination_iota_received_at, stored_at, expires_at, + external_principal.0, + relay_signer_principal.0, ], )?; let msg_id = tx.last_insert_rowid(); @@ -650,7 +954,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result { msg_id, Operation::Upsert, )?; - let mut contact = crate::users::contact::Contact::new(external_user); + let mut contact = + crate::users::contact::Contact::for_principal(external_user, external_principal); contact.set_last_message_at( destination_iota_received_at .or(origin_iota_received_at) @@ -659,8 +964,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result { crate::util::chats_util::upsert_contact(&tx, storage_owner, &contact)?; if !sent_by_self { tx.execute( - "UPDATE contacts SET notifications = CASE WHEN notifications < 9223372036854775807 THEN notifications + 1 ELSE notifications END WHERE storage_owner = ?1 AND user_id = ?2", - params![storage_owner, external_user], + "UPDATE contacts SET notifications = CASE WHEN notifications < 9223372036854775807 THEN notifications + 1 ELSE notifications END WHERE storage_owner = ?1 AND principal_handle = ?2", + params![storage_owner, external_principal.0], )?; } tx.commit()?; @@ -670,7 +975,8 @@ pub fn add_message(message: NewMessage<'_>) -> Result { pub fn change_message_state_by_relay_id( storage_owner: i64, - relay_signer_id: i64, + _relay_signer_id: i64, + relay_signer_principal: iota_identity::PrincipalHandle, relay_message_id: &str, new_state: MessageState, ) -> Result<(), StorageError> { @@ -678,8 +984,8 @@ pub fn change_message_state_by_relay_id( let tx = conn.unchecked_transaction()?; let Some((msg_id, current)) = tx .query_row( - "SELECT id, message_state FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], + "SELECT id, message_state FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", + params![storage_owner, relay_signer_principal.0, relay_message_id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) .optional()? @@ -706,8 +1012,10 @@ pub fn change_message_state_by_relay_id( pub fn record_message_receipt( storage_owner: i64, target_signer_id: i64, + target_signer_principal: iota_identity::PrincipalHandle, target_message_id: &str, receipt_signer_id: i64, + receipt_signer_principal: iota_identity::PrincipalHandle, receipt_message_id: &str, receipt_type: MessageState, event_at: i64, @@ -720,17 +1028,17 @@ pub fn record_message_receipt( }; db::with_db(|conn| { let tx = conn.unchecked_transaction()?; - let Some((message_id, external_user, authored_at, history_deleted)) = tx + let Some((message_id, external_principal, authored_at, history_deleted)) = tx .query_row( - "SELECT id, external_user, authored_at, history_deleted FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, target_signer_id, target_message_id], + "SELECT id, external_principal, authored_at, history_deleted FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", + params![storage_owner, target_signer_principal.0, target_message_id], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option>(2)?, row.get::<_, i64>(3)?)), ) .optional()? else { return Err(StorageError::Other("message receipt target was not found".into())); }; - if external_user != receipt_signer_id { + if external_principal != receipt_signer_principal.0 { return Err(StorageError::Other( "message receipt signer is not the chat partner".into(), )); @@ -746,8 +1054,8 @@ pub fn record_message_receipt( return Ok(()); } tx.execute( - "INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![storage_owner, target_signer_id, target_message_id, receipt_signer_id, receipt_message_id, receipt_type, event_at, recorded_at], + "INSERT OR IGNORE INTO message_receipts (storage_owner, target_signer_id, target_signer_principal, target_message_id, receipt_signer_id, receipt_signer_principal, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![storage_owner, target_signer_id, target_signer_principal.0, target_message_id, receipt_signer_id, receipt_signer_principal.0, receipt_message_id, receipt_type, event_at, recorded_at], )?; let (state_column, recorded_column) = if receipt_type == "read" { ("read_at", "read_recorded_at") @@ -780,7 +1088,7 @@ pub fn record_message_receipt( pub fn record_destination_iota_received( storage_owner: i64, - relay_signer_id: i64, + relay_signer_principal: iota_identity::PrincipalHandle, relay_message_id: &str, accepted_at: i64, ) -> Result<(), StorageError> { @@ -788,8 +1096,8 @@ pub fn record_destination_iota_received( let tx = conn.unchecked_transaction()?; let Some(message_id) = tx .query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], + "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", + params![storage_owner, relay_signer_principal.0, relay_message_id], |row| row.get::<_, i64>(0), ) .optional()? @@ -814,7 +1122,7 @@ pub fn record_destination_iota_received( pub fn record_delivery_failure( storage_owner: i64, - relay_signer_id: i64, + relay_signer_principal: iota_identity::PrincipalHandle, relay_message_id: &str, failure: &str, failed_at: i64, @@ -823,8 +1131,8 @@ pub fn record_delivery_failure( let tx = conn.unchecked_transaction()?; let Some(message_id) = tx .query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - params![storage_owner, relay_signer_id, relay_message_id], + "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", + params![storage_owner, relay_signer_principal.0, relay_message_id], |row| row.get::<_, i64>(0), ) .optional()? @@ -957,6 +1265,20 @@ pub fn get_messages( external_user: i64, loaded_messages: i64, amount: i64, +) -> Result, StorageError> { + let Some(principal) = crate::util::chats_util::get_user(storage_owner, external_user)? + .and_then(|contact| contact.principal) + else { + return Ok(Vec::new()); + }; + get_messages_for_principal(storage_owner, principal, loaded_messages, amount) +} + +pub fn get_messages_for_principal( + storage_owner: i64, + external_principal: iota_identity::PrincipalHandle, + loaded_messages: i64, + amount: i64, ) -> Result, StorageError> { if amount <= 0 || loaded_messages < 0 { return Ok(Vec::new()); @@ -969,21 +1291,25 @@ pub fn get_messages( origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, - reply_to, edited_count + reply_to, edited_count, external_user, relay_signer_principal FROM messages - WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0 + WHERE storage_owner = ?1 AND external_principal = ?2 AND deleted_by_external = 0 AND history_deleted = 0 ORDER BY COALESCE(destination_iota_received_at, origin_iota_received_at, authored_at, id) DESC, id DESC LIMIT ?3 OFFSET ?4 "#, )?; let rows = stmt.query_map( - params![storage_owner, external_user, amount, loaded_messages], + params![storage_owner, external_principal.0, amount, loaded_messages], |row| { Ok(StoredMessage { id: row.get(0)?, - external_user, + external_user: row.get(20)?, + external_principal: Some(external_principal), relay_signer_id: row.get(1)?, + relay_signer_principal: row + .get::<_, Option>(21)? + .map(iota_identity::PrincipalHandle), relay_message_id: row.get(2)?, message_time: row.get(3)?, authored_at: row.get(4)?, @@ -1026,6 +1352,7 @@ pub fn get_message( storage_owner: i64, message_time: i64, external_user: Option, + external_principal: Option, ) -> Result, StorageError> { db::with_db(|conn| { let mut stmt = conn.prepare( @@ -1034,43 +1361,62 @@ pub fn get_message( origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, - reply_to, edited_count, external_user + reply_to, edited_count, external_user, external_principal, + relay_signer_principal FROM messages WHERE storage_owner = ?1 AND message_time = ?2 AND deleted_by_external = 0 AND history_deleted = 0 - AND (?3 IS NULL OR external_user = ?3) + AND ( + ?3 IS NULL + OR (?4 IS NULL AND external_user = ?3) + OR external_principal = ?4 + ) ORDER BY id DESC "#, )?; - let rows = stmt.query_map(params![storage_owner, message_time, external_user], |row| { - Ok(StoredMessage { - id: row.get(0)?, - relay_signer_id: row.get(1)?, - relay_message_id: row.get(2)?, - message_time: row.get(3)?, - authored_at: row.get(4)?, - origin_iota_received_at: row.get(5)?, - destination_iota_received_at: row.get(6)?, - client_received_at: row.get(7)?, - client_received_recorded_at: row.get(8)?, - read_at: row.get(9)?, - read_recorded_at: row.get(10)?, - delivery_failed_at: row.get(11)?, - delivery_failure: row.get(12)?, - content: row.get(13)?, - sent_by_self: row.get::<_, i64>(14)? != 0, - message_state: row.get(15)?, - height: row.get(16)?, - key_version: row.get(17)?, - reply_to: row.get(18)?, - edited: row.get::<_, i64>(19)? > 0, - external_user: row.get(20)?, - reactions: Vec::new(), - }) - })?; + let rows = stmt.query_map( + params![ + storage_owner, + message_time, + external_user, + external_principal.map(|principal| principal.0) + ], + |row| { + Ok(StoredMessage { + id: row.get(0)?, + relay_signer_id: row.get(1)?, + relay_signer_principal: row + .get::<_, Option>(22)? + .map(iota_identity::PrincipalHandle), + relay_message_id: row.get(2)?, + message_time: row.get(3)?, + authored_at: row.get(4)?, + origin_iota_received_at: row.get(5)?, + destination_iota_received_at: row.get(6)?, + client_received_at: row.get(7)?, + client_received_recorded_at: row.get(8)?, + read_at: row.get(9)?, + read_recorded_at: row.get(10)?, + delivery_failed_at: row.get(11)?, + delivery_failure: row.get(12)?, + content: row.get(13)?, + sent_by_self: row.get::<_, i64>(14)? != 0, + message_state: row.get(15)?, + height: row.get(16)?, + key_version: row.get(17)?, + reply_to: row.get(18)?, + edited: row.get::<_, i64>(19)? > 0, + external_user: row.get(20)?, + external_principal: row + .get::<_, Option>(21)? + .map(iota_identity::PrincipalHandle), + reactions: Vec::new(), + }) + }, + )?; let messages: Vec = rows.collect::>()?; if messages.is_empty() { @@ -1079,7 +1425,7 @@ pub fn get_message( if external_user.is_none() && messages .iter() - .map(|message| message.external_user) + .filter_map(|message| message.external_principal) .collect::>() .len() > 1 @@ -1099,7 +1445,18 @@ pub fn get_message_with_offset( external_user: i64, message_time: i64, ) -> Result, StorageError> { - let Some(message) = get_message(storage_owner, message_time, Some(external_user))? else { + let Some(external_principal) = crate::util::chats_util::get_user(storage_owner, external_user)? + .and_then(|contact| contact.principal) + else { + return Ok(None); + }; + let Some(message) = get_message( + storage_owner, + message_time, + Some(external_user), + Some(external_principal), + )? + else { return Ok(None); }; let offset = db::with_db(|conn| { @@ -1108,7 +1465,7 @@ pub fn get_message_with_offset( SELECT COUNT(*) FROM messages WHERE storage_owner = ?1 - AND external_user = ?2 + AND external_principal = ?2 AND deleted_by_external = 0 AND history_deleted = 0 AND ( @@ -1120,7 +1477,7 @@ pub fn get_message_with_offset( "#, params![ storage_owner, - external_user, + external_principal.0, message.destination_iota_received_at.or(message.origin_iota_received_at).or(message.authored_at), message.id ], @@ -1142,13 +1499,19 @@ pub fn get_messages_by_ids( // A journal id uniquely identifies a row. Load all messages for this owner and retain only // those ids; this keeps reaction hydration identical to normal message loading. db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?; + let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, reply_to, edited_count, external_user, external_principal, relay_signer_principal FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?; let rows = stmt.query_map([storage_owner], |row| { let external_user: i64 = row.get(20)?; Ok(StoredMessage { id: row.get(0)?, external_user, + external_principal: row + .get::<_, Option>(21)? + .map(iota_identity::PrincipalHandle), relay_signer_id: row.get(1)?, + relay_signer_principal: row + .get::<_, Option>(22)? + .map(iota_identity::PrincipalHandle), relay_message_id: row.get(2)?, message_time: row.get(3)?, authored_at: row.get(4)?, diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index 9257f76..64d4fda 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -9,14 +9,31 @@ pub(crate) fn upsert_contact( storage_owner: i64, contact: &Contact, ) -> Result<(), StorageError> { + let principal = match contact.principal { + Some(principal) => principal, + None => iota_identity::PrincipalHandle(tx.query_row( + r#" + SELECT p.principal_pk + FROM principals p + WHERE p.authority_id = COALESCE( + (SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1), + 'omega:central' + ) + AND p.remote_user_id = ?1 + "#, + [contact.user_id], + |row| row.get(0), + )?), + }; tx.execute( r#" INSERT INTO contacts ( - storage_owner, user_id, user_name, created_at, last_message_at, + storage_owner, user_id, principal_handle, user_name, created_at, last_message_at, notifications, notifications_read_at ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(storage_owner, user_id) DO UPDATE SET + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(storage_owner, principal_handle) DO UPDATE SET + user_id = excluded.user_id, user_name = COALESCE(excluded.user_name, contacts.user_name), created_at = MIN(contacts.created_at, excluded.created_at), last_message_at = CASE @@ -28,6 +45,7 @@ pub(crate) fn upsert_contact( params![ storage_owner, contact.user_id, + principal.0, contact.user_name, contact.created_at, contact.last_message_at, @@ -39,63 +57,112 @@ pub(crate) fn upsert_contact( tx, storage_owner, EntityType::Contact, - contact.user_id, + principal.0, Operation::Upsert, )?; Ok(()) } -pub fn has_user(storage_owner: i64, user_id: i64) -> Result { +pub fn has_principal( + storage_owner: i64, + principal: iota_identity::PrincipalHandle, +) -> Result { db::with_db(|conn| { Ok(conn.query_row( - "SELECT EXISTS(SELECT 1 FROM contacts WHERE storage_owner = ?1 AND user_id = ?2)", - params![storage_owner, user_id], + "SELECT EXISTS(SELECT 1 FROM contacts WHERE storage_owner = ?1 AND principal_handle = ?2)", + params![storage_owner, principal.0], |row| row.get(0), )?) }) } +pub fn has_user(storage_owner: i64, user_id: i64) -> Result { + get_user(storage_owner, user_id).map(|contact| contact.is_some()) +} + pub fn mod_user(storage_owner: i64, contact: &Contact) -> Result<(), StorageError> { db::with_immediate_transaction(|tx| upsert_contact(tx, storage_owner, contact)) } -pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Result, StorageError> { - if ids.is_empty() { +pub fn get_users_by_principals( + storage_owner: i64, + principals: &[iota_identity::PrincipalHandle], +) -> Result, StorageError> { + if principals.is_empty() { return Ok(Vec::new()); } - let wanted: std::collections::HashSet = ids.iter().copied().collect(); + let wanted: std::collections::HashSet = + principals.iter().copied().collect(); Ok(get_users(storage_owner)? .into_iter() - .filter(|contact| wanted.contains(&contact.user_id)) + .filter(|contact| { + contact + .principal + .is_some_and(|principal| wanted.contains(&principal)) + }) .collect()) } pub fn get_user(storage_owner: i64, user_id: i64) -> Result, StorageError> { db::with_db(|conn| { - match conn.query_row( + let mut statement = conn.prepare( r#" - SELECT user_id, user_name, created_at, last_message_at, + SELECT user_id, principal_handle, user_name, created_at, last_message_at, notifications, notifications_read_at FROM contacts WHERE storage_owner = ?1 AND user_id = ?2 - LIMIT 1 + LIMIT 2 "#, - params![storage_owner, user_id], - |r| { + )?; + let contacts = statement + .query_map(params![storage_owner, user_id], |r| { Ok(Contact { user_id: r.get(0)?, - user_name: r.get(1)?, - created_at: r.get(2)?, - last_message_at: r.get(3)?, - notifications: r.get(4)?, - notifications_read_at: r.get(5)?, + principal: r + .get::<_, Option>(1)? + .map(iota_identity::PrincipalHandle), + user_name: r.get(2)?, + created_at: r.get(3)?, + last_message_at: r.get(4)?, + notifications: r.get(5)?, + notifications_read_at: r.get(6)?, + }) + })? + .collect::, _>>()?; + match contacts.as_slice() { + [contact] => Ok(Some(contact.clone())), + _ => Ok(None), + } + }) +} + +pub fn get_user_by_principal( + storage_owner: i64, + principal: iota_identity::PrincipalHandle, +) -> Result, StorageError> { + db::with_db(|conn| { + conn.query_row( + r#" + SELECT user_id, principal_handle, user_name, created_at, last_message_at, + notifications, notifications_read_at + FROM contacts + WHERE storage_owner = ?1 AND principal_handle = ?2 + "#, + params![storage_owner, principal.0], + |row| { + Ok(Contact { + user_id: row.get(0)?, + principal: Some(iota_identity::PrincipalHandle(row.get(1)?)), + user_name: row.get(2)?, + created_at: row.get(3)?, + last_message_at: row.get(4)?, + notifications: row.get(5)?, + notifications_read_at: row.get(6)?, }) }, - ) { - Ok(c) => Ok(Some(c)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } + ) + .optional() + .map_err(Into::into) }) } @@ -103,7 +170,7 @@ pub fn get_users(storage_owner: i64) -> Result, StorageError> { db::with_db(|conn| { let mut stmt = conn.prepare( r#" - SELECT user_id, user_name, created_at, last_message_at, + SELECT user_id, principal_handle, user_name, created_at, last_message_at, notifications, notifications_read_at FROM contacts WHERE storage_owner = ?1 @@ -117,11 +184,14 @@ pub fn get_users(storage_owner: i64) -> Result, StorageError> { let rows = stmt.query_map(params![storage_owner], |r| { Ok(Contact { user_id: r.get(0)?, - user_name: r.get(1)?, - created_at: r.get(2)?, - last_message_at: r.get(3)?, - notifications: r.get(4)?, - notifications_read_at: r.get(5)?, + principal: r + .get::<_, Option>(1)? + .map(iota_identity::PrincipalHandle), + user_name: r.get(2)?, + created_at: r.get(3)?, + last_message_at: r.get(4)?, + notifications: r.get(5)?, + notifications_read_at: r.get(6)?, }) })?; @@ -135,14 +205,14 @@ pub fn get_users(storage_owner: i64) -> Result, StorageError> { pub fn read_notifications( storage_owner: i64, - user_id: i64, + principal: iota_identity::PrincipalHandle, through: i64, ) -> Result, StorageError> { db::with_immediate_transaction(|tx| { let Some(current_read_at) = tx .query_row( - "SELECT notifications_read_at FROM contacts WHERE storage_owner = ?1 AND user_id = ?2", - params![storage_owner, user_id], + "SELECT notifications_read_at FROM contacts WHERE storage_owner = ?1 AND principal_handle = ?2", + params![storage_owner, principal.0], |row| row.get::<_, i64>(0), ) .optional()? @@ -155,43 +225,46 @@ pub fn read_notifications( SELECT COUNT(*) FROM messages WHERE storage_owner = ?1 - AND external_user = ?2 + AND external_principal = ?2 AND sent_by_self = 0 AND deleted_by_external = 0 AND history_deleted = 0 AND COALESCE(destination_iota_received_at, stored_at, authored_at, message_time) > ?3 "#, - params![storage_owner, user_id, read_at], + params![storage_owner, principal.0, read_at], |row| row.get::<_, i64>(0), )?; tx.execute( - "UPDATE contacts SET notifications = ?3, notifications_read_at = ?4 WHERE storage_owner = ?1 AND user_id = ?2", - params![storage_owner, user_id, notifications, read_at], + "UPDATE contacts SET notifications = ?3, notifications_read_at = ?4 WHERE storage_owner = ?1 AND principal_handle = ?2", + params![storage_owner, principal.0, notifications, read_at], )?; sync::record_event( tx, storage_owner, EntityType::Contact, - user_id, + principal.0, Operation::Upsert, )?; Ok(Some(tx.query_row( r#" - SELECT user_id, user_name, created_at, last_message_at, + SELECT user_id, principal_handle, user_name, created_at, last_message_at, notifications, notifications_read_at FROM contacts - WHERE storage_owner = ?1 AND user_id = ?2 + WHERE storage_owner = ?1 AND principal_handle = ?2 "#, - params![storage_owner, user_id], + params![storage_owner, principal.0], |row| { Ok(Contact { user_id: row.get(0)?, - user_name: row.get(1)?, - created_at: row.get(2)?, - last_message_at: row.get(3)?, - notifications: row.get(4)?, - notifications_read_at: row.get(5)?, + principal: row + .get::<_, Option>(1)? + .map(iota_identity::PrincipalHandle), + user_name: row.get(2)?, + created_at: row.get(3)?, + last_message_at: row.get(4)?, + notifications: row.get(5)?, + notifications_read_at: row.get(6)?, }) }, )?)) diff --git a/iota-storage/src/util/client_relay_delivery.rs b/iota-storage/src/util/client_relay_delivery.rs index bd72208..fa02dd3 100644 --- a/iota-storage/src/util/client_relay_delivery.rs +++ b/iota-storage/src/util/client_relay_delivery.rs @@ -25,8 +25,8 @@ pub fn acknowledge_client_delivery( == message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery { let message_id = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - rusqlite::params![destination_id, relay.signer_id, relay.message_id], + "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", + rusqlite::params![destination_id, relay.signer.0, relay.message_id], |row| row.get::<_, i64>(0), ).optional()?; if let Some(message_id) = message_id { diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index bf66f0b..1e01050 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -30,6 +30,16 @@ pub enum ConfigError { #[source] source: std::net::AddrParseError, }, + #[error("invalid federation endpoint {endpoint:?}: {source}")] + InvalidFederationEndpoint { + endpoint: String, + #[source] + source: iota_identity::IdentityError, + }, + #[error("invalid relay router public key: {0}")] + InvalidRelayRouterKey(String), + #[error("relay router certificate path must not be empty")] + MissingRelayRouterCertificate, #[error("max_ipc_clients must be greater than zero")] InvalidMaxIpcClients, } @@ -43,6 +53,8 @@ pub struct IotaConfig { pub port: u16, #[serde(default)] pub web: WebSettings, + #[serde(default)] + pub relay_routers: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub omikron_host: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -61,6 +73,14 @@ pub struct IotaConfig { pub max_ipc_clients: usize, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RelayRouterSettings { + pub endpoint: String, + pub public_key: String, + pub certificate: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WebMode { @@ -89,6 +109,10 @@ pub struct WebSettings { pub key: Option, #[serde(default)] pub required: bool, + #[serde(default)] + pub direct_endpoints: Vec, + #[serde(default)] + pub relay_hints: Vec, } fn default_web_bind() -> String { "127.0.0.1".into() @@ -106,6 +130,8 @@ impl Default for WebSettings { certificate: None, key: None, required: false, + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), } } } @@ -128,6 +154,7 @@ impl Default for IotaConfig { iota_id: None, port: default_port(), web: WebSettings::default(), + relay_routers: Vec::new(), omikron_host: None, omikron_port: None, omikron_id: None, @@ -187,6 +214,32 @@ pub fn validate_config(config: &IotaConfig) -> Result<(), ConfigError> { if config.max_ipc_clients == 0 { return Err(ConfigError::InvalidMaxIpcClients); } + for endpoint in config + .web + .direct_endpoints + .iter() + .chain(&config.web.relay_hints) + { + iota_identity::AuthorityLocator::new(endpoint.clone()).map_err(|source| { + ConfigError::InvalidFederationEndpoint { + endpoint: endpoint.clone(), + source, + } + })?; + } + for router in &config.relay_routers { + iota_identity::AuthorityLocator::new(router.endpoint.clone()).map_err(|source| { + ConfigError::InvalidFederationEndpoint { + endpoint: router.endpoint.clone(), + source, + } + })?; + iota_identity::PublicKeyBundle::from_base64(&router.public_key) + .map_err(|error| ConfigError::InvalidRelayRouterKey(error.to_string()))?; + if router.certificate.trim().is_empty() { + return Err(ConfigError::MissingRelayRouterCertificate); + } + } Ok(()) } @@ -301,7 +354,7 @@ pub fn configure_config_path(path: PathBuf) { #[cfg(test)] mod tests { - use super::{ConfigError, IotaConfig, parse_config, validate_config}; + use super::{ConfigError, IotaConfig, RelayRouterSettings, parse_config, validate_config}; use std::path::Path; #[test] @@ -339,4 +392,34 @@ mod tests { Err(ConfigError::InvalidMaxIpcClients) )); } + + #[test] + fn relay_router_requires_valid_explicit_trust_pins() { + let mut config = IotaConfig::default(); + config.relay_routers.push(RelayRouterSettings { + endpoint: "router.example:1984".into(), + public_key: "invalid".into(), + certificate: "router.pem".into(), + }); + assert!(matches!( + validate_config(&config), + Err(ConfigError::InvalidRelayRouterKey(_)) + )); + } + + #[test] + fn relay_router_configuration_parses_with_all_trust_material() { + let public_key = mtp::crypto::Keyring::generate() + .public_key_bundle() + .try_to_base64() + .unwrap(); + let config = parse_config( + Path::new("config.yaml"), + &format!( + "relay_routers:\n - endpoint: router.example:1984\n public_key: {public_key}\n certificate: router.pem\n" + ), + ) + .unwrap(); + assert_eq!(config.relay_routers.len(), 1); + } } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 965aee7..734477e 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1076,6 +1076,828 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 31 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS principals ( + principal_pk INTEGER PRIMARY KEY AUTOINCREMENT, + authority_kind TEXT NOT NULL CHECK (authority_kind IN ('iota', 'omega')), + authority_id TEXT NOT NULL, + remote_user_id INTEGER NOT NULL, + username TEXT, + display_name TEXT, + home TEXT, + descriptor_revision INTEGER NOT NULL DEFAULT 0, + descriptor_valid_until INTEGER, + last_resolved_at INTEGER NOT NULL, + UNIQUE(authority_id, remote_user_id) + ); + CREATE INDEX IF NOT EXISTS idx_principals_username + ON principals (authority_id, username); + CREATE TABLE IF NOT EXISTS principal_keys ( + principal_pk INTEGER NOT NULL REFERENCES principals(principal_pk) ON DELETE CASCADE, + public_key TEXT NOT NULL, + valid_from INTEGER NOT NULL, + valid_until INTEGER, + source_revision INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (principal_pk, public_key) + ); + CREATE INDEX IF NOT EXISTS idx_principal_keys_validity + ON principal_keys (principal_pk, valid_until); + + "#, + )?; + let users_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')", + [], + |row| row.get(0), + )?; + if users_exist { + conn.execute_batch( + r#" + INSERT OR IGNORE INTO principals ( + authority_kind, authority_id, remote_user_id, username, display_name, + descriptor_revision, last_resolved_at + ) + SELECT 'omega', 'omega:central', user_id, username, display_name, 0, created_at + FROM users; + INSERT OR IGNORE INTO principal_keys ( + principal_pk, public_key, valid_from, source_revision + ) + SELECT p.principal_pk, u.public_key, u.created_at, 0 + FROM users u + JOIN principals p + ON p.authority_id = 'omega:central' AND p.remote_user_id = u.user_id; + "#, + )?; + } + for (table, column, definition) in [ + ( + "contacts", + "principal_handle", + "principal_handle INTEGER REFERENCES principals(principal_pk)", + ), + ( + "messages", + "external_principal", + "external_principal INTEGER REFERENCES principals(principal_pk)", + ), + ( + "relay_replay", + "signer_principal", + "signer_principal INTEGER REFERENCES principals(principal_pk)", + ), + ( + "relay_inbox", + "signer_principal", + "signer_principal INTEGER REFERENCES principals(principal_pk)", + ), + ( + "pending_relays", + "destination_principal", + "destination_principal INTEGER REFERENCES principals(principal_pk)", + ), + ( + "blocked_users", + "blocked_principal", + "blocked_principal INTEGER REFERENCES principals(principal_pk)", + ), + ] { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table], + |row| row.get(0), + )?; + if exists { + add_table_column_if_missing(conn, table, column, definition)?; + } + } + let contacts_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'contacts')", + [], + |row| row.get(0), + )?; + if contacts_exist { + conn.execute( + r#"UPDATE contacts SET principal_handle = ( + SELECT principal_pk FROM principals + WHERE authority_id = 'omega:central' AND remote_user_id = contacts.user_id + ) WHERE principal_handle IS NULL"#, + [], + )?; + } + let messages_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", + [], + |row| row.get(0), + )?; + if messages_exist { + conn.execute( + r#"UPDATE messages SET external_principal = ( + SELECT principal_pk FROM principals + WHERE authority_id = 'omega:central' AND remote_user_id = messages.external_user + ) WHERE external_principal IS NULL"#, + [], + )?; + } + conn.pragma_update(None, "user_version", 31)?; + } + + if current_version < 32 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS identity_configuration ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + omega_authority_id TEXT NOT NULL + ); + "#, + )?; + for (table, trigger) in [ + ( + "contacts", + "CREATE TRIGGER IF NOT EXISTS contacts_assign_principal_after_insert AFTER INSERT ON contacts WHEN NEW.principal_handle IS NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.user_id, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.user_id > 0; UPDATE contacts SET principal_handle = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.user_id) WHERE rowid = NEW.rowid; END;", + ), + ( + "messages", + "CREATE TRIGGER IF NOT EXISTS messages_assign_principal_after_insert AFTER INSERT ON messages WHEN NEW.external_principal IS NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.external_user, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.external_user > 0; UPDATE messages SET external_principal = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.external_user) WHERE rowid = NEW.rowid; END;", + ), + ( + "relay_replay", + "CREATE TRIGGER IF NOT EXISTS relay_replay_assign_principal_after_insert AFTER INSERT ON relay_replay WHEN NEW.signer_principal IS NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.signer_id, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.signer_id > 0; UPDATE relay_replay SET signer_principal = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.signer_id) WHERE rowid = NEW.rowid; END;", + ), + ( + "relay_inbox", + "CREATE TRIGGER IF NOT EXISTS relay_inbox_assign_principal_after_insert AFTER INSERT ON relay_inbox WHEN NEW.signer_principal IS NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.signer_id, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.signer_id > 0; UPDATE relay_inbox SET signer_principal = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.signer_id) WHERE rowid = NEW.rowid; END;", + ), + ( + "pending_relays", + "CREATE TRIGGER IF NOT EXISTS pending_relays_assign_principal_after_insert AFTER INSERT ON pending_relays WHEN NEW.destination_principal IS NULL AND NEW.relay_destination_user_id IS NOT NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.relay_destination_user_id, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.relay_destination_user_id > 0; UPDATE pending_relays SET destination_principal = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.relay_destination_user_id) WHERE rowid = NEW.rowid; END; CREATE TRIGGER IF NOT EXISTS pending_relays_assign_principal_after_update AFTER UPDATE OF relay_destination_user_id ON pending_relays WHEN NEW.destination_principal IS NULL AND NEW.relay_destination_user_id IS NOT NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.relay_destination_user_id, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.relay_destination_user_id > 0; UPDATE pending_relays SET destination_principal = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.relay_destination_user_id) WHERE rowid = NEW.rowid; END;", + ), + ( + "blocked_users", + "CREATE TRIGGER IF NOT EXISTS blocked_users_assign_principal_after_insert AFTER INSERT ON blocked_users WHEN NEW.blocked_principal IS NULL BEGIN INSERT OR IGNORE INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) SELECT 'omega', omega_authority_id, NEW.blocked_user_id, 0, 0 FROM identity_configuration WHERE singleton = 1 AND NEW.blocked_user_id > 0; UPDATE blocked_users SET blocked_principal = (SELECT p.principal_pk FROM principals p JOIN identity_configuration c ON c.singleton = 1 AND p.authority_id = c.omega_authority_id WHERE p.remote_user_id = NEW.blocked_user_id) WHERE rowid = NEW.rowid; END;", + ), + ] { + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table], + |row| row.get(0), + )?; + if exists { + conn.execute_batch(trigger)?; + } + } + conn.pragma_update(None, "user_version", 32)?; + } + + if current_version < 33 { + 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, + signer_principal INTEGER REFERENCES principals(principal_pk), + PRIMARY KEY (signer_id, message_id) + ); + 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')), + accepted_at INTEGER, + applied_at INTEGER, + queued_at INTEGER, + downstream_acked_at INTEGER, + rejected_at INTEGER, + signer_principal INTEGER REFERENCES principals(principal_pk), + PRIMARY KEY (signer_id, message_id) + ); + DROP TRIGGER IF EXISTS relay_replay_assign_principal_after_insert; + DROP TRIGGER IF EXISTS relay_inbox_assign_principal_after_insert; + + INSERT OR IGNORE INTO principals ( + authority_kind, authority_id, remote_user_id, + descriptor_revision, last_resolved_at + ) + SELECT 'omega', 'omega:central', signer_id, 0, 0 + FROM relay_replay + WHERE signer_principal IS NULL AND signer_id > 0; + INSERT OR IGNORE INTO principals ( + authority_kind, authority_id, remote_user_id, + descriptor_revision, last_resolved_at + ) + SELECT 'omega', 'omega:central', signer_id, 0, 0 + FROM relay_inbox + WHERE signer_principal IS NULL AND signer_id > 0; + UPDATE relay_replay + SET signer_principal = ( + SELECT principal_pk FROM principals + WHERE authority_id = 'omega:central' + AND remote_user_id = relay_replay.signer_id + ) + WHERE signer_principal IS NULL; + UPDATE relay_inbox + SET signer_principal = ( + SELECT principal_pk FROM principals + WHERE authority_id = 'omega:central' + AND remote_user_id = relay_inbox.signer_id + ) + WHERE signer_principal IS NULL; + + CREATE TABLE relay_replay_v33 ( + signer_principal INTEGER NOT NULL REFERENCES principals(principal_pk), + signer_id INTEGER NOT NULL, + message_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (signer_principal, message_id) + ); + INSERT INTO relay_replay_v33 + (signer_principal, signer_id, message_id, created_at) + SELECT signer_principal, signer_id, message_id, created_at + FROM relay_replay; + DROP TABLE relay_replay; + ALTER TABLE relay_replay_v33 RENAME TO relay_replay; + CREATE INDEX idx_relay_replay_created_at ON relay_replay (created_at); + + CREATE TABLE relay_inbox_v33 ( + signer_principal INTEGER NOT NULL REFERENCES principals(principal_pk), + 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')), + accepted_at INTEGER, + applied_at INTEGER, + queued_at INTEGER, + downstream_acked_at INTEGER, + rejected_at INTEGER, + PRIMARY KEY (signer_principal, message_id) + ); + INSERT INTO relay_inbox_v33 ( + signer_principal, signer_id, message_id, created_at, + destination_id, frame, type_map_version, frame_id, state, + accepted_at, applied_at, queued_at, downstream_acked_at, rejected_at + ) + SELECT signer_principal, signer_id, message_id, created_at, + destination_id, frame, type_map_version, frame_id, state, + accepted_at, applied_at, queued_at, downstream_acked_at, rejected_at + FROM relay_inbox; + DROP TABLE relay_inbox; + ALTER TABLE relay_inbox_v33 RENAME TO relay_inbox; + CREATE INDEX idx_relay_inbox_state ON relay_inbox (state, created_at); + "#, + )?; + conn.pragma_update(None, "user_version", 33)?; + } + + if current_version < 34 { + let pending_relays_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_relays')", + [], + |row| row.get(0), + )?; + if pending_relays_exist { + add_table_column_if_missing( + conn, + "pending_relays", + "signer_principal", + "signer_principal INTEGER REFERENCES principals(principal_pk)", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "destination_node_id", + "destination_node_id TEXT", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "destination_authority_id", + "destination_authority_id TEXT", + )?; + conn.execute_batch( + r#" + DROP TRIGGER IF EXISTS pending_relays_assign_principal_after_insert; + DROP TRIGGER IF EXISTS pending_relays_assign_principal_after_update; + UPDATE pending_relays + SET signer_principal = ( + SELECT p.principal_pk + FROM principals p + JOIN identity_configuration c + ON c.singleton = 1 AND p.authority_id = c.omega_authority_id + WHERE p.remote_user_id = pending_relays.relay_signer_id + ) + WHERE signer_principal IS NULL AND relay_signer_id IS NOT NULL; + UPDATE pending_relays + SET destination_authority_id = ( + SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1 + ) + WHERE target_kind = 1 AND destination_authority_id IS NULL; + "#, + )?; + } + conn.pragma_update(None, "user_version", 34)?; + } + + if current_version < 35 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS hosted_principals ( + local_user_id INTEGER PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + principal_handle INTEGER NOT NULL UNIQUE REFERENCES principals(principal_pk) + ); + "#, + )?; + let users_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')", + [], + |row| row.get(0), + )?; + if users_exist { + conn.execute_batch( + r#" + INSERT OR IGNORE INTO hosted_principals (local_user_id, principal_handle) + SELECT u.user_id, p.principal_pk + FROM users u + JOIN principals p ON p.remote_user_id = u.user_id + JOIN identity_configuration c + ON c.singleton = 1 AND p.authority_id = c.omega_authority_id; + "#, + )?; + } + conn.pragma_update(None, "user_version", 35)?; + } + + if current_version < 36 { + let contacts_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'contacts')", + [], + |row| row.get(0), + )?; + if contacts_exist { + conn.execute_batch( + r#" + INSERT OR IGNORE INTO principals ( + authority_kind, authority_id, remote_user_id, + descriptor_revision, last_resolved_at + ) + SELECT 'omega', + COALESCE( + (SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1), + 'omega:central' + ), + user_id, 0, 0 + FROM contacts + WHERE principal_handle IS NULL; + UPDATE contacts + SET principal_handle = ( + SELECT principal_pk + FROM principals + WHERE authority_id = COALESCE( + (SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1), + 'omega:central' + ) + AND remote_user_id = contacts.user_id + ) + WHERE principal_handle IS NULL; + DROP TRIGGER IF EXISTS contacts_assign_principal_after_insert; + CREATE TABLE contacts_v36 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + user_id INTEGER NOT NULL, + principal_handle INTEGER REFERENCES principals(principal_pk), + user_name TEXT, + created_at INTEGER NOT NULL, + last_message_at INTEGER, + notifications INTEGER NOT NULL DEFAULT 0, + notifications_read_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(storage_owner, principal_handle) + ); + INSERT INTO contacts_v36 ( + id, storage_owner, user_id, principal_handle, user_name, created_at, + last_message_at, notifications, notifications_read_at + ) + SELECT id, storage_owner, user_id, principal_handle, user_name, created_at, + last_message_at, notifications, notifications_read_at + FROM contacts; + DROP TABLE contacts; + ALTER TABLE contacts_v36 RENAME TO contacts; + CREATE INDEX idx_contacts_owner + ON contacts (storage_owner, last_message_at DESC, user_id ASC); + CREATE TRIGGER contacts_assign_principal_after_insert + AFTER INSERT ON contacts + WHEN NEW.principal_handle IS NULL + BEGIN + INSERT OR IGNORE INTO principals ( + authority_kind, authority_id, remote_user_id, + descriptor_revision, last_resolved_at + ) + SELECT 'omega', omega_authority_id, NEW.user_id, 0, 0 + FROM identity_configuration + WHERE singleton = 1 AND NEW.user_id > 0; + UPDATE contacts + SET principal_handle = ( + SELECT p.principal_pk + FROM principals p + JOIN identity_configuration c + ON c.singleton = 1 AND p.authority_id = c.omega_authority_id + WHERE p.remote_user_id = NEW.user_id + ) + WHERE rowid = NEW.rowid; + END; + "#, + )?; + } + let messages_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", + [], + |row| row.get(0), + )?; + if messages_exist { + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_messages_principal_lookup ON messages (storage_owner, external_principal, message_time DESC)", + [], + )?; + } + conn.pragma_update(None, "user_version", 36)?; + } + + if current_version < 37 { + let blocked_users_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'blocked_users')", + [], + |row| row.get(0), + )?; + if blocked_users_exist { + conn.execute_batch( + r#" + INSERT OR IGNORE INTO principals ( + authority_kind, authority_id, remote_user_id, + descriptor_revision, last_resolved_at + ) + SELECT 'omega', + COALESCE( + (SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1), + 'omega:central' + ), + blocked_user_id, 0, 0 + FROM blocked_users + WHERE blocked_principal IS NULL; + UPDATE blocked_users + SET blocked_principal = ( + SELECT principal_pk + FROM principals + WHERE authority_id = COALESCE( + (SELECT omega_authority_id FROM identity_configuration WHERE singleton = 1), + 'omega:central' + ) + AND remote_user_id = blocked_users.blocked_user_id + ) + WHERE blocked_principal IS NULL; + DROP TRIGGER IF EXISTS blocked_users_assign_principal_after_insert; + CREATE TABLE blocked_users_v37 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + blocked_user_id INTEGER NOT NULL, + blocked_principal INTEGER NOT NULL REFERENCES principals(principal_pk), + revision INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(user_id, blocked_principal) + ); + INSERT INTO blocked_users_v37 ( + id, user_id, blocked_user_id, blocked_principal, revision, created_at + ) + SELECT id, user_id, blocked_user_id, blocked_principal, revision, created_at + FROM blocked_users; + DROP TABLE blocked_users; + ALTER TABLE blocked_users_v37 RENAME TO blocked_users; + CREATE INDEX idx_blocked_users_owner ON blocked_users (user_id); + "#, + )?; + } + conn.pragma_update(None, "user_version", 37)?; + } + + if current_version < 38 { + let messages_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')", + [], + |row| row.get(0), + )?; + if messages_exist { + add_table_column_if_missing( + conn, + "messages", + "relay_signer_principal", + "relay_signer_principal INTEGER REFERENCES principals(principal_pk)", + )?; + conn.execute_batch( + r#" + UPDATE messages + SET relay_signer_principal = CASE + WHEN sent_by_self = 1 THEN ( + SELECT principal_handle FROM hosted_principals + WHERE local_user_id = messages.storage_owner + ) + ELSE external_principal + END + WHERE relay_signer_principal IS NULL; + DROP INDEX IF EXISTS idx_messages_relay_identity; + CREATE UNIQUE INDEX idx_messages_relay_principal_identity + ON messages (storage_owner, relay_signer_principal, relay_message_id) + WHERE relay_signer_principal IS NOT NULL AND relay_message_id IS NOT NULL; + CREATE UNIQUE INDEX idx_messages_legacy_relay_identity + ON messages (storage_owner, relay_signer_id, relay_message_id) + WHERE relay_signer_principal IS NULL AND relay_message_id IS NOT NULL; + "#, + )?; + } + + let reactions_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'reactions')", + [], + |row| row.get(0), + )?; + if reactions_exist { + add_table_column_if_missing( + conn, + "reactions", + "user_principal", + "user_principal INTEGER REFERENCES principals(principal_pk)", + )?; + conn.execute_batch( + r#" + UPDATE reactions + SET user_principal = CASE + WHEN user_id = (SELECT storage_owner FROM messages WHERE id = reactions.message_id) + THEN (SELECT hp.principal_handle FROM hosted_principals hp + JOIN messages m ON m.storage_owner = hp.local_user_id + WHERE m.id = reactions.message_id) + WHEN user_id = (SELECT external_user FROM messages WHERE id = reactions.message_id) + THEN (SELECT external_principal FROM messages WHERE id = reactions.message_id) + END + WHERE user_principal IS NULL; + DROP INDEX IF EXISTS idx_reactions_msg; + DROP INDEX IF EXISTS idx_reactions_principal_identity; + ALTER TABLE reactions RENAME TO reactions_v37; + CREATE TABLE reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id), + user_id INTEGER NOT NULL, + user_principal INTEGER REFERENCES principals(principal_pk), + reaction TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + INSERT INTO reactions ( + id, message_id, user_id, user_principal, reaction, created_at + ) + SELECT id, message_id, user_id, user_principal, reaction, created_at + FROM reactions_v37; + DROP TABLE reactions_v37; + CREATE INDEX idx_reactions_msg + ON reactions (message_id, reaction); + CREATE UNIQUE INDEX idx_reactions_principal_identity + ON reactions (message_id, user_principal, reaction) + WHERE user_principal IS NOT NULL; + CREATE UNIQUE INDEX idx_reactions_legacy_identity + ON reactions (message_id, user_id, reaction) + WHERE user_principal IS NULL; + "#, + )?; + } + + let receipts_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'message_receipts')", + [], + |row| row.get(0), + )?; + if receipts_exist { + add_table_column_if_missing( + conn, + "message_receipts", + "target_signer_principal", + "target_signer_principal INTEGER REFERENCES principals(principal_pk)", + )?; + add_table_column_if_missing( + conn, + "message_receipts", + "receipt_signer_principal", + "receipt_signer_principal INTEGER REFERENCES principals(principal_pk)", + )?; + conn.execute_batch( + r#" + UPDATE message_receipts + SET target_signer_principal = ( + SELECT relay_signer_principal FROM messages + WHERE storage_owner = message_receipts.storage_owner + AND relay_signer_id = message_receipts.target_signer_id + AND relay_message_id = message_receipts.target_message_id + ), + receipt_signer_principal = ( + SELECT external_principal FROM messages + WHERE storage_owner = message_receipts.storage_owner + AND relay_signer_id = message_receipts.target_signer_id + AND relay_message_id = message_receipts.target_message_id + ) + WHERE target_signer_principal IS NULL OR receipt_signer_principal IS NULL; + DROP INDEX IF EXISTS idx_message_receipts_target; + DROP INDEX IF EXISTS idx_message_receipts_principal_identity; + DROP INDEX IF EXISTS idx_message_receipts_principal_target; + ALTER TABLE message_receipts RENAME TO message_receipts_v37; + CREATE TABLE message_receipts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + storage_owner INTEGER NOT NULL, + target_signer_id INTEGER NOT NULL, + target_signer_principal INTEGER REFERENCES principals(principal_pk), + target_message_id TEXT NOT NULL, + receipt_signer_id INTEGER NOT NULL, + receipt_signer_principal INTEGER REFERENCES principals(principal_pk), + receipt_message_id TEXT NOT NULL, + receipt_type TEXT NOT NULL CHECK (receipt_type IN ('received', 'read')), + event_at INTEGER NOT NULL, + recorded_at INTEGER NOT NULL + ); + INSERT INTO message_receipts ( + id, storage_owner, target_signer_id, target_signer_principal, + target_message_id, receipt_signer_id, receipt_signer_principal, + receipt_message_id, receipt_type, event_at, recorded_at + ) + SELECT id, storage_owner, target_signer_id, target_signer_principal, + target_message_id, receipt_signer_id, receipt_signer_principal, + receipt_message_id, receipt_type, event_at, recorded_at + FROM message_receipts_v37; + DROP TABLE message_receipts_v37; + CREATE UNIQUE INDEX idx_message_receipts_principal_message + ON message_receipts (receipt_signer_principal, receipt_message_id) + WHERE receipt_signer_principal IS NOT NULL; + CREATE UNIQUE INDEX idx_message_receipts_principal_identity + ON message_receipts ( + storage_owner, target_signer_principal, target_message_id, + receipt_signer_principal, receipt_type + ) + WHERE target_signer_principal IS NOT NULL + AND receipt_signer_principal IS NOT NULL; + CREATE UNIQUE INDEX idx_message_receipts_legacy_message + ON message_receipts (receipt_signer_id, receipt_message_id) + WHERE receipt_signer_principal IS NULL; + CREATE UNIQUE INDEX idx_message_receipts_legacy_identity + ON message_receipts ( + storage_owner, target_signer_id, target_message_id, + receipt_signer_id, receipt_type + ) + WHERE target_signer_principal IS NULL + AND receipt_signer_principal IS NULL; + CREATE INDEX idx_message_receipts_principal_target + ON message_receipts (storage_owner, target_signer_principal, target_message_id); + "#, + )?; + } + conn.pragma_update(None, "user_version", 38)?; + } + + if current_version < 39 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS local_identity_allocator ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + next_user_id INTEGER NOT NULL + ); + "#, + )?; + let users_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users')", + [], + |row| row.get(0), + )?; + if users_exist { + conn.execute( + "INSERT OR IGNORE INTO local_identity_allocator (singleton, next_user_id) SELECT 1, COALESCE(MAX(user_id), 0) + 1 FROM users", + [], + )?; + } else { + conn.execute( + "INSERT OR IGNORE INTO local_identity_allocator (singleton, next_user_id) VALUES (1, 1)", + [], + )?; + } + let principals_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'principals')", + [], + |row| row.get(0), + )?; + if principals_exist { + add_table_column_if_missing(conn, "principals", "retired_at", "retired_at INTEGER")?; + } + conn.pragma_update(None, "user_version", 39)?; + } + + if current_version < 40 { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS node_directory ( + node_id TEXT PRIMARY KEY, + descriptor_revision INTEGER NOT NULL, + descriptor_valid_until INTEGER, + direct_endpoints TEXT NOT NULL, + relay_hints TEXT NOT NULL, + last_successful_endpoint TEXT, + last_resolved_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS node_keys ( + node_id TEXT NOT NULL REFERENCES node_directory(node_id) ON DELETE CASCADE, + public_key TEXT NOT NULL, + valid_from INTEGER NOT NULL, + valid_until INTEGER, + source_revision INTEGER NOT NULL, + PRIMARY KEY (node_id, public_key) + ); + CREATE TABLE IF NOT EXISTS node_endpoint_pins ( + endpoint TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + pinned_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_node_keys_validity + ON node_keys (node_id, valid_until); + PRAGMA user_version = 40; + "#, + )?; + } + + if current_version < 41 { + let pending_relays_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_relays')", + [], + |row| row.get(0), + )?; + if pending_relays_exist { + add_table_column_if_missing( + conn, + "pending_relays", + "attempt_count", + "attempt_count INTEGER NOT NULL DEFAULT 0", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "last_attempt_at", + "last_attempt_at INTEGER", + )?; + add_table_column_if_missing( + conn, + "pending_relays", + "next_attempt_at", + "next_attempt_at INTEGER NOT NULL DEFAULT 0", + )?; + } + conn.pragma_update(None, "user_version", 41)?; + } + + if current_version < 42 { + let principals_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'principals')", + [], + |row| row.get(0), + )?; + if principals_exist { + add_table_column_if_missing( + conn, + "principals", + "descriptor_issued_at", + "descriptor_issued_at INTEGER", + )?; + conn.execute( + "UPDATE principals SET descriptor_issued_at = last_resolved_at WHERE descriptor_issued_at IS NULL AND principal_pk IN (SELECT principal_handle FROM hosted_principals)", + [], + )?; + } + conn.pragma_update(None, "user_version", 42)?; + } + + if current_version < 43 { + let endpoint_pins_exist: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'node_endpoint_pins')", + [], + |row| row.get(0), + )?; + if endpoint_pins_exist { + add_table_column_if_missing( + conn, + "node_endpoint_pins", + "tls_certificate", + "tls_certificate BLOB", + )?; + } + conn.pragma_update(None, "user_version", 43)?; + } + Ok(()) } @@ -1150,7 +1972,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, 30); + assert_eq!(version, 43); 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")?; @@ -1169,7 +1991,7 @@ 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, 30); + assert_eq!(version, 43); for table in [ "sync_heads", "sync_events", @@ -1186,6 +2008,9 @@ mod tests { "user_receipt_policy", "user_message_storage_policy", "user_invitations", + "principals", + "principal_keys", + "identity_configuration", ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -1200,6 +2025,9 @@ mod tests { "type_map_version", "delivery_state", "last_error", + "attempt_count", + "last_attempt_at", + "next_attempt_at", ] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('pending_relays') WHERE name = ?1")?; @@ -1208,6 +2036,34 @@ mod tests { Ok(()) } + #[test] + fn replay_schema_scopes_equal_numeric_signers_by_principal() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + run_migrations_on_connection(&conn)?; + conn.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('omega', 'omega:first', 7, 0, 0)", + [], + )?; + let first = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('omega', 'omega:second', 7, 0, 0)", + [], + )?; + let second = conn.last_insert_rowid(); + + let first_insert = conn.execute( + "INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, 7, 'same-message', 1)", + [first], + )?; + let second_insert = conn.execute( + "INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, 7, 'same-message', 1)", + [second], + )?; + + assert_eq!((first_insert, second_insert), (1, 1)); + Ok(()) + } + #[test] fn adds_synced_settings_to_a_version_eleven_schema() -> Result<(), StorageError> { let conn = Connection::open_in_memory()?; @@ -1216,7 +2072,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, 30); + assert_eq!(version, 43); for column in [ "id", "user_id", @@ -1311,7 +2167,7 @@ mod tests { )?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; assert_eq!(preserved, "remote_committed"); - assert_eq!(version, 30); + assert_eq!(version, 43); Ok(()) } @@ -1349,7 +2205,152 @@ mod tests { })?; assert_eq!(count, 0); let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 30); + assert_eq!(version, 43); + Ok(()) + } + + #[test] + fn principal_identity_is_scoped_by_authority() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + run_migrations_on_connection(&conn)?; + conn.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, last_resolved_at) VALUES ('omega', 'omega:first', 7, 1)", + [], + )?; + conn.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, last_resolved_at) VALUES ('omega', 'omega:second', 7, 1)", + [], + )?; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM principals WHERE remote_user_id = 7", + [], + |row| row.get(0), + )?; + assert_eq!(count, 2); + Ok(()) + } + + #[test] + fn local_identity_allocator_starts_at_one() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + run_migrations_on_connection(&conn)?; + let next: i64 = conn.query_row( + "SELECT next_user_id FROM local_identity_allocator WHERE singleton = 1", + [], + |row| row.get(0), + )?; + assert_eq!(next, 1); + Ok(()) + } + + #[test] + fn equal_numeric_users_remain_separate_in_application_storage() -> Result<(), StorageError> { + let conn = Connection::open_in_memory()?; + run_migrations_on_connection(&conn)?; + conn.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:first', 7, 0, 0)", + [], + )?; + let first = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:second', 7, 0, 0)", + [], + )?; + let second = conn.last_insert_rowid(); + + conn.execute( + "INSERT INTO contacts (storage_owner, user_id, principal_handle, created_at, notifications) VALUES (1, 7, ?1, 1, 2)", + [first], + )?; + conn.execute( + "INSERT INTO contacts (storage_owner, user_id, principal_handle, created_at, notifications) VALUES (1, 7, ?1, 1, 3)", + [second], + )?; + conn.execute( + "UPDATE contacts SET notifications = 0 WHERE storage_owner = 1 AND principal_handle = ?1", + [first], + )?; + let notifications: Vec = conn + .prepare( + "SELECT notifications FROM contacts WHERE storage_owner = 1 ORDER BY principal_handle", + )? + .query_map([], |row| row.get(0))? + .collect::>()?; + assert_eq!(notifications, vec![0, 3]); + + conn.execute( + "INSERT INTO blocked_users (user_id, blocked_user_id, blocked_principal, revision, created_at) VALUES (1, 7, ?1, 0, 1)", + [first], + )?; + conn.execute( + "INSERT INTO blocked_users (user_id, blocked_user_id, blocked_principal, revision, created_at) VALUES (1, 7, ?1, 0, 1)", + [second], + )?; + + conn.execute( + "INSERT INTO messages (storage_owner, external_user, external_principal, relay_signer_id, relay_signer_principal, relay_message_id, message_time, content, sent_by_self, message_state) VALUES (1, 7, ?1, 7, ?1, 'first-message', 1, 'one', 0, 'sent')", + [first], + )?; + let message = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO reactions (message_id, user_id, user_principal, reaction, created_at) VALUES (?1, 7, ?2, 'same', 1)", + rusqlite::params![message, first], + )?; + conn.execute( + "INSERT INTO reactions (message_id, user_id, user_principal, reaction, created_at) VALUES (?1, 7, ?2, 'same', 1)", + rusqlite::params![message, second], + )?; + + for (principal, target_message) in [(first, "first-message"), (second, "second-message")] { + conn.execute( + "INSERT INTO message_receipts (storage_owner, target_signer_id, target_signer_principal, target_message_id, receipt_signer_id, receipt_signer_principal, receipt_message_id, receipt_type, event_at, recorded_at) VALUES (1, 1, 1, ?1, 7, ?2, 'same-receipt', 'read', 1, 1)", + rusqlite::params![target_message, principal], + )?; + } + + let counts = ( + conn.query_row("SELECT COUNT(*) FROM contacts", [], |row| { + row.get::<_, i64>(0) + })?, + conn.query_row("SELECT COUNT(*) FROM blocked_users", [], |row| { + row.get::<_, i64>(0) + })?, + conn.query_row("SELECT COUNT(*) FROM reactions", [], |row| { + row.get::<_, i64>(0) + })?, + conn.query_row("SELECT COUNT(*) FROM message_receipts", [], |row| { + row.get::<_, i64>(0) + })?, + ); + assert_eq!(counts, (2, 2, 2, 2)); + Ok(()) + } + + #[test] + fn observed_remote_references_receive_authority_scoped_principals() -> Result<(), StorageError> + { + let conn = Connection::open_in_memory()?; + run_migrations_on_connection(&conn)?; + conn.execute( + "INSERT INTO identity_configuration (singleton, omega_authority_id) VALUES (1, 'omega:omega-a.example')", + [], + )?; + conn.execute( + "INSERT INTO contacts (storage_owner, user_id, created_at) VALUES (1, 7, 1)", + [], + )?; + conn.execute( + "INSERT INTO messages (storage_owner, external_user, message_time, content, sent_by_self, message_state) VALUES (1, 7, 1, 'message', 0, 'received')", + [], + )?; + let (contact_principal, message_principal, authority): (i64, i64, String) = + conn.query_row( + "SELECT c.principal_handle, m.external_principal, p.authority_id FROM contacts c JOIN messages m ON m.external_user = c.user_id JOIN principals p ON p.principal_pk = c.principal_handle", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!(contact_principal, message_principal); + assert_eq!(authority, "omega:omega-a.example"); Ok(()) } } diff --git a/iota-storage/src/util/downstream_relay.rs b/iota-storage/src/util/downstream_relay.rs index b1eddf2..08210ab 100644 --- a/iota-storage/src/util/downstream_relay.rs +++ b/iota-storage/src/util/downstream_relay.rs @@ -4,65 +4,85 @@ use crate::util::{db, sync}; use rusqlite::{OptionalExtension, params}; pub fn acknowledge_iota_delivery( - destination_iota: u64, frame_id: u32, - signer_id: i64, + signer_principal: iota_identity::PrincipalHandle, + _legacy_signer_id: Option, relay_message_id: &str, destination_accepted_at: i64, ) -> Result<(), StorageError> { - let destination_iota = i64::try_from(destination_iota) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; db::with_immediate_transaction(|tx| { - let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?; + let pending_id = tx + .query_row( + "SELECT id FROM pending_relays WHERE signer_principal = ?1 AND frame_id = ?2", + params![signer_principal.0, i64::from(frame_id)], + |row| row.get::<_, i64>(0), + ) + .optional()?; let Some(pending_id) = pending_id else { return Ok(()); }; - let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?; - if let Some(message_id) = message_id { + let message = tx + .query_row( + "SELECT id, storage_owner FROM messages WHERE relay_signer_principal = ?1 AND relay_message_id = ?2", + params![signer_principal.0, relay_message_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + if let Some((message_id, storage_owner)) = message { tx.execute("UPDATE messages SET destination_iota_received_at = COALESCE(destination_iota_received_at, ?1), delivery_failed_at = NULL, delivery_failure = NULL, message_state = CASE WHEN message_state = 'sending' THEN 'sent' ELSE message_state END WHERE id = ?2", params![destination_accepted_at, message_id])?; sync::record_event( tx, - signer_id, + storage_owner, sync::EntityType::Message, message_id, sync::Operation::Upsert, )?; } - tx.execute("UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?; + tx.execute("UPDATE relay_inbox SET state = 'delivered', downstream_acked_at = COALESCE(downstream_acked_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", params![signer_principal.0, relay_message_id, sync::now_millis()])?; tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?; Ok(()) }) } pub fn reject_iota_delivery( - destination_iota: u64, frame_id: u32, - signer_id: i64, + signer_principal: iota_identity::PrincipalHandle, + _legacy_signer_id: Option, relay_message_id: &str, failure: &str, ) -> Result<(), StorageError> { - let destination_iota = i64::try_from(destination_iota) - .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; db::with_immediate_transaction(|tx| { - let pending_id = tx.query_row("SELECT id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)], |row| row.get::<_, i64>(0)).optional()?; + let pending_id = tx + .query_row( + "SELECT id FROM pending_relays WHERE signer_principal = ?1 AND frame_id = ?2", + params![signer_principal.0, i64::from(frame_id)], + |row| row.get::<_, i64>(0), + ) + .optional()?; let Some(pending_id) = pending_id else { return Ok(()); }; - let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", params![signer_id, relay_message_id], |row| row.get::<_, i64>(0)).optional()?; - if let Some(message_id) = message_id { + let message = tx + .query_row( + "SELECT id, storage_owner FROM messages WHERE relay_signer_principal = ?1 AND relay_message_id = ?2", + params![signer_principal.0, relay_message_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + if let Some((message_id, storage_owner)) = message { tx.execute( "UPDATE messages SET delivery_failed_at = ?1, delivery_failure = ?2 WHERE id = ?3", params![sync::now_millis(), failure, message_id], )?; sync::record_event( tx, - signer_id, + storage_owner, sync::EntityType::Message, message_id, sync::Operation::Upsert, )?; } - tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, sync::now_millis()])?; + tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", params![signer_principal.0, relay_message_id, sync::now_millis()])?; tx.execute("DELETE FROM pending_relays WHERE id = ?1", [pending_id])?; Ok(()) }) diff --git a/iota-storage/src/util/e2ee_storage.rs b/iota-storage/src/util/e2ee_storage.rs index 9e72c94..afb6513 100644 --- a/iota-storage/src/util/e2ee_storage.rs +++ b/iota-storage/src/util/e2ee_storage.rs @@ -25,6 +25,68 @@ pub struct ChatSecretQuery { pub version: Option, } +pub fn principal_chat_id( + first: iota_identity::PrincipalHandle, + second: iota_identity::PrincipalHandle, +) -> Option { + if first.0 <= 0 || second.0 <= 0 || first == second { + return None; + } + let (first, second) = if first.0 < second.0 { + (first.0, second.0) + } else { + (second.0, first.0) + }; + Some(format!("principal:{first}:{second}")) +} + +pub fn principal_secret_id(chat_id: &str) -> String { + format!("chat:{chat_id}:main") +} + +pub fn migrate_chat_secret_namespace( + user_id: &str, + legacy_chat_id: &str, + legacy_secret_id: &str, + principal_chat_id: &str, + principal_secret_id: &str, +) -> Result<(), StorageError> { + if [ + user_id, + legacy_chat_id, + legacy_secret_id, + principal_chat_id, + principal_secret_id, + ] + .iter() + .any(|value| value.is_empty()) + { + return Err("chat secret namespace is incomplete".into()); + } + db::with_conn(&E2EE_DB, |conn| { + conn.execute( + r#" + INSERT OR IGNORE INTO chat_secrets ( + user_id, chat_id, secret_id, version, encrypted_secret, + kem_ciphertext, wrapping_scheme, created_at, updated_at + ) + SELECT user_id, ?4, ?5, version, encrypted_secret, + kem_ciphertext, wrapping_scheme, created_at, updated_at + FROM chat_secrets + WHERE user_id = ?1 AND chat_id = ?2 AND secret_id = ?3 + "#, + params![ + user_id, + legacy_chat_id, + legacy_secret_id, + principal_chat_id, + principal_secret_id + ], + )?; + Ok(()) + }) +} + static E2EE_DB: LazyLock>> = LazyLock::new(|| { let database = db::create_shared_connection( "e2ee", @@ -186,3 +248,34 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - pub target: RouteTarget, + pub target: relay_queue::RelayTarget, pub identity: &'a relay_queue::RelayIdentity, pub frame: &'a [u8], pub created_at: i64, @@ -24,22 +23,30 @@ pub fn commit_outgoing_relay(relay: OutgoingRelay<'_>) -> Result<(), StorageErro relay.frame_id, relay.type_map_version, )?; - if message_storage_policy::get_in_tx(tx, relay.identity.signer_id)?.history_mode + let signer_id = tx + .query_row( + "SELECT local_user_id FROM hosted_principals WHERE principal_handle = ?1", + [relay.identity.signer.0], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| StorageError::Other("outgoing signer is not hosted locally".into()))?; + if message_storage_policy::get_in_tx(tx, signer_id)?.history_mode == message_storage_policy::MessageHistoryMode::DeleteAfterClientDelivery { - let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?1 AND relay_message_id = ?2", rusqlite::params![relay.identity.signer_id, relay.identity.message_id], |row| row.get::<_, i64>(0)).optional()?; + let message_id = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", rusqlite::params![signer_id, relay.identity.signer.0, relay.identity.message_id], |row| row.get::<_, i64>(0)).optional()?; if let Some(message_id) = message_id { - chat_files::remove_message_history_in_tx(tx, relay.identity.signer_id, message_id)?; + chat_files::remove_message_history_in_tx(tx, signer_id, message_id)?; } } - relay_replay::mark_queued_in_tx(tx, relay.identity.signer_id, &relay.identity.message_id)?; + relay_replay::mark_queued_in_tx(tx, relay.identity.signer, &relay.identity.message_id)?; Ok(()) }) } pub fn apply_outgoing_history_policy( storage_owner: i64, - relay_signer_id: i64, + relay_signer_principal: iota_identity::PrincipalHandle, relay_message_id: &str, ) -> Result<(), StorageError> { db::with_immediate_transaction(|tx| { @@ -49,8 +56,8 @@ pub fn apply_outgoing_history_policy( return Ok(()); } let message_id = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_id = ?2 AND relay_message_id = ?3", - rusqlite::params![storage_owner, relay_signer_id, relay_message_id], + "SELECT id FROM messages WHERE storage_owner = ?1 AND relay_signer_principal = ?2 AND relay_message_id = ?3", + rusqlite::params![storage_owner, relay_signer_principal.0, relay_message_id], |row| row.get::<_, i64>(0), ).optional()?; if let Some(message_id) = message_id { diff --git a/iota-storage/src/util/relay_queue.rs b/iota-storage/src/util/relay_queue.rs index 2d327f4..2fe6838 100644 --- a/iota-storage/src/util/relay_queue.rs +++ b/iota-storage/src/util/relay_queue.rs @@ -1,21 +1,44 @@ use crate::storage_error::StorageError; use crate::util::db; +use iota_identity::{AuthorityId, IotaNodeId, PrincipalHandle}; use iota_util::route_target::RouteTarget; use rusqlite::{OptionalExtension, Transaction, params}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct PendingRelay { pub id: i64, - pub target: RouteTarget, + pub target: RelayTarget, pub frame: Vec, pub created_at: i64, pub frame_id: u32, pub type_map_version: String, pub relay_signer_id: Option, pub relay_destination_user_id: Option, + pub signer_principal: Option, + pub destination_principal: Option, pub relay_message_id: Option, pub delivery_state: PendingRelayDeliveryState, pub last_error: Option, + pub attempt_count: i64, + pub last_attempt_at: Option, + pub next_attempt_at: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RelayTarget { + User(u64), + Iota(IotaNodeId), + LegacyOmegaIota { omega: AuthorityId, iota_id: u64 }, +} + +impl RelayTarget { + pub fn legacy_wire_target(&self) -> Option { + match self { + Self::User(user_id) => Some(RouteTarget::User(*user_id)), + Self::LegacyOmegaIota { iota_id, .. } => Some(RouteTarget::Iota(*iota_id)), + Self::Iota(_) => None, + } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -38,23 +61,25 @@ impl PendingRelayDeliveryState { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RelayIdentity { - pub signer_id: i64, - pub destination_user_id: i64, + pub signer: PrincipalHandle, + pub recipient: PrincipalHandle, pub message_id: String, + pub legacy_signer_id: Option, + pub legacy_recipient_id: Option, } pub fn enqueue( - target: RouteTarget, + target: RelayTarget, relay: &RelayIdentity, frame: &[u8], created_at: i64, frame_id: u32, type_map_version: &str, ) -> Result<(), StorageError> { - if let RouteTarget::User(destination) = target { + if let RelayTarget::User(destination) = target { let destination = i64::try_from(destination) .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; - if destination != relay.destination_user_id { + if Some(destination) != relay.legacy_recipient_id { return Err(StorageError::Other( "user relay target does not match final recipient".into(), )); @@ -75,31 +100,49 @@ pub fn enqueue( pub fn enqueue_in_tx( tx: &Transaction<'_>, - target: RouteTarget, + target: RelayTarget, relay: &RelayIdentity, 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, + let (destination_id, target_kind, destination_node_id, destination_authority_id) = match &target + { + RelayTarget::User(user_id) => ( + i64::try_from(*user_id).map_err(|_| { + StorageError::Other("relay destination ID exceeds SQLite range".into()) + })?, + 0_i64, + None, + None, + ), + RelayTarget::LegacyOmegaIota { omega, iota_id } => ( + i64::try_from(*iota_id).map_err(|_| { + StorageError::Other("relay destination ID exceeds SQLite range".into()) + })?, + 1_i64, + None, + Some(omega.as_str()), + ), + RelayTarget::Iota(node) => (0, 2_i64, Some(node.as_str()), None), }; tx.execute( - "INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, relay_signer_id, relay_destination_user_id, relay_message_id, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, relay_signer_id, relay_destination_user_id, relay_message_id, frame, created_at, frame_id, type_map_version, signer_principal, destination_principal, destination_node_id, destination_authority_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ destination_id, target_kind, - relay.signer_id, - relay.destination_user_id, + relay.legacy_signer_id, + relay.legacy_recipient_id, relay.message_id, frame, created_at, i64::from(frame_id), - type_map_version + type_map_version, + relay.signer.0, + relay.recipient.0, + destination_node_id, + destination_authority_id, ], )?; Ok(()) @@ -108,7 +151,7 @@ pub fn enqueue_in_tx( 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, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error FROM pending_relays ORDER BY id LIMIT ?1", + "SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error, destination_principal, signer_principal, destination_node_id, destination_authority_id, attempt_count, last_attempt_at, next_attempt_at 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)?; @@ -121,8 +164,26 @@ pub fn list(limit: i64) -> Result, StorageError> { ) })?; let target = match target_kind { - 0 => RouteTarget::User(destination_id), - 1 => RouteTarget::Iota(destination_id), + 0 => RelayTarget::User(destination_id), + 1 => RelayTarget::LegacyOmegaIota { + omega: AuthorityId::new(row.get::<_, String>(15)?).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 15, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?, + iota_id: destination_id, + }, + 2 => RelayTarget::Iota(IotaNodeId::new(row.get::<_, String>(14)?).map_err( + |error| { + rusqlite::Error::FromSqlConversionFailure( + 14, + rusqlite::types::Type::Text, + Box::new(error), + ) + }, + )?), _ => { return Err(rusqlite::Error::FromSqlConversionFailure( 2, @@ -146,9 +207,16 @@ pub fn list(limit: i64) -> Result, StorageError> { type_map_version: row.get(6)?, relay_signer_id: row.get(7)?, relay_destination_user_id: row.get(8)?, + signer_principal: row.get::<_, Option>(13)?.map(PrincipalHandle), + destination_principal: row + .get::<_, Option>(12)? + .map(iota_identity::PrincipalHandle), relay_message_id: row.get(9)?, delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?, last_error: row.get(11)?, + attempt_count: row.get(16)?, + last_attempt_at: row.get(17)?, + next_attempt_at: row.get(18)?, }) })?; rows.collect::, _>>().map_err(Into::into) @@ -160,9 +228,12 @@ pub fn list(limit: i64) -> Result, StorageError> { pub fn list_active(limit: i64) -> Result, StorageError> { db::with_db(|connection| { let mut statement = connection.prepare( - "SELECT p.id, p.destination_id, p.target_kind, p.frame, p.created_at, p.frame_id, p.type_map_version, p.relay_signer_id, p.relay_destination_user_id, p.relay_message_id, p.delivery_state, p.last_error FROM pending_relays p WHERE p.delivery_state = 'ready' AND ((p.target_kind = 0 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.destination_id)) OR (p.target_kind = 1 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.relay_signer_id))) ORDER BY p.id LIMIT ?1", + "SELECT p.id, p.destination_id, p.target_kind, p.frame, p.created_at, p.frame_id, p.type_map_version, p.relay_signer_id, p.relay_destination_user_id, p.relay_message_id, p.delivery_state, p.last_error, p.destination_principal, p.signer_principal, p.destination_node_id, p.destination_authority_id, p.attempt_count, p.last_attempt_at, p.next_attempt_at FROM pending_relays p WHERE p.delivery_state = 'ready' AND p.next_attempt_at <= ?1 AND ((p.target_kind = 0 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.destination_id)) OR (p.target_kind IN (1, 2) AND p.signer_principal IS NOT NULL)) ORDER BY p.id LIMIT ?2", + )?; + let rows = statement.query_map( + params![now_millis(), limit.clamp(1, 500)], + pending_relay_from_row, )?; - let rows = statement.query_map(params![limit.clamp(1, 500)], pending_relay_from_row)?; rows.collect::, _>>().map_err(Into::into) }) } @@ -176,7 +247,7 @@ pub fn list_without_relay_identity_after( 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, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error FROM pending_relays WHERE id > ?1 AND (relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?; + let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error, destination_principal, signer_principal, destination_node_id, destination_authority_id, attempt_count, last_attempt_at, next_attempt_at FROM pending_relays WHERE id > ?1 AND (signer_principal IS NULL OR destination_principal IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?; let rows = statement.query_map( params![after_id, limit.clamp(1, 500)], pending_relay_from_row, @@ -189,8 +260,16 @@ fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result(1)?) .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(1, 0))?; let target = match row.get::<_, i64>(2)? { - 0 => RouteTarget::User(destination_id), - 1 => RouteTarget::Iota(destination_id), + 0 => RelayTarget::User(destination_id), + 1 => RelayTarget::LegacyOmegaIota { + omega: AuthorityId::new(row.get::<_, String>(15)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + iota_id: destination_id, + }, + 2 => RelayTarget::Iota( + IotaNodeId::new(row.get::<_, String>(14)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + ), _ => return Err(rusqlite::Error::IntegralValueOutOfRange(2, 0)), }; Ok(PendingRelay { @@ -203,12 +282,55 @@ fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result>(13)?.map(PrincipalHandle), + destination_principal: row + .get::<_, Option>(12)? + .map(iota_identity::PrincipalHandle), relay_message_id: row.get(9)?, delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?, last_error: row.get(11)?, + attempt_count: row.get(16)?, + last_attempt_at: row.get(17)?, + next_attempt_at: row.get(18)?, }) } +pub fn record_retry(id: i64, now: i64, error: &str) -> Result<(), StorageError> { + db::with_db(|connection| { + let attempts = connection.query_row( + "SELECT attempt_count FROM pending_relays WHERE id = ?1", + [id], + |row| row.get::<_, i64>(0), + )?; + let exponent = u32::try_from(attempts.clamp(0, 6)).unwrap_or(6); + let delay = 5_000_i64.saturating_mul(1_i64 << exponent).min(300_000); + connection.execute( + "UPDATE pending_relays SET attempt_count = attempt_count + 1, last_attempt_at = ?2, next_attempt_at = ?3, last_error = ?4 WHERE id = ?1", + params![id, now, now.saturating_add(delay), error], + )?; + Ok(()) + }) +} + +pub fn quarantine_for_frame(frame_id: u32, error: &str) -> Result<(), StorageError> { + db::with_db(|connection| { + connection.execute( + "UPDATE pending_relays SET delivery_state = 'quarantined', last_error = ?2 WHERE frame_id = ?1", + params![i64::from(frame_id), error], + )?; + Ok(()) + }) +} + +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + pub fn mark_client_offline(destination_user_id: i64, frame_id: u32) -> Result<(), StorageError> { db::with_immediate_transaction(|tx| { tx.execute("UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'client_offline' WHERE target_kind = 0 AND destination_id = ?1 AND frame_id = ?2", params![destination_user_id, i64::from(frame_id)])?; @@ -258,7 +380,7 @@ pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageE [id], |row| row.get::<_, i64>(0), )?; - if destination_id != relay.destination_user_id { + if Some(destination_id) != relay.legacy_recipient_id { connection.execute( "UPDATE pending_relays SET delivery_state = 'quarantined', last_error = 'target_recipient_mismatch' WHERE id = ?1", [id], @@ -266,7 +388,7 @@ pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageE return Ok(()); } } - connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4 WHERE id = ?1", params![id, relay.signer_id, relay.destination_user_id, relay.message_id])?; + connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4, signer_principal = ?5, destination_principal = ?6 WHERE id = ?1", params![id, relay.legacy_signer_id, relay.legacy_recipient_id, relay.message_id, relay.signer.0, relay.recipient.0])?; Ok(()) }) } @@ -274,7 +396,7 @@ pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageE pub fn has_unclassified_relays() -> Result { db::with_db(|connection| { connection - .query_row("SELECT EXISTS(SELECT 1 FROM pending_relays WHERE relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL)", [], |row| row.get(0)) + .query_row("SELECT EXISTS(SELECT 1 FROM pending_relays WHERE signer_principal IS NULL OR destination_principal IS NULL OR relay_message_id IS NULL)", [], |row| row.get(0)) .map_err(Into::into) }) } @@ -285,14 +407,24 @@ pub fn find_user_delivery_in_tx( frame_id: u32, ) -> Result, StorageError> { tx.query_row( - "SELECT id, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2", + "SELECT id, relay_signer_id, relay_destination_user_id, relay_message_id, signer_principal, destination_principal FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2", params![destination_id, i64::from(frame_id)], |row| { let signer_id = row.get::<_, Option>(1)?; let destination_user_id = row.get::<_, Option>(2)?; let message_id = row.get::<_, Option>(3)?; match (signer_id, destination_user_id, message_id) { - (Some(signer_id), Some(destination_user_id), Some(message_id)) => Ok((row.get(0)?, RelayIdentity { signer_id, destination_user_id, message_id })), + (Some(signer_id), Some(destination_user_id), Some(message_id)) => { + let signer = row.get::<_, Option>(4)?.ok_or(rusqlite::Error::InvalidQuery)?; + let recipient = row.get::<_, Option>(5)?.ok_or(rusqlite::Error::InvalidQuery)?; + Ok((row.get(0)?, RelayIdentity { + signer: PrincipalHandle(signer), + recipient: PrincipalHandle(recipient), + message_id, + legacy_signer_id: Some(signer_id), + legacy_recipient_id: Some(destination_user_id), + })) + } _ => Err(rusqlite::Error::InvalidQuery), } }, @@ -310,14 +442,14 @@ pub fn acknowledge_in_tx(tx: &Transaction<'_>, id: i64) -> Result<(), StorageErr pub fn reject_outgoing_relay( destination_iota: u64, frame_id: u32, - signer_id: i64, + signer_principal: PrincipalHandle, relay_message_id: &str, ) -> Result<(), StorageError> { let destination_iota = i64::try_from(destination_iota) .map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?; db::with_immediate_transaction(|tx| { tx.execute("DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2", params![destination_iota, i64::from(frame_id)])?; - tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", params![signer_id, relay_message_id, crate::util::sync::now_millis()])?; + tx.execute("UPDATE relay_inbox SET state = 'rejected', rejected_at = COALESCE(rejected_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", params![signer_principal.0, relay_message_id, crate::util::sync::now_millis()])?; Ok(()) }) } diff --git a/iota-storage/src/util/relay_replay.rs b/iota-storage/src/util/relay_replay.rs index 0b7d03a..ee4e476 100644 --- a/iota-storage/src/util/relay_replay.rs +++ b/iota-storage/src/util/relay_replay.rs @@ -15,16 +15,30 @@ pub enum RelayReservation { Existing { state: String, frame_matches: bool }, } -pub fn reserve( - signer_id: u64, - message_id: &str, - created_at: u64, - accepted_at: i64, - destination_id: u64, - frame: &[u8], - frame_id: u32, - type_map_version: &str, -) -> Result { +pub struct RelayReservationRequest<'a> { + pub signer_principal: iota_identity::PrincipalHandle, + pub signer_id: u64, + pub message_id: &'a str, + pub created_at: u64, + pub accepted_at: i64, + pub destination_id: u64, + pub frame: &'a [u8], + pub frame_id: u32, + pub type_map_version: &'a str, +} + +pub fn reserve(request: RelayReservationRequest<'_>) -> Result { + let RelayReservationRequest { + signer_principal, + signer_id, + message_id, + created_at, + accepted_at, + destination_id, + frame, + frame_id, + type_map_version, + } = request; 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) @@ -34,8 +48,9 @@ pub fn reserve( db::with_db(|connection| { let inserted = connection.execute( - "INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, accepted_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'received')", + "INSERT OR IGNORE INTO relay_inbox (signer_principal, signer_id, message_id, created_at, accepted_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'received')", params![ + signer_principal.0, signer_id, message_id, created_at, @@ -52,8 +67,8 @@ pub fn reserve( 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], + "SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_principal = ?1 AND message_id = ?2", + params![signer_principal.0, message_id], |row| { Ok(( row.get(0)?, @@ -119,52 +134,67 @@ pub fn mark_delivered_for_frame_in_tx( } fn mark_transition( - signer_id: u64, + signer_principal: iota_identity::PrincipalHandle, message_id: &str, state: &str, column: &str, ) -> Result<(), StorageError> { - let signer_id = i64::try_from(signer_id) - .map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?; let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; db::with_db(|connection| { connection.execute( - &format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_id = ?1 AND message_id = ?2"), - params![signer_id, message_id, state, timestamp], + &format!("UPDATE relay_inbox SET state = ?3, {column} = COALESCE({column}, ?4) WHERE signer_principal = ?1 AND message_id = ?2"), + params![signer_principal.0, message_id, state, timestamp], )?; Ok(()) }) } -pub fn mark_applied(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "applied", "applied_at") +pub fn mark_applied( + signer_principal: iota_identity::PrincipalHandle, + message_id: &str, +) -> Result<(), StorageError> { + mark_transition(signer_principal, message_id, "applied", "applied_at") } -pub fn mark_queued(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "queued", "queued_at") +pub fn mark_queued( + signer_principal: iota_identity::PrincipalHandle, + message_id: &str, +) -> Result<(), StorageError> { + mark_transition(signer_principal, message_id, "queued", "queued_at") } pub fn mark_queued_in_tx( tx: &rusqlite::Transaction<'_>, - signer_id: i64, + signer_principal: iota_identity::PrincipalHandle, message_id: &str, ) -> Result<(), StorageError> { tx.execute( - "UPDATE relay_inbox SET state = 'queued', queued_at = COALESCE(queued_at, ?3) WHERE signer_id = ?1 AND message_id = ?2", - params![signer_id, message_id, crate::util::sync::now_millis()], + "UPDATE relay_inbox SET state = 'queued', queued_at = COALESCE(queued_at, ?3) WHERE signer_principal = ?1 AND message_id = ?2", + params![signer_principal.0, message_id, crate::util::sync::now_millis()], )?; Ok(()) } -pub fn mark_downstream_acked(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "delivered", "downstream_acked_at") +pub fn mark_downstream_acked( + signer_principal: iota_identity::PrincipalHandle, + message_id: &str, +) -> Result<(), StorageError> { + mark_transition( + signer_principal, + message_id, + "delivered", + "downstream_acked_at", + ) } -pub fn mark_rejected(signer_id: u64, message_id: &str) -> Result<(), StorageError> { - mark_transition(signer_id, message_id, "rejected", "rejected_at") +pub fn mark_rejected( + signer_principal: iota_identity::PrincipalHandle, + message_id: &str, +) -> Result<(), StorageError> { + mark_transition(signer_principal, message_id, "rejected", "rejected_at") } pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> { @@ -174,14 +204,19 @@ pub fn prune_completed(before_terminal_at: i64) -> Result<(), StorageError> { params![before_terminal_at], )?; connection.execute( - "DELETE FROM relay_replay WHERE NOT EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_id = relay_replay.signer_id AND relay_inbox.message_id = relay_replay.message_id)", + "DELETE FROM relay_replay WHERE NOT EXISTS (SELECT 1 FROM relay_inbox WHERE relay_inbox.signer_principal = relay_replay.signer_principal AND relay_inbox.message_id = relay_replay.message_id)", [], )?; Ok(()) }) } -pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result { +pub fn accept( + signer_principal: iota_identity::PrincipalHandle, + 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) @@ -189,8 +224,8 @@ pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result Result<(), rusqlite::Error> { + fn replay_identity_uses_principal_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));", + "CREATE TABLE relay_replay (signer_principal INTEGER NOT NULL, signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_principal, 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], + "INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![70_i64, 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], + "INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![70_i64, 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], + let other_authority_same_numeric_signer = connection.execute( + "INSERT OR IGNORE INTO relay_replay (signer_principal, signer_id, message_id, created_at) VALUES (?1, ?2, ?3, ?4)", + params![71_i64, 7_i64, "message", 2_i64], )?; - assert_eq!((first, duplicate, other_signer), (1, 0, 1)); + assert_eq!( + (first, duplicate, other_authority_same_numeric_signer), + (1, 0, 1) + ); Ok(()) } } diff --git a/iota-storage/tests/descriptor_lifecycle.rs b/iota-storage/tests/descriptor_lifecycle.rs new file mode 100644 index 0000000..84dafe7 --- /dev/null +++ b/iota-storage/tests/descriptor_lifecycle.rs @@ -0,0 +1,132 @@ +use iota_identity::{ + AuthorityKind, LocalNodeIdentity, LocalUserDescriptor, LocalUserId, PrincipalHome, PrincipalId, + PrincipalStore, VerifiedPrincipalDescriptor, +}; +use iota_storage::identity::SqlitePrincipalStore; +use mtp::crypto::Keyring; + +#[test] +fn local_descriptor_revision_persists_changes_and_retirement() { + let storage = tempfile::tempdir().unwrap(); + iota_util::file_util::configure_storage_directory(storage.path().to_owned()); + iota_storage::util::db::initialize_database().unwrap(); + + let identity = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let user_key = Keyring::generate(); + let mut user = LocalUserDescriptor { + id: LocalUserId(9), + username: "alice".into(), + display_name: None, + public_key: user_key.public_key_bundle().try_to_base64().unwrap(), + }; + iota_storage::util::db::with_db(|connection| { + connection.execute( + "INSERT INTO users (user_id, username, public_key, created_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![user.id.0, user.username, user.public_key, 1_i64], + )?; + Ok(()) + }) + .unwrap(); + + let store = SqlitePrincipalStore; + let home = PrincipalHome::Iota(identity.node_id().clone()); + store + .ensure_local_principal( + identity.authority_id(), + AuthorityKind::Iota, + &user, + home.clone(), + 1_000, + ) + .unwrap(); + let first = store.local_principal_descriptor(user.id).unwrap(); + assert_eq!(first.revision, 0); + + store + .ensure_local_principal( + identity.authority_id(), + AuthorityKind::Iota, + &user, + home.clone(), + 1_001, + ) + .unwrap(); + assert_eq!( + store.local_principal_descriptor(user.id).unwrap().revision, + 0 + ); + + user.display_name = Some("Alice".into()); + store + .ensure_local_principal( + identity.authority_id(), + AuthorityKind::Iota, + &user, + home.clone(), + 1_002, + ) + .unwrap(); + assert_eq!( + store.local_principal_descriptor(user.id).unwrap().revision, + 1 + ); + + let replacement_key = Keyring::generate().public_key_bundle(); + user.public_key = replacement_key.try_to_base64().unwrap(); + store + .ensure_local_principal( + identity.authority_id(), + AuthorityKind::Iota, + &user, + home.clone(), + 1_003, + ) + .unwrap(); + let rotated = store.local_principal_descriptor(user.id).unwrap(); + assert_eq!(rotated.revision, 2); + assert_eq!(rotated.public_keys.len(), 1); + assert_eq!( + rotated.public_keys[0].try_to_base64().unwrap(), + replacement_key.try_to_base64().unwrap() + ); + + let renewal_at = rotated.valid_until.unwrap() - 1; + store + .ensure_local_principal( + identity.authority_id(), + AuthorityKind::Iota, + &user, + home, + renewal_at, + ) + .unwrap(); + let renewed = store.local_principal_descriptor(user.id).unwrap(); + assert_eq!(renewed.revision, 3); + let mut conflicting = renewed.clone(); + conflicting.display_name = Some("Changed without revision".into()); + assert!( + store + .upsert_remote_descriptor( + &VerifiedPrincipalDescriptor::from_trusted_authority(conflicting).unwrap() + ) + .is_err() + ); + + store + .retire_local_principal(user.id, renewal_at + 1) + .unwrap(); + assert!(store.local_principal_descriptor(user.id).is_err()); + let principal = PrincipalId { + authority: identity.authority_id().clone(), + user_id: 9, + }; + let retired = store.get_by_canonical_id(&principal).unwrap().unwrap(); + assert_eq!(retired.descriptor_revision, 4); + assert!( + store + .upsert_remote_descriptor( + &VerifiedPrincipalDescriptor::from_trusted_authority(first).unwrap() + ) + .is_err() + ); +} diff --git a/iota-storage/tests/node_directory.rs b/iota-storage/tests/node_directory.rs new file mode 100644 index 0000000..999d1f6 --- /dev/null +++ b/iota-storage/tests/node_directory.rs @@ -0,0 +1,111 @@ +use iota_identity::{ + AuthorityLocator, LocalNodeIdentity, NodeDescriptor, NodeDirectory, NodeIdentityResolver, + SignedNodeDescriptor, +}; +use iota_storage::node_directory::SqliteNodeDirectory; +use mtp::crypto::Keyring; + +#[tokio::test] +async fn node_directory_persists_identity_revision_endpoints_and_pins() { + let storage = tempfile::tempdir().unwrap(); + iota_util::file_util::configure_storage_directory(storage.path().to_owned()); + iota_storage::util::db::initialize_database().unwrap(); + + let identity = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let endpoint = AuthorityLocator::new("iota.example.org:443").unwrap(); + let relay = AuthorityLocator::new("relay.example.org:443").unwrap(); + let now = iota_storage::util::sync::now_millis(); + let signed = SignedNodeDescriptor::sign( + NodeDescriptor { + node: identity.node_id().clone(), + public_keys: identity.public_keys(), + direct_endpoints: vec![endpoint.clone()], + relay_hints: vec![relay.clone()], + revision: 1, + valid_until: Some(now + 60_000), + }, + &identity, + ) + .unwrap(); + let verified = signed.verify(None, now).unwrap(); + SqliteNodeDirectory + .upsert_node_descriptor(&verified) + .unwrap(); + SqliteNodeDirectory + .pin_endpoint(&endpoint, identity.node_id(), now) + .unwrap(); + let certificate = b"test certificate"; + SqliteNodeDirectory + .pin_endpoint_certificate(&endpoint, certificate) + .unwrap(); + SqliteNodeDirectory + .record_successful_endpoint(identity.node_id(), &endpoint) + .unwrap(); + + let resolved = SqliteNodeDirectory + .resolve_node(identity.node_id()) + .await + .unwrap(); + assert_eq!(resolved.descriptor_revision, 1); + assert_eq!(resolved.direct_endpoints, vec![endpoint.clone()]); + assert_eq!(resolved.relay_hints, vec![relay]); + assert_eq!(resolved.last_successful_endpoint, Some(endpoint.clone())); + assert_eq!( + SqliteNodeDirectory.pinned_node(&endpoint).unwrap(), + Some(identity.node_id().clone()) + ); + assert_eq!( + SqliteNodeDirectory + .pinned_endpoint_certificate(&endpoint) + .unwrap(), + Some(certificate.to_vec()) + ); + assert!( + SqliteNodeDirectory + .pin_endpoint_certificate(&endpoint, b"changed certificate") + .is_err() + ); + + let stale = SignedNodeDescriptor::sign( + NodeDescriptor { + node: identity.node_id().clone(), + public_keys: identity.public_keys(), + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + revision: 0, + valid_until: Some(now + 60_000), + }, + &identity, + ) + .unwrap() + .verify(None, now) + .unwrap(); + assert!(SqliteNodeDirectory.upsert_node_descriptor(&stale).is_err()); + + let conflicting = SignedNodeDescriptor::sign( + NodeDescriptor { + node: identity.node_id().clone(), + public_keys: identity.public_keys(), + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + revision: 1, + valid_until: Some(now + 60_000), + }, + &identity, + ) + .unwrap() + .verify(None, now) + .unwrap(); + assert!( + SqliteNodeDirectory + .upsert_node_descriptor(&conflicting) + .is_err() + ); + + let other = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + assert!( + SqliteNodeDirectory + .pin_endpoint(&endpoint, other.node_id(), now + 1) + .is_err() + ); +} diff --git a/iota-storage/tests/pending_retry.rs b/iota-storage/tests/pending_retry.rs new file mode 100644 index 0000000..258eac8 --- /dev/null +++ b/iota-storage/tests/pending_retry.rs @@ -0,0 +1,60 @@ +use iota_identity::{IotaNodeId, PrincipalHandle}; +use iota_storage::util::relay_queue::{ + self, PendingRelayDeliveryState, RelayIdentity, RelayTarget, +}; +use mtp::crypto::Keyring; + +#[test] +fn pending_iota_relay_uses_bounded_backoff_and_quarantine() { + let storage = tempfile::tempdir().unwrap(); + iota_util::file_util::configure_storage_directory(storage.path().to_owned()); + iota_storage::util::db::initialize_database().unwrap(); + let (signer, recipient) = iota_storage::util::db::with_db(|connection| { + connection.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:first', 1, 1, 1)", + [], + )?; + let signer = connection.last_insert_rowid(); + connection.execute( + "INSERT INTO principals (authority_kind, authority_id, remote_user_id, descriptor_revision, last_resolved_at) VALUES ('iota', 'iota:second', 1, 1, 1)", + [], + )?; + Ok((signer, connection.last_insert_rowid())) + }) + .unwrap(); + let node = IotaNodeId::from_public_keys(&Keyring::generate().public_key_bundle()).unwrap(); + relay_queue::enqueue( + RelayTarget::Iota(node), + &RelayIdentity { + signer: PrincipalHandle(signer), + recipient: PrincipalHandle(recipient), + message_id: "retry".into(), + legacy_signer_id: Some(1), + legacy_recipient_id: Some(1), + }, + &[1, 2, 3], + 1, + 7, + "1.0", + ) + .unwrap(); + let id = relay_queue::list(1).unwrap()[0].id; + let now = iota_storage::util::sync::now_millis(); + + for attempt in 0..8 { + relay_queue::record_retry(id, now, "offline").unwrap(); + let relay = relay_queue::list(1).unwrap().remove(0); + let exponent = attempt.min(6); + let expected_delay = (5_000_i64 * (1_i64 << exponent)).min(300_000); + assert_eq!(relay.attempt_count, attempt + 1); + assert_eq!(relay.last_attempt_at, Some(now)); + assert_eq!(relay.next_attempt_at, now + expected_delay); + assert_eq!(relay.last_error.as_deref(), Some("offline")); + } + assert!(relay_queue::list_active(1).unwrap().is_empty()); + + relay_queue::quarantine_for_frame(7, "wrong acknowledgement").unwrap(); + let relay = relay_queue::list(1).unwrap().remove(0); + assert_eq!(relay.delivery_state, PendingRelayDeliveryState::Quarantined); + assert_eq!(relay.last_error.as_deref(), Some("wrong acknowledgement")); +} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index fa1ccb6..d8caa2c 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -5,11 +5,12 @@ edition = "2024" [dependencies] iota-paths = { path = "../iota-paths" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = [ +iota-identity = { path = "../iota-identity" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = [ "crypto" ] } -reqwest = "0.13.2" +reqwest = { version = "0.13.2", features = ["json"] } tokio = { version = "1.50.0", features = ["full"] } sysinfo = "0.38.0" uuid = { version = "*", features = ["v4"] } diff --git a/iota-util/src/authority_discovery.rs b/iota-util/src/authority_discovery.rs new file mode 100644 index 0000000..3f9d47e --- /dev/null +++ b/iota-util/src/authority_discovery.rs @@ -0,0 +1,30 @@ +use iota_identity::{ + AuthorityDiscoveryDocument, AuthorityLocator, DiscoveredAuthority, IdentityError, +}; + +pub async fn discover_authority( + client: &reqwest::Client, + locator: &AuthorityLocator, +) -> Result { + let url = format!("https://{}/.well-known/tensamin", locator.as_str()); + let response = client + .get(&url) + .send() + .await + .map_err(|error| { + IdentityError::Unavailable(format!("authority discovery failed for {url}: {error}")) + })? + .error_for_status() + .map_err(|error| { + IdentityError::Unavailable(format!("authority discovery rejected {url}: {error}")) + })?; + let document = response + .json::() + .await + .map_err(|error| { + IdentityError::InvalidDescriptor(format!( + "invalid authority discovery document from {url}: {error}" + )) + })?; + document.verify() +} diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 1b87352..6ec88ec 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -1,4 +1,5 @@ pub mod atomic_file; +pub mod authority_discovery; pub mod crypto_helper; pub mod crypto_util; pub mod file_util; diff --git a/iota-util/src/tu.rs b/iota-util/src/tu.rs index 3d05508..65fa63f 100644 --- a/iota-util/src/tu.rs +++ b/iota-util/src/tu.rs @@ -3,6 +3,7 @@ * names the file after its owner's username. */ use crate::crypto_helper::{keyring_from_base64, keyring_to_base64}; +use iota_identity::{AuthorityId, IotaNodeId, PrincipalId}; use mtp::crypto::{Keyring, PublicKeyBundle}; use std::fmt; @@ -13,6 +14,7 @@ pub enum TuError { InvalidFormat, InvalidUserId, InvalidKeyring, + InvalidAuthority, } impl fmt::Display for TuError { @@ -21,15 +23,22 @@ impl fmt::Display for TuError { Self::InvalidFormat => "invalid .tu credential format", Self::InvalidUserId => "invalid .tu user id", Self::InvalidKeyring => "invalid .tu keyring", + Self::InvalidAuthority => "invalid .tu authority", }) } } impl std::error::Error for TuError {} +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TuAuthority { + Omega(String), + Iota(IotaNodeId), +} + pub struct TuCredential { pub user_id: i64, - pub omega_host: String, + pub authority: TuAuthority, pub keyring: Keyring, } @@ -37,7 +46,7 @@ impl fmt::Debug for TuCredential { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TuCredential") .field("user_id", &self.user_id) - .field("omega_host", &self.omega_host) + .field("authority", &self.authority) .field("keyring", &"") .finish() } @@ -45,6 +54,13 @@ impl fmt::Debug for TuCredential { impl TuCredential { pub fn parse(input: &str) -> Result { + if let Some(versioned) = input.trim().strip_prefix("tensamin-tu:v2:") { + return Self::parse_v2(versioned); + } + Self::parse_v1(input) + } + + fn parse_v1(input: &str) -> Result { let (identity, encoded_keyring) = input .trim() .split_once("::") @@ -63,22 +79,70 @@ impl TuCredential { let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; Ok(Self { user_id, - omega_host: omega_host.trim().to_owned(), + authority: TuAuthority::Omega(omega_host.trim().to_owned()), keyring, }) } + fn parse_v2(input: &str) -> Result { + let (identity, encoded_keyring) = input.split_once("::").ok_or(TuError::InvalidFormat)?; + if encoded_keyring.is_empty() || encoded_keyring.contains("::") { + return Err(TuError::InvalidFormat); + } + let (user_id, node_id) = identity.split_once('@').ok_or(TuError::InvalidFormat)?; + if node_id.contains('@') { + return Err(TuError::InvalidFormat); + } + let user_id = user_id.parse::().map_err(|_| TuError::InvalidUserId)?; + if !(1..=MAX_PROTOCOL_ID).contains(&user_id) { + return Err(TuError::InvalidUserId); + } + let node_id = IotaNodeId::new(node_id).map_err(|_| TuError::InvalidAuthority)?; + let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?; + Ok(Self { + user_id, + authority: TuAuthority::Iota(node_id), + keyring, + }) + } + + pub fn omega_host(&self) -> Option<&str> { + match &self.authority { + TuAuthority::Omega(host) => Some(host), + TuAuthority::Iota(_) => None, + } + } + + pub fn principal(&self) -> Result { + let user_id = u64::try_from(self.user_id).map_err(|_| TuError::InvalidUserId)?; + let authority = match &self.authority { + TuAuthority::Omega(host) => { + AuthorityId::omega_legacy(host).map_err(|_| TuError::InvalidAuthority)? + } + TuAuthority::Iota(node) => AuthorityId::for_iota(node), + }; + Ok(PrincipalId { authority, user_id }) + } + pub fn public_key_bundle(&self) -> PublicKeyBundle { self.keyring.public_key_bundle() } pub fn to_canonical_string(&self) -> String { - format!( - "{}@{}::{}", - self.user_id, - self.omega_host, - keyring_to_base64(&self.keyring) - ) + match &self.authority { + TuAuthority::Omega(host) => format!( + "{}@{}::{}", + self.user_id, + host, + keyring_to_base64(&self.keyring) + ), + TuAuthority::Iota(node) => format!( + "tensamin-tu:v2:{}@{}::{}", + self.user_id, + node.as_str(), + keyring_to_base64(&self.keyring) + ), + } } } @@ -91,18 +155,37 @@ mod tests { fn round_trip_is_canonical() { let credential = TuCredential { user_id: 42, - omega_host: "omega.example:443".into(), + authority: TuAuthority::Omega("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.omega_host(), Some("omega.example:443")); assert_eq!( parsed.to_canonical_string(), credential.to_canonical_string() ); } + #[test] + fn decentralized_round_trip_preserves_principal() { + let node = iota_identity::LocalNodeIdentity::from_keyring(generate_keyring()) + .unwrap() + .node_id() + .clone(); + let credential = TuCredential { + user_id: 7, + authority: TuAuthority::Iota(node.clone()), + keyring: generate_keyring(), + }; + let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap(); + assert_eq!(parsed.authority, TuAuthority::Iota(node.clone())); + assert_eq!( + parsed.principal().unwrap().authority, + AuthorityId::for_iota(&node) + ); + } + #[test] fn rejects_malformed_credentials() { for value in [ diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 1fc0858..b05cfe8 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -13,6 +13,7 @@ iota-paths = { path = "../iota-paths" } iota-terms = { path = "../iota-terms" } iota-util = { path = "../iota-util" } tokio = { version = "1.50.0", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } serde_json = "1" serde_yaml = "0.9" clap = { version = "4.5", features = ["derive"] } diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs index 1953e78..da490c7 100644 --- a/iota/src/daemon_setup_flow.rs +++ b/iota/src/daemon_setup_flow.rs @@ -6,7 +6,7 @@ use iota_cli::{ LaunchOption, }, theme::UiConfig, - ui::UI, + ui::UiHandle, }; use iota_process_manager::ProcessManager; use std::{ @@ -61,7 +61,7 @@ impl Capabilities { } } pub async fn run( - ui: Arc, + ui: Arc, endpoints: &DaemonEndpoints, caps: Capabilities, ) -> Result { @@ -175,17 +175,17 @@ fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) - } } async fn start_local_with_ui( - ui: Arc, + ui: Arc, exe: &Path, path: &Path, ) -> Result { tokio::select! { - result = crate::local_daemon::launch(ui.clone(), exe, path) => result.map(|ipc| ConnectionContext { ipc }), + result = crate::local_daemon::launch(ui.cancellation_token(), exe, path) => result.map(|ipc| ConnectionContext { ipc }), _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), } } async fn show( - ui: Arc, + ui: Arc, options: Vec, message: impl Into, ) -> Result { diff --git a/iota/src/local_daemon.rs b/iota/src/local_daemon.rs index 4af10ea..a83a139 100644 --- a/iota/src/local_daemon.rs +++ b/iota/src/local_daemon.rs @@ -1,5 +1,5 @@ use crate::startup_error::StartupError; -use iota_cli::{ipc_client::IpcClient, ui::UI}; +use iota_cli::ipc_client::IpcClient; use std::process::Stdio; use std::{ collections::VecDeque, @@ -12,6 +12,7 @@ use tokio::{ process::{Child, Command}, time::Instant, }; +use tokio_util::sync::CancellationToken; struct LocalDaemonGuard { child: Option, @@ -43,7 +44,7 @@ impl Drop for LocalDaemonGuard { } pub async fn launch( - ui: Arc, + cancellation: CancellationToken, executable: &Path, socket: &Path, ) -> Result, StartupError> { @@ -73,7 +74,6 @@ pub async fn launch( } let mut guard = LocalDaemonGuard::new(child); let deadline = Instant::now() + Duration::from_secs(20); - let cancellation = ui.cancellation_token(); loop { let result = tokio::select! { status = guard.child.as_mut().expect("child").wait() => { diff --git a/iota/src/main.rs b/iota/src/main.rs index 9eddc8a..23c1418 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -1,6 +1,6 @@ use iota_cli::{ ipc_client::IpcClient, screens::main_screen::MainScreen, theme, - ui::start_bootstrap_tui_with_theme, + ui::start_bootstrap_tui_with_config, }; use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult}; use iota_process_manager::detect; @@ -250,37 +250,16 @@ async fn run_dashboard( }, policy => policy, }; - let color_enabled = match color_policy { - CapabilityPolicy::Always => true, - CapabilityPolicy::Never => false, - CapabilityPolicy::Auto => { - std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb") - } + let to_terminal_policy = |policy| match policy { + CapabilityPolicy::Auto => theme::TerminalPolicy::Auto, + CapabilityPolicy::Always => theme::TerminalPolicy::Always, + CapabilityPolicy::Never => theme::TerminalPolicy::Never, }; - let unicode_enabled = match unicode_policy { - CapabilityPolicy::Always => true, - CapabilityPolicy::Never => false, - CapabilityPolicy::Auto => std::env::var("LC_ALL") - .or_else(|_| std::env::var("LC_CTYPE")) - .or_else(|_| std::env::var("LANG")) - .map(|locale| { - let locale = locale.to_ascii_lowercase(); - locale.contains("utf-8") || locale.contains("utf8") - }) - .unwrap_or(false), - }; - let truecolor_enabled = std::env::var("COLORTERM") - .map(|value| { - let value = value.to_ascii_lowercase(); - value.contains("truecolor") || value.contains("24bit") - }) - .unwrap_or(false); - let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile( + let session = start_bootstrap_tui_with_config( theme::UiConfig::resolve_theme(theme_override), - color_enabled, - unicode_enabled, - truecolor_enabled, - )) + to_terminal_policy(color_policy), + to_terminal_policy(unicode_policy), + ) .map_err(|error| StartupError::Terminal(error.to_string()))?; let ui = session.ui(); let result = async { @@ -319,8 +298,8 @@ async fn run_dashboard( }; let ipc = context.ipc.clone(); ipc.spawn_reconnector(); - ui.attach_daemon(ipc).await; - let main_screen = MainScreen::new(ui.clone()).await; + ui.attach_daemon(ipc.clone()).await; + let main_screen = MainScreen::new(ipc.clone()); ui.set_root_screen(Box::new(main_screen)).await; ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?; ui.wait_for_shutdown().await; diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 15ffcf4..c10f784 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -6,11 +6,13 @@ edition = "2024" [dependencies] async-trait = "0.1.89" iota-connection = { path = "../iota-connection" } +iota-auth = { path = "../iota-auth" } +iota-identity = { path = "../iota-identity" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = [ +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = [ "client", "crypto", "files", diff --git a/omikron-connector/src/identity.rs b/omikron-connector/src/identity.rs new file mode 100644 index 0000000..ea47604 --- /dev/null +++ b/omikron-connector/src/identity.rs @@ -0,0 +1,414 @@ +use async_trait::async_trait; +use iota_identity::{ + AuthorityId, AuthorityKind, AuthorityLocator, IdentityError, IdentityResolver, + PrincipalDescriptor, PrincipalHome, PrincipalId, PrincipalStore, ResolutionContext, + ResolvedPrincipal, UserAddress, UserSelector, +}; +use iota_util::crypto_helper::public_key_bundle_from_base64; +use iota_util::mtp_compat::OptionalDataValueExt; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::OmikronClient; + +pub struct OmegaIdentityResolver { + client: Arc, + authority: AuthorityId, + locator: AuthorityLocator, + principals: Arc, +} + +impl OmegaIdentityResolver { + pub fn new( + client: Arc, + authority: AuthorityId, + locator: AuthorityLocator, + principals: Arc, + ) -> Self { + Self { + client, + authority, + locator, + principals, + } + } + + pub fn authority(&self) -> &AuthorityId { + &self.authority + } + + async fn resolve_remote( + &self, + selector: &UserSelector, + ) -> Result { + resolve_omega_principal( + self.client.as_ref(), + self.principals.as_ref(), + &self.authority, + &self.locator, + selector, + ) + .await + } + + async fn resolve_cached_or_remote( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result { + if principal.authority != self.authority { + return Err(IdentityError::NotFound); + } + let cached = self.principals.get_by_canonical_id(principal)?; + let now = now_millis(); + if let Some(resolved) = &cached + && resolved.is_valid_at(now) + && !resolved.public_keys.is_empty() + && now.saturating_sub(resolved.resolved_at) < 900_000 + { + return Ok(resolved.clone()); + } + if context.allow_network { + match self + .resolve_remote(&UserSelector::UserId(principal.user_id)) + .await + { + Ok(resolved) => return Ok(resolved), + Err(IdentityError::Unavailable(_)) => {} + Err(error) => return Err(error), + } + } + match cached { + Some(resolved) + if resolved.is_valid_at(now) + && !resolved.public_keys.is_empty() + && context + .offline_policy + .allows_cached(resolved.resolved_at, now) => + { + Ok(resolved) + } + Some(_) => Err(IdentityError::Unavailable( + "cached principal descriptor is stale".into(), + )), + None => Err(IdentityError::NotFound), + } + } +} + +pub async fn resolve_omega_principal( + client: &dyn OmikronClient, + principals: &dyn PrincipalStore, + authority: &AuthorityId, + locator: &AuthorityLocator, + selector: &UserSelector, +) -> Result { + let request = match selector { + UserSelector::UserId(user_id) => CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default( + DataType::UserId, + DataValue::UnsignedNumber(u128::from(*user_id)), + ), + UserSelector::Username(username) => CommunicationValue::new(CommunicationType::GetUserData) + .add_typed_default(DataType::Username, DataValue::Str(username.clone())), + }; + let response = client + .await_response(&request, Duration::from_secs(10)) + .await + .map_err(|error| IdentityError::Unavailable(error.to_string()))?; + if !response.is_type(CommunicationType::GetUserData) { + return Err(IdentityError::InvalidDescriptor( + "Omega returned an unexpected identity response".into(), + )); + } + let user_id = response + .get_data(DataType::UserId) + .as_number() + .and_then(|value| u64::try_from(value).ok()) + .or(match selector { + UserSelector::UserId(user_id) => Some(*user_id), + UserSelector::Username(_) => None, + }) + .ok_or_else(|| IdentityError::InvalidDescriptor("Omega returned no user ID".into()))?; + let public_key = response + .get_data(DataType::PublicKey) + .as_str() + .and_then(public_key_bundle_from_base64) + .ok_or_else(|| { + IdentityError::InvalidDescriptor("Omega returned no valid user key".into()) + })?; + let username = response + .get_data(DataType::Username) + .as_str() + .map(str::to_owned); + let home = response + .get_data(DataType::IotaId) + .as_number() + .and_then(|value| u64::try_from(value).ok()) + .filter(|value| *value > 0) + .map(|iota_id| PrincipalHome::LegacyOmegaIota { + omega: authority.clone(), + iota_id, + }) + .unwrap_or_else(|| PrincipalHome::Omega(locator.clone())); + let principal = PrincipalId { + authority: authority.clone(), + user_id, + }; + let descriptor = + iota_identity::VerifiedPrincipalDescriptor::from_trusted_authority(PrincipalDescriptor { + principal: principal.clone(), + authority_kind: AuthorityKind::Omega, + username: username.clone(), + display_name: None, + public_keys: vec![public_key], + home, + revision: 0, + valid_until: None, + issued_at: now_millis(), + })?; + let handle = principals.upsert_remote_descriptor(&descriptor)?; + principals + .get_principal(handle)? + .ok_or(IdentityError::NotFound) +} + +#[async_trait] +impl IdentityResolver for OmegaIdentityResolver { + async fn resolve_address( + &self, + address: &UserAddress, + context: &ResolutionContext, + ) -> Result { + if let Some(locator) = &address.authority + && locator != &self.locator + && locator.as_str() != self.authority.as_str() + { + return Err(IdentityError::NotFound); + } + let cached = match &address.selector { + UserSelector::UserId(user_id) => self.principals.get_by_canonical_id(&PrincipalId { + authority: self.authority.clone(), + user_id: *user_id, + })?, + UserSelector::Username(_) => None, + }; + let now = now_millis(); + let resolved = match cached { + Some(resolved) + if resolved.is_valid_at(now) + && !resolved.public_keys.is_empty() + && now.saturating_sub(resolved.resolved_at) < 900_000 => + { + resolved + } + None if context.allow_network => self.resolve_remote(&address.selector).await?, + Some(_) if context.allow_network => self.resolve_remote(&address.selector).await?, + Some(resolved) + if resolved.is_valid_at(now) + && !resolved.public_keys.is_empty() + && context + .offline_policy + .allows_cached(resolved.resolved_at, now) => + { + resolved + } + None => return Err(IdentityError::NotFound), + Some(_) => { + return Err(IdentityError::Unavailable( + "cached principal descriptor is stale".into(), + )); + } + }; + verify_pin(address, &resolved)?; + Ok(resolved) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + if principal.authority != self.authority { + return Err(IdentityError::NotFound); + } + if let Some(cached) = self.principals.get_by_canonical_id(principal)? + && cached.is_valid_at(now_millis()) + && !cached.public_keys.is_empty() + && now_millis().saturating_sub(cached.resolved_at) < 900_000 + { + return Ok(cached); + } + self.resolve_remote(&UserSelector::UserId(principal.user_id)) + .await + } + + async fn resolve_principal_with_context( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result { + self.resolve_cached_or_remote(principal, context).await + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result, IdentityError> { + self.resolve_cached_or_remote(principal, context) + .await + .map(|resolved| resolved.public_keys) + } +} + +fn verify_pin(address: &UserAddress, resolved: &ResolvedPrincipal) -> Result<(), IdentityError> { + let Some(pin) = &address.public_key_pin else { + return Ok(()); + }; + let pin = pin + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + if resolved.public_keys.iter().any(|key| { + key.try_as_bytes() + .map(|candidate| candidate == pin) + .unwrap_or(false) + }) { + Ok(()) + } else { + Err(IdentityError::KeyPinMismatch) + } +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use iota_identity::{OfflineResolutionPolicy, PrincipalHandle}; + use mtp::crypto::Keyring; + + struct CachedStore { + resolved: ResolvedPrincipal, + } + + impl PrincipalStore for CachedStore { + fn get_principal( + &self, + handle: PrincipalHandle, + ) -> Result, IdentityError> { + Ok((handle == self.resolved.handle).then(|| self.resolved.clone())) + } + + fn get_by_canonical_id( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError> { + Ok((principal == &self.resolved.principal).then(|| self.resolved.clone())) + } + + fn get_by_username( + &self, + authority: &AuthorityId, + username: &str, + ) -> Result, IdentityError> { + Ok((authority == &self.resolved.principal.authority + && self.resolved.username.as_deref() == Some(username)) + .then(|| self.resolved.clone())) + } + + fn upsert_remote_descriptor( + &self, + _: &iota_identity::VerifiedPrincipalDescriptor, + ) -> Result { + Err(IdentityError::Storage( + "unexpected descriptor update".into(), + )) + } + + fn signing_keys( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError> { + self.get_by_canonical_id(principal)? + .map(|resolved| resolved.public_keys) + .ok_or(IdentityError::NotFound) + } + } + + struct OfflineClient; + + #[async_trait] + impl OmikronClient for OfflineClient { + async fn send_message(&self, _: &CommunicationValue) -> Result<(), crate::OmikronError> { + Err(crate::OmikronError::Disconnected("offline".into())) + } + + async fn await_response( + &self, + _: &CommunicationValue, + _: Duration, + ) -> Result { + Err(crate::OmikronError::Disconnected("offline".into())) + } + + async fn reconnect(&self) -> Result<(), crate::OmikronError> { + Err(crate::OmikronError::Disconnected("offline".into())) + } + + async fn rotate_identity(&self) -> Result<(), crate::OmikronError> { + Err(crate::OmikronError::Disconnected("offline".into())) + } + + async fn is_connected(&self) -> bool { + false + } + } + + #[tokio::test] + async fn network_preferred_resolution_falls_back_to_unexpired_cache() { + let authority = AuthorityId::new("omega:remote.example").unwrap(); + let principal = PrincipalId { + authority: authority.clone(), + user_id: 7, + }; + let resolved = ResolvedPrincipal { + principal: principal.clone(), + handle: PrincipalHandle(9), + username: Some("cached-user".into()), + public_keys: vec![Keyring::generate().public_key_bundle()], + home: PrincipalHome::Omega(AuthorityLocator::new("remote.example").unwrap()), + descriptor_revision: 0, + valid_until: None, + resolved_at: now_millis().saturating_sub(16 * 60 * 1000), + }; + let resolver = OmegaIdentityResolver::new( + Arc::new(OfflineClient), + authority, + AuthorityLocator::new("remote.example").unwrap(), + Arc::new(CachedStore { resolved }), + ); + + let cached = resolver + .resolve_principal_with_context( + &principal, + &ResolutionContext { + allow_network: true, + offline_policy: OfflineResolutionPolicy::AllowUnexpired { + max_staleness: Duration::from_secs(24 * 60 * 60), + }, + }, + ) + .await + .unwrap(); + + assert_eq!(cached.handle, PrincipalHandle(9)); + } +} diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index 69044c0..c2d1f6a 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -1,6 +1,8 @@ pub mod client; +pub mod identity; pub mod omega_discovery; pub mod omikron_connection; +pub mod router; pub mod user_ops; pub use client::{OmikronClient, OmikronError, OmikronStartupError}; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 6e05380..2d0665b 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -3,9 +3,7 @@ use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_state::AppState; use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::relay_replay; -use iota_storage::util::{ - chat_files, chats_util, client_relay_delivery, outgoing_relay, relay_queue, -}; +use iota_storage::util::{chat_files, client_relay_delivery, relay_queue}; use iota_util::crypto_helper::{self, keyring_from_base64}; use iota_util::crypto_util::{self}; use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; @@ -13,10 +11,9 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, Da use mtp::crypto::{Keyring, PublicKeyBundle}; use rand_core::RngCore; use std::env; -use std::fs; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{Arc, LazyLock}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{Mutex, RwLock, Semaphore, oneshot, watch}; use tokio::task::JoinHandle; @@ -33,7 +30,6 @@ use iota_connection::relay::{ RelayValidationError, forward_verified_relay, open_verified_relay_content, verify_relay_metadata, }; -use iota_util::route_target::RouteTarget; // ============================================================================ // Configuration @@ -42,26 +38,6 @@ use iota_util::route_target::RouteTarget; const IOTA_KEYRING_PATH: &str = "iota.mk"; static IDENTITY_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); static OMIKRON_TRUST_DIRECTORY: std::sync::OnceLock = std::sync::OnceLock::new(); -static NEXT_CLIENT_EVENT_ID: AtomicU32 = AtomicU32::new(1); - -fn next_client_event_id() -> u32 { - NEXT_CLIENT_EVENT_ID.fetch_add(1, Ordering::Relaxed).max(1) -} - -fn record_origin_delivery_failure(signer_id: u64, relay_message_id: &str, failure: &str) { - let Ok(storage_owner) = i64::try_from(signer_id) else { - return; - }; - if let Err(error) = chat_files::record_delivery_failure( - storage_owner, - storage_owner, - relay_message_id, - failure, - now_millis_i64(), - ) { - log!("Relay delivery failure storage failed: {error}"); - } -} /* * Keeps identity and pinned Omikron key files independent from the process @@ -282,29 +258,6 @@ fn next_invitation_retry_delay(current: Duration) -> Duration { (current * 2).min(Duration::from_secs(300)) } -#[derive(Debug)] -pub enum IdentityError { - Storage(mtp::files::FileError), - Directory(std::io::Error), - InvalidLegacyIdentity, - Verification(String), -} - -impl std::fmt::Display for IdentityError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Storage(error) => write!(f, "identity storage error: {error}"), - Self::Directory(error) => write!(f, "unable to create identity directory: {error}"), - Self::InvalidLegacyIdentity => f.write_str("legacy identity is invalid"), - Self::Verification(error) => { - write!(f, "persisted identity could not be verified: {error}") - } - } - } -} - -impl std::error::Error for IdentityError {} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ConnectionAttemptResult { became_healthy: bool, @@ -343,59 +296,22 @@ fn wire_user_id(user_id: i64) -> u64 { u64::try_from(user_id).expect("validated user ID is non-negative") } -/* - * The identity is stored in the Iota state directory as raw keyring bytes so - * daemon restarts do not depend on a separately managed passphrase. - */ -fn save_keyring_verified(keyring: &Keyring, path: &Path) -> Result<(), IdentityError> { - mtp::files::save_keyring_raw(keyring, path).map_err(IdentityError::Storage)?; - let persisted = mtp::files::load_keyring_raw(path).map_err(IdentityError::Storage)?; - let expected = keyring - .try_to_bytes() - .map_err(|error| IdentityError::Verification(error.to_string()))?; - let actual = persisted - .try_to_bytes() - .map_err(|error| IdentityError::Verification(error.to_string()))?; - if expected != actual { - return Err(IdentityError::Verification( - "persisted keyring differs from the requested identity".into(), - )); - } - Ok(()) -} - fn load_or_migrate_keyring_at( path: &Path, legacy: Option, -) -> Result { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).map_err(IdentityError::Directory)?; - } - - match mtp::files::load_keyring_raw(path) { - Ok(keyring) => return Ok(keyring), - Err(mtp::files::FileError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(IdentityError::Storage(error)), - } - - let keyring = match legacy { - Some(encoded) => { - keyring_from_base64(&encoded).ok_or(IdentityError::InvalidLegacyIdentity)? - } - None => { - log!( - "No existing Iota identity found at {}; generating a new identity", - path.display() - ); - crypto_helper::generate_keyring() - } - }; - - save_keyring_verified(&keyring, path)?; - Ok(keyring) +) -> Result { + let legacy = legacy + .map(|encoded| { + keyring_from_base64(&encoded).ok_or_else(|| { + iota_identity::LocalNodeIdentityError::InvalidIdentity( + iota_identity::IdentityError::InvalidIdentifier( + "legacy Iota keyring is invalid".into(), + ), + ) + }) + }) + .transpose()?; + iota_identity::LocalNodeIdentity::load_or_create(path, legacy) } // ============================================================================ @@ -429,6 +345,19 @@ pub enum ConnectionState { Connected { identified: bool }, } +#[derive(Clone)] +struct PendingAppChallenge { + challenge: String, + user_id: i64, + app_identifier: String, +} + +#[derive(Clone)] +struct AppSession { + connection_id: Uuid, + app_identifier: String, +} + impl ConnectionState { pub fn is_connected(&self) -> bool { matches!(self, ConnectionState::Connected { .. }) @@ -456,13 +385,15 @@ pub struct OmikronConnection { reconnect_on_close: Arc>, auth_failure: Arc>>, keyring: Arc>>>, - pub app_challenges: Arc>, - pub app_sessions: Arc>, + app_challenges: Arc>, + app_sessions: Arc>, + session_manager: Arc, handler_semaphore: Arc, cancellation: CancellationToken, pub(crate) active_tasks: Arc>, pub(crate) app: Arc>, invitation_sync: Arc, + relay_service: Arc>>, } impl OmikronConnection { @@ -492,11 +423,58 @@ impl OmikronConnection { keyring: Arc::new(RwLock::new(None)), app_challenges: Arc::new(DashMap::new()), app_sessions: Arc::new(DashMap::new()), + session_manager: Arc::new(iota_auth::SessionManager::default()), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), cancellation, active_tasks, app, invitation_sync: Arc::new(InvitationSyncRuntime::new()), + relay_service: Arc::new(OnceLock::new()), + } + } + + pub fn install_relay_service( + &self, + relay: Arc, + ) -> Result<(), Arc> { + self.relay_service.set(relay) + } + + pub fn relay_service(&self) -> Option> { + self.relay_service.get().cloned() + } + + pub fn session_manager(&self) -> Arc { + self.session_manager.clone() + } + + async fn dispatch_relay(self: Arc, frame: CommunicationValue) { + let Some(relay) = self.relay_service() else { + log!("Rejecting Omikron Relay because the daemon relay service is unavailable"); + return; + }; + match relay + .accept_relay( + iota_connection::relay_service::IngressSource::Omikron { + connection_id: self.connection_id.to_string(), + }, + frame, + ) + .await + { + Ok(outcome) => { + let (ingress_response, local_deliveries) = outcome.into_parts(); + for response in ingress_response + .into_iter() + .chain(local_deliveries.into_iter().map(|delivery| delivery.frame)) + { + if let Err(error) = self.send_message(&response).await { + log!("Relay outcome delivery failed: {error}"); + break; + } + } + } + Err(error) => log!("Relay service rejected Omikron ingress: {error:?}"), } } @@ -616,11 +594,11 @@ impl OmikronConnection { self.set_state(ConnectionState::Connecting).await; log_t!("omikron_connecting"); - let keyring = Arc::new( - self.load_or_migrate_keyring() - .await - .map_err(|error| format!("Iota identity initialization failed: {error}"))?, - ); + let identity = self + .load_or_migrate_keyring() + .await + .map_err(|error| format!("Iota identity initialization failed: {error}"))?; + let keyring = identity.keyring(); *self.keyring.write().await = Some(keyring.clone()); let existing_iota_id = CONFIG.load().iota_id; @@ -732,7 +710,9 @@ impl OmikronConnection { // Identity (own Keyring, migrated from the legacy base64-in-config format) // ------------------------------------------------------------------------- - async fn load_or_migrate_keyring(&self) -> Result { + async fn load_or_migrate_keyring( + &self, + ) -> Result { load_or_migrate_keyring_at(identity_path(), CONFIG.load().keyring.clone()) } @@ -1058,7 +1038,7 @@ impl OmikronConnection { let self_clone = self.clone(); tokio::spawn(async move { let _permit = permit; - self_clone.handle_relay(cv).await; + self_clone.dispatch_relay(cv).await; }); continue; } @@ -1143,1041 +1123,60 @@ impl OmikronConnection { &self, signer_id: u64, ) -> Result, RelayValidationError> { - let signer_id_i64 = i64::try_from(signer_id).map_err(|_| { - RelayValidationError::KeyLookup("signer ID exceeds local storage range".into()) - })?; - let local_user = iota_storage::users::user_manager::get_user(signer_id_i64) - .map_err(|error| RelayValidationError::KeyLookup(error.to_string()))?; - if let Some(user) = local_user { - 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(u128::from(signer_id)), - ); - let response = self - .await_response(&request, Some(Duration::from_secs(10))) + self.resolve_relay_principal(signer_id) .await - .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]) + .map(|principal| principal.public_keys) } - pub async fn hosting_iota_for_user(&self, user_id: u64) -> Result { - let user_id_i64 = i64::try_from(user_id) - .map_err(|_| "user ID exceeds local storage range".to_string())?; - if iota_storage::users::user_manager::get_user(user_id_i64) - .map_err(|error| error.to_string())? - .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(u128::from(user_id)), - ); - 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 send_relay_success( + async fn resolve_relay_principal( &self, - frame_id: Option, - iota_id: u64, - relay_message_id: &str, - accepted_at: i64, - include_origin_timestamp: bool, - ) { - let Some(frame_id) = frame_id else { return }; - let response = CommunicationValue::new(CommunicationType::Success) - .with_id(frame_id) - .add_typed_default(DataType::IotaId, DataValue::UnsignedNumber(iota_id.into())) - .add_typed_default( - DataType::RelayMessageId, - DataValue::Str(relay_message_id.to_string()), - ) - .add_typed_default( - DataType::RelayAcceptedAt, - DataValue::SignedNumber(accepted_at.into()), - ); - let response = if include_origin_timestamp { - response - .add_typed_default( - DataType::OriginIotaReceivedAt, - DataValue::SignedNumber(accepted_at.into()), - ) - .add_typed_default( - DataType::DestinationIotaReceivedAt, - DataValue::SignedNumber(accepted_at.into()), - ) - } else { - response - }; - if let Err(error) = self.send_message(&response).await { - log!("Relay response could not be sent: {}", error); - } - } - - fn client_event_from_relay( - message_type: CommunicationType, - payload: &DataValue, signer_id: u64, - recipient_id: u64, - relay_message_id: &str, - accepted_at: i64, - ) -> Option { - let sender = DataValue::UnsignedNumber(u128::from(signer_id)); - let receiver = recipient_id; - let event_id = next_client_event_id(); - - match message_type { - CommunicationType::MessageSend => { - let frame = CommunicationValue::new(CommunicationType::MessageSend) - .with_payload(payload.clone()); - let content = frame.get_data(DataType::Content)?.clone(); - let send_time = frame.get_data(DataType::SendTime)?.clone(); - let version = frame.get_data(DataType::VersionNumber)?.clone(); - let mut message = vec![ - (DataType::Content, content), - (DataType::SendTime, send_time), - (DataType::VersionNumber, version), - ( - DataType::RelayMessageId, - DataValue::Str(relay_message_id.to_string()), - ), - (DataType::MessageState, DataValue::Str("sent".to_string())), - ]; - if let Some(reply_id) = frame.get_data(DataType::ReplyId) { - message.push((DataType::ReplyId, reply_id.clone())); - } - let mut event = CommunicationValue::new(CommunicationType::MessageLive) - .with_id(event_id) - .with_sender(signer_id) - .with_receiver(receiver) - .add_typed_default(DataType::SenderId, sender) - .add_typed_default(DataType::Message, typed_container(message)) - .add_typed_default( - DataType::LastMessageAt, - DataValue::SignedNumber(accepted_at.into()), - ); - if let (Ok(owner), Ok(partner)) = - (i64::try_from(recipient_id), i64::try_from(signer_id)) - && let Ok(Some(contact)) = chats_util::get_user(owner, partner) - { - event = event.add_typed_default( - DataType::Notifications, - DataValue::SignedNumber(contact.notifications.into()), - ); - if let Some(last_message_at) = contact.last_message_at { - event = event.add_typed_default( - DataType::LastMessageAt, - DataValue::SignedNumber(last_message_at.into()), - ); - } - } - Some(event) - } - CommunicationType::SetChatSecret => { - let frame = CommunicationValue::new(CommunicationType::SetChatSecret) - .with_payload(payload.clone()); - let chat_id = frame.get_data(DataType::ChatId)?.clone(); - let secret_id = frame.get_data(DataType::SecretId)?.clone(); - let version = frame.get_data(DataType::VersionNumber)?.clone(); - Some( - CommunicationValue::new(CommunicationType::ChatSecretForward) - .with_id(event_id) - .with_sender(signer_id) - .with_receiver(receiver) - .add_typed_default(DataType::ChatId, chat_id) - .add_typed_default( - DataType::SenderUserId, - DataValue::Str(signer_id.to_string()), - ) - .add_typed_default( - DataType::RecipientUserId, - DataValue::Str(recipient_id.to_string()), - ) - .add_typed_default(DataType::SecretId, secret_id) - .add_typed_default(DataType::VersionNumber, version) - .add_typed_default( - DataType::Payload, - DataValue::Str("available".to_string()), - ), - ) - } - CommunicationType::MessageEdit => { - let frame = CommunicationValue::new(CommunicationType::MessageEdit) - .with_payload(payload.clone()); - Some( - CommunicationValue::new(CommunicationType::MessageEditLive) - .with_id(event_id) - .with_sender(signer_id) - .with_receiver(receiver) - .add_typed_default( - DataType::Content, - frame.get_data(DataType::Content)?.clone(), - ) - .add_typed_default( - DataType::SendTime, - frame.get_data(DataType::SendTime)?.clone(), - ) - .add_typed_default( - DataType::VersionNumber, - frame.get_data(DataType::VersionNumber)?.clone(), - ) - .add_typed_default(DataType::ChatPartnerId, sender.clone()), - ) - } - CommunicationType::MessageReactionAdd | CommunicationType::MessageReactionRemove => { - let frame = CommunicationValue::new(message_type).with_payload(payload.clone()); - Some( - CommunicationValue::new(CommunicationType::MessageReactionLive) - .with_id(event_id) - .with_sender(signer_id) - .with_receiver(receiver) - .add_typed_default( - DataType::Reaction, - frame.get_data(DataType::Reaction)?.clone(), - ) - .add_typed_default( - DataType::SendTime, - frame.get_data(DataType::SendTime)?.clone(), - ) - .add_typed_default(DataType::ChatPartnerId, sender.clone()) - .add_typed_default(DataType::SenderId, sender) - .add_typed_default( - DataType::Accepted, - DataValue::Bool(message_type == CommunicationType::MessageReactionAdd), - ), - ) - } - CommunicationType::MessageDelete | CommunicationType::MessageDeleteLive => { - let frame = CommunicationValue::new(CommunicationType::MessageDelete) - .with_payload(payload.clone()); - Some( - CommunicationValue::new(CommunicationType::MessageDeleteLive) - .with_id(event_id) - .with_sender(signer_id) - .with_receiver(receiver) - .add_typed_default( - DataType::SendTime, - frame.get_data(DataType::SendTime)?.clone(), - ) - .add_typed_default(DataType::ChatPartnerId, sender), - ) - } - CommunicationType::AddConversation if recipient_id < signer_id => { - let chat_id = format!("{recipient_id}:{signer_id}"); - Some( - CommunicationValue::new(CommunicationType::ChatSecretForward) - .with_id(event_id) - .with_sender(signer_id) - .with_receiver(receiver) - .add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone())) - .add_typed_default( - DataType::SenderUserId, - DataValue::Str(signer_id.to_string()), - ) - .add_typed_default( - DataType::RecipientUserId, - DataValue::Str(recipient_id.to_string()), - ) - .add_typed_default( - DataType::SecretId, - DataValue::Str(format!("chat:{chat_id}:main")), - ) - .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)) - .add_typed_default(DataType::Payload, DataValue::Str("init".to_string())), - ) - } - _ => None, - } - } - - async fn handle_relay(self: Arc, frame: CommunicationValue) { - let Some(incoming_frame_id) = frame.id() else { - log!("Rejecting Relay without a message id"); - return; + ) -> Result { + use iota_identity::{ + AuthorityId, AuthorityLocator, IdentityResolver, PrincipalId, UserSelector, }; - let Some(local_iota_id) = CONFIG.load().iota_id else { - log!("Rejecting Relay because this Iota has no registered identity"); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInvalidData) - .await; - return; + let authority = AuthorityId::omega_legacy(&omega_discovery::omega_host()) + .map_err(|error| RelayValidationError::KeyLookup(error.to_string()))?; + let locator = AuthorityLocator::new(omega_discovery::omega_host()) + .map_err(|error| RelayValidationError::KeyLookup(error.to_string()))?; + let principals = Arc::new(iota_storage::identity::SqlitePrincipalStore); + let local_users = Arc::new(iota_storage::identity::SqliteLocalUserStore); + let local = iota_storage::identity::LocalIdentityResolver::new( + authority.clone(), + iota_identity::AuthorityKind::Omega, + iota_identity::PrincipalHome::Omega(locator.clone()), + local_users, + principals.clone(), + ); + let principal = PrincipalId { + authority: authority.clone(), + user_id: signer_id, }; - let Some(keyring) = self.keyring.read().await.as_ref().cloned() else { - log!("Rejecting Relay because the Iota keyring is unavailable"); - self.send_relay_response(Some(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( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let accepted_at = now_millis_i64(); - let signer_id = match i64::try_from(verified.context.signer_id) { - Ok(id) => id, - Err(_) => { - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let recipient_id = match i64::try_from(verified.context.final_recipient_id) { - Ok(id) => id, - Err(_) => { - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let signer_is_local = match iota_storage::users::user_manager::get_user(signer_id) { - Ok(user) => user.is_some(), - Err(error) => { - log!( - "Relay locality lookup failed for signer {}: {}", - signer_id, - error - ); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let recipient_is_local = match iota_storage::users::user_manager::get_user(recipient_id) { - Ok(user) => user.is_some(), - Err(error) => { - log!( - "Relay locality lookup failed for recipient {}: {}", - recipient_id, - error - ); - self.send_relay_response(Some(incoming_frame_id), CommunicationType::ErrorInternal) - .await; - return; - } - }; - 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(Some(incoming_frame_id), CommunicationType::ErrorInvalidData) - .await; - return; - } - - /* Evaluate recipient policy before reserving relay replay state or - * persisting the frame, so blocked traffic leaves no durable trace. */ - if recipient_block_policy_applies(recipient_is_local, recipient_id, signer_id) { - match iota_storage::util::blocked_users::is_blocked(recipient_id, signer_id) { - Ok(true) => { - log!( - "Rejecting Relay from blocked signer {} to recipient {}", - signer_id, - recipient_id - ); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorNotAuthenticated, - ) - .await; - return; - } - Ok(false) => {} - Err(error) => { - log!( - "Relay block policy lookup failed for recipient {}: {}", - recipient_id, - error - ); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInternal, - ) - .await; - return; - } - } - } - - /* An origin Iota is authoritative for receipt disclosure. Inspect - * local-origin relay content before reserving or queuing the frame. */ - if signer_is_local { - let content = match open_verified_relay_content(&verified, &[&keyring]) { - Ok(content) => content, - Err(error) => { - log!("Relay origin content verification failed: {}", error); - self.send_relay_response( - Some(incoming_frame_id), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - if let Err(error) = message_handlers::validate_outgoing_receipt_policy( - signer_id, - &verified.context, - &content, - ) { - log!("Rejecting local receipt relay: {}", error); - self.send_relay_response( - Some(incoming_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 = incoming_frame_id; - let reservation = match relay_replay::reserve( - verified.context.signer_id, - &verified.context.message_id, - verified.context.created_at, - accepted_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, - }; - - /* A shared Iota owns both independent replicas before delivering to its - * local recipient. The destination path below writes the recipient copy. */ - if signer_is_local && recipient_is_local && !already_applied { - let content = match open_verified_relay_content(&verified, &[&keyring]) { - Ok(value) => value, - Err(error) => { - log!( - "Relay shared-Iota origin content verification failed: {}", - error - ); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - let owner = match i64::try_from(verified.context.signer_id) { - Ok(value) => value, - Err(_) => { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - owner, - true, - ) { - log!("Relay shared-Iota origin application failed: {}", error); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - if let Err(error) = chat_files::record_destination_iota_received( - owner, - owner, - &verified.context.message_id, - accepted_at, - ) { - log!( - "Relay shared-Iota destination timestamp storage failed: {}", - error - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - - if signer_is_local && !recipient_is_local { - /* Chat-secret versions are immutable. Apply them locally only after - * the peer has accepted the same relay, so a peer conflict cannot - * leave a newly generated origin version behind. */ - let defer_chat_secret_commit = frame.is_type(CommunicationType::SetChatSecret); - if !already_applied { - if !defer_chat_secret_commit { - let content = match open_verified_relay_content(&verified, &[&keyring]) { - Ok(value) => value, - Err(error) => { - log!("Relay origin content verification failed: {}", error); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response( - frame.id(), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - i64::try_from(verified.context.signer_id).unwrap_or_default(), - true, - ) { - log!("Relay origin application failed: {}", error); - let _ = relay_replay::mark_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - } - } - 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); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_iota_not_found", - ); - 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); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "forwarding_validation_failed", - ); - 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); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "serialization_failed", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - }; - let relay_identity = match ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.final_recipient_id), - ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id.clone(), - }, - _ => { - log!("Relay identity exceeds the storage range"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = - outgoing_relay::commit_outgoing_relay(outgoing_relay::OutgoingRelay { - target: RouteTarget::Iota(router), - identity: &relay_identity, - frame: &bytes, - created_at: now_millis_i64(), - frame_id, - type_map_version: &type_map_version, - }) - { - log!("Relay origin retry queue failed: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "queue_failed", - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - match self - .await_relay_response(&forwarded, Duration::from_secs(20)) - .await - { - Ok(response) if response.is_type(CommunicationType::Success) => { - let returned_id = response.get_data(DataType::RelayMessageId).as_str(); - let destination_accepted_at = response - .get_data(DataType::RelayAcceptedAt) - .as_number() - .and_then(|value| i64::try_from(value).ok()); - if returned_id != Some(verified.context.message_id.as_str()) { - log!("Relay acknowledgement returned a different RelayMessageId"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - let Some(destination_accepted_at) = destination_accepted_at else { - log!("Relay acknowledgement is missing RelayAcceptedAt"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - }; - let Ok(signer_id) = i64::try_from(verified.context.signer_id) else { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - }; - if defer_chat_secret_commit && !already_applied { - let content = match open_verified_relay_content(&verified, &[&keyring]) { - Ok(value) => value, - Err(error) => { - log!("Relay origin content verification failed: {}", error); - self.send_relay_response( - frame.id(), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - } - }; - let Ok(origin_id) = i64::try_from(verified.context.signer_id) else { - self.send_relay_response( - frame.id(), - CommunicationType::ErrorInvalidData, - ) - .await; - return; - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - origin_id, - true, - ) { - log!("Relay origin chat-secret application failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - if let Err(error) = - iota_storage::util::downstream_relay::acknowledge_iota_delivery( - router, - frame_id, - signer_id, - &verified.context.message_id, - destination_accepted_at, - ) - { - log!( - "Relay destination acknowledgement storage failed: {}", - error - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - let response = response - .add_typed_default( - DataType::OriginIotaReceivedAt, - DataValue::SignedNumber(accepted_at.into()), - ) - .add_typed_default( - DataType::DestinationIotaReceivedAt, - DataValue::SignedNumber(destination_accepted_at.into()), - ) - .with_id(frame_id); - if let Err(error) = self.send_message(&response).await { - log!("Relay response could not be sent: {}", error); - } - } - Ok(response) => { - log!("Relay origin route returned {}", response.get_type()); - if response.is_type(CommunicationType::ErrorInternal) - && !defer_chat_secret_commit - { - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_internal_error", - ); - self.send_relay_success( - frame.id(), - local_iota_id, - &verified.context.message_id, - accepted_at, - true, - ) - .await; - return; - } else if let Ok(signer_id) = i64::try_from(verified.context.signer_id) { - if let Err(error) = - iota_storage::util::downstream_relay::reject_iota_delivery( - router, - frame_id, - signer_id, - &verified.context.message_id, - "destination_rejected", - ) - { - log!("Relay rejection cleanup failed: {}", error); - } - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_rejected", - ); - } - self.send_relay_response( - frame.id(), - response - .get_comm_type_enum() - .unwrap_or(CommunicationType::ErrorInternal), - ) - .await; - } - Err(error) => { - log!("Relay origin forwarding failed: {}", error); - record_origin_delivery_failure( - verified.context.signer_id, - &verified.context.message_id, - "destination_unreachable", - ); - if defer_chat_secret_commit { - if let Ok(signer_id) = i64::try_from(verified.context.signer_id) { - if let Err(queue_error) = - iota_storage::util::downstream_relay::reject_iota_delivery( - router, - frame_id, - signer_id, - &verified.context.message_id, - "destination_unreachable", - ) - { - log!("Chat-secret relay retry cleanup failed: {}", queue_error); - } - } - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - self.send_relay_success( - frame.id(), - local_iota_id, - &verified.context.message_id, - accepted_at, - true, - ) - .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; - } - }; - let relay_identity = match ( - i64::try_from(verified.context.signer_id), - i64::try_from(verified.context.final_recipient_id), - ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id.clone(), - }, - _ => { - log!("Relay identity exceeds the storage range"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = relay_queue::enqueue( - RouteTarget::User(destination), - &relay_identity, - &bytes, - now_millis_i64(), - frame_id, - &type_map_version, - ) { - log!("Relay could not be queued for client delivery: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - - let mut client_event = None; - if !already_applied { - let content = match open_verified_relay_content(&verified, &[&keyring]) { - 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_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }; - if let Err(error) = message_handlers::apply_verified_relay_content( - &verified.context, - &content, - accepted_at, - match i64::try_from(destination) { - Ok(value) => value, - Err(_) => { - log!("Relay destination ID exceeds storage range"); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - }, - false, - ) { - 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_rejected( - verified.context.signer_id, - &verified.context.message_id, - ); - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - } - client_event = Self::client_event_from_relay( - content.message_type, - &content.content, - verified.context.signer_id, - destination, - &verified.context.message_id, - accepted_at, - ); - if let Err(error) = - relay_replay::mark_applied(verified.context.signer_id, &verified.context.message_id) - { - log!("Relay application state update failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - - if signer_is_local && recipient_is_local { - let Ok(owner) = i64::try_from(verified.context.signer_id) else { - self.send_relay_response(frame.id(), CommunicationType::ErrorInvalidData) - .await; - return; - }; - if let Err(error) = outgoing_relay::apply_outgoing_history_policy( - owner, - owner, - &verified.context.message_id, - ) { - log!("Shared-Iota outgoing history policy failed: {}", error); - self.send_relay_response(frame.id(), CommunicationType::ErrorInternal) - .await; - return; - } - } - - if let Err(error) = - relay_replay::mark_queued(verified.context.signer_id, &verified.context.message_id) + match local + .signing_keys(&principal, &iota_identity::ResolutionContext::default()) + .await { - log!("Relay queue state update failed: {}", error); - } - self.send_relay_success( - frame.id(), - local_iota_id, - &verified.context.message_id, - accepted_at, - signer_is_local, - ) - .await; - if let Some(event) = client_event { - if let Err(error) = self.send_message(&event).await { - log!("Relay client event delivery failed: {}", error); + Ok(_) => local + .resolve_principal(&principal) + .await + .map_err(|error| RelayValidationError::KeyLookup(error.to_string())), + Err(iota_identity::IdentityError::NotFound) => { + crate::identity::resolve_omega_principal( + self, + principals.as_ref(), + &authority, + &locator, + &UserSelector::UserId(signer_id), + ) + .await + .map_err(|error| RelayValidationError::KeyLookup(error.to_string())) } + Err(error) => Err(RelayValidationError::KeyLookup(error.to_string())), } } +} +impl OmikronConnection { async fn classify_legacy_pending_relays(&self) { let mut after_id = 0; loop { @@ -2253,17 +1252,33 @@ impl OmikronConnection { i64::try_from(verified.context.signer_id), i64::try_from(verified.context.final_recipient_id), ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id, - }, + (Ok(signer_id), Ok(destination_user_id)) => { + let Ok(signer) = self + .resolve_relay_principal(verified.context.signer_id) + .await + else { + continue; + }; + let Ok(recipient) = self + .resolve_relay_principal(verified.context.final_recipient_id) + .await + else { + continue; + }; + relay_queue::RelayIdentity { + signer: signer.handle, + recipient: recipient.handle, + message_id: verified.context.message_id, + legacy_signer_id: Some(signer_id), + legacy_recipient_id: Some(destination_user_id), + } + } _ => { let _ = relay_queue::delete(record.id); continue; } }; - if matches!(record.target, RouteTarget::User(destination) if i64::try_from(destination).ok() != Some(identity.destination_user_id)) + if matches!(record.target, relay_queue::RelayTarget::User(destination) if i64::try_from(destination).ok() != identity.legacy_recipient_id) { log!( "Quarantining pending Relay {} with a target-recipient mismatch", @@ -2293,12 +1308,14 @@ impl OmikronConnection { }; for record in records.into_iter().filter(|record| { destination_user_id.is_none_or(|user_id| { - matches!(record.target, RouteTarget::User(destination) if i64::try_from(destination).ok() == Some(user_id)) + matches!(record.target, relay_queue::RelayTarget::User(destination) if i64::try_from(destination).ok() == Some(user_id)) }) }) { if record.relay_signer_id.is_none() || record.relay_destination_user_id.is_none() || record.relay_message_id.is_none() + || record.signer_principal.is_none() + || record.destination_principal.is_none() { log!( "Skipping pending Relay {} until ownership is classified", @@ -2306,6 +1323,9 @@ impl OmikronConnection { ); continue; } + let Some(signer_principal) = record.signer_principal else { + continue; + }; let Some(version) = mtp::type_map::Version::parse(&record.type_map_version) else { log!( "Retaining pending Relay {} with invalid type-map version {}", @@ -2360,11 +1380,21 @@ impl OmikronConnection { i64::try_from(verified.context.signer_id), i64::try_from(verified.context.final_recipient_id), ) { - (Ok(signer_id), Ok(destination_user_id)) => relay_queue::RelayIdentity { - signer_id, - destination_user_id, - message_id: verified.context.message_id, - }, + (Ok(signer_id), Ok(destination_user_id)) => { + let Ok(signer) = self.resolve_relay_principal(verified.context.signer_id).await else { + continue; + }; + let Ok(recipient) = self.resolve_relay_principal(verified.context.final_recipient_id).await else { + continue; + }; + relay_queue::RelayIdentity { + signer: signer.handle, + recipient: recipient.handle, + message_id: verified.context.message_id, + legacy_signer_id: Some(signer_id), + legacy_recipient_id: Some(destination_user_id), + } + } _ => { log!( "Deleting pending Relay {} with an out-of-range identity", @@ -2385,7 +1415,10 @@ impl OmikronConnection { continue; } } - let Ok(forwarded) = forward_verified_relay(&frame, record.target) else { + let Some(wire_target) = record.target.legacy_wire_target() else { + continue; + }; + let Ok(forwarded) = forward_verified_relay(&frame, wire_target) else { log!( "Retaining pending Relay {} with invalid route target", record.id @@ -2393,7 +1426,7 @@ impl OmikronConnection { continue; }; match record.target { - RouteTarget::Iota(destination_iota) => { + relay_queue::RelayTarget::LegacyOmegaIota { iota_id: destination_iota, .. } => { match self .await_relay_response(&forwarded, Duration::from_secs(20)) .await @@ -2456,10 +1489,31 @@ impl OmikronConnection { continue; } }; + let recipient_principal = match record.destination_principal { + Some(principal) => principal, + None => match self + .resolve_relay_principal( + verified.context.final_recipient_id, + ) + .await + { + Ok(principal) => principal.handle, + Err(error) => { + log!( + "Retaining pending chat-secret Relay {} after recipient resolution failure: {}", + record.id, + error + ); + continue; + } + }, + }; if let Err(error) = message_handlers::apply_verified_relay_content( &verified.context, &content, + signer_principal, + recipient_principal, now_millis_i64(), signer_id, true, @@ -2475,9 +1529,9 @@ impl OmikronConnection { } if let Err(error) = iota_storage::util::downstream_relay::acknowledge_iota_delivery( - destination_iota, record.frame_id, - signer_id, + signer_principal, + Some(signer_id), message_id, accepted_at, ) @@ -2498,9 +1552,9 @@ impl OmikronConnection { { if let Err(error) = iota_storage::util::downstream_relay::reject_iota_delivery( - destination_iota, record.frame_id, - signer_id, + signer_principal, + Some(signer_id), message_id, "destination_rejected", ) @@ -2523,11 +1577,12 @@ impl OmikronConnection { } } } - RouteTarget::User(_) => { + relay_queue::RelayTarget::User(_) => { if let Err(error) = self.send_message(&forwarded).await { log!("Pending Relay {} delivery failed: {}", record.id, error); } } + relay_queue::RelayTarget::Iota(_) => {} } } } @@ -2590,7 +1645,7 @@ impl OmikronConnection { async fn handle_message_impl(self: Arc, cv: CommunicationValue) { if cv.is_type(CommunicationType::Relay) { - self.handle_relay(cv).await; + self.dispatch_relay(cv).await; return; } if cv.require_id().is_err() { @@ -2864,9 +1919,14 @@ impl OmikronConnection { if trusted { let challenge = Uuid::new_v4().to_string(); - self.app_challenges.insert(sender_id, challenge.clone()); - self.app_sessions - .insert(sender_id, (user_id, app_identifier.clone())); + self.app_challenges.insert( + sender_id, + PendingAppChallenge { + challenge: challenge.clone(), + user_id, + app_identifier: app_identifier.clone(), + }, + ); if let Some(app_pub_bundle) = iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) @@ -2911,9 +1971,59 @@ impl OmikronConnection { return; } }; - if let Some((_, expected_challenge)) = self.app_challenges.remove(&sender_id) { + if let Some((_, pending)) = self.app_challenges.remove(&sender_id) { if let Some(DataValue::Str(response)) = cv.get_data(DataType::Challenge) { - if expected_challenge == *response { + if pending.challenge == *response { + let authority = match iota_identity::AuthorityId::omega_legacy( + &omega_discovery::omega_host(), + ) { + Ok(authority) => authority, + Err(_) => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + let user_id = match u64::try_from(pending.user_id) { + Ok(user_id) => user_id, + Err(_) => { + let _ = self + .send_message(&error_response( + cv, + CommunicationType::ErrorInvalidData, + )) + .await; + return; + } + }; + let principal = iota_identity::PrincipalId { authority, user_id }; + let principal = match iota_identity::PrincipalStore::get_by_canonical_id( + &iota_storage::identity::SqlitePrincipalStore, + &principal, + ) { + Ok(Some(principal)) => principal, + _ => { + let _ = self + .send_message(&error_response(cv, CommunicationType::ErrorInternal)) + .await; + return; + } + }; + let connection_id = Uuid::new_v4(); + iota_auth::HostedSessionRegistrar::new(self.session_manager.clone()) + .authenticate( + connection_id, + iota_identity::LocalUserId(pending.user_id), + principal.handle, + ); + self.app_sessions.insert( + sender_id, + AppSession { + connection_id, + app_identifier: pending.app_identifier, + }, + ); let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) .with_request_id(cv) .with_receiver(sender_id); @@ -2944,9 +2054,18 @@ impl OmikronConnection { .unwrap_or("") .to_string(); - if let Some(session) = self.app_sessions.get(&sender_id) { - let (user_id, app_identifier) = session.value(); - iota_storage::users::user_manager::save_app_data(*user_id, app_identifier, &app_data); + if let Some(session) = self.app_sessions.get(&sender_id) + && let Ok(authenticated) = self.session_manager.authorize( + session.connection_id, + &iota_auth::SessionCapability::LocalStorage, + ) + && let iota_auth::SessionIdentity::Hosted { local_user, .. } = authenticated.identity + { + iota_storage::users::user_manager::save_app_data( + local_user.0, + &session.app_identifier, + &app_data, + ); } let res = CommunicationValue::new(CommunicationType::SaveAppData) @@ -2967,9 +2086,17 @@ impl OmikronConnection { }; let mut app_data = String::new(); - if let Some(session) = self.app_sessions.get(&sender_id) { - let (user_id, app_identifier) = session.value(); - app_data = iota_storage::users::user_manager::load_app_data(*user_id, app_identifier); + if let Some(session) = self.app_sessions.get(&sender_id) + && let Ok(authenticated) = self.session_manager.authorize( + session.connection_id, + &iota_auth::SessionCapability::LocalStorage, + ) + && let iota_auth::SessionIdentity::Hosted { local_user, .. } = authenticated.identity + { + app_data = iota_storage::users::user_manager::load_app_data( + local_user.0, + &session.app_identifier, + ); } let res = CommunicationValue::new(CommunicationType::LoadAppData) @@ -3806,12 +2933,14 @@ impl OmikronConnection { )) })?; } - save_keyring_verified(&keyring, path).map_err(|error| { - OmikronError::Internal(format!( - "could not save new identity {}: {error}", - path.display() - )) - })?; + iota_identity::LocalNodeIdentity::save_keyring_verified(&keyring, path).map_err( + |error| { + OmikronError::Internal(format!( + "could not save new identity {}: {error}", + path.display() + )) + }, + )?; modify_config(|config| { config.iota_id = None; config.keyring = None; @@ -3844,14 +2973,6 @@ impl OmikronConnection { } } -fn recipient_block_policy_applies( - recipient_is_local: bool, - recipient_id: i64, - signer_id: i64, -) -> bool { - recipient_is_local && recipient_id != signer_id -} - // ============================================================================ // Global Instance // ============================================================================ @@ -3878,17 +2999,31 @@ pub async fn connect_initial( } } +#[async_trait::async_trait] impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection { - async fn send_message(&self, cv: &CommunicationValue) -> Result<(), String> { - OmikronConnection::send_message(self, cv).await + async fn send_message( + &self, + cv: &CommunicationValue, + ) -> Result<(), iota_connection::connection_handler::ConnectionError> { + OmikronConnection::send_message(self, cv) + .await + .map_err(iota_connection::connection_handler::ConnectionError::Disconnected) } async fn await_response( &self, cv: &CommunicationValue, timeout: Option, - ) -> Result { - OmikronConnection::await_response(self, cv, timeout).await + ) -> Result { + OmikronConnection::await_response(self, cv, timeout) + .await + .map_err(|error| { + if error.contains("timed out") { + iota_connection::connection_handler::ConnectionError::Timeout(error) + } else { + iota_connection::connection_handler::ConnectionError::Disconnected(error) + } + }) } async fn is_connected(&self) -> bool { @@ -3904,6 +3039,29 @@ impl iota_connection::connection_handler::ConnectionHandler for OmikronConnectio } } +#[async_trait::async_trait] +impl iota_connection::relay_service::RelayNodeIdentity for OmikronConnection { + async fn keyring(&self) -> Option> { + self.keyring.read().await.as_ref().cloned() + } + + fn node_id(&self) -> Option { + self.keyring + .try_read() + .ok() + .and_then(|keyring| keyring.as_ref().cloned()) + .and_then(|keyring| { + iota_identity::IotaNodeId::from_public_keys(&keyring.public_key_bundle()).ok() + }) + } +} + +impl iota_connection::relay_service::LegacyRelayIdentity for OmikronConnection { + fn legacy_iota_id(&self) -> Option { + CONFIG.load().iota_id + } +} + #[async_trait::async_trait] impl OmikronClient for OmikronConnection { async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError> { @@ -3945,11 +3103,13 @@ impl OmikronClient for OmikronConnection { keyring: self.keyring.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), + session_manager: self.session_manager.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), active_tasks: self.active_tasks.clone(), app: self.app.clone(), invitation_sync: self.invitation_sync.clone(), + relay_service: self.relay_service.clone(), }); Self::reconnect(&this).await; Ok(()) @@ -3970,11 +3130,13 @@ impl OmikronClient for OmikronConnection { keyring: self.keyring.clone(), app_challenges: self.app_challenges.clone(), app_sessions: self.app_sessions.clone(), + session_manager: self.session_manager.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), active_tasks: self.active_tasks.clone(), app: self.app.clone(), invitation_sync: self.invitation_sync.clone(), + relay_service: self.relay_service.clone(), }); Self::rotate_identity(&this).await } @@ -3987,6 +3149,7 @@ impl OmikronClient for OmikronConnection { #[cfg(test)] mod tests { use super::*; + use std::fs; fn test_path(name: &str) -> PathBuf { std::env::temp_dir().join(format!( @@ -3999,11 +3162,17 @@ mod tests { #[test] fn generated_identity_is_unprotected_and_survives_reload() { let path = test_path("reload"); - let keyring = load_or_migrate_keyring_at(&path, None).expect("identity saves"); + let identity = load_or_migrate_keyring_at(&path, None).expect("identity saves"); let reloaded = load_or_migrate_keyring_at(&path, None).expect("identity loads"); assert_eq!( - keyring.try_to_bytes().expect("keyring serializes"), - reloaded.try_to_bytes().expect("keyring serializes") + identity + .keyring() + .try_to_bytes() + .expect("keyring serializes"), + reloaded + .keyring() + .try_to_bytes() + .expect("keyring serializes") ); assert!(mtp::files::load_keyring_raw(&path).is_ok()); let _ = fs::remove_file(path); @@ -4015,7 +3184,10 @@ mod tests { fs::write(&path, b"not a keyring").expect("corrupt fixture writes"); let error = load_or_migrate_keyring_at(&path, None).expect_err("corrupt identity must fail"); - assert!(matches!(error, IdentityError::Storage(_))); + assert!(matches!( + error, + iota_identity::LocalNodeIdentityError::Storage { .. } + )); let _ = fs::remove_file(path); } @@ -4030,7 +3202,10 @@ mod tests { let migrated = load_or_migrate_keyring_at(&path, None).expect("legacy identity loads"); assert_eq!( - migrated.try_to_bytes().expect("keyring serializes"), + migrated + .keyring() + .try_to_bytes() + .expect("keyring serializes"), keyring.try_to_bytes().expect("keyring serializes") ); let _ = fs::remove_file(path); @@ -4043,7 +3218,10 @@ mod tests { let path = parent.join("iota.mk"); let error = load_or_migrate_keyring_at(&path, None) .expect_err("directory failure must be returned"); - assert!(matches!(error, IdentityError::Directory(_))); + assert!(matches!( + error, + iota_identity::LocalNodeIdentityError::Directory { .. } + )); let _ = fs::remove_file(parent); } @@ -4075,13 +3253,6 @@ mod tests { )); } - #[test] - fn recipient_block_policy_skips_self_delivery() { - assert!(!recipient_block_policy_applies(true, 42, 42)); - assert!(recipient_block_policy_applies(true, 42, 43)); - assert!(!recipient_block_policy_applies(false, 42, 43)); - } - #[test] fn omega_invitation_snapshot_parser_requires_authoritative_fields() { let type_map = TypeMap::latest(); diff --git a/omikron-connector/src/router.rs b/omikron-connector/src/router.rs new file mode 100644 index 0000000..2bdfff8 --- /dev/null +++ b/omikron-connector/src/router.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_identity::AuthorityId; +use iota_util::mtp_compat::OptionalDataValueExt; +use iota_util::route_target::RouteTarget; +use mtp::codec::{CommunicationType, CommunicationValue, DataType}; +use std::sync::Arc; +use std::time::Duration; + +use crate::OmikronClient; + +pub struct OmikronPeerRouter { + client: Arc, + authority: AuthorityId, +} + +impl OmikronPeerRouter { + pub fn new(client: Arc, authority: AuthorityId) -> Self { + Self { client, authority } + } +} + +#[async_trait] +impl PeerRouter for OmikronPeerRouter { + async fn route( + &self, + destination: &RouteDestination, + frame: CommunicationValue, + ) -> Result { + let (omega, iota_id) = match destination { + RouteDestination::LegacyOmegaIota { omega, iota_id } => (omega, *iota_id), + RouteDestination::Iota(node) => return Err(RouteError::NoRoute(node.clone())), + }; + if omega != &self.authority { + return Ok(RouteOutcome::Retryable { + reason: "legacy Omega route belongs to another authority".into(), + }); + } + if frame.receiver() != RouteTarget::Iota(iota_id).wire_id() { + return Err(RouteError::Delivery( + "relay frame receiver does not match its route destination".into(), + )); + } + let response = self + .client + .await_response(&frame, Duration::from_secs(20)) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + if response.is_type(CommunicationType::Success) { + let relay_message_id = response + .get_data(DataType::RelayMessageId) + .as_str() + .ok_or_else(|| { + RouteError::Delivery("relay acceptance omitted its message ID".into()) + })? + .to_owned(); + let destination_accepted_at = response + .get_data(DataType::RelayAcceptedAt) + .as_number() + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| { + RouteError::Delivery("relay acceptance omitted its timestamp".into()) + })?; + Ok(RouteOutcome::Accepted { + relay_message_id, + destination_accepted_at, + }) + } else { + Ok(RouteOutcome::Rejected { + response_type: response + .get_comm_type_enum() + .unwrap_or(CommunicationType::ErrorInternal), + }) + } + } +} diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index e3f9b4a..b935087 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -92,7 +92,7 @@ async fn inspect_credential_account( connection: &dyn OmikronClient, credential: &TuCredential, ) -> Result<(String, String, i64), LifecycleUserError> { - if credential.omega_host != omega_discovery::omega_host() { + if credential.omega_host() != Some(omega_discovery::omega_host().as_str()) { return Err(LifecycleUserError::OmegaHostMismatch); } let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( @@ -773,7 +773,7 @@ mod tests { async fn inspection_returns_verified_identity_without_lifecycle_requests() { let credential = TuCredential { user_id: 42, - omega_host: crate::omega_discovery::omega_host(), + authority: iota_util::tu::TuAuthority::Omega(crate::omega_discovery::omega_host()), keyring: generate_keyring(), }; let client = InspectionClient { diff --git a/other-iota/Cargo.toml b/other-iota/Cargo.toml index 1fb5337..863317d 100644 --- a/other-iota/Cargo.toml +++ b/other-iota/Cargo.toml @@ -4,3 +4,17 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = "0.1.89" +iota-connection = { path = "../iota-connection" } +iota-identity = { path = "../iota-identity" } +iota-util = { path = "../iota-util" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["client", "crypto", "pipes", "web-server"] } +reqwest = { version = "0.13", features = ["json"] } +serde = "1" +tokio = { version = "1.50.0", features = ["full"] } +web-server = { path = "../web-server" } + +[dev-dependencies] +iota-storage = { path = "../iota-storage" } +rcgen = "0.14" +tempfile = "3" diff --git a/other-iota/src/lib.rs b/other-iota/src/lib.rs index 8b13789..f7941a6 100644 --- a/other-iota/src/lib.rs +++ b/other-iota/src/lib.rs @@ -1 +1,1960 @@ +use async_trait::async_trait; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::relay_service::{ + IngressSource, RelayOutcome, RelayService, RelayServiceError, VerifiedRelayEnvelope, +}; +use iota_identity::IotaNodeId; +use iota_identity::{ + AuthorityId, AuthorityKind, AuthorityLocator, DiscoveredAuthority, IdentityError, + IdentityResolver, NodeDescriptorWireV1, NodeDirectory, PrincipalDescriptorWireV1, PrincipalId, + PrincipalStore, PublicKeyBundle, ResolutionContext, ResolvedPrincipal, UserAddress, + UserSelector, +}; +use iota_util::mtp_compat::OptionalDataValueExt; +use mtp::codec::CommunicationValue; +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock, Weak}; +pub mod relay_router; + +const FRESH_CACHE_MILLIS: i64 = 15 * 60 * 1_000; + +#[async_trait] +pub trait FederationIdentityClient: Send + Sync { + async fn discover( + &self, + locator: &AuthorityLocator, + ) -> Result; + + async fn node_descriptor( + &self, + locator: &AuthorityLocator, + path: &str, + ) -> Result; + + async fn principal_descriptor( + &self, + locator: &AuthorityLocator, + selector: &UserSelector, + ) -> Result; +} + +pub struct FetchedNodeDescriptor { + pub descriptor: NodeDescriptorWireV1, + pub tls_certificate: Option>, +} + +pub struct HttpFederationIdentityClient { + client: reqwest::Client, + ip_bootstrap_client: Result, +} + +impl Default for HttpFederationIdentityClient { + fn default() -> Self { + Self { + client: reqwest::Client::new(), + ip_bootstrap_client: reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .tls_info(true) + .build() + .map_err(|error| error.to_string()), + } + } +} + +pub async fn publish_omega_node_descriptor( + locator: &AuthorityLocator, + iota_id: u64, + descriptor: &NodeDescriptorWireV1, +) -> Result<(), IdentityError> { + let response = reqwest::Client::new() + .post(format!( + "https://{}/federation/v1/iotas/{iota_id}/node", + locator.as_str() + )) + .json(descriptor) + .send() + .await + .map_err(|error| IdentityError::Unavailable(error.to_string()))?; + if !response.status().is_success() { + return Err(IdentityError::Unavailable(format!( + "Omega rejected node descriptor publication with HTTP {}", + response.status() + ))); + } + Ok(()) +} + +#[async_trait] +impl FederationIdentityClient for HttpFederationIdentityClient { + async fn discover( + &self, + locator: &AuthorityLocator, + ) -> Result { + iota_util::authority_discovery::discover_authority(self.client(locator)?, locator).await + } + + async fn node_descriptor( + &self, + locator: &AuthorityLocator, + path: &str, + ) -> Result { + let (descriptor, certificate) = self.get_json_with_certificate(locator, path).await?; + Ok(FetchedNodeDescriptor { + descriptor, + tls_certificate: certificate.map(certificate_to_pem).transpose()?, + }) + } + + async fn principal_descriptor( + &self, + locator: &AuthorityLocator, + selector: &UserSelector, + ) -> Result { + let path = match selector { + UserSelector::UserId(user) => format!("/federation/v1/users/id/{user}"), + UserSelector::Username(username) => { + let mut url = reqwest::Url::parse("https://placeholder.invalid/") + .map_err(|error| IdentityError::InvalidIdentifier(error.to_string()))?; + url.path_segments_mut() + .map_err(|_| { + IdentityError::InvalidIdentifier( + "username cannot be encoded in a federation URL".into(), + ) + })? + .extend(["federation", "v1", "users", "name", username]); + url.path().to_owned() + } + }; + self.get_json(locator, &path).await + } +} + +impl HttpFederationIdentityClient { + fn client(&self, locator: &AuthorityLocator) -> Result<&reqwest::Client, IdentityError> { + if !locator.is_ip_literal() { + return Ok(&self.client); + } + self.ip_bootstrap_client.as_ref().map_err(|error| { + IdentityError::Unavailable(format!("raw-IP bootstrap client is unavailable: {error}")) + }) + } + + async fn get_json( + &self, + locator: &AuthorityLocator, + path: &str, + ) -> Result { + let url = format!("https://{}{}", locator.as_str(), path); + self.client(locator)? + .get(&url) + .send() + .await + .map_err(|error| { + IdentityError::Unavailable(format!("federation request failed for {url}: {error}")) + })? + .error_for_status() + .map_err(|error| { + IdentityError::Unavailable(format!("federation request rejected {url}: {error}")) + })? + .json() + .await + .map_err(|error| { + IdentityError::InvalidDescriptor(format!( + "invalid federation response from {url}: {error}" + )) + }) + } + + async fn get_json_with_certificate( + &self, + locator: &AuthorityLocator, + path: &str, + ) -> Result<(T, Option>), IdentityError> { + let url = format!("https://{}{}", locator.as_str(), path); + let response = self + .client(locator)? + .get(&url) + .send() + .await + .map_err(|error| { + IdentityError::Unavailable(format!("federation request failed for {url}: {error}")) + })? + .error_for_status() + .map_err(|error| { + IdentityError::Unavailable(format!("federation request rejected {url}: {error}")) + })?; + let certificate = response + .extensions() + .get::() + .and_then(reqwest::tls::TlsInfo::peer_certificate) + .map(Vec::from); + let descriptor = response.json().await.map_err(|error| { + IdentityError::InvalidDescriptor(format!( + "invalid federation response from {url}: {error}" + )) + })?; + Ok((descriptor, certificate)) + } +} + +fn certificate_to_pem(certificate: Vec) -> Result, IdentityError> { + if certificate.is_empty() { + return Err(IdentityError::InvalidDescriptor( + "raw-IP bootstrap response contains an empty TLS certificate".into(), + )); + } + let encoded = + iota_util::crypto_util::export(&certificate, iota_util::crypto_util::DataFormat::Base64) + .map_err(IdentityError::InvalidDescriptor)?; + Ok(format!("-----BEGIN CERTIFICATE-----\n{encoded}\n-----END CERTIFICATE-----\n").into_bytes()) +} + +pub struct RemoteIotaResolver { + client: Arc, + principals: Arc, + nodes: Arc, + authorities: RwLock>, +} + +impl RemoteIotaResolver { + pub fn new( + client: Arc, + principals: Arc, + nodes: Arc, + ) -> Self { + Self { + client, + principals, + nodes, + authorities: RwLock::new(HashMap::new()), + } + } + + pub fn pin_node( + &self, + locator: &AuthorityLocator, + node: &IotaNodeId, + ) -> Result<(), IdentityError> { + self.nodes.pin_endpoint(locator, node, now_millis()) + } + + pub fn pin_public_key( + &self, + locator: &AuthorityLocator, + key: &PublicKeyBundle, + ) -> Result { + let node = IotaNodeId::from_public_keys(key)?; + self.pin_node(locator, &node)?; + Ok(node) + } + + async fn authority( + &self, + locator: &AuthorityLocator, + allow_network: bool, + ) -> Result { + if let Some(authority) = self + .authorities + .read() + .ok() + .and_then(|authorities| authorities.get(locator).cloned()) + { + return Ok(authority); + } + if !allow_network { + let node = self.nodes.pinned_node(locator)?.ok_or_else(|| { + IdentityError::Unavailable("authority discovery is not cached".into()) + })?; + let resolved = self.nodes.resolve_node(&node).await?; + let public_keys = resolved.public_keys.first().cloned().ok_or_else(|| { + IdentityError::InvalidDescriptor("cached node has no trusted key".into()) + })?; + return Ok(DiscoveredAuthority { + service: AuthorityKind::Iota, + authority_id: AuthorityId::for_iota(&node), + node_id: Some(node), + public_keys, + protocols: vec!["identity-http-v1".into(), "mtp-relay-v2".into()], + node_descriptor: Some("/federation/v1/node".into()), + direct_endpoints: resolved.direct_endpoints, + relay_hints: resolved.relay_hints, + }); + } + let authority = self.client.discover(locator).await?; + if authority.service != AuthorityKind::Iota { + return Err(IdentityError::NotFound); + } + if let Ok(mut authorities) = self.authorities.write() { + authorities.insert(locator.clone(), authority.clone()); + } + Ok(authority) + } + + async fn refresh( + &self, + locator: &AuthorityLocator, + authority: &DiscoveredAuthority, + selector: &UserSelector, + ) -> Result { + let expected_node = authority.node_id.as_ref().ok_or_else(|| { + IdentityError::InvalidDescriptor("Iota discovery contains no node ID".into()) + })?; + if self + .nodes + .pinned_node(locator)? + .is_some_and(|pinned| &pinned != expected_node) + { + return Err(IdentityError::InvalidDescriptor( + "authority locator node identity changed from its pin".into(), + )); + } + let node_path = authority.node_descriptor.as_deref().ok_or_else(|| { + IdentityError::InvalidDescriptor( + "Iota discovery contains no node descriptor endpoint".into(), + ) + })?; + let fetched_node = self.client.node_descriptor(locator, node_path).await?; + let raw_ip_certificate = if locator.is_ip_literal() { + let certificate = fetched_node.tls_certificate.as_ref().ok_or_else(|| { + IdentityError::InvalidDescriptor( + "raw-IP bootstrap response contains no TLS certificate".into(), + ) + })?; + if self + .nodes + .pinned_endpoint_certificate(locator)? + .as_deref() + .is_some_and(|pinned| pinned != certificate) + { + return Err(IdentityError::InvalidDescriptor( + "raw-IP TLS certificate changed from its TOFU pin".into(), + )); + } + Some(certificate) + } else { + None + }; + let signed_node = fetched_node.descriptor.parse()?; + if &signed_node.descriptor.node != expected_node { + return Err(IdentityError::InvalidDescriptor( + "node descriptor does not match discovery node ID".into(), + )); + } + let discovered_key = authority + .public_keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + let descriptor_key = signed_node + .descriptor + .public_keys + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + if discovered_key != descriptor_key { + return Err(IdentityError::InvalidDescriptor( + "node descriptor key does not match discovery".into(), + )); + } + let current_node_revision = self + .nodes + .resolve_node(expected_node) + .await + .ok() + .map(|node| node.descriptor_revision); + let verified_node = signed_node.verify(current_node_revision, now_millis())?; + self.nodes.upsert_node_descriptor(&verified_node)?; + self.nodes + .pin_endpoint(locator, expected_node, now_millis())?; + if let Some(certificate) = raw_ip_certificate { + self.nodes.pin_endpoint_certificate(locator, certificate)?; + } + + let signed = self + .client + .principal_descriptor(locator, selector) + .await? + .parse()?; + match selector { + UserSelector::UserId(user_id) if signed.descriptor.principal.user_id != *user_id => { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor user ID does not match lookup".into(), + )); + } + UserSelector::Username(username) + if signed.descriptor.username.as_deref() != Some(username.as_str()) => + { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor username does not match lookup".into(), + )); + } + _ => {} + } + let current_revision = self + .principals + .get_by_canonical_id(&signed.descriptor.principal)? + .map(|principal| principal.descriptor_revision); + let verified = signed.verify( + &authority.authority_id, + &verified_node.descriptor().public_keys, + current_revision, + now_millis(), + )?; + let handle = self.principals.upsert_remote_descriptor(&verified)?; + self.principals + .get_principal(handle)? + .ok_or(IdentityError::NotFound) + } + + fn cached( + &self, + authority: &AuthorityId, + selector: &UserSelector, + ) -> Result, IdentityError> { + match selector { + UserSelector::UserId(user_id) => self.principals.get_by_canonical_id(&PrincipalId { + authority: authority.clone(), + user_id: *user_id, + }), + UserSelector::Username(username) => { + self.principals.get_by_username(authority, username) + } + } + } +} + +#[async_trait] +impl IdentityResolver for RemoteIotaResolver { + async fn resolve_address( + &self, + address: &UserAddress, + context: &ResolutionContext, + ) -> Result { + let locator = address.authority.as_ref().ok_or(IdentityError::NotFound)?; + let authority = self.authority(locator, context.allow_network).await?; + let cached = self.cached(&authority.authority_id, &address.selector)?; + let now = now_millis(); + let resolved = if let Some(cached) = cached.as_ref() + && cached.is_valid_at(now) + && !cached.public_keys.is_empty() + && now.saturating_sub(cached.resolved_at) < FRESH_CACHE_MILLIS + { + cached.clone() + } else if context.allow_network { + self.refresh(locator, &authority, &address.selector).await? + } else if let Some(cached) = cached + && cached.is_valid_at(now) + && !cached.public_keys.is_empty() + && context + .offline_policy + .allows_cached(cached.resolved_at, now) + { + cached + } else { + return Err(IdentityError::Unavailable( + "cached principal descriptor is stale".into(), + )); + }; + verify_key_pin(address.public_key_pin.as_ref(), &resolved.public_keys)?; + Ok(resolved) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + self.resolve_principal_with_context( + principal, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + } + + async fn resolve_principal_with_context( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result { + let cached = self.principals.get_by_canonical_id(principal)?; + let now = now_millis(); + if let Some(cached) = cached.as_ref() + && cached.is_valid_at(now) + && !cached.public_keys.is_empty() + && (now.saturating_sub(cached.resolved_at) < FRESH_CACHE_MILLIS + || context + .offline_policy + .allows_cached(cached.resolved_at, now)) + { + return Ok(cached.clone()); + } + let locator = self.authorities.read().ok().and_then(|authorities| { + authorities + .iter() + .find(|(_, authority)| authority.authority_id == principal.authority) + .map(|(locator, authority)| (locator.clone(), authority.clone())) + }); + if context.allow_network + && let Some((locator, authority)) = locator + { + return self + .refresh( + &locator, + &authority, + &UserSelector::UserId(principal.user_id), + ) + .await; + } + match cached { + Some(_) => Err(IdentityError::Unavailable( + "cached principal descriptor is stale".into(), + )), + None => Err(IdentityError::NotFound), + } + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result, IdentityError> { + self.resolve_principal_with_context(principal, context) + .await + .map(|principal| principal.public_keys) + } +} + +pub struct RemoteOmegaResolver { + client: Arc, + principals: Arc, + nodes: Arc, + authorities: RwLock>, +} + +impl RemoteOmegaResolver { + pub fn new( + client: Arc, + principals: Arc, + nodes: Arc, + ) -> Self { + Self { + client, + principals, + nodes, + authorities: RwLock::new(HashMap::new()), + } + } + + async fn authority( + &self, + locator: &AuthorityLocator, + allow_network: bool, + ) -> Result { + if let Some(authority) = self + .authorities + .read() + .ok() + .and_then(|authorities| authorities.get(locator).cloned()) + { + return Ok(authority); + } + if !allow_network { + return Err(IdentityError::Unavailable( + "Omega authority discovery is not cached".into(), + )); + } + let authority = self.client.discover(locator).await?; + if authority.service != AuthorityKind::Omega { + return Err(IdentityError::NotFound); + } + if let Ok(mut authorities) = self.authorities.write() { + authorities.insert(locator.clone(), authority.clone()); + } + Ok(authority) + } + + async fn refresh( + &self, + locator: &AuthorityLocator, + authority: &DiscoveredAuthority, + selector: &UserSelector, + ) -> Result { + let wire = self.client.principal_descriptor(locator, selector).await?; + let home_node = wire.home_node.clone(); + let signed = wire.parse()?; + match selector { + UserSelector::UserId(user_id) if signed.descriptor.principal.user_id != *user_id => { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor user ID does not match lookup".into(), + )); + } + UserSelector::Username(username) + if signed.descriptor.username.as_deref() != Some(username.as_str()) => + { + return Err(IdentityError::InvalidDescriptor( + "principal descriptor username does not match lookup".into(), + )); + } + _ => {} + } + let current_revision = self + .principals + .get_by_canonical_id(&signed.descriptor.principal)? + .map(|principal| principal.descriptor_revision); + let verified = signed.verify( + &authority.authority_id, + &authority.public_keys, + current_revision, + now_millis(), + )?; + if verified.descriptor().authority_kind != AuthorityKind::Omega { + return Err(IdentityError::InvalidDescriptor( + "remote Omega returned a non-Omega principal".into(), + )); + } + if let iota_identity::PrincipalHome::Iota(home) = &verified.descriptor().home { + let signed_node = home_node + .ok_or_else(|| { + IdentityError::InvalidDescriptor( + "Omega principal response contains no hosting Iota descriptor".into(), + ) + })? + .parse()?; + if &signed_node.descriptor.node != home { + return Err(IdentityError::InvalidDescriptor( + "hosting Iota descriptor does not match principal home".into(), + )); + } + let current_node_revision = self + .nodes + .resolve_node(home) + .await + .ok() + .map(|node| node.descriptor_revision); + let verified_node = signed_node.verify(current_node_revision, now_millis())?; + self.nodes.upsert_node_descriptor(&verified_node)?; + } + let handle = self.principals.upsert_remote_descriptor(&verified)?; + self.principals + .get_principal(handle)? + .ok_or(IdentityError::NotFound) + } + + fn cached( + &self, + authority: &AuthorityId, + selector: &UserSelector, + ) -> Result, IdentityError> { + match selector { + UserSelector::UserId(user_id) => self.principals.get_by_canonical_id(&PrincipalId { + authority: authority.clone(), + user_id: *user_id, + }), + UserSelector::Username(username) => { + self.principals.get_by_username(authority, username) + } + } + } +} + +#[async_trait] +impl IdentityResolver for RemoteOmegaResolver { + async fn resolve_address( + &self, + address: &UserAddress, + context: &ResolutionContext, + ) -> Result { + let locator = address.authority.as_ref().ok_or(IdentityError::NotFound)?; + let authority = self.authority(locator, context.allow_network).await?; + let cached = self.cached(&authority.authority_id, &address.selector)?; + let now = now_millis(); + let resolved = if let Some(cached) = cached.as_ref() + && cached.is_valid_at(now) + && !cached.public_keys.is_empty() + && now.saturating_sub(cached.resolved_at) < FRESH_CACHE_MILLIS + { + cached.clone() + } else if context.allow_network { + self.refresh(locator, &authority, &address.selector).await? + } else if let Some(cached) = cached + && cached.is_valid_at(now) + && !cached.public_keys.is_empty() + && context + .offline_policy + .allows_cached(cached.resolved_at, now) + { + cached + } else { + return Err(IdentityError::Unavailable( + "cached Omega principal descriptor is stale".into(), + )); + }; + verify_key_pin(address.public_key_pin.as_ref(), &resolved.public_keys)?; + Ok(resolved) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + self.resolve_principal_with_context( + principal, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + } + + async fn resolve_principal_with_context( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result { + let cached = self.principals.get_by_canonical_id(principal)?; + let now = now_millis(); + if let Some(cached) = cached.as_ref() + && cached.is_valid_at(now) + && !cached.public_keys.is_empty() + && (now.saturating_sub(cached.resolved_at) < FRESH_CACHE_MILLIS + || context + .offline_policy + .allows_cached(cached.resolved_at, now)) + { + return Ok(cached.clone()); + } + let locator = self.authorities.read().ok().and_then(|authorities| { + authorities + .iter() + .find(|(_, authority)| authority.authority_id == principal.authority) + .map(|(locator, authority)| (locator.clone(), authority.clone())) + }); + if context.allow_network + && let Some((locator, authority)) = locator + { + return self + .refresh( + &locator, + &authority, + &UserSelector::UserId(principal.user_id), + ) + .await; + } + match cached { + Some(_) => Err(IdentityError::Unavailable( + "cached Omega principal descriptor is stale".into(), + )), + None => Err(IdentityError::NotFound), + } + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + context: &ResolutionContext, + ) -> Result, IdentityError> { + self.resolve_principal_with_context(principal, context) + .await + .map(|principal| principal.public_keys) + } +} + +fn verify_key_pin( + pin: Option<&PublicKeyBundle>, + keys: &[PublicKeyBundle], +) -> Result<(), IdentityError> { + let Some(pin) = pin else { + return Ok(()); + }; + let pin = pin + .try_as_bytes() + .map_err(|error| IdentityError::InvalidDescriptor(error.to_string()))?; + if keys.iter().any(|key| { + key.try_as_bytes() + .map(|candidate| candidate == pin) + .unwrap_or(false) + }) { + Ok(()) + } else { + Err(IdentityError::KeyPinMismatch) + } +} + +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + +#[async_trait] +pub trait DirectPeerConnection: Send + Sync { + async fn send_relay(&self, frame: CommunicationValue) -> Result; +} + +type RegisteredDirectPeer = (u64, Arc); + +#[derive(Default)] +pub struct DirectPeerRouter { + peers: RwLock>, + next_registration: AtomicU64, + manager: RwLock>>, +} + +impl DirectPeerRouter { + pub fn register(&self, node: IotaNodeId, connection: Arc) -> u64 { + let registration = self.next_registration.fetch_add(1, Ordering::Relaxed) + 1; + if let Ok(mut peers) = self.peers.write() { + peers.insert(node, (registration, connection)); + } + registration + } + + pub fn unregister(&self, node: &IotaNodeId, registration: u64) { + if let Ok(mut peers) = self.peers.write() + && peers + .get(node) + .is_some_and(|current| current.0 == registration) + { + peers.remove(node); + } + } + + pub fn attach_manager(&self, manager: &Arc) { + if let Ok(mut current) = self.manager.write() { + *current = Some(Arc::downgrade(manager)); + } + } + + pub fn has_connection(&self, node: &IotaNodeId) -> bool { + self.peers + .read() + .is_ok_and(|peers| peers.contains_key(node)) + } +} + +#[async_trait] +impl PeerRouter for DirectPeerRouter { + async fn route( + &self, + destination: &RouteDestination, + frame: CommunicationValue, + ) -> Result { + let RouteDestination::Iota(node) = destination else { + return Ok(RouteOutcome::Retryable { + reason: "direct Iota router cannot route a legacy Omega destination".into(), + }); + }; + let mut connection = self + .peers + .read() + .ok() + .and_then(|peers| peers.get(node).map(|peer| peer.1.clone())); + if connection.is_none() { + let manager = self + .manager + .read() + .ok() + .and_then(|manager| manager.as_ref().and_then(Weak::upgrade)); + if let Some(manager) = manager { + manager.connect_best(node).await?; + connection = self + .peers + .read() + .ok() + .and_then(|peers| peers.get(node).map(|peer| peer.1.clone())); + } + } + let connection = connection.ok_or_else(|| RouteError::NoRoute(node.clone()))?; + connection.send_relay(frame).await + } +} + +#[async_trait] +pub trait LocalDeliverySink: Send + Sync { + async fn deliver(&self, recipient: iota_identity::PrincipalHandle, frame: CommunicationValue); +} + +pub struct PeerManager { + identity: iota_identity::LocalNodeIdentity, + nodes: Arc, + router: Arc, + relay: Arc, + deliveries: Arc, +} + +impl PeerManager { + pub fn new( + identity: iota_identity::LocalNodeIdentity, + nodes: Arc, + router: Arc, + relay: Arc, + deliveries: Arc, + ) -> Self { + Self { + identity, + nodes, + router, + relay, + deliveries, + } + } + + pub async fn connect( + self: &Arc, + node: IotaNodeId, + endpoint: &AuthorityLocator, + pinned_certificate: Option>, + ) -> Result<(), RouteError> { + let resolved = self + .nodes + .resolve_node(&node) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + let host_key = resolved + .public_keys + .first() + .ok_or_else(|| RouteError::Delivery("remote node has no trusted key".into()))?; + let mut config = mtp::client::ClientConfig::new(format!("https://{}/", endpoint.as_str())) + .with_client_id(self.identity.node_id().mtp_authentication_hint()) + .with_description(format!("iota-peer:{}", self.identity.node_id().as_str())); + if let Some(certificate) = pinned_certificate { + config = config.with_pinned_pem(certificate); + } + let connection = + mtp::client::MTPClient::auth_connect(config, &self.identity.keyring(), host_key) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + if connection.auth_state != mtp::client::AuthState::Authenticated { + return Err(RouteError::Delivery( + "remote peer did not complete node authentication".into(), + )); + } + self.nodes + .pin_endpoint(endpoint, &node, now_millis()) + .map_err(|error| RouteError::Delivery(error.to_string()))?; + let connection = Arc::new(connection); + let registration = Some( + self.router + .register(node.clone(), Arc::new(OutgoingPeer(connection.clone()))), + ); + let manager = self.clone(); + tokio::spawn(async move { + manager + .outgoing_read_loop(node, connection, registration) + .await; + }); + Ok(()) + } + + pub async fn connect_best(self: &Arc, node: &IotaNodeId) -> Result<(), RouteError> { + let resolved = self + .nodes + .resolve_node(node) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + let mut endpoints = resolved.direct_endpoints; + if let Some(last) = resolved.last_successful_endpoint { + endpoints.retain(|endpoint| endpoint != &last); + endpoints.insert(0, last); + } + let mut last_error = None; + for endpoint in endpoints { + let certificate = self + .nodes + .pinned_endpoint_certificate(&endpoint) + .map_err(|error| RouteError::Delivery(error.to_string()))?; + match self.connect(node.clone(), &endpoint, certificate).await { + Ok(()) => { + let _ = self.nodes.record_successful_endpoint(node, &endpoint); + return Ok(()); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| RouteError::NoRoute(node.clone()))) + } + + fn prefers_inbound(&self, remote: &IotaNodeId) -> bool { + prefers_inbound(self.identity.node_id(), remote) + } + + async fn outgoing_read_loop( + self: Arc, + node: IotaNodeId, + connection: Arc, + registration: Option, + ) { + while let Ok(frame) = connection.receive().await { + self.process_incoming(&node, frame, |response| { + let sender = connection.sender.clone(); + async move { + sender + .send(&response) + .await + .map_err(|error| error.to_string()) + } + }) + .await; + } + if let Some(registration) = registration { + self.router.unregister(&node, registration); + } + } + + async fn process_incoming(&self, node: &IotaNodeId, frame: CommunicationValue, send: F) + where + F: FnOnce(CommunicationValue) -> Fut, + Fut: Future>, + { + let outcome = self + .relay + .accept_relay( + IngressSource::DirectPeer { + node_id: node.clone(), + }, + frame, + ) + .await; + let Ok(outcome) = outcome else { + return; + }; + let (response, deliveries) = outcome.into_parts(); + for delivery in deliveries { + self.deliveries + .deliver(delivery.recipient, delivery.frame) + .await; + } + if let Some(response) = response { + let _ = send(response).await; + } + } +} + +fn prefers_inbound(local: &IotaNodeId, remote: &IotaNodeId) -> bool { + local.as_str() > remote.as_str() +} + +struct OutgoingPeer(Arc); + +#[async_trait] +impl DirectPeerConnection for OutgoingPeer { + async fn send_relay(&self, frame: CommunicationValue) -> Result { + let response = self + .0 + .request(&frame, None) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + route_outcome(response) + } +} + +fn route_outcome(response: CommunicationValue) -> Result { + if response.is_type(mtp::codec::CommunicationType::Success) { + let relay_message_id = response + .get_data(mtp::codec::DataType::RelayMessageId) + .as_str() + .ok_or_else(|| { + RouteError::Delivery("peer acknowledgement has no RelayMessageId".into()) + })? + .to_owned(); + let destination_accepted_at = response + .get_data(mtp::codec::DataType::RelayAcceptedAt) + .as_number() + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| { + RouteError::Delivery("peer acknowledgement has no RelayAcceptedAt".into()) + })?; + Ok(RouteOutcome::Accepted { + relay_message_id, + destination_accepted_at, + }) + } else if response.is_type(mtp::codec::CommunicationType::ErrorInternal) { + Ok(RouteOutcome::Retryable { + reason: "remote Iota returned an internal error".into(), + }) + } else { + Ok(RouteOutcome::Rejected { + response_type: response + .get_comm_type_enum() + .unwrap_or(mtp::codec::CommunicationType::ErrorInvalidData), + }) + } +} + +#[async_trait] +impl web_server::MtpConnectionHandler for PeerManager { + async fn accept(&self, connection: mtp::webserver::WebMTPConnection) { + if connection.auth_state != mtp::host::AuthState::Authenticated { + return; + } + let Some(public_key) = connection.client_public_key.clone() else { + return; + }; + let Ok(node) = IotaNodeId::from_public_keys(&public_key) else { + return; + }; + let Ok(expected) = self.nodes.resolve_node(&node).await else { + return; + }; + let Ok(actual) = public_key.try_as_bytes() else { + return; + }; + if !expected.public_keys.iter().any(|key| { + key.try_as_bytes() + .map(|trusted| trusted == actual) + .unwrap_or(false) + }) { + return; + } + let connection = Arc::new(connection); + let pending: Arc< + tokio::sync::Mutex>>, + > = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + let registration = self.prefers_inbound(&node).then(|| { + self.router.register( + node.clone(), + Arc::new(IncomingPeer { + connection: connection.clone(), + pending: pending.clone(), + }), + ) + }); + while let Ok(frame) = connection.receive().await { + if let Some(frame_id) = frame.id() + && let Some(response) = pending.lock().await.remove(&frame_id) + { + let _ = response.send(frame); + continue; + } + self.process_incoming(&node, frame, |response| { + let sender = connection.sender.clone(); + async move { + sender + .send(&response) + .await + .map_err(|error| error.to_string()) + } + }) + .await; + } + if let Some(registration) = registration { + self.router.unregister(&node, registration); + } + } +} + +struct IncomingPeer { + connection: Arc, + pending: + Arc>>>, +} + +#[async_trait] +impl DirectPeerConnection for IncomingPeer { + async fn send_relay(&self, frame: CommunicationValue) -> Result { + let frame_id = frame + .id() + .filter(|frame_id| *frame_id != 0) + .ok_or_else(|| RouteError::Delivery("relay request has no frame ID".into()))?; + let (send, receive) = tokio::sync::oneshot::channel(); + if self.pending.lock().await.insert(frame_id, send).is_some() { + return Err(RouteError::Delivery( + "relay request ID is already pending".into(), + )); + } + if let Err(error) = self.connection.sender.send(&frame).await { + self.pending.lock().await.remove(&frame_id); + return Err(RouteError::Delivery(error.to_string())); + } + let response = tokio::time::timeout(std::time::Duration::from_secs(30), receive) + .await + .map_err(|_| RouteError::Delivery("peer relay request timed out".into()))? + .map_err(|_| RouteError::Delivery("peer relay connection closed".into()))?; + route_outcome(response) + } +} + +pub struct PeerRelayAdapter { + relay: Arc, +} + +impl PeerRelayAdapter { + pub fn new(relay: Arc) -> Self { + Self { relay } + } + + pub async fn accept_verified_relay( + &self, + authenticated_peer: IotaNodeId, + relay: VerifiedRelayEnvelope, + ) -> Result { + self.relay + .accept_verified_relay( + IngressSource::DirectPeer { + node_id: authenticated_peer, + }, + relay, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iota_identity::{ + LocalNodeIdentity, NodeDescriptor, NodeIdentityResolver, PrincipalDescriptor, + PrincipalHandle, PrincipalHome, SignedNodeDescriptor, SignedPrincipalDescriptor, + VerifiedNodeDescriptor, VerifiedPrincipalDescriptor, + }; + use mtp::codec::CommunicationType; + use std::sync::Mutex; + + fn node() -> IotaNodeId { + iota_identity::LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()) + .expect("generated identity is valid") + .node_id() + .clone() + } + + #[test] + fn captured_der_certificate_is_stored_as_valid_pem() { + let key_pair = rcgen::KeyPair::generate().unwrap(); + let certificate = rcgen::CertificateParams::new(vec!["127.0.0.1".into()]) + .unwrap() + .self_signed(&key_pair) + .unwrap(); + let pem = certificate_to_pem(certificate.der().to_vec()).unwrap(); + reqwest::Certificate::from_pem(&pem).unwrap(); + } + + struct AcceptingPeer; + + #[async_trait] + impl DirectPeerConnection for AcceptingPeer { + async fn send_relay(&self, _: CommunicationValue) -> Result { + Ok(RouteOutcome::Accepted { + relay_message_id: "relay-a".into(), + destination_accepted_at: 42, + }) + } + } + + #[tokio::test] + async fn direct_router_uses_the_connection_registered_for_the_node() { + let router = DirectPeerRouter::default(); + let node = node(); + router.register(node.clone(), Arc::new(AcceptingPeer)); + + let outcome = router + .route( + &RouteDestination::Iota(node), + CommunicationValue::new(CommunicationType::Relay), + ) + .await + .expect("registered peer is routable"); + assert!(matches!(outcome, RouteOutcome::Accepted { .. })); + } + + #[tokio::test] + async fn direct_router_reports_an_unknown_node_as_no_route() { + let router = DirectPeerRouter::default(); + let node = node(); + let error = router + .route( + &RouteDestination::Iota(node.clone()), + CommunicationValue::new(CommunicationType::Relay), + ) + .await + .expect_err("unregistered peer has no route"); + assert!(matches!(error, RouteError::NoRoute(found) if found == node)); + } + + #[test] + fn simultaneous_dials_choose_one_connection_direction() { + let first = node(); + let second = node(); + assert_ne!(first, second); + assert_ne!( + prefers_inbound(&first, &second), + prefers_inbound(&second, &first) + ); + } + + struct MemoryPrincipals { + values: Mutex>, + } + + impl PrincipalStore for MemoryPrincipals { + fn get_principal( + &self, + handle: PrincipalHandle, + ) -> Result, IdentityError> { + Ok(self + .values + .lock() + .map_err(|_| IdentityError::Storage("principal lock is poisoned".into()))? + .values() + .find(|principal| principal.handle == handle) + .cloned()) + } + + fn get_by_canonical_id( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError> { + Ok(self + .values + .lock() + .map_err(|_| IdentityError::Storage("principal lock is poisoned".into()))? + .get(principal) + .cloned()) + } + + fn get_by_username( + &self, + authority: &AuthorityId, + username: &str, + ) -> Result, IdentityError> { + Ok(self + .values + .lock() + .map_err(|_| IdentityError::Storage("principal lock is poisoned".into()))? + .values() + .find(|principal| { + &principal.principal.authority == authority + && principal.username.as_deref() == Some(username) + }) + .cloned()) + } + + fn upsert_remote_descriptor( + &self, + descriptor: &VerifiedPrincipalDescriptor, + ) -> Result { + let descriptor_value = descriptor.descriptor(); + let mut values = self + .values + .lock() + .map_err(|_| IdentityError::Storage("principal lock is poisoned".into()))?; + if values + .get(&descriptor_value.principal) + .is_some_and(|current| current.descriptor_revision > descriptor_value.revision) + { + return Err(IdentityError::Storage("stale descriptor".into())); + } + let handle = values + .get(&descriptor_value.principal) + .map(|principal| principal.handle) + .unwrap_or(PrincipalHandle(values.len() as i64 + 1)); + values.insert( + descriptor_value.principal.clone(), + ResolvedPrincipal { + principal: descriptor_value.principal.clone(), + handle, + username: descriptor_value.username.clone(), + public_keys: descriptor_value.public_keys.clone(), + home: descriptor_value.home.clone(), + descriptor_revision: descriptor_value.revision, + valid_until: descriptor_value.valid_until, + resolved_at: descriptor.resolved_at(), + }, + ); + Ok(handle) + } + + fn signing_keys( + &self, + principal: &PrincipalId, + ) -> Result, IdentityError> { + self.get_by_canonical_id(principal)? + .map(|principal| principal.public_keys) + .ok_or(IdentityError::NotFound) + } + } + + struct MemoryNodes { + values: Mutex>, + pins: Mutex>, + certificates: Mutex>>, + } + + #[async_trait] + impl iota_identity::NodeIdentityResolver for MemoryNodes { + async fn resolve_node( + &self, + node: &IotaNodeId, + ) -> Result { + self.values + .lock() + .map_err(|_| IdentityError::Storage("node lock is poisoned".into()))? + .get(node) + .cloned() + .ok_or(IdentityError::NotFound) + } + } + + impl NodeDirectory for MemoryNodes { + fn upsert_node_descriptor( + &self, + descriptor: &VerifiedNodeDescriptor, + ) -> Result<(), IdentityError> { + let value = descriptor.descriptor(); + self.values + .lock() + .map_err(|_| IdentityError::Storage("node lock is poisoned".into()))? + .insert( + value.node.clone(), + iota_identity::ResolvedNodeIdentity { + node: value.node.clone(), + public_keys: vec![value.public_keys.clone()], + descriptor_revision: value.revision, + valid_until: value.valid_until, + direct_endpoints: value.direct_endpoints.clone(), + relay_hints: value.relay_hints.clone(), + last_successful_endpoint: None, + resolved_at: descriptor.resolved_at(), + }, + ); + Ok(()) + } + + fn pin_endpoint( + &self, + endpoint: &AuthorityLocator, + node: &IotaNodeId, + _: i64, + ) -> Result<(), IdentityError> { + let mut pins = self + .pins + .lock() + .map_err(|_| IdentityError::Storage("pin lock is poisoned".into()))?; + if pins.get(endpoint).is_some_and(|pinned| pinned != node) { + return Err(IdentityError::InvalidDescriptor( + "endpoint node identity changed from its TOFU pin".into(), + )); + } + pins.insert(endpoint.clone(), node.clone()); + Ok(()) + } + + fn pinned_node( + &self, + endpoint: &AuthorityLocator, + ) -> Result, IdentityError> { + Ok(self + .pins + .lock() + .map_err(|_| IdentityError::Storage("pin lock is poisoned".into()))? + .get(endpoint) + .cloned()) + } + + fn pin_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + certificate: &[u8], + ) -> Result<(), IdentityError> { + let mut certificates = self + .certificates + .lock() + .map_err(|_| IdentityError::Storage("certificate lock is poisoned".into()))?; + if certificates + .get(endpoint) + .is_some_and(|pinned| pinned != certificate) + { + return Err(IdentityError::InvalidDescriptor( + "endpoint TLS certificate changed from its TOFU pin".into(), + )); + } + certificates.insert(endpoint.clone(), certificate.to_vec()); + Ok(()) + } + + fn pinned_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + ) -> Result>, IdentityError> { + Ok(self + .certificates + .lock() + .map_err(|_| IdentityError::Storage("certificate lock is poisoned".into()))? + .get(endpoint) + .cloned()) + } + + fn record_successful_endpoint( + &self, + _: &IotaNodeId, + _: &AuthorityLocator, + ) -> Result<(), IdentityError> { + Ok(()) + } + } + + struct FakeFederationClient { + authority: Mutex, + node: Mutex, + principal: Mutex, + certificate: Mutex>, + } + + #[async_trait] + impl FederationIdentityClient for FakeFederationClient { + async fn discover( + &self, + _: &AuthorityLocator, + ) -> Result { + self.authority + .lock() + .map(|authority| authority.clone()) + .map_err(|_| IdentityError::Storage("authority lock is poisoned".into())) + } + + async fn node_descriptor( + &self, + _: &AuthorityLocator, + _: &str, + ) -> Result { + let descriptor = self + .node + .lock() + .map_err(|_| IdentityError::Storage("node lock is poisoned".into()))? + .clone(); + let certificate = self + .certificate + .lock() + .map_err(|_| IdentityError::Storage("certificate lock is poisoned".into()))? + .clone(); + Ok(FetchedNodeDescriptor { + descriptor, + tls_certificate: Some(certificate), + }) + } + + async fn principal_descriptor( + &self, + _: &AuthorityLocator, + _: &UserSelector, + ) -> Result { + self.principal + .lock() + .map(|principal| principal.clone()) + .map_err(|_| IdentityError::Storage("principal lock is poisoned".into())) + } + } + + type RemoteFixture = ( + Arc, + Arc, + Arc, + Arc, + AuthorityLocator, + PrincipalId, + PublicKeyBundle, + ); + + fn remote_fixture(principal_revision: i64) -> RemoteFixture { + let authority = LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let user = mtp::crypto::Keyring::generate(); + let locator = AuthorityLocator::new("192.0.2.10").unwrap(); + let node = SignedNodeDescriptor::sign( + NodeDescriptor { + node: authority.node_id().clone(), + public_keys: authority.public_keys(), + direct_endpoints: vec![locator.clone()], + relay_hints: Vec::new(), + revision: 1, + valid_until: Some(now_millis() + 60_000), + }, + &authority, + ) + .unwrap() + .to_wire_v1() + .unwrap(); + let principal_id = PrincipalId { + authority: authority.authority_id().clone(), + user_id: 7, + }; + let principal = SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: principal_id.clone(), + authority_kind: AuthorityKind::Iota, + username: Some("alice".into()), + display_name: None, + public_keys: vec![user.public_key_bundle()], + home: PrincipalHome::Iota(authority.node_id().clone()), + revision: principal_revision, + valid_until: Some(now_millis() + 60_000), + issued_at: now_millis(), + }, + &authority.keyring(), + ) + .unwrap() + .to_wire_v1() + .unwrap(); + let client = Arc::new(FakeFederationClient { + authority: Mutex::new(DiscoveredAuthority { + service: AuthorityKind::Iota, + authority_id: authority.authority_id().clone(), + node_id: Some(authority.node_id().clone()), + public_keys: authority.public_keys(), + protocols: vec!["identity-http-v1".into()], + node_descriptor: Some("/federation/v1/node".into()), + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + }), + node: Mutex::new(node), + principal: Mutex::new(principal), + certificate: Mutex::new(b"test certificate".to_vec()), + }); + let principals = Arc::new(MemoryPrincipals { + values: Mutex::new(HashMap::new()), + }); + let nodes = Arc::new(MemoryNodes { + values: Mutex::new(HashMap::new()), + pins: Mutex::new(HashMap::new()), + certificates: Mutex::new(HashMap::new()), + }); + let resolver = Arc::new(RemoteIotaResolver::new( + client.clone(), + principals.clone(), + nodes.clone(), + )); + ( + resolver, + client, + principals, + nodes, + locator, + principal_id, + user.public_key_bundle(), + ) + } + + #[tokio::test] + async fn remote_iota_resolves_username_id_and_key_pin() { + let (resolver, _, _, _, locator, principal, key) = remote_fixture(1); + for selector in [ + UserSelector::Username("alice".into()), + UserSelector::UserId(7), + ] { + let resolved = resolver + .resolve_address( + &UserAddress { + selector, + public_key_pin: Some(key.clone()), + authority: Some(locator.clone()), + }, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap(); + assert_eq!(resolved.principal, principal); + } + } + + #[tokio::test] + async fn remote_iota_rejects_wrong_key_pin() { + let (resolver, _, _, _, locator, _, _) = remote_fixture(1); + let error = resolver + .resolve_address( + &UserAddress { + selector: UserSelector::UserId(7), + public_key_pin: Some(mtp::crypto::Keyring::generate().public_key_bundle()), + authority: Some(locator.clone()), + }, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap_err(); + assert_eq!(error, IdentityError::KeyPinMismatch); + } + + #[tokio::test] + async fn remote_iota_rejects_wrong_authority_signature() { + let (resolver, client, _, _, locator, _, _) = remote_fixture(1); + let descriptor = client + .principal + .lock() + .unwrap() + .clone() + .parse() + .unwrap() + .descriptor; + *client.principal.lock().unwrap() = + SignedPrincipalDescriptor::sign(descriptor, &mtp::crypto::Keyring::generate()) + .unwrap() + .to_wire_v1() + .unwrap(); + + let authority = client.discover(&locator).await.unwrap(); + let error = resolver + .refresh(&locator, &authority, &UserSelector::UserId(7)) + .await + .unwrap_err(); + assert!(matches!(error, IdentityError::InvalidDescriptor(_))); + } + + #[tokio::test] + async fn remote_iota_rejects_stale_principal_revision() { + let (resolver, client, principals, _, locator, _, _) = remote_fixture(1); + let mut current = client + .principal + .lock() + .unwrap() + .clone() + .parse() + .unwrap() + .descriptor; + current.revision = 2; + principals + .upsert_remote_descriptor( + &VerifiedPrincipalDescriptor::from_trusted_authority(current).unwrap(), + ) + .unwrap(); + + let authority = client.discover(&locator).await.unwrap(); + let error = resolver + .refresh(&locator, &authority, &UserSelector::UserId(7)) + .await + .unwrap_err(); + assert!(matches!(error, IdentityError::InvalidDescriptor(_))); + } + + #[tokio::test] + async fn remote_iota_uses_valid_cached_descriptor_offline() { + let (resolver, client, principals, nodes, locator, principal, _) = remote_fixture(1); + let address = UserAddress { + selector: UserSelector::UserId(7), + public_key_pin: None, + authority: Some(locator), + }; + resolver + .resolve_address( + &address, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap(); + + let restarted = RemoteIotaResolver::new(client, principals, nodes); + let cached = restarted + .resolve_address(&address, &ResolutionContext::default()) + .await + .unwrap(); + assert_eq!(cached.principal, principal); + } + + #[tokio::test] + async fn remote_iota_rejects_discovery_node_mismatch() { + let (resolver, client, _, _, locator, _, _) = remote_fixture(1); + client.authority.lock().unwrap().node_id = Some(node()); + + let error = resolver + .resolve_address( + &UserAddress { + selector: UserSelector::UserId(7), + public_key_pin: None, + authority: Some(locator), + }, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, IdentityError::InvalidDescriptor(_))); + } + + #[tokio::test] + async fn raw_ip_bootstrap_rejects_changed_tofu_node() { + let (resolver, _, _, nodes, locator, _, _) = remote_fixture(1); + nodes.pins.lock().unwrap().insert(locator.clone(), node()); + + let error = resolver + .resolve_address( + &UserAddress { + selector: UserSelector::UserId(7), + public_key_pin: None, + authority: Some(locator), + }, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, IdentityError::InvalidDescriptor(_))); + } + + #[tokio::test] + async fn raw_ip_bootstrap_accepts_explicit_node_key_pin() { + let (resolver, client, _, nodes, locator, principal, _) = remote_fixture(1); + let key = client + .node + .lock() + .unwrap() + .clone() + .parse() + .unwrap() + .descriptor + .public_keys; + let pinned = resolver.pin_public_key(&locator, &key).unwrap(); + + let resolved = resolver + .resolve_address( + &UserAddress { + selector: UserSelector::UserId(7), + public_key_pin: None, + authority: Some(locator.clone()), + }, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap(); + assert_eq!(resolved.principal, principal); + assert!(matches!(resolved.home, PrincipalHome::Iota(home) if home == pinned)); + assert_eq!( + nodes.pinned_endpoint_certificate(&locator).unwrap(), + Some(b"test certificate".to_vec()) + ); + let resolved_at = nodes.resolve_node(&pinned).await.unwrap().resolved_at; + + *client.certificate.lock().unwrap() = b"changed certificate".to_vec(); + let authority = client.discover(&locator).await.unwrap(); + assert!( + resolver + .refresh(&locator, &authority, &UserSelector::UserId(7)) + .await + .is_err() + ); + assert_eq!( + nodes.resolve_node(&pinned).await.unwrap().resolved_at, + resolved_at + ); + } + + #[tokio::test] + async fn remote_omega_resolves_principal_and_hosting_iota() { + let omega = mtp::crypto::Keyring::generate(); + let omega_authority = AuthorityId::for_omega(&omega.public_key_bundle()).unwrap(); + let hosting_iota = + LocalNodeIdentity::from_keyring(mtp::crypto::Keyring::generate()).unwrap(); + let user = mtp::crypto::Keyring::generate(); + let locator = AuthorityLocator::new("omega.example.org").unwrap(); + let node = SignedNodeDescriptor::sign( + NodeDescriptor { + node: hosting_iota.node_id().clone(), + public_keys: hosting_iota.public_keys(), + direct_endpoints: vec![AuthorityLocator::new("iota.example.org").unwrap()], + relay_hints: Vec::new(), + revision: 1, + valid_until: Some(now_millis() + 60_000), + }, + &hosting_iota, + ) + .unwrap() + .to_wire_v1() + .unwrap(); + let principal_id = PrincipalId { + authority: omega_authority.clone(), + user_id: 7, + }; + let principal = SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: principal_id.clone(), + authority_kind: AuthorityKind::Omega, + username: Some("alice".into()), + display_name: None, + public_keys: vec![user.public_key_bundle()], + home: PrincipalHome::Iota(hosting_iota.node_id().clone()), + revision: 1, + valid_until: Some(now_millis() + 60_000), + issued_at: now_millis(), + }, + &omega, + ) + .unwrap() + .to_wire_v1() + .unwrap() + .with_home_node(node); + let client = Arc::new(FakeFederationClient { + authority: Mutex::new(DiscoveredAuthority { + service: AuthorityKind::Omega, + authority_id: omega_authority, + node_id: None, + public_keys: omega.public_key_bundle(), + protocols: vec!["identity-http-v1".into()], + node_descriptor: None, + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + }), + node: Mutex::new( + SignedNodeDescriptor::sign( + NodeDescriptor { + node: hosting_iota.node_id().clone(), + public_keys: hosting_iota.public_keys(), + direct_endpoints: Vec::new(), + relay_hints: Vec::new(), + revision: 1, + valid_until: Some(now_millis() + 60_000), + }, + &hosting_iota, + ) + .unwrap() + .to_wire_v1() + .unwrap(), + ), + principal: Mutex::new(principal), + certificate: Mutex::new(b"test certificate".to_vec()), + }); + let principals = Arc::new(MemoryPrincipals { + values: Mutex::new(HashMap::new()), + }); + let nodes = Arc::new(MemoryNodes { + values: Mutex::new(HashMap::new()), + pins: Mutex::new(HashMap::new()), + certificates: Mutex::new(HashMap::new()), + }); + let resolver = RemoteOmegaResolver::new(client, principals, nodes.clone()); + + let resolved = resolver + .resolve_address( + &UserAddress { + selector: UserSelector::Username("alice".into()), + public_key_pin: Some(user.public_key_bundle()), + authority: Some(locator), + }, + &ResolutionContext { + allow_network: true, + ..ResolutionContext::default() + }, + ) + .await + .unwrap(); + assert_eq!(resolved.principal, principal_id); + assert!(nodes.resolve_node(hosting_iota.node_id()).await.is_ok()); + } +} diff --git a/other-iota/src/relay_router.rs b/other-iota/src/relay_router.rs new file mode 100644 index 0000000..231fc05 --- /dev/null +++ b/other-iota/src/relay_router.rs @@ -0,0 +1,413 @@ +use async_trait::async_trait; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::relay_service::{IngressSource, RelayService}; +use iota_identity::{ + AuthorityLocator, IotaNodeId, LocalNodeIdentity, PublicKeyBundle, RelayRouterId, +}; +use iota_util::mtp_compat::OptionalDataValueExt; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +use crate::{LocalDeliverySink, route_outcome}; + +const ROUTER_PROTOCOL: &str = "tensamin-relay-router:v1"; +static NEXT_ROUTER_FRAME_ID: AtomicU32 = AtomicU32::new(1); + +#[async_trait] +trait RouterPeerConnection: Send + Sync { + async fn request(&self, frame: CommunicationValue) -> Result; +} + +type RegisteredRouterPeer = (u64, Arc); + +#[derive(Default)] +pub struct RelayRouterService { + peers: RwLock>, + next_registration: AtomicU64, +} + +impl RelayRouterService { + pub fn has_connection(&self, node: &IotaNodeId) -> bool { + self.peers + .read() + .is_ok_and(|peers| peers.contains_key(node)) + } + + fn register(&self, node: IotaNodeId, connection: Arc) -> u64 { + let registration = self.next_registration.fetch_add(1, Ordering::Relaxed) + 1; + if let Ok(mut peers) = self.peers.write() { + peers.insert(node, (registration, connection)); + } + registration + } + + fn unregister(&self, node: &IotaNodeId, registration: u64) { + if let Ok(mut peers) = self.peers.write() + && peers + .get(node) + .is_some_and(|current| current.0 == registration) + { + peers.remove(node); + } + } + + async fn forward( + &self, + source: &IotaNodeId, + destination: &IotaNodeId, + frame: CommunicationValue, + ) -> Result { + if source == destination { + return Err(RouteError::Delivery( + "relay router source and destination are identical".into(), + )); + } + let peer = self + .peers + .read() + .ok() + .and_then(|peers| peers.get(destination).map(|peer| peer.1.clone())) + .ok_or_else(|| RouteError::NoRoute(destination.clone()))?; + peer.request(frame).await + } +} + +#[async_trait] +impl web_server::MtpConnectionHandler for RelayRouterService { + async fn accept(&self, connection: mtp::webserver::WebMTPConnection) { + if connection.auth_state != mtp::host::AuthState::Authenticated { + return; + } + let Some(public_key) = connection.client_public_key.clone() else { + return; + }; + let Ok(node) = IotaNodeId::from_public_keys(&public_key) else { + return; + }; + let connection = Arc::new(connection); + let pending = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + let registration = self.register( + node.clone(), + Arc::new(IncomingRouterPeer { + connection: connection.clone(), + pending: pending.clone(), + }), + ); + while let Ok(frame) = connection.receive().await { + if let Some(frame_id) = frame.id() + && let Some(response) = pending.lock().await.remove(&frame_id) + { + let _ = response.send(frame); + continue; + } + let Some(frame_id) = frame.id() else { + continue; + }; + let response = match decode_router_request(&frame) { + Ok((destination, relay)) => match self.forward(&node, &destination, relay).await { + Ok(response) => response.with_id(frame_id), + Err(RouteError::NoRoute(_)) => { + CommunicationValue::new(CommunicationType::ErrorNoIota).with_id(frame_id) + } + Err(RouteError::Delivery(message)) => { + CommunicationValue::new(CommunicationType::ErrorInternal) + .with_id(frame_id) + .add_typed_default(DataType::ErrorMessage, DataValue::Str(message)) + } + }, + Err(message) => CommunicationValue::new(CommunicationType::ErrorInvalidData) + .with_id(frame_id) + .add_typed_default(DataType::ErrorMessage, DataValue::Str(message)), + }; + let _ = connection.sender.send(&response).await; + } + self.unregister(&node, registration); + } +} + +struct IncomingRouterPeer { + connection: Arc, + pending: + Arc>>>, +} + +#[async_trait] +impl RouterPeerConnection for IncomingRouterPeer { + async fn request( + &self, + mut frame: CommunicationValue, + ) -> Result { + let frame_id = next_frame_id(); + frame = frame.with_id(frame_id); + let (send, receive) = tokio::sync::oneshot::channel(); + if self.pending.lock().await.insert(frame_id, send).is_some() { + return Err(RouteError::Delivery( + "relay router request ID is already pending".into(), + )); + } + if let Err(error) = self.connection.sender.send(&frame).await { + self.pending.lock().await.remove(&frame_id); + return Err(RouteError::Delivery(error.to_string())); + } + tokio::time::timeout(std::time::Duration::from_secs(30), receive) + .await + .map_err(|_| RouteError::Delivery("relay router request timed out".into()))? + .map_err(|_| RouteError::Delivery("relay router connection closed".into())) + } +} + +pub struct RelayRouterClient { + router_id: RelayRouterId, + connection: Arc, + connected: AtomicBool, + disconnected: tokio::sync::Notify, +} + +#[derive(Default)] +pub struct RelayRouterSet { + routers: RwLock>>, +} + +impl RelayRouterSet { + pub fn add(&self, router: Arc) { + if let Ok(mut routers) = self.routers.write() { + routers.retain(|current| current.router_id() != router.router_id()); + routers.push(router); + } + } +} + +#[async_trait] +impl PeerRouter for RelayRouterSet { + async fn route( + &self, + destination: &RouteDestination, + frame: CommunicationValue, + ) -> Result { + let routers = self + .routers + .read() + .map(|routers| routers.clone()) + .unwrap_or_default(); + if routers.is_empty() { + return Ok(RouteOutcome::Retryable { + reason: "no relay router is configured".into(), + }); + } + let mut last_reason = "all relay routers are unavailable".to_string(); + for router in routers { + if !router.is_connected() { + continue; + } + match router.route(destination, frame.clone()).await { + outcome @ Ok(RouteOutcome::Accepted { .. } | RouteOutcome::Rejected { .. }) => { + return outcome; + } + Ok(RouteOutcome::Retryable { reason }) => last_reason = reason, + Err(error) => last_reason = error.to_string(), + } + } + Ok(RouteOutcome::Retryable { + reason: last_reason, + }) + } +} + +impl RelayRouterClient { + pub async fn connect( + identity: LocalNodeIdentity, + endpoint: &AuthorityLocator, + pinned_certificate: Vec, + router_key: PublicKeyBundle, + relay: Arc, + deliveries: Arc, + ) -> Result, RouteError> { + let router_id = RelayRouterId::from_public_keys(&router_key) + .map_err(|error| RouteError::Delivery(error.to_string()))?; + let config = mtp::client::ClientConfig::new(format!("https://{}/", endpoint.as_str())) + .with_pinned_pem(pinned_certificate) + .with_client_id(identity.node_id().mtp_authentication_hint()) + .with_description(format!("iota-router: {}", identity.node_id().as_str())); + let connection = + mtp::client::MTPClient::auth_connect(config, &identity.keyring(), &router_key) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + if connection.auth_state != mtp::client::AuthState::Authenticated { + return Err(RouteError::Delivery( + "relay router did not complete authentication".into(), + )); + } + let client = Arc::new(Self { + router_id, + connection: Arc::new(connection), + connected: AtomicBool::new(true), + disconnected: tokio::sync::Notify::new(), + }); + let reader = client.clone(); + tokio::spawn(async move { + while let Ok(frame) = reader.connection.receive().await { + let outcome = relay + .accept_relay( + IngressSource::RelayRouter { + router_id: reader.router_id.clone(), + }, + frame, + ) + .await; + let Ok(outcome) = outcome else { + continue; + }; + let (response, local_deliveries) = outcome.into_parts(); + for delivery in local_deliveries { + deliveries.deliver(delivery.recipient, delivery.frame).await; + } + if let Some(response) = response { + let _ = reader.connection.sender.send(&response).await; + } + } + reader.connected.store(false, Ordering::Release); + reader.disconnected.notify_waiters(); + }); + Ok(client) + } + + pub fn router_id(&self) -> &RelayRouterId { + &self.router_id + } + + pub fn is_connected(&self) -> bool { + self.connected.load(Ordering::Acquire) + } + + pub async fn wait_disconnected(&self) { + loop { + let disconnected = self.disconnected.notified(); + if !self.is_connected() { + return; + } + disconnected.await; + } + } +} + +#[async_trait] +impl PeerRouter for RelayRouterClient { + async fn route( + &self, + destination: &RouteDestination, + frame: CommunicationValue, + ) -> Result { + let RouteDestination::Iota(destination) = destination else { + return Ok(RouteOutcome::Retryable { + reason: "relay router cannot route a legacy Omega destination".into(), + }); + }; + let request = encode_router_request(destination, &frame)?; + let response = self + .connection + .request(&request, None) + .await + .map_err(|error| RouteError::Delivery(error.to_string()))?; + if response.is_type(CommunicationType::ErrorNoIota) + || response.is_type(CommunicationType::ErrorInternal) + { + return Ok(RouteOutcome::Retryable { + reason: response + .get_data(DataType::ErrorMessage) + .as_str() + .unwrap_or("relay router destination is unavailable") + .to_owned(), + }); + } + route_outcome(response) + } +} + +pub struct DirectThenRelayRouter { + direct: Arc, + relay: Arc, +} + +impl DirectThenRelayRouter { + pub fn new(direct: Arc, relay: Arc) -> Self { + Self { direct, relay } + } +} + +#[async_trait] +impl PeerRouter for DirectThenRelayRouter { + async fn route( + &self, + destination: &RouteDestination, + frame: CommunicationValue, + ) -> Result { + match self.direct.route(destination, frame.clone()).await { + outcome @ Ok(RouteOutcome::Accepted { .. } | RouteOutcome::Rejected { .. }) => outcome, + Ok(RouteOutcome::Retryable { .. }) | Err(_) => { + self.relay.route(destination, frame).await + } + } + } +} + +fn encode_router_request( + destination: &IotaNodeId, + frame: &CommunicationValue, +) -> Result { + let type_map = frame + .type_map() + .ok_or_else(|| RouteError::Delivery("relay frame has no type map".into()))?; + let payload = frame + .to_bytes() + .map_err(|error| RouteError::Delivery(error.to_string()))?; + Ok(CommunicationValue::new(CommunicationType::Relay) + .with_id(next_frame_id()) + .add_typed_default(DataType::About, DataValue::Str(ROUTER_PROTOCOL.into())) + .add_typed_default( + DataType::Description, + DataValue::Str(destination.as_str().into()), + ) + .add_typed_default( + DataType::Version, + DataValue::Str(type_map.version.to_string()), + ) + .add_typed_default(DataType::SecurePayload, DataValue::Bytes(payload))) +} + +fn decode_router_request( + frame: &CommunicationValue, +) -> Result<(IotaNodeId, CommunicationValue), String> { + if !frame.is_type(CommunicationType::Relay) + || frame.get_data(DataType::About).as_str() != Some(ROUTER_PROTOCOL) + { + return Err("frame is not a relay-router request".into()); + } + let destination = frame + .get_data(DataType::Description) + .as_str() + .ok_or_else(|| "relay-router destination is missing".to_string()) + .and_then(|destination| IotaNodeId::new(destination).map_err(|error| error.to_string()))?; + let version = frame + .get_data(DataType::Version) + .as_str() + .and_then(mtp::type_map::Version::parse) + .ok_or_else(|| "relay-router type-map version is invalid".to_string())?; + let payload = match frame.get_data(DataType::SecurePayload) { + Some(DataValue::Bytes(payload)) => payload, + _ => return Err("relay-router payload is missing".into()), + }; + let relay = CommunicationValue::from_bytes_with(payload, &TypeMap::new(version)) + .map_err(|error| error.to_string())?; + Ok((destination, relay)) +} + +fn next_frame_id() -> u32 { + loop { + let id = NEXT_ROUTER_FRAME_ID.fetch_add(1, Ordering::Relaxed); + if id != 0 { + return id; + } + } +} diff --git a/other-iota/tests/direct_federation.rs b/other-iota/tests/direct_federation.rs new file mode 100644 index 0000000..b0c8169 --- /dev/null +++ b/other-iota/tests/direct_federation.rs @@ -0,0 +1,582 @@ +use async_trait::async_trait; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::federated_relay::FederatedRelayV2; +use iota_connection::relay_service::{RelayNodeIdentity, RelayService}; +use iota_identity::{ + AuthorityLocator, IdentityError, IdentityResolver, IotaNodeId, LocalNodeIdentity, + LocalUserDescriptor, LocalUserId, LocalUserStore, NodeDirectory, NodeIdentityResolver, + PrincipalDescriptor, PrincipalHandle, PrincipalHome, PrincipalId, PrincipalStore, + PublicKeyBundle, ResolutionContext, ResolvedNodeIdentity, ResolvedPrincipal, + SignedPrincipalDescriptor, UserAddress, +}; +use iota_storage::identity::SqlitePrincipalStore; +use iota_storage::util::{chat_files, e2ee_storage}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::Keyring; +use mtp::host::{AuthenticationPolicy, HostConfig}; +use mtp::webserver::{MTPWebServer, WebServerConfig}; +use other_iota::{DirectPeerRouter, LocalDeliverySink, PeerManager}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::{Arc, Mutex, RwLock}; + +struct StoreResolver; + +#[async_trait] +impl IdentityResolver for StoreResolver { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + SqlitePrincipalStore + .get_by_canonical_id(principal)? + .ok_or(IdentityError::NotFound) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + SqlitePrincipalStore.signing_keys(principal) + } +} + +struct HostedRecipient { + principal: PrincipalHandle, +} + +impl LocalUserStore for HostedRecipient { + fn get_local_user(&self, _: LocalUserId) -> Result, IdentityError> { + Ok(None) + } + + fn get_local_user_by_username( + &self, + _: &str, + ) -> Result, IdentityError> { + Ok(None) + } + + fn is_hosted_here(&self, id: LocalUserId) -> Result { + Ok(id == LocalUserId(1)) + } + + fn local_user_for_principal( + &self, + principal: PrincipalHandle, + ) -> Result, IdentityError> { + Ok((principal == self.principal).then_some(LocalUserId(1))) + } + + fn principal_for_local_user( + &self, + user: LocalUserId, + ) -> Result, IdentityError> { + Ok((user == LocalUserId(1)).then_some(self.principal)) + } +} + +struct TestNodeIdentity(LocalNodeIdentity); + +#[async_trait] +impl RelayNodeIdentity for TestNodeIdentity { + async fn keyring(&self) -> Option> { + Some(self.0.keyring()) + } + + fn node_id(&self) -> Option { + Some(self.0.node_id().clone()) + } +} + +struct NoRoute; + +#[async_trait] +impl PeerRouter for NoRoute { + async fn route( + &self, + destination: &RouteDestination, + _: CommunicationValue, + ) -> Result { + match destination { + RouteDestination::Iota(node) => Err(RouteError::NoRoute(node.clone())), + RouteDestination::LegacyOmegaIota { .. } => Ok(RouteOutcome::Retryable { + reason: "legacy route is unavailable".into(), + }), + } + } +} + +#[derive(Default)] +struct MemoryNodes { + nodes: RwLock>, + certificates: RwLock>>, +} + +#[async_trait] +impl NodeIdentityResolver for MemoryNodes { + async fn resolve_node(&self, node: &IotaNodeId) -> Result { + self.nodes + .read() + .map_err(|_| IdentityError::Storage("node lock is poisoned".into()))? + .get(node) + .cloned() + .ok_or(IdentityError::NotFound) + } +} + +impl NodeDirectory for MemoryNodes { + fn upsert_node_descriptor( + &self, + _: &iota_identity::VerifiedNodeDescriptor, + ) -> Result<(), IdentityError> { + Ok(()) + } + + fn pin_endpoint( + &self, + _: &AuthorityLocator, + _: &IotaNodeId, + _: i64, + ) -> Result<(), IdentityError> { + Ok(()) + } + + fn pinned_node(&self, _: &AuthorityLocator) -> Result, IdentityError> { + Ok(None) + } + + fn pin_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + certificate: &[u8], + ) -> Result<(), IdentityError> { + self.certificates + .write() + .map_err(|_| IdentityError::Storage("certificate lock is poisoned".into()))? + .insert(endpoint.clone(), certificate.to_vec()); + Ok(()) + } + + fn pinned_endpoint_certificate( + &self, + endpoint: &AuthorityLocator, + ) -> Result>, IdentityError> { + Ok(self + .certificates + .read() + .map_err(|_| IdentityError::Storage("certificate lock is poisoned".into()))? + .get(endpoint) + .cloned()) + } + + fn record_successful_endpoint( + &self, + _: &IotaNodeId, + _: &AuthorityLocator, + ) -> Result<(), IdentityError> { + Ok(()) + } +} + +#[derive(Default)] +struct Deliveries(Mutex>); + +#[async_trait] +impl LocalDeliverySink for Deliveries { + async fn deliver(&self, recipient: PrincipalHandle, frame: CommunicationValue) { + self.0.lock().unwrap().push((recipient, frame)); + } +} + +fn verified_principal( + authority: &LocalNodeIdentity, + user: &Keyring, + user_id: u64, +) -> iota_identity::VerifiedPrincipalDescriptor { + let now = iota_storage::util::sync::now_millis(); + SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: PrincipalId { + authority: authority.authority_id().clone(), + user_id, + }, + authority_kind: iota_identity::AuthorityKind::Iota, + username: Some("alice".into()), + display_name: None, + public_keys: vec![user.public_key_bundle()], + home: PrincipalHome::Iota(authority.node_id().clone()), + revision: 1, + valid_until: Some(now + 60_000), + issued_at: now, + }, + &authority.keyring(), + ) + .unwrap() + .verify( + authority.authority_id(), + &authority.public_keys(), + None, + now, + ) + .unwrap() +} + +fn resolved_node( + identity: &LocalNodeIdentity, + direct_endpoints: Vec, +) -> ResolvedNodeIdentity { + ResolvedNodeIdentity { + node: identity.node_id().clone(), + public_keys: vec![identity.public_keys()], + descriptor_revision: 1, + valid_until: None, + direct_endpoints, + relay_hints: Vec::new(), + last_successful_endpoint: None, + resolved_at: iota_storage::util::sync::now_millis(), + } +} + +fn certificate() -> (Vec, Vec) { + let key_pair = rcgen::KeyPair::generate().unwrap(); + let params = + rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]).unwrap(); + let certificate = params.self_signed(&key_pair).unwrap(); + ( + certificate.pem().into_bytes(), + key_pair.serialize_pem().into_bytes(), + ) +} + +async fn start_peer_server( + port: u16, + certificate: Vec, + private_key: Vec, + client: &LocalNodeIdentity, + host: &LocalNodeIdentity, + manager: Arc, +) -> ( + AuthorityLocator, + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle<()>, +) { + let client_key = client.public_keys(); + let client_hint = client.node_id().mtp_authentication_hint(); + let host_keyring = Keyring::from_bytes(&host.keyring().try_to_bytes().unwrap()).unwrap(); + let host = HostConfig::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + certificate, + private_key, + ) + .with_authentication( + host_keyring, + Box::new(move |client_id, _| { + let public_key = (client_id == client_hint).then(|| client_key.clone()); + Box::pin(async move { public_key }) + }), + Box::new(|_, _| Box::pin(async { 0 })), + ) + .with_authentication_policy(AuthenticationPolicy::ForceAuthentication); + let mut server = MTPWebServer::new(host, WebServerConfig::new()) + .await + .unwrap(); + let endpoint = AuthorityLocator::new(server.local_addr().to_string()).unwrap(); + let (shutdown, mut shutdown_requested) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_requested => { + server.shutdown().await; + break; + } + accepted = server.accept() => match accepted { + Ok(Some(connection)) => { + let manager = manager.clone(); + tokio::spawn(async move { + web_server::MtpConnectionHandler::accept(manager.as_ref(), connection) + .await; + }); + } + Ok(None) => break, + Err(_) => continue, + } + } + } + }); + (endpoint, shutdown, task) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn authenticated_peer_delivers_relay_v2_to_same_numeric_user_on_another_iota() { + let storage = tempfile::tempdir().unwrap(); + iota_util::file_util::configure_storage_directory(storage.path().to_owned()); + iota_storage::util::db::initialize_database().unwrap(); + + let iota_a = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let iota_b = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let user_a = Keyring::generate(); + let user_b = Keyring::generate(); + let principal_a = verified_principal(&iota_a, &user_a, 1); + let principal_b = verified_principal(&iota_b, &user_b, 1); + let handle_a = SqlitePrincipalStore + .upsert_remote_descriptor(&principal_a) + .unwrap(); + let handle_b = SqlitePrincipalStore + .upsert_remote_descriptor(&principal_b) + .unwrap(); + assert_ne!(handle_a, handle_b); + + let chat_id = e2ee_storage::principal_chat_id(handle_a, handle_b).unwrap(); + e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { + user_id: "1".into(), + secret_id: e2ee_storage::principal_secret_id(&chat_id), + chat_id, + version: 1, + encrypted_secret: vec![1], + kem_ciphertext: vec![2], + wrapping_scheme: "test".into(), + created_at: 1, + updated_at: 1, + }) + .unwrap(); + + let resolver: Arc = Arc::new(StoreResolver); + let local_users: Arc = Arc::new(HostedRecipient { + principal: handle_b, + }); + let relay_b = Arc::new( + RelayService::new(local_users, Arc::new(NoRoute), None) + .with_federation(resolver, Arc::new(TestNodeIdentity(iota_b.clone()))), + ); + let deliveries = Arc::new(Deliveries::default()); + let nodes_b = Arc::new(MemoryNodes::default()); + nodes_b + .nodes + .write() + .unwrap() + .insert(iota_a.node_id().clone(), resolved_node(&iota_a, Vec::new())); + let router_b = Arc::new(DirectPeerRouter::default()); + let manager_b = Arc::new(PeerManager::new( + iota_b.clone(), + nodes_b.clone(), + router_b, + relay_b.clone(), + deliveries.clone(), + )); + + let (certificate, private_key) = certificate(); + let (endpoint, shutdown, server_task) = start_peer_server( + 0, + certificate.clone(), + private_key.clone(), + &iota_a, + &iota_b, + manager_b.clone(), + ) + .await; + + let wrong_nodes = Arc::new(MemoryNodes::default()); + wrong_nodes.nodes.write().unwrap().insert( + iota_b.node_id().clone(), + ResolvedNodeIdentity { + public_keys: vec![Keyring::generate().public_key_bundle()], + ..resolved_node(&iota_b, vec![endpoint.clone()]) + }, + ); + let wrong_manager = Arc::new(PeerManager::new( + iota_a.clone(), + wrong_nodes, + Arc::new(DirectPeerRouter::default()), + relay_b.clone(), + Arc::new(Deliveries::default()), + )); + assert!( + wrong_manager + .connect( + iota_b.node_id().clone(), + &endpoint, + Some(certificate.clone()), + ) + .await + .is_err() + ); + + let nodes_a = Arc::new(MemoryNodes::default()); + nodes_a.nodes.write().unwrap().insert( + iota_b.node_id().clone(), + resolved_node(&iota_b, vec![endpoint.clone()]), + ); + nodes_a + .pin_endpoint_certificate(&endpoint, &certificate) + .unwrap(); + let router_a = Arc::new(DirectPeerRouter::default()); + let manager_a = Arc::new(PeerManager::new( + iota_a.clone(), + nodes_a.clone(), + router_a.clone(), + relay_b, + Arc::new(Deliveries::default()), + )); + router_a.attach_manager(&manager_a); + manager_a.connect_best(iota_b.node_id()).await.unwrap(); + + let content = CommunicationValue::new(CommunicationType::MessageSend) + .add_typed_default(DataType::Content, DataValue::Str("ciphertext".into())) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(10)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)); + let frame = FederatedRelayV2::sign( + principal_a.descriptor().principal.clone(), + principal_b.descriptor().principal.clone(), + iota_b.node_id().clone(), + "a-to-b".into(), + 10, + content, + &user_a, + ) + .unwrap() + .into_frame(7) + .unwrap(); + let outcome = tokio::time::timeout( + std::time::Duration::from_secs(5), + router_a.route(&RouteDestination::Iota(iota_b.node_id().clone()), frame), + ) + .await + .unwrap() + .unwrap(); + assert!(matches!( + outcome, + RouteOutcome::Accepted { + ref relay_message_id, + .. + } if relay_message_id == "a-to-b" + )); + + let messages = chat_files::get_messages_for_principal(1, handle_a, 0, 10).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "ciphertext"); + { + let delivered = deliveries.0.lock().unwrap(); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].0, handle_b); + assert!(delivered[0].1.is_type(CommunicationType::MessageLive)); + } + + shutdown.send(()).unwrap(); + server_task.await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while router_a.has_connection(iota_b.node_id()) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + let restarted_relay_b = Arc::new( + RelayService::new( + Arc::new(HostedRecipient { + principal: handle_b, + }), + Arc::new(NoRoute), + None, + ) + .with_federation( + Arc::new(StoreResolver), + Arc::new(TestNodeIdentity(iota_b.clone())), + ), + ); + let restarted_manager_b = Arc::new(PeerManager::new( + iota_b.clone(), + nodes_b, + Arc::new(DirectPeerRouter::default()), + restarted_relay_b, + deliveries, + )); + let (restarted_endpoint, shutdown, server_task) = start_peer_server( + 0, + certificate.clone(), + private_key, + &iota_a, + &iota_b, + restarted_manager_b, + ) + .await; + nodes_a + .nodes + .write() + .unwrap() + .get_mut(iota_b.node_id()) + .unwrap() + .direct_endpoints = vec![restarted_endpoint.clone()]; + nodes_a + .pin_endpoint_certificate(&restarted_endpoint, &certificate) + .unwrap(); + let router_a = Arc::new(DirectPeerRouter::default()); + let restarted_relay_a = Arc::new( + RelayService::new( + Arc::new(HostedRecipient { + principal: handle_a, + }), + Arc::new(NoRoute), + None, + ) + .with_federation( + Arc::new(StoreResolver), + Arc::new(TestNodeIdentity(iota_a.clone())), + ), + ); + let manager_a = Arc::new(PeerManager::new( + iota_a.clone(), + nodes_a, + router_a.clone(), + restarted_relay_a, + Arc::new(Deliveries::default()), + )); + router_a.attach_manager(&manager_a); + + let second = FederatedRelayV2::sign( + principal_a.descriptor().principal.clone(), + principal_b.descriptor().principal.clone(), + iota_b.node_id().clone(), + "a-to-b-after-reconnect".into(), + 11, + CommunicationValue::new(CommunicationType::MessageSend) + .add_typed_default(DataType::Content, DataValue::Str("second".into())) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(11)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)), + &user_a, + ) + .unwrap() + .into_frame(8) + .unwrap(); + let outcome = router_a + .route(&RouteDestination::Iota(iota_b.node_id().clone()), second) + .await + .unwrap(); + assert!(matches!( + outcome, + RouteOutcome::Accepted { + ref relay_message_id, + .. + } if relay_message_id == "a-to-b-after-reconnect" + )); + assert_eq!( + chat_files::get_messages_for_principal(1, handle_a, 0, 10) + .unwrap() + .len(), + 2 + ); + + shutdown.send(()).unwrap(); + server_task.await.unwrap(); +} diff --git a/other-iota/tests/relay_router.rs b/other-iota/tests/relay_router.rs new file mode 100644 index 0000000..aa17485 --- /dev/null +++ b/other-iota/tests/relay_router.rs @@ -0,0 +1,344 @@ +use async_trait::async_trait; +use iota_connection::connection_handler::{PeerRouter, RouteDestination, RouteError, RouteOutcome}; +use iota_connection::federated_relay::FederatedRelayV2; +use iota_connection::relay_service::{RelayNodeIdentity, RelayService}; +use iota_identity::{ + AuthorityLocator, IdentityError, IdentityResolver, IotaNodeId, LocalNodeIdentity, + LocalUserDescriptor, LocalUserId, LocalUserStore, PrincipalDescriptor, PrincipalHandle, + PrincipalHome, PrincipalId, PrincipalStore, PublicKeyBundle, ResolutionContext, + ResolvedPrincipal, SignedPrincipalDescriptor, UserAddress, +}; +use iota_storage::identity::SqlitePrincipalStore; +use iota_storage::util::{chat_files, e2ee_storage}; +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::crypto::Keyring; +use mtp::host::{AuthenticationPolicy, HostConfig}; +use mtp::webserver::{MTPWebServer, WebServerConfig}; +use other_iota::LocalDeliverySink; +use other_iota::relay_router::{RelayRouterClient, RelayRouterService}; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::{Arc, Mutex}; + +struct StoreResolver; + +#[async_trait] +impl IdentityResolver for StoreResolver { + async fn resolve_address( + &self, + _: &UserAddress, + _: &ResolutionContext, + ) -> Result { + Err(IdentityError::NotFound) + } + + async fn resolve_principal( + &self, + principal: &PrincipalId, + ) -> Result { + SqlitePrincipalStore + .get_by_canonical_id(principal)? + .ok_or(IdentityError::NotFound) + } + + async fn signing_keys( + &self, + principal: &PrincipalId, + _: &ResolutionContext, + ) -> Result, IdentityError> { + SqlitePrincipalStore.signing_keys(principal) + } +} + +struct HostedRecipient(PrincipalHandle); + +impl LocalUserStore for HostedRecipient { + fn get_local_user(&self, _: LocalUserId) -> Result, IdentityError> { + Ok(None) + } + + fn get_local_user_by_username( + &self, + _: &str, + ) -> Result, IdentityError> { + Ok(None) + } + + fn is_hosted_here(&self, user: LocalUserId) -> Result { + Ok(user == LocalUserId(1)) + } + + fn local_user_for_principal( + &self, + principal: PrincipalHandle, + ) -> Result, IdentityError> { + Ok((principal == self.0).then_some(LocalUserId(1))) + } + + fn principal_for_local_user( + &self, + user: LocalUserId, + ) -> Result, IdentityError> { + Ok((user == LocalUserId(1)).then_some(self.0)) + } +} + +struct TestNodeIdentity(LocalNodeIdentity); + +#[async_trait] +impl RelayNodeIdentity for TestNodeIdentity { + async fn keyring(&self) -> Option> { + Some(self.0.keyring()) + } + + fn node_id(&self) -> Option { + Some(self.0.node_id().clone()) + } +} + +struct NoRoute; + +#[async_trait] +impl PeerRouter for NoRoute { + async fn route( + &self, + destination: &RouteDestination, + _: CommunicationValue, + ) -> Result { + match destination { + RouteDestination::Iota(node) => Err(RouteError::NoRoute(node.clone())), + RouteDestination::LegacyOmegaIota { .. } => Ok(RouteOutcome::Retryable { + reason: "legacy route is unavailable".into(), + }), + } + } +} + +#[derive(Default)] +struct Deliveries(Mutex>); + +#[async_trait] +impl LocalDeliverySink for Deliveries { + async fn deliver(&self, recipient: PrincipalHandle, frame: CommunicationValue) { + self.0.lock().unwrap().push((recipient, frame)); + } +} + +fn verified_principal( + authority: &LocalNodeIdentity, + user: &Keyring, +) -> iota_identity::VerifiedPrincipalDescriptor { + let now = iota_storage::util::sync::now_millis(); + SignedPrincipalDescriptor::sign( + PrincipalDescriptor { + principal: PrincipalId { + authority: authority.authority_id().clone(), + user_id: 1, + }, + authority_kind: iota_identity::AuthorityKind::Iota, + username: Some("alice".into()), + display_name: None, + public_keys: vec![user.public_key_bundle()], + home: PrincipalHome::Iota(authority.node_id().clone()), + revision: 1, + valid_until: Some(now + 60_000), + issued_at: now, + }, + &authority.keyring(), + ) + .unwrap() + .verify( + authority.authority_id(), + &authority.public_keys(), + None, + now, + ) + .unwrap() +} + +fn certificate() -> (Vec, Vec) { + let key_pair = rcgen::KeyPair::generate().unwrap(); + let params = + rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]).unwrap(); + let certificate = params.self_signed(&key_pair).unwrap(); + ( + certificate.pem().into_bytes(), + key_pair.serialize_pem().into_bytes(), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn authenticated_router_forwards_opaque_relay_v2() { + let storage = tempfile::tempdir().unwrap(); + iota_util::file_util::configure_storage_directory(storage.path().to_owned()); + iota_storage::util::db::initialize_database().unwrap(); + + let iota_a = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let iota_b = LocalNodeIdentity::from_keyring(Keyring::generate()).unwrap(); + let user_a = Keyring::generate(); + let user_b = Keyring::generate(); + let principal_a = verified_principal(&iota_a, &user_a); + let principal_b = verified_principal(&iota_b, &user_b); + let handle_a = SqlitePrincipalStore + .upsert_remote_descriptor(&principal_a) + .unwrap(); + let handle_b = SqlitePrincipalStore + .upsert_remote_descriptor(&principal_b) + .unwrap(); + let chat_id = e2ee_storage::principal_chat_id(handle_a, handle_b).unwrap(); + e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret { + user_id: "1".into(), + secret_id: e2ee_storage::principal_secret_id(&chat_id), + chat_id, + version: 1, + encrypted_secret: vec![1], + kem_ciphertext: vec![2], + wrapping_scheme: "test".into(), + created_at: 1, + updated_at: 1, + }) + .unwrap(); + + let relay = Arc::new( + RelayService::new(Arc::new(HostedRecipient(handle_b)), Arc::new(NoRoute), None) + .with_federation( + Arc::new(StoreResolver), + Arc::new(TestNodeIdentity(iota_b.clone())), + ), + ); + let deliveries = Arc::new(Deliveries::default()); + let router = Arc::new(RelayRouterService::default()); + let router_keyring = Keyring::generate(); + let router_public_key = router_keyring.public_key_bundle(); + let key_a = iota_a.public_keys(); + let key_b = iota_b.public_keys(); + let hint_a = iota_a.node_id().mtp_authentication_hint(); + let hint_b = iota_b.node_id().mtp_authentication_hint(); + let (certificate, private_key) = certificate(); + let host = HostConfig::new( + IpAddr::V4(Ipv4Addr::LOCALHOST), + 0, + certificate.clone(), + private_key, + ) + .with_authentication( + router_keyring, + Box::new(move |client_id, _| { + let key = if client_id == hint_a { + Some(key_a.clone()) + } else if client_id == hint_b { + Some(key_b.clone()) + } else { + None + }; + Box::pin(async move { key }) + }), + Box::new(|_, _| Box::pin(async { 0 })), + ) + .with_authentication_policy(AuthenticationPolicy::ForceAuthentication); + let mut server = MTPWebServer::new(host, WebServerConfig::new()) + .await + .unwrap(); + let endpoint = AuthorityLocator::new(server.local_addr().to_string()).unwrap(); + let server_task = { + let router = router.clone(); + tokio::spawn(async move { + loop { + match server.accept().await { + Ok(Some(connection)) => { + let router = router.clone(); + tokio::spawn(async move { + web_server::MtpConnectionHandler::accept(router.as_ref(), connection) + .await; + }); + } + Ok(None) => break, + Err(_) => continue, + } + } + }) + }; + + let iota_a_router = RelayRouterClient::connect( + iota_a.clone(), + &endpoint, + certificate.clone(), + router_public_key.clone(), + relay.clone(), + deliveries.clone(), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !router.has_connection(iota_a.node_id()) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + let frame = FederatedRelayV2::sign( + principal_a.descriptor().principal.clone(), + principal_b.descriptor().principal.clone(), + iota_b.node_id().clone(), + "through-router".into(), + 10, + CommunicationValue::new(CommunicationType::MessageSend) + .add_typed_default(DataType::Content, DataValue::Str("opaque".into())) + .add_typed_default(DataType::SendTime, DataValue::SignedNumber(10)) + .add_typed_default(DataType::VersionNumber, DataValue::SignedNumber(1)), + &user_a, + ) + .unwrap() + .into_frame(7) + .unwrap(); + assert!(matches!( + iota_a_router + .route( + &RouteDestination::Iota(iota_b.node_id().clone()), + frame.clone(), + ) + .await + .unwrap(), + RouteOutcome::Retryable { .. } + )); + + let _iota_b_router = RelayRouterClient::connect( + iota_b.clone(), + &endpoint, + certificate, + router_public_key, + relay, + deliveries.clone(), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !router.has_connection(iota_b.node_id()) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let outcome = iota_a_router + .route(&RouteDestination::Iota(iota_b.node_id().clone()), frame) + .await + .unwrap(); + assert!( + matches!( + &outcome, + RouteOutcome::Accepted { + relay_message_id, + .. + } if relay_message_id == "through-router" + ), + "unexpected router outcome: {outcome:?}" + ); + let messages = chat_files::get_messages_for_principal(1, handle_a, 0, 10).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "opaque"); + let delivered = deliveries.0.lock().unwrap(); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].0, handle_b); + + server_task.abort(); +} diff --git a/web-server/Cargo.toml b/web-server/Cargo.toml index 500da07..706fa90 100644 --- a/web-server/Cargo.toml +++ b/web-server/Cargo.toml @@ -5,9 +5,13 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a135d0f0c2b35147011905f8ee0fc37050f69a6c", features = ["web-server"] } +async-trait = "0.1.89" +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c265d1e3f590a876f95e766ff99318", features = ["web-server", "crypto", "pipes"] } bytes = "1" http = "1" iota-logger = { path = "../iota-logger" } +iota-identity = { path = "../iota-identity" } +serde = "1" +serde_json = "1" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs index 9cbabb2..bcd0765 100644 --- a/web-server/src/lib.rs +++ b/web-server/src/lib.rs @@ -1,4 +1,6 @@ use bytes::Bytes; +use http::Method; +use iota_identity::{LocalDescriptorPublisher, LocalUserId, LocalUserStore, SignedNodeDescriptor}; use iota_logger::log; use mtp::host::HostConfig; use mtp::webserver::{HttpRequest, HttpResponse, MTPWebServer, WebServerConfig}; @@ -7,6 +9,16 @@ use tokio::sync::Mutex; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +#[async_trait::async_trait] +pub trait ClientPublicKeyResolver: Send + Sync { + async fn public_key(&self, client_id: u64) -> Option; +} + +#[async_trait::async_trait] +pub trait MtpConnectionHandler: Send + Sync { + async fn accept(&self, connection: mtp::webserver::WebMTPConnection); +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WebMode { Disabled, @@ -20,7 +32,7 @@ pub struct TlsConfig { pub key: PathBuf, } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct WebConfig { pub mode: WebMode, pub bind: IpAddr, @@ -28,6 +40,13 @@ pub struct WebConfig { pub asset_dir: PathBuf, pub tls: Option, pub required: bool, + pub authority_discovery: iota_identity::AuthorityDiscoveryDocument, + pub local_users: Arc, + pub descriptor_publisher: Arc, + pub node_descriptor: SignedNodeDescriptor, + pub node_identity: iota_identity::LocalNodeIdentity, + pub client_keys: Arc, + pub mtp_handler: Arc, } #[derive(Debug)] @@ -63,6 +82,33 @@ impl WebServerHandle { async fn root(asset_dir: PathBuf, _request: HttpRequest, response: HttpResponse) -> HttpResponse { static_file(asset_dir, "index.html".into(), response).await } +async fn authority_discovery(body: Bytes, _: HttpRequest, response: HttpResponse) -> HttpResponse { + response + .status(http::StatusCode::OK) + .header("content-type", "application/json") + .body(body) +} + +fn json_response(value: &T, response: HttpResponse) -> HttpResponse { + match serde_json::to_vec(value) { + Ok(body) => response + .status(http::StatusCode::OK) + .header("content-type", "application/json") + .body(Bytes::from(body)), + Err(_) => response + .status(http::StatusCode::INTERNAL_SERVER_ERROR) + .body("descriptor encoding failed"), + } +} + +fn descriptor_error(error: iota_identity::IdentityError, response: HttpResponse) -> HttpResponse { + let status = match error { + iota_identity::IdentityError::NotFound => http::StatusCode::NOT_FOUND, + iota_identity::IdentityError::InvalidIdentifier(_) => http::StatusCode::BAD_REQUEST, + _ => http::StatusCode::INTERNAL_SERVER_ERROR, + }; + response.status(status).body(error.to_string()) +} async fn static_file(asset_dir: PathBuf, path: String, response: HttpResponse) -> HttpResponse { let file = path.trim_start_matches('/'); let file = if file.is_empty() { "index.html" } else { file }; @@ -123,12 +169,114 @@ pub async fn start( let key = tokio::fs::read(&tls.key) .await .map_err(|e| WebServerError::Io(e.to_string()))?; - let host_config = HostConfig::new(config.bind, config.port, certificate, key); + let node_keyring = config.node_identity.keyring(); + let node_keyring = node_keyring + .try_to_bytes() + .map_err(|error| WebServerError::Startup(error.to_string())) + .and_then(|bytes| { + mtp::crypto::Keyring::from_bytes(&bytes) + .map_err(|error| WebServerError::Startup(error.to_string())) + })?; + let client_keys = config.client_keys.clone(); + let host_config = HostConfig::new(config.bind, config.port, certificate, key) + .with_authentication( + node_keyring, + Box::new(move |client_id, _| { + let client_keys = client_keys.clone(); + Box::pin(async move { client_keys.public_key(client_id).await }) + }), + Box::new(|_, _| Box::pin(async { 0 })), + ) + .with_authentication_policy(mtp::host::AuthenticationPolicy::AllowAuthentication); let assets = config.asset_dir.clone(); + let discovery = serde_json::to_vec(&config.authority_discovery) + .map(Bytes::from) + .map_err(|error| WebServerError::Startup(error.to_string()))?; + let node_descriptor = config + .node_descriptor + .to_wire_v1() + .map_err(|error| WebServerError::Startup(error.to_string()))?; + let local_users = config.local_users.clone(); + let descriptor_publisher = config.descriptor_publisher.clone(); + let mtp_handler = config.mtp_handler.clone(); let web_config = WebServerConfig::new() .route("/", move |request, response| { root(assets.clone(), request, response) }) + .and_then(|web_config| { + web_config.route("/.well-known/tensamin", move |request, response| { + authority_discovery(discovery.clone(), request, response) + }) + }) + .and_then(|web_config| { + let descriptor = node_descriptor.clone(); + web_config.route_method(Method::GET, "/federation/v1/node", move |_, response| { + let descriptor = descriptor.clone(); + async move { json_response(&descriptor, response) } + }) + }) + .and_then(|web_config| { + let publisher = descriptor_publisher.clone(); + web_config.route_pattern_method( + Method::GET, + "/federation/v1/users/id/{user_id}", + move |_, response, params| { + let publisher = publisher.clone(); + async move { + let user = params + .get("user_id") + .ok_or_else(|| { + iota_identity::IdentityError::InvalidIdentifier( + "missing user ID".into(), + ) + }) + .and_then(|user| { + user.parse::().map(LocalUserId).map_err(|_| { + iota_identity::IdentityError::InvalidIdentifier( + "invalid user ID".into(), + ) + }) + }); + match user + .and_then(|user| publisher.principal_descriptor(user)) + .and_then(|descriptor| descriptor.to_wire_v1()) + { + Ok(descriptor) => json_response(&descriptor, response), + Err(error) => descriptor_error(error, response), + } + } + }, + ) + }) + .and_then(|web_config| { + let local_users = local_users.clone(); + let publisher = descriptor_publisher.clone(); + web_config.route_pattern_method( + Method::GET, + "/federation/v1/users/name/{username}", + move |_, response, params| { + let local_users = local_users.clone(); + let publisher = publisher.clone(); + async move { + let descriptor = params + .get("username") + .ok_or_else(|| { + iota_identity::IdentityError::InvalidIdentifier( + "missing username".into(), + ) + }) + .and_then(|username| local_users.get_local_user_by_username(username)) + .and_then(|user| user.ok_or(iota_identity::IdentityError::NotFound)) + .and_then(|user| publisher.principal_descriptor(user.id)) + .and_then(|descriptor| descriptor.to_wire_v1()); + match descriptor { + Ok(descriptor) => json_response(&descriptor, response), + Err(error) => descriptor_error(error, response), + } + } + }, + ) + }) .and_then(|web_config| { let assets = config.asset_dir.clone(); web_config.fallback(move |request, response| { @@ -144,7 +292,17 @@ pub async fn start( let task_cancellation = cancellation.clone(); let join = tokio::spawn(async move { loop { - tokio::select! { result = server.accept() => match result { Ok(Some(_)) => {}, Ok(None) => break, Err(error) => log!("MTP webserver connection failed: {}", error) }, _ = task_cancellation.cancelled() => { server.shutdown().await; break; } } + tokio::select! { + result = server.accept() => match result { + Ok(Some(connection)) => { + let handler = mtp_handler.clone(); + tokio::spawn(async move { handler.accept(connection).await; }); + } + Ok(None) => break, + Err(error) => log!("MTP webserver connection failed: {}", error), + }, + _ = task_cancellation.cancelled() => { server.shutdown().await; break; } + } } }); Ok(Some(Arc::new(WebServerHandle {