[WIP] 0.3.0 mtp update

This commit is contained in:
Alex Emmet 2026-08-18 22:39:02 +02:00
commit e1dd86ec02
No known key found for this signature in database
42 changed files with 2422 additions and 1429 deletions

342
Cargo.lock generated
View file

@ -44,9 +44,9 @@ dependencies = [
[[package]]
name = "actix-http"
version = "3.13.2"
version = "3.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53200bd1513e569e6e644181c922cec072c121a27db5b38d45e88e630c369366"
checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6"
dependencies = [
"actix-codec",
"actix-rt",
@ -175,9 +175,9 @@ dependencies = [
[[package]]
name = "actix-web"
version = "4.14.0"
version = "4.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
checksum = "58356675d8c86d2e720480645a0316808471a62d0073f6a3b98810a5e0ca0e73"
dependencies = [
"actix-codec",
"actix-http",
@ -410,6 +410,18 @@ dependencies = [
"rustversion",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
@ -422,7 +434,7 @@ dependencies = [
"nom",
"num-traits",
"rusticata-macros",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
]
@ -451,9 +463,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.91"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
@ -559,6 +571,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -657,9 +678,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.1"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@ -1493,9 +1514,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.10"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "finl_unicode"
@ -1564,9 +1585,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
dependencies = [
"futures-channel",
"futures-core",
@ -1579,9 +1600,9 @@ dependencies = [
[[package]]
name = "futures-channel"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
@ -1589,15 +1610,15 @@ dependencies = [
[[package]]
name = "futures-core"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-executor"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [
"futures-core",
"futures-task",
@ -1606,38 +1627,38 @@ dependencies = [
[[package]]
name = "futures-io"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-channel",
"futures-core",
@ -1736,9 +1757,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@ -1957,9 +1978,9 @@ dependencies = [
[[package]]
name = "http-body-util"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
@ -2000,7 +2021,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.15",
"h2 0.4.16",
"http 1.5.0",
"http-body",
"httparse",
@ -2078,9 +2099,9 @@ dependencies = [
[[package]]
name = "icu_collections"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
@ -2092,9 +2113,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
@ -2105,9 +2126,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@ -2119,16 +2140,17 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
@ -2139,15 +2161,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
dependencies = [
"displaydoc",
"icu_locale_core",
@ -2371,6 +2393,7 @@ dependencies = [
"iota-storage",
"iota-util",
"mtp",
"tokio",
]
[[package]]
@ -2526,7 +2549,7 @@ dependencies = [
"serde_yaml",
"sha2 0.11.0",
"sysinfo",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"uuid",
"walkdir",
@ -2659,7 +2682,7 @@ dependencies = [
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.19",
"thiserror 2.0.20",
"walkdir",
"windows-link",
]
@ -2708,9 +2731,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.103"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
@ -2731,7 +2754,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@ -2791,9 +2814,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libsqlite3-sys"
version = "0.38.1"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"pkg-config",
"vcpkg",
@ -2816,9 +2839,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
@ -3013,8 +3036,8 @@ dependencies = [
[[package]]
name = "mtp"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"mtp-client",
"mtp-codec",
@ -3029,8 +3052,8 @@ dependencies = [
[[package]]
name = "mtp-client"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"mtp-codec",
"mtp-common",
@ -3042,8 +3065,8 @@ dependencies = [
[[package]]
name = "mtp-codec"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"base64 0.23.1",
"byteorder",
@ -3051,25 +3074,27 @@ dependencies = [
"mtp-crypto",
"mtp-type-map",
"rand 0.10.2",
"thiserror 2.0.20",
]
[[package]]
name = "mtp-common"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"quinn",
"rustls",
"thiserror 2.0.19",
"thiserror 2.0.20",
"wtransport",
]
[[package]]
name = "mtp-crypto"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"base64 0.23.1",
"argon2",
"base64 0.22.1",
"chacha20poly1305",
"ed25519-dalek 3.0.0",
"getrandom 0.4.3",
@ -3077,36 +3102,37 @@ dependencies = [
"ml-dsa",
"mlkem-tls",
"rand 0.10.2",
"rand_core 0.10.1",
"rand_core 0.6.4",
"rustls",
"serde",
"sha2 0.11.0",
"thiserror 2.0.19",
"thiserror 1.0.69",
"tokio",
"zeroize",
]
[[package]]
name = "mtp-files"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"mtp-crypto",
"rand 0.10.2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"zeroize",
]
[[package]]
name = "mtp-host"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"mtp-codec",
"mtp-common",
"mtp-crypto",
"mtp-transport",
"rand 0.10.2",
"thiserror 2.0.20",
"tokio",
"tracing",
"wtransport",
@ -3114,13 +3140,14 @@ dependencies = [
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"async-trait",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"rand 0.10.2",
"rcgen",
"rustls",
"rustls-native-certs",
@ -3128,12 +3155,13 @@ dependencies = [
"tokio",
"tracing",
"wtransport",
"zeroize",
]
[[package]]
name = "mtp-type-map"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"serde",
"serde_yaml",
@ -3141,8 +3169,8 @@ dependencies = [
[[package]]
name = "mtp-webserver"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b067614a684eb1856bc5db7b3fd82148c036ce6b"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#b331b9f6a3943d0331d8fdcb7c696d6c2bc5e8b8"
dependencies = [
"async-trait",
"bytes",
@ -3159,9 +3187,8 @@ dependencies = [
"mtp-host",
"mtp-transport",
"quinn",
"rand 0.10.2",
"rustls",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tokio-rustls",
"tokio-stream",
@ -3252,9 +3279,9 @@ dependencies = [
[[package]]
name = "num-integer"
version = "0.1.46"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
@ -3561,6 +3588,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
@ -3598,9 +3636,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
dependencies = [
"memchr",
"ucd-trie",
@ -3608,9 +3646,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
dependencies = [
"pest",
"pest_generator",
@ -3618,9 +3656,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
dependencies = [
"pest",
"pest_meta",
@ -3631,9 +3669,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
dependencies = [
"pest",
]
@ -3738,9 +3776,9 @@ dependencies = [
[[package]]
name = "pkg-config"
version = "0.3.33"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "pnet"
@ -3858,15 +3896,15 @@ dependencies = [
[[package]]
name = "portable-atomic"
version = "1.14.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "potential_utf"
version = "0.1.5"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
@ -3916,7 +3954,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
@ -3924,9 +3962,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.16"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
@ -3941,7 +3979,7 @@ dependencies = [
"rustls-pki-types",
"rustls-platform-verifier",
"slab",
"thiserror 2.0.19",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
@ -4087,7 +4125,7 @@ dependencies = [
"palette",
"serde",
"strum",
"thiserror 2.0.19",
"thiserror 2.0.20",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
@ -4158,9 +4196,9 @@ dependencies = [
[[package]]
name = "rcgen"
version = "0.14.8"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
dependencies = [
"aws-lc-rs",
"pem",
@ -4225,7 +4263,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
"h2 0.4.15",
"h2 0.4.16",
"http 1.5.0",
"http-body",
"http-body-util",
@ -4274,14 +4312,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
name = "rusqlite"
version = "0.40.1"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
@ -4405,9 +4443,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"aws-lc-rs",
"ring",
@ -5006,11 +5044,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl 2.0.19",
"thiserror-impl 2.0.20",
]
[[package]]
@ -5026,9 +5064,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
@ -5069,9 +5107,9 @@ dependencies = [
[[package]]
name = "tinystr"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
@ -5277,7 +5315,7 @@ dependencies = [
"native-tls",
"rand 0.10.2",
"sha1 0.11.0",
"thiserror 2.0.19",
"thiserror 2.0.20",
]
[[package]]
@ -5387,9 +5425,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"atomic",
"getrandom 0.4.3",
@ -5481,9 +5519,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
@ -5494,9 +5532,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
version = "0.4.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
dependencies = [
"js-sys",
"wasm-bindgen",
@ -5504,9 +5542,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -5514,9 +5552,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
@ -5527,9 +5565,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
@ -5549,9 +5587,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.103"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
dependencies = [
"js-sys",
"wasm-bindgen",
@ -5941,15 +5979,15 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "wtransport"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4"
checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036"
dependencies = [
"bytes",
"pem",
@ -5960,7 +5998,7 @@ dependencies = [
"rustls-pki-types",
"sha2 0.11.0",
"socket2",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
"tokio",
"tracing",
@ -5971,13 +6009,13 @@ dependencies = [
[[package]]
name = "wtransport-proto"
version = "0.7.1"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11"
checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede"
dependencies = [
"httlib-huffman",
"octets",
"thiserror 2.0.19",
"thiserror 2.0.20",
"url",
]
@ -6019,7 +6057,7 @@ dependencies = [
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 2.0.19",
"thiserror 2.0.20",
"time",
]
@ -6119,9 +6157,9 @@ dependencies = [
[[package]]
name = "zerotrie"
version = "0.2.4"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
@ -6130,9 +6168,9 @@ dependencies = [
[[package]]
name = "zerovec"
version = "0.11.6"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
dependencies = [
"yoke",
"zerofrom",
@ -6141,13 +6179,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
version = "0.11.3"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.3",
]
[[package]]

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client"] }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client", "crypto"] }
iota-connection = { path = "../iota-connection" }
iota-logger = { path = "../iota-logger" }
iota-util = { path = "../iota-util" }

View file

@ -1,14 +1,14 @@
use dashmap::DashMap;
use iota_connection::message_common::*;
use iota_connection::message_handlers;
use iota_connection::relay::message_security_class;
use iota_logger::{log_cv_in, log_cv_out, log_t};
use iota_storage::util::chat_files::{self, MessageState, change_message_state};
use iota_storage::util::config_util::CONFIG;
use iota_storage::util::e2ee_storage::{self, StoredChatSecret};
use iota_util::crypto_helper::keyring_from_base64;
use iota_util::crypto_util::{self};
use mtp::client::{Receiver, Sender};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::Keyring;
use std::sync::Arc;
use std::time::Duration;
@ -30,6 +30,7 @@ pub struct ClientConnection {
pub waiting_tasks:
DashMap<u32, Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>>,
shutdown: Arc<RwLock<bool>>,
keyring: Arc<RwLock<Option<Arc<Keyring>>>>,
}
impl ClientConnection {
@ -53,9 +54,31 @@ impl ClientConnection {
shutdown_tx,
waiting_tasks,
shutdown,
keyring: Arc::new(RwLock::new(None)),
}
}
pub async fn set_keyring(&self, keyring: Arc<Keyring>) {
*self.keyring.write().await = Some(keyring);
}
async fn local_keyring(&self) -> Result<Arc<Keyring>, String> {
if let Some(keyring) = self.keyring.read().await.as_ref().cloned() {
return Ok(keyring);
}
let keyring_data = CONFIG
.load()
.keyring
.clone()
.ok_or_else(|| "Iota keyring is not configured".to_string())?;
let keyring = keyring_from_base64(&keyring_data)
.ok_or_else(|| "Iota keyring is invalid".to_string())?;
let keyring = Arc::new(keyring);
*self.keyring.write().await = Some(keyring.clone());
Ok(keyring)
}
pub fn start(self: Arc<Self>) {
let self_clone = self.clone();
tokio::spawn(async move {
@ -96,6 +119,27 @@ impl ClientConnection {
pub async fn handle_message(self: Arc<Self>, cv: CommunicationValue) {
log_cv_in!(&cv);
if cv.is_type(CommunicationType::Relay) {
log_t!(
"relay_from_client_rejected",
"legacy client path has no Relay router".to_string()
);
let _ = self
.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
if matches!(
message_security_class(&cv),
iota_connection::relay::MessageSecurityClass::RelayOnly
) {
let _ = self
.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
let _msg_id = cv.get_id();
if cv.is_type(CommunicationType::Challenge) {
@ -103,82 +147,12 @@ impl ClientConnection {
return;
}
if cv.is_type(CommunicationType::SetChatSecret) {
let sender_id = cv.get_sender().to_string();
let recipients = match chat_secret_recipients(&cv) {
Some(recipients) => recipients,
None => {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
};
let now = now_millis_i64();
let chat_id = data_string(&cv, DataType::ChatId);
let secret_id = data_string(&cv, DataType::SecretId);
let version = data_i64(&cv, DataType::VersionNumber);
let wrapping_scheme = data_string(&cv, DataType::WrappingScheme);
let created_at = data_i64(&cv, DataType::CreatedAt).unwrap_or(now);
let Some((((chat_id, secret_id), version), wrapping_scheme)) =
chat_id.zip(secret_id).zip(version).zip(wrapping_scheme)
else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
for recipient in recipients.iter().filter(|item| item.user_id == sender_id) {
if e2ee_storage::put_chat_secret(StoredChatSecret {
user_id: recipient.user_id.clone(),
chat_id: chat_id.clone(),
secret_id: secret_id.clone(),
version,
encrypted_secret: recipient.encrypted_secret.clone(),
kem_ciphertext: recipient.kem_ciphertext.clone(),
wrapping_scheme: wrapping_scheme.clone(),
created_at,
updated_at: now,
})
.is_err()
{
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
}
for recipient in recipients.iter().filter(|item| item.user_id != sender_id) {
self.send_message(&set_chat_secret_cv_for_recipient(&cv, recipient))
.await;
}
self.send_message(&error_response(&cv, CommunicationType::Success))
.await;
return;
}
if cv.is_type(CommunicationType::GetChatSecret) {
self.send_message(&message_handlers::handle_get_chat_secret(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::ChatSecretForward) {
let sender_id = cv.get_sender().to_string();
let recipient_id = data_string(&cv, DataType::RecipientUserId).unwrap_or_default();
if data_string(&cv, DataType::SenderUserId).as_deref() != Some(sender_id.as_str())
|| recipient_id.is_empty()
{
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
self.send_message(&cv.with_receiver(recipient_id.parse::<u64>().unwrap_or(0)))
.await;
return;
}
if cv.is_type(CommunicationType::SaveAppData) {
let sender_id = cv.get_sender();
let _app_data = cv
@ -270,137 +244,6 @@ impl ClientConnection {
return;
}
if cv.is_type(CommunicationType::MessageOtherIota) {
let sender_id = &cv.get_sender();
let receiver_id = &cv.get_receiver();
// parse send_time safely (number or string), fallback to now
let send_time_val = cv.get_data(DataType::SendTime);
let now_i64 = now_millis_i64();
let timestamp = if let Some(n) = send_time_val.as_number() {
n as i64
} else if let Some(s) = send_time_val.as_str() {
s.parse::<i64>().unwrap_or(now_i64)
} else {
now_i64
};
// content may be missing or non-string; default to empty string
let content = cv
.get_data(DataType::Content)
.as_str()
.unwrap_or("")
.to_string();
let height = cv.get_data(DataType::Height).as_number().unwrap_or(0) as i64;
let reply_to = cv.get_data(DataType::ReplyId).as_number().map(|n| n as i64);
chat_files::add_message(
timestamp as u128,
false,
*receiver_id as i64,
*sender_id as i64,
&content,
height,
reply_to,
);
// Build user_forward using the parsed numeric timestamp and safe content string
let user_forward = CommunicationValue::new(CommunicationType::MessageLive)
.with_id(cv.get_id())
.with_receiver(*receiver_id)
.add_typed_default(
DataType::SenderId,
DataValue::SignedNumber(*sender_id as i128),
)
.add_typed_default(DataType::Message, {
let mut msg_fields = vec![
(DataType::Content, DataValue::Str(content.clone())),
(
DataType::SendTime,
DataValue::SignedNumber(timestamp as i128),
),
(DataType::Height, DataValue::SignedNumber(height as i128)),
];
if let Some(rt) = reply_to {
msg_fields.push((
DataType::ReplyId,
DataValue::UnsignedNumber(rt as u64 as u128),
));
}
typed_container(msg_fields)
});
let user_resp = self
.clone()
.await_response(&user_forward, Some(Duration::from_secs(10)))
.await;
if let Ok(user_resp) = user_resp {
let ms_raw = user_resp
.get_data(DataType::MessageState)
.as_string()
.unwrap_or_else(|| "".to_string());
let ms = MessageState::from_str(&ms_raw).upgrade(MessageState::Received);
let _ = change_message_state(
timestamp,
*receiver_id as i64,
*sender_id as i64,
ms.clone(),
);
self.send_message(
&CommunicationValue::new(CommunicationType::MessageState)
.with_id(cv.get_id())
.with_receiver(*sender_id)
.with_sender(*receiver_id)
.add_typed_default(
DataType::SendTime,
DataValue::SignedNumber(timestamp as i128),
)
.add_typed_default(
DataType::ChatPartnerId,
DataValue::SignedNumber(*sender_id as i128),
)
.add_typed_default(
DataType::MessageState,
DataValue::Str(ms.as_str().to_string()),
),
)
.await;
} else {
// Delivery timed out/failed — update stored state and notify sender with numeric timestamp
let _ = chat_files::change_message_state(
timestamp,
*receiver_id as i64,
*sender_id as i64,
MessageState::Sent,
);
self.send_message(
&CommunicationValue::new(CommunicationType::MessageState)
.with_id(cv.get_id())
.with_receiver(*sender_id)
.with_sender(*receiver_id)
.add_typed_default(
DataType::SendTime,
DataValue::SignedNumber(timestamp as i128),
)
.add_typed_default(
DataType::ChatPartnerId,
DataValue::SignedNumber(*receiver_id as i128),
)
.add_typed_default(
DataType::MessageState,
DataValue::Str(MessageState::Sent.as_str().to_string()),
),
)
.await;
}
return;
}
if cv.is_type(CommunicationType::MessagesGet) {
self.send_message(&message_handlers::handle_messages_get(&cv))
.await;
@ -503,9 +346,7 @@ impl ClientConnection {
}
async fn handle_challenge(&self, cv: &CommunicationValue) {
let kr_str = CONFIG.load().keyring.clone().unwrap();
let Some(keyring) = keyring_from_base64(&kr_str) else {
let Ok(keyring) = self.local_keyring().await else {
return;
};

View file

@ -2,6 +2,7 @@ use crate::auth::auth_user::AuthUser;
use crate::communities::community::Community;
use crate::communities::interactables::interactable::Interactable;
use crate::users::user_manager::get_user;
use iota_util::mtp_compat::CommunicationValueCompat;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use futures::SinkExt;

View file

@ -8,6 +8,7 @@ use crate::{
};
use async_trait::async_trait;
use json::{JsonValue, array, object};
use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt};
use std::fs;
use std::path::Path;
use std::sync::Arc;

View file

@ -1,6 +1,7 @@
use crate::communities::{community::Community, interactables::interactable::Interactable};
use async_trait::async_trait;
use json::JsonValue;
use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt};
use std::sync::Arc;
use std::{any::Any, sync::RwLock};
use uuid::Uuid;

View file

@ -189,9 +189,7 @@ impl Screen for ConfirmDialog {
let button_widths: Vec<u16> = self
.buttons
.iter()
.map(|b| {
crate::controls::button::button_minimum_width(&b.label)
})
.map(|b| crate::controls::button::button_minimum_width(&b.label))
.collect();
let total_width: u16 = button_widths.iter().sum();
@ -200,9 +198,7 @@ impl Screen for ConfirmDialog {
let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2;
let mut x = start_x;
for (i, (button_config, &width)) in
self.buttons.iter().zip(&button_widths).enumerate()
{
for (i, (button_config, &width)) in self.buttons.iter().zip(&button_widths).enumerate() {
let button_area = Rect {
x,
y: buttons_area.y,

View file

@ -11,12 +11,17 @@ use ratatui::{
widgets::Paragraph,
};
fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) {
fn connection_badge(
state: &IpcConnectionState,
theme: &ResolvedTheme,
) -> (&'static str, ratatui::style::Style) {
match state {
IpcConnectionState::Connected => ("OK", theme.status.success),
IpcConnectionState::Connecting => ("..", theme.status.warning),
IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning),
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error),
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => {
("FAIL", theme.status.error)
}
IpcConnectionState::Disconnected => ("WARN", theme.status.warning),
}
}
@ -52,7 +57,8 @@ pub fn render_header(
format!(" v{}", daemon.version)
};
let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let rows =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
@ -94,10 +100,34 @@ pub fn render_header(
hits.register(brand_area, AppAction::OpenMain);
for (index, (top, _bottom, label, intent, action)) in [
(cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview),
(cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers),
(cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings),
(cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit),
(
cells[1],
cells2[1],
"Overview",
ButtonIntent::Primary,
AppAction::OpenOverview,
),
(
cells[2],
cells2[2],
"Users",
ButtonIntent::Neutral,
AppAction::OpenUsers,
),
(
cells[3],
cells2[3],
"Settings",
ButtonIntent::Neutral,
AppAction::OpenSettings,
),
(
cells[4],
cells2[4],
"Quit",
ButtonIntent::Destructive,
AppAction::Quit,
),
]
.into_iter()
.enumerate()

View file

@ -186,8 +186,7 @@ impl Screen for SettingsScreen {
let inner = header_block.inner(area);
frame.render_widget(header_block, area);
let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)])
.split(inner);
let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
frame.render_widget(
Paragraph::new(format!(

View file

@ -122,10 +122,7 @@ impl UsersScreen {
if self.loading {
const SPINNERS: &[u8] = b"|/-\\";
let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()];
f.render_widget(
Paragraph::new(format!("{ch} Loading users…")),
inner,
);
f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner);
return;
}
if visible_indices.is_empty() {
@ -149,9 +146,20 @@ impl UsersScreen {
let user = &self.users[*user_index];
(
*user_index,
format!("{:>6} {} {}{}", user.user_id, user.username,
match user.state { iota_ipc::LocalUserState::Managed => "managed", iota_ipc::LocalUserState::Released => "released" },
if user.data_present { "" } else { ", data purged" }),
format!(
"{:>6} {} {}{}",
user.user_id,
user.username,
match user.state {
iota_ipc::LocalUserState::Managed => "managed",
iota_ipc::LocalUserState::Released => "released",
},
if user.data_present {
""
} else {
", data purged"
}
),
)
})
.collect();
@ -533,11 +541,14 @@ impl Screen for UsersScreen {
match result {
Ok(()) => {
self.pending_dialog = None;
if let Some(user) = self.users.iter_mut().find(|user| user.user_id == user_id) {
if let Some(user) =
self.users.iter_mut().find(|user| user.user_id == user_id)
{
user.state = iota_ipc::LocalUserState::Released;
user.credential_present = false;
}
self.message = Some(format!("Released user {user_id}; hosted data retained."));
self.message =
Some(format!("Released user {user_id}; hosted data retained."));
}
Err(error) => {
self.dialog = self.pending_dialog.take();

View file

@ -306,7 +306,10 @@ impl UI {
}
pub async fn notifications(&self) -> Vec<Notification> {
self.notifications.lock().map(|n| n.clone()).unwrap_or_default()
self.notifications
.lock()
.map(|n| n.clone())
.unwrap_or_default()
}
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
@ -611,7 +614,9 @@ impl UI {
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Cannot load users: {error}"))
}
Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
Ok(_) => {
Err("Daemon returned an unexpected response while loading users.".into())
}
Err(error) => Err(format!("Cannot load users: {error}")),
}
};
@ -705,7 +710,12 @@ impl UI {
width: 40.min(rows[1].width),
height: 3.min(rows[1].height),
};
render_notification_area(f, notification_area, &notifications, context.theme);
render_notification_area(
f,
notification_area,
&notifications,
context.theme,
);
}
}
})?;

View file

@ -6,4 +6,7 @@ edition = "2024"
[dependencies]
iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["crypto"] }
[dev-dependencies]
tokio = { version = "1.50.0", features = ["macros", "rt"] }

View file

@ -1,3 +1,4 @@
pub mod connection_handler;
pub mod message_common;
pub mod message_handlers;
pub mod relay;

View file

@ -2,6 +2,8 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::type_map::TypeMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub use iota_util::mtp_compat::{CommunicationValueCompat, OptionalDataValueExt};
pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue {
use mtp::type_map::{DataTypeId, TypeMap};
let tm = TypeMap::latest();
@ -15,22 +17,34 @@ pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue {
pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option<String> {
cv.get_data(dt)
.as_str()
.and_then(DataValue::as_str)
.map(|s| s.to_string())
.or_else(|| cv.get_data(dt).as_number().map(|n| n.to_string()))
.or_else(|| cv.get_data(dt).as_signed_number().map(|n| n.to_string()))
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_number)
.map(|n| n.to_string())
})
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_signed_number)
.map(|n| n.to_string())
})
}
pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option<i64> {
cv.get_data(dt)
.as_number()
.and_then(DataValue::as_number)
.and_then(|n| i64::try_from(n).ok())
.or_else(|| {
cv.get_data(dt)
.as_signed_number()
.and_then(DataValue::as_signed_number)
.and_then(|n| i64::try_from(n).ok())
})
.or_else(|| cv.get_data(dt).as_str().and_then(|s| s.parse::<i64>().ok()))
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_str)
.and_then(|s| s.parse::<i64>().ok())
})
}
#[derive(Debug, Clone)]
@ -67,7 +81,7 @@ pub fn recipient_from_value(value: &DataValue) -> Option<ChatSecretRecipient> {
}
pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option<Vec<ChatSecretRecipient>> {
let recipients = cv.get_data(DataType::Recipients).as_array()?;
let recipients = cv.get_data(DataType::Recipients)?.as_array()?;
let parsed = recipients
.iter()
.map(recipient_from_value)
@ -80,49 +94,6 @@ pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option<Vec<ChatSecretR
}
}
pub fn set_chat_secret_cv_for_recipient(
source: &CommunicationValue,
recipient: &ChatSecretRecipient,
) -> CommunicationValue {
let recipient_value = typed_container(vec![
(DataType::UserId, DataValue::Str(recipient.user_id.clone())),
(
DataType::EncryptedSecret,
DataValue::Bytes(recipient.encrypted_secret.clone()),
),
(
DataType::KemCiphertext,
DataValue::Bytes(recipient.kem_ciphertext.clone()),
),
]);
CommunicationValue::new(CommunicationType::SetChatSecret)
.with_id(source.get_id())
.with_sender(source.get_sender())
.with_receiver(recipient.user_id.parse::<u64>().unwrap_or(0))
.add_typed_default(DataType::ChatId, source.get_data(DataType::ChatId).clone())
.add_typed_default(
DataType::SecretId,
source.get_data(DataType::SecretId).clone(),
)
.add_typed_default(
DataType::VersionNumber,
source.get_data(DataType::VersionNumber).clone(),
)
.add_typed_default(
DataType::WrappingScheme,
source.get_data(DataType::WrappingScheme).clone(),
)
.add_typed_default(
DataType::CreatedAt,
source.get_data(DataType::CreatedAt).clone(),
)
.add_typed_default(
DataType::Recipients,
DataValue::Array(vec![recipient_value]),
)
}
pub fn now_millis_i64() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@ -131,7 +102,9 @@ pub fn now_millis_i64() -> i64 {
}
pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue {
CommunicationValue::new(ty)
.with_id(request.get_id())
.with_receiver(request.get_sender())
let mut response = CommunicationValue::new(ty).with_id(request.id().unwrap_or_default());
if let Some(sender) = request.sender() {
response = response.with_receiver(sender);
}
response
}

View file

@ -4,7 +4,11 @@ use iota_storage::util::chats_util::{self, get_user, mod_user};
use iota_storage::util::communities_util::CommunitiesUtil;
use iota_storage::util::e2ee_storage::{self, ChatSecretQuery};
use iota_storage::util::settings;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, VerifiedRelayContent,
};
use crate::relay::VerifiedRelayContext;
pub struct MessageMutation {
pub sender_id: i64,
@ -33,6 +37,170 @@ pub fn success_response(cv: &CommunicationValue) -> CommunicationValue {
error_response(cv, CommunicationType::Success)
}
fn relay_field<'a>(
payload: &'a DataValue,
data_type: DataType,
type_map: &TypeMap,
) -> Option<&'a DataValue> {
payload.get_field(data_type.try_to_id(type_map)?)
}
fn relay_string<'a>(
payload: &'a DataValue,
data_type: DataType,
type_map: &TypeMap,
) -> Option<&'a str> {
relay_field(payload, data_type, type_map)?.as_str()
}
fn relay_number(payload: &DataValue, data_type: DataType, type_map: &TypeMap) -> Option<i128> {
relay_field(payload, data_type, type_map)?.as_number()
}
fn relay_identity(
payload: &DataValue,
data_type: DataType,
type_map: &TypeMap,
) -> Result<Option<u64>, String> {
let Some(value) = relay_field(payload, data_type, type_map) else {
return Ok(None);
};
if let Some(number) = value.as_number() {
return u64::try_from(number)
.map(Some)
.map_err(|_| format!("Relay {data_type:?} is outside the user ID range"));
}
if let Some(text) = value.as_str() {
return text
.parse::<u64>()
.map(Some)
.map_err(|_| format!("Relay {data_type:?} is not a user ID"));
}
Err(format!("Relay {data_type:?} has an invalid user ID value"))
}
fn validate_relay_identity(
context: &VerifiedRelayContext,
payload: &DataValue,
) -> Result<(), String> {
if relay_identity(payload, DataType::SenderId, &context.type_map)?
.is_some_and(|sender_id| sender_id != context.signer_id)
{
return Err("Relay SenderId does not match the authenticated signer".into());
}
if relay_identity(payload, DataType::ReceiverId, &context.type_map)?
.is_some_and(|receiver_id| receiver_id != context.final_recipient_id)
{
return Err("Relay ReceiverId does not match the authenticated recipient".into());
}
Ok(())
}
/*
* Apply only operations whose actor and recipient can be taken from verified
* Relay metadata. The raw Relay frame never enters these handlers, so outer
* routing fields cannot become application identity.
*/
pub fn apply_verified_relay_content(
context: &VerifiedRelayContext,
content: &VerifiedRelayContent,
) -> Result<(), String> {
validate_relay_identity(context, &content.content)?;
let sender_id = i64::try_from(context.signer_id)
.map_err(|_| "Relay signer ID exceeds the local storage range".to_string())?;
let recipient_id = i64::try_from(context.final_recipient_id)
.map_err(|_| "Relay recipient ID exceeds the local storage range".to_string())?;
let created_at = i64::try_from(context.created_at)
.map_err(|_| "Relay creation time exceeds the local storage range".to_string())?;
match content.message_type.as_str() {
"MessageSend" => {
let message = relay_string(&content.content, DataType::Content, &context.type_map)
.ok_or_else(|| "Relay MessageSend is missing Content".to_string())?;
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.unwrap_or(created_at);
let height = relay_number(&content.content, DataType::Height, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.unwrap_or_default();
let reply_to = relay_number(&content.content, DataType::ReplyId, &context.type_map)
.and_then(|value| i64::try_from(value).ok());
chat_files::add_message(
u128::try_from(send_time)
.map_err(|_| "Relay MessageSend has a negative SendTime".to_string())?,
false,
recipient_id,
sender_id,
message,
height,
reply_to,
);
Ok(())
}
"MessageEdit" => {
let message = relay_string(&content.content, DataType::Content, &context.type_map)
.ok_or_else(|| "Relay MessageEdit is missing Content".to_string())?;
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| "Relay MessageEdit is missing SendTime".to_string())?;
chat_files::apply_remote_edit(recipient_id, sender_id, send_time, sender_id, message)
.map_err(|error| error.to_string())
}
"MessageReactionAdd" | "MessageReactionRemove" => {
let reaction = relay_string(&content.content, DataType::Reaction, &context.type_map)
.filter(|value| !value.is_empty() && value.len() <= 64)
.ok_or_else(|| "Relay reaction is invalid".to_string())?;
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| "Relay reaction is missing SendTime".to_string())?;
let result = if content.message_type == "MessageReactionAdd" {
chat_files::add_reaction(recipient_id, sender_id, send_time, sender_id, reaction)
} else {
chat_files::remove_reaction(recipient_id, sender_id, send_time, sender_id, reaction)
};
result.map_err(|error| error.to_string())
}
"MessageDeleteLive" => {
let send_time = relay_number(&content.content, DataType::SendTime, &context.type_map)
.and_then(|value| i64::try_from(value).ok())
.ok_or_else(|| "Relay MessageDeleteLive is missing SendTime".to_string())?;
chat_files::apply_remote_delete(recipient_id, sender_id, send_time, sender_id)
.map_err(|error| error.to_string())
}
"SetChatSecret" => {
let frame = CommunicationValue::new(CommunicationType::SetChatSecret)
.with_payload(content.content.clone());
let recipients = chat_secret_recipients(&frame)
.ok_or_else(|| "Relay SetChatSecret has no recipients".to_string())?;
let recipient = recipients
.into_iter()
.find(|value| value.user_id == context.final_recipient_id.to_string())
.ok_or_else(|| "Relay SetChatSecret recipient mismatch".to_string())?;
let chat_id = data_string(&frame, DataType::ChatId)
.ok_or_else(|| "Relay SetChatSecret is missing ChatId".to_string())?;
let secret_id = data_string(&frame, DataType::SecretId)
.ok_or_else(|| "Relay SetChatSecret is missing SecretId".to_string())?;
let version = data_i64(&frame, DataType::VersionNumber)
.ok_or_else(|| "Relay SetChatSecret is missing VersionNumber".to_string())?;
let wrapping_scheme = data_string(&frame, DataType::WrappingScheme)
.ok_or_else(|| "Relay SetChatSecret is missing WrappingScheme".to_string())?;
e2ee_storage::put_chat_secret(e2ee_storage::StoredChatSecret {
user_id: context.final_recipient_id.to_string(),
chat_id,
secret_id,
version,
encrypted_secret: recipient.encrypted_secret,
kem_ciphertext: recipient.kem_ciphertext,
wrapping_scheme,
created_at,
updated_at: now_millis_i64(),
})
.map_err(|error| error.to_string())
}
_ => Ok(()),
}
}
pub fn handle_message_edit(cv: &CommunicationValue) -> CommunicationValue {
let mutation = match message_mutation(cv) {
Ok(mutation) => mutation,
@ -346,7 +514,9 @@ mod presence_tests {
fn sync_error(cv: &CommunicationValue) -> CommunicationValue {
error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default(
DataType::SessionId,
cv.get_data(DataType::SessionId).clone(),
cv.get_data(DataType::SessionId)
.cloned()
.unwrap_or(DataValue::Null),
)
}

View file

@ -0,0 +1,350 @@
use iota_util::route_target::RouteTarget;
use mtp::codec::{
CommunicationValue, ProtectionPolicy, RelayError, SignaturePolicy, TypeMap,
VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame,
open_relay_content_with_keyrings, open_relay_metadata_with, relay_metadata_claimed_signer_id,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
use std::fmt;
pub const RELAY_PROTECTION_POLICY: ProtectionPolicy = ProtectionPolicy {
signature: SignaturePolicy::Dual,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageSecurityClass {
RelayOnly,
AuthenticatedPeerControl,
AuthenticatedLocalRequest,
}
pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClass {
const RELAY_ONLY_TYPES: &[mtp::codec::CommunicationType] = &[
mtp::codec::CommunicationType::MessageSend,
mtp::codec::CommunicationType::MessageLive,
mtp::codec::CommunicationType::MessageState,
mtp::codec::CommunicationType::MessageEdit,
mtp::codec::CommunicationType::MessageEditLive,
mtp::codec::CommunicationType::MessageReactionAdd,
mtp::codec::CommunicationType::MessageReactionRemove,
mtp::codec::CommunicationType::MessageReactionLive,
mtp::codec::CommunicationType::MessageDelete,
mtp::codec::CommunicationType::MessageDeleteLive,
mtp::codec::CommunicationType::MessageOtherIota,
mtp::codec::CommunicationType::SetChatSecret,
mtp::codec::CommunicationType::SendChat,
mtp::codec::CommunicationType::SettingsSave,
mtp::codec::CommunicationType::GlobalSettingsSave,
mtp::codec::CommunicationType::AddConversation,
mtp::codec::CommunicationType::AddCommunity,
mtp::codec::CommunicationType::RemoveCommunity,
];
if RELAY_ONLY_TYPES.iter().any(|kind| frame.is_type(*kind)) {
MessageSecurityClass::RelayOnly
} else if frame.is_type(mtp::codec::CommunicationType::GetChatSecret)
|| frame.is_type(mtp::codec::CommunicationType::MessageGet)
|| frame.is_type(mtp::codec::CommunicationType::MessagesGet)
{
MessageSecurityClass::AuthenticatedPeerControl
} else {
MessageSecurityClass::AuthenticatedLocalRequest
}
}
#[derive(Debug, Clone)]
pub struct UserIdentity {
pub user_id: u64,
pub iota_id: u64,
pub signing_keys: Vec<PublicKeyBundle>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRelayContext {
pub signer_id: u64,
pub final_recipient_id: u64,
pub message_id: String,
pub created_at: u64,
pub type_map: TypeMap,
}
#[derive(Debug, Clone)]
pub struct VerifiedRelay {
pub metadata: VerifiedRelayMetadata,
pub context: VerifiedRelayContext,
pub signing_keys: Vec<PublicKeyBundle>,
}
#[derive(Debug)]
pub enum RelayValidationError {
WrongNextHop { expected: u64, actual: Option<u64> },
OuterSenderNotAllowed,
MissingSigningKeys(u64),
MissingTypeMap,
InvalidRouteTarget(u64),
KeyLookup(String),
Relay(RelayError),
}
impl fmt::Display for RelayValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongNextHop { expected, actual } => {
write!(
formatter,
"relay next hop {:?} does not match Iota {expected}",
actual
)
}
Self::OuterSenderNotAllowed => formatter.write_str("relay has an outer sender"),
Self::MissingSigningKeys(signer_id) => {
write!(formatter, "no trusted signing keys for user {signer_id}")
}
Self::MissingTypeMap => formatter.write_str("relay has no negotiated type map"),
Self::InvalidRouteTarget(target) => {
write!(formatter, "relay has invalid route target {target}")
}
Self::KeyLookup(error) => write!(formatter, "trusted signer lookup failed: {error}"),
Self::Relay(error) => error.fmt(formatter),
}
}
}
impl std::error::Error for RelayValidationError {}
impl From<RelayError> for RelayValidationError {
fn from(error: RelayError) -> Self {
Self::Relay(error)
}
}
/*
* Relay metadata is opened only after the claimed signer selects trusted key
* history. Replay reservation happens after verification and durable
* acceptance, so a failed delivery can be retried without losing the frame.
*/
pub async fn verify_relay_metadata<F, Fut>(
frame: &CommunicationValue,
local_iota_id: u64,
keyring: &Keyring,
resolve_signing_keys: F,
) -> Result<VerifiedRelay, RelayValidationError>
where
F: FnOnce(u64) -> Fut,
Fut: Future<Output = Result<Vec<PublicKeyBundle>, RelayValidationError>>,
{
let expected_next_hop = RouteTarget::Iota(local_iota_id)
.wire_id()
.ok_or(RelayValidationError::InvalidRouteTarget(local_iota_id))?;
if frame.receiver() != Some(expected_next_hop) {
return Err(RelayValidationError::WrongNextHop {
expected: expected_next_hop,
actual: frame.receiver(),
});
}
if frame.sender().is_some() {
return Err(RelayValidationError::OuterSenderNotAllowed);
}
let claimed_signer = relay_metadata_claimed_signer_id(frame, &[keyring])?;
let signing_keys = resolve_signing_keys(claimed_signer).await?;
if signing_keys.is_empty() {
return Err(RelayValidationError::MissingSigningKeys(claimed_signer));
}
let resolver_keys = signing_keys.clone();
let type_map = frame
.type_map()
.cloned()
.ok_or(RelayValidationError::MissingTypeMap)?;
let metadata = open_relay_metadata_with(
frame,
&[keyring],
Some(claimed_signer),
move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()),
RELAY_PROTECTION_POLICY,
None,
)?;
let context = VerifiedRelayContext {
signer_id: metadata.signer_id(),
final_recipient_id: metadata.final_recipient_id(),
message_id: metadata.message_id().to_owned(),
created_at: metadata.created_at(),
type_map,
};
Ok(VerifiedRelay {
metadata,
context,
signing_keys,
})
}
pub fn open_verified_relay_content(
relay: &VerifiedRelay,
keyrings: &[&Keyring],
expected_recipient_id: u64,
) -> Result<VerifiedRelayContent, RelayValidationError> {
Ok(open_relay_content_with_keyrings(
&relay.metadata,
keyrings,
&relay.signing_keys,
Some(expected_recipient_id),
RELAY_PROTECTION_POLICY,
)?)
}
pub fn forward_verified_relay(
frame: &CommunicationValue,
target: RouteTarget,
) -> Result<CommunicationValue, RelayValidationError> {
let next_hop_id = target
.wire_id()
.ok_or(RelayValidationError::InvalidRouteTarget(target.id()))?;
Ok(forward_relay_frame(frame, next_hop_id)?)
}
#[cfg(test)]
mod tests {
use super::*;
use mtp::codec::SealedRelayBuilder;
use mtp::crypto::{DualSigner, Ed25519Signer, Keyring};
fn relay(message_id: &str) -> Result<(Keyring, Keyring, CommunicationValue), String> {
let signer_keyring = Keyring::generate();
let recipient_keyring = Keyring::generate();
let signer = DualSigner::new(
&signer_keyring.sig_cl_secret_key,
&signer_keyring.sig_pq_secret_key,
&signer_keyring.sig_pq_public_key,
)
.map_err(|error| error.to_string())?;
let frame = SealedRelayBuilder::new(
"MessageSend",
mtp::codec::DataValue::Str("payload".into()),
7,
42,
RouteTarget::Iota(99)
.wire_id()
.ok_or("invalid test target")?,
&signer,
)
.message_id(message_id)
.created_at(123)
.metadata_recipients(vec![recipient_keyring.public_key_bundle()])
.content_recipients(vec![recipient_keyring.public_key_bundle()])
.build()
.map_err(|error| error.to_string())?;
Ok((signer_keyring, recipient_keyring, frame))
}
#[tokio::test]
async fn verifies_metadata_with_trusted_signing_key() -> Result<(), String> {
let (signer, recipient, frame) = relay("accepted")?;
let trusted_key = signer.public_key_bundle();
let verified = verify_relay_metadata(&frame, 99, &recipient, move |signer_id| async move {
(signer_id == 7)
.then_some(vec![trusted_key])
.ok_or(RelayValidationError::MissingSigningKeys(signer_id))
})
.await
.map_err(|error| error.to_string())?;
assert_eq!(verified.context.signer_id, 7);
assert_eq!(verified.context.final_recipient_id, 42);
assert_eq!(verified.context.message_id, "accepted");
Ok(())
}
#[tokio::test]
async fn rejects_metadata_signed_by_untrusted_key() -> Result<(), String> {
let (_signer, recipient, frame) = relay("wrong-key")?;
let wrong_signer = Keyring::generate();
let trusted_key = wrong_signer.public_key_bundle();
let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move {
Ok(vec![trusted_key])
})
.await;
assert!(matches!(result, Err(RelayValidationError::Relay(_))));
Ok(())
}
#[tokio::test]
async fn rejects_classical_only_relay_under_dual_policy() -> Result<(), String> {
let signer_keyring = Keyring::generate();
let recipient_keyring = Keyring::generate();
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)
.map_err(|error| error.to_string())?;
let frame = SealedRelayBuilder::new(
"MessageSend",
mtp::codec::DataValue::Str("payload".into()),
7,
42,
RouteTarget::Iota(99)
.wire_id()
.ok_or("invalid test target")?,
&signer,
)
.message_id("classical-only")
.created_at(123)
.metadata_recipients(vec![recipient_keyring.public_key_bundle()])
.content_recipients(vec![recipient_keyring.public_key_bundle()])
.build()
.map_err(|error| error.to_string())?;
let trusted_key = signer_keyring.public_key_bundle();
let result = verify_relay_metadata(&frame, 99, &recipient_keyring, move |_| async move {
Ok(vec![trusted_key])
})
.await;
assert!(matches!(result, Err(RelayValidationError::Relay(_))));
Ok(())
}
#[tokio::test]
async fn rejects_outer_sender_before_key_lookup() -> Result<(), String> {
let (_signer, recipient, frame) = relay("outer-sender")?;
let frame = frame.with_sender(501);
let result = verify_relay_metadata(&frame, 99, &recipient, |_| async {
Err(RelayValidationError::MissingSigningKeys(7))
})
.await;
assert!(matches!(
result,
Err(RelayValidationError::OuterSenderNotAllowed)
));
Ok(())
}
#[tokio::test]
async fn verification_does_not_commit_replay_state() -> Result<(), String> {
let (signer, recipient, frame) = relay("duplicate")?;
let trusted_key = signer.public_key_bundle();
for _ in 0..2 {
let trusted_key = trusted_key.clone();
let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move {
Ok(vec![trusted_key])
})
.await;
let _ = result.map_err(|error| error.to_string())?;
}
Ok(())
}
#[test]
fn forwarding_preserves_sealed_payload() -> Result<(), String> {
let (_signer, _recipient, frame) = relay("forwarding")?;
let forwarded = forward_verified_relay(&frame, RouteTarget::User(100))
.map_err(|error| error.to_string())?;
assert_eq!(frame.sender(), None);
assert_eq!(forwarded.sender(), None);
assert_eq!(forwarded.receiver(), RouteTarget::User(100).wire_id());
assert_eq!(frame.payload(), forwarded.payload());
Ok(())
}
}

View file

@ -100,10 +100,25 @@ impl CommandRouter {
.into_iter()
.map(|user| UserSummary {
credential_present: user.state == user_manager::LocalUserState::Managed
&& user_manager::get_user(user.user_id).is_some_and(|profile| iota_util::file_util::read_user_credential_with_legacy(user.user_id, &profile.username).ok().flatten().is_some()),
&& user_manager::get_user(user.user_id).is_some_and(|profile| {
iota_util::file_util::read_user_credential_with_legacy(
user.user_id,
&profile.username,
)
.ok()
.flatten()
.is_some()
}),
user_id: user.user_id,
username: user.username,
state: match user.state { user_manager::LocalUserState::Managed => iota_ipc::LocalUserState::Managed, user_manager::LocalUserState::Released => iota_ipc::LocalUserState::Released },
state: match user.state {
user_manager::LocalUserState::Managed => {
iota_ipc::LocalUserState::Managed
}
user_manager::LocalUserState::Released => {
iota_ipc::LocalUserState::Released
}
},
data_present: user.data_present,
})
.collect();
@ -145,7 +160,8 @@ impl CommandRouter {
}
}
}
LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id) {
LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id)
{
Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }),
Err(error) => {
log!("User data purge failed for {user_id}: {error}");
@ -153,25 +169,51 @@ impl CommandRouter {
}
},
LocalRequest::AttachUserFromTu { credential } => {
match omikron_connector::user_ops::attach_user_from_tu(self.services.omikron.as_ref(), &credential.0).await {
Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Added {} ({}) to this Iota", user.username, user.user_id) }),
match omikron_connector::user_ops::attach_user_from_tu(
self.services.omikron.as_ref(),
&credential.0,
)
.await
{
Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Added {} ({}) to this Iota", user.username, user.user_id),
}),
Err(error) => {
log!("Credential attach failed: {error:?}");
ResponseResult::Error(IpcErrorCode::Unauthorized)
}
}
}
LocalRequest::CompleteDeleteUser { user_id, credential } => {
LocalRequest::CompleteDeleteUser {
user_id,
credential,
} => {
let contents = match credential {
Some(value) => Ok(value.0),
None => user_manager::get_user(user_id)
.ok_or(())
.and_then(|user| iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).map_err(|_| ()))
.and_then(|user| {
iota_util::file_util::read_user_credential_with_legacy(
user_id,
&user.username,
)
.map_err(|_| ())
})
.and_then(|value| value.ok_or(())),
};
let Ok(contents) = contents else { return ResponseResult::Error(IpcErrorCode::Unauthorized); };
match omikron_connector::user_ops::complete_delete_user_with_tu(self.services.omikron.as_ref(), &contents, user_id).await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { message: format!("Deleted Tensamin account {user_id}") }),
let Ok(contents) = contents else {
return ResponseResult::Error(IpcErrorCode::Unauthorized);
};
match omikron_connector::user_ops::complete_delete_user_with_tu(
self.services.omikron.as_ref(),
&contents,
user_id,
)
.await
{
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Deleted Tensamin account {user_id}"),
}),
Err(error) => {
log!("Credential deletion failed for {user_id}: {error:?}");
ResponseResult::Error(IpcErrorCode::Unauthorized)
@ -185,19 +227,34 @@ impl CommandRouter {
}
let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()));
match self.services.omikron.await_response(&request, Duration::from_secs(20)).await {
Ok(response) if response.is_type(CommunicationType::Success) => match user_manager::release_user(user_id) {
match self
.services
.omikron
.await_response(&request, Duration::from_secs(20))
.await
{
Ok(response) if response.is_type(CommunicationType::Success) => {
match user_manager::release_user(user_id) {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Released user {user_id}; hosted data was retained"),
message: format!(
"Released user {user_id}; hosted data was retained"
),
}),
Err(error) => {
log!("Remote release succeeded but local cleanup failed for {user_id}: {error}");
log!(
"Remote release succeeded but local cleanup failed for {user_id}: {error}"
);
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
},
Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => ResponseResult::Error(IpcErrorCode::Unauthorized),
}
}
Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => {
ResponseResult::Error(IpcErrorCode::Unauthorized)
}
Ok(_) => ResponseResult::Error(IpcErrorCode::Conflict),
Err(omikron_connector::OmikronError::Timeout(_)) => ResponseResult::Error(IpcErrorCode::Timeout),
Err(omikron_connector::OmikronError::Timeout(_)) => {
ResponseResult::Error(IpcErrorCode::Timeout)
}
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
}
}
@ -294,7 +351,14 @@ impl CommandRouter {
}
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
Some(user) => {
let credential_present = iota_util::file_util::read_user_credential_with_legacy(user_id, &user.username).ok().flatten().is_some();
let credential_present =
iota_util::file_util::read_user_credential_with_legacy(
user_id,
&user.username,
)
.ok()
.flatten()
.is_some();
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
user_id: user.user_id,
username: user.username,
@ -302,10 +366,13 @@ impl CommandRouter {
created_at: user.created_at,
trusted_apps: user.trusted_apps.keys().cloned().collect(),
state: iota_ipc::LocalUserState::Managed,
data_present: user_manager::get_residency().iter().find(|entry| entry.user_id == user_id).is_none_or(|entry| entry.data_present),
data_present: user_manager::get_residency()
.iter()
.find(|entry| entry.user_id == user_id)
.is_none_or(|entry| entry.data_present),
credential_present,
}))
},
}
None => ResponseResult::Error(IpcErrorCode::NotFound),
},
LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),

View file

@ -215,8 +215,14 @@ async fn main() -> ExitCode {
.spawn_tracked("user-lifecycle-reconciliation", async move {
let mut states = omikron_reconcile.connection_state();
loop {
if matches!(*states.borrow(), omikron_connector::omikron_connection::ConnectionState::Connected { .. }) {
omikron_connector::user_ops::reconcile_managed_users(omikron_reconcile.as_ref()).await;
if matches!(
*states.borrow(),
omikron_connector::omikron_connection::ConnectionState::Connected { .. }
) {
omikron_connector::user_ops::reconcile_managed_users(
omikron_reconcile.as_ref(),
)
.await;
}
tokio::select! {
changed = states.changed() => if changed.is_err() { break },

View file

@ -6,9 +6,10 @@ pub use protocol::{
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope,
ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse,
SecretString, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, LocalUserState,
LocalUserState, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse,
RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, SecretString, StartupPhase,
StateSnapshot, StatusResponse, SupervisorKind, TaskSummary, UpdateStatusResponse,
UserDetailResponse, UserSummary,
};
pub use transport::{read_msg, write_msg};

View file

@ -163,11 +163,20 @@ pub enum ResponsePayload {
Status(StatusResponse),
Tasks(Vec<TaskSummary>),
Users(Vec<UserSummary>),
UserCreated { user_id: i64, username: String },
UserCreated {
user_id: i64,
username: String,
},
/// Retained only for wire compatibility. New lifecycle code never emits it.
UserRemoved { user_id: i64 },
UserDataPurged { user_id: i64 },
Acknowledged { message: String },
UserRemoved {
user_id: i64,
},
UserDataPurged {
user_id: i64,
},
Acknowledged {
message: String,
},
DaemonStatus(DaemonStatusResponse),
Config(ConfigResponse),
OmikronStatus(OmikronStatusResponse),

View file

@ -318,28 +318,29 @@ pub fn log_cv_internal(
pub fn format_cv(cv: &CommunicationValue) -> String {
let mut parts = Vec::new();
let sender = cv.get_sender();
let receiver = cv.get_receiver();
if sender > 0 && receiver > 0 {
parts.push(format!("{} > {}", sender, receiver));
} else if sender > 0 {
parts.push(format!("{}", sender));
} else if receiver > 0 {
parts.push(format!("> {}", receiver));
match (cv.sender(), cv.receiver()) {
(Some(sender), Some(receiver)) => parts.push(format!("{} > {}", sender, receiver)),
(Some(sender), None) => parts.push(sender.to_string()),
(None, Some(receiver)) => parts.push(format!("> {}", receiver)),
(None, None) => {}
}
let comm_type = cv
.get_comm_type_enum()
.map(|kind| kind.to_string())
.unwrap_or_else(|| cv.get_type().to_string());
parts.push(format!("{} (id={})", comm_type, cv.get_id()));
let id = cv
.id()
.map_or_else(|| "none".to_string(), |value| value.to_string());
parts.push(format!("{} (id={})", comm_type, id));
let data = cv.data();
let formated_data = format_data_container(
data.iter().map(|(k, v)| (*k, v.clone())).collect(),
Version(1, 0),
let version = cv
.type_map()
.map(|type_map| type_map.version.clone())
.unwrap_or_else(|| Version(3, 0));
let formated_data = cv.data().map_or_else(
|| "<opaque payload>".to_string(),
|data| format_data_container(data.to_vec(), version),
);
parts.push(format!("{}", formated_data));

View file

@ -19,7 +19,10 @@ pub struct UserResidency {
}
fn now_millis() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
pub fn add_user(user: UserProfile) {
@ -235,7 +238,10 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
})?;
db::with_db(|conn| {
let tx = conn.unchecked_transaction()?;
tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
tx.execute(
"DELETE FROM trusted_apps WHERE user_id = ?1",
params![user_id],
)?;
tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
tx.execute(
r#"INSERT INTO user_residency (user_id, username, lifecycle_state, data_state, updated_at)
@ -246,7 +252,8 @@ pub fn release_user(user_id: i64) -> Result<(), crate::storage_error::StorageErr
tx.commit()?;
Ok(())
})?;
remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
remove_user_credential(user_id)
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
}
/// Authoritative hosted-data erasure used by local purge and future Omega
@ -256,14 +263,35 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
let tx = conn.unchecked_transaction()?;
tx.execute("DELETE FROM message_edits WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
tx.execute("DELETE FROM reactions WHERE message_id IN (SELECT id FROM messages WHERE storage_owner = ?1)", params![user_id])?;
tx.execute("DELETE FROM messages WHERE storage_owner = ?1", params![user_id])?;
tx.execute("DELETE FROM contacts WHERE storage_owner = ?1", params![user_id])?;
tx.execute("DELETE FROM communities WHERE storage_owner = ?1", params![user_id])?;
tx.execute(
"DELETE FROM messages WHERE storage_owner = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM contacts WHERE storage_owner = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM communities WHERE storage_owner = ?1",
params![user_id],
)?;
tx.execute("DELETE FROM settings WHERE user_id = ?1", params![user_id])?;
tx.execute("DELETE FROM sync_events WHERE user_id = ?1", params![user_id])?;
tx.execute("DELETE FROM sync_heads WHERE user_id = ?1", params![user_id])?;
tx.execute("DELETE FROM client_sync_state WHERE user_id = ?1", params![user_id])?;
tx.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
tx.execute(
"DELETE FROM sync_events WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM sync_heads WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM client_sync_state WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"DELETE FROM trusted_apps WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"UPDATE user_residency SET data_state = 'empty', updated_at = ?2 WHERE user_id = ?1",
params![user_id, now_millis()],
@ -273,7 +301,8 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
})?;
crate::util::e2ee_storage::purge_user(user_id)
.map_err(crate::storage_error::StorageError::Other)?;
delete_user_directory(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
delete_user_directory(user_id)
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
}
/// Complete local erasure is idempotent and is the target for a durable
@ -281,12 +310,19 @@ pub fn purge_user_data(user_id: i64) -> Result<(), crate::storage_error::Storage
pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
purge_user_data(user_id)?;
db::with_db(|conn| {
conn.execute("DELETE FROM trusted_apps WHERE user_id = ?1", params![user_id])?;
conn.execute(
"DELETE FROM trusted_apps WHERE user_id = ?1",
params![user_id],
)?;
conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
conn.execute("DELETE FROM user_residency WHERE user_id = ?1", params![user_id])?;
conn.execute(
"DELETE FROM user_residency WHERE user_id = ?1",
params![user_id],
)?;
Ok(())
})?;
remove_user_credential(user_id).map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
remove_user_credential(user_id)
.map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))
}
pub fn get_residency() -> Vec<UserResidency> {
@ -306,14 +342,15 @@ pub fn get_residency() -> Vec<UserResidency> {
pub fn clear() {
if let Err(e) = db::with_db(|conn| {
conn.execute_batch("DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;")?;
conn.execute_batch(
"DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;",
)?;
Ok(())
}) {
eprintln!("Failed to clear users: {}", e);
}
}
pub fn save_users() {
// No-op: users are auto-saved via SQLite.
}

View file

@ -77,12 +77,22 @@ fn add_column_if_missing(
column: &str,
definition: &str,
) -> Result<(), StorageError> {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
add_table_column_if_missing(conn, "messages", column, definition)
}
fn add_table_column_if_missing(
conn: &Connection,
table: &str,
column: &str,
definition: &str,
) -> Result<(), StorageError> {
let mut statement = conn.prepare(&format!(
"SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"
))?;
let exists = statement.exists([column])?;
if !exists {
conn.execute_batch(&format!("ALTER TABLE messages ADD COLUMN {definition};"))?;
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {definition};"))?;
}
Ok(())
@ -266,6 +276,72 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
)?;
}
if current_version < 8 {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS relay_replay (
signer_id INTEGER NOT NULL,
message_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (signer_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_relay_replay_created_at
ON relay_replay (created_at);
CREATE TABLE IF NOT EXISTS pending_relays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_id INTEGER NOT NULL,
target_kind INTEGER NOT NULL DEFAULT 0,
frame BLOB NOT NULL,
created_at INTEGER NOT NULL,
frame_id INTEGER NOT NULL DEFAULT 0,
UNIQUE(destination_id, frame)
);
CREATE INDEX IF NOT EXISTS idx_pending_relays_destination
ON pending_relays (destination_id, id);
PRAGMA user_version = 8;
"#,
)?;
}
if current_version < 9 {
add_table_column_if_missing(
conn,
"pending_relays",
"target_kind",
"target_kind INTEGER NOT NULL DEFAULT 0",
)?;
add_table_column_if_missing(
conn,
"pending_relays",
"type_map_version",
"type_map_version TEXT NOT NULL DEFAULT '1.0'",
)?;
add_table_column_if_missing(
conn,
"pending_relays",
"frame_id",
"frame_id INTEGER NOT NULL DEFAULT 0",
)?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS relay_inbox (
signer_id INTEGER NOT NULL,
message_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
destination_id INTEGER NOT NULL,
frame BLOB NOT NULL,
type_map_version TEXT NOT NULL,
frame_id INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN ('received', 'applied', 'queued', 'delivered', 'rejected')),
PRIMARY KEY (signer_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_relay_inbox_state
ON relay_inbox (state, created_at);
PRAGMA user_version = 9;
"#,
)?;
}
Ok(())
}
@ -335,7 +411,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 7);
assert_eq!(version, 9);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -352,8 +428,16 @@ mod tests {
run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 7);
for table in ["sync_heads", "sync_events", "client_sync_state", "user_residency"] {
assert_eq!(version, 9);
for table in [
"sync_heads",
"sync_events",
"client_sync_state",
"user_residency",
"relay_replay",
"pending_relays",
"relay_inbox",
] {
let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
@ -361,6 +445,11 @@ mod tests {
)?;
assert_eq!(exists, 1);
}
for column in ["frame_id", "target_kind", "type_map_version"] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('pending_relays') WHERE name = ?1")?;
assert!(statement.exists([column])?);
}
Ok(())
}
}

View file

@ -24,19 +24,6 @@ pub struct ChatSecretQuery {
pub secret_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingChatSecretForward {
pub recipient_user_id: String,
pub chat_id: String,
pub sender_user_id: String,
pub secret_id: String,
pub version: i64,
pub encrypted_secret: Vec<u8>,
pub kem_ciphertext: Vec<u8>,
pub wrapping_scheme: String,
pub created_at: i64,
}
static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
db::create_shared_connection(
"e2ee",
@ -63,21 +50,6 @@ static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
CREATE INDEX IF NOT EXISTS idx_chat_secrets_owner
ON chat_secrets (user_id, chat_id, secret_id);
CREATE TABLE IF NOT EXISTS pending_chat_secret_forwards (
recipient_user_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
sender_user_id TEXT NOT NULL,
secret_id TEXT NOT NULL,
version INTEGER NOT NULL,
encrypted_secret BLOB NOT NULL,
kem_ciphertext BLOB NOT NULL,
wrapping_scheme TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (recipient_user_id, chat_id, secret_id)
);
CREATE INDEX IF NOT EXISTS idx_pending_chat_secret_forwards_recipient
ON pending_chat_secret_forwards (recipient_user_id, created_at);
"#,
)
.expect("Failed to create or initialize E2EE DB")
@ -115,89 +87,15 @@ pub fn put_chat_secret(record: StoredChatSecret) -> Result<(), StorageError> {
})
}
pub fn put_pending_chat_secret_forward(
record: PendingChatSecretForward,
) -> Result<(), StorageError> {
db::with_conn(&E2EE_DB, |conn| {
conn.execute(
r#"
INSERT INTO pending_chat_secret_forwards (
recipient_user_id, chat_id, sender_user_id, secret_id, version,
encrypted_secret, kem_ciphertext, wrapping_scheme, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(recipient_user_id, chat_id, secret_id) DO UPDATE SET
sender_user_id = excluded.sender_user_id,
version = excluded.version,
encrypted_secret = excluded.encrypted_secret,
kem_ciphertext = excluded.kem_ciphertext,
wrapping_scheme = excluded.wrapping_scheme,
created_at = excluded.created_at
"#,
params![
record.recipient_user_id,
record.chat_id,
record.sender_user_id,
record.secret_id,
record.version,
record.encrypted_secret,
record.kem_ciphertext,
record.wrapping_scheme,
record.created_at,
],
)?;
Ok(())
})
}
pub fn get_pending_chat_secret_forwards(
limit: i64,
) -> Result<Vec<PendingChatSecretForward>, StorageError> {
db::with_conn(&E2EE_DB, |conn| {
let mut stmt = conn.prepare(
r#"
SELECT recipient_user_id, chat_id, sender_user_id, secret_id, version,
encrypted_secret, kem_ciphertext, wrapping_scheme, created_at
FROM pending_chat_secret_forwards
ORDER BY created_at ASC
LIMIT ?1
"#,
)?;
let rows = stmt.query_map(params![limit.clamp(1, 500)], pending_forward_from_row)?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
})
}
pub fn delete_pending_chat_secret_forward(
recipient_user_id: &str,
chat_id: &str,
secret_id: &str,
) -> Result<(), StorageError> {
db::with_conn(&E2EE_DB, |conn| {
conn.execute(
r#"
DELETE FROM pending_chat_secret_forwards
WHERE recipient_user_id = ?1 AND chat_id = ?2 AND secret_id = ?3
"#,
params![recipient_user_id, chat_id, secret_id],
)?;
Ok(())
})
}
/// Erase every E2EE record owned by, or queued for, a user. The operation is
/// Erase every E2EE record owned by a user. The operation is
/// intentionally idempotent so it can be retried after an interrupted remote
/// erasure request.
pub fn purge_user(user_id: i64) -> Result<(), StorageError> {
let user_id = user_id.to_string();
db::with_conn(&E2EE_DB, |conn| {
let tx = conn.unchecked_transaction()?;
tx.execute("DELETE FROM chat_secrets WHERE user_id = ?1", params![user_id])?;
tx.execute(
"DELETE FROM pending_chat_secret_forwards WHERE recipient_user_id = ?1 OR sender_user_id = ?1",
"DELETE FROM chat_secrets WHERE user_id = ?1",
params![user_id],
)?;
tx.commit()?;
@ -242,17 +140,3 @@ fn chat_secret_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredChatS
updated_at: row.get(8)?,
})
}
fn pending_forward_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PendingChatSecretForward> {
Ok(PendingChatSecretForward {
recipient_user_id: row.get(0)?,
chat_id: row.get(1)?,
sender_user_id: row.get(2)?,
secret_id: row.get(3)?,
version: row.get(4)?,
encrypted_secret: row.get(5)?,
kem_ciphertext: row.get(6)?,
wrapping_scheme: row.get(7)?,
created_at: row.get(8)?,
})
}

View file

@ -4,5 +4,7 @@ pub mod communities_util;
pub mod config_util;
pub mod db;
pub mod e2ee_storage;
pub mod relay_queue;
pub mod relay_replay;
pub mod settings;
pub mod sync;

View file

@ -0,0 +1,135 @@
use crate::storage_error::StorageError;
use crate::util::db;
use iota_util::route_target::RouteTarget;
use rusqlite::params;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingRelay {
pub id: i64,
pub target: RouteTarget,
pub frame: Vec<u8>,
pub created_at: i64,
pub frame_id: u32,
pub type_map_version: String,
}
pub fn enqueue(
target: RouteTarget,
frame: &[u8],
created_at: i64,
frame_id: u32,
type_map_version: &str,
) -> Result<(), StorageError> {
let destination_id = i64::try_from(target.id())
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
let target_kind = match target {
RouteTarget::User(_) => 0_i64,
RouteTarget::Iota(_) => 1_i64,
};
db::with_db(|connection| {
connection.execute(
"INSERT OR IGNORE INTO pending_relays (destination_id, target_kind, frame, created_at, frame_id, type_map_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
destination_id,
target_kind,
frame,
created_at,
i64::from(frame_id),
type_map_version
],
)?;
Ok(())
})
}
pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
db::with_db(|connection| {
let mut statement = connection.prepare(
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version FROM pending_relays ORDER BY id LIMIT ?1",
)?;
let rows = statement.query_map(params![limit.clamp(1, 500)], |row| {
let destination_id = row.get::<_, i64>(1)?;
let target_kind = row.get::<_, i64>(2)?;
let destination_id = u64::try_from(destination_id).map_err(|_| {
rusqlite::Error::FromSqlConversionFailure(
1,
rusqlite::types::Type::Integer,
"negative relay destination ID".into(),
)
})?;
let target = match target_kind {
0 => RouteTarget::User(destination_id),
1 => RouteTarget::Iota(destination_id),
_ => {
return Err(rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Integer,
"invalid relay target kind".into(),
));
}
};
Ok(PendingRelay {
id: row.get(0)?,
target,
frame: row.get(3)?,
created_at: row.get(4)?,
frame_id: u32::try_from(row.get::<_, i64>(5)?).map_err(|_| {
rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Integer,
"negative relay frame ID".into(),
)
})?,
type_map_version: row.get(6)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
})
}
pub fn acknowledge(destination_id: u64, frame_id: u32) -> Result<bool, StorageError> {
let destination_id = i64::try_from(destination_id)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_db(|connection| {
let changed = connection.execute(
"DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 0 AND frame_id = ?2",
params![destination_id, i64::from(frame_id)],
)?;
Ok(changed == 1)
})
}
pub fn acknowledge_iota(destination_id: u64, frame_id: u32) -> Result<bool, StorageError> {
let destination_id = i64::try_from(destination_id)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_db(|connection| {
let changed = connection.execute(
"DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = 1 AND frame_id = ?2",
params![destination_id, i64::from(frame_id)],
)?;
Ok(changed == 1)
})
}
pub fn remove_for_frame(target: RouteTarget, frame_id: u32) -> Result<bool, StorageError> {
let destination_id = i64::try_from(target.id())
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
let target_kind = match target {
RouteTarget::User(_) => 0_i64,
RouteTarget::Iota(_) => 1_i64,
};
db::with_db(|connection| {
let changed = connection.execute(
"DELETE FROM pending_relays WHERE destination_id = ?1 AND target_kind = ?2 AND frame_id = ?3",
params![destination_id, target_kind, i64::from(frame_id)],
)?;
Ok(changed == 1)
})
}
pub fn delete(id: i64) -> Result<(), StorageError> {
db::with_db(|connection| {
connection.execute("DELETE FROM pending_relays WHERE id = ?1", params![id])?;
Ok(())
})
}

View file

@ -0,0 +1,152 @@
use crate::storage_error::StorageError;
use crate::util::db;
use rusqlite::params;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayReservation {
New,
Existing { state: String, frame_matches: bool },
}
pub fn reserve(
signer_id: u64,
message_id: &str,
created_at: u64,
destination_id: u64,
frame: &[u8],
frame_id: u32,
type_map_version: &str,
) -> Result<RelayReservation, StorageError> {
let signer_id = i64::try_from(signer_id)
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
let created_at = i64::try_from(created_at)
.map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?;
let destination_id = i64::try_from(destination_id)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_db(|connection| {
let inserted = connection.execute(
"INSERT OR IGNORE INTO relay_inbox (signer_id, message_id, created_at, destination_id, frame, frame_id, type_map_version, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'received')",
params![
signer_id,
message_id,
created_at,
destination_id,
frame,
i64::from(frame_id),
type_map_version
],
)?;
if inserted == 1 {
return Ok(RelayReservation::New);
}
let (state, existing_destination_id, existing_frame, existing_type_map_version):
(String, i64, Vec<u8>, String) = connection.query_row(
"SELECT state, destination_id, frame, type_map_version FROM relay_inbox WHERE signer_id = ?1 AND message_id = ?2",
params![signer_id, message_id],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get::<_, Vec<u8>>(2)?,
row.get(3)?,
))
},
)?;
Ok(RelayReservation::Existing {
state,
frame_matches: existing_destination_id == destination_id
&& existing_frame == frame
&& existing_type_map_version == type_map_version,
})
})
}
pub fn mark_delivered_for_frame(destination_id: u64, frame_id: u32) -> Result<(), StorageError> {
let destination_id = i64::try_from(destination_id)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
db::with_db(|connection| {
connection.execute(
"UPDATE relay_inbox SET state = 'delivered' WHERE destination_id = ?1 AND frame_id = ?2",
params![destination_id, i64::from(frame_id)],
)?;
Ok(())
})
}
pub fn mark_state(signer_id: u64, message_id: &str, state: &str) -> Result<(), StorageError> {
if !matches!(
state,
"received" | "applied" | "queued" | "delivered" | "rejected"
) {
return Err(StorageError::Other("invalid relay inbox state".into()));
}
let signer_id = i64::try_from(signer_id)
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
db::with_db(|connection| {
connection.execute(
"UPDATE relay_inbox SET state = ?3 WHERE signer_id = ?1 AND message_id = ?2",
params![signer_id, message_id, state],
)?;
Ok(())
})
}
pub fn prune_completed(before_created_at: i64) -> Result<(), StorageError> {
db::with_db(|connection| {
connection.execute(
"DELETE FROM relay_inbox WHERE created_at < ?1 AND state IN ('delivered', 'rejected')",
params![before_created_at],
)?;
connection.execute(
"DELETE FROM relay_replay WHERE created_at < ?1",
params![before_created_at],
)?;
Ok(())
})
}
pub fn accept(signer_id: u64, message_id: &str, created_at: u64) -> Result<bool, StorageError> {
let signer_id = i64::try_from(signer_id)
.map_err(|_| StorageError::Other("relay signer ID exceeds SQLite range".into()))?;
let created_at = i64::try_from(created_at)
.map_err(|_| StorageError::Other("relay creation time exceeds SQLite range".into()))?;
db::with_db(|connection| {
let inserted = connection.execute(
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
params![signer_id, message_id, created_at],
)?;
Ok(inserted == 1)
})
}
#[cfg(test)]
mod tests {
use rusqlite::{Connection, params};
#[test]
fn replay_identity_uses_signer_and_message_id() -> Result<(), rusqlite::Error> {
let connection = Connection::open_in_memory()?;
connection.execute_batch(
"CREATE TABLE relay_replay (signer_id INTEGER NOT NULL, message_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (signer_id, message_id));",
)?;
let first = connection.execute(
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
params![7_i64, "message", 1_i64],
)?;
let duplicate = connection.execute(
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
params![7_i64, "message", 2_i64],
)?;
let other_signer = connection.execute(
"INSERT OR IGNORE INTO relay_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)",
params![8_i64, "message", 2_i64],
)?;
assert_eq!((first, duplicate, other_signer), (1, 0, 1));
Ok(())
}
}

View file

@ -1,5 +1,50 @@
use base64::{Engine as _, engine::general_purpose::STANDARD};
use mtp::crypto::{EncryptionType, Keyring, PublicKeyBundle, decrypt_with, encrypt_for};
use mtp::crypto::{
EncryptionType, Keyring, MultiEncryptedMessage, PublicKeyBundle, decrypt_multi_for,
encrypt_multi_for,
};
const CHALLENGE_PURPOSE: u8 = 0x01;
const LEGACY_AAD_DOMAIN: &[u8] = b"IOTA-MTP-AAD-1";
fn bind_aad(plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
let aad_len = u32::try_from(aad.len())
.map_err(|_| "associated data is too large to encode".to_string())?;
let mut bound = Vec::with_capacity(
LEGACY_AAD_DOMAIN
.len()
.saturating_add(4)
.saturating_add(aad.len())
.saturating_add(plaintext.len()),
);
bound.extend_from_slice(LEGACY_AAD_DOMAIN);
bound.extend_from_slice(&aad_len.to_be_bytes());
bound.extend_from_slice(aad);
bound.extend_from_slice(plaintext);
Ok(bound)
}
fn unbind_aad(bound: &[u8], aad: &[u8]) -> Result<Vec<u8>, String> {
let header_len = LEGACY_AAD_DOMAIN.len() + 4;
if bound.len() < header_len || &bound[..LEGACY_AAD_DOMAIN.len()] != LEGACY_AAD_DOMAIN {
return Err("associated-data binding is invalid".to_string());
}
let length_start = LEGACY_AAD_DOMAIN.len();
let length_end = length_start + 4;
let aad_len = u32::from_be_bytes(
bound[length_start..length_end]
.try_into()
.map_err(|_| "associated-data length is invalid".to_string())?,
) as usize;
let aad_start = length_end;
let aad_end = aad_start
.checked_add(aad_len)
.ok_or_else(|| "associated-data length overflows".to_string())?;
if aad_end > bound.len() || &bound[aad_start..aad_end] != aad {
return Err("associated data does not match".to_string());
}
Ok(bound[aad_end..].to_vec())
}
#[derive(Clone, Copy, Debug)]
pub enum DataFormat {
@ -13,17 +58,25 @@ pub fn encrypt(
aad: &[u8],
recipient_pub_key_bundle: &PublicKeyBundle,
) -> Result<Vec<u8>, String> {
encrypt_for(
let bound_plaintext = bind_aad(plaintext, aad)?;
let encrypted = encrypt_multi_for(
EncryptionType::MlKemChaCha20Poly1305,
recipient_pub_key_bundle,
plaintext,
aad,
CHALLENGE_PURPOSE,
&bound_plaintext,
std::slice::from_ref(recipient_pub_key_bundle),
)
.map_err(|e| format!("encryption error: {:?}", e))
.map_err(|e| format!("encryption error: {e:?}"))?;
encrypted
.to_bytes()
.map_err(|e| format!("encryption encoding error: {e:?}"))
}
pub fn decrypt(ciphertext: &[u8], aad: &[u8], keyring: &Keyring) -> Result<Vec<u8>, String> {
decrypt_with(ciphertext, keyring, aad).map_err(|e| format!("decryption error: {:?}", e))
let message = MultiEncryptedMessage::from_bytes(ciphertext)
.map_err(|e| format!("decryption envelope error: {e:?}"))?;
let bound_plaintext = decrypt_multi_for(&message, CHALLENGE_PURPOSE, keyring)
.map_err(|e| format!("decryption error: {e:?}"))?;
unbind_aad(&bound_plaintext, aad)
}
pub fn encrypt_challenge(
@ -51,3 +104,18 @@ pub fn export(data: &[u8], format: DataFormat) -> Result<String, String> {
DataFormat::Hex => Ok(hex::encode(data)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_binds_associated_data() -> Result<(), String> {
let keyring = Keyring::generate();
let ciphertext = encrypt(b"challenge", b"context", &keyring.public_key_bundle())?;
assert_eq!(decrypt(&ciphertext, b"context", &keyring)?, b"challenge");
assert!(decrypt(&ciphertext, b"other-context", &keyring).is_err());
Ok(())
}
}

View file

@ -44,7 +44,9 @@ pub fn delete_user_directory(user_id: i64) -> io::Result<()> {
}
pub fn credential_path(user_id: i64) -> PathBuf {
storage_directory().join("credentials").join(format!("{user_id}.tu"))
storage_directory()
.join("credentials")
.join(format!("{user_id}.tu"))
}
pub fn read_user_credential(user_id: i64) -> io::Result<Option<String>> {
@ -58,7 +60,10 @@ pub fn read_user_credential(user_id: i64) -> io::Result<Option<String>> {
/// Resolve a credential by immutable account id. A valid legacy
/// `<username>.tu` is migrated atomically the first time it is encountered.
pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Result<Option<String>> {
pub fn read_user_credential_with_legacy(
user_id: i64,
username: &str,
) -> io::Result<Option<String>> {
if let Some(credential) = read_user_credential(user_id)? {
return Ok(Some(credential));
}
@ -71,7 +76,10 @@ pub fn read_user_credential_with_legacy(user_id: i64, username: &str) -> io::Res
let parsed = crate::tu::TuCredential::parse(&credential)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
if parsed.user_id != user_id {
return Err(io::Error::new(io::ErrorKind::InvalidData, "legacy credential user id mismatch"));
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"legacy credential user id mismatch",
));
}
write_user_credential(user_id, &parsed.to_canonical_string())?;
fs::remove_file(legacy)?;

View file

@ -1,4 +1,6 @@
pub mod crypto_helper;
pub mod crypto_util;
pub mod file_util;
pub mod mtp_compat;
pub mod route_target;
pub mod tu;

View file

@ -0,0 +1,67 @@
use mtp::codec::{CommunicationValue, DataValue};
use mtp::type_map::DataTypeId;
/*
* Keep legacy control-plane handlers source-compatible while they migrate to
* MTP's explicit optional routing fields. Relay handlers must use sender() and
* receiver() directly so an absent outer sender cannot become an identity.
*/
pub trait CommunicationValueCompat {
fn get_id(&self) -> u32;
fn get_sender(&self) -> u64;
fn get_receiver(&self) -> u64;
}
impl CommunicationValueCompat for CommunicationValue {
fn get_id(&self) -> u32 {
self.id().unwrap_or_default()
}
fn get_sender(&self) -> u64 {
self.sender().unwrap_or_default()
}
fn get_receiver(&self) -> u64 {
self.receiver().unwrap_or_default()
}
}
pub trait OptionalDataValueExt<'a> {
fn as_bool(self) -> Option<bool>;
fn as_str(self) -> Option<&'a str>;
fn as_string(self) -> Option<String>;
fn as_number(self) -> Option<i128>;
fn as_signed_number(self) -> Option<i128>;
fn as_array(self) -> Option<Vec<DataValue>>;
fn as_container(self) -> Option<Vec<(DataTypeId, DataValue)>>;
}
impl<'a> OptionalDataValueExt<'a> for Option<&'a DataValue> {
fn as_bool(self) -> Option<bool> {
self.and_then(DataValue::as_bool)
}
fn as_str(self) -> Option<&'a str> {
self.and_then(DataValue::as_str)
}
fn as_string(self) -> Option<String> {
self.and_then(DataValue::as_string)
}
fn as_number(self) -> Option<i128> {
self.and_then(DataValue::as_number)
}
fn as_signed_number(self) -> Option<i128> {
self.and_then(DataValue::as_signed_number)
}
fn as_array(self) -> Option<Vec<DataValue>> {
self.and_then(DataValue::as_array)
}
fn as_container(self) -> Option<Vec<(DataTypeId, DataValue)>> {
self.and_then(DataValue::as_container)
}
}

View file

@ -0,0 +1,44 @@
const TARGET_KIND_MASK: u64 = 0xC000_0000_0000_0000;
const TARGET_ID_MASK: u64 = (1_u64 << 48) - 1;
const USER_TARGET_KIND: u64 = 0x4000_0000_0000_0000;
const IOTA_TARGET_KIND: u64 = 0x8000_0000_0000_0000;
/*
* Relay receivers carry their namespace in the wire identity. This prevents
* a user ID and an Iota ID with the same numeric value from selecting the
* wrong connection at an Omikron.
*/
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteTarget {
User(u64),
Iota(u64),
}
impl RouteTarget {
pub fn wire_id(self) -> Option<u64> {
let (kind, id) = match self {
Self::User(id) => (USER_TARGET_KIND, id),
Self::Iota(id) => (IOTA_TARGET_KIND, id),
};
(id > 0 && id <= TARGET_ID_MASK).then_some(kind | id)
}
pub fn from_wire_id(value: u64) -> Option<Self> {
let id = value & TARGET_ID_MASK;
if id == 0 || value & !(TARGET_KIND_MASK | TARGET_ID_MASK) != 0 {
return None;
}
match value & TARGET_KIND_MASK {
USER_TARGET_KIND => Some(Self::User(id)),
IOTA_TARGET_KIND => Some(Self::Iota(id)),
_ => None,
}
}
pub const fn id(self) -> u64 {
match self {
Self::User(id) | Self::Iota(id) => id,
}
}
}

View file

@ -47,7 +47,10 @@ impl fmt::Debug for TuCredential {
impl TuCredential {
pub fn parse(input: &str) -> Result<Self, TuError> {
let (identity, encoded_keyring) = input.trim().split_once("::").ok_or(TuError::InvalidFormat)?;
let (identity, encoded_keyring) = input
.trim()
.split_once("::")
.ok_or(TuError::InvalidFormat)?;
if encoded_keyring.is_empty() || encoded_keyring.contains("::") {
return Err(TuError::InvalidFormat);
}
@ -60,7 +63,11 @@ impl TuCredential {
return Err(TuError::InvalidUserId);
}
let keyring = keyring_from_base64(encoded_keyring).ok_or(TuError::InvalidKeyring)?;
Ok(Self { user_id, omega_host: omega_host.trim().to_owned(), keyring })
Ok(Self {
user_id,
omega_host: omega_host.trim().to_owned(),
keyring,
})
}
pub fn public_key_bundle(&self) -> PublicKeyBundle {
@ -68,7 +75,12 @@ impl TuCredential {
}
pub fn to_canonical_string(&self) -> String {
format!("{}@{}::{}", self.user_id, self.omega_host, keyring_to_base64(&self.keyring))
format!(
"{}@{}::{}",
self.user_id,
self.omega_host,
keyring_to_base64(&self.keyring)
)
}
}
@ -79,16 +91,31 @@ mod tests {
#[test]
fn round_trip_is_canonical() {
let credential = TuCredential { user_id: 42, omega_host: "omega.example:443".into(), keyring: generate_keyring() };
let credential = TuCredential {
user_id: 42,
omega_host: "omega.example:443".into(),
keyring: generate_keyring(),
};
let parsed = TuCredential::parse(&credential.to_canonical_string()).unwrap();
assert_eq!(parsed.user_id, 42);
assert_eq!(parsed.omega_host, "omega.example:443");
assert_eq!(parsed.to_canonical_string(), credential.to_canonical_string());
assert_eq!(
parsed.to_canonical_string(),
credential.to_canonical_string()
);
}
#[test]
fn rejects_malformed_credentials() {
for value in ["", "1@omega", "@omega::abc", "0@omega::abc", "281474976710656@omega::abc", "1@::abc", "1@omega::abc::def"] {
for value in [
"",
"1@omega",
"@omega::abc",
"0@omega::abc",
"281474976710656@omega::abc",
"1@::abc",
"1@omega::abc::def",
] {
assert!(TuCredential::parse(value).is_err(), "{value}");
}
}

View file

@ -13,10 +13,8 @@ impl Default for ColorConfig {
impl ColorConfig {
pub fn new() -> Self {
let enabled = env::var("NO_COLOR").is_err()
&& env::var("TERM")
.map(|t| t != "dumb")
.unwrap_or(true);
let enabled =
env::var("NO_COLOR").is_err() && env::var("TERM").map(|t| t != "dumb").unwrap_or(true);
Self { enabled }
}

View file

@ -386,17 +386,11 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
fn print_help() {
let color = cli_color::ColorConfig::new();
println!(
"{}",
cli_color::heading(&color, "Iota Operator Console")
);
println!("{}", cli_color::heading(&color, "Iota Operator Console"));
println!();
println!("Usage: iota [OPTIONS] [COMMAND]");
println!();
println!(
"{}",
cli_color::info(&color, "Commands:")
);
println!("{}", cli_color::info(&color, "Commands:"));
println!(" (no command) Launch the interactive dashboard");
println!(" status Show daemon status");
println!(" tasks List active tasks");
@ -432,10 +426,7 @@ fn print_help() {
println!(" completions <SHELL> Generate shell completions");
println!(" man Show the man page");
println!();
println!(
"{}",
cli_color::info(&color, "Options:")
);
println!("{}", cli_color::info(&color, "Options:"));
println!(" --theme <THEME> Theme: monospace, binary, ansi, surface");
println!(" --output <FORMAT> Output format: text, json, yaml, table");
println!(" --color <WHEN> Color: auto, always, never");
@ -445,10 +436,7 @@ fn print_help() {
println!(" -h, --help Show help");
println!(" -V, --version Show version");
println!();
println!(
"{}",
cli_color::info(&color, "Examples:")
);
println!("{}", cli_color::info(&color, "Examples:"));
println!(" iota Launch the interactive dashboard");
println!(" iota status Show daemon status");
println!(" iota users list --output=json List users in JSON format");
@ -458,19 +446,13 @@ fn print_help() {
println!(" iota logs --limit 50 Show last 50 log entries");
println!(" iota completions bash Generate bash completions");
println!();
println!(
"{}",
cli_color::info(&color, "Exit Codes:")
);
println!("{}", cli_color::info(&color, "Exit Codes:"));
println!(" 0 Success");
println!(" 1 General error");
println!(" 2 Invalid command or arguments");
println!(" 130 Interrupted (Ctrl+C)");
println!();
println!(
"{}",
cli_color::muted(&color, "Environment Variables:")
);
println!("{}", cli_color::muted(&color, "Environment Variables:"));
println!(" NO_COLOR Disable colored output when set");
println!(" TERM Terminal type (dumb disables colors)");
println!(" IOTA_THEME Default theme override");
@ -566,13 +548,26 @@ async fn run_command(
Command::Tasks => LocalRequest::ListTasks,
Command::UsersList => LocalRequest::ListUsers,
Command::UsersShow { user_id } => LocalRequest::GetUser { user_id },
Command::UsersAdd { username: Some(username), tu: None } => LocalRequest::CreateUser { username },
Command::UsersAdd { username: None, tu: Some(path) } => {
let contents = std::fs::read_to_string(&path)
.map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?;
iota_util::tu::TuCredential::parse(&contents)
.map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?;
LocalRequest::AttachUserFromTu { credential: iota_ipc::SecretString(contents) }
Command::UsersAdd {
username: Some(username),
tu: None,
} => LocalRequest::CreateUser { username },
Command::UsersAdd {
username: None,
tu: Some(path),
} => {
let contents = std::fs::read_to_string(&path).map_err(|error| {
StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display()))
})?;
iota_util::tu::TuCredential::parse(&contents).map_err(|error| {
StartupError::InvalidCommand(format!(
"Invalid credential {}: {error}",
path.display()
))
})?;
LocalRequest::AttachUserFromTu {
credential: iota_ipc::SecretString(contents),
}
}
Command::UsersAdd { .. } => {
return Err(StartupError::InvalidCommand(
@ -583,22 +578,43 @@ async fn run_command(
user_id,
confirmed: true,
} => LocalRequest::ReleaseUser { user_id },
Command::UsersPurgeData { user_id, confirmed: true } => LocalRequest::PurgeUserData { user_id },
Command::UsersCompleteDelete { user_id, tu, confirmed: true } => {
Command::UsersPurgeData {
user_id,
confirmed: true,
} => LocalRequest::PurgeUserData { user_id },
Command::UsersCompleteDelete {
user_id,
tu,
confirmed: true,
} => {
let credential = match tu {
Some(path) => {
let contents = std::fs::read_to_string(&path)
.map_err(|error| StartupError::InvalidCommand(format!("Cannot read {}: {error}", path.display())))?;
let parsed = iota_util::tu::TuCredential::parse(&contents)
.map_err(|error| StartupError::InvalidCommand(format!("Invalid credential {}: {error}", path.display())))?;
let contents = std::fs::read_to_string(&path).map_err(|error| {
StartupError::InvalidCommand(format!(
"Cannot read {}: {error}",
path.display()
))
})?;
let parsed =
iota_util::tu::TuCredential::parse(&contents).map_err(|error| {
StartupError::InvalidCommand(format!(
"Invalid credential {}: {error}",
path.display()
))
})?;
if parsed.user_id != user_id {
return Err(StartupError::InvalidCommand("credential user ID does not match complete-delete target".into()));
return Err(StartupError::InvalidCommand(
"credential user ID does not match complete-delete target".into(),
));
}
Some(iota_ipc::SecretString(contents))
}
None => None,
};
LocalRequest::CompleteDeleteUser { user_id, credential }
LocalRequest::CompleteDeleteUser {
user_id,
credential,
}
}
Command::OmikronReconnect => LocalRequest::ReconnectOmikron,
Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity,
@ -621,8 +637,12 @@ async fn run_command(
Command::UsersRelease {
confirmed: false, ..
}
| Command::UsersPurgeData { confirmed: false, .. }
| Command::UsersCompleteDelete { confirmed: false, .. }
| Command::UsersPurgeData {
confirmed: false, ..
}
| Command::UsersCompleteDelete {
confirmed: false, ..
}
| Command::IdentityRotate { confirmed: false }
| Command::RegenerateKeys { confirmed: false }
| Command::DaemonRestart { confirmed: false }
@ -715,14 +735,14 @@ async fn run_command(
);
}
ResponsePayload::UserRemoved { user_id } => {
println!(
"{} {}",
cli_color::warning(&color, "Removed user"),
user_id
);
println!("{} {}", cli_color::warning(&color, "Removed user"), user_id);
}
ResponsePayload::UserDataPurged { user_id } => {
println!("{} hosted data for {}. Account remains managed by this Iota.", cli_color::success(&color, "Purged"), user_id);
println!(
"{} hosted data for {}. Account remains managed by this Iota.",
cli_color::success(&color, "Purged"),
user_id
);
}
ResponsePayload::Acknowledged { message } => {
println!("{}", message);
@ -740,11 +760,7 @@ async fn run_command(
status.connected
);
if let Some(id) = status.iota_id {
println!(
"{}: {}",
cli_color::info(&color, "Iota ID"),
id
);
println!("{}: {}", cli_color::info(&color, "Iota ID"), id);
}
}
ResponsePayload::Components(components) => {
@ -756,9 +772,7 @@ async fn run_command(
} else {
for comp in &components {
let (status_str, style) = match comp.status {
iota_ipc::HealthStatus::Healthy => {
("healthy", cli_color::SUCCESS)
}
iota_ipc::HealthStatus::Healthy => ("healthy", cli_color::SUCCESS),
iota_ipc::HealthStatus::Degraded => {
("degraded", cli_color::WARNING)
}
@ -811,15 +825,9 @@ async fn run_command(
}
ResponsePayload::UpdateStatus(status) => {
if status.available {
println!(
"{}",
cli_color::success(&color, "Update available.")
);
println!("{}", cli_color::success(&color, "Update available."));
} else {
println!(
"{}",
cli_color::info(&color, "Up to date.")
);
println!("{}", cli_color::info(&color, "Up to date."));
}
}
ResponsePayload::Communities(communities) => {
@ -827,11 +835,7 @@ async fn run_command(
println!("{}", cli_color::muted(&color, "No communities."));
} else {
for c in &communities {
println!(
"{} ({})",
cli_color::heading(&color, &c.title),
c.name
);
println!("{} ({})", cli_color::heading(&color, &c.title), c.name);
}
}
}
@ -905,7 +909,12 @@ fn render_table(payload: &ResponsePayload) {
iota_ipc::HealthStatus::Failed => "failed",
};
let message = comp.message.as_deref().unwrap_or("-");
println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message);
println!(
"{:<20} {:<10} {}",
format!("{:?}", comp.id),
status_str,
message
);
}
}
ResponsePayload::Communities(communities) => {
@ -924,8 +933,14 @@ fn render_table(payload: &ResponsePayload) {
println!("No log entries.");
return;
}
println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE");
println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------");
println!(
"{:<20} {:<6} {:<12} {}",
"TIMESTAMP", "LEVEL", "SENDER", "MESSAGE"
);
println!(
"{:<20} {:<6} {:<12} {}",
"--------", "--------", "--------", "--------"
);
for entry in &logs.entries {
let level = if entry.is_error { "ERR" } else { "INF" };
println!(

View file

@ -43,34 +43,24 @@ impl StartupError {
Self::SystemPermissionDenied(_) => {
Some("Run with appropriate privileges or use a user-level daemon instead.")
}
Self::SocketPermissionDenied(_) => {
Some(
Self::SocketPermissionDenied(_) => Some(
"Check file permissions on the socket or ensure the daemon is running as your user.",
)
}
Self::IpcTimedOut(_) => {
Some(
),
Self::IpcTimedOut(_) => Some(
"The daemon may be starting up. Wait a moment and try again, or check daemon logs.",
)
}
Self::ProtocolMismatch { .. } => {
Some("Update your CLI or daemon to match versions.")
}
Self::DaemonExited { .. } => {
Some("Restart the daemon with `iota daemon restart`.")
}
Self::IpcBindUnavailable(_) => {
Some("Another instance may be running. Stop it first or use a different socket path.")
}
),
Self::ProtocolMismatch { .. } => Some("Update your CLI or daemon to match versions."),
Self::DaemonExited { .. } => Some("Restart the daemon with `iota daemon restart`."),
Self::IpcBindUnavailable(_) => Some(
"Another instance may be running. Stop it first or use a different socket path.",
),
Self::Terminal(_) => {
Some("Use a terminal that supports interactive mode, or run commands headlessly.")
}
Self::Consent(_) => {
Some("Run `iota terms accept` in an interactive terminal to review and accept terms.")
}
Self::InvalidCommand(_) => {
Some("Run `iota --help` to see available commands.")
}
Self::Consent(_) => Some(
"Run `iota terms accept` in an interactive terminal to review and accept terms.",
),
Self::InvalidCommand(_) => Some("Run `iota --help` to see available commands."),
_ => None,
}
}
@ -149,8 +139,16 @@ mod tests {
}
#[test]
fn most_errors_have_suggestions() {
assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some());
assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some());
assert!(
StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon"))
.suggestion()
.is_some()
);
assert!(
StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock"))
.suggestion()
.is_some()
);
assert!(StartupError::Cancelled.suggestion().is_none());
}
}

View file

@ -14,6 +14,7 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
"client",
"crypto",
"files",
"raw",
] }
dashmap = "6.2.1"

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ use iota_storage::users::user_profile::UserProfile;
use iota_storage::util::config_util::CONFIG;
use iota_util::crypto_helper::{self, hex_hash, public_key_bundle_to_base64};
use iota_util::file_util::write_user_credential;
use iota_util::mtp_compat::OptionalDataValueExt;
use iota_util::tu::TuCredential;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
@ -33,7 +34,9 @@ pub enum LifecycleUserError {
}
impl From<crate::OmikronError> for LifecycleUserError {
fn from(value: crate::OmikronError) -> Self { Self::Transport(value) }
fn from(value: crate::OmikronError) -> Self {
Self::Transport(value)
}
}
fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> Vec<u8> {
@ -46,18 +49,28 @@ fn lifecycle_payload(domain: &[u8], user_id: i64, iota_id: i64, nonce: u64) -> V
}
fn configured_iota_id() -> Result<i64, LifecycleUserError> {
CONFIG.load().iota_id
CONFIG
.load()
.iota_id
.and_then(|id| i64::try_from(id).ok())
.filter(|id| *id > 0)
.ok_or_else(|| LifecycleUserError::InvalidCredential("Iota identity is not registered".into()))
.ok_or_else(|| {
LifecycleUserError::InvalidCredential("Iota identity is not registered".into())
})
}
fn sign_lifecycle_payload(credential: &TuCredential, payload: &[u8]) -> Result<(Vec<u8>, Vec<u8>), LifecycleUserError> {
fn sign_lifecycle_payload(
credential: &TuCredential,
payload: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), LifecycleUserError> {
let classical = Ed25519Signer::new(&credential.keyring.sig_cl_secret_key)
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?
.sign(payload)
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
let pq = MlDsaSigner::new(&credential.keyring.sig_pq_secret_key, &credential.keyring.sig_pq_public_key)
let pq = MlDsaSigner::new(
&credential.keyring.sig_pq_secret_key,
&credential.keyring.sig_pq_public_key,
)
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?
.sign(payload)
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
@ -71,15 +84,25 @@ async fn inspect_credential_account(
if credential.omega_host != omega_discovery::omega_host() {
return Err(LifecycleUserError::OmegaHostMismatch);
}
let request = CommunicationValue::new(CommunicationType::GetUserData)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into()));
let response = connection.await_response(&request, Duration::from_secs(20)).await?;
let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
DataType::UserId,
DataValue::SignedNumber(credential.user_id.into()),
);
let response = connection
.await_response(&request, Duration::from_secs(20))
.await?;
if !response.is_type(CommunicationType::GetUserData) {
return Err(LifecycleUserError::RemoteRejected);
}
let username = response.get_data(DataType::Username).as_str().map(str::to_owned)
let username = response
.get_data(DataType::Username)
.as_str()
.map(str::to_owned)
.ok_or(LifecycleUserError::RemoteRejected)?;
let public_key = response.get_data(DataType::PublicKey).as_str().map(str::to_owned)
let public_key = response
.get_data(DataType::PublicKey)
.as_str()
.map(str::to_owned)
.ok_or(LifecycleUserError::RemoteRejected)?;
if public_key != public_key_bundle_to_base64(&credential.public_key_bundle()) {
return Err(LifecycleUserError::RemoteRejected);
@ -96,54 +119,121 @@ async fn credential_proof(
domain: &[u8],
) -> Result<(), LifecycleUserError> {
let iota_id = configured_iota_id()?;
let begin_request = CommunicationValue::new(begin)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into()));
let challenge_response = connection.await_response(&begin_request, Duration::from_secs(20)).await?;
let begin_request = CommunicationValue::new(begin).add_typed_default(
DataType::UserId,
DataValue::SignedNumber(credential.user_id.into()),
);
let challenge_response = connection
.await_response(&begin_request, Duration::from_secs(20))
.await?;
if !challenge_response.is_type(challenge) {
return Err(LifecycleUserError::RemoteRejected);
}
let nonce = challenge_response.get_data(DataType::ServerNonce).as_signed_number()
let nonce = challenge_response
.get_data(DataType::ServerNonce)
.as_signed_number()
.and_then(|value| u64::try_from(value).ok())
.ok_or(LifecycleUserError::RemoteRejected)?;
let (signature, pq_signature) = sign_lifecycle_payload(credential, &lifecycle_payload(domain, credential.user_id, iota_id, nonce))?;
let (signature, pq_signature) = sign_lifecycle_payload(
credential,
&lifecycle_payload(domain, credential.user_id, iota_id, nonce),
)?;
let complete_request = CommunicationValue::new(complete)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(credential.user_id.into()))
.add_typed_default(
DataType::UserId,
DataValue::SignedNumber(credential.user_id.into()),
)
.add_typed_default(DataType::ServerNonce, DataValue::SignedNumber(nonce.into()))
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature));
let response = connection.await_response(&complete_request, Duration::from_secs(20)).await?;
if response.is_type(CommunicationType::Success) { Ok(()) } else { Err(LifecycleUserError::RemoteRejected) }
let response = connection
.await_response(&complete_request, Duration::from_secs(20))
.await?;
if response.is_type(CommunicationType::Success) {
Ok(())
} else {
Err(LifecycleUserError::RemoteRejected)
}
}
/// Attach or migrate an existing account. Local state is written only after
/// Omega has accepted the credential proof and changed its assignment.
pub async fn attach_user_from_tu(connection: &dyn OmikronClient, contents: &str) -> Result<UserProfile, LifecycleUserError> {
let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
pub async fn attach_user_from_tu(
connection: &dyn OmikronClient,
contents: &str,
) -> Result<UserProfile, LifecycleUserError> {
let credential = TuCredential::parse(contents)
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
let (username, public_key) = inspect_credential_account(connection, &credential).await?;
credential_proof(connection, &credential, CommunicationType::AttachUserBegin, CommunicationType::AttachUserChallenge, CommunicationType::AttachUserComplete, b"tensamin:user-attach:v1\0").await?;
let profile = UserProfile::new(credential.user_id, username, None, public_key, hex_hash(contents), String::new());
credential_proof(
connection,
&credential,
CommunicationType::AttachUserBegin,
CommunicationType::AttachUserChallenge,
CommunicationType::AttachUserComplete,
b"tensamin:user-attach:v1\0",
)
.await?;
let profile = UserProfile::new(
credential.user_id,
username,
None,
public_key,
hex_hash(contents),
String::new(),
);
write_user_credential(profile.user_id, &credential.to_canonical_string())
.map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?;
try_add_user(profile.clone()).map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?;
try_add_user(profile.clone())
.map_err(|error| LifecycleUserError::LocalPersistence(error.to_string()))?;
Ok(profile)
}
pub async fn complete_delete_user_with_tu(connection: &dyn OmikronClient, contents: &str, expected_user_id: i64) -> Result<(), LifecycleUserError> {
let credential = TuCredential::parse(contents).map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
if credential.user_id != expected_user_id { return Err(LifecycleUserError::InvalidCredential("credential user ID does not match deletion target".into())); }
pub async fn complete_delete_user_with_tu(
connection: &dyn OmikronClient,
contents: &str,
expected_user_id: i64,
) -> Result<(), LifecycleUserError> {
let credential = TuCredential::parse(contents)
.map_err(|error| LifecycleUserError::InvalidCredential(error.to_string()))?;
if credential.user_id != expected_user_id {
return Err(LifecycleUserError::InvalidCredential(
"credential user ID does not match deletion target".into(),
));
}
inspect_credential_account(connection, &credential).await?;
credential_proof(connection, &credential, CommunicationType::DeleteUserCredentialBegin, CommunicationType::DeleteUserCredentialChallenge, CommunicationType::DeleteUserCredentialComplete, b"tensamin:user-delete:v1\0").await
credential_proof(
connection,
&credential,
CommunicationType::DeleteUserCredentialBegin,
CommunicationType::DeleteUserCredentialChallenge,
CommunicationType::DeleteUserCredentialComplete,
b"tensamin:user-delete:v1\0",
)
.await
}
/// Repair local management state after a release or migration committed in
/// Omega but local cleanup was interrupted. Hosted data is retained.
pub async fn reconcile_managed_users(connection: &dyn OmikronClient) {
let Ok(local_iota_id) = configured_iota_id() else { return; };
let Ok(local_iota_id) = configured_iota_id() else {
return;
};
for user in iota_storage::users::user_manager::get_users() {
let request = CommunicationValue::new(CommunicationType::GetUserData)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user.user_id.into()));
let Ok(response) = connection.await_response(&request, Duration::from_secs(10)).await else { continue; };
let remote_iota_id = response.get_data(DataType::IotaId).as_signed_number().and_then(|value| i64::try_from(value).ok());
let request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default(
DataType::UserId,
DataValue::SignedNumber(user.user_id.into()),
);
let Ok(response) = connection
.await_response(&request, Duration::from_secs(10))
.await
else {
continue;
};
let remote_iota_id = response
.get_data(DataType::IotaId)
.as_signed_number()
.and_then(|value| i64::try_from(value).ok());
if remote_iota_id != Some(local_iota_id) {
let _ = iota_storage::users::user_manager::release_user(user.user_id);
}
@ -268,7 +358,12 @@ pub async fn create_user(
log!("Created User");
write_user_credential(
user_id,
&format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64),
&format!(
"{}@{}::{}",
user_id,
omega_discovery::omega_host(),
keyring_b64
),
)
.map_err(|error| CreateUserError::LocalPersistence(error.to_string()))?;
@ -282,6 +377,7 @@ mod tests {
use super::{CreateUserError, request_user_id, valid_username};
use crate::{OmikronClient, OmikronError};
use async_trait::async_trait;
use iota_util::mtp_compat::CommunicationValueCompat;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use std::time::Duration;