commit 627832187378e171ec3c2d077ac00cb01a33bb80 Author: Niko Marmeladkov Date: Sat Aug 22 23:20:41 2026 +0300 niko_trust_gui: TCE protocol client with iced GUI - Byte-exact TCE codec (encoder/decoder, canonical numbers, vectors-tested) - Ed25519 identities with bech32m trust1… addresses - Relay client: challenge/auth handshake, request feed, responses, transparent re-auth on 401 - iced GUI (Material-dark, Solana-style palette): tab shell, approval prompts with native toasts on Linux, address book, history, settings, password change - Portable single-file vault (identity + book + history + settings) - Live 2FA roundtrip examples (send_2fa, await_2fa) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e269752 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Rust / Cargo +/target +**/*.rs.bk + +# Local builds & artifacts +*.exe +/dist + +# Editor / OS noise +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad97889 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5290 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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 = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf" +dependencies = [ + "dirs", + "os_info", + "smappservice-rs", + "thiserror 2.0.20", + "windows-registry", + "windows-result 0.4.1", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +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" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.3.0", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "clipboard_macos" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "clipboard_wayland" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" +dependencies = [ + "smithay-clipboard", +] + +[[package]] +name = "clipboard_x11" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" +dependencies = [ + "thiserror 2.0.20", + "x11rb", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173852283a9a57a3cbe365d86e74dc428a09c50421477d5ad6fe9d9509e37737" +dependencies = [ + "bitflags 2.13.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.37.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cryoglyph" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc795bdbccdbd461736fb163930a009da6597b226d6f6fce33e7a8eb6ec519" +dependencies = [ + "cosmic-text", + "etagere", + "lru", + "rustc-hash 2.1.3", + "wgpu", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iced" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e01026c93ba643f8357a3db3ada0e6555265a377f6f9291c472f6dd701fb3" +dependencies = [ + "iced_core", + "iced_debug", + "iced_futures", + "iced_renderer", + "iced_runtime", + "iced_widget", + "iced_winit", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_core" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ab1937d699403e7e69252ae743a902bcee9f4ab2052cc4c9a46fcf34729d85" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "glam", + "lilt", + "log", + "num-traits", + "rustc-hash 2.1.3", + "smol_str", + "thiserror 2.0.20", + "web-time", +] + +[[package]] +name = "iced_debug" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25035ab0215a620e53f4103e36fc4e59a1fb2817e4bfc38a30ad27b4202ea0be" +dependencies = [ + "iced_core", + "iced_futures", + "log", +] + +[[package]] +name = "iced_futures" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0c85ccad42dfbec7293c36c018af0ea0dbcc52d137a4a9a0b0f6822a3fdf0a" +dependencies = [ + "futures", + "iced_core", + "log", + "rustc-hash 2.1.3", + "tokio", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "iced_graphics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234ca1c2cec4155055f68fa5fad1b5242c496ac8238d80a259bca382fb44a102" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cosmic-text", + "half", + "iced_core", + "iced_futures", + "log", + "raw-window-handle", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_program" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfafec2947cda688d8eb00dac337ba11aa60f9ef6335aed343e189d26e4a673" +dependencies = [ + "iced_graphics", + "iced_runtime", +] + +[[package]] +name = "iced_renderer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250cc0802408e8c077986ec56c7d07c65f423ee658a4b9fd795a1f2aae5dac05" +dependencies = [ + "iced_graphics", + "iced_tiny_skia", + "iced_wgpu", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_runtime" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1889b819ce4c06674183242e336c8d49465665441396914dc07cc86f44fa8d4" +dependencies = [ + "bytes", + "iced_core", + "iced_futures", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "iced_tiny_skia" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0acf8b75a3bc914aff5f2329fdffc1b36eeaea29dda0e4bd232f1c62e9cc3d" +dependencies = [ + "bytemuck", + "cosmic-text", + "iced_debug", + "iced_graphics", + "kurbo", + "log", + "rustc-hash 2.1.3", + "softbuffer", + "tiny-skia", +] + +[[package]] +name = "iced_wgpu" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff144a999b0ca0f8a10257934500060240825c42e950ec0ebee9c8ae30561c13" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "cryoglyph", + "futures", + "glam", + "guillotiere", + "iced_debug", + "iced_graphics", + "log", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "wgpu", +] + +[[package]] +name = "iced_widget" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab" +dependencies = [ + "iced_renderer", + "log", + "num-traits", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "unicode-segmentation", +] + +[[package]] +name = "iced_winit" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7dbedc47562d1de3b9707d939f678b88c382004b7ab5a18f7a7dd723162d75" +dependencies = [ + "iced_debug", + "iced_program", + "log", + "mundy", + "rustc-hash 2.1.3", + "thiserror 2.0.20", + "tracing", + "wasm-bindgen-futures", + "web-sys", + "window_clipboard", + "winit", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +dependencies = [ + "arrayvec", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.2", +] + +[[package]] +name = "lilt" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d4c256f7d9f2dbd633891d48ace853efa5b1554122d9220b172ad9c03c3a9" +dependencies = [ + "web-time", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "cfg-if", + "dispatch", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "niko_trust_gui" +version = "0.1.0" +dependencies = [ + "aead", + "argon2", + "auto-launch", + "base64", + "bech32", + "chacha20poly1305", + "curve25519-dalek", + "dirs", + "ed25519-dalek", + "getrandom 0.4.3", + "hex", + "iced", + "notify-rust", + "serde", + "serde_json", + "sha2", + "subtle", + "ureq", + "windows 0.62.2", + "winrt-notification", + "zeroize", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-security", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smappservice-rs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-service-management", + "thiserror 2.0.20", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strum" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ac893c7d471c8a21f31cfe213ec4f6d9afeed25537c772e08ef3f005f8729e" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.20", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "window_clipboard" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" +dependencies = [ + "clipboard-win", + "clipboard_macos", + "clipboard_wayland", + "clipboard_x11", + "raw-window-handle", + "thiserror 2.0.20", +] + +[[package]] +name = "windows" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f39345ae0c8ab072c0ac7fe8a8b411636aa34f89be19ddd0d9226544f13944" +dependencies = [ + "windows_i686_gnu 0.24.0", + "windows_i686_msvc 0.24.0", + "windows_x86_64_gnu 0.24.0", + "windows_x86_64_msvc 0.24.0", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0866510a3eca9aed73a077490bbbf03e5eaac4e1fd70849d89539e5830501fd" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0ffed56b7e9369a29078d2ab3aaeceea48eb58999d2cff3aa2494a275b95c6" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384a173630588044205a2993b6864a2f56e5a8c1e7668c07b93ec18cf4888dc4" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd8f062d8ca5446358159d79a90be12c543b3a965c847c8f3eedf14b321d399" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winrt-notification" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007a0353840b23e0c6dc73e5b962ff58ed7f6bc9ceff3ce7fe6fbad8d496edf4" +dependencies = [ + "strum", + "windows 0.24.0", + "xml-rs", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa3422d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "niko_trust_gui" +version = "0.1.0" +edition = "2021" +description = "Niko Trust GUI client (trust.n1ko.dev)" +license = "MIT" + +[features] +default = ["gui"] +# "gui" builds the iced desktop app. Disable (--no-default-features) for a fast +# core-only build that skips the heavy GUI dependency tree. +gui = ["dep:iced"] + +[dependencies] +# crypto +sha2 = "0.11" +ed25519-dalek = { version = "3.0", default-features = true } +curve25519-dalek = "5.0" +chacha20poly1305 = "0.11" +aead = "0.6" +argon2 = "0.5" +subtle = "2" +zeroize = { version = "1", features = ["zeroize_derive"] } +getrandom = "0.4" + +# codecs / io +bech32 = "0.12" +hex = "0.4" +base64 = "0.23" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "6" +ureq = { version = "3.4", default-features = true, features = ["json"] } + +# GUI (cross-platform) +# "tokio" gives iced::time::every (the default thread-pool backend has an empty +# time module), so the poll loop has a real timer. +iced = { version = "0.14", optional = true, features = ["tokio"] } + +# autostart (cross-platform: XDG autostart / Windows registry) +auto-launch = "0.6" + +[target.'cfg(target_os = "linux")'.dependencies] +notify-rust = "4.18" + +[target.'cfg(target_os = "windows")'.dependencies] +winrt-notification = "0.5" +windows = "0.62" + +[dev-dependencies] +# nothing yet + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 \ No newline at end of file diff --git a/OPENCODE.md b/OPENCODE.md new file mode 100644 index 0000000..5c53bc8 --- /dev/null +++ b/OPENCODE.md @@ -0,0 +1,191 @@ +# OPENCODE — контекст проекта Niko Trust GUI + +Этот файл — постоянная память контекста. Обновлять при каждом значимом решении/изменении, +чтобы не терять суть между сессиями. + +## Что это + +**Niko Trust GUI** — лёгкий GUI-клиент (Rust, **iced**) к trust-релею +(`~/niko_trust`, Go, `git.n1ko.dev/Niko/niko_trust`). Один бинарник, Linux + Windows. +Для людей, которые не разбираются в криптографии. + +**Product-флоу (история юзера):** +1. Юзер запускает GUI. +2. Первый запуск: создание identity (ed25519), установка пароля, согласие на автозагрузку. +3. Юзер копирует свой адрес (`trust1q...`) и вставляет его в нужную программу/сервис + (например, на свою почту как 2FA). +4. При входе в сервис сервис создаёт `ApprovalRequest` на этот адрес. +5. GUI периодически опрашивает релей и при новом запросе шлёт **нативную нотификацию + с кнопками Approve/Deny**. +6. Юзер жмёт кнопку → GUI подписывает `ApprovalResponse` и кладёт его в релей. +7. Сервис видит ответ (`GET /v1/responses?request=`) и пускает/не пускает юзера. + +## Ключевые пути/ресурсы + +| Что | Где | +|---|---| +| GUI-репо (этот) | `/home/niko/niko_trust_gui` | +| Сервер-репо (Go) | `/home/niko/niko_trust` | +| Протокол (нормативный) | `~/niko_trust/docs/PROTOCOL.md` | +| HTTP API | `~/niko_trust/docs/API.md` | +| Trust-модель | `~/niko_trust/docs/TRUST-MODEL.md` | +| Адреса | `~/niko_trust/docs/ADDRESS.md` | +| Frozen test vectors (эталон) | `~/niko_trust/testdata/vectors/tce_vectors.json` | +| Go-эталон кодирования | `~/niko_trust/internal/protocol/encode.go`, `internal/tce/encoder.go` | + +## Серверы + +- **Production:** `https://trust.n1ko.dev` +- **Тест сейчас:** `http://192.168.31.155:5743` — значение по умолчанию в конфиге GUI. + Настройка переключает на production-пресет. +- **`audience` для подписей не хардкодим** — берём из `GET /v1/config` (`{"audience":"..."}`). +- Инструкция юзера: **если найден баг в сервере — остановиться и сообщить.** + +## Ключевые решения и почему + +1. **GUI = iced** (выбрано юзером, а не egui). Elm-style async (Subscription), + нативный вид, хорошо для poll-потока. Минус: больше кода. +2. **Seed хранится зашифрованным паролем**: `argon2id` (KDF) → + `XChaCha20-Poly1305` (AEAD), файл `0600`. Юзер вводит пароль при каждом запуске. + Безопасность важна: seed подписывает Approval. +3. **Windows-уведомления с кнопками** через `winrt-notification`; + **fallback** (если COM/регистрация нестабильны) — toast без кнопок + попап + в приложении с Approve/Deny. Linux — `notify-rust` (libnotify), кнопки из коробки. +4. **HTTP = `ureq` + rustls** — лёгкий, без openssl, один бинарник, blocking в фоновом + потоке под iced `Subscription`. +5. **Верификация подписей — на клиенте.** `PUT /v1/objects` НЕ проверяет подписи + (INV-5: релей отвечает "кто сказал", не "верить ли"). Значит GUI обязан: + строго декодировать, валидировать pubkey, проверить ed25519 по встроенному в TCE ключу, + применить правила объекта (скев ±120 с, lifetime запроса ≤60 с). +6. **TCE — byte-exact.** Ноль нормализации строк, canonical uvarint, сортировка карт по + сырым байтам, decimal-числа без float. Проверка — сверка с frozen vectors. +7. **Только клиент держит приватный ключ** (server никогда не импортит signer; INV-1). + У нас то же правило: no server-side signing. + +## Rust-эквиваленты Go + +| Go | Rust | +|---|---| +| `crypto/ed25519` (RFC8032, детерм.) | `ed25519-dalek` | +| `filippo.io/edwards25519` (ValidatePubKey) | `curve25519-dalek` | +| `btcutil/bech32` (bech32m) | crate `bech32` (Bech32m) | +| `crypto/sha256` (object_id) | `sha2` | +| `crypto/rand` (nonce/seed) | `rand` + `getrandom` | +| argon2id + XChaCha20-Poly1305 | crates `argon2`, `chacha20poly1305` | +| HTTP | `ureq` (rustls) | + +## Wire-протокол (сводка, детали в PROTOCOL.md) + +- Magic 21 байт `trust.n1ko.dev/tce/1\0`, затем object_tag (1 байт), version uvarint (=1). +- Теги: 0x01 Identity, 0x02 Claim, 0x03 Revocation, 0x04 ApprovalRequest, + 0x05 ApprovalResponse, 0x06 AuthAssertion. +- `object_id = hex(sha256(tce))`. Подпись = Ed25519 над точными TCE-байтами (не над JSON). +- Identity-поле: `uvarint(address_version=0) || uvarint(32) || pubkey(32)` → `00 20 <32>`. +- Строки: `uvarint(len) || utf8`, без контроля, без нормализации, без BOM. +- uvarint canonical (без `81 00`). Таймстампы: uvarint, диапазон 1e9..4102444800, + `0` разрешён только как "no expiry". +- Значения: 0x00 null, 0x01 false, 0x02 true, 0x03 string, 0x04 number(canonical decimal text). +- Карты: `uvarint(count) || (enc_bytes(key)||value)*`, ключи по `[a-z][a-z0-9]*([._-][a-z0-9]+)*`, + сортировка bytewise ascending, дубли запрещены. +- Адрес: bech32m, hrp `trust`, payload = `0x00||pubkey33`, строгий decode (lowercase, no pad bits). +- Полные лимиты полей и объектов — PROTOCOL.md §6.3 (зашиты в encoder/decoder). + +### Поля объектов (строгий порядок!) +- **IdentityRegistration (0x01):** identity, alias(≤64), created_at +- **Claim (0x02):** issuer, subject, claims(map≥1), created_at, expires_at(0=noexp), serial, nonce(16) +- **Revocation (0x03):** issuer, claim_id(32), reason(≤256), created_at, nonce(16) +- **ApprovalRequest (0x04):** sender, recipient, action(≤128), payload(map,может быть пустой), + message(≤256), created_at, expires_at(≤ created_at+60s!), nonce(16) +- **ApprovalResponse (0x05):** request_hash(32), responder, decision(uvarint 0|1), created_at, nonce(16) +- **AuthAssertion (0x06):** identity, challenge(32), scope(≤32), audience(≤128), created_at + +### API-эндпоинты (детали API.md) +- `POST /v1/auth/challenge` → `{"challenge":""}` (rate 30/мин/IP) +- `POST /v1/auth/assert` (Envelope AuthAssertion) → `{"session_token","identity","scope"}` +- `POST /v1/objects` (Envelope) → `{"object_id"}`; idempotent; 1 response/request (422 иначе) +- `GET /v1/objects/{id}`, `GET /v1/claims?subject=`, `GET /v1/requests?recipient=`, + `GET /v1/responses?request=`, `GET /v1/revocations?claim=` + `limit`/`offset` +- Read-эндпоинты: `Authorization: Bearer ` или `?token=`; сессия 30 мин. +- Scope для чтения всего: `"read"` или `"*"` (или точный `read:requests` и т.д.); 401/403. +- `GET /v1/config` (audience), `/v1/healthz`, `/v1/readyz`, `/v1/metrics`. + +### Auth-поток клиента +1. `GET /v1/config` → audience. +2. `POST /v1/auth/challenge` → 32-байт challenge (hex). +3. Собрать `AuthAssertion{identity=mypub, challenge, scope:"*", audience, created_at=now}`. +4. Подписать TCE, `POST /v1/auth/assert` с Envelope `{tce, signature}` → `session_token`. +5. Дальше read-эндпоинты с Bearer. Сессия живёт 30 мин; при 401 → пере-auth. + +### Ответ на запрос +1. GET `/v1/requests?recipient=<мой адрес>&limit=N`. Каждый item = Envelope `{tce, signature}` + object view. +2. Строго декодировать ApprovalRequest из `tce`; извлечь sender, action, message, nonce, created_at, expires_at; + вычислить request_hash = sha256(tce). +3. Проверить подпись: address.ValidatePubKey(sender) + ed25519.Verify(sender, tce, signature). +4. Проверить время: now ∈ [created_at−120s, expires_at+120s]. +5. Показать: message + sender-address + действие. NOT как endorsement от релея. +6. Approve → `ApprovalResponse{request_hash, responder=mypub, decision=1, created_at=now, nonce=rand16}` + → подписать → `POST /v1/objects`. Deny → decision=0. +7. Один ответ на запрос — сервер отклонит второй (422). + +## Тестовые векторы (эталон) + +- Партии: NikoCraft seed=`01`×32, Niko seed=`02`×32 (указы в vectors: pubkey, address). +- Проверки: encoder даёт тот же hex; object_id совпадает; dalek verify подписи ok; + decoder→re-encode тождество; адреса совпадают; таблица canonical numbers; + выборочные rejects на decoder. + +## Статус + +- [x] Изучен сервер-репо, протокол, API, векторы (source review + read-only smoke GET: + healthz/readyz/config ok, claims без токена → честный 401). Видимых багов сервера нет. +- [x] OPENCODE.md создан. +- [x] Скаффолд Cargo (feature `gui` = iced; `cargo test --no-default-features` = быстрое ядро без iced). +- [x] tce (encoder/decoder/number) + address (свой bech32m) + signer (ed25519-dalek 3, is_weak+verify_strict) + + protocol (6 объектов). **Все frozen vectors byte-exact: encode→hex, object_id, подпись (dalek + воспроизводит детерменированные подписи), decode→re-encode, reject-набор, числа, адреса.** clippy чист. +- [x] keyring (argon2id + XChaCha20-Poly1305, файл 0600). **Готово**: `src/keyring.rs`, + `FileFormat{v,kdf,m,t,p,salt,nonce,cipher}` (base64), `aead` добавлен в deps (0.6), + `XNonce::from`, 5 unit-тестов (roundtrip, wrong-password, kdf-params), clippy чист. +- [x] relay + live-тест. **Готово**: `src/relay.rs` + `tests/live_relay.rs` (env `NIKO_RELAY`). + Auth-рукопожатие (config→audience, challenge, AuthAssertion{scope:"*"}, assert→Bearer), + `fetch_requests` (строгая декодировка + validate_pubkey + verify + recipient must = me, + пропускает ядовитые конверты), `respond` (проверка окна запроса ±120с, request_hash=sha256(tce), + POST objects), `store`. **3 live-теста против `192.168.31.155:5743` проходят:** + roundtrip (ответ принят, 2-й ответ на тот же запрос → 422), wrong-recipient отфильтрован, + forged-подпись отброшена. ВАЖНО: у ureq 3.4 API другой (http::Response + + body_mut().read_json()/read_to_string(), `http_status_as_error(false)` в конфиге агента). +- [x] inbox/verify. **Готово**: `src/inbox.rs` — `Inbox` (в памяти): dedup по request_id + (каждый запрос всплывает один раз), история `HistoryItem{request_id_hex, sender(Address), + action, message, created_at, expires_at, outcome}`, `in_window(created,expires,now)` с + ±120s skew (§13.1), outcome Pending/Approved/Denied/TimedOut/Stale. `observe()` возвращает + только actionable (`Pending`); истёкшие/из будущего записываются в историю как TimedOut/Stale. + `Address` получил `PartialEq, Eq`. 3 unit-теста (dedup, window+outcomes, outcome_changes). +- [x] notify + autostart. **Готово**: `src/notify.rs` — `ApprovalNotification{sender,action,message}`, + `show()` (notify-rust, кнопки Approve/Deny, ждёт через отдельный поток), `PromptHandle::wait_timeout`, + `PromptOutcome{Approved,Denied,Ignored}`, `to_decision()`, `interactive_supported()` = `dbus_stack().is_some()`. + `src/autostart.rs` — обёртка `auto-launch` (XDG autostart на Linux, registry на Windows), `APP_NAME="niko-trust"`, + `enable/disable/is_enabled`. Smoke-тест `examples/smoke.rs` отработал на этой машине: dbus есть, + уведомление показывается (кнопки кликнуть некому — демона нет, outcome=Ignored после таймаута), autostart is_enabled=false. + Модули `notify`/`autostart` вне feature-gate `gui` (не зависят от iced). +- [x] UI iced (последний блок). **Готово**: `src/main.rs` (модуль `gui`, feature `gui`; вне gui — стаб + `main` с ошибкой). Сборка: iced 0.14 + фича `tokio` (default-бэкенд `thread-pool` имеет пустой + `time`, без tokio/smol нет `iced::time::every`). Экраны: lock (первый запуск = создание + identity, иначе разблокировка; поле relay URL, default `https://trust.n1ko.dev`) и main + (заголовок с адресом/релеем, статус-строка, карточка запроса с Approve/Deny, история). + Poll-loop: `Subscription` `time::every(5s)` → `Relay::fetch_requests` → `Inbox::observe` → + очередь (`VecDeque`) → текущий `Prompt`. Нативные нотификации: `notify::show` в отдельном + потоке, outcome в UI через `futures::channel::mpsc` + глобальный слот `PROMPT_RX` + + `Subscription::run_with(request_id, |_| iced::stream::channel(...))` (инфраструктура + `iced::stream`/`futures` реэкспортируется iced'ом). Ответ: `Relay::respond` в `Task::perform`, + при истечении окна (проверка `in_window`) помечается TimedOut. **Изменено в ядре**: в + `IncomingRequest` добавлено поле `tce: Vec` (точные байты запроса нужны для request_hash + при ответе; re-encode не byte-exact). Проверено: `cargo build`+`clippy --all-targets` чистые + (и с gui, и `--no-default-features`), 10 lib + 3 tce + 3 live тестов зелёные, **live-тесты + прогнаны против production `NIKO_RELAY=https://trust.n1ko.dev` — проходят**, GUI запускается + (без падений, первый запуск = экран создания). + +## Команды + +- `cargo build` / `cargo test` / `cargo clippy` в `/home/niko/niko_trust_gui`. +- Live-интеграционные тесты: env `NIKO_RELAY=https://trust.n1ko.dev` (или `http://192.168.31.155:5743`). +- Windows: target `x86_64-pc-windows-gnu` установлен; кросс-сборка отдельно. \ No newline at end of file diff --git a/examples/await_2fa.rs b/examples/await_2fa.rs new file mode 100644 index 0000000..4e76206 --- /dev/null +++ b/examples/await_2fa.rs @@ -0,0 +1,103 @@ +//! Full 2FA service flow: post an approval request, then poll for the user's +//! decision until the request window lapses. +//! +//! Usage: +//! cargo run --example await_2fa -- [relay] [action] [message] +//! +//! Exit code: 0 = approved, 4 = denied, 5 = timeout. + +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use niko_trust_gui::address::Address; +use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest}; +use niko_trust_gui::relay::{object_hash, request_id_hex, Relay}; +use niko_trust_gui::signer::Signer; +use niko_trust_gui::tce::{MAX_APPROVAL_LIFETIME, NONCE_SIZE}; + +fn main() { + let mut args = std::env::args().skip(1); + let first = args.next().unwrap_or_default(); + let (base, addr_s) = if first.starts_with("trust1") { + ("https://trust.n1ko.dev".to_string(), first) + } else { + let a = args.next().unwrap_or_else(|| { + eprintln!("usage: await_2fa [relay] [action] [message]"); + std::process::exit(2); + }); + (first.trim_end_matches('/').to_string(), a) + }; + let action = args.next().unwrap_or_else(|| "2fa".to_string()); + let message = args + .next() + .unwrap_or_else(|| "Confirm sign-in (2FA)".to_string()); + + let recipient = Address::parse(&addr_s).expect("invalid recipient address"); + let sender = Signer::generate().expect("generate sender key"); + + let created = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs(); + let mut nonce = [0u8; NONCE_SIZE]; + getrandom::fill(&mut nonce).expect("rng"); + + let req = ApprovalRequest { + sender: sender.pubkey(), + recipient: *recipient.pubkey(), + action, + payload: Default::default(), + message, + created_at: created, + expires_at: created + MAX_APPROVAL_LIFETIME, + nonce, + }; + let tce = encode_approval_request(&req).expect("encode request"); + let sig = sender.sign(&tce); + let req_hash = object_hash(&tce); + + // The "service" authenticates and stores the request. + let agent = Relay::auth(&base, &sender, "*").expect("sender auth"); + let id = agent.store(&tce, &sig).expect("store request"); + println!( + "request {} stored (id {}), waiting up to {MAX_APPROVAL_LIFETIME}s for a decision…", + req.action, id + ); + + // Poll for the response until the window lapses. + let rid = request_id_hex(&tce); + let deadline = Instant::now() + Duration::from_secs(MAX_APPROVAL_LIFETIME + 15); + while Instant::now() < deadline { + std::thread::sleep(Duration::from_secs(2)); + let responses = match agent.fetch_responses(&rid) { + Ok(r) => r, + Err(e) => { + eprintln!("poll error: {e}"); + continue; + } + }; + for inc in responses { + if inc.response.request_hash != req_hash { + continue; // answer to some other request + } + if inc.response.responder != *recipient.pubkey() { + eprintln!( + "warning: response from {}, expected {}", + inc.responder, recipient + ); + continue; + } + match inc.response.decision { + niko_trust_gui::protocol::Decision::Allow => { + println!("DECISION: APPROVED by {}", inc.responder); + std::process::exit(0); + } + niko_trust_gui::protocol::Decision::Deny => { + println!("DECISION: DENIED by {}", inc.responder); + std::process::exit(4); + } + } + } + } + println!("DECISION: TIMEOUT (no answer within the request window)"); + std::process::exit(5); +} diff --git a/examples/send_2fa.rs b/examples/send_2fa.rs new file mode 100644 index 0000000..fd099a8 --- /dev/null +++ b/examples/send_2fa.rs @@ -0,0 +1,59 @@ +//! Send a live approval request ("2FA authentication") to a recipient. +//! +//! Usage: +//! cargo run --example send_2fa -- [relay] [action] [message] +//! +//! Defaults: relay = https://trust.n1ko.dev, action = "2fa". + +use niko_trust_gui::address::Address; +use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest}; +use niko_trust_gui::relay::Relay; +use niko_trust_gui::signer::Signer; +use niko_trust_gui::tce::NONCE_SIZE; + +fn main() { + let mut args = std::env::args().skip(1); + let first = args.next().unwrap_or_default(); + let (base, addr_s) = if first.starts_with("trust1") { + ("https://trust.n1ko.dev".to_string(), first) + } else { + let a = args.next().unwrap_or_else(|| { + eprintln!("usage: send_2fa [relay] [action] [message]"); + std::process::exit(2); + }); + (first.trim_end_matches('/').to_string(), a) + }; + let action = args.next().unwrap_or_else(|| "2fa".to_string()); + let message = args + .next() + .unwrap_or_else(|| "Confirm sign-in (2FA)".to_string()); + + let recipient = Address::parse(&addr_s).expect("invalid recipient address"); + + let sender = Signer::generate().expect("generate sender key"); + let created = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_secs(); + + let mut nonce = [0u8; NONCE_SIZE]; + getrandom::fill(&mut nonce).expect("rng"); + + let req = ApprovalRequest { + sender: sender.pubkey(), + recipient: *recipient.pubkey(), + action, + payload: Default::default(), + message, + created_at: created, + expires_at: created + niko_trust_gui::tce::MAX_APPROVAL_LIFETIME, + nonce, + }; + let tce = encode_approval_request(&req).expect("encode request"); + let sig = sender.sign(&tce); + + let agent = Relay::auth(&base, &sender, "*").expect("sender auth"); + let id = agent.store(&tce, &sig).expect("store request"); + println!("request stored: object id {}", id); + println!("recipient: {}", recipient); +} diff --git a/examples/smoke.rs b/examples/smoke.rs new file mode 100644 index 0000000..e0aeb74 --- /dev/null +++ b/examples/smoke.rs @@ -0,0 +1,32 @@ +use std::time::Duration; + +use niko_trust_gui::autostart::Autostart; +use niko_trust_gui::notify::{show, ApprovalNotification}; + +fn main() { + println!("interactive_supported = {}", niko_trust_gui::notify::interactive_supported()); + + let handle = show(ApprovalNotification { + sender: "trust1qz9g...jya75".to_string(), + action: "login".to_string(), + message: "Approve 2FA login to mail.example.com".to_string(), + }); + let outcome = handle.wait_timeout(Duration::from_secs(5)); + println!("outcome = {outcome:?}"); + + let exe = std::env::current_exe().unwrap(); + match Autostart::new(&exe) { + Ok(a) => { + println!( + "autostart app={} path={:?}", + niko_trust_gui::autostart::APP_NAME, + exe + ); + match a.is_enabled() { + Ok(enabled) => println!("is_enabled = {enabled}"), + Err(e) => println!("is_enabled error: {e}"), + } + } + Err(e) => println!("autostart build failed: {e}"), + } +} \ No newline at end of file diff --git a/src/address.rs b/src/address.rs new file mode 100644 index 0000000..0e142e8 --- /dev/null +++ b/src/address.rs @@ -0,0 +1,311 @@ +//! Trust addresses: bech32m encoding of an Ed25519 public key plus a protocol +//! version byte. +//! +//! hrp = "trust"; payload = version(1) || pubkey(32). bech32m (BIP-350) is used +//! and checked for, so a bech32-checksum downgrade is rejected. Every valid +//! address decodes to exactly one key and vice versa; public keys are +//! validated as canonical prime-order curve points (see [validate_pubkey]). + +// The parallel to the Go implementation is exact except that bech32m is +// implemented here directly (~90 lines) rather than via a dependency, which +// keeps the parser fully under our control and unambiguous. + +use ed25519_dalek::VerifyingKey; + +const HRP: &str = "trust"; +const VERSION0: u8 = 0x00; +const PUBKEY_SIZE: usize = 32; +const PAYLOAD_SIZE: usize = 1 + PUBKEY_SIZE; + +const CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32M_CONST: u32 = 0x2bc8_30a3; +const BECH32_CONST: u32 = 1; + +/// Exact canonical encoded length of a version-0 trust address (65 chars). +pub const ENCODED_LEN: usize = 5 + 1 + 53 + 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddressError { + /// Empty input. + Empty, + /// Input is longer than any address can be. + TooLong, + /// Input contains uppercase characters. + NotLowercase, + /// Bad checksum or malformed data. + Checksum, + /// Valid checksum but the bech32 (not bech32m) constant. + NotBech32m, + /// Human-readable part is not "trust". + WrongHrp, + /// Decoded payload is not version || 32-byte key. + PayloadSize, + /// Unknown protocol version byte. + Version, + /// Non-zero padding bits (a second spelling of an address). + Padding, + /// Public key is not 32 bytes. + KeySize, + /// Public key is not a valid curve point. + KeyNotOnCurve, + /// Public key encoding is non-canonical. + KeyNonCanonical, + /// Public key has small order. + KeySmallOrder, +} + +impl std::fmt::Display for AddressError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + AddressError::Empty => "address: empty", + AddressError::TooLong => "address: too long", + AddressError::NotLowercase => "address: must be lowercase", + AddressError::Checksum => "address: invalid checksum", + AddressError::NotBech32m => "address: not bech32m", + AddressError::WrongHrp => "address: wrong human-readable part", + AddressError::PayloadSize => "address: wrong payload size", + AddressError::Version => "address: unsupported version", + AddressError::Padding => "address: non-canonical padding", + AddressError::KeySize => "address: public key must be 32 bytes", + AddressError::KeyNotOnCurve => "address: public key is not a valid curve point", + AddressError::KeyNonCanonical => "address: public key encoding is non-canonical", + AddressError::KeySmallOrder => "address: public key has small order", + }; + f.write_str(s) + } +} + +impl std::error::Error for AddressError {} + +/// Returns the byte index of `c` in the bech32 charset, if any. +fn charset_pos(c: u8) -> Option { + CHARSET.iter().position(|&x| x == c).map(|i| i as u8) +} + +fn polymod(values: &[u8]) -> u32 { + let gen = [0x3b6a_57b2u32, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3]; + let mut chk: u32 = 1; + for &v in values { + let b = chk >> 25; + chk = ((chk & 0x1ff_ffff) << 5) ^ (v as u32); + for (i, &g) in gen.iter().enumerate() { + if (b >> i) & 1 == 1 { + chk ^= g; + } + } + } + chk +} + +fn hrp_expand(hrp: &str) -> Vec { + let mut out = Vec::with_capacity(hrp.len() * 2 + 1); + for &c in hrp.as_bytes() { + out.push(c >> 5); + } + out.push(0); + for &c in hrp.as_bytes() { + out.push(c & 31); + } + out +} + +fn create_checksum(hrp: &str, data: &[u8]) -> [u8; 6] { + let mut values = hrp_expand(hrp); + values.extend_from_slice(data); + values.extend_from_slice(&[0; 6]); + let pm = polymod(&values) ^ BECH32M_CONST; + let mut out = [0u8; 6]; + for (i, o) in out.iter_mut().enumerate() { + *o = ((pm >> (5 * (5 - i))) & 31) as u8; + } + out +} + +fn convert_bits(data: &[u8], from_bits: u32, to_bits: u32, pad: bool) -> Option> { + let mut acc: u32 = 0; + let mut bits: u32 = 0; + let max_v: u32 = (1 << to_bits) - 1; + let max_acc: u32 = (1 << (from_bits + to_bits - 1)) - 1; + let mut out = Vec::with_capacity(data.len() * from_bits as usize / to_bits as usize + 1); + for &v in data { + if (v as u32) >> from_bits != 0 { + return None; + } + acc = ((acc << from_bits) | v as u32) & max_acc; + bits += from_bits; + while bits >= to_bits { + bits -= to_bits; + out.push(((acc >> bits) & max_v) as u8); + } + } + if pad { + if bits > 0 { + out.push(((acc << (to_bits - bits)) & max_v) as u8); + } + } else { + // No leftover partial group, and no padding bits set. + if bits >= from_bits || ((acc << (to_bits - bits)) & max_v) != 0 { + return None; + } + } + Some(out) +} + +fn encode_bech32m(hrp: &str, data: &[u8]) -> String { + let mut result = String::with_capacity(hrp.len() + 1 + data.len() + 6); + result.push_str(hrp); + result.push('1'); + let checksum = create_checksum(hrp, data); + for &d in data { + result.push(CHARSET[d as usize] as char); + } + for c in checksum { + result.push(CHARSET[c as usize] as char); + } + result +} + +/// Decodes a bech32 string into (hrp, 5-bit data without checksum, is_bech32m). +fn decode_bech32(s: &str) -> Result<(String, Vec, bool), AddressError> { + if s.is_empty() { + return Err(AddressError::Empty); + } + if s.len() < 8 { + return Err(AddressError::Checksum); + } + if s.len() > ENCODED_LEN { + return Err(AddressError::TooLong); + } + if s.as_bytes().iter().any(|&c| c.is_ascii_uppercase()) { + return Err(AddressError::NotLowercase); + } + let pos = s.rfind('1').ok_or(AddressError::Checksum)?; + if pos < 1 || pos + 7 > s.len() { + return Err(AddressError::Checksum); + } + let hrp = &s[..pos]; + let data_part = &s[pos + 1..]; + let combined: Vec = data_part + .as_bytes() + .iter() + .map(|&c| charset_pos(c).ok_or(AddressError::Checksum)) + .collect::>()?; + let pm = { + let mut values = hrp_expand(hrp); + values.extend_from_slice(&combined); + polymod(&values) + }; + if pm != BECH32M_CONST && pm != BECH32_CONST { + return Err(AddressError::Checksum); + } + let is_m = pm == BECH32M_CONST; + let n = combined.len(); + Ok((hrp.to_string(), combined[..n - 6].to_vec(), is_m)) +} + +/// Validates a public key as a canonical, on-curve, prime-order-subgroup +/// point — mirroring the Go reference. Without this an attacker could +/// register an all-zero key whose zero signature verifies for every message. +pub fn validate_pubkey(pubkey: &[u8]) -> Result<(), AddressError> { + if pubkey.len() != PUBKEY_SIZE { + return Err(AddressError::KeySize); + } + let arr: [u8; PUBKEY_SIZE] = pubkey.try_into().unwrap(); + // ZIP-215 point validation (rejects off-curve). Non-canonical encodings + // are accepted as valid points by ZIP-215, so we check canonical by + // re-encoding below. + let vk = VerifyingKey::from_bytes(&arr).map_err(|_| AddressError::KeyNotOnCurve)?; + if vk.to_bytes() != arr { + return Err(AddressError::KeyNonCanonical); + } + // A small-order ("weak") key makes signatures forgeable or ambiguous. + if vk.is_weak() { + return Err(AddressError::KeySmallOrder); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Address { + s: String, + key: [u8; PUBKEY_SIZE], +} + +impl Address { + /// Encodes a validated public key as a version-0 trust address. + pub fn from_pubkey(pubkey: &[u8]) -> Result { + validate_pubkey(pubkey)?; + let arr: [u8; PUBKEY_SIZE] = pubkey.try_into().unwrap(); + let mut payload = Vec::with_capacity(PAYLOAD_SIZE); + payload.push(VERSION0); + payload.extend_from_slice(&arr); + let five = convert_bits(&payload, 8, 5, true).unwrap(); + let s = encode_bech32m(HRP, &five); + Ok(Address { s, key: arr }) + } + + /// Strictly parses and validates a trust address. + pub fn parse(s: &str) -> Result { + let (hrp, data5, is_m) = decode_bech32(s)?; + if !is_m { + return Err(AddressError::NotBech32m); + } + if hrp != HRP { + return Err(AddressError::WrongHrp); + } + let payload = convert_bits(&data5, 5, 8, false).ok_or(AddressError::Padding)?; + if payload.len() != PAYLOAD_SIZE { + return Err(AddressError::PayloadSize); + } + if payload[0] != VERSION0 { + return Err(AddressError::Version); + } + validate_pubkey(&payload[1..])?; + let mut key = [0u8; PUBKEY_SIZE]; + key.copy_from_slice(&payload[1..]); + Ok(Address { s: s.to_string(), key }) + } + + pub fn to_string(&self) -> &str { + &self.s + } + + pub fn pubkey(&self) -> &[u8; PUBKEY_SIZE] { + &self.key + } +} + +impl std::fmt::Display for Address { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_addresses() { + // From testdata/vectors/tce_vectors.json parties. + let cases = [ + ( + "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75", + ), + ( + "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394", + "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s", + ), + ]; + for (hexkey, addr) in cases { + let pk = hex::decode(hexkey).unwrap(); + let a = Address::from_pubkey(&pk).unwrap(); + assert_eq!(a.to_string(), addr); + let b = Address::parse(addr).unwrap(); + assert_eq!(b.to_string(), addr); + let want: [u8; 32] = pk[..].try_into().unwrap(); + assert_eq!(b.pubkey(), &want); + } + } +} \ No newline at end of file diff --git a/src/autostart.rs b/src/autostart.rs new file mode 100644 index 0000000..f2c8af5 --- /dev/null +++ b/src/autostart.rs @@ -0,0 +1,60 @@ +//! Autostart integration (`auto-launch`): register/unregister the app at +//! login. Uses the XDG autostart entry on Linux, the run/registry key on +//! Windows and the login item on macOS via the underlying crate. + +use std::path::Path; + +#[derive(Debug)] +pub struct Autostart { + inner: auto_launch::AutoLaunch, +} + +/// Application identity used for both the autostart entry and notifications. +pub const APP_NAME: &str = "niko-trust"; + +impl Autostart { + /// Builds an autostart registration for the currently running executable. + /// + /// `current_exe` is normally `std::env::current_exe()`. A silently-missing + /// binary is not fatal: the app can still run, it just won't autostart. + pub fn new(current_exe: &Path) -> Result { + let path = current_exe + .to_str() + .ok_or(auto_launch::Error::AppPathNotSpecified)?; + let inner = auto_launch::AutoLaunchBuilder::new() + .set_app_name(APP_NAME) + .set_app_path(path) + .build()?; + Ok(Autostart { inner }) + } + + /// Idempotently registers the app to start at login. + pub fn enable(&self) -> Result<(), auto_launch::Error> { + self.inner.enable() + } + + /// Idempotently removes the login entry. + pub fn disable(&self) -> Result<(), auto_launch::Error> { + self.inner.disable() + } + + pub fn is_enabled(&self) -> Result { + self.inner.is_enabled() + } +} + +/// Writes nothing, never errors; useful when the caller cannot find the +/// running binary. +pub fn noop() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_name_is_stable() { + assert_eq!(APP_NAME, "niko-trust"); + } +} \ No newline at end of file diff --git a/src/book.rs b/src/book.rs new file mode 100644 index 0000000..3cf9bc9 --- /dev/null +++ b/src/book.rs @@ -0,0 +1,83 @@ +//! Local address book: memorable labels for trust1… addresses. +//! +//! Entries map an address string to a free-form label ("Niko"); persistence +//! lives in the vault file. The display helper renders known addresses as +//! `Label (trust1qz8jr…7tx6w)` and unknown ones as just the shortened address. + +use std::collections::BTreeMap; + +#[derive(Debug, Default)] +pub struct AddressBook { + entries: BTreeMap, +} + +impl AddressBook { + pub fn from_entries(entries: BTreeMap) -> AddressBook { + AddressBook { entries } + } + + pub fn entries(&self) -> &BTreeMap { + &self.entries + } + + pub fn label_of(&self, addr: &str) -> Option<&str> { + self.entries.get(addr).map(|s| s.as_str()) + } + + /// Sets (or clears, when `label` is blank) the label for an address. + /// Returns true when the map changed. + pub fn set_label(&mut self, addr: &str, label: &str) -> bool { + let label = label.trim(); + if label.is_empty() { + self.entries.remove(addr).is_some() + } else { + match self.entries.get(addr) { + Some(cur) if cur == label => false, + _ => { + self.entries.insert(addr.to_string(), label.to_string()); + true + } + } + } + } + + /// Compact display form: `Niko (trust1qz8jr…7tx6w)` when known, otherwise + /// just the shortened address. + pub fn display(&self, addr: &str) -> String { + match self.label_of(addr) { + Some(l) => format!("{l} ({})", short_addr(addr)), + None => short_addr(addr), + } + } +} + +/// Bech32 addresses are long; keep a compact form for the UI. +pub fn short_addr(a: &str) -> String { + if a.len() <= 20 { + a.to_string() + } else { + format!("{}…{}", &a[..10], &a[a.len() - 6..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_known_and_unknown() { + let mut b = AddressBook::default(); + let addr = "trust1qz8jrkwdnqscwrvptu69z6ntd2dp092s0fk25ayq94qlzsx649dszg7tx6w"; + assert_eq!(b.display(addr), short_addr(addr)); + + assert!(b.set_label(addr, "Niko")); + assert_eq!(b.display(addr), format!("Niko ({})", short_addr(addr))); + assert_eq!(b.label_of(addr), Some("Niko")); + + // Blank label clears the entry. + b.set_label(addr, " "); + assert_eq!(b.display(addr), short_addr(addr)); + // Clearing a missing entry reports no change. + assert!(!b.set_label(addr, "")); + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..970f9f8 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,15 @@ +//! Sha-256 helpers: object content addressing. + +use sha2::{Digest, Sha256}; + +/// SHA-256 of `data` as a `[u8; 32]`. +pub fn sha256(data: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(data); + h.finalize().into() +} + +/// Lowercase-hex content id of TCE bytes (the protocol object id). +pub fn object_id(tce: &[u8]) -> String { + hex::encode(sha256(tce)) +} \ No newline at end of file diff --git a/src/inbox.rs b/src/inbox.rs new file mode 100644 index 0000000..2e2482a --- /dev/null +++ b/src/inbox.rs @@ -0,0 +1,278 @@ +//! Inbox: dedup, history and time validation for incoming approval requests. +//! +//! The relay returns a request in every poll until it is answered +//! (one-response-per-request means an answered request stays listed after the +//! response exists). The inbox keeps a seen-set so a request is surfaced only +//! once, records the action taken for history, and refuses to act on requests +//! whose window has lapsed or that arrived before they were supposed to be +//! valid (clock skew, PROTOCOL.md §13.1). + +use std::collections::{HashMap, HashSet}; + +use crate::address::Address; +use crate::protocol::{Decision, MAX_CLOCK_SKEW}; +use crate::relay::IncomingRequest; + +/// The decision a request ended with, or none if still pending. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Pending, + Approved, + Denied, + /// Left unanswered and its window lapsed. + TimedOut, + /// Was not answered because it was already stale when seen. + Stale, +} + +/// One item of history, for display and audit. +#[derive(Debug, Clone)] +pub struct HistoryItem { + pub request_id_hex: String, + pub sender: Address, + pub action: String, + pub message: String, + pub created_at: u64, + pub expires_at: u64, + pub outcome: Outcome, +} + +/// Is `now` inside the request window with the ±120s skew allowance? +/// Mirrors the reference `ValidateCurrent` (PROTOCOL.md §13.1): a request may +/// be shown up to SKEW after expiry, and one whose creation is more than SKEW +/// in the future is treated as invalid rather than trusted. +pub fn in_window(created_at: u64, expires_at: u64, now: u64) -> bool { + if created_at > now && created_at - now > MAX_CLOCK_SKEW { + return false; // from the future beyond skew + } + if now > expires_at && now - expires_at > MAX_CLOCK_SKEW { + return false; // long expired + } + true +} + +/// Plain in-memory inbox. Owned by one poll loop / UI controller. +#[derive(Debug, Default)] +pub struct Inbox { + /// request_id bytes already surfaced (hex kept for history lookup). + known: HashSet<[u8; 32]>, + /// Filled-in history: request_id bytes -> item. + history: HashMap<[u8; 32], HistoryItem>, +} + +impl Inbox { + pub fn new() -> Inbox { + Inbox::default() + } + + /// Number of requests currently pending (seen, not yet acted on). + pub fn pending_count(&self) -> usize { + self.history.values().filter(|h| h.outcome == Outcome::Pending).count() + } + + /// Consolidated, most-recent-first list for display. + pub fn history(&self) -> Vec<&HistoryItem> { + let mut v: Vec<&HistoryItem> = self.history.values().collect(); + v.sort_by_key(|h| std::cmp::Reverse(h.created_at)); + v + } + + /// Whether this request has already been surfaced. + pub fn is_known(&self, request_id: &[u8; 32]) -> bool { + self.known.contains(request_id) + } + + /// Registers the outcome of a surfaced request. + pub fn set_outcome(&mut self, request_id: &[u8; 32], outcome: Outcome) { + if let Some(item) = self.history.get_mut(request_id) { + item.outcome = outcome; + } + } + + /// Feeds a batch of incoming requests: records all *newly seen* ones into + /// history and returns them (caller then reconciles each against the + /// relay's responses feed before deciding what to surface). + /// + /// The initial outcome reflects the time window only (Pending / TimedOut / + /// Stale); `set_outcome` refines it with the actual decision afterwards. + /// Requests outside the window are never actionable regardless of outcome. + pub fn observe<'a>( + &mut self, + now: u64, + incoming: &'a [IncomingRequest], + ) -> Vec<&'a IncomingRequest> { + let mut new_ones: Vec<&'a IncomingRequest> = Vec::new(); + for r in incoming { + let id = &r.request_id; + if !self.known.insert(*id) { + continue; // already surfaced + } + // Record regardless of validity, so an expired prompt is visible. + let outcome = if !in_window(r.request.created_at, r.request.expires_at, now) { + if r.request.expires_at != 0 && now > r.request.expires_at { + Outcome::TimedOut + } else { + Outcome::Stale + } + } else { + Outcome::Pending + }; + let mut item = history_item(r); + item.outcome = outcome; + self.history.insert(*id, item); + new_ones.push(r); + } + new_ones + } + + /// Recorded outcome for a request, if it has been seen. + pub fn outcome_of(&self, request_id: &[u8; 32]) -> Option { + self.history.get(request_id).map(|h| h.outcome) + } + + /// Restores persisted history after a restart: items become known (so + /// they are never re-surfaced as prompts) and show up in history with + /// their saved outcomes. Bad entries are skipped silently. + pub fn restore(&mut self, items: Vec) { + for h in items { + let Ok(id_bytes) = hex::decode(&h.request_id_hex) else { continue }; + let Ok(id) = <[u8; 32]>::try_from(id_bytes) else { continue }; + self.known.insert(id); + self.history.insert(id, h); + } + } +} + +fn history_item(r: &IncomingRequest) -> HistoryItem { + HistoryItem { + request_id_hex: hex::encode(r.request_id), + sender: r.sender.clone(), + action: r.request.action.clone(), + message: r.request.message.clone(), + created_at: r.request.created_at, + expires_at: r.request.expires_at, + outcome: Outcome::Pending, + } +} + +/// Conviction: allow or deny, carried on the action taken for a request. +impl From for Outcome { + fn from(d: Decision) -> Self { + match d { + Decision::Allow => Outcome::Approved, + Decision::Deny => Outcome::Denied, + } + } +} + +/// Human-readable reason shown next to history entries. +pub fn outcome_label(o: Outcome) -> &'static str { + match o { + Outcome::Pending => "pending", + Outcome::Approved => "approved", + Outcome::Denied => "denied", + Outcome::TimedOut => "expired", + Outcome::Stale => "stale", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::ApprovalRequest; + use crate::signer::Signer; + use crate::tce::NONCE_SIZE; + + fn incoming(me: &Signer, sender: &Signer, action: &str) -> IncomingRequest { + let request = ApprovalRequest { + sender: sender.pubkey(), + recipient: me.pubkey(), + action: action.to_string(), + payload: Default::default(), + message: "m".to_string(), + created_at: 1_700_000_000, + expires_at: 1_700_000_030, + nonce: [0u8; NONCE_SIZE], + }; + IncomingRequest { + request, + tce: vec![], + request_id: [0x11; 32], + sender: Address::from_pubkey(&sender.pubkey()).unwrap(), + } + } + + #[test] + fn dedup_surfaces_once() { + let me = Signer::generate().unwrap(); + let sender = Signer::generate().unwrap(); + let mut boxed = Inbox::new(); + let r = incoming(&me, &sender, "login"); + + let first = [r.clone()]; + let one = boxed.observe(1_700_000_020, &first); + assert_eq!(one.len(), 1); + + // Same request again -> nothing new. + let again = [r.clone()]; + let two = boxed.observe(1_700_000_025, &again); + assert!(two.is_empty()); + assert_eq!(boxed.pending_count(), 1); + } + + #[test] + fn window_and_outcomes() { + let me = Signer::generate().unwrap(); + let sender = Signer::generate().unwrap(); + let mut boxed = Inbox::new(); + let r = incoming(&me, &sender, "x"); + + // Inside window -> pending & fresh. + let inside_batch = [r.clone()]; + let inside = boxed.observe(1_700_000_010, &inside_batch); + assert_eq!(inside.len(), 1); + + // Long after expiry (beyond the skew allowance) -> TimedOut, recorded + // but never actionable. expires=1_700_000_030, +120s skew => valid + // until 1_700_000_150; 200 is past that. + let stale_id = [0x22; 32]; + let mut stale = r.clone(); + stale.request_id = stale_id; + let stale_batch = [stale]; + let out = boxed.observe(1_700_000_200, &stale_batch); + assert_eq!(out.len(), 1, "expired request is recorded as new"); + assert_eq!( + boxed.outcome_of(&stale_id), + Some(Outcome::TimedOut), + "expired request must not be pending" + ); + + // Long in the future -> not valid yet (Stale), not actionable. + let f = [0x33; 32]; + let mut future = r.clone(); + future.request_id = f; + future.request.created_at = 1_800_000_000; + future.request.expires_at = 1_800_000_030; + let future_batch = [future]; + let out = boxed.observe(1_700_000_000, &future_batch); + assert_eq!(out.len(), 1); + assert_eq!(boxed.outcome_of(&f), Some(Outcome::Stale)); + } + + // The clippy placeholder above is removed; this test documents outcome + // transitions via set_outcome. + #[test] + fn outcome_changes() { + let me = Signer::generate().unwrap(); + let sender = Signer::generate().unwrap(); + let mut boxed = Inbox::new(); + let r = incoming(&me, &sender, "login"); + let b = [r.clone()]; + boxed.observe(1_700_000_020, &b); + let id = r.request_id; + boxed.set_outcome(&id, Outcome::from(Decision::Allow)); + let h = boxed.history(); + assert_eq!(h[0].outcome, Outcome::Approved); + assert_eq!(outcome_label(h[0].outcome), "approved"); + } +} \ No newline at end of file diff --git a/src/keyring.rs b/src/keyring.rs new file mode 100644 index 0000000..e651781 --- /dev/null +++ b/src/keyring.rs @@ -0,0 +1,241 @@ +//! Keyring: encrypts the 32-byte identity seed at rest with a passphrase. +//! +//! KDF = Argon2id (default params, 19 MiB); AEAD = XChaCha20-Poly1305 with a +//! 24-byte random nonce; salt 16 bytes. The identity file is written with +//! 0o600 permissions mirroring the seed's sensitivity. A wrong passphrase +//! fails the AEAD tag check, so no separate password verifier is needed. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use aead::Payload; +use aead::{Aead, KeyInit}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use zeroize::Zeroizing; + +use crate::signer::Signer; + +const FILE_VERSION: u8 = 1; +const KDF_M: u32 = 19 * 1024; // 19 MiB (matches Argon2 default) +const KDF_T: u32 = 2; +const KDF_P: u32 = 1; + +#[derive(Debug)] +pub enum KeyringError { + Io(std::io::Error), + Json(serde_json::Error), + /// Missing identity file or wrong passphrase. + Decrypt(String), + /// Directory creation failed. + Setup(String), +} + +impl std::fmt::Display for KeyringError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KeyringError::Io(e) => write!(f, "io: {e}"), + KeyringError::Json(e) => write!(f, "json: {e}"), + KeyringError::Decrypt(m) => write!(f, "{m}"), + KeyringError::Setup(m) => write!(f, "{m}"), + } + } +} + +impl std::error::Error for KeyringError {} + +fn open_private(path: &Path) -> Result { + let f = OpenOptions::new().create(true).write(true).truncate(true).open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + f.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(f) +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct FileFormat { + v: u8, + kdf: String, + m: u32, + t: u32, + p: u32, + salt: String, // base64 16 + nonce: String, // base64 24 + cipher: String, // base64 ciphertext +} + +fn derive_key(password: &str, salt: &[u8]) -> Result, KeyringError> { + use argon2::{Algorithm, Argon2, Params, Version}; + let params = Params::new(KDF_M, KDF_T, KDF_P, Some(32)) + .map_err(|e| KeyringError::Setup(format!("argon2 params: {e}")))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0u8; 32]); + argon + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|e| KeyringError::Setup(format!("argon2: {e}")))?; + Ok(key) +} + +/// Loads or creates the identity store in the app config directory. +pub struct Keyring { + dir: PathBuf, +} + +impl Keyring { + pub fn new(dir: PathBuf) -> Result { + std::fs::create_dir_all(&dir) + .map_err(|e| KeyringError::Setup(format!("mkdir {}: {e}", dir.display())))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + Ok(Keyring { dir }) + } + + pub fn identity_file(&self) -> PathBuf { + self.dir.join("identity.json") + } + + pub fn has_identity(&self) -> bool { + self.identity_file().exists() + } + + /// Encrypts a freshly generated seed under `password` and writes it out. + pub fn save_new_key(&self, password: &str, seed: &[u8; 32]) -> Result<(), KeyringError> { + let json = serde_json::to_vec_pretty(&encrypt_seed(password, seed)?) + .map_err(KeyringError::Json)?; + let mut f = open_private(&self.identity_file()).map_err(KeyringError::Io)?; + f.write_all(&json).map_err(KeyringError::Io)?; + Ok(()) + } + + /// Decrypts the stored seed. Wrong passphrase => Err(Decrypt). + pub fn load_key(&self, password: &str) -> Result<[u8; 32], KeyringError> { + let raw = std::fs::read_to_string(self.identity_file()) + .map_err(|_| KeyringError::Decrypt("identity file missing".into()))?; + let blob: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| KeyringError::Decrypt(format!("bad file: {e}")))?; + decrypt_seed(password, &blob) + } + + /// Loads the signer, if present and the passphrase is correct. + pub fn load_signer(&self, password: &str) -> Result { + let seed = self.load_key(password)?; + Signer::from_seed(seed).map_err(|e| KeyringError::Decrypt(format!("bad seed: {e}"))) + } +} + +/// Encrypts a seed into an identity JSON value (the vault stores exactly this). +pub fn encrypt_seed(password: &str, seed: &[u8; 32]) -> Result { + let mut salt = [0u8; 16]; + getrandom::fill(&mut salt).map_err(|e| KeyringError::Setup(format!("rng: {e}")))?; + let key = derive_key(password, &salt)?; + + let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()) + .expect("32-byte key from argon2id"); + let mut nonce_bytes = [0u8; 24]; + getrandom::fill(&mut nonce_bytes).map_err(|e| KeyringError::Setup(format!("rng: {e}")))?; + let nonce = XNonce::from(nonce_bytes); + let payload = Payload { msg: seed, aad: b"niko-trust/identity/v1" }; + let ciphertext = cipher + .encrypt(&nonce, payload) + .map_err(|_| KeyringError::Setup("encrypt failed".into()))?; + + let fmt = FileFormat { + v: FILE_VERSION, + kdf: "argon2id".into(), + m: KDF_M, + t: KDF_T, + p: KDF_P, + salt: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, salt), + nonce: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, nonce_bytes), + cipher: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext), + }; + serde_json::to_value(&fmt).map_err(KeyringError::Json) +} + +/// Decrypts a seed from an identity JSON value (vault-stored). A wrong +/// passphrase fails the AEAD tag check => Err(Decrypt). +pub fn decrypt_seed( + password: &str, + blob: &serde_json::Value, +) -> Result<[u8; 32], KeyringError> { + let fmt: FileFormat = + serde_json::from_value(blob.clone()).map_err(|e| KeyringError::Decrypt(format!("bad blob: {e}")))?; + if fmt.v != FILE_VERSION || fmt.kdf != "argon2id" { + return Err(KeyringError::Decrypt("unsupported key file".into())); + } + let salt = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &fmt.salt) + .map_err(|_| KeyringError::Decrypt("bad salt".into()))?; + let nonce_b = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &fmt.nonce) + .map_err(|_| KeyringError::Decrypt("bad nonce".into()))?; + let ct = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &fmt.cipher) + .map_err(|_| KeyringError::Decrypt("bad cipher".into()))?; + + // Use the params stored in the file. + let key = derive_key_with_params(password, &salt, fmt.m, fmt.t, fmt.p)?; + + let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref()).expect("32-byte key from argon2id"); + let nonce_bytes: [u8; 24] = nonce_b + .try_into() + .map_err(|_| KeyringError::Decrypt("bad nonce length".into()))?; + let nonce = XNonce::from(nonce_bytes); + let payload = Payload { msg: ct.as_ref(), aad: b"niko-trust/identity/v1" }; + let plain = cipher.decrypt(&nonce, payload).map_err(|_| { + KeyringError::Decrypt("неверный пароль или повреждённый файл ключа".into()) + })?; + let seed: [u8; 32] = plain.try_into().map_err(|_| KeyringError::Decrypt("bad seed length".into()))?; + Ok(seed) +} + +fn derive_key_with_params( + password: &str, + salt: &[u8], + m: u32, + t: u32, + p: u32, +) -> Result, KeyringError> { + use argon2::{Algorithm, Argon2, Params, Version}; + let params = + Params::new(m, t, p, Some(32)).map_err(|e| KeyringError::Setup(format!("argon2: {e}")))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0u8; 32]); + argon + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|e| KeyringError::Setup(format!("argon2: {e}")))?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_and_wrong_password() { + let dir = std::env::temp_dir().join(format!("nktrst-{}", std::process::id())); + let kr = Keyring::new(dir.clone()).unwrap(); + assert!(!kr.has_identity()); + let seed = [0xabu8; 32]; + kr.save_new_key("correct horse", &seed).unwrap(); + assert!(kr.has_identity()); + assert_eq!(kr.load_key("correct horse").unwrap(), seed); + assert!(kr.load_key("wrong pass").is_err()); + // clean up + let _ = std::fs::remove_file(kr.identity_file()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn kdf_params_are_always_writeable() { + let dir = std::env::temp_dir().join(format!("nktrst2-{}", std::process::id())); + let kr = Keyring::new(dir.clone()).unwrap(); + let seed = [7u8; 32]; + kr.save_new_key("x", &seed).unwrap(); + assert_eq!(kr.load_key("x").unwrap(), seed); + let _ = std::fs::remove_file(kr.identity_file()); + let _ = std::fs::remove_dir_all(dir); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cba0dd6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,17 @@ +//! Niko Trust GUI — core library. +//! +//! Byte-exact TCE protocol client for the niko_trust relay. The GUI (main.rs, +//! feature "gui") builds on these modules. + +pub mod address; +pub mod autostart; +pub mod book; +pub mod crypto; +pub mod inbox; +pub mod keyring; +pub mod notify; +pub mod protocol; +pub mod relay; +pub mod signer; +pub mod tce; +pub mod vault; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d688bde --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1741 @@ +//! Niko Trust — iced desktop app. +//! +//! Screens: +//! - Lock: enter the password (or create a fresh identity on first run) and +//! the relay URL, then connect. +//! - Main: polls the relay, surfaces approval requests (native notification + +//! an in-app card), records history. +//! +//! Built only with the `gui` feature (the crate default). The core library +//! stays UI-free via `--no-default-features`. + +#[cfg(feature = "gui")] +mod gui { + use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use iced::futures::{channel as fchannel, SinkExt, Stream, StreamExt}; +use iced::widget::{button, container, scrollable, text, Checkbox, Column, Container, Row, Space, TextInput}; +use iced::widget::{checkbox, text_input}; +use iced::{alignment, border, Color, Element, Length, Size, Subscription, Task, Theme}; + +use niko_trust_gui::book::{short_addr, AddressBook}; +use niko_trust_gui::inbox::{outcome_label, HistoryItem, Inbox, Outcome}; +use niko_trust_gui::keyring::{decrypt_seed, encrypt_seed}; +use niko_trust_gui::notify::{self, ApprovalNotification, PromptOutcome}; +use niko_trust_gui::protocol::Decision; +use niko_trust_gui::relay::{IncomingRequest, IncomingResponse, Relay}; +use niko_trust_gui::signer::Signer; +use niko_trust_gui::vault::{StoredItem, Vault}; + +const APP_NAME: &str = "Niko Trust"; +const DEFAULT_RELAY_URL: &str = "https://trust.n1ko.dev"; +const POLL_INTERVAL: Duration = Duration::from_secs(5); +const NATIVE_PROMPT_TIMEOUT: Duration = Duration::from_secs(120); +const MIN_PASSWORD_LEN: usize = 8; + +/// Hands the native-notification outcome to the UI event loop. Only one prompt +/// is active at a time, so a single slot is enough; the subscription takes it. +static PROMPT_RX: Mutex>> = Mutex::new(None); + +pub fn run() -> iced::Result { + iced::application(boot, update, view) + .title(APP_NAME) + .theme(theme_of) + .window(window_settings()) + .subscription(subscription) + .run() +} + +fn boot() -> (App, Task) { + let vault = Vault::load(); + let book = AddressBook::from_entries(vault.address_book().clone()); + let relay_url = if vault.relay_url().is_empty() { + DEFAULT_RELAY_URL.to_string() + } else { + vault.relay_url().to_string() + }; + let has_identity = vault.identity_blob().is_some(); + let app = App { + vault, + has_identity, + password: String::new(), + confirm: String::new(), + relay_url, + lock_error: None, + connecting: false, + pending_signer: None, + session: None, + book, + book_edit: None, + screen: Screen::Requests, + new_password: String::new(), + new_confirm: String::new(), + autostart_enabled: autostart_now(), + }; + (app, Task::none()) +} + +/// Current autostart registration state (false on any error). +fn autostart_now() -> bool { + std::env::current_exe() + .ok() + .and_then(|exe| niko_trust_gui::autostart::Autostart::new(&exe).ok()) + .and_then(|a| a.is_enabled().ok()) + .unwrap_or(false) +} + +fn window_settings() -> iced::window::Settings { + iced::window::Settings { + size: Size::new(460.0, 720.0), + min_size: Some(Size::new(400.0, 520.0)), + ..Default::default() + } +} + +/// The three tabs of the main shell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Screen { + Requests, + Book, + Settings, +} + +struct App { + /// Single portable state file (identity + book + history + settings). + vault: Vault, + has_identity: bool, + password: String, + confirm: String, + relay_url: String, + lock_error: Option, + connecting: bool, + pending_signer: Option, + session: Option, + /// Local address book (address -> label), persisted in the vault. + book: AddressBook, + /// Open "add to address book" editor: address + label being typed. + book_edit: Option, + /// Which tab is open (Requests is the main view). + screen: Screen, + new_password: String, + new_confirm: String, + /// Cached autostart state (filesystem/registry is not polled per frame). + autostart_enabled: bool, +} + +/// The add-to-address-book editor state. An empty `addr` means a brand-new +/// entry (the address itself is being typed). +struct BookEdit { + addr: String, + label: String, + error: Option, +} + +/// An unlocked, connected session. +struct Session { + signer: Signer, + relay: Arc, + inbox: Inbox, + my_address: String, + queue: VecDeque, + current: Option, + polling: bool, + responding: bool, + /// A 401-triggered re-login is in flight; suppress further reauths. + reconnecting: bool, + status: Option, +} + +/// The approval request currently awaiting the user's decision. +struct Prompt { + req: IncomingRequest, + native_shown: bool, +} + +#[derive(Clone)] +enum Message { + PasswordChanged(String), + ConfirmChanged(String), + RelayUrlChanged(String), + Unlock, + Create, + /// Fresh identity generated + encrypted blob ready to store in the vault. + IdentityReady(Result<(Signer, serde_json::Value), String>), + Unlocked(Result), + Connected(Result, String>), + Tick, + Polled(Result, String>), + PromptOutcome(PromptOutcome), + CopyText(String), + BookAdd(String), + BookLabel(String), + /// Typing the address of a brand-new entry. + BookAddr(String), + BookSave, + BookCancel, + BookDelete(String), + ShowTab(Screen), + NewPasswordChanged(String), + NewConfirmChanged(String), + ChangePassword, + PasswordUpdated(Result), + ToggleAutostart(bool), + Approve, + Deny, + Responded { + id: [u8; 32], + decision: Decision, + result: Result, + }, + /// First time we see a request: check the relay for an existing response + /// before surfacing it (state is rebuilt from the relay after a restart). + AnswerChecked { + req: IncomingRequest, + result: Result, String>, + }, + /// Transparent re-login after a 401 (relay restarts drop all sessions). + Reauthed(Result, String>), +} + +// ---------------------------------------------------------------- polling + +fn subscription(state: &App) -> Subscription { + let mut subs: Vec> = Vec::new(); + if let Some(s) = &state.session { + subs.push(iced::time::every(POLL_INTERVAL).map(|_| Message::Tick)); + if !s.responding { + if let Some(p) = &s.current { + if p.native_shown { + subs.push(Subscription::run_with(p.req.request_id, |_| prompt_stream())); + } + } + } + } + Subscription::batch(subs) +} + +/// Reads the native-notification outcome off the shared channel. +fn prompt_stream() -> impl Stream { + iced::stream::channel(1, async |mut sender| { + let rx = PROMPT_RX.lock().expect("prompt channel lock").take(); + if let Some(mut rx) = rx { + while let Some(outcome) = rx.next().await { + if sender.send(Message::PromptOutcome(outcome)).await.is_err() { + break; + } + } + } + }) +} + +// ---------------------------------------------------------------- update + +fn update(state: &mut App, msg: Message) -> Task { + match msg { + Message::PasswordChanged(v) => { + state.password = v; + Task::none() + } + Message::ConfirmChanged(v) => { + state.confirm = v; + Task::none() + } + Message::RelayUrlChanged(v) => { + state.relay_url = v; + Task::none() + } + Message::Unlock => unlock(state), + Message::Create => create(state), + Message::IdentityReady(r) => match r { + Ok((signer, blob)) => { + state.vault.set_identity_blob(blob); + state.has_identity = true; + unlocked(state, Ok(signer)) + } + Err(e) => { + state.connecting = false; + state.lock_error = Some(format!("create failed: {e}")); + Task::none() + } + }, + Message::Unlocked(r) => unlocked(state, r), + Message::Connected(r) => connected(state, r), + Message::Tick => poll(state), + Message::Polled(r) => polled(state, r), + Message::AnswerChecked { req, result } => answer_checked(state, req, result), + Message::Reauthed(r) => reauthed(state, r), + Message::PromptOutcome(o) => prompt_outcome(state, o), + Message::CopyText(t) => iced::clipboard::write(t), + Message::BookAdd(addr) => { + state.book_edit = Some(BookEdit { addr, label: String::new(), error: None }); + Task::none() + } + Message::BookLabel(l) => { + if let Some(e) = state.book_edit.as_mut() { + e.label = l; + } + Task::none() + } + Message::BookAddr(a) => { + if let Some(e) = state.book_edit.as_mut() { + e.addr = a; + e.error = None; + } + Task::none() + } + Message::BookSave => { + let Some(e) = state.book_edit.take() else { return Task::none() }; + let label = e.label.trim().to_string(); + if e.addr.trim().is_empty() && !label.is_empty() { + // New entry without an address: put the editor back. + let mut edit = e; + edit.error = Some("address is required".to_string()); + state.book_edit = Some(edit); + return Task::none(); + } + if !e.addr.trim().is_empty() && !label.is_empty() { + // Validate the address before storing it. + if let Err(err) = niko_trust_gui::address::Address::parse(e.addr.trim()) { + let mut edit = e; + edit.error = Some(format!("invalid address: {err}")); + state.book_edit = Some(edit); + return Task::none(); + } + } + state.book.set_label(e.addr.trim(), &label); + state + .vault + .set_address_book(state.book.entries().clone()); + if let Some(s) = state.session.as_mut() { + s.status = Some(if label.is_empty() { + "address book entry removed".to_string() + } else { + format!("saved \"{}\" to address book", label) + }); + } + Task::none() + } + Message::BookCancel => { + state.book_edit = None; + Task::none() + } + Message::BookDelete(addr) => { + state.book.set_label(&addr, ""); + state.vault.set_address_book(state.book.entries().clone()); + Task::none() + } + Message::ShowTab(tab) => { + // Persist relay edits when leaving the Settings tab. + if state.screen == Screen::Settings && tab != Screen::Settings { + state.vault.set_relay_url(state.relay_url.trim().to_string()); + } + state.screen = tab; + state.book_edit = None; + Task::none() + } + Message::NewPasswordChanged(v) => { + state.new_password = v; + Task::none() + } + Message::NewConfirmChanged(v) => { + state.new_confirm = v; + Task::none() + } + Message::ChangePassword => { + let Some(s) = state.session.as_ref() else { return Task::none() }; + if state.new_password.len() < MIN_PASSWORD_LEN { + if let Some(s) = state.session.as_mut() { + s.status = + Some(format!("password must be at least {MIN_PASSWORD_LEN} characters")); + } + return Task::none(); + } + if state.new_password != state.new_confirm { + if let Some(s) = state.session.as_mut() { + s.status = Some("passwords do not match".into()); + } + return Task::none(); + } + let seed = s.signer.seed(); + let pass = state.new_password.clone(); + Task::perform( + async move { encrypt_seed(&pass, &seed).map_err(|e| e.to_string()) }, + Message::PasswordUpdated, + ) + } + Message::PasswordUpdated(r) => match r { + Ok(blob) => { + state.vault.set_identity_blob(blob); + state.new_password.clear(); + state.new_confirm.clear(); + if let Some(s) = state.session.as_mut() { + s.status = Some("password updated".to_string()); + } + Task::none() + } + Err(e) => { + if let Some(s) = state.session.as_mut() { + s.status = Some(format!("password update failed: {e}")); + } + Task::none() + } + }, + Message::ToggleAutostart(on) => { + let res = std::env::current_exe() + .ok() + .and_then(|exe| niko_trust_gui::autostart::Autostart::new(&exe).ok()) + .map(|a| if on { a.enable() } else { a.disable() }); + state.autostart_enabled = match res { + Some(Ok(())) => on, + _ => state.autostart_enabled, + }; + Task::none() + } + Message::Approve => decide(state, Decision::Allow), + Message::Deny => decide(state, Decision::Deny), + Message::Responded { id, decision, result } => responded(state, id, decision, result), + } +} + +// ---------------------------------------------------------------- unlock + +fn unlock(state: &mut App) -> Task { + if state.password.is_empty() { + state.lock_error = Some("enter your password".into()); + return Task::none(); + } + state.lock_error = None; + state.connecting = true; + let Some(blob) = state.vault.identity_blob().cloned() else { + state.connecting = false; + state.lock_error = Some("no identity in vault".into()); + return Task::none(); + }; + let password = state.password.clone(); + Task::perform( + async move { + decrypt_seed(&password, &blob) + .map_err(|e| e.to_string()) + .and_then(|seed| Signer::from_seed(seed).map_err(|e| e.to_string())) + }, + Message::Unlocked, + ) +} + +fn create(state: &mut App) -> Task { + if state.password.len() < MIN_PASSWORD_LEN { + state.lock_error = + Some(format!("password must be at least {MIN_PASSWORD_LEN} characters")); + return Task::none(); + } + if state.password != state.confirm { + state.lock_error = Some("passwords do not match".into()); + return Task::none(); + } + state.lock_error = None; + state.connecting = true; + let password = state.password.clone(); + Task::perform( + async move { + let signer = Signer::generate().map_err(|e| e.to_string())?; + let blob = encrypt_seed(&password, &signer.seed()).map_err(|e| e.to_string())?; + Ok((signer, blob)) + }, + Message::IdentityReady, + ) +} + +fn unlocked(state: &mut App, r: Result) -> Task { + match r { + Ok(signer) => { + state.vault.set_relay_url(state.relay_url.trim().to_string()); + state.pending_signer = Some(signer.clone()); + connect_task(signer, state.relay_url.trim().to_string()) + } + Err(e) => { + state.connecting = false; + state.lock_error = Some(format!("unlock failed: {e}")); + Task::none() + } + } +} + +fn connect_task(signer: Signer, url: String) -> Task { + Task::perform( + async move { + Relay::auth(&url, &signer, "*") + .map(Arc::new) + .map_err(|e| e.to_string()) + }, + Message::Connected, + ) +} + +fn connected(state: &mut App, r: Result, String>) -> Task { + state.connecting = false; + match r { + Ok(relay) => { + let Some(signer) = state.pending_signer.take() else { + return Task::none(); + }; + let my_address = signer + .address() + .map(|a| format!("{a}")) + .unwrap_or_else(|e| format!("
")); + // Restore persisted history so it survives relay restarts. + let mut inbox = Inbox::new(); + let restored: Vec = state + .vault + .history() + .iter() + .filter_map(|s| HistoryItem::try_from(s).ok()) + .collect(); + inbox.restore(restored); + state.session = Some(Session { + signer, + relay, + inbox, + my_address, + queue: VecDeque::new(), + current: None, + polling: false, + responding: false, + reconnecting: false, + status: None, + }); + Task::none() + } + Err(e) => { + state.lock_error = Some(format!("relay connect failed: {e}")); + Task::none() + } + } +} + +// ---------------------------------------------------------------- poll + +/// Persists the current history into the vault (called after any change). +fn snapshot_history(state: &mut App) { + let Some(s) = state.session.as_ref() else { return }; + let items: Vec = + s.inbox.history().into_iter().map(StoredItem::from).collect(); + state.vault.set_history(items); +} + +fn poll(state: &mut App) -> Task { + let Some(s) = state.session.as_mut() else { + return Task::none(); + }; + if s.polling || s.responding { + return Task::none(); + } + s.polling = true; + let relay = s.relay.clone(); + let signer = s.signer.clone(); + Task::perform( + async move { relay.fetch_requests(&signer).map_err(|e| e.to_string()) }, + Message::Polled, + ) +} + +fn polled(state: &mut App, r: Result, String>) -> Task { + let Some(s) = state.session.as_mut() else { + return Task::none(); + }; + s.polling = false; + match r { + Ok(list) => { + let now = now_secs(); + let fresh = s.inbox.observe(now, &list); + let n = fresh.len(); + s.status = Some(if n == 0 { + "poll ok".to_string() + } else { + format!("checking {n} new request{}…", if n == 1 { "" } else { "s" }) + }); + let relay = s.relay.clone(); + let tasks: Vec> = fresh + .into_iter() + .map(|req| check_answered(relay.clone(), req.clone())) + .collect(); + advance(s); + if n > 0 { + snapshot_history(state); + } + Task::batch(tasks) + } + Err(e) => { + s.status = Some(format!("poll failed: {e}")); + if is_auth_error(&e) { + return reauth(s); + } + Task::none() + } + } +} + +/// Relay sessions are in-memory server-side: a relay restart invalidates every +/// token, so a 401 means "log in again", not "give up". +fn is_auth_error(e: &str) -> bool { + e.contains("401") || e.to_lowercase().contains("unauthorized") +} + +/// Re-runs the challenge/assert handshake and swaps the relay client. +fn reauth(s: &mut Session) -> Task { + if s.reconnecting { + return Task::none(); + } + s.reconnecting = true; + let base = s.relay.base_url().to_string(); + let signer = s.signer.clone(); + Task::perform( + async move { Relay::auth(&base, &signer, "*").map(Arc::new).map_err(|e| e.to_string()) }, + Message::Reauthed, + ) +} + +fn reauthed(state: &mut App, r: Result, String>) -> Task { + let Some(s) = state.session.as_mut() else { + return Task::none(); + }; + s.reconnecting = false; + match r { + Ok(relay) => { + s.relay = relay; + s.status = Some("re-authenticated".to_string()); + poll(state) + } + Err(e) => { + s.status = Some(format!("re-auth failed: {e}")); + Task::none() + } + } +} + +/// Before surfacing a request, ask the relay whether it was already answered +/// (the inbox is in-memory, so after a restart answered requests would +/// otherwise reappear as pending and refuse re-answering with 422). +fn check_answered(relay: std::sync::Arc, req: IncomingRequest) -> Task { + Task::perform( + async move { + let rid = hex::encode(req.request_id); + let res = relay.fetch_responses(&rid).map_err(|e| e.to_string()); + (req, res) + }, + |(req, result)| Message::AnswerChecked { req, result }, + ) +} + +fn answer_checked( + state: &mut App, + req: IncomingRequest, + result: Result, String>, +) -> Task { + let id = req.request_id; + let snapshot; + { + let Some(s) = state.session.as_mut() else { + return Task::none(); + }; + let already_visible = s.current.as_ref().is_some_and(|p| p.req.request_id == id) + || s.queue.iter().any(|q| q.request_id == id); + match result { + Ok(list) => { + let mine = list + .into_iter() + .find(|r| r.response.responder == s.signer.pubkey()); + match mine { + Some(r) => { + let decision = r.response.decision; + s.inbox.set_outcome(&id, Outcome::from(decision)); + s.queue.retain(|q| q.request_id != id); + if s.current.as_ref().is_some_and(|p| p.req.request_id == id) { + s.current = None; + advance(s); + } + let label = outcome_label(decision.into()); + s.status = Some(format!("request already {label}")); + } + None => { + // Genuinely unanswered: surface it now, but only when + // its window is still open (stale stay history-only). + if !already_visible + && s.inbox.outcome_of(&id) == Some(Outcome::Pending) + { + s.queue.push_back(req); + } + let n = s.queue.len(); + s.status = Some(match n { + 1 => "1 new request".to_string(), + k => format!("{k} queued requests"), + }); + advance(s); + } + } + snapshot = true; + } + Err(e) => { + // Can't tell; keep it visible rather than silently dropping. + if !already_visible { + s.queue.push_back(req); + } + s.status = Some(format!("answer check failed: {e}")); + advance(s); + snapshot = false; + } + } + } + if snapshot { + snapshot_history(state); + } + Task::none() +} + +// ---------------------------------------------------------------- prompt + +fn prompt_outcome(state: &mut App, o: PromptOutcome) -> Task { + match o { + PromptOutcome::Approved => decide(state, Decision::Allow), + PromptOutcome::Denied => decide(state, Decision::Deny), + PromptOutcome::Ignored => { + // Native toast dismissed: keep the in-app card as the fallback. + if let Some(s) = state.session.as_mut() { + if let Some(p) = s.current.as_mut() { + p.native_shown = false; + } + } + Task::none() + } + } +} + +fn decide(state: &mut App, decision: Decision) -> Task { + let Some(s) = state.session.as_mut() else { + return Task::none(); + }; + if s.responding { + return Task::none(); + } + let Some(req) = s.current.as_ref().map(|p| p.req.clone()) else { + return Task::none(); + }; + s.responding = true; + let relay = s.relay.clone(); + let signer = s.signer.clone(); + Task::perform( + async move { + let res = relay.respond(&signer, &req.request, &req.tce, decision); + (req.request_id, decision, res.map_err(|e| e.to_string())) + }, + |(id, d, res)| Message::Responded { id, decision: d, result: res }, + ) +} + +fn responded( + state: &mut App, + id: [u8; 32], + decision: Decision, + result: Result, +) -> Task { + let mut snapshot = false; + { + let Some(s) = state.session.as_mut() else { + return Task::none(); + }; + s.responding = false; + match result { + Ok(object_id) => { + s.inbox.set_outcome(&id, decision.into()); + s.current = None; + s.status = + Some(format!("response {} stored ({object_id})", decision.as_str())); + advance(s); + snapshot = true; + } + Err(e) => { + // The server refuses a second answer (one response per + // request): our local state is behind, reconcile from relay. + if e.contains("already answered") { + if let Some(p) = s.current.as_ref().filter(|p| p.req.request_id == id) { + let req = p.req.clone(); + s.status = Some("reconciling with relay…".to_string()); + return check_answered(s.relay.clone(), req); + } + s.status = Some("already answered elsewhere".to_string()); + return Task::none(); + } + if is_auth_error(&e) { + // Keep the card up; after re-login the user can answer. + s.status = Some("session expired, re-authenticating…".to_string()); + return reauth(s); + } + let expired = s.current.as_ref().is_some_and(|p| { + !niko_trust_gui::inbox::in_window( + p.req.request.created_at, + p.req.request.expires_at, + now_secs(), + ) + }); + s.status = Some(format!("response failed: {e}")); + if expired { + s.inbox.set_outcome(&id, Outcome::TimedOut); + s.current = None; + advance(s); + snapshot = true; + } + } + } + } + if snapshot { + snapshot_history(state); + } + Task::none() +} + +/// Makes the next queued request the active prompt (with a native toast when +/// the platform supports one). +fn advance(s: &mut Session) { + if s.current.is_some() { + return; + } + if let Some(req) = s.queue.pop_front() { + let native_shown = notify::interactive_supported(); + if native_shown { + fire_native(&req); + } + s.current = Some(Prompt { req, native_shown }); + } +} + +fn fire_native(req: &IncomingRequest) { + let n = ApprovalNotification { + sender: format!("{}", req.sender), + action: req.request.action.clone(), + message: req.request.message.clone(), + }; + let (tx, rx) = fchannel::mpsc::channel(1); + *PROMPT_RX.lock().expect("prompt channel lock") = Some(rx); + std::thread::spawn(move || { + let handle = notify::show(n); + let outcome = handle.wait_timeout(NATIVE_PROMPT_TIMEOUT); + let mut tx = tx; + let _ = tx.try_send(outcome); + }); +} + +// ---------------------------------------------------------------- view + +fn view<'a>(state: &'a App) -> Element<'a, Message> { + match &state.session { + None => view_locked(state), + Some(_) => view_main(state), + } +} + +fn view_locked<'a>(state: &'a App) -> Element<'a, Message> { + let mut items: Vec> = Vec::new(); + items.push( + Row::with_children(vec![ + text("Niko").size(32).color(c(COL_TEXT)).into(), + text("Trust").size(32).color(c(COL_ACCENT)).into(), + ]) + .into(), + ); + items.push( + text(if state.has_identity { + "Unlock your identity." + } else { + "Create a new identity with a password." + }) + .size(14) + .color(label_color()) + .into(), + ); + items.push(Space::new().height(24).into()); + + items.push(text("Relay").size(13).color(label_color()).into()); + items.push( + TextInput::new("https://trust.n1ko.dev", &state.relay_url) + .on_input(Message::RelayUrlChanged) + .width(Length::Fill) + .padding(10).style(input_style) + .into(), + ); + items.push(Space::new().height(14).into()); + + items.push(text("Password").size(13).color(label_color()).into()); + items.push( + TextInput::new("password", &state.password) + .secure(true) + .on_input(Message::PasswordChanged) + .on_submit(if state.has_identity { + Message::Unlock + } else { + Message::Create + }) + .width(Length::Fill) + .padding(10).style(input_style) + .into(), + ); + + if !state.has_identity { + items.push(Space::new().height(14).into()); + items.push(text("Confirm password").size(13).color(label_color()).into()); + items.push( + TextInput::new("password again", &state.confirm) + .secure(true) + .on_input(Message::ConfirmChanged) + .on_submit(Message::Create) + .width(Length::Fill) + .padding(10) + .into(), + ); + } + + items.push(Space::new().height(24).into()); + let (label, msg) = if state.has_identity { + ("Unlock", Message::Unlock) + } else { + ("Create identity", Message::Create) + }; + items.push( + button(text(label)) + .style(btn_primary) + .on_press_maybe((!state.connecting).then_some(msg)) + .width(Length::Fill) + .padding(12) + .into(), + ); + + if state.connecting { + items.push(Space::new().height(10).into()); + items.push( + text("Connecting…") + .size(13) + .color(label_color()) + .into(), + ); + } + if let Some(e) = &state.lock_error { + items.push(Space::new().height(10).into()); + items.push(text(e.clone()).size(13).color(error_color()).into()); + } + + let col = Column::with_children(items).spacing(10); + let card = Container::new(col) + .width(400) + .padding(28) + .style(card_style); + Container::new(card) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +/// Main shell: fixed top bar, scrollable content, fixed bottom tab bar. +/// Everything is capped to a phone-like column and centered on wide screens. +fn view_main<'a>(state: &'a App) -> Element<'a, Message> { + let s = state.session.as_ref().expect("view_main without session"); + + let content: Element = match state.screen { + Screen::Requests => view_requests(state), + Screen::Book => view_book_list(state), + Screen::Settings => view_settings(state), + }; + + let shell = Column::with_children(vec![ + top_bar(s.status.as_ref()), + divider().into(), + Container::new(content) + .width(Length::Fill) + .height(Length::Fill) + .padding(iced::Padding { top: 14.0, right: 6.0, bottom: 12.0, left: 6.0 }) + .into(), + tab_bar(state.screen), + ]) + .width(Length::Fill) + .height(Length::Fill) + .padding(12); + + // Fixed-width column… + let capped = + Container::new(shell).width(APP_MAX_WIDTH as f32).height(Length::Fill); + // …centered by a full-size outer container. + Container::new(capped) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .into() +} + +/// Maximum width of the app column (px). Keeps the layout compact on +/// large monitors instead of stretching edge to edge. +const APP_MAX_WIDTH: u32 = 540; + +/// Brand + live status pill. +fn top_bar<'a>(status: Option<&String>) -> Element<'a, Message> { + Row::with_children(vec![ + Row::with_children(vec![ + text("niko").size(19).color(c(COL_TEXT)).into(), + text("trust").size(19).color(c(COL_ACCENT)).into(), + ]) + .align_y(alignment::Vertical::Center) + .into(), + Space::new().width(Length::Fill).into(), + status_pill(status), + ]) + .align_y(alignment::Vertical::Center) + .width(Length::Fill) + .padding([4, 4]) + .into() +} + +/// One horizontal hairline. +fn divider() -> Container<'static, Message> { + Container::new(Space::new().height(1)) + .width(Length::Fill) + .style(|_t| container::Style { + background: Some(c(COL_BORDER).into()), + ..Default::default() + }) +} + +/// A small colored dot + short status text (truncated). +fn status_pill<'a>(status: Option<&String>) -> Element<'a, Message> { + let text_s = status.map(String::as_str).unwrap_or("online"); + let lower = text_s.to_lowercase(); + let color = if ["failed", "error", "refus", "timeout", "expired"] + .iter() + .any(|w| lower.contains(w)) + { + COL_RED + } else if ["re-auth", "reconcil", "checking", "waiting"].iter().any(|w| lower.contains(w)) { + COL_AMBER + } else { + COL_GREEN + }; + let mut label = text_s.to_string(); + if label.chars().count() > 34 { + label = format!("{}…", label.chars().take(34).collect::()); + } + + Row::with_children(vec![ + dot(color), + Space::new().width(7).into(), + text(label).size(11).color(label_color()).into(), + ]) + .align_y(alignment::Vertical::Center) + .into() +} + +/// A filled circle of the given color. +fn dot(color: u32) -> Element<'static, Message> { + Container::new(Space::new().width(8).height(8)) + .style(move |_t| container::Style { + background: Some(c(color).into()), + border: border(Color::TRANSPARENT, 0.0, 4.0), + ..Default::default() + }) + .into() +} + +/// Bottom navigation dock with an active segment highlight. +fn tab_bar(active: Screen) -> Element<'static, Message> { + let tab = |label: &'static str, sc: Screen| { + button( + text(label) + .size(13) + .width(Length::Fill) + .align_x(alignment::Horizontal::Center), + ) + .style(move |t, st| btn_tab(sc == active, t, st)) + .on_press(Message::ShowTab(sc)) + .width(Length::Fill) + .padding([9, 0]) + }; + let tabs: Vec> = vec![ + tab("Requests", Screen::Requests).into(), + Space::new().width(6).into(), + tab("Contacts", Screen::Book).into(), + Space::new().width(6).into(), + tab("Settings", Screen::Settings).into(), + ]; + Container::new(Row::with_children(tabs).width(Length::Fill)) + .width(Length::Fill) + .padding([6, 6]) + .style(|_t| container::Style { + background: Some(c(COL_SURFACE).into()), + border: border(c(COL_BORDER), 1.0, 14.0), + ..Default::default() + }) + .into() +} + +fn btn_tab(is_active: bool, _t: &Theme, s: button::Status) -> button::Style { + let pressed = matches!(s, button::Status::Pressed); + if is_active { + let bg = match s { + button::Status::Pressed => c(0x7A34D9), + button::Status::Hovered => c(0x8A3DF0), + _ => c(COL_ACCENT), + }; + button::Style { + background: Some(bg.into()), + text_color: c(0xFFFFFF), + border: border(c(COL_ACCENT), 0.0, 9.0), + ..Default::default() + } + } else { + button::Style { + background: Some(c(if pressed { 0x181D26 } else { 0x12151D }).into()), + text_color: c(if matches!(s, button::Status::Hovered) { COL_TEXT } else { 0x767E92 }), + border: border(Color::TRANSPARENT, 0.0, 9.0), + ..Default::default() + } + } +} + +/// Requests tab: identity card, active prompt, history. +fn view_requests<'a>(state: &'a App) -> Element<'a, Message> { + let s = state.session.as_ref().expect("view_requests without session"); + let mut items: Vec> = Vec::new(); + + // Identity card. + items.push( + Container::new( + Column::with_children(vec![ + text("MY ADDRESS").size(10).color(label_color()).into(), + Row::with_children(vec![ + text(short_addr(&s.my_address)).size(15).color(c(COL_TEXT)).into(), + Space::new().width(Length::Fill).into(), + button(text("copy").size(12)) + .style(btn_secondary) + .on_press(Message::CopyText(s.my_address.clone())) + .padding([4, 12]) + .into(), + ]) + .align_y(alignment::Vertical::Center) + .width(Length::Fill) + .into(), + ]) + .spacing(5), + ) + .width(Length::Fill) + .padding(14) + .style(card_style) + .into(), + ); + + if let Some(edit) = &state.book_edit { + items.push(Space::new().height(12).into()); + items.push(view_book_editor(state, edit)); + } + + items.push(Space::new().height(12).into()); + + if let Some(p) = &s.current { + items.push(view_prompt(state, p)); + items.push(Space::new().height(16).into()); + } + + items.push( + Row::with_children(vec![ + text("History").size(15).color(c(COL_TEXT)).into(), + Space::new().width(Length::Fill).into(), + text(format!("{} total", s.inbox.history().len())) + .size(11) + .color(label_color()) + .into(), + ]) + .align_y(alignment::Vertical::Center) + .into(), + ); + items.push(Space::new().height(8).into()); + items.push(view_history(state)); + + scrollable(Column::with_children(items).spacing(0).width(Length::Fill)) + .width(Length::Fill) + .height(Length::Fill) + .style(scroll_style) + .into() +} + + +/// Settings screen: relay URL, autostart, password change. +fn view_settings<'a>(state: &'a App) -> Element<'a, Message> { + let can_change = state.session.is_some() + && state.new_password.len() >= MIN_PASSWORD_LEN + && !state.new_confirm.is_empty(); + + let items: Vec> = vec![ + text("Relay").size(13).color(label_color()).into(), + TextInput::new("https://trust.n1ko.dev", &state.relay_url) + .on_input(Message::RelayUrlChanged) + .width(Length::Fill) + .padding(10).style(input_style) + .into(), + Space::new().height(16).into(), + Checkbox::new(state.autostart_enabled) + .label("Launch at startup") + .on_toggle(Message::ToggleAutostart) + .size(18) + .style(checkbox_style) + .into(), + Space::new().height(24).into(), + text("Change password").size(16).into(), + TextInput::new("new password", &state.new_password) + .secure(true) + .on_input(Message::NewPasswordChanged) + .width(Length::Fill) + .padding(10).style(input_style) + .into(), + Space::new().height(8).into(), + TextInput::new("repeat new password", &state.new_confirm) + .secure(true) + .on_input(Message::NewConfirmChanged) + .on_submit(Message::ChangePassword) + .width(Length::Fill) + .padding(10).style(input_style) + .into(), + Space::new().height(12).into(), + button(text("Update password")) + .style(btn_primary) + .on_press_maybe(can_change.then_some(Message::ChangePassword)) + .width(Length::Fill) + .padding(12) + .into(), + ]; + + scrollable(Column::with_children(items).spacing(8).width(Length::Fill)).style(scroll_style) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// A clickable address chip: pressing it copies the *full* address. Unknown +/// addresses get an "add" button that opens the address-book editor. +fn addr_line<'a>( + book: &'a AddressBook, + addr: &str, + size: f32, +) -> Vec> { + let known = book.label_of(addr).is_some(); + let mut out: Vec> = Vec::new(); + out.push( + button(text(book.display(addr)).size(size)) + .style(btn_text) + .on_press(Message::CopyText(addr.to_string())) + .padding(0) + .into(), + ); + if !known { + out.push(Space::new().width(4).into()); + out.push( + button(text("+book").size(size - 1.0)) + .style(btn_secondary) + .on_press(Message::BookAdd(addr.to_string())) + .padding([2, 8]) + .into(), + ); + } + out +} + +/// Inline editor for a label of one address. An empty `addr` means a new +/// entry: the address itself becomes editable. +fn view_book_editor<'a>(_state: &'a App, e: &'a BookEdit) -> Element<'a, Message> { + let mut body: Vec> = vec![text("Address book").size(14).into()]; + if e.addr.is_empty() { + body.push( + TextInput::new("trust1… address", &e.addr) + .on_input(Message::BookAddr) + .width(Length::Fill) + .padding(8).style(input_style) + .into(), + ); + } else { + body.push(text(short_addr(&e.addr)).size(12).color(label_color()).into()); + } + body.push( + TextInput::new("label", &e.label) + .on_input(Message::BookLabel) + .on_submit(Message::BookSave) + .width(Length::Fill) + .padding(8) + .into(), + ); + if let Some(err) = &e.error { + body.push(text(err.clone()).size(12).color(error_color()).into()); + } + body.push( + Row::with_children(vec![ + button(text(if e.label.trim().is_empty() && !e.addr.is_empty() { + "Remove" + } else { + "Save" + })) + .style(btn_primary) + .on_press(Message::BookSave) + .padding([6, 16]) + .into(), + Space::new().width(8).into(), + button(text("Cancel")) + .style(btn_secondary) + .on_press(Message::BookCancel) + .padding([6, 16]) + .into(), + ]) + .align_y(alignment::Vertical::Center) + .into(), + ); + + Container::new(Column::with_children(body).spacing(8)) + .width(Length::Fill) + .padding(14) + .style(card_style) + .into() +} + +/// Full address-book screen: browse, copy, edit and delete entries. +fn view_book_list<'a>(state: &'a App) -> Element<'a, Message> { + let mut items: Vec> = Vec::new(); + + if let Some(edit) = &state.book_edit { + items.push(view_book_editor(state, edit)); + items.push(Space::new().height(10).into()); + } + + let entries: Vec<(&String, &String)> = state.book.entries().iter().collect(); + if entries.is_empty() && state.book_edit.is_none() { + items.push( + text("No entries yet.") + .size(13) + .color(c(0x6B7280)) + .into(), + ); + } + for (addr, label) in entries { + items.push( + Row::with_children(vec![ + Column::with_children(vec![ + text(label.clone()).size(13).into(), + text(short_addr(addr)).size(11).color(label_color()).into(), + ]) + .spacing(2) + .width(Length::Fill) + .into(), + button(text("copy")) + .style(btn_secondary) + .on_press(Message::CopyText(addr.clone())) + .padding([4, 10]) + .into(), + Space::new().width(6).into(), + button(text("edit")) + .style(btn_secondary) + .on_press(Message::BookAdd(addr.clone())) + .padding([4, 10]) + .into(), + Space::new().width(6).into(), + button(text("del")) + .style(btn_danger) + .on_press(Message::BookDelete(addr.clone())) + .padding([4, 10]) + .into(), + ]) + .align_y(alignment::Vertical::Center) + .into(), + ); + } + + items.push(Space::new().height(10).into()); + items.push( + button(text("+ Add entry")) + .style(btn_secondary) + .on_press(Message::BookAdd(String::new())) + .padding([6, 14]) + .into(), + ); + + scrollable(Column::with_children(items).spacing(8).width(Length::Fill)).style(scroll_style) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn view_prompt<'a>(state: &'a App, p: &'a Prompt) -> Element<'a, Message> { + let s = state.session.as_ref().expect("view_prompt without session"); + let actions = if s.responding { + Row::with_children(vec![text("Submitting…").size(14).into()]) + } else { + Row::with_children(vec![ + button(text("Approve")) + .style(btn_success) + .on_press(Message::Approve) + .padding(10) + .into(), + Space::new().width(8).into(), + button(text("Deny")) + .style(btn_danger) + .on_press(Message::Deny) + .padding(10) + .into(), + ]) + }; + + let sender_addr = p.req.sender.to_string(); + let mut from_children: Vec> = + vec![text("from").size(13).color(label_color()).into()]; + from_children.push(Space::new().width(4).into()); + from_children.extend(addr_line(&state.book, sender_addr, 13.0)); + let from_line: Element = + Row::with_children(from_children).align_y(alignment::Vertical::Center).into(); + + let body = Column::with_children(vec![ + text("Approval requested").size(18).color(c(COL_ACCENT)).into(), + from_line, + text(p.req.request.action.clone()).size(16).into(), + text(p.req.request.message.clone()).size(14).into(), + text(expires_in(p)).size(12).color(label_color()).into(), + Space::new().height(4).into(), + actions.into(), + ]) + .spacing(8); + + Container::new(body) + .width(Length::Fill) + .padding(18) + .style(|_t| container::Style { + background: Some(c(COL_SURFACE).into()), + border: border(Color::from_rgba(0.60, 0.27, 1.0, 0.55), 1.2, 16.0), + ..Default::default() + }) + .into() +} + +// ---------------------------------------------------------------- theme +// +// Dark "crypto" look: deep blue-black surfaces, violet accent, mint green for +// confirmations. All widget styles are explicit, so the look is stable. + +fn c(hex: u32) -> Color { + Color::from_rgb8((hex >> 16) as u8, ((hex >> 8) & 0xff) as u8, (hex & 0xff) as u8) +} + +const COL_BG: u32 = 0x0A0C12; // app background +const COL_SURFACE: u32 = 0x12151D; // cards +const COL_FIELD: u32 = 0x0E1118; // inputs +const COL_BORDER: u32 = 0x242A38; +const COL_TEXT: u32 = 0xE8EBF2; +const COL_MUTED: u32 = 0x8B93A7; +const COL_ACCENT: u32 = 0x9945FF; // violet (Solana-ish) +const COL_GREEN: u32 = 0x14F195; // mint (Solana-ish) +const COL_RED: u32 = 0xE5484D; +const COL_AMBER: u32 = 0xFFC24B; + +fn border(color: Color, width: f32, radius: f32) -> border::Border { + border::Border { color, width, radius: radius.into() } +} + +/// The application theme handed to iced (covers widgets without custom styles). +fn app_theme() -> Theme { + Theme::custom( + "niko-dark", + iced::theme::Palette { + background: c(COL_BG), + text: c(COL_TEXT), + primary: c(COL_ACCENT), + success: c(COL_GREEN), + warning: c(COL_AMBER), + danger: c(COL_RED), + }, + ) +} + +/// `.theme` wants a plain function of the app state. +fn theme_of(_state: &App) -> Theme { + app_theme() +} + +fn btn_primary(_t: &Theme, s: button::Status) -> button::Style { + let bg = match s { + button::Status::Active => c(COL_ACCENT), + button::Status::Hovered => c(0x8A3DF0), + button::Status::Pressed => c(0x7A34D9), + button::Status::Disabled => c(0x251E3A), + }; + let txt = if matches!(s, button::Status::Disabled) { c(0x6B6580) } else { c(0xFFFFFF) }; + button::Style { + background: Some(bg.into()), + text_color: txt, + border: border(c(COL_ACCENT), 0.0, 10.0), + ..Default::default() + } +} + +fn btn_secondary(_t: &Theme, s: button::Status) -> button::Style { + let hovered = matches!(s, button::Status::Hovered); + let pressed = matches!(s, button::Status::Pressed); + button::Style { + background: Some(c(if pressed { 0x1B2029 } else if hovered { 0x181D26 } else { COL_SURFACE }).into()), + text_color: c(if pressed || hovered { COL_TEXT } else { 0xC6CCDA }), + border: border(c(if hovered { 0x39415A } else { COL_BORDER }), 1.0, 10.0), + ..Default::default() + } +} + +fn btn_success(_t: &Theme, s: button::Status) -> button::Style { + let bg = match s { + button::Status::Active => c(COL_GREEN), + button::Status::Hovered => c(0x3BF5A7), + button::Status::Pressed => c(0x0ED183), + button::Status::Disabled => c(0x143328), + }; + let txt = if matches!(s, button::Status::Disabled) { c(0x4E7A66) } else { c(0x05130C) }; + button::Style { + background: Some(bg.into()), + text_color: txt, + border: border(c(COL_GREEN), 0.0, 10.0), + ..Default::default() + } +} + +fn btn_danger(_t: &Theme, s: button::Status) -> button::Style { + let bg = match s { + button::Status::Active => c(COL_RED), + button::Status::Hovered => c(0xF2555A), + button::Status::Pressed => c(0xC93B40), + button::Status::Disabled => c(0x33191B), + }; + let txt = if matches!(s, button::Status::Disabled) { c(0x8A5558) } else { c(0xFFFFFF) }; + button::Style { + background: Some(bg.into()), + text_color: txt, + border: border(c(COL_RED), 0.0, 10.0), + ..Default::default() + } +} + +fn btn_text(_t: &Theme, s: button::Status) -> button::Style { + button::Style { + background: None, + text_color: c(match s { + button::Status::Hovered | button::Status::Pressed => COL_TEXT, + button::Status::Disabled => 0x5A6070, + _ => COL_MUTED, + }), + border: border(Color::TRANSPARENT, 0.0, 8.0), + ..Default::default() + } +} + +fn card_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(c(COL_SURFACE).into()), + border: border(c(COL_BORDER), 1.0, 14.0), + ..Default::default() + } +} + +fn input_style(_t: &Theme, s: text_input::Status) -> text_input::Style { + let bd = match s { + text_input::Status::Focused { .. } => border(c(COL_ACCENT), 1.4, 10.0), + text_input::Status::Hovered => border(c(0x333B50), 1.0, 10.0), + _ => border(c(COL_BORDER), 1.0, 10.0), + }; + text_input::Style { + background: c(COL_FIELD).into(), + border: bd, + icon: c(COL_MUTED), + placeholder: c(0x5F6779), + value: c(COL_TEXT), + selection: Color::from_rgba(0.60, 0.27, 1.0, 0.35), + } +} + +fn scroll_style(_t: &Theme, _s: scrollable::Status) -> scrollable::Style { + let rail = |scroller: Color| scrollable::Rail { + background: None, + border: border(Color::TRANSPARENT, 0.0, 4.0), + scroller: scrollable::Scroller { + background: scroller.into(), + border: border(Color::TRANSPARENT, 0.0, 4.0), + }, + }; + scrollable::Style { + container: container::Style::default(), + vertical_rail: rail(c(0x2B3242)), + horizontal_rail: rail(c(0x2B3242)), + gap: None, + auto_scroll: scrollable::AutoScroll { + background: c(0x1B2029).into(), + border: border(c(COL_BORDER), 1.0, 8.0), + shadow: iced::Shadow::default(), + icon: c(COL_TEXT), + }, + } +} + +fn checkbox_style(_t: &Theme, s: checkbox::Status) -> checkbox::Style { + let checked = match s { + checkbox::Status::Active { is_checked } => is_checked, + checkbox::Status::Hovered { is_checked } => is_checked, + checkbox::Status::Disabled { is_checked } => is_checked, + }; + let hovered = matches!(s, checkbox::Status::Hovered { .. }); + checkbox::Style { + background: c(if checked { COL_ACCENT } else { COL_FIELD }).into(), + icon_color: c(0xFFFFFF), + border: border( + c(if checked { COL_ACCENT } else if hovered { 0x39415A } else { COL_BORDER }), + 1.2, + 6.0, + ), + text_color: None, + } +} + +fn label_color() -> Color { + c(COL_MUTED) +} + +fn error_color() -> Color { + c(COL_RED) +} + +fn view_history<'a>(state: &'a App) -> Element<'a, Message> { + let s = state.session.as_ref().expect("view_history without session"); + let items = s.inbox.history(); + if items.is_empty() { + return Container::new( + text("No requests yet.").size(13).color(c(0x6B7280)), + ) + .width(Length::Fill) + .padding(14) + .style(|_t| container::Style { + background: Some(c(COL_SURFACE).into()), + border: border(c(COL_BORDER), 1.0, 10.0), + ..Default::default() + }) + .into(); + } + let now = now_secs(); + let rows: Vec> = items + .iter() + .map(|h| { + let sender_addr = h.sender.to_string(); + + Row::with_children(vec![ + dot(outcome_dot(h.outcome)), + Space::new().width(10).into(), + Column::with_children(vec![ + Row::with_children(vec![ + text(h.action.clone()).size(13).color(c(COL_TEXT)).into(), + text(format!(" · {}", outcome_label(h.outcome))) + .size(11) + .color(outcome_color(h.outcome)) + .into(), + ]) + .align_y(alignment::Vertical::Center) + .into(), + Row::with_children({ + let mut ch: Vec> = + vec![text("from").size(11).color(c(0x6B7280)).into()]; + ch.push(Space::new().width(3).into()); + ch.extend(addr_line(&state.book, sender_addr, 11.0)); + ch + }) + .align_y(alignment::Vertical::Center) + .into(), + ]) + .spacing(2) + .width(Length::Fill) + .into(), + Space::new().width(8).into(), + text(rel_time(now.saturating_sub(h.created_at))) + .size(11) + .color(c(0x6B7280)) + .into(), + ]) + .align_y(alignment::Vertical::Center) + .into() + }) + .map(|row: Element| { + Container::new(row) + .width(Length::Fill) + .padding([10, 12]) + .style(|_t| container::Style { + background: Some(c(COL_SURFACE).into()), + border: border(c(COL_BORDER), 1.0, 10.0), + ..Default::default() + }) + .into() + }) + .collect(); + Column::with_children(rows).spacing(6).into() +} + +/// Dot color per outcome (slightly deeper than the label colors for contrast). +fn outcome_dot(o: Outcome) -> u32 { + match o { + Outcome::Pending => COL_AMBER, + Outcome::Approved => COL_GREEN, + Outcome::Denied => COL_RED, + Outcome::TimedOut | Outcome::Stale => 0x4B5263, + } +} + +// ---------------------------------------------------------------- helpers + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// `seconds` as a compact human duration: "42s", "5m 3s", "2h 7m". +fn rel_time(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + +fn expires_in(p: &Prompt) -> String { + let now = now_secs(); + if p.req.request.expires_at > now { + format!("expires in {}", rel_time(p.req.request.expires_at - now)) + } else { + "expired".to_string() + } +} + +fn outcome_color(o: Outcome) -> Color { + match o { + Outcome::Pending => c(COL_AMBER), + Outcome::Approved => c(COL_GREEN), + Outcome::Denied => c(COL_RED), + Outcome::TimedOut | Outcome::Stale => c(0x6B7280), + } +} +} // mod gui + +fn main() { + #[cfg(feature = "gui")] + gui::run().unwrap_or_else(|e| { + eprintln!("niko-trust-gui: {e}"); + std::process::exit(1); + }); + #[cfg(not(feature = "gui"))] + { + eprintln!("niko-trust-gui: built without the \"gui\" feature"); + std::process::exit(1); + } +} diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..a5fbb7d --- /dev/null +++ b/src/notify.rs @@ -0,0 +1,119 @@ +//! Notifications: show an approval request with Approve / Deny buttons. +//! +//! The notification is fire-and-forget from the UI's point of view (it runs on +//! its own thread); the user's decision arrives on a channel. If the backend +//! cannot show interactive notifications (or the user dismisses), the caller +//! falls back to an in-app prompt. + +use std::sync::mpsc; +use std::time::Duration; + +use crate::protocol::Decision; + +/// A native notification with exactly two actions. +pub struct ApprovalNotification { + /// Sender address, shown as the source line. + pub sender: String, + /// Request action (opaque). + pub action: String, + /// Human-readable message. + pub message: String, +} + +/// Outcome of a user interacting with a notification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptOutcome { + /// The user hit Approve. + Approved, + /// The user hit Deny. + Denied, + /// The notification was dismissed or the backend has no interactivity. + Ignored, +} + +/// Result of showing one notification. +pub struct PromptHandle { + rx: mpsc::Receiver, +} + +impl PromptHandle { + /// Blocks until the user answers. Returns immediately if the backend + /// reported no interactivity (the handle then yields `Ignored`). + pub fn wait(self) -> PromptOutcome { + self.rx.recv().unwrap_or(PromptOutcome::Ignored) + } + + /// Waits up to `timeout` for an answer; `Ignored` if the user is slow. + pub fn wait_timeout(self, timeout: Duration) -> PromptOutcome { + self.rx + .recv_timeout(timeout) + .unwrap_or(PromptOutcome::Ignored) + } +} + +/// Whether this platform can raise interactive notifications. +#[cfg(target_os = "linux")] +pub fn interactive_supported() -> bool { + notify_rust::dbus_stack().is_some() +} + +/// Windows/toast support is not wired yet; the in-app prompt covers it. +#[cfg(not(target_os = "linux"))] +pub fn interactive_supported() -> bool { + false +} + +/// Shows the notification and returns a handle to its outcome. +/// +/// The "not interactive" case still shows a plain, non-interactive toast so +/// the user at least sees the request, and returns a handle that yields +/// `Ignored` immediately. +#[cfg(target_os = "linux")] +pub fn show(n: ApprovalNotification) -> PromptHandle { + let (tx, rx) = mpsc::channel(); + let mut notif = notify_rust::Notification::new(); + notif.summary(&format!("Approval requested: {}", n.action)); + notif.body(&format!("{}\nfrom: {}", n.message, n.sender)); + notif.appname("niko-trust"); + notif.action("approve", "Approve"); + notif.action("deny", "Deny"); + + let handle = notif.show(); + match handle { + Ok(h) => { + std::thread::spawn(move || { + h.wait_for_action(|a| { + let _ = tx.send(match a { + "approve" => PromptOutcome::Approved, + "deny" => PromptOutcome::Denied, + _ => PromptOutcome::Ignored, + }); + }); + }); + } + Err(_) => { + // Backend down / non-interactive: what we showed (if anything) + // cannot answer. Report Ignored so the UI shows its own dialog. + let _ = tx.send(PromptOutcome::Ignored); + } + } + PromptHandle { rx } +} + +/// Placeholder backend: no native toast outside Linux yet, so report `Ignored` +/// right away — the GUI falls back to its in-app prompt card. +#[cfg(not(target_os = "linux"))] +pub fn show(_n: ApprovalNotification) -> PromptHandle { + let (tx, rx) = mpsc::channel(); + let _ = tx.send(PromptOutcome::Ignored); + PromptHandle { rx } +} + +/// Maps a prompt outcome to a protocol decision, if the user chose one. +pub fn to_decision(o: PromptOutcome) -> Option { + match o { + PromptOutcome::Approved => Some(Decision::Allow), + PromptOutcome::Denied => Some(Decision::Deny), + PromptOutcome::Ignored => None, + } +} \ No newline at end of file diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..e4efe26 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,402 @@ +//! Protocol objects: field order and rules for the six signed object types. +//! Field order here is exactly the order in PROTOCOL.md section 8 and matches +//! the Go reference (internal/protocol). + +use std::collections::BTreeMap; + +use crate::address::{validate_pubkey, AddressError}; +use crate::crypto::sha256; +use crate::tce::decode::Decoder; +use crate::tce::encode::Encoder; +use crate::tce::value::Value; +use crate::tce::TceError; + +pub const MAX_CLOCK_SKEW: u64 = 120; + +pub use crate::tce::{ + MAX_ACTION_LEN, MAX_ALIAS_LEN, MAX_AUDIENCE_LEN, MAX_AUTH_TCE, MAX_CLAIM_TCE, MAX_IDENTITY_TCE, + MAX_MESSAGE_LEN, MAX_REASON_LEN, MAX_REQUEST_TCE, MAX_RESPONSE_TCE, MAX_REVOC_TCE, + MAX_SCOPE_LEN, +}; + +fn validate_identity(pubkey: &[u8; 32]) -> Result<(), AddressError> { + validate_pubkey(pubkey) +} + +fn check_end(d: &Decoder) -> Result<(), TceError> { + d.end() +} + +// --------------------------------------------------------------------------- +// IdentityRegistration (0x01) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Identity { + pub pubkey: [u8; crate::tce::PUBKEY_SIZE], + pub alias: String, + pub created_at: u64, +} + +pub fn encode_identity(o: &Identity) -> Result, TceError> { + validate_identity(&o.pubkey).map_err(|_| TceError::Protocol("identity: invalid key".into()))?; + let mut e = Encoder::new(); + e.header(crate::tce::TAG_IDENTITY); + e.identity(&o.pubkey); + e.string(&o.alias, MAX_ALIAS_LEN); + e.timestamp(o.created_at, false); + e.check_size(MAX_IDENTITY_TCE); + e.bytes() +} + +pub fn decode_identity(b: &[u8]) -> Result { + if b.len() > MAX_IDENTITY_TCE { + return Err(TceError::ObjectTooLarge); + } + let mut d = Decoder::new(b); + let tag = d.header()?; + if tag != crate::tce::TAG_IDENTITY { + return Err(TceError::WrongObject); + } + let pk = d.identity()?; + validate_identity(&pk).map_err(|_| TceError::Protocol("identity: invalid key".into()))?; + let alias = d.string(MAX_ALIAS_LEN)?; + let created_at = d.timestamp(false)?; + check_end(&d)?; + Ok(Identity { pubkey: pk, alias, created_at }) +} + +// --------------------------------------------------------------------------- +// Claim (0x02) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Claim { + pub issuer: [u8; crate::tce::PUBKEY_SIZE], + pub subject: [u8; crate::tce::PUBKEY_SIZE], + pub claims: BTreeMap, + pub created_at: u64, + pub expires_at: u64, + pub serial: u64, + pub nonce: [u8; crate::tce::NONCE_SIZE], +} + +pub fn encode_claim(o: &Claim) -> Result, TceError> { + validate_identity(&o.issuer).map_err(|_| TceError::Protocol("claim: issuer".into()))?; + validate_identity(&o.subject).map_err(|_| TceError::Protocol("claim: subject".into()))?; + if o.nonce.len() != crate::tce::NONCE_SIZE { + return Err(TceError::FieldSize); + } + if o.expires_at != 0 && o.expires_at <= o.created_at { + return Err(TceError::Expiry); + } + let mut e = Encoder::new(); + e.header(crate::tce::TAG_CLAIM); + e.identity(&o.issuer); + e.identity(&o.subject); + e.map(&o.claims, 1); + e.timestamp(o.created_at, false); + e.timestamp(o.expires_at, true); + e.uvarint(o.serial); + e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE); + e.check_size(MAX_CLAIM_TCE); + e.bytes() +} + +pub fn decode_claim(b: &[u8]) -> Result { + if b.len() > MAX_CLAIM_TCE { + return Err(TceError::ObjectTooLarge); + } + let mut d = Decoder::new(b); + let tag = d.header()?; + if tag != crate::tce::TAG_CLAIM { + return Err(TceError::WrongObject); + } + let issuer = d.identity()?; + validate_identity(&issuer).map_err(|_| TceError::Protocol("claim: issuer".into()))?; + let subject = d.identity()?; + validate_identity(&subject).map_err(|_| TceError::Protocol("claim: subject".into()))?; + let claims = d.map(1)?; + let created_at = d.timestamp(false)?; + let expires_at = d.timestamp(true)?; + if expires_at != 0 && expires_at <= created_at { + return Err(TceError::Expiry); + } + let serial = d.uvarint()?; + let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?; + check_end(&d)?; + let mut nonce = [0u8; crate::tce::NONCE_SIZE]; + nonce.copy_from_slice(nonce_b); + Ok(Claim { issuer, subject, claims, created_at, expires_at, serial, nonce }) +} + +// --------------------------------------------------------------------------- +// Revocation (0x03) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Revocation { + pub issuer: [u8; crate::tce::PUBKEY_SIZE], + pub claim_id: [u8; crate::tce::HASH_SIZE], + pub reason: String, + pub created_at: u64, + pub nonce: [u8; crate::tce::NONCE_SIZE], +} + +pub fn encode_revocation(o: &Revocation) -> Result, TceError> { + validate_identity(&o.issuer).map_err(|_| TceError::Protocol("revocation: issuer".into()))?; + if o.nonce.len() != crate::tce::NONCE_SIZE { + return Err(TceError::FieldSize); + } + let mut e = Encoder::new(); + e.header(crate::tce::TAG_REVOCATION); + e.identity(&o.issuer); + e.fixed_bytes(&o.claim_id, crate::tce::HASH_SIZE); + e.string(&o.reason, MAX_REASON_LEN); + e.timestamp(o.created_at, false); + e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE); + e.check_size(MAX_REVOC_TCE); + e.bytes() +} + +pub fn decode_revocation(b: &[u8]) -> Result { + if b.len() > MAX_REVOC_TCE { + return Err(TceError::ObjectTooLarge); + } + let mut d = Decoder::new(b); + let tag = d.header()?; + if tag != crate::tce::TAG_REVOCATION { + return Err(TceError::WrongObject); + } + let issuer = d.identity()?; + validate_identity(&issuer).map_err(|_| TceError::Protocol("revocation: issuer".into()))?; + let claim_b = d.fixed_bytes(crate::tce::HASH_SIZE)?; + let mut claim_id = [0u8; crate::tce::HASH_SIZE]; + claim_id.copy_from_slice(claim_b); + let reason = d.string(MAX_REASON_LEN)?; + let created_at = d.timestamp(false)?; + let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?; + check_end(&d)?; + let mut nonce = [0u8; crate::tce::NONCE_SIZE]; + nonce.copy_from_slice(nonce_b); + Ok(Revocation { issuer, claim_id, reason, created_at, nonce }) +} + +// --------------------------------------------------------------------------- +// ApprovalRequest (0x04) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ApprovalRequest { + pub sender: [u8; crate::tce::PUBKEY_SIZE], + pub recipient: [u8; crate::tce::PUBKEY_SIZE], + pub action: String, + pub payload: BTreeMap, + pub message: String, + pub created_at: u64, + pub expires_at: u64, + pub nonce: [u8; crate::tce::NONCE_SIZE], +} + +impl ApprovalRequest { + /// Lowercase-hex request id over the exact request bytes. + pub fn request_id(&self, tce: &[u8]) -> String { + hex::encode(sha256(tce)) + } +} + +pub fn encode_approval_request(o: &ApprovalRequest) -> Result, TceError> { + validate_identity(&o.sender).map_err(|_| TceError::Protocol("request: sender".into()))?; + validate_identity(&o.recipient).map_err(|_| TceError::Protocol("request: recipient".into()))?; + if o.nonce.len() != crate::tce::NONCE_SIZE { + return Err(TceError::FieldSize); + } + if o.expires_at <= o.created_at { + return Err(TceError::Expiry); + } + if o.expires_at - o.created_at > crate::tce::MAX_APPROVAL_LIFETIME { + return Err(TceError::Lifetime); + } + let mut e = Encoder::new(); + e.header(crate::tce::TAG_APPROVAL_REQUEST); + e.identity(&o.sender); + e.identity(&o.recipient); + e.string(&o.action, MAX_ACTION_LEN); + e.map(&o.payload, 0); + e.string(&o.message, MAX_MESSAGE_LEN); + e.timestamp(o.created_at, false); + e.timestamp(o.expires_at, false); + e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE); + e.check_size(MAX_REQUEST_TCE); + e.bytes() +} + +pub fn decode_approval_request(b: &[u8]) -> Result { + if b.len() > MAX_REQUEST_TCE { + return Err(TceError::ObjectTooLarge); + } + let mut d = Decoder::new(b); + let tag = d.header()?; + if tag != crate::tce::TAG_APPROVAL_REQUEST { + return Err(TceError::WrongObject); + } + let sender = d.identity()?; + validate_identity(&sender).map_err(|_| TceError::Protocol("request: sender".into()))?; + let recipient = d.identity()?; + validate_identity(&recipient).map_err(|_| TceError::Protocol("request: recipient".into()))?; + let action = d.string(MAX_ACTION_LEN)?; + let payload = d.map(0)?; + let message = d.string(MAX_MESSAGE_LEN)?; + let created_at = d.timestamp(false)?; + let expires_at = d.timestamp(false)?; + if expires_at <= created_at { + return Err(TceError::Expiry); + } + if expires_at - created_at > crate::tce::MAX_APPROVAL_LIFETIME { + return Err(TceError::Lifetime); + } + let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?; + check_end(&d)?; + let mut nonce = [0u8; crate::tce::NONCE_SIZE]; + nonce.copy_from_slice(nonce_b); + Ok(ApprovalRequest { sender, recipient, action, payload, message, created_at, expires_at, nonce }) +} + +// --------------------------------------------------------------------------- +// ApprovalResponse (0x05) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Decision { + #[default] + Deny, + Allow, +} + +impl Decision { + pub fn from_u64(v: u64) -> Option { + match v { + crate::tce::DECISION_DENY => Some(Decision::Deny), + crate::tce::DECISION_ALLOW => Some(Decision::Allow), + _ => None, + } + } + pub fn as_u64(&self) -> u64 { + match self { + Decision::Deny => crate::tce::DECISION_DENY, + Decision::Allow => crate::tce::DECISION_ALLOW, + } + } + pub fn as_str(&self) -> &'static str { + match self { + Decision::Allow => "allow", + Decision::Deny => "deny", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ApprovalResponse { + pub request_hash: [u8; crate::tce::HASH_SIZE], + pub responder: [u8; crate::tce::PUBKEY_SIZE], + pub decision: Decision, + pub created_at: u64, + pub nonce: [u8; crate::tce::NONCE_SIZE], +} + +pub fn encode_approval_response(o: &ApprovalResponse) -> Result, TceError> { + if !matches!(o.decision, Decision::Deny | Decision::Allow) { + return Err(TceError::Decision); + } + validate_identity(&o.responder).map_err(|_| TceError::Protocol("response: responder".into()))?; + if o.nonce.len() != crate::tce::NONCE_SIZE { + return Err(TceError::FieldSize); + } + let mut e = Encoder::new(); + e.header(crate::tce::TAG_APPROVAL_RESPONSE); + e.fixed_bytes(&o.request_hash, crate::tce::HASH_SIZE); + e.identity(&o.responder); + e.uvarint(o.decision.as_u64()); + e.timestamp(o.created_at, false); + e.fixed_bytes(&o.nonce, crate::tce::NONCE_SIZE); + e.check_size(MAX_RESPONSE_TCE); + e.bytes() +} + +pub fn decode_approval_response(b: &[u8]) -> Result { + if b.len() > MAX_RESPONSE_TCE { + return Err(TceError::ObjectTooLarge); + } + let mut d = Decoder::new(b); + let tag = d.header()?; + if tag != crate::tce::TAG_APPROVAL_RESPONSE { + return Err(TceError::WrongObject); + } + let hash_b = d.fixed_bytes(crate::tce::HASH_SIZE)?; + let mut request_hash = [0u8; crate::tce::HASH_SIZE]; + request_hash.copy_from_slice(hash_b); + let responder = d.identity()?; + validate_identity(&responder).map_err(|_| TceError::Protocol("response: responder".into()))?; + let decision = Decision::from_u64(d.uvarint()?).ok_or(TceError::Decision)?; + let created_at = d.timestamp(false)?; + let nonce_b = d.fixed_bytes(crate::tce::NONCE_SIZE)?; + check_end(&d)?; + let mut nonce = [0u8; crate::tce::NONCE_SIZE]; + nonce.copy_from_slice(nonce_b); + Ok(ApprovalResponse { request_hash, responder, decision, created_at, nonce }) +} + +// --------------------------------------------------------------------------- +// AuthAssertion (0x06) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AuthAssertion { + pub pubkey: [u8; crate::tce::PUBKEY_SIZE], + pub challenge: [u8; crate::tce::CHALLENGE_SIZE], + pub scope: String, + pub audience: String, + pub created_at: u64, +} + +pub fn encode_auth_assertion(o: &AuthAssertion) -> Result, TceError> { + validate_identity(&o.pubkey).map_err(|_| TceError::Protocol("auth: identity".into()))?; + if o.challenge.len() != crate::tce::CHALLENGE_SIZE { + return Err(TceError::FieldSize); + } + let mut e = Encoder::new(); + e.header(crate::tce::TAG_AUTH_ASSERTION); + e.identity(&o.pubkey); + e.fixed_bytes(&o.challenge, crate::tce::CHALLENGE_SIZE); + e.string(&o.scope, MAX_SCOPE_LEN); + e.string(&o.audience, MAX_AUDIENCE_LEN); + e.timestamp(o.created_at, false); + e.check_size(MAX_AUTH_TCE); + e.bytes() +} + +pub fn decode_auth_assertion(b: &[u8]) -> Result { + if b.len() > MAX_AUTH_TCE { + return Err(TceError::ObjectTooLarge); + } + let mut d = Decoder::new(b); + let tag = d.header()?; + if tag != crate::tce::TAG_AUTH_ASSERTION { + return Err(TceError::WrongObject); + } + let pubkey = d.identity()?; + validate_identity(&pubkey).map_err(|_| TceError::Protocol("auth: identity".into()))?; + let chal_b = d.fixed_bytes(crate::tce::CHALLENGE_SIZE)?; + let mut challenge = [0u8; crate::tce::CHALLENGE_SIZE]; + challenge.copy_from_slice(chal_b); + let scope = d.string(MAX_SCOPE_LEN)?; + let audience = d.string(MAX_AUDIENCE_LEN)?; + let created_at = d.timestamp(false)?; + check_end(&d)?; + Ok(AuthAssertion { pubkey, challenge, scope, audience, created_at }) +} + +/// Lowercase-hex object id of TCE bytes. +pub fn object_id_of(tce: &[u8]) -> String { + hex::encode(sha256(tce)) +} \ No newline at end of file diff --git a/src/relay.rs b/src/relay.rs new file mode 100644 index 0000000..30ac876 --- /dev/null +++ b/src/relay.rs @@ -0,0 +1,442 @@ +//! Relay client: the AuthAssertion handshake, reading an approval inbox and +//! submitting ApprovalResponses over the JSON transport (docs/API.md). +//! +//! The server never verifies signatures on ingest (`POST /v1/objects` stores +//! any well-formed envelope), so every object this module *reads* is strictly +//! decoded and verified locally before it is shown to the user. Nothing here +//! ever re-encodes the `object` JSON view and verifies that; the verified +//! bytes are the received `tce` bytes. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use ureq::Body; + +use crate::address::{validate_pubkey, Address}; +use crate::protocol::{ + decode_approval_request, decode_approval_response, encode_approval_response, object_id_of, + ApprovalRequest, ApprovalResponse, AuthAssertion, Decision, +}; +use crate::signer::{verify, Signer}; +use crate::tce::{CHALLENGE_SIZE, NONCE_SIZE}; + +#[derive(Debug)] +pub enum RelayError { + /// Transport or HTTP-level failure (connection, timeout, bad status). + Http(String), + /// The relay returned an unexpected shape (missing field, bad base64...). + Malformed(String), + /// An incoming object failed strict verification. + Verify(String), + /// Local signing/encoding error. + Build(String), +} + +impl std::fmt::Display for RelayError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RelayError::Http(m) => write!(f, "relay http: {m}"), + RelayError::Malformed(m) => write!(f, "relay malformed: {m}"), + RelayError::Verify(m) => write!(f, "relay verify: {m}"), + RelayError::Build(m) => write!(f, "relay build: {m}"), + } + } +} + +impl std::error::Error for RelayError {} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn b64_encode(b: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(b) +} + +fn b64_decode(s: &str) -> Result, RelayError> { + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|e| RelayError::Malformed(format!("base64: {e}"))) +} + +fn agent() -> ureq::Agent { + ureq::Agent::new_with_config( + ureq::config::Config::builder().http_status_as_error(false).build(), + ) +} + +/// Body read helpers: ureq 3 returns `http::Response`; the body APIs +/// borrow (`&mut self`), so both helpers take the response by value, split it, +/// and return it for chained status handling. +fn body_text(body: &mut Body) -> String { + body.read_to_string().unwrap_or_default() +} + +fn body_json(body: &mut Body) -> Result { + body.read_json::().map_err(|e| RelayError::Malformed(format!("json: {e}"))) +} + +/// One signed object as carried by the wire (envelope). +#[derive(Debug, Clone)] +pub struct Envelope { + pub tce: Vec, + pub signature: [u8; 64], +} + +/// A verified, ready-to-show approval request. +#[derive(Debug, Clone)] +pub struct IncomingRequest { + pub request: ApprovalRequest, + /// The exact request TCE bytes as received. A response must hash *these* + /// bytes (re-encoding the decoded struct is not byte-exact). + pub tce: Vec, + /// request_id = SHA-256(exact request tce), the `request_hash` to answer. + pub request_id: [u8; 32], + /// Sender address, for display next to the message. + pub sender: Address, +} + +/// A verified approval response, as read by the requesting service. +#[derive(Debug, Clone)] +pub struct IncomingResponse { + pub response: ApprovalResponse, + /// The exact response TCE bytes as received. + pub tce: Vec, + /// Responder address, for display. + pub responder: Address, +} + +fn parse_envelope(v: &serde_json::Value) -> Result { + let tce = b64_decode( + v.get("tce") + .and_then(|x| x.as_str()) + .ok_or_else(|| RelayError::Malformed("missing tce".into()))?, + )?; + let sig_b = b64_decode( + v.get("signature") + .and_then(|x| x.as_str()) + .ok_or_else(|| RelayError::Malformed("missing signature".into()))?, + )?; + let signature: [u8; 64] = sig_b + .try_into() + .map_err(|_| RelayError::Malformed("signature not 64 bytes".into()))?; + Ok(Envelope { tce, signature }) +} + +/// Responder + signature verification for one listed response envelope. +fn verify_incoming_response(item: &serde_json::Value) -> Result { + let e = parse_envelope(item)?; + let response = decode_approval_response(&e.tce) + .map_err(|err| RelayError::Verify(format!("decode: {err}")))?; + + validate_pubkey(&response.responder) + .map_err(|err| RelayError::Verify(format!("responder key: {err}")))?; + verify(&response.responder, &e.tce, &e.signature) + .map_err(|err| RelayError::Verify(format!("signature: {err}")))?; + + let responder = Address::from_pubkey(&response.responder) + .map_err(|err| RelayError::Verify(format!("responder address: {err}")))?; + + Ok(IncomingResponse { response, tce: e.tce, responder }) +} + +/// Client for one relay endpoint. +pub struct Relay { + base: String, + agent: ureq::Agent, + audience: String, + token: String, +} + +#[derive(serde::Deserialize)] +struct AssertResponse { + session_token: String, +} + +#[derive(serde::Deserialize)] +struct ChallengeResponse { + challenge: String, +} + +#[allow(clippy::needless_pass_by_value)] +fn build_auth_assertion( + signer: &Signer, + challenge: &[u8; CHALLENGE_SIZE], + audience: &str, + scope: &str, +) -> Result<(Vec, [u8; 64]), RelayError> { + let now = now_secs(); + let a = AuthAssertion { + pubkey: signer.pubkey(), + challenge: *challenge, + scope: scope.to_string(), + audience: audience.to_string(), + created_at: now, + }; + let tce = crate::protocol::encode_auth_assertion(&a) + .map_err(|e| RelayError::Build(format!("encode auth: {e}")))?; + let sig = signer.sign(&tce); + Ok((tce, sig)) +} + +fn post_envelope( + agent: &ureq::Agent, + url: &str, + tce: &[u8], + sig: &[u8; 64], +) -> Result { + let body = serde_json::json!({ + "tce": b64_encode(tce), + "signature": b64_encode(sig), + }); + let mut resp = agent + .post(url) + .send_json(body) + .map_err(|e| RelayError::Http(format!("{e}")))?; + let status = resp.status().as_u16(); + if status != 200 { + let text = body_text(resp.body_mut()); + return Err(RelayError::Http(format!("status {status}: {text}"))); + } + body_json(resp.body_mut()) +} + +fn parse_client_err(raw: ureq::Error) -> RelayError { + RelayError::Http(raw.to_string()) +} + +impl Relay { + /// Fetches the server's audience and performs the challenge/assert + /// handshake, returning an authenticated client bound to `base`. + pub fn auth(base: &str, signer: &Signer, scope: &str) -> Result { + let base = base.trim_end_matches('/').to_string(); + let agent = agent(); + + // 1. audience + let mut cfg = agent.get(format!("{base}/v1/config")).call().map_err(parse_client_err)?; + let audience = { + let v: serde_json::Value = body_json(cfg.body_mut())?; + v.get("audience") + .and_then(|x| x.as_str()) + .ok_or_else(|| RelayError::Malformed("config: missing audience".into()))? + .to_string() + }; + if audience.is_empty() { + return Err(RelayError::Malformed("config: empty audience".into())); + } + + // 2. challenge + let mut chal = agent + .post(format!("{base}/v1/auth/challenge")) + .send_empty() + .map_err(parse_client_err)?; + let ch: ChallengeResponse = body_json(chal.body_mut())?; + let ch_b = hex::decode(&ch.challenge) + .map_err(|e| RelayError::Malformed(format!("challenge hex: {e}")))?; + let challenge: [u8; CHALLENGE_SIZE] = ch_b + .try_into() + .map_err(|_| RelayError::Malformed("challenge not 32 bytes".into()))?; + + // 3. assert + let (a_tce, a_sig) = build_auth_assertion(signer, &challenge, &audience, scope)?; + let out = post_envelope(&agent, &format!("{base}/v1/auth/assert"), &a_tce, &a_sig)?; + let ar: AssertResponse = serde_json::from_value(out) + .map_err(|e| RelayError::Malformed(format!("assert json: {e}")))?; + if ar.session_token.is_empty() { + return Err(RelayError::Malformed("assert: empty session_token".into())); + } + + Ok(Relay { base, agent, audience, token: ar.session_token }) + } + + pub fn audience(&self) -> &str { + &self.audience + } + + /// The relay this client is bound to (trailing slash trimmed). + pub fn base_url(&self) -> &str { + &self.base + } + + /// Fetches approval requests addressed to `signer`, strict-decodes and + /// verifies each, returning the ones that pass. + /// + /// A single poisoned envelope is skipped, not fatal: the relay can store + /// anything, so a client must never trust the feed blindly. + pub fn fetch_requests(&self, signer: &Signer) -> Result, RelayError> { + let addr = signer + .address() + .map_err(|e| RelayError::Build(format!("address: {e}")))?; + let url = format!("{}/v1/requests?recipient={}", self.base, addr); + let mut resp = self + .agent + .get(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .call() + .map_err(parse_client_err)?; + let status = resp.status().as_u16(); + if status != 200 { + let text = body_text(resp.body_mut()); + return Err(RelayError::Http(format!("status {status}: {text}"))); + } + let v: serde_json::Value = body_json(resp.body_mut())?; + let list = v + .get("requests") + .and_then(|x| x.as_array()) + .ok_or_else(|| RelayError::Malformed("requests: missing list".into()))?; + + let mut out = Vec::new(); + for item in list { + match self.verify_incoming_request(signer, item) { + Ok(r) => out.push(r), + Err(e) => eprintln!("relay: skipping bad request envelope: {e}"), + } + } + Ok(out) + } + + // Recipient + sender verification, then builds an IncomingRequest. + fn verify_incoming_request( + &self, + me: &Signer, + item: &serde_json::Value, + ) -> Result { + let e = parse_envelope(item)?; + let request = decode_approval_request(&e.tce) + .map_err(|e| RelayError::Verify(format!("decode: {e}")))?; + + // The request must target my key (not just my address text). + if request.recipient != me.pubkey() { + return Err(RelayError::Verify("recipient mismatch".into())); + } + // Same verification steps as the reference verifier, in order. + validate_pubkey(&request.sender) + .map_err(|e| RelayError::Verify(format!("sender key: {e}")))?; + verify(&request.sender, &e.tce, &e.signature) + .map_err(|e| RelayError::Verify(format!("signature: {e}")))?; + + let sender = Address::from_pubkey(&request.sender) + .map_err(|e| RelayError::Verify(format!("sender address: {e}")))?; + + let request_id = object_hash(&e.tce); + + Ok(IncomingRequest { request, tce: e.tce, request_id, sender }) + } + + /// Fetches ApprovalResponses for one request (by id hex), strict-decodes + /// and verifies each. This is the service side of the flow: after storing + /// a request, poll this until the recipient answers. + pub fn fetch_responses(&self, request_id_hex: &str) -> Result, RelayError> { + let url = format!("{}/v1/responses?request={}", self.base, request_id_hex); + let mut resp = self + .agent + .get(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .call() + .map_err(parse_client_err)?; + let status = resp.status().as_u16(); + if status != 200 { + let text = body_text(resp.body_mut()); + return Err(RelayError::Http(format!("status {status}: {text}"))); + } + let v: serde_json::Value = body_json(resp.body_mut())?; + let list = v + .get("responses") + .and_then(|x| x.as_array()) + .ok_or_else(|| RelayError::Malformed("responses: missing list".into()))?; + + let mut out = Vec::new(); + for item in list { + match verify_incoming_response(item) { + Ok(r) => out.push(r), + Err(e) => eprintln!("relay: skipping bad response envelope: {e}"), + } + } + Ok(out) + } + + /// Stores an arbitrary signed object, returning its object_id. + pub fn store(&self, tce: &[u8], sig: &[u8; 64]) -> Result { + let out = post_envelope(&self.agent, &format!("{}/v1/objects", self.base), tce, sig)?; + out.get("object_id") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| RelayError::Malformed("objects: missing object_id".into())) + } + + /// Builds, signs and submits an ApprovalResponse for `req`. + pub fn respond( + &self, + signer: &Signer, + req: &ApprovalRequest, + req_tce: &[u8], + decision: Decision, + ) -> Result { + let now = now_secs(); + // §8.5.3: response.created_at must fall inside the request window with + // the ±120s clock-skew allowance of §13.1. A stale request is refused + // locally before we ever submit an answer. + if !response_in_window(req, now) { + return Err(RelayError::Build("request window has lapsed".into())); + } + let request_hash = object_hash(req_tce); + let mut nonce = [0u8; NONCE_SIZE]; + getrandom::fill(&mut nonce).map_err(|e| RelayError::Build(format!("rng: {e}")))?; + + let resp = ApprovalResponse { + request_hash, + responder: signer.pubkey(), + decision, + created_at: now, + nonce, + }; + let tce = encode_approval_response(&resp) + .map_err(|e| RelayError::Build(format!("encode response: {e}")))?; + let sig = signer.sign(&tce); + + let out = post_envelope(&self.agent, &format!("{}/v1/objects", self.base), &tce, &sig)?; + out.get("object_id") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| RelayError::Malformed("objects: missing object_id".into())) + } +} + +/// SHA-256 of the exact request TCE bytes (the `request_hash` field). +pub fn object_hash(tce: &[u8]) -> [u8; 32] { + crate::crypto::sha256(tce) +} + +/// PROTOCOL.md §8.5 check 3: `request.created_at - skew <= now <= +/// request.expires_at + skew`, with the §13.1 allowance. +fn response_in_window(req: &ApprovalRequest, now: u64) -> bool { + const SKEW: u64 = crate::protocol::MAX_CLOCK_SKEW; + if now < req.created_at { + req.created_at - now <= SKEW + } else if now > req.expires_at { + now - req.expires_at <= SKEW + } else { + true + } +} + +/// Lowcase-hex object id, exposed for logging only; the request_hash compared +/// at verify-time is the raw 32 bytes (INV-4). +pub fn request_id_hex(tce: &[u8]) -> String { + object_id_of(tce) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_hash_is_sha256() { + let data = b"trust.n1ko.dev/tce/1\x00"; + let want = crate::crypto::sha256(data); + assert_eq!(object_hash(data), want); + } +} \ No newline at end of file diff --git a/src/signer.rs b/src/signer.rs new file mode 100644 index 0000000..435f2aa --- /dev/null +++ b/src/signer.rs @@ -0,0 +1,79 @@ +//! Ed25519 signer/verifier. The only place a private key lives on the client. + +use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey}; + +use crate::address::{validate_pubkey, Address, AddressError}; + +#[derive(Debug, Clone)] +pub struct Signer { + signing: SigningKey, + key: [u8; 32], +} + +impl Signer { + /// Reconstructs a signer from a 32-byte seed. + pub fn from_seed(seed: [u8; 32]) -> Result { + let signing = SigningKey::from_bytes(&seed); + let key = signing.verifying_key().to_bytes(); + validate_pubkey(&key)?; + Ok(Signer { signing, key }) + } + + /// Generates a fresh identity from the OS CSPRNG. + pub fn generate() -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|_| AddressError::KeySize)?; + Signer::from_seed(seed) + } + + pub fn pubkey(&self) -> [u8; 32] { + self.key + } + + pub fn address(&self) -> Result { + Address::from_pubkey(&self.key) + } + + pub fn seed(&self) -> [u8; 32] { + self.signing.to_bytes() + } + + /// Signs canonical TCE bytes. + pub fn sign(&self, msg: &[u8]) -> [u8; 64] { + self.signing.sign(msg).to_bytes() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyError { + /// The public key itself is unusable. + InvalidPublicKey(AddressError), + /// The signature does not verify over the message. + InvalidSignature, +} + +impl std::fmt::Display for VerifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + VerifyError::InvalidPublicKey(e) => write!(f, "invalid public key: {e}"), + VerifyError::InvalidSignature => f.write_str("signature does not verify"), + } + } +} + +/// Verifies an Ed25519 signature (strict) over `msg` against `pubkey`. +/// +/// `verify_strict` additionally rejects scalar-malleability and torsion-group +/// malleability, matching the protocol's RFC 8032 requirement. The protocol +/// also demands the key be validated (canonical, prime-order) before any +/// signature check; callers should pass keys that passed +/// `validate_pubkey`, but a small-order key is rejected here too. +pub fn verify(pubkey: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), VerifyError> { + let vk = VerifyingKey::from_bytes(pubkey) + .map_err(|_| VerifyError::InvalidPublicKey(AddressError::KeyNotOnCurve))?; + if vk.is_weak() { + return Err(VerifyError::InvalidPublicKey(AddressError::KeySmallOrder)); + } + let sig = Signature::from_bytes(sig); + vk.verify_strict(msg, &sig).map_err(|_| VerifyError::InvalidSignature) +} \ No newline at end of file diff --git a/src/tce/decode.rs b/src/tce/decode.rs new file mode 100644 index 0000000..306e234 --- /dev/null +++ b/src/tce/decode.rs @@ -0,0 +1,202 @@ +//! Strict TCE decoder. Accepts only the exact byte string the encoder would +//! produce and rejects everything else — never skips unknown fields. + +use std::collections::BTreeMap; + +use super::number::{is_canonical_number, canonical_number}; +use super::value::Value; +use super::{ + validate_key, validate_string, validate_timestamp, TceError, MAGIC, MAGIC_LEN, + ADDRESS_VERSION, MAX_KEY_LEN, MAX_MAP_ENTRIES, MAX_STRING_VALUE, MAX_UVARINT_BYTES, + PUBKEY_SIZE, VAL_FALSE, VAL_NULL, VAL_NUMBER, VAL_STRING, VAL_TRUE, VERSION, +}; + +pub struct Decoder<'a> { + buf: &'a [u8], + off: usize, +} + +impl<'a> Decoder<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, off: 0 } + } + + pub fn remaining(&self) -> usize { + self.buf.len() - self.off + } + + /// Asserts the input is fully consumed; trailing bytes are an error. + pub fn end(&self) -> Result<(), TceError> { + if self.off != self.buf.len() { + return Err(TceError::Trailing); + } + Ok(()) + } + + /// Reads and validates magic, object tag, version. + pub fn header(&mut self) -> Result { + if self.remaining() < MAGIC_LEN { + return Err(TceError::Truncated); + } + if &self.buf[self.off..self.off + MAGIC_LEN] != MAGIC { + return Err(TceError::Magic); + } + self.off += MAGIC_LEN; + + if self.remaining() < 1 { + return Err(TceError::Truncated); + } + let tag = self.buf[self.off]; + self.off += 1; + if !(1..=6).contains(&tag) { + return Err(TceError::ObjectTag); + } + + let ver = self.uvarint()?; + if ver != VERSION { + return Err(TceError::Version); + } + Ok(tag) + } + + /// Canonical LEB128 uvarint; non-minimal and oversized forms are rejected. + pub fn uvarint(&mut self) -> Result { + let mut n: u64 = 0; + let mut shift: u32 = 0; + let start = self.off; + loop { + if self.off >= self.buf.len() { + return Err(TceError::Truncated); + } + if self.off - start >= MAX_UVARINT_BYTES { + return Err(TceError::Uvarint); + } + let b = self.buf[self.off]; + self.off += 1; + + if shift >= 64 || (shift == 63 && b > 1) { + return Err(TceError::Overflow); + } + n |= ((b & 0x7f) as u64) << shift; + + if b & 0x80 == 0 { + if self.off - start > 1 && b == 0x00 { + return Err(TceError::NonMinimal); + } + return Ok(n); + } + shift += 7; + } + } + + /// Length-prefixed byte string, bounded by `max_len`, checked against + /// remaining input before the length is used. + pub fn raw_bytes(&mut self, max_len: usize) -> Result<&'a [u8], TceError> { + let n = self.uvarint()?; + if n > self.remaining() as u64 { + return Err(TceError::Truncated); + } + if n > max_len as u64 { + return Err(TceError::TooLong); + } + let b = &self.buf[self.off..self.off + n as usize]; + self.off += n as usize; + Ok(b) + } + + /// Length-prefixed byte string of exactly `want` bytes. + pub fn fixed_bytes(&mut self, want: usize) -> Result<&'a [u8], TceError> { + let b = self.raw_bytes(want)?; + if b.len() != want { + return Err(TceError::FieldSize); + } + Ok(b) + } + + /// Length-prefixed validated UTF-8 string. + pub fn string(&mut self, max_len: usize) -> Result { + let b = self.raw_bytes(max_len)?; + let s = std::str::from_utf8(b).map_err(|_| TceError::Utf8)?; + validate_string(s, max_len)?; + Ok(s.to_string()) + } + + /// Identity field: address version + pubkey. Returns a copy. + pub fn identity(&mut self) -> Result<[u8; PUBKEY_SIZE], TceError> { + let ver = self.uvarint()?; + if ver != ADDRESS_VERSION { + return Err(TceError::AddressVersion); + } + let b = self.fixed_bytes(PUBKEY_SIZE)?; + let mut out = [0u8; PUBKEY_SIZE]; + out.copy_from_slice(b); + Ok(out) + } + + pub fn timestamp(&mut self, allow_zero: bool) -> Result { + let ts = self.uvarint()?; + validate_timestamp(ts, allow_zero)?; + Ok(ts) + } + + /// Typed value; reserved/unknown tags are rejected, numbers must arrive + /// in canonical form. + pub fn value(&mut self) -> Result { + if self.remaining() < 1 { + return Err(TceError::Truncated); + } + let tag = self.buf[self.off]; + self.off += 1; + match tag { + VAL_NULL => Ok(Value::Null), + VAL_FALSE => Ok(Value::Bool(false)), + VAL_TRUE => Ok(Value::Bool(true)), + VAL_STRING => { + let s = self.string(MAX_STRING_VALUE)?; + Ok(Value::Str(s)) + } + VAL_NUMBER => { + let b = self.raw_bytes(super::MAX_NUMBER_TOKEN)?; + let tok = std::str::from_utf8(b).map_err(|_| TceError::NumberFormat)?; + if !is_canonical_number(tok) { + return Err(TceError::NumberFormat); + } + Ok(Value::Number(canonical_number(tok).expect("canonical"))) + } + _ => Err(TceError::ValueTag), + } + } + + /// Map with ascending-order, uniqueness, grammar and entry-limit checks. + pub fn map(&mut self, min_entries: usize) -> Result, TceError> { + let n = self.uvarint()?; + if n > MAX_MAP_ENTRIES as u64 { + return Err(TceError::TooLong); + } + if n < min_entries as u64 { + return Err(TceError::EmptyMap); + } + // Each entry costs at least two bytes; refuse an impossible count + // before allocating. + if n > self.remaining() as u64 { + return Err(TceError::Truncated); + } + let mut m = BTreeMap::new(); + let mut prev: Option = None; + for _ in 0..n { + let kb = self.raw_bytes(MAX_KEY_LEN)?; + let key = std::str::from_utf8(kb).map_err(|_| TceError::Utf8)?; + validate_key(key)?; + if prev.as_deref().is_some_and(|p| key == p) { + return Err(TceError::DuplicateKey); + } + if prev.as_deref().is_some_and(|p| key < p) { + return Err(TceError::KeyOrder); + } + let v = self.value()?; + m.insert(key.to_string(), v); + prev = Some(key.to_string()); + } + Ok(m) + } +} \ No newline at end of file diff --git a/src/tce/encode.rs b/src/tce/encode.rs new file mode 100644 index 0000000..9e73ec0 --- /dev/null +++ b/src/tce/encode.rs @@ -0,0 +1,189 @@ +//! Append-only canonical TCE encoder. Records the first error and then no-ops, +//! so a caller can write a straight sequence of field writes and check once. + +use std::collections::BTreeMap; + +use super::number::canonical_number; +use super::value::Value; +use super::{ + append_uvarint, validate_key, validate_string, validate_timestamp, TceError, MAGIC, + PUBKEY_SIZE, VAL_FALSE, VAL_NULL, VAL_NUMBER, VAL_STRING, VAL_TRUE, VERSION, +}; + +#[derive(Default)] +pub struct Encoder { + buf: Vec, + err: Option, +} + +impl Encoder { + pub fn new() -> Self { + Self { buf: Vec::with_capacity(256), err: None } + } + + pub fn err(&self) -> Option<&TceError> { + self.err.as_ref() + } + + /// Bytes of the object, or the first recorded error. + pub fn bytes(&self) -> Result, TceError> { + match &self.err { + Some(e) => Err(e.clone()), + None => Ok(self.buf.clone()), + } + } + + /// Writes magic + object tag + version. Must be the first call. + pub fn header(&mut self, tag: u8) { + if self.err.is_some() { + return; + } + if !known_object_tag(tag) { + self.err = Some(TceError::ObjectTag); + return; + } + self.buf.extend_from_slice(MAGIC); + self.buf.push(tag); + self.uvarint(VERSION); + } + + pub fn uvarint(&mut self, n: u64) { + if self.err.is_none() { + append_uvarint(&mut self.buf, n); + } + } + + /// Length-prefixed byte string with no limit of its own. + pub fn raw_bytes(&mut self, b: &[u8]) { + if self.err.is_some() { + return; + } + append_uvarint(&mut self.buf, b.len() as u64); + self.buf.extend_from_slice(b); + } + + /// Length-prefixed byte string of exactly `want` bytes. + pub fn fixed_bytes(&mut self, b: &[u8], want: usize) { + if self.err.is_some() { + return; + } + if b.len() != want { + self.err = Some(TceError::FieldSize); + return; + } + self.raw_bytes(b); + } + + /// Length-prefixed validated UTF-8 string. + pub fn string(&mut self, s: &str, max_len: usize) { + if self.err.is_some() { + return; + } + if let Err(e) = validate_string(s, max_len) { + self.err = Some(e); + return; + } + self.raw_bytes(s.as_bytes()); + } + + /// Identity field: address version 0, then length-prefixed raw public key. + pub fn identity(&mut self, pubkey: &[u8; PUBKEY_SIZE]) { + if self.err.is_some() { + return; + } + self.uvarint(super::ADDRESS_VERSION); + self.raw_bytes(pubkey); + } + + /// Timestamp with protocol range validation. + pub fn timestamp(&mut self, ts: u64, allow_zero: bool) { + if self.err.is_some() { + return; + } + if let Err(e) = validate_timestamp(ts, allow_zero) { + self.err = Some(e); + return; + } + self.uvarint(ts); + } + + /// Typed value: null/false/true have no body; strings and numbers are + /// length-prefixed. Numbers are canonicalized here. + pub fn value(&mut self, v: &Value) { + if self.err.is_some() { + return; + } + match v { + Value::Null => self.buf.push(VAL_NULL), + Value::Bool(false) => self.buf.push(VAL_FALSE), + Value::Bool(true) => self.buf.push(VAL_TRUE), + Value::Str(s) => { + if let Err(e) = validate_string(s, super::MAX_STRING_VALUE) { + self.err = Some(e); + return; + } + self.buf.push(VAL_STRING); + self.raw_bytes(s.as_bytes()); + } + Value::Number(tok) => { + let canon = match canonical_number(tok) { + Ok(c) => c, + Err(e) => { + self.err = Some(e); + return; + } + }; + self.buf.push(VAL_NUMBER); + self.raw_bytes(canon.as_bytes()); + } + } + } + + /// Map in canonical (bytewise ascending) order. `min_entries` is enforced. + pub fn map(&mut self, m: &BTreeMap, min_entries: usize) { + if self.err.is_some() { + return; + } + if m.len() < min_entries { + self.err = Some(TceError::EmptyMap); + return; + } + if m.len() > super::MAX_MAP_ENTRIES { + self.err = Some(TceError::TooLong); + return; + } + for k in m.keys() { + if let Err(e) = validate_key(k) { + self.err = Some(e); + return; + } + } + self.uvarint(m.len() as u64); + for (k, v) in m { + self.raw_bytes(k.as_bytes()); + self.value(v); + if self.err.is_some() { + return; + } + } + } + + /// Applies a whole-object size limit. + pub fn check_size(&mut self, limit: usize) { + if self.err.is_none() && self.buf.len() > limit { + self.err = Some(TceError::ObjectTooLarge); + } + } +} + +fn known_object_tag(tag: u8) -> bool { + matches!( + tag, + super::TAG_IDENTITY + | super::TAG_CLAIM + | super::TAG_REVOCATION + | super::TAG_APPROVAL_REQUEST + | super::TAG_APPROVAL_RESPONSE + | super::TAG_AUTH_ASSERTION + ) +} \ No newline at end of file diff --git a/src/tce/mod.rs b/src/tce/mod.rs new file mode 100644 index 0000000..e6ccfb7 --- /dev/null +++ b/src/tce/mod.rs @@ -0,0 +1,237 @@ +//! TCE — Trust Canonical Encoding, version 1. +//! +//! Byte-exact port of the normative specification in docs/PROTOCOL.md of the +//! niko_trust relay. The frozen vectors in `testdata/vectors/tce_vectors.json` +//! are the ground truth this module is checked against; see tests/tce_vectors.rs. + +pub mod decode; +pub mod encode; +pub mod number; +pub mod value; + +pub use value::Value; + +/// Framing magic: `trust.n1ko.dev/tce/1` followed by NUL. +pub const MAGIC: &[u8; 21] = b"trust.n1ko.dev/tce/1\x00"; +pub const MAGIC_LEN: usize = 21; + +/// Object version encoded by this implementation. +pub const VERSION: u64 = 1; + +/// Object tags (domain separation, inside signed bytes). +pub const TAG_IDENTITY: u8 = 0x01; +pub const TAG_CLAIM: u8 = 0x02; +pub const TAG_REVOCATION: u8 = 0x03; +pub const TAG_APPROVAL_REQUEST: u8 = 0x04; +pub const TAG_APPROVAL_RESPONSE: u8 = 0x05; +pub const TAG_AUTH_ASSERTION: u8 = 0x06; + +/// Value tags. +pub const VAL_NULL: u8 = 0x00; +pub const VAL_FALSE: u8 = 0x01; +pub const VAL_TRUE: u8 = 0x02; +pub const VAL_STRING: u8 = 0x03; +pub const VAL_NUMBER: u8 = 0x04; + +/// Fixed sizes. +pub const ADDRESS_VERSION: u64 = 0; +pub const PUBKEY_SIZE: usize = 32; +pub const NONCE_SIZE: usize = 16; +pub const HASH_SIZE: usize = 32; +pub const CHALLENGE_SIZE: usize = 32; +pub const SIGNATURE_SIZE: usize = 64; + +/// Field limits (PROTOCOL.md section 6.3). +pub const MAX_UVARINT_BYTES: usize = 10; +pub const MAX_KEY_LEN: usize = 128; +pub const MAX_STRING_VALUE: usize = 512; +pub const MAX_NUMBER_TOKEN: usize = 52; +pub const MAX_MAP_ENTRIES: usize = 32; +pub const MAX_ACTION_LEN: usize = 128; +pub const MAX_MESSAGE_LEN: usize = 256; +pub const MAX_REASON_LEN: usize = 256; +pub const MAX_ALIAS_LEN: usize = 64; +pub const MAX_SCOPE_LEN: usize = 32; +pub const MAX_AUDIENCE_LEN: usize = 128; +pub const MAX_NUMBER_INT_DIGS: usize = 32; +pub const MAX_NUMBER_FRAC_DIG: usize = 18; +pub const MAX_NUMBER_SOURCE: usize = 64; + +/// Whole-object TCE limits. +pub const MAX_IDENTITY_TCE: usize = 1024; +pub const MAX_CLAIM_TCE: usize = 4096; +pub const MAX_REVOC_TCE: usize = 1024; +pub const MAX_REQUEST_TCE: usize = 8192; +pub const MAX_RESPONSE_TCE: usize = 1024; +pub const MAX_AUTH_TCE: usize = 1024; + +/// Timestamp bounds. +pub const MIN_TIMESTAMP: u64 = 1_000_000_000; +pub const MAX_TIMESTAMP: u64 = 4_102_444_800; + +/// Max lifetime of an ApprovalRequest (seconds). +pub const MAX_APPROVAL_LIFETIME: u64 = 60; + +/// Decision values. +pub const DECISION_DENY: u64 = 0; +pub const DECISION_ALLOW: u64 = 1; + +/// TCE / protocol error set. Mirrors the small, data-free error set of the Go +/// reference so a hostile object cannot influence error text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TceError { + /// Bad framing magic. + Magic, + /// Unknown object tag. + ObjectTag, + /// Unsupported object version. + Version, + /// Input ended mid-field. + Truncated, + /// Bytes after the last field. + Trailing, + /// Malformed uvarint (too many bytes / truncated). + Uvarint, + /// Non-minimal uvarint spelling. + NonMinimal, + /// Uvarint overflow. + Overflow, + /// Field exceeds its maximum length. + TooLong, + /// Whole object exceeds its size limit. + ObjectTooLarge, + /// Invalid UTF-8. + Utf8, + /// Control character in a string. + ControlChar, + /// Map key does not match the key grammar. + KeyGrammar, + /// Duplicate map key. + DuplicateKey, + /// Map keys not ascending. + KeyOrder, + /// Unknown or reserved value tag. + ValueTag, + /// Malformed or non-canonical number token. + NumberFormat, + /// Number out of representable range. + NumberRange, + /// Timestamp out of range. + Timestamp, + /// Fixed-size field has wrong length. + FieldSize, + /// Unsupported address version in an identity field. + AddressVersion, + /// Map requires at least the specified entries. + EmptyMap, + /// Unknown decision value. + Decision, + /// Approval lifetime out of bounds. + Lifetime, + /// expires_at must be after created_at. + Expiry, + /// Decoded to a different object type than expected. + WrongObject, + /// Signature has wrong length or does not verify. + Signature, + /// Other identity errors surfaced as strings. + Protocol(String), +} + +impl std::fmt::Display for TceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + TceError::Magic => "tce: bad magic", + TceError::ObjectTag => "tce: unknown object tag", + TceError::Version => "tce: unsupported object version", + TceError::Truncated => "tce: truncated input", + TceError::Trailing => "tce: trailing bytes after object", + TceError::Uvarint => "tce: malformed uvarint", + TceError::NonMinimal => "tce: non-minimal uvarint", + TceError::Overflow => "tce: integer overflow", + TceError::TooLong => "tce: field exceeds maximum length", + TceError::ObjectTooLarge => "tce: object exceeds maximum size", + TceError::Utf8 => "tce: invalid UTF-8", + TceError::ControlChar => "tce: control character in string", + TceError::KeyGrammar => "tce: map key does not match grammar", + TceError::DuplicateKey => "tce: duplicate map key", + TceError::KeyOrder => "tce: map keys not in ascending order", + TceError::ValueTag => "tce: unknown or reserved value tag", + TceError::NumberFormat => "tce: not a valid JSON number", + TceError::NumberRange => "tce: number out of representable range", + TceError::Timestamp => "tce: timestamp out of range", + TceError::FieldSize => "tce: fixed-size field has wrong length", + TceError::AddressVersion => "tce: unsupported address version", + TceError::EmptyMap => "tce: map requires at least one entry", + TceError::Decision => "tce: unknown decision value", + TceError::Lifetime => "tce: approval lifetime out of bounds", + TceError::Expiry => "tce: expires_at must be after created_at", + TceError::WrongObject => "protocol: unexpected object type", + TceError::Signature => "protocol: signature does not verify", + TceError::Protocol(m) => return f.write_str(m), + }; + f.write_str(s) + } +} + +impl std::error::Error for TceError {} + +/// Appends the canonical LEB128 encoding of `n` (shortest form). +pub fn append_uvarint(dst: &mut Vec, mut n: u64) { + while n >= 0x80 { + dst.push((n as u8) | 0x80); + n >>= 7; + } + dst.push(n as u8); +} + +/// Validates a timestamp against protocol bounds. `allow_zero` permits the one +/// exception `0` meaning "does not expire". +pub fn validate_timestamp(ts: u64, allow_zero: bool) -> Result<(), TceError> { + if allow_zero && ts == 0 { + return Ok(()); + } + if !(MIN_TIMESTAMP..=MAX_TIMESTAMP).contains(&ts) { + return Err(TceError::Timestamp); + } + Ok(()) +} + +/// Applies the protocol string rules: length bound, valid UTF-8, no controls. +pub fn validate_string(s: &str, max_len: usize) -> Result<(), TceError> { + if s.len() > max_len { + return Err(TceError::TooLong); + } + for c in s.chars() { + if c <= '\u{1f}' || c == '\u{7f}' || ('\u{80}'..='\u{9f}').contains(&c) { + return Err(TceError::ControlChar); + } + } + Ok(()) +} + +/// Map key grammar: `[a-z][a-z0-9]*([._-][a-z0-9]+)*`. +pub fn validate_key(k: &str) -> Result<(), TceError> { + let b = k.as_bytes(); + if b.is_empty() || b.len() > MAX_KEY_LEN { + return Err(TceError::TooLong); + } + if !b[0].is_ascii_lowercase() { + return Err(TceError::KeyGrammar); + } + let mut prev_sep = false; + for i in 1..b.len() { + let c = b[i]; + if c.is_ascii_lowercase() || c.is_ascii_digit() { + prev_sep = false; + } else if c == b'.' || c == b'_' || c == b'-' { + if prev_sep || i == b.len() - 1 { + return Err(TceError::KeyGrammar); + } + prev_sep = true; + } else { + return Err(TceError::KeyGrammar); + } + } + Ok(()) +} \ No newline at end of file diff --git a/src/tce/number.rs b/src/tce/number.rs new file mode 100644 index 0000000..9e664bd --- /dev/null +++ b/src/tce/number.rs @@ -0,0 +1,247 @@ +//! Number canonicalization (PROTOCOL.md section 5.1). +//! +//! Numbers are carried as decimal text. The canonical form is plain decimal +//! with no exponent, no leading zeros, and no trailing fractional zeros. +//! Negative zero is not representable and maps to "0". No binary float is ever +//! involved. + +use crate::tce::{TceError, MAX_NUMBER_FRAC_DIG, MAX_NUMBER_INT_DIGS, MAX_NUMBER_SOURCE, MAX_NUMBER_TOKEN}; + +fn is_digit(c: u8) -> bool { + c.is_ascii_digit() +} + +fn zeros(n: usize) -> String { + if n == 0 { + return String::new(); + } + "0".repeat(n) +} + +/// Splits a token into (int_part, frac_part, exp_part, negative). +fn split_number(s: &str) -> Result<(String, String, String, bool), TceError> { + let b = s.as_bytes(); + let mut i = 0; + let negative = if i < b.len() && b[i] == b'-' { + i += 1; + true + } else { + false + }; + + let start = i; + if i >= b.len() { + return Err(TceError::NumberFormat); + } + if b[i] == b'0' { + i += 1; + } else if b[i].is_ascii_digit() && b[i] != b'0' { + while i < b.len() && is_digit(b[i]) { + i += 1; + } + } else { + return Err(TceError::NumberFormat); + } + let int_part = &s[start..i]; + if int_part.len() > 1 && int_part.as_bytes()[0] == b'0' { + return Err(TceError::NumberFormat); + } + + let mut frac_part = ""; + if i < b.len() && b[i] == b'.' { + i += 1; + let fs = i; + while i < b.len() && is_digit(b[i]) { + i += 1; + } + if i == fs { + return Err(TceError::NumberFormat); + } + frac_part = &s[fs..i]; + } + + let mut exp_part = ""; + if i < b.len() && (b[i] == b'e' || b[i] == b'E') { + i += 1; + let es = i; + if i < b.len() && (b[i] == b'+' || b[i] == b'-') { + i += 1; + } + let ds = i; + while i < b.len() && is_digit(b[i]) { + i += 1; + } + if i == ds { + return Err(TceError::NumberFormat); + } + exp_part = &s[es..i]; + } + + if i != b.len() { + return Err(TceError::NumberFormat); + } + Ok((int_part.to_string(), frac_part.to_string(), exp_part.to_string(), negative)) +} + +fn trim_leading_zeros(s: &str) -> &str { + let mut i = 0; + while i < s.len() - 1 && s.as_bytes()[i] == b'0' { + i += 1; + } + &s[i..] +} + +/// Reduces a JSON number source token to its unique canonical form. +pub fn canonical_number(token: &str) -> Result { + if token.is_empty() || token.len() > MAX_NUMBER_SOURCE { + return Err(TceError::NumberFormat); + } + let (int_part, frac_part, exp_part, negative) = split_number(token)?; + + if exp_part.len() > 4 { + return Err(TceError::NumberRange); + } + let mut exp: i64 = 0; + if !exp_part.is_empty() { + let mut k = 0; + let exp_neg = { + let c = exp_part.as_bytes()[0]; + if c == b'+' || c == b'-' { + k = 1; + c == b'-' + } else { + false + } + }; + let digits = &exp_part[k..]; + if digits.is_empty() || digits.len() > 4 { + return Err(TceError::NumberRange); + } + for c in digits.bytes() { + exp = exp * 10 + (c - b'0') as i64; + } + if exp_neg { + exp = -exp; + } + } + + // value = sign * mantissa * 10^scale, mantissa = int_part ++ frac_part. + let digits = format!("{}{}", int_part, frac_part); + let scale: i64 = exp - frac_part.len() as i64; + + // Strip leading zeros of the mantissa (equivalent to big.Int parse). + let mantissa = trim_leading_zeros(&digits); + + if *mantissa == *"0" { + return Ok("0".to_string()); + } + + // Strip trailing zeros while scale < 0 (fractional padding only). + let mut ds = mantissa.to_string(); + let mut scale = scale; + while scale < 0 && ds.ends_with('0') { + ds.pop(); + scale += 1; + } + + if scale > 0 && ds.len() + scale as usize > MAX_NUMBER_INT_DIGS { + return Err(TceError::NumberRange); + } + if scale < 0 && -scale > MAX_NUMBER_FRAC_DIG as i64 + ds.len() as i64 { + return Err(TceError::NumberRange); + } + + let (int_digits, frac_digits): (String, String); + if scale >= 0 { + int_digits = format!("{}{}", ds, zeros(scale as usize)); + frac_digits = String::new(); + } else { + let point = ds.len() as i64 + scale; + if point <= 0 { + int_digits = "0".to_string(); + frac_digits = format!("{}{}", zeros((-point) as usize), ds); + } else { + int_digits = ds[..point as usize].to_string(); + frac_digits = ds[point as usize..].to_string(); + } + } + + if trim_leading_zeros(&int_digits).len() > MAX_NUMBER_INT_DIGS { + return Err(TceError::NumberRange); + } + if frac_digits.len() > MAX_NUMBER_FRAC_DIG { + return Err(TceError::NumberRange); + } + + let mut n = int_digits.len(); + if negative { + n += 1; + } + if !frac_digits.is_empty() { + n += 1 + frac_digits.len(); + } + if n > MAX_NUMBER_TOKEN { + return Err(TceError::NumberRange); + } + + let mut out = String::with_capacity(n); + if negative { + out.push('-'); + } + out.push_str(&int_digits); + if !frac_digits.is_empty() { + out.push('.'); + out.push_str(&frac_digits); + } + Ok(out) +} + +/// Reports whether `token` is already in canonical form (decoder check). +pub fn is_canonical_number(token: &str) -> bool { + matches!(canonical_number(token), Ok(ref c) if c == token) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_cases() { + for (input, want) in [ + ("0", "0"), + ("-0", "0"), + ("0.0", "0"), + ("0e10", "0"), + ("1", "1"), + ("1.0", "1"), + ("1e0", "1"), + ("1.000", "1"), + ("1e1", "10"), + ("1.5", "1.5"), + ("1.50", "1.5"), + ("1e-1", "0.1"), + ("1e-3", "0.001"), + ("1.23e2", "123"), + ("1e18", "1000000000000000000"), + ("-1e-18", "-0.000000000000000001"), + ("12345678901234567890", "12345678901234567890"), + ("999999999999999999999999", "999999999999999999999999"), + ("1E+2", "100"), + ("-0.0000", "0"), + ] { + assert_eq!(canonical_number(input).unwrap(), want, "input {input}"); + } + } + + #[test] + fn rejected_cases() { + for input in ["+1", "01", "1.", ".5", "1e", "0x10", "NaN", "Infinity", "1_000", "1e99999", "1e9999"] { + assert!(canonical_number(input).is_err(), "should reject {input}"); + } + // 1 followed by 40 zeros exceeds integer digits. + let big = format!("1{}", "0".repeat(40)); + assert!(canonical_number(&big).is_err()); + // too many fractional digits + assert!(canonical_number("0.0000000000000000011").is_err()); + } +} \ No newline at end of file diff --git a/src/tce/value.rs b/src/tce/value.rs new file mode 100644 index 0000000..d184285 --- /dev/null +++ b/src/tce/value.rs @@ -0,0 +1,75 @@ +//! Typed claim/payload values. + +use crate::tce::number::{canonical_number, is_canonical_number as is_canonical}; + +/// A claim or approval payload value. The set of representable values is +/// exactly the set the specification defines; there is no way to hold a +/// reserved tag. +#[derive(Clone, Debug)] +pub enum Value { + Null, + Bool(bool), + Str(String), + /// Number as its decimal source token. After decode always canonical; + /// before encode it is canonicalized by the encoder. + Number(String), +} + +impl Value { + pub fn num(token: impl Into) -> Value { + Value::Number(token.into()) + } + + /// Canonical form token when this is a number. + pub fn number_token(&self) -> Option { + match self { + Value::Number(t) => canonical_number(t).ok(), + _ => None, + } + } +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + use Value::*; + match (self, other) { + (Null, Null) => true, + (Bool(a), Bool(b)) => a == b, + (Str(a), Str(b)) => a == b, + (Number(a), Number(b)) => match (canonical_number(a), canonical_number(b)) { + (Ok(x), Ok(y)) => x == y, + _ => false, + }, + _ => false, + } + } +} + +/// Reads the value tag byte for encoding. +#[allow(dead_code)] +pub fn tag_of(v: &Value) -> u8 { + match v { + Value::Null => crate::tce::VAL_NULL, + Value::Bool(false) => crate::tce::VAL_FALSE, + Value::Bool(true) => crate::tce::VAL_TRUE, + Value::Str(_) => crate::tce::VAL_STRING, + Value::Number(_) => crate::tce::VAL_NUMBER, + } +} + +/// Whether a raw value tag byte is a defined, non-reserved tag. +pub fn known_tag(tag: u8) -> bool { + matches!( + tag, + crate::tce::VAL_NULL + | crate::tce::VAL_FALSE + | crate::tce::VAL_TRUE + | crate::tce::VAL_STRING + | crate::tce::VAL_NUMBER + ) +} + +/// Whether `token` is already in canonical form, for the decoder. +pub fn is_canonical_number(token: &str) -> bool { + is_canonical(token) +} \ No newline at end of file diff --git a/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..486755d --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,235 @@ +//! Vault: the single portable state file. +//! +//! Everything the app persists lives in one JSON document, +//! `/niko-trust/vault.json` (`~/.config/niko-trust/` on Linux, +//! `%APPDATA%\niko-trust\` on Windows). Copy that one file to another device +//! and the whole identity, address book, history and settings move with it. +//! +//! The identity is stored exactly in the keyring `FileFormat` (argon2id + +//! XChaCha20-Poly1305), so a vault remains useless without the passphrase. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::inbox::{HistoryItem, Outcome}; + +pub const VAULT_VERSION: u8 = 1; + +/// One stored history entry (display-only; responses are never replayed). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct StoredItem { + pub id: String, // request_id hex + pub sender: String, + pub action: String, + pub message: String, + pub created_at: u64, + pub expires_at: u64, + /// outcome label, one of pending/approved/denied/expired/stale + pub outcome: String, +} + +impl From<&HistoryItem> for StoredItem { + fn from(h: &HistoryItem) -> Self { + StoredItem { + id: h.request_id_hex.clone(), + sender: h.sender.to_string().to_owned(), + action: h.action.clone(), + message: h.message.clone(), + created_at: h.created_at, + expires_at: h.expires_at, + outcome: crate::inbox::outcome_label(h.outcome).to_string(), + } + } +} + +impl TryFrom<&StoredItem> for HistoryItem { + type Error = (); + fn try_from(s: &StoredItem) -> Result { + let sender = crate::address::Address::parse(&s.sender).map_err(|_| ())?; + let outcome = match s.outcome.as_str() { + "approved" => Outcome::Approved, + "denied" => Outcome::Denied, + "expired" => Outcome::TimedOut, + "stale" => Outcome::Stale, + _ => Outcome::Pending, + }; + Ok(HistoryItem { + request_id_hex: s.id.clone(), + sender, + action: s.action.clone(), + message: s.message.clone(), + created_at: s.created_at, + expires_at: s.expires_at, + outcome, + }) + } +} + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct VaultData { + version: u8, + /// Keyring FileFormat (opaque here), present once an identity exists. + #[serde(default)] + identity: Option, + #[serde(default)] + address_book: BTreeMap, + #[serde(default)] + history: Vec, + #[serde(default)] + relay_url: String, +} + +/// Handle over the loaded vault file. +#[derive(Debug)] +pub struct Vault { + path: PathBuf, + data: VaultData, +} + +/// The directory holding `vault.json` (created if missing). +pub fn config_dir() -> PathBuf { + let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); + base.join("niko-trust") +} + +impl Vault { + /// Loads `vault.json`; a missing or corrupt file yields defaults. Migrates + /// the legacy `identity.json` / `addressbook.json` files when present. + pub fn load() -> Vault { + let dir = config_dir(); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("vault.json"); + let mut data: VaultData = std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + data.version = VAULT_VERSION; + + // Legacy migration: identity written by older builds. + if data.identity.is_none() { + let legacy = dir.join("identity.json"); + if let Ok(raw) = std::fs::read_to_string(&legacy) { + if let Ok(v) = serde_json::from_str::(&raw) { + data.identity = Some(v); + } + } + } + // Legacy migration: address book written by older builds. + let legacy_book = dir.join("addressbook.json"); + if data.address_book.is_empty() { + if let Ok(raw) = std::fs::read_to_string(&legacy_book) { + if let Ok(m) = serde_json::from_str::>(&raw) { + data.address_book = m; + } + } + } + + let v = Vault { path, data }; + v.save(); + v + } + + pub fn save(&self) { + if let Ok(json) = serde_json::to_vec_pretty(&self.data) { + let _ = std::fs::write(&self.path, json); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions( + &self.path, + std::fs::Permissions::from_mode(0o600), + ); + } + } + } + + pub fn path(&self) -> &std::path::Path { + &self.path + } + + // ------------------------------------------------------- identity + + pub fn identity_blob(&self) -> Option<&serde_json::Value> { + self.data.identity.as_ref() + } + + pub fn set_identity_blob(&mut self, v: serde_json::Value) { + self.data.identity = Some(v); + self.save(); + } + + // --------------------------------------------------- address book + + pub fn address_book(&self) -> &BTreeMap { + &self.data.address_book + } + + pub fn set_address_book(&mut self, entries: BTreeMap) { + self.data.address_book = entries; + self.save(); + } + + // -------------------------------------------------------- history + + pub fn history(&self) -> &[StoredItem] { + &self.data.history + } + + pub fn set_history(&mut self, items: Vec) { + self.data.history = items; + self.save(); + } + + // ------------------------------------------------------- settings + + pub fn relay_url(&self) -> &str { + &self.data.relay_url + } + + pub fn set_relay_url(&mut self, url: impl Into) { + self.data.relay_url = url.into(); + self.save(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_all_sections() { + let dir = std::env::temp_dir().join(format!("nk-vault-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // Can't easily override config_dir(); exercise VaultData directly via + // serde by writing and re-reading through a temp file path clone. + let mut data = VaultData { + version: VAULT_VERSION, + ..Default::default() + }; + data.relay_url = "https://relay.example".into(); + let addr = "trust1qz8jrkwdnqscwrvptu69z6ntd2dp092s0fk25ayq94qlzsx649dszg7tx6w"; + data.address_book.insert(addr.to_string(), "Niko".into()); + data.history.push(StoredItem { + id: "ff".repeat(32), + sender: addr.to_string(), + action: "2fa".into(), + message: "hi".into(), + created_at: 1_700_000_000, + expires_at: 1_700_000_060, + outcome: "approved".into(), + }); + + let p = dir.join("vault.json"); + std::fs::write(&p, serde_json::to_vec(&data).unwrap()).unwrap(); + let back: VaultData = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap(); + assert_eq!(back.relay_url, "https://relay.example"); + assert_eq!(back.address_book.get(addr).map(String::as_str), Some("Niko")); + assert_eq!(back.history.len(), 1); + + let h = HistoryItem::try_from(&back.history[0]).unwrap(); + assert_eq!(h.outcome, Outcome::Approved); + assert_eq!(h.request_id_hex, "ff".repeat(32)); + + std::fs::remove_dir_all(dir).ok(); + } +} diff --git a/tests/live_relay.rs b/tests/live_relay.rs new file mode 100644 index 0000000..7f01a59 --- /dev/null +++ b/tests/live_relay.rs @@ -0,0 +1,236 @@ +//! Live integration tests against a real relay instance. +//! +//! Skips (with a clear message) when `NIKO_RELAY` is not set. The store is +//! in-memory per server run, so these tests are self-contained: they create +//! their own sender and recipient identities, post a request, verify and +//! answer it, and check the response is queryable. Nothing is left behind. + +use niko_trust_gui::inbox::{Inbox, Outcome}; +use niko_trust_gui::protocol::{encode_approval_request, ApprovalRequest, Decision}; +use niko_trust_gui::relay::Relay; +use niko_trust_gui::signer::Signer; +use niko_trust_gui::tce::NONCE_SIZE; + +fn base() -> Option { + std::env::var("NIKO_RELAY") + .ok() + .map(|s| s.trim_end_matches('/').to_string()) +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +fn mk_request( + sender: &Signer, + recipient: &Signer, + action: &str, + message: &str, +) -> (Vec, [u8; 64]) { + let create = now(); + let req = ApprovalRequest { + sender: sender.pubkey(), + recipient: recipient.pubkey(), + action: action.to_string(), + payload: Default::default(), + message: message.to_string(), + created_at: create, + expires_at: create + 40, + nonce: [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, + ], + }; + let tce = encode_approval_request(&req).expect("encode request"); + let sig = sender.sign(&tce); + (tce, sig) +} + +#[test] +fn live_auth_fetch_respond_roundtrip() { + let Some(base) = base() else { + eprintln!("skipping live_auth_fetch_respond_roundtrip: NIKO_RELAY not set"); + return; + }; + + let recipient = Signer::generate().expect("recipient signer"); + let sender = Signer::generate().expect("sender signer"); + + // Authenticate as the recipient with the generic "read" scope. + let relay = Relay::auth(&base, &recipient, "*").expect("auth handshake"); + assert!(!relay.audience().is_empty()); + + // Sender posts an ApprovalRequest addressed to the recipient. + let (req_tce, req_sig) = mk_request(&sender, &recipient, "login", "Approve 2FA login"); + let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth"); + let req_id = sender_agent.store(&req_tce, &req_sig).expect("store request"); + assert!(!req_id.is_empty()); + + // Recipient fetches, decodes and verifies the incoming request. + let inbox = relay.fetch_requests(&recipient).expect("fetch requests"); + assert_eq!(inbox.len(), 1, "expected exactly one new request"); + let inc = &inbox[0]; + assert_eq!(inc.request.action, "login"); + assert_eq!(inc.request.message, "Approve 2FA login"); + + // Respond Allow; the relay must accept it. + let resp_id = relay + .respond(&recipient, &inc.request, &req_tce, Decision::Allow) + .expect("respond allow"); + assert!(!resp_id.is_empty()); + + // Responding again to the same request must be refused by the server. + let again = relay.respond(&recipient, &inc.request, &req_tce, Decision::Deny); + assert!(again.is_err(), "second response to a request must be rejected"); +} + +#[test] +fn live_rejects_wrong_recipient() { + let Some(base) = base() else { + eprintln!("skipping live_rejects_wrong_recipient: NIKO_RELAY not set"); + return; + }; + + let me = Signer::generate().expect("me signer"); + let someone_else = Signer::generate().expect("victim signer"); + let sender = Signer::generate().expect("sender signer"); + + let relay = Relay::auth(&base, &me, "*").expect("auth"); + + // Request addressed to someone_else: polling as `me` must yield nothing. + let (req_tce, req_sig) = mk_request(&sender, &someone_else, "x", "not for me"); + let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth"); + sender_agent.store(&req_tce, &req_sig).expect("store"); + + let inbox = relay.fetch_requests(&me).expect("fetch"); + assert!( + inbox.iter().all(|r| r.request.recipient != me.pubkey()), + "no request may target my key that was addressed elsewhere" + ); +} + +#[test] +fn live_ignores_poisoned_envelope() { + let Some(base) = base() else { + eprintln!("skipping live_ignores_poisoned_envelope: NIKO_RELAY not set"); + return; + }; + + let me = Signer::generate().expect("me signer"); + let sender = Signer::generate().expect("sender signer"); + let relay = Relay::auth(&base, &me, "*").expect("auth"); + + // A request addressed to me but signed by a *different* key. The relay + // stores any well-formed envelope without checking signatures (INV-5), so + // this lands in the feed; the client must skip it because the signature + // does not verify against the request's sender field. + let timestamp = now() - 1000; + let req = ApprovalRequest { + sender: sender.pubkey(), + recipient: me.pubkey(), + action: "phish".to_string(), + payload: Default::default(), + message: "".to_string(), + created_at: timestamp, + expires_at: timestamp + 30, + nonce: [7u8; NONCE_SIZE], + }; + let tce = encode_approval_request(&req).expect("encode"); + + // Wrong signer: forge a signature with an unrelated identity. + let forger = Signer::generate().expect("forger signer"); + let bad_sig = forger.sign(&tce); + + let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth"); + let _ = sender_agent.store(&tce, &bad_sig).expect("store poisoned"); + + let inbox = relay.fetch_requests(&me).expect("fetch"); + assert!( + inbox.iter().all(|r| r.request.sender != sender.pubkey() || r.request.action != "phish"), + "envelope with a forged signature must be dropped from the inbox" + ); +} +/// Regression: after a restart the in-memory inbox is empty, so an +/// already-answered request used to reappear as "pending" and re-answering +/// failed with 422. The client must reconcile each new request against the +/// relay's responses feed and restore the recorded outcome. +#[test] +fn live_restart_reconciles_answered_requests() { + let Some(base) = base() else { + eprintln!("skipping live_restart_reconciles_answered_requests: NIKO_RELAY not set"); + return; + }; + + let recipient = Signer::generate().expect("recipient signer"); + let sender = Signer::generate().expect("sender signer"); + + fn mk( + sender: &Signer, + recipient: &Signer, + message: &str, + nonce_byte: u8, + ) -> (Vec, [u8; 64]) { + let create = now(); + let req = ApprovalRequest { + sender: sender.pubkey(), + recipient: recipient.pubkey(), + action: "login".to_string(), + payload: Default::default(), + message: message.to_string(), + created_at: create, + expires_at: create + 40, + nonce: [nonce_byte; NONCE_SIZE], + }; + let tce = encode_approval_request(&req).expect("encode"); + let sig = sender.sign(&tce); + (tce, sig) + } + + let relay = Relay::auth(&base, &recipient, "*").expect("recipient auth"); + let sender_agent = Relay::auth(&base, &sender, "*").expect("sender auth"); + + // Two distinct requests; answer one Allow, the other Deny. + let (tce1, sig1) = mk(&sender, &recipient, "restart-allow", 0x01); + sender_agent.store(&tce1, &sig1).expect("store 1"); + let (tce2, sig2) = mk(&sender, &recipient, "restart-deny", 0x02); + sender_agent.store(&tce2, &sig2).expect("store 2"); + + let list = relay.fetch_requests(&recipient).expect("fetch"); + for r in &list { + let d = if r.request.message == "restart-allow" { + Decision::Allow + } else { + Decision::Deny + }; + relay + .respond(&recipient, &r.request, &r.tce, d) + .expect("respond as recipient"); + } + + // Simulate a restart: a brand-new inbox that has never seen anything. + let mut inbox = Inbox::new(); + let list2 = relay.fetch_requests(&recipient).expect("refetch after restart"); + let new_items = inbox.observe(now(), &list2); + assert_eq!(new_items.len(), 2, "both requests are new to the fresh inbox"); + + // Reconcile exactly like the GUI does (AnswerChecked). + for r in new_items { + let rid = hex::encode(r.request_id); + let resps = relay.fetch_responses(&rid).expect("responses feed"); + let mine = resps + .into_iter() + .find(|x| x.response.responder == recipient.pubkey()) + .expect("request was answered before the restart"); + inbox.set_outcome(&r.request_id, Outcome::from(mine.response.decision)); + } + + let h = inbox.history(); + let allow = h.iter().find(|x| x.message == "restart-allow").unwrap(); + let deny = h.iter().find(|x| x.message == "restart-deny").unwrap(); + assert_eq!(allow.outcome, Outcome::Approved, "answered-allow must not be pending"); + assert_eq!(deny.outcome, Outcome::Denied, "answered-deny must not be pending"); + assert_eq!(inbox.pending_count(), 0, "nothing may be left pending after reconciliation"); +} diff --git a/tests/tce_vectors.rs b/tests/tce_vectors.rs new file mode 100644 index 0000000..9e63dcd --- /dev/null +++ b/tests/tce_vectors.rs @@ -0,0 +1,260 @@ +//! Byte-exact conformance against the frozen TCE vectors produced by the +//! reference implementation. The vectors file lives in the relay repo at +//! ~/niko_trust/testdata/vectors/tce_vectors.json and is the normative ground +//! truth. If the file is absent the tests are skipped (keeps this crate +//! self-contained). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use niko_trust_gui::address::Address; +use niko_trust_gui::protocol::{self, Decision, MAX_CLOCK_SKEW}; +use niko_trust_gui::signer::{self, Signer}; +use niko_trust_gui::tce::number::canonical_number; +use niko_trust_gui::tce::value::Value; +use serde_json::Value as Json; + +fn vectors_path() -> PathBuf { + if let Ok(p) = std::env::var("NIKO_VECTORS") { + return PathBuf::from(p); + } + let d = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); + Path::new(&d).join("../niko_trust/testdata/vectors/tce_vectors.json") +} + +fn load_vectors() -> Option { + let p = vectors_path(); + if !p.exists() { + eprintln!("skipping: vectors file not found at {}", p.display()); + return None; + } + let raw = std::fs::read_to_string(&p).expect("read vectors"); + serde_json::from_str(&raw).map(Some).expect("parse vectors json") +} + +fn pubkey_of(addr: &str) -> [u8; 32] { + let a = Address::parse(addr).expect("valid address"); + *a.pubkey() +} + +fn seed_for_pubkey(parties: &Json, pubkey_hex: &str) -> [u8; 32] { + for (_name, p) in parties.as_object().unwrap() { + if p["pubkey_hex"].as_str() == Some(pubkey_hex) { + let s = hex::decode(p["seed_hex"].as_str().unwrap()).unwrap(); + return s.try_into().unwrap(); + } + } + panic!("party not found for pubkey {pubkey_hex}") +} + +fn signer_for(parties: &Json, pubkey_hex: &str) -> Signer { + Signer::from_seed(seed_for_pubkey(parties, pubkey_hex)).expect("signer") +} + +fn hex_bytes(s: &str) -> Vec { + hex::decode(s).expect("hex") +} + +fn value_from_json(v: &Json) -> Value { + match v { + Json::Null => Value::Null, + Json::Bool(b) => Value::Bool(*b), + Json::String(s) => Value::Str(s.clone()), + Json::Number(n) => Value::Number(n.to_string()), + other => panic!("unsupported value in vector json: {other}"), + } +} + +fn map_from_json(v: &Json) -> BTreeMap { + let mut m = BTreeMap::new(); + for (k, val) in v.as_object().unwrap() { + m.insert(k.clone(), value_from_json(val)); + } + m +} + +fn nonce_from_json(v: &Json) -> [u8; 16] { + hex_bytes(v.as_str().unwrap()).try_into().unwrap() +} + +fn hash_from_json(v: &Json) -> [u8; 32] { + hex_bytes(v.as_str().unwrap()).try_into().unwrap() +} + +fn check_vector(parties: &Json, v: &Json) { + let tce_expected = hex_bytes(v["tce_hex"].as_str().unwrap()); + let object_id = v["object_id_hex"].as_str().unwrap(); + let signer_key = v["signer_pubkey_hex"].as_str().unwrap(); + let _ = v["signature_hex"]; + let j = &v["json"]; + let typ = j["type"].as_str().unwrap(); + let signer = signer_for(parties, signer_key); + + // Build the typed object from the json view. + let encoded: Vec = match typ { + "identity" => protocol::encode_identity(&protocol::Identity { + pubkey: signer.pubkey(), + alias: j["alias"].as_str().unwrap().to_string(), + created_at: j["created_at"].as_u64().unwrap(), + }) + .unwrap(), + "claim" => protocol::encode_claim(&protocol::Claim { + issuer: signer.pubkey(), + subject: pubkey_of(j["subject"].as_str().unwrap()), + claims: map_from_json(&j["claims"]), + created_at: j["created_at"].as_u64().unwrap(), + expires_at: j["expires_at"].as_u64().unwrap(), + serial: j["serial"].as_u64().unwrap(), + nonce: nonce_from_json(&j["nonce"]), + }) + .unwrap(), + "revocation" => protocol::encode_revocation(&protocol::Revocation { + issuer: signer.pubkey(), + claim_id: hash_from_json(&j["claim_id"]), + reason: j["reason"].as_str().unwrap().to_string(), + created_at: j["created_at"].as_u64().unwrap(), + nonce: nonce_from_json(&j["nonce"]), + }) + .unwrap(), + "approval_request" => protocol::encode_approval_request(&protocol::ApprovalRequest { + sender: signer.pubkey(), + recipient: pubkey_of(j["recipient"].as_str().unwrap()), + action: j["action"].as_str().unwrap().to_string(), + payload: map_from_json(&j["payload"]), + message: j["message"].as_str().unwrap().to_string(), + created_at: j["created_at"].as_u64().unwrap(), + expires_at: j["expires_at"].as_u64().unwrap(), + nonce: nonce_from_json(&j["nonce"]), + }) + .unwrap(), + "approval_response" => { + let decision = match j["decision"].as_str().unwrap() { + "allow" => Decision::Allow, + "deny" => Decision::Deny, + other => panic!("bad decision {other}"), + }; + protocol::encode_approval_response(&protocol::ApprovalResponse { + request_hash: hash_from_json(&j["request_hash"]), + responder: signer.pubkey(), + decision, + created_at: j["created_at"].as_u64().unwrap(), + nonce: nonce_from_json(&j["nonce"]), + }) + .unwrap() + } + "auth_assertion" => protocol::encode_auth_assertion(&protocol::AuthAssertion { + pubkey: signer.pubkey(), + challenge: hash_from_json(&j["challenge"]), + scope: j["scope"].as_str().unwrap().to_string(), + audience: j["audience"].as_str().unwrap().to_string(), + created_at: j["created_at"].as_u64().unwrap(), + }) + .unwrap(), + other => panic!("unknown vector type {other}"), + }; + + assert_eq!(encoded, tce_expected, "vector {}: encoded bytes differ", v["name"]); + assert_eq!( + hex::encode(niko_trust_gui::crypto::sha256(&encoded)), + object_id, + "vector {}: object id differs", + v["name"] + ); + + // Deterministic Ed25519 must reproduce the reference signature. + let sig = signer.sign(&encoded); + assert_eq!(hex::encode(sig), v["signature_hex"], "vector {}: signature differs", v["name"]); + + // Rule checks on the decoded form. + let _ = MAX_CLOCK_SKEW; + + // decode -> re-encode round trip must be byte-identical. + let reencoded: Vec = match typ { + "identity" => { + let d = protocol::decode_identity(&tce_expected).unwrap(); + protocol::encode_identity(&d).unwrap() + } + "claim" => { + let d = protocol::decode_claim(&tce_expected).unwrap(); + protocol::encode_claim(&d).unwrap() + } + "revocation" => { + let d = protocol::decode_revocation(&tce_expected).unwrap(); + protocol::encode_revocation(&d).unwrap() + } + "approval_request" => { + let d = protocol::decode_approval_request(&tce_expected).unwrap(); + protocol::encode_approval_request(&d).unwrap() + } + "approval_response" => { + let d = protocol::decode_approval_response(&tce_expected).unwrap(); + protocol::encode_approval_response(&d).unwrap() + } + "auth_assertion" => { + let d = protocol::decode_auth_assertion(&tce_expected).unwrap(); + protocol::encode_auth_assertion(&d).unwrap() + } + _ => unreachable!(), + }; + assert_eq!(reencoded, tce_expected, "vector {}: decode/re-encode differs", v["name"]); + + // Signature verification over the exact bytes with the embedded key. + let verify_pub = match typ { + "claim" => pubkey_of(j["issuer"].as_str().unwrap()), + "approval_response" => signer.pubkey(), + "revocation" => signer.pubkey(), + "approval_request" => signer.pubkey(), + "auth_assertion" => signer.pubkey(), + "identity" => signer.pubkey(), + _ => unreachable!(), + }; + let sig_vec = hex_bytes(v["signature_hex"].as_str().unwrap()); + let sig_arr: [u8; 64] = sig_vec.try_into().unwrap(); + signer::verify(&verify_pub, &tce_expected, &sig_arr).expect("signature must verify"); +} + +#[test] +fn frozen_tce_vectors_byte_exact() { + let Some(doc) = load_vectors() else { return }; + let parties = &doc["parties"]; + for v in doc["vectors"].as_array().unwrap() { + check_vector(parties, v); + } + assert!(doc["vectors"].as_array().unwrap().len() >= 9); +} + +#[test] +fn number_canonicalization_vectors() { + let Some(doc) = load_vectors() else { return }; + let nc = &doc["number_canonicalization"]; + for (input, want) in nc["accept"].as_object().unwrap() { + assert_eq!( + canonical_number(input).ok(), + Some(want.as_str().unwrap().to_string()), + "accept {input}" + ); + } + for (input, _reason) in nc["reject"].as_object().unwrap() { + assert!(canonical_number(input).is_err(), "reject {input}"); + } +} + +#[test] +fn strict_decoder_rejects() { + let Some(doc) = load_vectors() else { return }; + // Every reject vector is hex bytes that a strict decoder must refuse as a + // Claim (the reference uses a claim context for these). Failing to decode + // is the assertion. + for r in doc["rejects"].as_array().unwrap() { + let name = r["name"].as_str().unwrap(); + // Some reject rows are prose descriptions without bytes; only rows + // carrying tce_hex are byte-checkable here. + let Some(b) = r["tce_hex"].as_str().map(hex_bytes) else { + continue; + }; + assert!( + niko_trust_gui::protocol::decode_claim(&b).is_err(), + "reject vector {name} must fail to decode" + ); + } +} \ No newline at end of file