steps 1-6: protocol, server, android skeleton, web UI, container
A.containerignore
@@ -0,0 +1,10 @@
target/
web/node_modules/
web/dist/
android/
dev/
crates/otproto/fuzz/target/
.git/
*.db
*.db-wal
*.db-shm
A.gitignore
@@ -0,0 +1,26 @@
/target
**/*.db
**/*.db-wal
**/*.db-shm
/cache
/dev
/secret.key
opentracker.toml
# Android
/android/.gradle
/android/build
/android/app/build
/android/local.properties
/android/keystore.properties
/android/*.jks
# Web
/web/node_modules
/web/dist
!/web/dist/.gitkeep
# cargo-fuzz
/crates/otproto/fuzz/target
/crates/otproto/fuzz/corpus
/crates/otproto/fuzz/artifacts
ACargo.lock
@@ -0,0 +1,3455 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[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 = "async-compression"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
dependencies = [
"compression-codecs",
"compression-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
dependencies = [
"num-traits",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[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 = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
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 = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"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.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
name = "compression-codecs"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[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.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[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 = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[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-queue"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[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",
"const-oid 0.9.6",
"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 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "either"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
dependencies = [
"serde",
]
[[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 = "etcetera"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
dependencies = [
"cfg-if",
"home",
"windows-sys 0.48.0",
]
[[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 = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[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 = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
dependencies = [
"futures-core",
"futures-sink",
"spin",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[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.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-intrusive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
dependencies = [
"futures-core",
"lock_api",
"parking_lot",
]
[[package]]
name = "futures-io"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-macro"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "futures-sink"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"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 = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
name = "governor"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"no-std-compat",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.5",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[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.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[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.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"bitflags",
"libc",
"plain",
"redox_syscall 0.9.0",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[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 = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
"serde",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest 0.10.7",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[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 = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.7",
"smallvec",
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[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 = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "otproto"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"hex",
"hkdf",
"proptest",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.19",
]
[[package]]
name = "otserver"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"axum",
"base64",
"chacha20poly1305",
"dashmap",
"governor",
"hex",
"hkdf",
"mime_guess",
"otproto",
"rand 0.9.5",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"sha2 0.10.9",
"socket2",
"sqlx",
"tempfile",
"thiserror 2.0.19",
"time",
"tokio",
"toml",
"tower",
"tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"tracing-subscriber",
]
[[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",
]
[[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 = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[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 = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[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 = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[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 = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand 0.9.5",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[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"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20 0.10.1",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[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 = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.119",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"sha2 0.11.0",
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"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 = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
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 = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[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 = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[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_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[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 = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[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 = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "sqlx"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
]
[[package]]
name = "sqlx-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"crc",
"crossbeam-queue",
"either",
"event-listener",
"futures-core",
"futures-intrusive",
"futures-io",
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap",
"log",
"memchr",
"once_cell",
"percent-encoding",
"serde",
"serde_json",
"sha2 0.10.9",
"smallvec",
"thiserror 2.0.19",
"time",
"tokio",
"tokio-stream",
"tracing",
"url",
]
[[package]]
name = "sqlx-macros"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
"sqlx-core",
"sqlx-macros-core",
"syn 2.0.119",
]
[[package]]
name = "sqlx-macros-core"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
"heck",
"hex",
"once_cell",
"proc-macro2",
"quote",
"serde",
"serde_json",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.119",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"bytes",
"crc",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"generic-array",
"hex",
"hkdf",
"hmac",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"percent-encoding",
"rand 0.8.7",
"rsa",
"serde",
"sha1",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.19",
"time",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-postgres"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
"bitflags",
"byteorder",
"crc",
"dotenvy",
"etcetera",
"futures-channel",
"futures-core",
"futures-util",
"hex",
"hkdf",
"hmac",
"home",
"itoa",
"log",
"md-5",
"memchr",
"once_cell",
"rand 0.8.7",
"serde",
"serde_json",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
"thiserror 2.0.19",
"time",
"tracing",
"whoami",
]
[[package]]
name = "sqlx-sqlite"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"flume",
"futures-channel",
"futures-core",
"futures-executor",
"futures-intrusive",
"futures-util",
"libsqlite3-sys",
"log",
"percent-encoding",
"serde",
"serde_urlencoded",
"sqlx-core",
"thiserror 2.0.19",
"time",
"tracing",
"url",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[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 = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[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 = "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",
"windows-sys 0.61.2",
]
[[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.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl 2.0.19",
]
[[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.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
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 = "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 = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[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_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 = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-cookies"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
dependencies = [
"axum-core",
"cookie",
"futures-util",
"http",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"async-compression",
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
"url",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
dependencies = [
"async-trait",
"http",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tower-sessions-memory-store",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
dependencies = [
"async-trait",
"axum-core",
"base64",
"futures",
"http",
"parking_lot",
"rand 0.8.7",
"serde",
"serde_json",
"thiserror 2.0.19",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tower-sessions-memory-store"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242"
dependencies = [
"async-trait",
"time",
"tokio",
"tower-sessions-core",
]
[[package]]
name = "tower-sessions-sqlx-store"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e054622079f57fc1a7d6a6089c9334f963d62028fe21dc9eddd58af9a78480b3"
dependencies = [
"async-trait",
"rmp-serde",
"sqlx",
"thiserror 1.0.69",
"time",
"tower-sessions-core",
]
[[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",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[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-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[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_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[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 = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[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 = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
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 = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[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 = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"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 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[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.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[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 = "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 = "zerocopy"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
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"
[[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"
ACargo.toml
@@ -0,0 +1,25 @@
[workspace]
members = ["crates/otproto", "crates/otserver"]
# cargo-fuzz builds its crates with their own nightly flags; keeping them out of
# the workspace stops a stable `cargo build` from trying to compile them.
exclude = ["crates/otproto/fuzz"]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.90"
license = "AGPL-3.0-or-later"
repository = "https://github.com/schulze/opentracker"
[workspace.dependencies]
otproto = { path = "crates/otproto" }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
AContainerfile
@@ -0,0 +1,46 @@
# One image: the Rust binary with the web UI compiled into it.
#
# Named Containerfile, so podman finds it without -f. The just recipes pass -f
# anyway, because the docker CLI only looks for Dockerfile.
#
# `rust-embed` pulls web/dist into the binary in release mode, so the runtime
# stage carries no assets and no web server — just the one executable.
FROM oven/bun:1.4 AS web
WORKDIR /web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build
FROM rust:1-slim-trixie AS server
WORKDIR /src
# libsqlite3-sys is vendored, so the build needs a C toolchain but no dev headers.
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY --from=web /web/dist/ web/dist/
RUN cargo build --release -p otserver
FROM debian:trixie-slim
# ca-certificates is not optional: the tile proxy fetches over HTTPS.
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --home /data opentracker \
&& mkdir -p /data && chown opentracker /data
COPY --from=server /src/target/release/otserver /usr/local/bin/otserver
USER opentracker
WORKDIR /data
ENV OT_HTTP_ADDR=0.0.0.0:7372 \
OT_UDP_ADDR=0.0.0.0:7373 \
OT_DB_PATH=/data/opentracker.db \
OT_CACHE_DIR=/data/cache
VOLUME /data
# THE TRAP: 7373 is UDP and HTTP reverse proxies do not forward it. It needs its
# own published port and its own firewall rule, or every phone silently falls
# back to TLS-over-TCP and the whole point of the protocol is lost.
EXPOSE 7372/tcp 7373/udp
ENTRYPOINT ["otserver"]
Aandroid/app/build.gradle.kts
@@ -0,0 +1,148 @@
import java.util.Properties
// The only build file in the project. AGP 9 ships Kotlin built in, so there is
// no `org.jetbrains.kotlin.android` line; and at one module a root build file and
// a version catalog would both be pure ceremony. Versions are pinned exactly —
// no `+`, no version ranges — because reproducible release builds are a goal from
// day one.
//
// The Compose compiler plugin is *not* implied by AGP's built-in Kotlin, despite
// what the "built-in Kotlin" framing suggests: enabling `buildFeatures.compose`
// without it fails configuration outright. Its version must equal the KGP version
// AGP bundles — 2.2.10 for AGP 9.2.1. Verify after any AGP bump with:
// ./gradlew :app:buildEnvironment | grep kotlin-gradle-plugin
plugins {
id("com.android.application") version "9.2.1"
id("org.jetbrains.kotlin.plugin.compose") version "2.2.10"
}
android {
namespace = "net.lexcom.opentracker"
compileSdk = 36
defaultConfig {
applicationId = "net.lexcom.opentracker"
// 29 is the first API with the 3-arg startForeground() overload, which
// is what lets us run a location foreground service without any
// compat library.
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
// Off by default since AGP 8; we read FLAG_DEBUGGABLE instead of
// generating a class for one boolean.
buildConfig = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildTypes {
debug {
// So a debug build can sit next to a release build on the same
// device. Referenced by the adb commands in the README.
applicationIdSuffix = ".debug"
}
release {
// No reflection anywhere in this app, and Compose/AndroidX ship
// consumer ProGuard rules, so minification can be turned on later
// without ever creating a proguard-rules.pro. Costs a few MB of APK.
isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
}
}
signingConfigs {
// Release signing is configured only when the (gitignored) properties
// file exists, so a fresh clone can still build debug.
val props = rootProject.file("keystore.properties")
if (props.exists()) {
create("release") {
val p = Properties().apply { props.inputStream().use(::load) }
storeFile = rootProject.file(p.getProperty("storeFile"))
storePassword = p.getProperty("storePassword")
keyAlias = p.getProperty("keyAlias")
keyPassword = p.getProperty("keyPassword")
// v1 signatures are only needed below API 24. Schemes v2/v3 are
// enough at minSdk 29 and keep the APK's zip entries untouched.
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
lint {
disable += setOf(
// There is no res/values/strings.xml on purpose: this app is not
// localized, and UI strings live as Kotlin constants next to the
// code that uses them.
"HardcodedText",
"MissingDefaultResource",
// "a newer version is available" checks. Every version here is
// pinned deliberately, for reproducible builds; being told daily
// that a newer one exists is noise, and with warningsAsErrors it
// would break the build the moment Google publishes anything.
// Upgrades are a decision, not a lint finding.
"AndroidGradlePluginVersion",
"GradleDependency",
"NewerVersionAvailable",
"OldTargetApi",
// Suggests replacing android:allowBackup="false" with a
// dataExtractionRules XML resource. There is nothing to configure:
// this app's data directory holds the device's token key, and
// backup/transfer of it is exactly what must not happen. Following
// the advice would add a res/ file that says "back up nothing".
"DataExtractionRules",
)
// A lint failure should stop the build rather than scroll past.
warningsAsErrors = true
abortOnError = true
}
packaging {
resources.excludes += setOf(
"META-INF/{AL2.0,LGPL2.1}",
"DebugProbesKt.bin",
)
}
testOptions {
unitTests.isReturnDefaultValues = true
}
sourceSets["test"].resources.srcDir("../../crates/otproto/tests")
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2026.06.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// The map. FOSS, no Play Services, raster tiles, points straight at our own
// /tiles proxy. Hosted in Compose via AndroidView.
implementation("org.osmdroid:osmdroid-android:6.1.20")
// Everything non-trivial in this app (sampling policy, frame codec, queue,
// AEAD) is pure Kotlin and tested on the JVM. No androidTest/, no emulator
// in the loop.
// Spelled out rather than `kotlin("test")`: AGP's built-in Kotlin does not
// apply the Kotlin Gradle plugin's version-inferring `kotlin()` helper, so
// that form resolves to a versionless coordinate and silently contributes
// nothing. The `-junit` variant brings the JUnit 4 runner AGP's unit tests
// expect. Version must track the KGP that AGP bundles.
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.10")
// Test-only. The app itself parses its one JSON response (the login reply)
// with the framework's org.json, but unit tests run against android.jar
// stubs where those methods return defaults, so the golden-vector test needs
// a real parser. Never reaches the APK.
testImplementation("org.json:json:20260719")
}
Aandroid/app/src/main/AndroidManifest.xml
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The one file with no Gradle equivalent. Everything here is either a permission,
a component declaration, or an <application> attribute that replaces a whole
res/ file:
label replaces values/strings.xml
allowBackup=false replaces backup_rules.xml + data_extraction_rules.xml
usesCleartextTraffic replaces network_security_config.xml
allowBackup="false" is a deliberate security decision, not a shortcut: the data
directory holds this device's token key, and restoring it onto a second device
would silently clone a credential.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- COARSE must be requested alongside FINE: asking for FINE alone on
API 31+ can be silently downgraded to COARSE by the system dialog. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Mandatory from API 34: without it startForeground(TYPE_LOCATION) throws
SecurityException. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Deliberately NOT SCHEDULE_EXACT_ALARM: the watchdog uses inexact
setAndAllowWhileIdle, which needs no permission and is why the heartbeat
is 15 minutes rather than 5. -->
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<application
android:allowBackup="false"
android:icon="@android:drawable/ic_menu_mylocation"
android:label="opentracker"
android:supportsRtl="true"
android:theme="@style/Theme.OpenTracker"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- stopWithTask=false: swiping the app away must not stop sharing.
location has no FGS runtime timeout (unlike dataSync) and is exempt
from the API 35 restriction on services started from
BOOT_COMPLETED — which is exactly why it is the right type. -->
<service
android:name=".TrackerService"
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false" />
<!-- MY_PACKAGE_REPLACED covers `adb install -r`, which otherwise leaves
tracking silently stopped after every reinstall. -->
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".WatchdogReceiver"
android:exported="false" />
</application>
</manifest>
Aandroid/app/src/main/java/net/lexcom/opentracker/BootReceiver.kt
@@ -0,0 +1,28 @@
package net.lexcom.opentracker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* Restarts tracking after a reboot or an app update.
*
* `MY_PACKAGE_REPLACED` matters as much as `BOOT_COMPLETED`: it covers
* `adb install -r`, which otherwise leaves tracking silently stopped after every
* reinstall. Starting a `location` foreground service from here is explicitly
* legal — the API 35 restriction on boot-started foreground services covers
* `dataSync`, `camera`, `mediaPlayback`, `phoneCall`, `mediaProjection` and
* `microphone`, and not `location`.
*
* Implemented at step 12, together with the kill-survival matrix.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.i(TAG, "received ${intent.action}; nothing to restart yet")
}
private companion object {
const val TAG = "OpenTracker"
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/MainActivity.kt
@@ -0,0 +1,62 @@
package net.lexcom.opentracker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.lexcom.opentracker.crypto.Sealer
/**
* The app's single activity. Three screens will hang off it — login, live map,
* settings — and it owns the permission and battery-exemption launchers, because
* only an Activity can.
*
* Right now it renders the crypto self-test, which is the one thing worth
* knowing before any of the rest is built.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface {
SelfTestScreen()
}
}
}
}
}
@Composable
private fun SelfTestScreen() {
// Runs once per composition of this screen; the real app runs it once per
// process start from Deps.
val report = remember { Sealer.selfTest() }
Column(
Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
) {
Text("opentracker", style = MaterialTheme.typography.headlineSmall)
Text(
"OTP/1 crypto self-test",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 16.dp),
)
Text(
report.summary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/TrackerService.kt
@@ -0,0 +1,31 @@
package net.lexcom.opentracker
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
/**
* The `location`-type foreground service that will drive
* policy → source → frame → queue → uplink.
*
* Declared in the manifest from the start so the permission and
* `foregroundServiceType` wiring is validated by the build, but not yet
* implemented — nothing starts it. Filled in at step 9 of the implementation
* order, alongside `Notifications` and `PermissionGate`.
*/
class TrackerService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// START_STICKY recreates the service after a low-memory kill, but with a
// null Intent — so state must come from Prefs, never from Intent extras.
Log.w(TAG, "TrackerService started before it was implemented; stopping")
stopSelf(startId)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private companion object {
const val TAG = "OpenTracker"
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/WatchdogReceiver.kt
@@ -0,0 +1,30 @@
package net.lexcom.opentracker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* Target of an inexact `setAndAllowWhileIdle` alarm, roughly every 15 minutes:
* liveness check, queue flush, and the stationary heartbeat. Re-arms itself,
* because `setRepeating` is both inexact *and* does not fire in doze.
*
* 15 minutes rather than 5 because inexact alarms in doze fire at most about
* once per 9 minutes, and `setExactAndAllowWhileIdle` would require
* `SCHEDULE_EXACT_ALARM` from API 31 — a permission this app deliberately does
* not request.
*
* Implemented at step 12.
*/
class WatchdogReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// The broadcast wake lock expires when onReceive returns, so any async
// work here must take its own PARTIAL_WAKE_LOCK first.
Log.i(TAG, "watchdog fired; no work scheduled yet")
}
private companion object {
const val TAG = "OpenTracker"
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/crypto/Sealer.kt
@@ -0,0 +1,261 @@
package net.lexcom.opentracker.crypto
import net.lexcom.opentracker.wire.HEADER_LEN
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.NONCE_LEN
import net.lexcom.opentracker.wire.TAG_LEN
import net.lexcom.opentracker.wire.WireFormatException
import net.lexcom.opentracker.wire.datagramLen
import java.security.GeneralSecurityException
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* ChaCha20-Poly1305 sealing and the HKDF key schedule.
*
* The point of this file is that it has **no dependencies**. Android's platform
* Conscrypt exposes `ChaCha20/Poly1305/NoPadding`, and Conscrypt is a Mainline
* module on Android 10+, so there is no Tink, no libsodium, no BouncyCastle, no
* JNI and no NDK anywhere in this build — which also means no per-ABI `.so`
* files and roughly 1.5 MB less APK.
*
* Two provider quirks are worth knowing:
* - The transform is spelled `ChaCha20/Poly1305/NoPadding` by Conscrypt but
* `ChaCha20-Poly1305` by the JDK's SunJCE (JEP 329), so JVM unit tests and
* the phone need different names. [transform] resolves whichever is present.
* - The nonce goes in an [IvParameterSpec], **not** a `GCMParameterSpec`.
* Passing the latter throws, and the error message does not say why.
*/
object Sealer {
const val KEY_LEN = 32
private val TRANSFORMS = listOf("ChaCha20/Poly1305/NoPadding", "ChaCha20-Poly1305")
/** Resolved once per process. */
val transform: String by lazy {
TRANSFORMS.firstOrNull { name ->
runCatching { Cipher.getInstance(name) }.isSuccess
} ?: throw GeneralSecurityException(
"no ChaCha20-Poly1305 provider; tried ${TRANSFORMS.joinToString()}",
)
}
val providerName: String by lazy { Cipher.getInstance(transform).provider.name }
private val rng = SecureRandom()
/** 12 fresh random bytes: an OTP/1 nonce, which is also a message id. */
fun newNonce(): ByteArray = ByteArray(NONCE_LEN).also(rng::nextBytes)
// -- key schedule --------------------------------------------------------
/**
* HKDF-Expand with SHA-256. Expand only, no extract: the token key is
* already 32 uniformly random bytes from the server's CSPRNG, so there is no
* entropy to condition.
*/
fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
require(length in 1..255 * 32) { "HKDF output length $length out of range" }
val mac = Mac.getInstance("HmacSHA256")
val out = ByteArray(length)
var previous = ByteArray(0)
var offset = 0
var counter = 1
while (offset < length) {
mac.init(SecretKeySpec(prk, "HmacSHA256"))
mac.update(previous)
mac.update(info)
mac.update(counter.toByte())
previous = mac.doFinal()
val take = minOf(previous.size, length - offset)
previous.copyInto(out, offset, 0, take)
offset += take
counter++
}
return out
}
/** `K_up = HKDF-Expand(token_key, "otp/1/up", 32)` — device to server. */
fun deriveUp(tokenKey: ByteArray): ByteArray =
hkdfExpand(tokenKey, "otp/1/up".toByteArray(), KEY_LEN)
/** `K_down = HKDF-Expand(token_key, "otp/1/down", 32)` — server to device. */
fun deriveDown(tokenKey: ByteArray): ByteArray =
hkdfExpand(tokenKey, "otp/1/down".toByteArray(), KEY_LEN)
/**
* `K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32)`.
*
* The app never derives this in production — the server issues it at login
* and it is stored alongside the token key. This exists so the golden vectors
* can prove the derivation matches, and so a test can construct a notice for
* a *different* token id and confirm it is rejected.
*/
fun deriveRevocation(master: ByteArray, tokenId: Long): ByteArray {
val info = ByteArray(12 + 8)
"otp/1/revoke".toByteArray().copyInto(info)
for (i in 0 until 8) {
info[12 + i] = (tokenId ushr (56 - 8 * i)).toByte()
}
return hkdfExpand(master, info, KEY_LEN)
}
// -- seal / open ---------------------------------------------------------
/**
* `header || ChaCha20Poly1305(key, nonce, payload, aad = header)`.
*
* The nonce comes from [header], so callers must have obtained it from
* [newNonce]. Reusing one under the same key is catastrophic for
* ChaCha20-Poly1305, which is why nothing here silently invents one.
*/
fun seal(key: ByteArray, header: Header, payload: ByteArray): ByteArray {
require(key.size == KEY_LEN) { "key must be $KEY_LEN bytes" }
val total = datagramLen(payload.size)
require(total <= MAX_DATAGRAM) { "datagram would be $total bytes, over the budget" }
val aad = header.toBytes()
val cipher = Cipher.getInstance(transform)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(header.nonce))
cipher.updateAAD(aad)
val sealed = cipher.doFinal(payload) // ciphertext || tag
val out = ByteArray(total)
aad.copyInto(out, 0)
sealed.copyInto(out, HEADER_LEN)
return out
}
fun sealMessage(key: ByteArray, tokenId: Long, nonce: ByteArray, msg: Message): ByteArray =
seal(key, Header(msg.type, tokenId, nonce), msg.encodePayload())
/**
* Verify and decrypt. Throws [GeneralSecurityException] on a bad tag.
*
* A device that cannot open a datagram simply drops it. Never answer one —
* a reply would turn the socket into a forgery oracle.
*/
fun open(key: ByteArray, datagram: ByteArray): Pair<Header, ByteArray> {
require(key.size == KEY_LEN) { "key must be $KEY_LEN bytes" }
val header = Header.peek(datagram)
if (datagram.size < HEADER_LEN + TAG_LEN) {
throw WireFormatException("datagram too short to hold a tag")
}
val cipher = Cipher.getInstance(transform)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(header.nonce))
cipher.updateAAD(datagram, 0, HEADER_LEN)
val payload = cipher.doFinal(datagram, HEADER_LEN, datagram.size - HEADER_LEN)
return header to payload
}
fun openMessage(key: ByteArray, datagram: ByteArray): Pair<Header, Message> {
val (header, payload) = open(key, datagram)
return header to Message.decodePayload(header.type, payload)
}
// -- self test -----------------------------------------------------------
data class SelfTestReport(
val ok: Boolean,
val transform: String?,
val provider: String?,
val detail: String,
) {
val summary: String
get() = buildString {
append(if (ok) "OK" else "FAILED")
if (transform != null) append("\ntransform: $transform")
if (provider != null) append("\nprovider: $provider")
append("\n")
append(detail)
}
}
/**
* Seals a known vector and checks it byte-for-byte, then opens it again.
*
* Cheap insurance against an OEM shipping a mangled provider set, and it
* turns "does API 29 really have ChaCha20-Poly1305?" into a fact visible in
* the log pane rather than a claim in a design document. The vector is the
* `loc_single` case from `crates/otproto/tests/vectors.json`; the full set is
* checked on the JVM by `VectorsTest`.
*/
fun selfTest(): SelfTestReport {
val tokenKey = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val expectedUp = "52c8535360382dd1d2b9d4b5d605f7c46f8a69fd4b5d62dfd900ca10b8ac6196"
val expectedDatagram = "110123456789abcdef000102030405060708090a0bee7fe4db683bd4169d85" +
"b517d4e14fceed13336f3437fe90f2c162c7b9a8073cfefc686999c6fa2a83"
val t = runCatching { transform }.getOrElse { e ->
return SelfTestReport(false, null, null, "no provider: ${e.message}")
}
val p = runCatching { providerName }.getOrNull()
return try {
val kUp = deriveUp(tokenKey)
if (bytesToHex(kUp) != expectedUp) {
return SelfTestReport(false, t, p, "HKDF mismatch: got ${bytesToHex(kUp)}")
}
val msg = Message.Loc(
listOf(
net.lexcom.opentracker.wire.Point(
ts = 1_785_000_042L,
latE7 = 525_200_080,
lonE7 = 134_050_000,
accDm = 80,
altM = 34,
spdCms = 450,
brgCdeg = 21_400,
batPct = 76,
flags = net.lexcom.opentracker.wire.PointFlags.NETWORK_FIX,
),
),
)
val nonce = hexToBytes("000102030405060708090a0b")
val sealed = sealMessage(kUp, 0x0123456789abcdefL, nonce, msg)
if (bytesToHex(sealed) != expectedDatagram) {
return SelfTestReport(
false, t, p,
"datagram mismatch\n got ${bytesToHex(sealed)}\nwant $expectedDatagram",
)
}
val (header, decoded) = openMessage(kUp, sealed)
if (decoded != msg) {
return SelfTestReport(false, t, p, "round trip changed the message")
}
// A tampered tag must be rejected.
val tampered = sealed.copyOf().also { it[it.size - 1] = (it[it.size - 1].toInt() xor 1).toByte() }
val rejected = runCatching { open(kUp, tampered) }.isFailure
if (!rejected) {
return SelfTestReport(false, t, p, "tampered datagram was accepted")
}
SelfTestReport(
true, t, p,
"HKDF, seal, open and tamper detection all match the Rust vectors " +
"(token 0x${header.tokenId.toString(16)}, ${sealed.size} B datagram).",
)
} catch (e: GeneralSecurityException) {
SelfTestReport(false, t, p, "crypto error: $e")
}
}
internal fun hexToBytes(s: String): ByteArray {
require(s.length % 2 == 0) { "odd-length hex string" }
return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
}
internal fun bytesToHex(b: ByteArray): String =
StringBuilder(b.size * 2).apply {
b.forEach { append("%02x".format(it)) }
}.toString()
}
Aandroid/app/src/main/java/net/lexcom/opentracker/wire/Frame.kt
@@ -0,0 +1,571 @@
package net.lexcom.opentracker.wire
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* The OTP/1 codec — the Kotlin half of the wire contract with `crates/otproto`.
*
* Deliberately pure: no Android types, no I/O, no clock, no RNG. Nonces are
* passed in. That is what lets `VectorsTest` check it against the Rust golden
* vectors on the JVM without an emulator.
*
* Layout is documented once, in `crates/otproto/src/{frame,msg,point}.rs`. This
* file must stay byte-identical to it, and `vectors.json` is what proves it does.
*
* There is no clock anywhere in this protocol except [Point.ts]. Nothing carries
* the server's time, nothing measures clock skew, nothing corrects a timestamp.
*/
const val VERSION = 1
const val HEADER_LEN = 21
const val TAG_LEN = 16
const val NONCE_LEN = 12
const val POINT_LEN = 24
const val MIN_DATAGRAM = HEADER_LEN + TAG_LEN
const val MAX_DATAGRAM = 1200
const val MAX_POINTS = 40
private const val ACC_UNKNOWN = 0xFFFF
private const val ALT_UNKNOWN = -0x8000
private const val SPD_UNKNOWN = 0xFFFF
private const val BRG_UNKNOWN = 0xFFFF
private const val BAT_UNKNOWN = 0xFF
private const val ACC_MAX = ACC_UNKNOWN - 1
private const val SPD_MAX = SPD_UNKNOWN - 1
private const val BRG_MAX = 35_999
private const val ALT_MIN = ALT_UNKNOWN + 1
/** Thrown for structurally impossible bytes. Never for merely odd values. */
class WireFormatException(message: String) : Exception(message)
enum class MsgType(val code: Int) {
LOC(0x1),
ACK(0x2),
NACK(0x3),
HELLO(0x4),
CONFIG(0x5),
CONFIG_GET(0x6),
PING(0x7),
PONG(0x8),
/** Sealed under `K_rev`, not `K_down`. See [Message.Revoked]. */
REVOKED(0x9),
;
/** Uplink messages are sealed under `K_up`, downlink under `K_down`. */
val isUplink: Boolean get() = this == LOC || this == HELLO || this == CONFIG_GET || this == PING
companion object {
fun fromCode(code: Int): MsgType =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown message type 0x${code.toString(16)}")
}
}
object PointFlags {
const val NONE = 0
const val CHARGING = 1 shl 0
const val NETWORK_FIX = 1 shl 1
const val LOW_ACCURACY = 1 shl 2
const val MOCK = 1 shl 3
}
object AckFlags {
const val NONE = 0
const val CONFIG_PENDING = 1 shl 0
const val THROTTLE = 1 shl 1
}
object HelloFlags {
const val NONE = 0
const val FIRST_LAUNCH = 1 shl 0
}
object ConfigFlags {
const val NONE = 0
const val TRACKING_ENABLED = 1 shl 0
const val REQUEST_HELLO = 1 shl 1
}
enum class Profile(val code: Int) {
BATTERY_SAVER(0),
BALANCED(1),
HIGH_ACCURACY(2),
;
companion object {
fun fromCode(code: Int): Profile =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown profile $code")
}
}
enum class RevokeReason(val code: Int) {
/** Explicitly revoked: "log out all other devices", or a password change. */
REVOKED(1),
/** Deleted by the server's staleness sweep after a long silence. */
EXPIRED(2),
/** The server has no record of this token: a restored backup, or a rotated
* server key. */
UNKNOWN(3),
;
companion object {
fun fromCode(code: Int): RevokeReason =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown revoke reason $code")
}
}
enum class NackReason(val code: Int) {
/** Token unknown, revoked or expired: clear local state, show login. */
UNKNOWN_TOKEN(1),
MALFORMED(2),
RATE_LIMITED(3),
STORAGE_FULL(4),
;
companion object {
fun fromCode(code: Int): NackReason =
entries.firstOrNull { it.code == code }
?: throw WireFormatException("unknown NACK reason $code")
}
}
/**
* One location report, 24 bytes on the wire.
*
* `null` means the device could not measure that field and is carried as the
* field's sentinel. [ts] is unsigned 32-bit, hence [Long].
*/
data class Point(
val ts: Long,
val latE7: Int,
val lonE7: Int,
val accDm: Int? = null,
val altM: Int? = null,
val spdCms: Int? = null,
val brgCdeg: Int? = null,
val batPct: Int? = null,
val flags: Int = PointFlags.NONE,
) {
fun writeTo(buf: ByteBuffer) {
buf.putInt(ts.toInt())
buf.putInt(latE7)
buf.putInt(lonE7)
buf.putShort((accDm?.coerceAtMost(ACC_MAX) ?: ACC_UNKNOWN).toShort())
buf.putShort((altM?.coerceAtLeast(ALT_MIN) ?: ALT_UNKNOWN).toShort())
buf.putShort((spdCms?.coerceAtMost(SPD_MAX) ?: SPD_UNKNOWN).toShort())
buf.putShort((brgCdeg?.coerceAtMost(BRG_MAX) ?: BRG_UNKNOWN).toShort())
buf.put((batPct?.coerceAtMost(100) ?: BAT_UNKNOWN).toByte())
buf.put(flags.toByte())
buf.putShort(0) // reserved
}
fun toBytes(): ByteArray = ByteArray(POINT_LEN).also { writeTo(bufferOf(it)) }
companion object {
fun readFrom(buf: ByteBuffer): Point {
val ts = buf.int.toLong() and 0xFFFFFFFFL
val lat = buf.int
val lon = buf.int
val acc = buf.short.toInt() and 0xFFFF
val alt = buf.short.toInt() // signed
val spd = buf.short.toInt() and 0xFFFF
val brg = buf.short.toInt() and 0xFFFF
val bat = buf.get().toInt() and 0xFF
val flags = buf.get().toInt() and 0xFF
buf.short // reserved: ignored, not rejected, so a later version can use it
return Point(
ts = ts,
latE7 = lat,
lonE7 = lon,
accDm = if (acc == ACC_UNKNOWN) null else acc,
altM = if (alt == ALT_UNKNOWN) null else alt,
spdCms = if (spd == SPD_UNKNOWN) null else spd,
brgCdeg = if (brg == BRG_UNKNOWN) null else brg,
batPct = if (bat == BAT_UNKNOWN) null else bat,
flags = flags,
)
}
fun fromBytes(b: ByteArray): Point {
require(b.size == POINT_LEN) { "point record must be $POINT_LEN bytes, got ${b.size}" }
return readFrom(bufferOf(b))
}
}
}
sealed interface Message {
val type: MsgType
val payloadLen: Int
fun writePayload(buf: ByteBuffer)
fun encodePayload(): ByteArray = ByteArray(payloadLen).also { writePayload(bufferOf(it)) }
/** One or more fully independent points. */
data class Loc(val points: List<Point>) : Message {
init {
require(points.size in 1..MAX_POINTS) {
"LOC must carry 1..$MAX_POINTS points, got ${points.size}"
}
}
override val type get() = MsgType.LOC
override val payloadLen get() = 1 + points.size * POINT_LEN
override fun writePayload(buf: ByteBuffer) {
buf.put(points.size.toByte())
points.forEach { it.writeTo(buf) }
}
}
data class Ack(
val nonces: List<ByteArray>,
val flags: Int,
) : Message {
init {
require(nonces.size in 1..MAX_POINTS) { "ACK must carry 1..$MAX_POINTS nonces" }
require(nonces.all { it.size == NONCE_LEN }) { "every nonce must be $NONCE_LEN bytes" }
}
override val type get() = MsgType.ACK
override val payloadLen get() = 1 + nonces.size * NONCE_LEN + 1
override fun writePayload(buf: ByteBuffer) {
buf.put(nonces.size.toByte())
nonces.forEach { buf.put(it) }
buf.put(flags.toByte())
}
// ByteArray identity would make data-class equality useless here.
override fun equals(other: Any?): Boolean =
other is Ack &&
flags == other.flags &&
nonces.size == other.nonces.size &&
nonces.indices.all { nonces[it].contentEquals(other.nonces[it]) }
override fun hashCode(): Int {
var h = flags
nonces.forEach { h = h * 31 + it.contentHashCode() }
return h
}
}
data class Nack(
val nonce: ByteArray,
val reason: NackReason,
val retryAfterS: Int,
) : Message {
init {
require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" }
}
override val type get() = MsgType.NACK
override val payloadLen get() = NONCE_LEN + 2
override fun writePayload(buf: ByteBuffer) {
buf.put(nonce)
buf.put(reason.code.toByte())
buf.put(retryAfterS.toByte())
}
override fun equals(other: Any?): Boolean =
other is Nack &&
nonce.contentEquals(other.nonce) &&
reason == other.reason &&
retryAfterS == other.retryAfterS
override fun hashCode(): Int =
(nonce.contentHashCode() * 31 + reason.hashCode()) * 31 + retryAfterS
}
data class Hello(
val appVersionCode: Int,
val osApiLevel: Int,
val flags: Int,
val configVersion: Int,
) : Message {
override val type get() = MsgType.HELLO
override val payloadLen get() = 6
override fun writePayload(buf: ByteBuffer) {
buf.putShort(appVersionCode.toShort())
buf.put(osApiLevel.toByte())
buf.put(flags.toByte())
buf.putShort(configVersion.toShort())
}
}
data class Config(
val configVersion: Int,
val profile: Profile,
val flags: Int,
val heartbeatS: Int,
val intervalScalePct: Int,
val minDistanceM: Int,
val maxPointsPerLoc: Int,
) : Message {
override val type get() = MsgType.CONFIG
override val payloadLen get() = 12
override fun writePayload(buf: ByteBuffer) {
buf.putShort(configVersion.toShort())
buf.put(profile.code.toByte())
buf.put(flags.toByte())
buf.putShort(heartbeatS.toShort())
buf.putShort(intervalScalePct.toShort())
buf.putShort(minDistanceM.toShort())
buf.put(maxPointsPerLoc.toByte())
buf.put(0) // reserved
}
}
data class ConfigGet(val haveVersion: Int) : Message {
override val type get() = MsgType.CONFIG_GET
override val payloadLen get() = 2
override fun writePayload(buf: ByteBuffer) {
buf.putShort(haveVersion.toShort())
}
}
/**
* [echo] is opaque to the server and comes back verbatim in the [Pong], so
* this side can match a reply and measure a round trip. Put a monotonic
* reading in it — deliberately not a wall-clock time, which the server has no
* business interpreting.
*/
data class Ping(val echo: Long, val seq: Int) : Message {
override val type get() = MsgType.PING
override val payloadLen get() = 6
override fun writePayload(buf: ByteBuffer) {
buf.putInt(echo.toInt())
buf.putShort(seq.toShort())
}
}
data class Pong(val echo: Long, val seq: Int) : Message {
override val type get() = MsgType.PONG
override val payloadLen get() = 6
override fun writePayload(buf: ByteBuffer) {
buf.putInt(echo.toInt())
buf.putShort(seq.toShort())
}
}
/**
* "This token is dead; log in again."
*
* The one message opened with `K_rev` rather than `K_down`. `K_down` derives
* from the token key, so it dies with the token's row on the server — and the
* moment the server most needs to speak is exactly when that row is gone.
* `K_rev` is issued at login and derives from a server master plus this
* device's `tokenId`, so no third party and no other device can produce one.
*
* Acting on it requires no clock and no counting. Two rules and nothing else:
* the datagram must open under this device's `K_rev`, and its header
* `tokenId` must match the token currently held. A captured notice for an old
* token therefore does nothing after the next login.
*/
data class Revoked(val reason: RevokeReason) : Message {
override val type get() = MsgType.REVOKED
override val payloadLen get() = 1
override fun writePayload(buf: ByteBuffer) {
buf.put(reason.code.toByte())
}
}
companion object {
fun decodePayload(type: MsgType, payload: ByteArray): Message {
fun exact(expected: Int) {
if (payload.size != expected) {
throw WireFormatException(
"$type: expected $expected payload bytes, got ${payload.size}",
)
}
}
val buf = bufferOf(payload)
return when (type) {
MsgType.LOC -> {
if (payload.isEmpty()) throw WireFormatException("LOC: empty payload")
val count = buf.get().toInt() and 0xFF
if (count !in 1..MAX_POINTS) {
throw WireFormatException("LOC point count $count out of range 1..$MAX_POINTS")
}
exact(1 + count * POINT_LEN)
Loc(List(count) { Point.readFrom(buf) })
}
MsgType.ACK -> {
if (payload.isEmpty()) throw WireFormatException("ACK: empty payload")
val count = buf.get().toInt() and 0xFF
if (count !in 1..MAX_POINTS) {
throw WireFormatException("ACK nonce count $count out of range 1..$MAX_POINTS")
}
exact(1 + count * NONCE_LEN + 1)
val nonces = List(count) { ByteArray(NONCE_LEN).also(buf::get) }
Ack(nonces = nonces, flags = buf.get().toInt() and 0xFF)
}
MsgType.NACK -> {
exact(NONCE_LEN + 2)
Nack(
nonce = ByteArray(NONCE_LEN).also(buf::get),
reason = NackReason.fromCode(buf.get().toInt() and 0xFF),
retryAfterS = buf.get().toInt() and 0xFF,
)
}
MsgType.HELLO -> {
exact(6)
Hello(
appVersionCode = buf.short.toInt() and 0xFFFF,
osApiLevel = buf.get().toInt() and 0xFF,
flags = buf.get().toInt() and 0xFF,
configVersion = buf.short.toInt() and 0xFFFF,
)
}
MsgType.CONFIG -> {
exact(12)
val configVersion = buf.short.toInt() and 0xFFFF
val profile = Profile.fromCode(buf.get().toInt() and 0xFF)
val flags = buf.get().toInt() and 0xFF
val heartbeatS = buf.short.toInt() and 0xFFFF
val intervalScalePct = buf.short.toInt() and 0xFFFF
val minDistanceM = buf.short.toInt() and 0xFFFF
val maxPointsPerLoc = buf.get().toInt() and 0xFF
buf.get() // reserved
Config(
configVersion = configVersion,
profile = profile,
flags = flags,
heartbeatS = heartbeatS,
intervalScalePct = intervalScalePct,
minDistanceM = minDistanceM,
maxPointsPerLoc = maxPointsPerLoc,
)
}
MsgType.CONFIG_GET -> {
exact(2)
ConfigGet(haveVersion = buf.short.toInt() and 0xFFFF)
}
MsgType.PING -> {
exact(6)
Ping(
echo = buf.int.toLong() and 0xFFFFFFFFL,
seq = buf.short.toInt() and 0xFFFF,
)
}
MsgType.PONG -> {
exact(6)
Pong(
echo = buf.int.toLong() and 0xFFFFFFFFL,
seq = buf.short.toInt() and 0xFFFF,
)
}
MsgType.REVOKED -> {
exact(1)
Revoked(RevokeReason.fromCode(buf.get().toInt() and 0xFF))
}
}
}
}
}
/**
* The 21 cleartext header bytes, which are also the AEAD's additional data.
*
* Cleartext because the server must read [tokenId] to pick a key before it can
* decrypt; authenticated as AAD so a ciphertext cannot be retargeted to another
* token or another message type.
*/
data class Header(
val type: MsgType,
val tokenId: Long,
val nonce: ByteArray,
) {
init {
require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" }
}
fun toBytes(): ByteArray {
val b = ByteArray(HEADER_LEN)
val buf = bufferOf(b)
buf.put(((VERSION shl 4) or type.code).toByte())
buf.putLong(tokenId)
buf.put(nonce)
return b
}
override fun equals(other: Any?): Boolean =
other is Header && type == other.type && tokenId == other.tokenId &&
nonce.contentEquals(other.nonce)
override fun hashCode(): Int =
(type.hashCode() * 31 + tokenId.hashCode()) * 31 + nonce.contentHashCode()
companion object {
/**
* Parse a datagram's header without decrypting it. Length, version and
* type filtering all happen here, before any crypto is spent.
*/
fun peek(datagram: ByteArray): Header {
if (datagram.size < MIN_DATAGRAM) {
throw WireFormatException(
"datagram too short: ${datagram.size} bytes, minimum is $MIN_DATAGRAM",
)
}
if (datagram.size > MAX_DATAGRAM) {
throw WireFormatException(
"datagram too long: ${datagram.size} bytes, maximum is $MAX_DATAGRAM",
)
}
val verType = datagram[0].toInt() and 0xFF
val version = verType shr 4
if (version != VERSION) {
throw WireFormatException("unsupported protocol version $version")
}
val buf = bufferOf(datagram)
buf.get()
return Header(
type = MsgType.fromCode(verType and 0x0F),
tokenId = buf.long,
nonce = ByteArray(NONCE_LEN).also(buf::get),
)
}
}
}
/** Total datagram size for a payload of [payloadLen] bytes. */
fun datagramLen(payloadLen: Int): Int = HEADER_LEN + payloadLen + TAG_LEN
/**
* Ceiling on any datagram the server sends in reply to one it received.
*
* This is the anti-amplification control. It replaces an earlier rule that
* `response <= request` for every message type, which was achievable only by
* padding requests with reserved bytes, and which guarded against a threat
* authentication already removes: a reply is only ever sent to a datagram that
* passed AEAD verification, so a reflection attacker must already hold a live
* token key, and the leverage on offer is a ratio near 1.
*/
const val MAX_REPLY = 64
fun fitsReplyBudget(responseLen: Int): Boolean = responseLen <= MAX_REPLY
private fun bufferOf(b: ByteArray): ByteBuffer = ByteBuffer.wrap(b).order(ByteOrder.BIG_ENDIAN)
Aandroid/app/src/main/res/values/themes.xml
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The one res/ file that could not be omitted.
The plan assumed `@android:style/Theme.DeviceDefault.DayNight.NoActionBar`
exists so the manifest could point at a framework theme and res/ could stay
empty. It does not: the framework has `Theme.DeviceDefault.DayNight` (API 29+)
and it has `Theme.DeviceDefault.NoActionBar`, but there is no combination of the
two, and `Theme.DeviceDefault` on its own is the dark variant rather than a
day/night one.
So this file is the minimum needed for the ~150 ms pre-first-frame window to
follow the system light/dark setting *and* not flash an action bar. Six lines,
and no values-night/ counterpart — DayNight already handles that. Everything
else about the app's appearance is Compose.
-->
<resources>
<style name="Theme.OpenTracker" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
Aandroid/app/src/test/java/net/lexcom/opentracker/FrameTest.kt
@@ -0,0 +1,293 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.wire.AckFlags
import net.lexcom.opentracker.wire.HEADER_LEN
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.MAX_POINTS
import net.lexcom.opentracker.wire.MAX_REPLY
import net.lexcom.opentracker.wire.MIN_DATAGRAM
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.PointFlags
import net.lexcom.opentracker.wire.Profile
import net.lexcom.opentracker.wire.RevokeReason
import net.lexcom.opentracker.wire.WireFormatException
import net.lexcom.opentracker.wire.datagramLen
import net.lexcom.opentracker.wire.fitsReplyBudget
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Codec behaviour that the golden vectors do not pin down: quantization bounds,
* sentinel handling, degenerate lengths, and tamper detection.
*
* `VectorsTest` proves this codec agrees with the Rust one. This proves it
* behaves sensibly on input the vectors do not contain.
*/
class FrameTest {
private val tokenKey = ByteArray(32) { it.toByte() }
private val kUp get() = Sealer.deriveUp(tokenKey)
private val kDown get() = Sealer.deriveDown(tokenKey)
private val kRev get() = Sealer.deriveRevocation(ByteArray(32) { 0xC3.toByte() }, tokenId)
/** REVOKED travels downlink but is sealed under K_rev, so the key cannot be
* chosen from the direction alone. */
private fun keyFor(type: MsgType) = when {
type == MsgType.REVOKED -> kRev
type.isUplink -> kUp
else -> kDown
}
private val tokenId = 0x1122334455667788L
private val nonce = ByteArray(12) { 0xA0.toByte() }
private fun allMessages() = listOf(
Message.Loc(listOf(Point(1_785_000_042L, 525_200_080, 134_050_000))),
Message.Ack(listOf(nonce), AckFlags.CONFIG_PENDING),
Message.Nack(nonce, NackReason.UNKNOWN_TOKEN, 0),
Message.Hello(2, 34, 0, 1),
Message.Config(1, Profile.BALANCED, 1, 900, 100, 20, MAX_POINTS),
Message.ConfigGet(1),
Message.Ping(0xDEADBEEFL, 2),
Message.Pong(0xDEADBEEFL, 3),
Message.Revoked(RevokeReason.EXPIRED),
)
@Test
fun `every message round trips through seal and open`() {
for (msg in allMessages()) {
val key = keyFor(msg.type)
val datagram = Sealer.sealMessage(key, tokenId, nonce, msg)
assertEquals(datagramLen(msg.payloadLen), datagram.size, "${msg.type}: size")
assertTrue(datagram.size <= MAX_DATAGRAM, "${msg.type}: over budget")
val (header, back) = Sealer.openMessage(key, datagram)
assertEquals(tokenId, header.tokenId)
assertEquals(msg.type, header.type)
assertEquals(msg, back, "${msg.type}: round trip")
}
}
@Test
fun `unknown optional fields survive as null`() {
val p = Point(ts = 1L, latE7 = 2, lonE7 = 3)
val back = Point.fromBytes(p.toBytes())
assertNull(back.accDm)
assertNull(back.altM)
assertNull(back.spdCms)
assertNull(back.brgCdeg)
assertNull(back.batPct)
assertEquals(p, back)
}
@Test
fun `an all-ones record reads as unknown except altitude`() {
// 0xFFFF as a signed altitude is -1 m, a perfectly good value, so it is
// not the sentinel — 0x8000 is. Easy to get backwards.
val p = Point.fromBytes(ByteArray(POINT_LEN) { 0xFF.toByte() })
assertNull(p.accDm)
assertNull(p.spdCms)
assertNull(p.brgCdeg)
assertNull(p.batPct)
assertEquals(-1, p.altM)
}
@Test
fun `out of range values clamp rather than becoming unknown`() {
val p = Point(
ts = 0L, latE7 = 0, lonE7 = 0,
accDm = 0xFFFF, altM = -0x8000, spdCms = 0xFFFF,
brgCdeg = 40_000, batPct = 200,
)
val back = Point.fromBytes(p.toBytes())
assertEquals(0xFFFE, back.accDm)
assertEquals(-0x7FFF, back.altM)
assertEquals(0xFFFE, back.spdCms)
assertEquals(35_999, back.brgCdeg)
assertEquals(100, back.batPct)
}
@Test
fun `timestamps past 2038 survive as unsigned`() {
// ts is unsigned 32-bit, so it is good to 2106. A signed Int would wrap
// to a negative in 2038, which is exactly the bug this guards.
val p = Point(ts = 4_000_000_000L, latE7 = 1, lonE7 = 2)
assertEquals(4_000_000_000L, Point.fromBytes(p.toBytes()).ts)
}
@Test
fun `reserved bytes are written zero and ignored on read`() {
val bytes = Point(1L, 2, 3).toBytes()
assertEquals(0, bytes[22])
assertEquals(0, bytes[23])
// A future version populating them must not change what we decode.
val future = bytes.copyOf().also { it[22] = 0x5A; it[23] = 0x3C }
assertEquals(Point.fromBytes(bytes), Point.fromBytes(future))
}
@Test
fun `quantization error stays within the documented precision`() {
val lat = 52.520008
val lon = 13.405
val p = Point(0L, Math.round(lat * 1e7).toInt(), Math.round(lon * 1e7).toInt())
val back = Point.fromBytes(p.toBytes())
assertTrue(Math.abs(back.latE7 / 1e7 - lat) < 1e-7, "latitude lost precision")
assertTrue(Math.abs(back.lonE7 / 1e7 - lon) < 1e-7, "longitude lost precision")
}
@Test
fun `documented wire sizes hold`() {
val one = Point(0L, 0, 0)
assertEquals(62, datagramLen(Message.Loc(listOf(one)).payloadLen))
assertEquals(518, datagramLen(Message.Loc(List(20) { one }).payloadLen))
assertEquals(998, datagramLen(Message.Loc(List(MAX_POINTS) { one }).payloadLen))
}
@Test
fun `a max size LOC still fits the datagram budget`() {
val msg = Message.Loc(List(MAX_POINTS) { Point(it.toLong(), it, -it) })
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, msg)
assertTrue(datagram.size <= MAX_DATAGRAM)
assertEquals(msg, Sealer.openMessage(kUp, datagram).second)
}
@Test
fun `a LOC count that disagrees with the length is rejected`() {
val payload = ByteArray(1 + POINT_LEN).also { it[0] = 2 } // claims two, carries one
assertFails { Message.decodePayload(MsgType.LOC, payload) }
assertFails { Message.decodePayload(MsgType.LOC, byteArrayOf(0)) }
}
@Test
fun `the wrong direction key cannot open a datagram`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(1L, 2))
assertFails { Sealer.openMessage(kDown, datagram) }
}
@Test
fun `every byte of the datagram is authenticated`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(9L, 1))
for (i in datagram.indices) {
val bad = datagram.copyOf().also { it[i] = (it[i].toInt() xor 1).toByte() }
assertFails("byte $i was not authenticated") { Sealer.openMessage(kUp, bad) }
}
}
@Test
fun `a datagram cannot be retargeted to another token`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(1L, 1))
val retargeted = datagram.copyOf()
// Overwrite the token_id field: it is cleartext, but it is also the AAD.
for (i in 1..8) retargeted[i] = 0
assertFails { Sealer.openMessage(kUp, retargeted) }
}
@Test
fun `truncated and oversized datagrams are rejected before any crypto`() {
val datagram = Sealer.sealMessage(kUp, tokenId, nonce, Message.Ping(1L, 1))
for (cut in 0 until MIN_DATAGRAM) {
assertFails("truncation to $cut accepted") { Header.peek(datagram.copyOf(cut)) }
}
assertFails { Header.peek(ByteArray(MAX_DATAGRAM + 1) { 0x11 }) }
}
@Test
fun `bad versions and unknown types are rejected`() {
val bad = ByteArray(MIN_DATAGRAM)
bad[0] = 0x21 // version 2
assertFails { Header.peek(bad) }
bad[0] = 0x1F // version 1, type 0xF
assertFails { Header.peek(bad) }
}
@Test
fun `the header is exactly the AAD`() {
val header = Header(MsgType.PING, tokenId, nonce)
val datagram = Sealer.seal(kUp, header, Message.Ping(1L, 1).encodePayload())
assertContentEquals(header.toBytes(), datagram.copyOf(HEADER_LEN))
}
@Test
fun `every reply fits the reply budget and stays near a 1x ratio`() {
val one = Point(0L, 0, 0)
val oneLoc = Message.Loc(listOf(one))
val fullLoc = Message.Loc(List(MAX_POINTS) { one })
val ackOne = Message.Ack(listOf(nonce), AckFlags.NONE)
val hello = Message.Hello(0, 0, 0, 0)
val configGet = Message.ConfigGet(0)
val config = Message.Config(0, Profile.BALANCED, 0, 0, 0, 0, 0)
val nack = Message.Nack(nonce, NackReason.MALFORMED, 0)
val revoked = Message.Revoked(RevokeReason.UNKNOWN)
for (reply in listOf(ackOne, nack, config, Message.Pong(0L, 0), revoked)) {
val len = datagramLen(reply.payloadLen)
assertTrue(fitsReplyBudget(len), "${reply.type} is $len B, over the $MAX_REPLY B budget")
}
val exchanges = listOf(
oneLoc to ackOne,
fullLoc to ackOne,
hello to ackOne,
configGet to config,
Message.Ping(0L, 0) to Message.Pong(0L, 0),
oneLoc to nack,
)
for ((req, resp) in exchanges) {
val ratio = datagramLen(resp.payloadLen).toDouble() / datagramLen(req.payloadLen)
assertTrue(ratio <= 1.5, "${req.type} -> ${resp.type} amplifies ${ratio}x")
}
}
/** The sizes both implementations must agree on. */
@Test
fun `documented message sizes hold`() {
assertEquals(51, datagramLen(Message.Ack(listOf(nonce), AckFlags.NONE).payloadLen))
assertEquals(51, datagramLen(Message.Nack(nonce, NackReason.MALFORMED, 0).payloadLen))
assertEquals(49, datagramLen(Message.Config(0, Profile.BALANCED, 0, 0, 0, 0, 0).payloadLen))
assertEquals(39, datagramLen(Message.ConfigGet(0).payloadLen))
assertEquals(43, datagramLen(Message.Ping(0L, 0).payloadLen))
assertEquals(43, datagramLen(Message.Pong(0L, 0).payloadLen))
assertEquals(43, datagramLen(Message.Hello(0, 0, 0, 0).payloadLen))
// The smallest message in the protocol, and the only reply the server
// sends without having verified the request. One byte of payload is what
// lets it be refused to anything shorter, so it can never amplify.
assertEquals(38, datagramLen(Message.Revoked(RevokeReason.UNKNOWN).payloadLen))
assertTrue(
datagramLen(Message.Revoked(RevokeReason.UNKNOWN).payloadLen) > MIN_DATAGRAM,
"a REVOKED must be larger than an empty datagram, or the length rule is vacuous",
)
}
@Test
fun `flag bits have the documented values`() {
assertEquals(1, PointFlags.CHARGING)
assertEquals(2, PointFlags.NETWORK_FIX)
assertEquals(4, PointFlags.LOW_ACCURACY)
assertEquals(8, PointFlags.MOCK)
}
@Test
fun `unknown enum codes throw a wire format error`() {
assertFails<WireFormatException> { MsgType.fromCode(0) }
assertFails<WireFormatException> { MsgType.fromCode(10) }
assertFails<WireFormatException> { Profile.fromCode(3) }
assertFails<WireFormatException> { NackReason.fromCode(0) }
assertFails<WireFormatException> { RevokeReason.fromCode(0) }
assertFails<WireFormatException> { RevokeReason.fromCode(4) }
}
}
private inline fun <reified T : Throwable> assertFails(noinline block: () -> Unit) {
val e = assertFails(block)
assertTrue(e is T, "expected ${T::class.simpleName} but got ${e::class.simpleName}: $e")
}
Aandroid/app/src/test/java/net/lexcom/opentracker/VectorsTest.kt
@@ -0,0 +1,282 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.wire.HEADER_LEN
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.MAX_POINTS
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.Profile
import net.lexcom.opentracker.wire.RevokeReason
import net.lexcom.opentracker.wire.TAG_LEN
import net.lexcom.opentracker.wire.VERSION
import org.json.JSONArray
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertTrue
/**
* Decodes `crates/otproto/tests/vectors.json` — the same file the Rust test
* suite checks — and verifies this Kotlin codec reproduces every byte.
*
* This is the whole reason the wire format cannot drift on one side only. The
* file is read from the Rust crate's directory (wired up as a test resource in
* `build.gradle.kts`), so there is exactly one copy of it and no sync step to
* forget.
*
* Runs on the JVM: JDK 11+ SunJCE ships ChaCha20-Poly1305 via JEP 329, so no
* emulator is needed to test the crypto.
*/
class VectorsTest {
private val root: JSONObject by lazy {
val stream = checkNotNull(javaClass.classLoader?.getResourceAsStream("vectors.json")) {
"vectors.json is not on the test classpath — check the test resources srcDir"
}
JSONObject(stream.bufferedReader().readText())
}
private val tokenKey: ByteArray by lazy { Sealer.hexToBytes(root.getString("token_key_hex")) }
private val tokenId: Long by lazy { root.getLong("token_id") }
private val kUp: ByteArray by lazy { Sealer.deriveUp(tokenKey) }
private val kDown: ByteArray by lazy { Sealer.deriveDown(tokenKey) }
private val revocationMaster: ByteArray by lazy {
Sealer.hexToBytes(root.getString("revocation_master_hex"))
}
private val kRev: ByteArray by lazy { Sealer.deriveRevocation(revocationMaster, tokenId) }
/** Which key seals a case, recorded explicitly: REVOKED is downlink but is
* sealed under K_rev so it can outlive the token's row on the server. */
private fun keyFor(name: String): ByteArray = when (name) {
"up" -> kUp
"down" -> kDown
"rev" -> kRev
else -> error("unknown key $name")
}
@Test
fun `constants agree with the generator`() {
assertEquals("OTP/1", root.getString("protocol"))
assertEquals(VERSION, root.getInt("version"))
assertEquals(HEADER_LEN, root.getInt("header_len"))
assertEquals(TAG_LEN, root.getInt("tag_len"))
assertEquals(MAX_DATAGRAM, root.getInt("max_datagram"))
assertEquals(MAX_POINTS, root.getInt("max_points"))
}
@Test
fun `key derivation matches`() {
assertEquals(root.getString("k_up_hex"), Sealer.bytesToHex(kUp), "K_up drifted")
assertEquals(root.getString("k_down_hex"), Sealer.bytesToHex(kDown), "K_down drifted")
assertEquals(root.getString("k_rev_hex"), Sealer.bytesToHex(kRev), "K_rev drifted")
}
@Test
fun `point records encode and decode exactly as recorded`() {
val points = root.getJSONArray("points")
assertTrue(points.length() > 0)
for (i in 0 until points.length()) {
val case = points.getJSONObject(i)
val name = case.getString("name")
val expected = Sealer.hexToBytes(case.getString("bytes_hex"))
assertEquals(POINT_LEN, expected.size, "$name: wrong record length")
val point = pointFrom(case.getJSONObject("point"))
assertContentEquals(expected, point.toBytes(), "$name: encode drifted")
assertEquals(point, Point.fromBytes(expected), "$name: decode drifted")
}
}
@Test
fun `every datagram vector reproduces byte for byte`() {
val cases = root.getJSONArray("datagrams")
val covered = mutableSetOf<MsgType>()
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val name = case.getString("name")
val msg = messageFrom(case.getJSONObject("message"))
covered += msg.type
assertEquals(msg.type.code, case.getInt("msg_type"), "$name: msg_type disagrees")
val expectedDir = if (msg.type.isUplink) "up" else "down"
assertEquals(expectedDir, case.getString("direction"), "$name: direction disagrees")
val key = keyFor(case.getString("key"))
val nonce = Sealer.hexToBytes(case.getString("nonce_hex"))
val header = Header(msg.type, tokenId, nonce)
assertEquals(case.getString("header_hex"), Sealer.bytesToHex(header.toBytes()), "$name: header")
assertEquals(case.getString("payload_hex"), Sealer.bytesToHex(msg.encodePayload()), "$name: payload")
val datagram = Sealer.seal(key, header, msg.encodePayload())
assertEquals(case.getString("datagram_hex"), Sealer.bytesToHex(datagram), "$name: datagram")
assertEquals(case.getInt("datagram_len"), datagram.size, "$name: length")
// And the recorded bytes must open back to the recorded message —
// encode agreeing with itself would prove nothing about decode.
val (openedHeader, opened) = Sealer.openMessage(key, Sealer.hexToBytes(case.getString("datagram_hex")))
assertEquals(header, openedHeader, "$name: header round trip")
assertEquals(msg, opened, "$name: message round trip")
}
assertEquals(MsgType.entries.toSet(), covered, "some message type has no golden vector")
}
@Test
fun `vectors only open under the key that sealed them`() {
val cases = root.getJSONArray("datagrams")
val keys = mapOf("up" to kUp, "down" to kDown, "rev" to kRev)
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val name = case.getString("name")
val datagram = Sealer.hexToBytes(case.getString("datagram_hex"))
val sealedWith = case.getString("key")
for ((keyName, key) in keys) {
if (keyName == sealedWith) continue
assertFails("$name: opened under K_$keyName, which did not seal it") {
Sealer.openMessage(key, datagram)
}
}
}
}
/**
* K_rev is derived per token id precisely so one device cannot forge a
* revocation notice for another. If this ever passes, any phone that has
* logged in could log every other phone out.
*/
@Test
fun `a revocation notice for another token does not open here`() {
val other = Sealer.deriveRevocation(revocationMaster, tokenId xor 1L)
val cases = root.getJSONArray("datagrams")
var checked = 0
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
if (case.getString("key") != "rev") continue
checked++
assertFails("${case.getString("name")}: opened under another token's K_rev") {
Sealer.openMessage(other, Sealer.hexToBytes(case.getString("datagram_hex")))
}
}
assertTrue(checked > 0, "no REVOKED vectors to check")
}
@Test
fun `the crypto self test passes on this JVM`() {
val report = Sealer.selfTest()
assertTrue(report.ok, "self test failed: ${report.summary}")
}
// -- JSON -> wire structs ------------------------------------------------
// Written by hand rather than with a serialization library: the app ships no
// serialization dependency, and doing it manually is what makes a renamed
// field fail here instead of in production.
private fun JSONObject.intOrNull(key: String): Int? = if (isNull(key)) null else getInt(key)
private fun pointFrom(o: JSONObject) = Point(
ts = o.getLong("ts"),
latE7 = o.getInt("lat_e7"),
lonE7 = o.getInt("lon_e7"),
accDm = o.intOrNull("acc_dm"),
altM = o.intOrNull("alt_m"),
spdCms = o.intOrNull("spd_cms"),
brgCdeg = o.intOrNull("brg_cdeg"),
batPct = o.intOrNull("bat_pct"),
flags = o.getInt("flags"),
)
private fun bytesFrom(a: JSONArray) = ByteArray(a.length()) { a.getInt(it).toByte() }
private fun messageFrom(o: JSONObject): Message {
val value = o.get("value")
return when (val type = o.getString("type")) {
"loc" -> {
val arr = value as JSONArray
Message.Loc(List(arr.length()) { pointFrom(arr.getJSONObject(it)) })
}
"ack" -> {
val v = value as JSONObject
val nonces = v.getJSONArray("nonces")
Message.Ack(
nonces = List(nonces.length()) { bytesFrom(nonces.getJSONArray(it)) },
flags = v.getInt("flags"),
)
}
"nack" -> {
val v = value as JSONObject
Message.Nack(
nonce = bytesFrom(v.getJSONArray("nonce")),
reason = when (val r = v.getString("reason")) {
"unknown_token" -> NackReason.UNKNOWN_TOKEN
"malformed" -> NackReason.MALFORMED
"rate_limited" -> NackReason.RATE_LIMITED
"storage_full" -> NackReason.STORAGE_FULL
else -> error("unknown NACK reason $r")
},
retryAfterS = v.getInt("retry_after_s"),
)
}
"hello" -> {
val v = value as JSONObject
Message.Hello(
appVersionCode = v.getInt("app_version_code"),
osApiLevel = v.getInt("os_api_level"),
flags = v.getInt("flags"),
configVersion = v.getInt("config_version"),
)
}
"config" -> {
val v = value as JSONObject
Message.Config(
configVersion = v.getInt("config_version"),
profile = when (val p = v.getString("profile")) {
"battery_saver" -> Profile.BATTERY_SAVER
"balanced" -> Profile.BALANCED
"high_accuracy" -> Profile.HIGH_ACCURACY
else -> error("unknown profile $p")
},
flags = v.getInt("flags"),
heartbeatS = v.getInt("heartbeat_s"),
intervalScalePct = v.getInt("interval_scale_pct"),
minDistanceM = v.getInt("min_distance_m"),
maxPointsPerLoc = v.getInt("max_points_per_loc"),
)
}
"config_get" -> Message.ConfigGet((value as JSONObject).getInt("have_version"))
"ping" -> {
val v = value as JSONObject
Message.Ping(echo = v.getLong("echo"), seq = v.getInt("seq"))
}
"pong" -> {
val v = value as JSONObject
Message.Pong(echo = v.getLong("echo"), seq = v.getInt("seq"))
}
"revoked" -> Message.Revoked(
when (val r = (value as JSONObject).getString("reason")) {
"revoked" -> RevokeReason.REVOKED
"expired" -> RevokeReason.EXPIRED
"unknown" -> RevokeReason.UNKNOWN
else -> error("unknown revoke reason $r")
},
)
else -> error("unknown message type $type")
}
}
}
Aandroid/gradle.properties
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.configuration-cache=true
Aandroid/gradle/wrapper/gradle-wrapper.jar
Binary file — not shown
Aandroid/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Aandroid/gradlew
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Aandroid/settings.gradle.kts
@@ -0,0 +1,24 @@
// Gradle walks *up* the tree looking for a settings file, so this one being
// here — and there being none at the repository root — is what keeps the Android
// build and the Cargo workspace from entangling.
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
// Fail the build if a subproject declares its own repositories, so there is
// exactly one place dependencies can come from.
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "opentracker"
include(":app")
Acrates/otproto/Cargo.toml
@@ -0,0 +1,34 @@
[package]
name = "otproto"
description = "OTP/1 wire protocol for opentracker: codec + AEAD. No I/O, no async."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
hkdf = "0.12"
sha2 = { version = "0.10", default-features = false }
thiserror.workspace = true
serde = { workspace = true, optional = true }
[dev-dependencies]
proptest = "1"
serde_json.workspace = true
hex.workspace = true
serde = { workspace = true }
[features]
default = []
# Serde impls for the wire structs. Used by the vector generator and by
# otserver when it hands protocol structs to the JSON API.
serde = ["dep:serde"]
# `cargo run -p otproto --features serde --example gen_vectors`
# Written as an example rather than a bin so it can use serde_json/hex from
# dev-dependencies without dragging them into the library's dependency tree.
[[example]]
name = "gen_vectors"
required-features = ["serde"]
Acrates/otproto/examples/gen_vectors.rs
@@ -0,0 +1,381 @@
//! Regenerates `tests/vectors.json` — the golden vectors that are the Rust ↔
//! Kotlin contract for OTP/1.
//!
//! ```sh
//! cargo run -p otproto --features serde --example gen_vectors
//! ```
//!
//! The output is committed. `tests/vectors.rs` verifies every entry against a
//! freshly built datagram, so a protocol change that is not reflected here fails
//! the Rust test suite; the Android build decodes the same file in
//! `./gradlew test`, so a change reflected here but not implemented in Kotlin
//! fails there. Between them, the wire format cannot drift on one side only.
//!
//! Everything is deterministic: fixed token key, fixed token id, nonces derived
//! from the case index. Nothing here calls an RNG or a clock.
use std::collections::BTreeMap;
use std::path::PathBuf;
use otproto::msg::Direction;
use otproto::point::Flags;
use otproto::{
Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, MAX_POINTS, Message,
MsgType, Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf,
};
use serde::Serialize;
/// Bytes 0x00..0x1F. Chosen to be obviously synthetic.
const TOKEN_KEY: [u8; 32] = {
let mut k = [0u8; 32];
let mut i = 0;
while i < 32 {
k[i] = i as u8;
i += 1;
}
k
};
const TOKEN_ID: u64 = 0x0123_4567_89AB_CDEF;
/// Stands in for the server's revocation master. Bytes 0xE0..0xFF, so it is
/// visibly distinct from [`TOKEN_KEY`] in a hex dump.
const REVOCATION_MASTER: [u8; 32] = {
let mut k = [0u8; 32];
let mut i = 0;
while i < 32 {
k[i] = 0xE0 + i as u8;
i += 1;
}
k
};
/// A fixed reference instant, 2026-08-19T09:20:42Z, used everywhere a timestamp
/// is needed so the vectors never depend on when they were generated.
const T0: u32 = 1_785_000_042;
#[derive(Serialize)]
struct Vectors {
protocol: &'static str,
version: u8,
note: &'static str,
header_len: usize,
tag_len: usize,
max_datagram: usize,
max_points: usize,
token_id: u64,
token_key_hex: String,
k_up_hex: String,
k_down_hex: String,
revocation_master_hex: String,
/// `K_rev` for [`TOKEN_ID`]. Derived from the master and the id, not from
/// the token key, so it outlives the token's row.
k_rev_hex: String,
/// Record-level vectors: the 24-byte point encoding on its own.
points: Vec<PointVector>,
/// Full datagram vectors, one per interesting message.
datagrams: Vec<DatagramVector>,
}
#[derive(Serialize)]
struct PointVector {
name: &'static str,
point: Point,
bytes_hex: String,
}
#[derive(Serialize)]
struct DatagramVector {
name: &'static str,
direction: &'static str,
/// Which key seals this datagram: `up`, `down`, or `rev`. Not implied by
/// `direction`: `REVOKED` travels downlink but is sealed under `K_rev`.
key: &'static str,
msg_type: u8,
nonce_hex: String,
/// The 21 cleartext header bytes, which are also the AAD.
header_hex: String,
payload_hex: String,
datagram_hex: String,
datagram_len: usize,
message: Message,
}
/// Distinct, obviously-synthetic nonce per case.
fn nonce_for(idx: usize) -> Nonce {
let mut n = [0u8; 12];
for (j, b) in n.iter_mut().enumerate() {
*b = (idx as u8) << 4 | j as u8;
}
n
}
fn point_vectors() -> Vec<PointVector> {
let cases: Vec<(&'static str, Point)> = vec![
("all_unknown", Point::new(T0, 525_200_080, 134_050_000)),
(
"fully_populated",
Point {
ts: T0,
lat_e7: 525_200_080,
lon_e7: 134_050_000,
acc_dm: Some(80),
alt_m: Some(34),
spd_cms: Some(450),
brg_cdeg: Some(21_400),
bat_pct: Some(76),
flags: Flags::NETWORK_FIX,
},
),
(
"southern_western_hemisphere",
Point {
acc_dm: Some(1_200),
alt_m: Some(-31),
spd_cms: Some(0),
brg_cdeg: Some(0),
bat_pct: Some(0),
flags: Flags::CHARGING | Flags::LOW_ACCURACY,
..Point::new(T0, -338_688_000, -1_754_500_000)
},
),
(
"extremes",
Point {
ts: u32::MAX,
lat_e7: 900_000_000,
lon_e7: -1_800_000_000,
acc_dm: Some(65_534),
alt_m: Some(-32_767),
spd_cms: Some(65_534),
brg_cdeg: Some(35_999),
bat_pct: Some(100),
flags: Flags(Flags::KNOWN),
},
),
("epoch_zero", Point::new(0, 0, 0)),
];
cases
.into_iter()
.map(|(name, point)| {
let point = point.canonical();
PointVector {
name,
bytes_hex: hex::encode(point.to_bytes()),
point,
}
})
.collect()
}
fn messages() -> Vec<(&'static str, Message)> {
let pv = point_vectors();
let populated = pv[1].point;
let mut cases = vec![
("loc_single", Message::Loc(vec![populated])),
(
"loc_three_independent",
Message::Loc(vec![
Point::new(T0 - 120, 525_200_080, 134_050_000),
populated,
Point {
acc_dm: Some(2_500),
flags: Flags::NETWORK_FIX | Flags::LOW_ACCURACY,
..Point::new(T0 + 60, 525_201_000, 134_051_000)
},
]),
),
(
"loc_max_points",
Message::Loc(
(0..MAX_POINTS)
.map(|i| Point {
acc_dm: Some(50 + i as u16),
bat_pct: Some(100 - i as u8),
..Point::new(
T0 + i as u32 * 30,
525_200_080 + i as i32 * 100,
134_050_000,
)
})
.collect(),
),
),
("ack_single", Message::Ack(Ack::single(nonce_for(0)))),
(
"ack_config_pending",
Message::Ack(Ack {
nonces: vec![nonce_for(1), nonce_for(2)],
flags: AckFlags::CONFIG_PENDING,
}),
),
(
"ack_max_throttle",
Message::Ack(Ack {
nonces: (0..MAX_POINTS).map(nonce_for).collect(),
flags: AckFlags::CONFIG_PENDING,
}),
),
(
"hello",
Message::Hello(Hello {
app_version_code: 17,
os_api_level: 34,
flags: HelloFlags::FIRST_LAUNCH,
config_version: 1,
}),
),
("config_balanced", Message::Config(Config::default())),
(
"config_battery_saver_paused",
Message::Config(Config {
config_version: 9,
profile: Profile::BatterySaver,
flags: ConfigFlags::REQUEST_HELLO,
heartbeat_s: 1_800,
interval_scale_pct: 250,
min_distance_m: 100,
max_points_per_loc: 20,
}),
),
(
"config_high_accuracy",
Message::Config(Config {
config_version: 2,
profile: Profile::HighAccuracy,
flags: ConfigFlags::TRACKING_ENABLED,
heartbeat_s: 600,
interval_scale_pct: 50,
min_distance_m: 10,
max_points_per_loc: MAX_POINTS as u8,
}),
),
(
"config_get",
Message::ConfigGet(ConfigGet { have_version: 1 }),
),
(
"ping",
Message::Ping(Ping {
echo: 0xDEAD_BEEF,
seq: 7,
}),
),
(
"pong",
Message::Pong(Pong {
echo: 0xDEAD_BEEF,
seq: 7,
}),
),
];
// One REVOKED per reason. Each drives the same client behaviour — clear
// state, show the login screen — but they are what the user is told, so a
// silently renumbered reason would be a real regression.
for (name, reason) in [
("revoked_explicit", RevokeReason::Revoked),
("revoked_expired", RevokeReason::Expired),
("revoked_unknown", RevokeReason::Unknown),
] {
cases.push((name, Message::Revoked(Revoked { reason })));
}
// One NACK per reason: each is a distinct client behaviour, so each is worth
// pinning byte-for-byte.
for (name, reason, retry) in [
("nack_unknown_token", NackReason::UnknownToken, 0),
("nack_malformed", NackReason::Malformed, 0),
("nack_rate_limited", NackReason::RateLimited, 30),
("nack_storage_full", NackReason::StorageFull, 255),
] {
cases.push((
name,
Message::Nack(Nack {
nonce: nonce_for(3),
reason,
retry_after_s: retry,
}),
));
}
cases
}
fn main() {
let (k_up, k_down) = kdf::derive_both(&TOKEN_KEY);
let k_rev = kdf::revocation_key(&REVOCATION_MASTER, TOKEN_ID);
let datagrams = messages()
.into_iter()
.enumerate()
.map(|(idx, (name, message))| {
let ty = message.msg_type();
let dir = ty.direction();
let (key, key_name) = match ty {
MsgType::Revoked => (&k_rev, "rev"),
_ => match dir {
Direction::Up => (&k_up, "up"),
Direction::Down => (&k_down, "down"),
},
};
let nonce = nonce_for(idx);
let header = Header::new(ty, TOKEN_ID, nonce);
let payload = message.encode_payload();
let datagram = otproto::seal(key, header, &payload);
DatagramVector {
name,
direction: match dir {
Direction::Up => "up",
Direction::Down => "down",
},
key: key_name,
msg_type: ty as u8,
nonce_hex: hex::encode(nonce),
header_hex: hex::encode(header.to_bytes()),
payload_hex: hex::encode(&payload),
datagram_len: datagram.len(),
datagram_hex: hex::encode(&datagram),
message,
}
})
.collect::<Vec<_>>();
// Distinct names are what let the Kotlin side address a single case.
let mut seen = BTreeMap::new();
for d in &datagrams {
assert!(
seen.insert(d.name, ()).is_none(),
"duplicate vector name {}",
d.name
);
}
let vectors = Vectors {
protocol: "OTP/1",
version: otproto::VERSION,
note: "Generated by `cargo run -p otproto --features serde --example gen_vectors`. \
Do not edit by hand.",
header_len: otproto::HEADER_LEN,
tag_len: otproto::TAG_LEN,
max_datagram: otproto::MAX_DATAGRAM,
max_points: MAX_POINTS,
token_id: TOKEN_ID,
token_key_hex: hex::encode(TOKEN_KEY),
k_up_hex: hex::encode(k_up),
k_down_hex: hex::encode(k_down),
revocation_master_hex: hex::encode(REVOCATION_MASTER),
k_rev_hex: hex::encode(k_rev),
points: point_vectors(),
datagrams,
};
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors.json");
let mut json = serde_json::to_string_pretty(&vectors).expect("vectors serialize");
json.push('\n');
std::fs::write(&path, json).expect("write vectors.json");
println!(
"wrote {} ({} cases)",
path.display(),
vectors.datagrams.len()
);
}
Acrates/otproto/fuzz/Cargo.lock
@@ -0,0 +1,338 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[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 = "cc"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
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 = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[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 = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[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 = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "otproto"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"hkdf",
"sha2",
"thiserror",
]
[[package]]
name = "otproto-fuzz"
version = "0.0.0"
dependencies = [
"libfuzzer-sys",
"otproto",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[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 = "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 = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[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 = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
Acrates/otproto/fuzz/Cargo.toml
@@ -0,0 +1,33 @@
[package]
name = "otproto-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
otproto = { path = ".." }
[[bin]]
name = "decode"
path = "fuzz_targets/decode.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_payload"
path = "fuzz_targets/decode_payload.rs"
test = false
doc = false
bench = false
[profile.release]
debug = 1
# cargo-fuzz invokes cargo directly on this manifest, which then finds the
# workspace root above it. An empty table detaches it.
[workspace]
Acrates/otproto/fuzz/fuzz_targets/decode.rs
@@ -0,0 +1,26 @@
//! Fuzzes the decoder against arbitrary datagrams — the exact input an open UDP
//! port receives from the internet.
//!
//! ```sh
//! cargo +nightly fuzz run decode
//! ```
//!
//! Note the deliberate limitation: without the key, almost nothing here gets
//! past the AEAD, so this target mostly exercises `Header::peek` and the
//! ChaCha20-Poly1305 layer. The payload decoders — where the interesting
//! structural parsing lives — are reached by the `decode_payload` target
//! instead. Fuzzing only this one would give a comforting but nearly meaningless
//! coverage number.
#![no_main]
use libfuzzer_sys::fuzz_target;
/// Fixed key: a fuzzer cannot forge a tag either way, so varying it would only
/// waste the corpus.
const KEY: [u8; 32] = [0x11; 32];
fuzz_target!(|data: &[u8]| {
let _ = otproto::Header::peek(data);
let _ = otproto::open_message(&KEY, data);
});
Acrates/otproto/fuzz/fuzz_targets/decode_payload.rs
@@ -0,0 +1,38 @@
//! Fuzzes the payload decoders directly, behind the AEAD.
//!
//! ```sh
//! cargo +nightly fuzz run decode_payload
//! ```
//!
//! This is where the structural parsing lives — point counts that disagree with
//! the payload length, unknown enum discriminants, arithmetic on attacker-chosen
//! lengths. Reaching it through a sealed datagram would require forging a
//! Poly1305 tag, so it gets its own target with the crypto stripped away. In
//! production only a client holding a valid token can reach this code, which
//! bounds the blast radius but does not make it safe to panic in.
#![no_main]
use libfuzzer_sys::fuzz_target;
use otproto::{Message, MsgType};
fuzz_target!(|data: &[u8]| {
// First byte picks the message type; the rest is the payload.
let Some((&ty, payload)) = data.split_first() else {
return;
};
let Ok(ty) = MsgType::try_from(ty & 0x0F) else {
return;
};
if let Ok(msg) = Message::decode_payload(ty, payload) {
assert_eq!(msg.msg_type(), ty);
// Anything that decodes must re-encode to the same *length*, and
// `payload_len` must agree without allocating. Byte equality is not
// asserted because reserved bytes are ignored on decode and written as
// zero on encode — deliberately, so a later version can populate them.
let re = msg.encode_payload();
assert_eq!(re.len(), payload.len());
assert_eq!(msg.payload_len(), payload.len());
}
});
Acrates/otproto/src/error.rs
@@ -0,0 +1,63 @@
/// A datagram could not be turned into a [`crate::Message`].
///
/// Everything here is a *structural* failure: the bytes cannot be parsed at
/// all. Values that parse but are nonsensical (a latitude of 300°, a timestamp
/// in 1970) are not errors at this layer — see [`ValidationError`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DecodeError {
#[error("datagram too short: {0} bytes, minimum is {min}", min = crate::MIN_DATAGRAM)]
TooShort(usize),
#[error("datagram too long: {0} bytes, maximum is {max}", max = crate::MAX_DATAGRAM)]
TooLong(usize),
#[error("unsupported protocol version {0}, this build speaks {ours}", ours = crate::VERSION)]
BadVersion(u8),
#[error("unknown message type 0x{0:x}")]
BadMsgType(u8),
/// The AEAD tag did not verify. Never answer this — see
/// [`crate::may_respond`] and the "no oracle" rule.
#[error("authentication failed")]
AuthFailed,
#[error("{what}: expected {expected} payload bytes, got {actual}")]
BadPayloadLen {
what: &'static str,
expected: usize,
actual: usize,
},
#[error("LOC point count {0} out of range 1..={max}", max = crate::MAX_POINTS)]
BadPointCount(usize),
#[error("ACK nonce count {0} out of range 1..={max}", max = crate::MAX_POINTS)]
BadNonceCount(usize),
#[error("unknown {field} discriminant {value}")]
BadEnum { field: &'static str, value: u8 },
}
/// A message parsed cleanly but carries values the server should not store.
///
/// Kept separate from [`DecodeError`] on purpose: the codec stays a total
/// function over well-formed byte strings, so the round-trip property holds
/// without carve-outs, and policy lives where policy belongs.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
#[error("latitude {0} out of range ±90e7")]
Latitude(i32),
#[error("longitude {0} out of range ±180e7")]
Longitude(i32),
#[error("bearing {0} centidegrees out of range 0..=35999")]
Bearing(u16),
#[error("battery {0}% out of range 0..=100")]
Battery(u8),
#[error("timestamp {ts} is {off_by}s outside the accepted window around {now}")]
Timestamp { ts: u32, now: u32, off_by: i64 },
}
Acrates/otproto/src/frame.rs
@@ -0,0 +1,510 @@
//! Datagram framing: a 21-byte cleartext header authenticated as AAD, followed
//! by a ChaCha20-Poly1305 sealed payload.
//!
//! ```text
//! off size field
//! 0 1 ver_type — high nibble version (1), low nibble message type
//! 1 8 token_id u64 BE — random, server-assigned at login
//! 9 12 nonce — random; also this message's id
//! ```
//!
//! The header is cleartext because the server must read `token_id` to select a
//! key before it can decrypt anything; it is authenticated as AAD so a
//! ciphertext cannot be retargeted to another token or another message type.
//!
//! `datagram = header || ChaCha20Poly1305(K_dir, nonce, payload, aad = header)`
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Tag};
use crate::error::DecodeError;
use crate::kdf::Key;
use crate::msg::{Message, MsgType, Nonce};
pub const VERSION: u8 = 1;
pub const HEADER_LEN: usize = 21;
pub const TAG_LEN: usize = 16;
/// Smallest possible datagram: header, empty payload, tag. No message type
/// actually has an empty payload, but this is the floor a length check can use
/// before it knows the type.
pub const MIN_DATAGRAM: usize = HEADER_LEN + TAG_LEN;
/// Path-MTU-safe ceiling for both IPv4 and IPv6. The 40-point `LOC` cap keeps
/// the largest real datagram at 998 bytes.
pub const MAX_DATAGRAM: usize = 1200;
/// Total datagram size for a payload of `payload_len` bytes.
#[must_use]
pub const fn datagram_len(payload_len: usize) -> usize {
HEADER_LEN + payload_len + TAG_LEN
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
pub msg_type: MsgType,
/// The u64 assigned at login. A *credential*, not an identity: it
/// authorises writing into its owner's position stream and never appears in
/// a stored point's key.
pub token_id: u64,
pub nonce: Nonce,
}
impl Header {
#[must_use]
pub const fn new(msg_type: MsgType, token_id: u64, nonce: Nonce) -> Self {
Self {
msg_type,
token_id,
nonce,
}
}
#[must_use]
pub fn to_bytes(self) -> [u8; HEADER_LEN] {
let mut b = [0u8; HEADER_LEN];
b[0] = (VERSION << 4) | (self.msg_type as u8);
b[1..9].copy_from_slice(&self.token_id.to_be_bytes());
b[9..21].copy_from_slice(&self.nonce);
b
}
/// Read the header of a datagram without decrypting it.
///
/// This is the server's first look at a packet: it yields the `token_id`
/// needed to pick a key, and it is where length, version and type filtering
/// happen — all before any crypto work is spent on the packet.
pub fn peek(datagram: &[u8]) -> Result<Self, DecodeError> {
if datagram.len() < MIN_DATAGRAM {
return Err(DecodeError::TooShort(datagram.len()));
}
if datagram.len() > MAX_DATAGRAM {
return Err(DecodeError::TooLong(datagram.len()));
}
let version = datagram[0] >> 4;
if version != VERSION {
return Err(DecodeError::BadVersion(version));
}
Ok(Self {
msg_type: MsgType::try_from(datagram[0] & 0x0F)?,
token_id: u64::from_be_bytes(datagram[1..9].try_into().expect("length checked")),
nonce: datagram[9..HEADER_LEN].try_into().expect("length checked"),
})
}
}
/// Seal a message into a datagram.
///
/// The nonce is passed in rather than generated here: this crate does no I/O and
/// owns no RNG, which is what makes it deterministically testable and lets the
/// golden vectors be reproducible. Callers must supply 12 fresh random bytes per
/// message — at 12 bytes the collision probability after 16 million messages is
/// about 2⁻⁴⁹, so a counter buys nothing and costs persistence.
#[must_use]
pub fn seal(key: &Key, header: Header, payload: &[u8]) -> Vec<u8> {
debug_assert!(
datagram_len(payload.len()) <= MAX_DATAGRAM,
"payload of {} bytes exceeds the datagram budget",
payload.len()
);
let aad = header.to_bytes();
let mut out = Vec::with_capacity(datagram_len(payload.len()));
out.extend_from_slice(&aad);
out.extend_from_slice(payload);
let cipher = ChaCha20Poly1305::new(key.into());
let tag = cipher
.encrypt_in_place_detached((&header.nonce).into(), &aad, &mut out[HEADER_LEN..])
.expect("in-place detached encryption of a bounded buffer cannot fail");
out.extend_from_slice(&tag);
out
}
/// Seal an already-typed message, deriving the header from it.
#[must_use]
pub fn seal_message(key: &Key, token_id: u64, nonce: Nonce, msg: &Message) -> Vec<u8> {
seal(
key,
Header::new(msg.msg_type(), token_id, nonce),
&msg.encode_payload(),
)
}
/// Open a datagram, returning its header and decrypted payload.
///
/// A [`DecodeError::AuthFailed`] must never be answered — not with a `NACK`, not
/// with anything. The server cannot know who sent it, and replying would make
/// the open port both a forgery oracle and a reflector.
pub fn open(key: &Key, datagram: &[u8]) -> Result<(Header, Vec<u8>), DecodeError> {
let header = Header::peek(datagram)?;
let (aad, rest) = datagram.split_at(HEADER_LEN);
let (ciphertext, tag) = rest.split_at(rest.len() - TAG_LEN);
let mut payload = ciphertext.to_vec();
ChaCha20Poly1305::new(key.into())
.decrypt_in_place_detached(
(&header.nonce).into(),
aad,
&mut payload,
Tag::from_slice(tag),
)
.map_err(|_| DecodeError::AuthFailed)?;
Ok((header, payload))
}
/// Open a datagram and parse its payload.
pub fn open_message(key: &Key, datagram: &[u8]) -> Result<(Header, Message), DecodeError> {
let (header, payload) = open(key, datagram)?;
let msg = Message::decode_payload(header.msg_type, &payload)?;
Ok((header, msg))
}
/// Ceiling on any datagram the server sends in reply to one it received.
///
/// This is the anti-amplification control, and it replaces an earlier rule that
/// `len(response) <= len(request)` for every message type. That rule was
/// achievable only by padding requests with reserved bytes — paying real bytes on
/// every `HELLO` and `PING` to make replies "fit" — and it protected against a
/// threat that authentication already eliminates:
///
/// **Nearly every reply is sent only to a datagram that passed AEAD
/// verification.** A bad tag gets silence. So a reflection attacker must already
/// hold a live token key, and the leverage they gain is the ratio below — against
/// DNS at ~50× and NTP `monlist` at ~550×, which is the scale at which reflection
/// is worth doing at all.
///
/// [`Revoked`] is the one exception, and it is deliberately the smallest message
/// in the protocol. The server cannot verify a datagram naming a token it has no
/// record of, yet that is exactly the device it must tell to log in again. Two
/// rules keep it from being useful: it is sent only when the request was at least
/// as long (see [`may_answer_unverified`], so the ratio never exceeds 1.0), and
/// the server rate-limits it per *destination* address, which for a spoofed
/// packet is the victim. Whether it is sent at all is a config switch.
///
/// | request | bytes | reply | bytes | ratio |
/// |---|---|---|---|---|
/// | `LOC`, 1 point | 62 | `ACK` | 51 | 0.82 |
/// | `LOC`, 40 points | 998 | `ACK` | 51 | 0.05 |
/// | `HELLO` | 43 | `ACK` | 51 | 1.19 |
/// | `PING` | 43 | `PONG` | 43 | 1.00 |
/// | `CONFIG_GET` | 39 | `CONFIG` | 49 | 1.26 |
/// | any, ≥ 38 B | 38 | `REVOKED` | 38 | ≤ 1.00 |
///
/// An absolute ceiling is also a stronger statement than a relative one: however
/// the protocol grows, the open port cannot be made to emit more than this many
/// bytes for one received datagram. A multi-nonce `ACK` is the one reply that
/// scales, and it scales with the number of datagrams *already received* from that
/// token, so it cannot amplify either — but nothing in this build emits one, and
/// [`fits_reply_budget`] holds for every reply it does emit.
pub const MAX_REPLY: usize = 64;
/// Whether a reply respects [`MAX_REPLY`].
#[must_use]
pub const fn fits_reply_budget(response_len: usize) -> bool {
response_len <= MAX_REPLY
}
/// Size of a sealed [`Revoked`] notice: the smallest datagram this protocol can
/// produce, since its payload is one byte.
pub const REVOKED_DATAGRAM_LEN: usize = HEADER_LEN + 1 + TAG_LEN;
/// Whether a request is long enough to earn an unverifiable [`Revoked`] reply.
///
/// The server cannot authenticate a datagram naming a token it does not know, so
/// answering one means answering an address the sender merely claimed. That is a
/// reflector. It is a tolerable one only while it can never be an *amplifier*, so
/// the reply must not exceed the request — which for a fixed 38-byte reply is
/// just this length test.
///
/// [`MIN_DATAGRAM`] is 37, one byte short, so a minimum-size datagram earns
/// nothing. Every real message is far larger: the smallest a device ever sends is
/// a 39-byte `CONFIG_GET`.
#[must_use]
pub const fn may_answer_unverified(request_len: usize) -> bool {
request_len >= REVOKED_DATAGRAM_LEN
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kdf;
use crate::msg::*;
use crate::point::Point;
const TOKEN_KEY: Key = [0x5A; 32];
const TOKEN_ID: u64 = 0x1122_3344_5566_7788;
const NONCE: Nonce = [0xA0; NONCE_LEN];
fn keys() -> (Key, Key) {
kdf::derive_both(&TOKEN_KEY)
}
#[test]
fn header_round_trips() {
let h = Header::new(MsgType::Loc, TOKEN_ID, NONCE);
assert_eq!(
Header::peek(&[h.to_bytes().as_slice(), &[0; TAG_LEN]].concat()),
Ok(h)
);
}
#[test]
fn seal_open_round_trips_every_type() {
let (up, down) = keys();
let messages = [
Message::Loc(vec![Point::new(1_785_000_042, 525_200_080, 134_050_000)]),
Message::Ack(Ack::single(NONCE)),
Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::UnknownToken,
retry_after_s: 0,
}),
Message::Hello(Hello {
app_version_code: 2,
os_api_level: 34,
flags: HelloFlags::NONE,
config_version: 1,
}),
Message::Config(Config::default()),
Message::ConfigGet(ConfigGet { have_version: 1 }),
Message::Ping(Ping { echo: 1, seq: 2 }),
Message::Pong(Pong { echo: 1, seq: 3 }),
];
for msg in messages {
let key = if msg.msg_type().is_uplink() {
&up
} else {
&down
};
let dg = seal_message(key, TOKEN_ID, NONCE, &msg);
assert_eq!(dg.len(), datagram_len(msg.payload_len()));
assert!(dg.len() <= MAX_DATAGRAM);
let (h, back) = open_message(key, &dg).expect("opens");
assert_eq!(h.token_id, TOKEN_ID);
assert_eq!(h.msg_type, msg.msg_type());
assert_eq!(back, msg);
}
}
#[test]
fn the_wrong_direction_key_cannot_open_a_datagram() {
let (up, down) = keys();
let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
assert_eq!(open(&down, &dg), Err(DecodeError::AuthFailed));
}
#[test]
fn every_header_byte_is_authenticated() {
let (up, _) = keys();
let dg = seal_message(
&up,
TOKEN_ID,
NONCE,
&Message::Ping(Ping { echo: 9, seq: 1 }),
);
for i in 0..HEADER_LEN {
let mut bad = dg.clone();
bad[i] ^= 0x01;
// A flipped version or type nibble fails the header check; anything
// else fails the tag. Either way it never yields a message.
assert!(
open(&up, &bad).is_err(),
"byte {i} of the header was not authenticated"
);
}
}
#[test]
fn a_flipped_ciphertext_or_tag_byte_fails() {
let (up, _) = keys();
let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
for i in HEADER_LEN..dg.len() {
let mut bad = dg.clone();
bad[i] ^= 0x80;
assert_eq!(open(&up, &bad), Err(DecodeError::AuthFailed), "byte {i}");
}
}
#[test]
fn truncation_is_caught_before_any_crypto() {
let (up, _) = keys();
let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
for cut in 0..MIN_DATAGRAM {
assert_eq!(Header::peek(&dg[..cut]), Err(DecodeError::TooShort(cut)));
}
// Long enough to look like a header, short enough to be corrupt.
for cut in MIN_DATAGRAM..dg.len() {
assert!(
open(&up, &dg[..cut]).is_err(),
"truncation to {cut} accepted"
);
}
}
#[test]
fn oversized_and_misversioned_datagrams_are_rejected() {
assert_eq!(
Header::peek(&vec![0x11; MAX_DATAGRAM + 1]),
Err(DecodeError::TooLong(MAX_DATAGRAM + 1))
);
let mut dg = vec![0u8; MIN_DATAGRAM];
dg[0] = 0x21; // version 2
assert_eq!(Header::peek(&dg), Err(DecodeError::BadVersion(2)));
dg[0] = 0x1F; // version 1, type 0xF
assert_eq!(Header::peek(&dg), Err(DecodeError::BadMsgType(0xF)));
}
/// The control that keeps the open UDP port useless as a reflector.
#[test]
fn every_reply_fits_the_budget() {
// Every message the server can send downstream.
let replies = [
Message::Ack(Ack::single(NONCE)),
Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::Malformed,
retry_after_s: 0,
}),
Message::Config(Config::default()),
Message::Pong(Pong::default()),
Message::Revoked(Revoked {
reason: RevokeReason::Revoked,
}),
];
for reply in &replies {
let len = datagram_len(reply.payload_len());
assert!(
fits_reply_budget(len),
"{:?} is {len} B, over the {MAX_REPLY} B reply budget",
reply.msg_type(),
);
}
}
/// The unverifiable reply can never be an amplifier.
///
/// This is the whole justification for REVOKED being one byte of payload.
/// A datagram short enough to be profitable to reflect is short enough to be
/// refused an answer.
#[test]
fn an_unverified_notice_never_amplifies() {
let revoked = Message::Revoked(Revoked {
reason: RevokeReason::Unknown,
});
assert_eq!(datagram_len(revoked.payload_len()), REVOKED_DATAGRAM_LEN);
assert!(
!may_answer_unverified(MIN_DATAGRAM),
"the smallest possible datagram must not earn a reply"
);
for request_len in MIN_DATAGRAM..=MAX_DATAGRAM {
if may_answer_unverified(request_len) {
assert!(
REVOKED_DATAGRAM_LEN <= request_len,
"a {request_len} B request drew a {REVOKED_DATAGRAM_LEN} B reply"
);
}
}
}
/// The ratios the reply budget actually permits, pinned so a future field
/// cannot quietly turn the port into a useful reflector. Nothing here is
/// remotely in DNS (~50x) or NTP monlist (~550x) territory, and every one of
/// them still requires the sender to hold a valid token key.
#[test]
fn amplification_ratios_stay_near_one() {
let hello = Message::Hello(Hello {
app_version_code: 0,
os_api_level: 0,
flags: HelloFlags::NONE,
config_version: 0,
});
let nack = Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::Malformed,
retry_after_s: 0,
});
let exchanges = [
(
Message::Loc(vec![Point::new(0, 0, 0)]),
Message::Ack(Ack::single(NONCE)),
),
(
Message::Loc(vec![Point::new(0, 0, 0); MAX_POINTS]),
Message::Ack(Ack::single(NONCE)),
),
(hello, Message::Ack(Ack::single(NONCE))),
(
Message::Ping(Ping::default()),
Message::Pong(Pong::default()),
),
(
Message::ConfigGet(ConfigGet::default()),
Message::Config(Config::default()),
),
(Message::Loc(vec![Point::new(0, 0, 0)]), nack),
];
for (req, resp) in &exchanges {
let req_len = datagram_len(req.payload_len()) as f64;
let resp_len = datagram_len(resp.payload_len()) as f64;
let ratio = resp_len / req_len;
assert!(
ratio <= 1.5,
"{:?} -> {:?} amplifies {ratio:.2}x, more leverage than this design allows",
req.msg_type(),
resp.msg_type(),
);
}
}
/// The sizes the ratio table in the module documentation is computed from.
#[test]
fn documented_message_sizes_hold() {
let sizes = [
(Message::Ack(Ack::single(NONCE)), 51),
(
Message::Nack(Nack {
nonce: NONCE,
reason: NackReason::Malformed,
retry_after_s: 0,
}),
51,
),
(Message::Config(Config::default()), 49),
(
Message::Revoked(Revoked {
reason: RevokeReason::Revoked,
}),
REVOKED_DATAGRAM_LEN,
),
(Message::ConfigGet(ConfigGet::default()), 39),
(Message::Ping(Ping::default()), 43),
(Message::Pong(Pong::default()), 43),
(
Message::Hello(Hello {
app_version_code: 0,
os_api_level: 0,
flags: HelloFlags::NONE,
config_version: 0,
}),
43,
),
];
for (msg, want) in &sizes {
assert_eq!(
datagram_len(msg.payload_len()),
*want,
"{:?} changed size, which moves the amplification ratios",
msg.msg_type()
);
}
}
#[test]
fn documented_wire_sizes_hold() {
let one = datagram_len(Message::Loc(vec![Point::new(0, 0, 0)]).payload_len());
let twenty = datagram_len(Message::Loc(vec![Point::new(0, 0, 0); 20]).payload_len());
let forty = datagram_len(Message::Loc(vec![Point::new(0, 0, 0); MAX_POINTS]).payload_len());
assert_eq!((one, twenty, forty), (62, 518, 998));
}
}
Acrates/otproto/src/kdf.rs
@@ -0,0 +1,123 @@
//! Key derivation from the token secret issued at login.
//!
//! ```text
//! K_up = HKDF-Expand(token_key, "otp/1/up", 32) device -> server
//! K_down = HKDF-Expand(token_key, "otp/1/down", 32) server -> device
//! K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32) server -> device
//! ```
//!
//! Expand only, no extract: `token_key` is already 32 uniformly random bytes
//! from the server's CSPRNG, so there is no entropy to condition. Two
//! directions means two keys, so a captured uplink datagram can never be
//! replayed back as a downlink one — which is also why the nonce space of the
//! two directions may overlap freely.
use hkdf::Hkdf;
use sha2::Sha256;
use crate::msg::Direction;
pub const KEY_LEN: usize = 32;
/// A 32-byte symmetric key: either the token secret or one of its two
/// derivatives.
pub type Key = [u8; KEY_LEN];
/// Derive the directional key for `dir`.
#[must_use]
pub fn derive(token_key: &Key, dir: Direction) -> Key {
let hk = Hkdf::<Sha256>::from_prk(token_key).expect("32-byte PRK is valid for HKDF-SHA256");
let mut out = [0u8; KEY_LEN];
hk.expand(dir.info(), &mut out)
.expect("32 bytes is well under HKDF-SHA256's output limit");
out
}
/// The key that seals a [`crate::Revoked`] notice for `token_id`.
///
/// Derived from a server master key and the id, *not* from the token key. That
/// is the point: `K_up` and `K_down` both die with the token's row, and the
/// moment the server most needs to speak is exactly when that row is gone. This
/// key the server can recompute for any id, including one it has never issued.
///
/// Per-id rather than one shared server key, so a device that learns its own
/// `K_rev` still cannot forge a notice for anyone else.
///
/// The master is a deployment secret, so rotating it invalidates every `K_rev`
/// already handed out. Devices that logged in beforehand then fall back to
/// silence, which is the pre-existing behaviour, not a new failure.
#[must_use]
pub fn revocation_key(master: &Key, token_id: u64) -> Key {
let hk = Hkdf::<Sha256>::from_prk(master).expect("32-byte PRK is valid for HKDF-SHA256");
let mut info = [0u8; 12 + 8];
info[..12].copy_from_slice(b"otp/1/revoke");
info[12..].copy_from_slice(&token_id.to_be_bytes());
let mut out = [0u8; KEY_LEN];
hk.expand(&info, &mut out)
.expect("32 bytes is well under HKDF-SHA256's output limit");
out
}
/// Both directional keys at once, in the order the server caches them.
#[must_use]
pub fn derive_both(token_key: &Key) -> (Key, Key) {
(
derive(token_key, Direction::Up),
derive(token_key, Direction::Down),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn directions_are_independent() {
let (up, down) = derive_both(&[0x42; KEY_LEN]);
assert_ne!(up, down);
assert_ne!(up, [0x42; KEY_LEN]);
}
#[test]
fn derivation_is_deterministic() {
assert_eq!(
derive(&[1; KEY_LEN], Direction::Up),
derive(&[1; KEY_LEN], Direction::Up)
);
}
#[test]
fn revocation_keys_differ_per_token_id() {
let master = [0x11; KEY_LEN];
let a = revocation_key(&master, 1);
let b = revocation_key(&master, 2);
assert_ne!(a, b, "one device could forge a notice for another");
assert_eq!(a, revocation_key(&master, 1), "must be recomputable");
}
/// The whole point of the separate master: `K_rev` must not be derivable
/// from anything that dies with the token row.
#[test]
fn a_revocation_key_is_independent_of_the_token_key() {
let key = [0x42; KEY_LEN];
let (up, down) = derive_both(&key);
let rev = revocation_key(&key, 7);
assert_ne!(rev, up);
assert_ne!(rev, down);
assert_ne!(rev, key);
}
#[test]
fn a_one_bit_token_change_changes_the_whole_key() {
let a = derive(&[0; KEY_LEN], Direction::Up);
let mut tk = [0u8; KEY_LEN];
tk[31] = 1;
let b = derive(&tk, Direction::Up);
assert_ne!(a, b);
let differing = a.iter().zip(&b).filter(|(x, y)| x != y).count();
assert!(
differing > KEY_LEN / 2,
"expected avalanche, only {differing} bytes differ"
);
}
}
Acrates/otproto/src/lib.rs
@@ -0,0 +1,70 @@
//! OTP/1 — the opentracker wire protocol.
//!
//! One encrypted UDP datagram per report, no handshake, no connection state. A
//! cold TCP+TLS connection costs roughly ten round trips before the first byte
//! of payload, and a phone that sleeps between reports has a cold connection
//! nearly every time; a single datagram that survives the phone changing IP
//! mid-journey is the entire point of this protocol.
//!
//! This crate is the codec and nothing else: **no I/O, no async, no RNG, no
//! clock.** Nonces and timestamps are passed in by the caller. That is what
//! makes it fuzzable, property-testable, and able to emit reproducible golden
//! vectors — which are the contract the Kotlin implementation is checked
//! against, and the thing that stops a protocol change from silently bricking
//! installed apps.
//!
//! ## Shape of a datagram
//!
//! ```text
//! header[21] || ChaCha20Poly1305(K_dir, nonce, payload, aad = header)
//! ```
//!
//! ## Why there is no replay protection
//!
//! Timestamps are absolute and client-supplied, and the server stores points
//! under `UNIQUE(user_id, ts)` with `ON CONFLICT DO UPDATE`. A replayed
//! datagram therefore carries a timestamp that already exists and collapses
//! into the row already there — replay is idempotent *by construction*. An
//! attacker without the key cannot forge new positions and cannot create
//! duplicate rows, so a counter, a persisted ledger and a replay window would
//! all be state to maintain for no gain.
//!
//! ## Example
//!
//! ```
//! use otproto::{Header, Message, MsgType, Point, kdf, msg::Direction};
//!
//! let token_key = [0x11; 32];
//! let k_up = kdf::derive(&token_key, Direction::Up);
//! let nonce = [0x22; 12]; // in production: 12 fresh random bytes
//!
//! let msg = Message::Loc(vec![Point::new(1_785_000_042, 525_200_080, 134_050_000)]);
//! let datagram = otproto::seal_message(&k_up, 0x1122_3344_5566_7788, nonce, &msg);
//! assert_eq!(datagram.len(), 62);
//!
//! // The server peeks the header to choose a key, then opens.
//! let peeked = Header::peek(&datagram).unwrap();
//! assert_eq!(peeked.msg_type, MsgType::Loc);
//! let (_, back) = otproto::open_message(&k_up, &datagram).unwrap();
//! assert_eq!(back, msg);
//! ```
pub mod error;
pub mod frame;
pub mod kdf;
pub mod msg;
pub mod point;
pub use error::{DecodeError, ValidationError};
pub use frame::{
HEADER_LEN, Header, MAX_DATAGRAM, MAX_REPLY, MIN_DATAGRAM, REVOKED_DATAGRAM_LEN, TAG_LEN,
VERSION, datagram_len, fits_reply_budget, may_answer_unverified, open, open_message, seal,
seal_message,
};
pub use kdf::{KEY_LEN, Key, revocation_key};
pub use msg::{
Ack, AckFlags, Config, ConfigFlags, ConfigGet, Direction, Hello, HelloFlags, MAX_POINTS,
Message, MsgType, NONCE_LEN, Nack, NackReason, Nonce, Ping, Pong, Profile, RevokeReason,
Revoked,
};
pub use point::{Flags as PointFlags, POINT_LEN, Point};
Acrates/otproto/src/msg.rs
@@ -0,0 +1,822 @@
//! Message types and their payload layouts.
//!
//! Every payload is fixed-width except `LOC` (which is a count plus that many
//! point records) and `ACK` (a count plus that many nonces).
//!
//! **There is no clock anywhere in this protocol except `Point::ts`.** No message
//! carries the server's time, nothing measures or reports clock skew, and nothing
//! corrects a timestamp. The client's `ts` is stored and displayed exactly as
//! sent. That is the whole of the timestamp design.
//!
//! Reserved bytes, where they exist, are written as zero and ignored on decode,
//! so a later version can populate them without this build rejecting the packet.
use crate::error::DecodeError;
use crate::point::{POINT_LEN, Point};
/// Number of nonce bytes, which is also the length of a message id.
pub const NONCE_LEN: usize = 12;
/// A message id: the random AEAD nonce, reused as the identifier that `ACK` and
/// `NACK` echo. It is unique and already on the wire, so a separate id field
/// would be pure overhead.
pub type Nonce = [u8; NONCE_LEN];
/// Most points in one `LOC`, and most nonces in one `ACK`.
///
/// 40 points is `21 + 1 + 40*24 + 16` = 998 bytes, comfortably inside the
/// 1200-byte datagram budget.
pub const MAX_POINTS: usize = 40;
const HELLO_LEN: usize = 6;
const CONFIG_LEN: usize = 12;
const CONFIG_GET_LEN: usize = 2;
const PING_LEN: usize = 6;
const PONG_LEN: usize = 6;
const NACK_LEN: usize = NONCE_LEN + 2;
const REVOKED_LEN: usize = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum MsgType {
Loc = 0x1,
Ack = 0x2,
Nack = 0x3,
Hello = 0x4,
Config = 0x5,
ConfigGet = 0x6,
Ping = 0x7,
Pong = 0x8,
/// Sealed under `K_rev`, not `K_down`. See [`Revoked`].
Revoked = 0x9,
}
impl MsgType {
pub const ALL: [Self; 9] = [
Self::Loc,
Self::Ack,
Self::Nack,
Self::Hello,
Self::Config,
Self::ConfigGet,
Self::Ping,
Self::Pong,
Self::Revoked,
];
/// True for messages a device sends to the server, which are sealed under
/// `K_up`. The two directions have separate keys, so a captured uplink
/// datagram can never be replayed back as a downlink one.
#[must_use]
pub const fn is_uplink(self) -> bool {
matches!(self, Self::Loc | Self::Hello | Self::ConfigGet | Self::Ping)
}
#[must_use]
pub const fn direction(self) -> Direction {
if self.is_uplink() {
Direction::Up
} else {
Direction::Down
}
}
}
impl TryFrom<u8> for MsgType {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
0x1 => Self::Loc,
0x2 => Self::Ack,
0x3 => Self::Nack,
0x4 => Self::Hello,
0x5 => Self::Config,
0x6 => Self::ConfigGet,
0x7 => Self::Ping,
0x8 => Self::Pong,
0x9 => Self::Revoked,
other => return Err(DecodeError::BadMsgType(other)),
})
}
}
/// Which of the two derived keys seals a message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Direction {
/// Device → server, `K_up`.
Up,
/// Server → device, `K_down`.
Down,
}
impl Direction {
/// HKDF info string. Distinct strings are what make the two keys
/// independent.
#[must_use]
pub const fn info(self) -> &'static [u8] {
match self {
Self::Up => b"otp/1/up",
Self::Down => b"otp/1/down",
}
}
}
// ---------------------------------------------------------------------------
// ACK
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct AckFlags(pub u8);
impl AckFlags {
pub const NONE: Self = Self(0);
/// The server has a newer config; the device should send `CONFIG_GET`.
/// Config delivery is pull-based so it never depends on a live NAT binding.
pub const CONFIG_PENDING: Self = Self(1 << 0);
/// The write path is saturated; back off before the next flush.
pub const THROTTLE: Self = Self(1 << 1);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
/// Retires one or more messages. One `ACK` can cover a whole flush burst.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Ack {
pub nonces: Vec<Nonce>,
pub flags: AckFlags,
}
impl Ack {
#[must_use]
pub fn single(nonce: Nonce) -> Self {
Self {
nonces: vec![nonce],
flags: AckFlags::NONE,
}
}
const fn payload_len_for(count: usize) -> usize {
1 + count * NONCE_LEN + 1
}
}
// ---------------------------------------------------------------------------
// NACK
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum NackReason {
/// Token unknown, revoked, or expired. The device clears local state and
/// shows the login screen.
UnknownToken = 1,
/// Decrypted cleanly but the payload made no sense.
Malformed = 2,
RateLimited = 3,
/// Per-account storage quota exhausted.
StorageFull = 4,
}
impl TryFrom<u8> for NackReason {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
1 => Self::UnknownToken,
2 => Self::Malformed,
3 => Self::RateLimited,
4 => Self::StorageFull,
value => {
return Err(DecodeError::BadEnum {
field: "NackReason",
value,
});
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Nack {
pub nonce: Nonce,
pub reason: NackReason,
/// Seconds to wait before retrying. 0 means "no advice".
pub retry_after_s: u8,
}
// ---------------------------------------------------------------------------
// REVOKED
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum RevokeReason {
/// Explicitly revoked: "log out all other devices", or a password change.
Revoked = 1,
/// Deleted by the staleness sweep after a long silence.
Expired = 2,
/// The server has no record of this token at all. A restored backup or a
/// rotated server key looks like this.
Unknown = 3,
}
impl TryFrom<u8> for RevokeReason {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
1 => Self::Revoked,
2 => Self::Expired,
3 => Self::Unknown,
value => {
return Err(DecodeError::BadEnum {
field: "RevokeReason",
value,
});
}
})
}
}
/// "This token is dead; log in again."
///
/// The one message sealed under `K_rev` rather than `K_down`, which is the whole
/// reason it exists as a separate type. `K_down` derives from the token key, so
/// it dies with the token's row — and the moment the server most needs to speak
/// is exactly when that row is gone. `K_rev` is derived from a server master key
/// and the `token_id` (see [`crate::kdf::revocation_key`]), so the server can
/// recompute it for any id, including one it has never seen.
///
/// Two properties follow from deriving per `token_id` rather than sharing one
/// server key: a third party cannot forge this message, and neither can another
/// legitimate device — it only ever learns its own `K_rev`.
///
/// Replay needs no counter. `token_id` lives in the header, and the header is the
/// AEAD's associated data, so a captured `REVOKED` names the token it was issued
/// against. After the user logs in again the device holds a different id and the
/// rule "act only on my current `token_id`" discards it.
///
/// One byte of payload, deliberately: at 38 bytes on the wire this is the
/// smallest reply in the protocol, and the server refuses to send it in answer to
/// anything shorter, so it can never amplify.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Revoked {
pub reason: RevokeReason,
}
// ---------------------------------------------------------------------------
// HELLO
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct HelloFlags(pub u8);
impl HelloFlags {
pub const NONE: Self = Self(0);
/// First run after an install or reinstall.
pub const FIRST_LAUNCH: Self = Self(1 << 0);
}
/// Sent once per service start, so the server has app and OS versions to show
/// alongside a token. Carries no time: the server has nothing to compare it to
/// that it would be allowed to act on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Hello {
pub app_version_code: u16,
pub os_api_level: u8,
pub flags: HelloFlags,
/// The config version the device currently holds.
pub config_version: u16,
}
// ---------------------------------------------------------------------------
// CONFIG / CONFIG_GET
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[repr(u8)]
pub enum Profile {
BatterySaver = 0,
#[default]
Balanced = 1,
HighAccuracy = 2,
}
impl TryFrom<u8> for Profile {
type Error = DecodeError;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Ok(match v {
0 => Self::BatterySaver,
1 => Self::Balanced,
2 => Self::HighAccuracy,
value => {
return Err(DecodeError::BadEnum {
field: "Profile",
value,
});
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct ConfigFlags(pub u8);
impl ConfigFlags {
pub const NONE: Self = Self(0);
/// Master switch. Clearing it stops sharing without revoking the token.
pub const TRACKING_ENABLED: Self = Self(1 << 0);
/// Server wants a fresh `HELLO` (e.g. it has no version info on record).
pub const REQUEST_HELLO: Self = Self(1 << 1);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
/// The server's view of how this device should behave.
///
/// `profile` names a parameter set the *client* owns; the numeric fields are
/// server-side overrides on top of it. That keeps the message small and means a
/// profile can be retuned by shipping an app update, without a protocol change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Config {
pub config_version: u16,
pub profile: Profile,
pub flags: ConfigFlags,
/// Stationary heartbeat period. Clamped by the client to what inexact
/// alarms can actually deliver in doze (~9 min floor).
pub heartbeat_s: u16,
/// Scales the profile's intervals, in percent. 100 = profile default.
pub interval_scale_pct: u16,
pub min_distance_m: u16,
pub max_points_per_loc: u8,
}
impl Default for Config {
fn default() -> Self {
Self {
config_version: 1,
profile: Profile::Balanced,
flags: ConfigFlags::TRACKING_ENABLED,
heartbeat_s: 900,
interval_scale_pct: 100,
min_distance_m: 20,
max_points_per_loc: MAX_POINTS as u8,
}
}
}
/// Asks for the current [`Config`]. Padded to `CONFIG`'s size so the reply is
/// never larger than the request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConfigGet {
/// The version the device already has, so the server can skip a no-op push.
pub have_version: u16,
}
// ---------------------------------------------------------------------------
// PING / PONG
// ---------------------------------------------------------------------------
/// Probes whether UDP works on the current network.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Ping {
/// Opaque to the server, echoed verbatim in the `PONG`. The client puts
/// whatever lets it match up a reply and measure a round trip — a reading of
/// its own monotonic clock, typically. Deliberately *not* a wall-clock time
/// the server is invited to interpret.
pub echo: u32,
pub seq: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Pong {
/// Copied from the `PING`, so the client needs no per-probe state.
pub echo: u32,
pub seq: u16,
}
// ---------------------------------------------------------------------------
// Message
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
// Adjacently tagged rather than internally tagged: `Loc` carries a sequence,
// which an internally-tagged representation cannot express.
#[cfg_attr(
feature = "serde",
serde(tag = "type", content = "value", rename_all = "snake_case")
)]
pub enum Message {
/// One or more independent points.
Loc(Vec<Point>),
Ack(Ack),
Nack(Nack),
Hello(Hello),
Config(Config),
ConfigGet(ConfigGet),
Ping(Ping),
Pong(Pong),
Revoked(Revoked),
}
impl Message {
#[must_use]
pub const fn msg_type(&self) -> MsgType {
match self {
Self::Loc(_) => MsgType::Loc,
Self::Ack(_) => MsgType::Ack,
Self::Nack(_) => MsgType::Nack,
Self::Hello(_) => MsgType::Hello,
Self::Config(_) => MsgType::Config,
Self::ConfigGet(_) => MsgType::ConfigGet,
Self::Ping(_) => MsgType::Ping,
Self::Pong(_) => MsgType::Pong,
Self::Revoked(_) => MsgType::Revoked,
}
}
/// Exact payload length, without allocating.
#[must_use]
pub fn payload_len(&self) -> usize {
match self {
Self::Loc(points) => 1 + points.len() * POINT_LEN,
Self::Ack(ack) => Ack::payload_len_for(ack.nonces.len()),
Self::Nack(_) => NACK_LEN,
Self::Hello(_) => HELLO_LEN,
Self::Config(_) => CONFIG_LEN,
Self::ConfigGet(_) => CONFIG_GET_LEN,
Self::Ping(_) => PING_LEN,
Self::Pong(_) => PONG_LEN,
Self::Revoked(_) => REVOKED_LEN,
}
}
/// Append this message's payload (the part that gets encrypted).
///
/// # Panics
/// If a `Loc` or `Ack` holds more than [`MAX_POINTS`] elements, or a `Loc`
/// holds none. Those are caller bugs, not wire conditions: nothing outside
/// this process can trigger them.
pub fn encode_payload_into(&self, out: &mut Vec<u8>) {
out.reserve(self.payload_len());
match self {
Self::Loc(points) => {
assert!(
(1..=MAX_POINTS).contains(&points.len()),
"LOC must carry 1..={MAX_POINTS} points, got {}",
points.len()
);
out.push(points.len() as u8);
for p in points {
out.extend_from_slice(&p.to_bytes());
}
}
Self::Ack(ack) => {
assert!(
(1..=MAX_POINTS).contains(&ack.nonces.len()),
"ACK must carry 1..={MAX_POINTS} nonces, got {}",
ack.nonces.len()
);
out.push(ack.nonces.len() as u8);
for n in &ack.nonces {
out.extend_from_slice(n);
}
out.push(ack.flags.0);
}
Self::Nack(nack) => {
out.extend_from_slice(&nack.nonce);
out.push(nack.reason as u8);
out.push(nack.retry_after_s);
}
Self::Hello(h) => {
out.extend_from_slice(&h.app_version_code.to_be_bytes());
out.push(h.os_api_level);
out.push(h.flags.0);
out.extend_from_slice(&h.config_version.to_be_bytes());
}
Self::Config(c) => {
out.extend_from_slice(&c.config_version.to_be_bytes());
out.push(c.profile as u8);
out.push(c.flags.0);
out.extend_from_slice(&c.heartbeat_s.to_be_bytes());
out.extend_from_slice(&c.interval_scale_pct.to_be_bytes());
out.extend_from_slice(&c.min_distance_m.to_be_bytes());
out.push(c.max_points_per_loc);
out.push(0); // reserved
}
Self::ConfigGet(g) => {
out.extend_from_slice(&g.have_version.to_be_bytes());
}
Self::Ping(p) => {
out.extend_from_slice(&p.echo.to_be_bytes());
out.extend_from_slice(&p.seq.to_be_bytes());
}
Self::Pong(p) => {
out.extend_from_slice(&p.echo.to_be_bytes());
out.extend_from_slice(&p.seq.to_be_bytes());
}
Self::Revoked(r) => out.push(r.reason as u8),
}
debug_assert_eq!(
out.len(),
self.payload_len(),
"payload_len disagrees with the encoder"
);
}
#[must_use]
pub fn encode_payload(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.payload_len());
self.encode_payload_into(&mut out);
out
}
/// Parse a decrypted payload.
pub fn decode_payload(ty: MsgType, p: &[u8]) -> Result<Self, DecodeError> {
let exact = |what: &'static str, expected: usize| -> Result<(), DecodeError> {
if p.len() == expected {
Ok(())
} else {
Err(DecodeError::BadPayloadLen {
what,
expected,
actual: p.len(),
})
}
};
Ok(match ty {
MsgType::Loc => {
let count = *p.first().ok_or(DecodeError::BadPayloadLen {
what: "LOC",
expected: 1 + POINT_LEN,
actual: 0,
})? as usize;
if !(1..=MAX_POINTS).contains(&count) {
return Err(DecodeError::BadPointCount(count));
}
exact("LOC", 1 + count * POINT_LEN)?;
let points = p[1..]
.chunks_exact(POINT_LEN)
.map(|c| {
Point::from_bytes(c.try_into().expect("chunks_exact yields POINT_LEN"))
})
.collect();
Self::Loc(points)
}
MsgType::Ack => {
let count = *p.first().ok_or(DecodeError::BadPayloadLen {
what: "ACK",
expected: Ack::payload_len_for(1),
actual: 0,
})? as usize;
if !(1..=MAX_POINTS).contains(&count) {
return Err(DecodeError::BadNonceCount(count));
}
exact("ACK", Ack::payload_len_for(count))?;
let nonces = p[1..1 + count * NONCE_LEN]
.chunks_exact(NONCE_LEN)
.map(|c| -> Nonce { c.try_into().expect("chunks_exact yields NONCE_LEN") })
.collect();
let tail = 1 + count * NONCE_LEN;
Self::Ack(Ack {
nonces,
flags: AckFlags(p[tail]),
})
}
MsgType::Nack => {
exact("NACK", NACK_LEN)?;
Self::Nack(Nack {
nonce: p[..NONCE_LEN].try_into().expect("length checked"),
reason: NackReason::try_from(p[NONCE_LEN])?,
retry_after_s: p[NONCE_LEN + 1],
})
}
MsgType::Hello => {
exact("HELLO", HELLO_LEN)?;
Self::Hello(Hello {
app_version_code: be16(p),
os_api_level: p[2],
flags: HelloFlags(p[3]),
config_version: be16(&p[4..]),
})
}
MsgType::Config => {
exact("CONFIG", CONFIG_LEN)?;
Self::Config(Config {
config_version: be16(p),
profile: Profile::try_from(p[2])?,
flags: ConfigFlags(p[3]),
heartbeat_s: be16(&p[4..]),
interval_scale_pct: be16(&p[6..]),
min_distance_m: be16(&p[8..]),
max_points_per_loc: p[10],
})
}
MsgType::ConfigGet => {
exact("CONFIG_GET", CONFIG_GET_LEN)?;
Self::ConfigGet(ConfigGet {
have_version: be16(p),
})
}
MsgType::Ping => {
exact("PING", PING_LEN)?;
Self::Ping(Ping {
echo: be32(p),
seq: be16(&p[4..]),
})
}
MsgType::Pong => {
exact("PONG", PONG_LEN)?;
Self::Pong(Pong {
echo: be32(p),
seq: be16(&p[4..]),
})
}
MsgType::Revoked => {
exact("REVOKED", REVOKED_LEN)?;
Self::Revoked(Revoked {
reason: RevokeReason::try_from(p[0])?,
})
}
})
}
}
fn be16(b: &[u8]) -> u16 {
u16::from_be_bytes([b[0], b[1]])
}
fn be32(b: &[u8]) -> u32 {
u32::from_be_bytes([b[0], b[1], b[2], b[3]])
}
#[cfg(test)]
mod tests {
use super::*;
fn round_trip(m: &Message) {
let bytes = m.encode_payload();
assert_eq!(bytes.len(), m.payload_len());
let back = Message::decode_payload(m.msg_type(), &bytes).expect("decodes");
assert_eq!(&back, m);
}
#[test]
fn every_type_round_trips() {
round_trip(&Message::Loc(vec![Point::new(
1_785_000_042,
525_200_080,
134_050_000,
)]));
round_trip(&Message::Loc(
(0..MAX_POINTS as u32)
.map(|i| Point::new(i, i as i32, -(i as i32)))
.collect(),
));
round_trip(&Message::Ack(Ack {
nonces: vec![[7; NONCE_LEN], [9; NONCE_LEN]],
flags: AckFlags::CONFIG_PENDING,
}));
round_trip(&Message::Nack(Nack {
nonce: [3; NONCE_LEN],
reason: NackReason::RateLimited,
retry_after_s: 30,
}));
round_trip(&Message::Hello(Hello {
app_version_code: 17,
os_api_level: 34,
flags: HelloFlags::FIRST_LAUNCH,
config_version: 2,
}));
round_trip(&Message::Config(Config::default()));
round_trip(&Message::ConfigGet(ConfigGet { have_version: 2 }));
round_trip(&Message::Ping(Ping {
echo: 0xDEAD_BEEF,
seq: 5,
}));
round_trip(&Message::Pong(Pong {
echo: 0xDEAD_BEEF,
seq: 5,
}));
}
#[test]
fn loc_rejects_degenerate_counts() {
assert_eq!(
Message::decode_payload(MsgType::Loc, &[0]),
Err(DecodeError::BadPointCount(0))
);
let mut too_many = vec![(MAX_POINTS + 1) as u8];
too_many.extend(std::iter::repeat_n(0u8, (MAX_POINTS + 1) * POINT_LEN));
assert_eq!(
Message::decode_payload(MsgType::Loc, &too_many),
Err(DecodeError::BadPointCount(MAX_POINTS + 1))
);
}
#[test]
fn loc_rejects_a_count_that_disagrees_with_the_length() {
// Claims two points, carries one. The classic truncation bug.
let mut p = vec![2u8];
p.extend_from_slice(&Point::new(1, 2, 3).to_bytes());
assert!(matches!(
Message::decode_payload(MsgType::Loc, &p),
Err(DecodeError::BadPayloadLen { .. })
));
}
#[test]
fn fixed_payload_sizes_are_what_the_spec_says() {
assert_eq!(
Message::Nack(Nack {
nonce: [0; NONCE_LEN],
reason: NackReason::Malformed,
retry_after_s: 0
})
.payload_len(),
14
);
assert_eq!(
Message::Hello(Hello {
app_version_code: 0,
os_api_level: 0,
flags: HelloFlags::NONE,
config_version: 0
})
.payload_len(),
6
);
assert_eq!(Message::Config(Config::default()).payload_len(), 12);
assert_eq!(Message::ConfigGet(ConfigGet::default()).payload_len(), 2);
assert_eq!(Message::Ping(Ping::default()).payload_len(), 6);
assert_eq!(Message::Pong(Pong::default()).payload_len(), 6);
assert_eq!(Message::Loc(vec![Point::new(0, 0, 0)]).payload_len(), 25);
}
/// CONFIG holds the only reserved byte left in the protocol. A later version
/// may populate it, and this build must ignore it rather than reject the
/// packet.
#[test]
fn reserved_bytes_do_not_break_decoding() {
let config = Config::default();
let mut p = Message::Config(config).encode_payload();
p[11] = 0xAB;
assert_eq!(
Message::decode_payload(MsgType::Config, &p),
Ok(Message::Config(config))
);
}
#[test]
fn unknown_discriminants_are_rejected() {
assert!(MsgType::try_from(0).is_err());
assert!(MsgType::try_from(10).is_err());
assert!(Profile::try_from(3).is_err());
assert!(NackReason::try_from(0).is_err());
assert!(NackReason::try_from(5).is_err());
assert!(RevokeReason::try_from(0).is_err());
assert!(RevokeReason::try_from(4).is_err());
}
#[test]
fn direction_split_matches_the_key_schedule() {
for ty in MsgType::ALL {
assert_eq!(
ty.direction() == Direction::Up,
matches!(
ty,
MsgType::Loc | MsgType::Hello | MsgType::ConfigGet | MsgType::Ping
)
);
}
assert_ne!(Direction::Up.info(), Direction::Down.info());
}
}
Acrates/otproto/src/point.rs
@@ -0,0 +1,323 @@
//! The 24-byte point record — the only part of OTP/1 that appears in bulk.
//!
//! ```text
//! off size field range / unit
//! 0 4 ts u32 unix seconds (good to 2106)
//! 4 4 lat i32 degrees × 1e7 → 1.1 cm
//! 8 4 lon i32 degrees × 1e7
//! 12 2 acc_dm u16 decimetres, 0–6553 m; 0xFFFF unknown
//! 14 2 alt_m i16 metres, ±32 km; 0x8000 unknown
//! 16 2 spd_cms u16 cm/s, 0–655 m/s; 0xFFFF unknown
//! 18 2 brg_cdeg u16 centidegrees, 0–35999; 0xFFFF unknown
//! 20 1 bat_pct u8 0–100; 0xFF unknown
//! 21 1 flags u8
//! 22 2 reserved zero
//! ```
//!
//! Fixed width, no varints, no delta coding, no flag-driven optional fields:
//! the codec is a straight struct read, and a 40-point datagram still fits in
//! 998 bytes.
use crate::error::ValidationError;
/// Wire size of one point record.
pub const POINT_LEN: usize = 24;
const ACC_UNKNOWN: u16 = 0xFFFF;
const ALT_UNKNOWN: i16 = i16::MIN; // 0x8000
const SPD_UNKNOWN: u16 = 0xFFFF;
const BRG_UNKNOWN: u16 = 0xFFFF;
const BAT_UNKNOWN: u8 = 0xFF;
/// Largest representable value for each sentinel-terminated field.
const ACC_MAX: u16 = ACC_UNKNOWN - 1;
const SPD_MAX: u16 = SPD_UNKNOWN - 1;
const BRG_MAX: u16 = 35_999;
const ALT_MIN: i16 = i16::MIN + 1;
pub const LAT_MAX_E7: i32 = 900_000_000;
pub const LON_MAX_E7: i32 = 1_800_000_000;
/// Per-point flag bits.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Flags(pub u8);
impl Flags {
pub const NONE: Self = Self(0);
/// Device is plugged in.
pub const CHARGING: Self = Self(1 << 0);
/// Fix came from the network provider rather than GNSS.
pub const NETWORK_FIX: Self = Self(1 << 1);
/// Accepted despite exceeding the accuracy gate — nothing better arrived.
pub const LOW_ACCURACY: Self = Self(1 << 2);
/// `Location.isFromMockProvider()`.
pub const MOCK: Self = Self(1 << 3);
/// Bits with an assigned meaning in version 1.
pub const KNOWN: u8 = 0b0000_1111;
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[must_use]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
impl core::ops::BitOr for Flags {
type Output = Self;
fn bitor(self, rhs: Self) -> Self {
self.union(rhs)
}
}
/// One location report.
///
/// `None` in an optional field means "the device could not measure this" and is
/// carried on the wire as that field's sentinel. Points inside a multi-point
/// `LOC` are fully independent — any order, any spacing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Point {
/// Unix seconds, from the device's wall clock. Trusted as-is; the server
/// never rewrites it, it only reports the observed skew back to the user.
pub ts: u32,
pub lat_e7: i32,
pub lon_e7: i32,
/// Horizontal accuracy in decimetres.
pub acc_dm: Option<u16>,
pub alt_m: Option<i16>,
/// Ground speed in cm/s.
pub spd_cms: Option<u16>,
/// Bearing in centidegrees, 0..=35999.
pub brg_cdeg: Option<u16>,
pub bat_pct: Option<u8>,
pub flags: Flags,
}
impl Point {
/// A point with only a time and a position — every optional field unknown.
#[must_use]
pub const fn new(ts: u32, lat_e7: i32, lon_e7: i32) -> Self {
Self {
ts,
lat_e7,
lon_e7,
acc_dm: None,
alt_m: None,
spd_cms: None,
brg_cdeg: None,
bat_pct: None,
flags: Flags::NONE,
}
}
/// Decode a point record. Infallible: every 24-byte string is a point.
///
/// The two reserved bytes are dropped rather than rejected, so a future
/// version can put something there without this build treating the packet
/// as garbage.
#[must_use]
pub fn from_bytes(b: &[u8; POINT_LEN]) -> Self {
let acc = u16::from_be_bytes([b[12], b[13]]);
let alt = i16::from_be_bytes([b[14], b[15]]);
let spd = u16::from_be_bytes([b[16], b[17]]);
let brg = u16::from_be_bytes([b[18], b[19]]);
Self {
ts: u32::from_be_bytes([b[0], b[1], b[2], b[3]]),
lat_e7: i32::from_be_bytes([b[4], b[5], b[6], b[7]]),
lon_e7: i32::from_be_bytes([b[8], b[9], b[10], b[11]]),
acc_dm: (acc != ACC_UNKNOWN).then_some(acc),
alt_m: (alt != ALT_UNKNOWN).then_some(alt),
spd_cms: (spd != SPD_UNKNOWN).then_some(spd),
brg_cdeg: (brg != BRG_UNKNOWN).then_some(brg),
bat_pct: (b[20] != BAT_UNKNOWN).then_some(b[20]),
flags: Flags(b[21]),
}
}
/// Encode a point record.
///
/// Values that would collide with a sentinel are clamped to the largest
/// representable value, so "20 km up" degrades to "32.767 km up" rather
/// than silently becoming "unknown".
#[must_use]
pub fn to_bytes(self) -> [u8; POINT_LEN] {
let mut b = [0u8; POINT_LEN];
b[0..4].copy_from_slice(&self.ts.to_be_bytes());
b[4..8].copy_from_slice(&self.lat_e7.to_be_bytes());
b[8..12].copy_from_slice(&self.lon_e7.to_be_bytes());
b[12..14].copy_from_slice(
&self
.acc_dm
.map_or(ACC_UNKNOWN, |v| v.min(ACC_MAX))
.to_be_bytes(),
);
b[14..16].copy_from_slice(
&self
.alt_m
.map_or(ALT_UNKNOWN, |v| v.max(ALT_MIN))
.to_be_bytes(),
);
b[16..18].copy_from_slice(
&self
.spd_cms
.map_or(SPD_UNKNOWN, |v| v.min(SPD_MAX))
.to_be_bytes(),
);
b[18..20].copy_from_slice(
&self
.brg_cdeg
.map_or(BRG_UNKNOWN, |v| v.min(BRG_MAX))
.to_be_bytes(),
);
b[20] = self.bat_pct.map_or(BAT_UNKNOWN, |v| v.min(100));
b[21] = self.flags.0;
// b[22..24] stay zero.
b
}
/// True when `to_bytes` will not have to clamp anything, i.e. the struct
/// survives a round trip unchanged.
#[must_use]
pub fn is_canonical(self) -> bool {
self.acc_dm.is_none_or(|v| v <= ACC_MAX)
&& self.alt_m.is_none_or(|v| v >= ALT_MIN)
&& self.spd_cms.is_none_or(|v| v <= SPD_MAX)
&& self.brg_cdeg.is_none_or(|v| v <= BRG_MAX)
&& self.bat_pct.is_none_or(|v| v <= 100)
}
/// Clamp every field into its representable range. `to_bytes` does this
/// implicitly; call this when you want the struct itself to agree.
#[must_use]
pub fn canonical(self) -> Self {
Self::from_bytes(&self.to_bytes())
}
/// Reject values the server should not store.
///
/// `now` is the server's clock; timestamps are accepted within ±`window_s`
/// of it. That bound exists to stop a badly-set phone clock from writing
/// points into the year 2100 where retention will never reach them — it is
/// not a security control, since the client clock is trusted by design.
pub fn validate(self, now: u32, window_s: u32) -> Result<(), ValidationError> {
if !(-LAT_MAX_E7..=LAT_MAX_E7).contains(&self.lat_e7) {
return Err(ValidationError::Latitude(self.lat_e7));
}
if !(-LON_MAX_E7..=LON_MAX_E7).contains(&self.lon_e7) {
return Err(ValidationError::Longitude(self.lon_e7));
}
if let Some(brg) = self.brg_cdeg
&& brg > BRG_MAX
{
return Err(ValidationError::Bearing(brg));
}
if let Some(bat) = self.bat_pct
&& bat > 100
{
return Err(ValidationError::Battery(bat));
}
let off_by = i64::from(self.ts) - i64::from(now);
if off_by.unsigned_abs() > u64::from(window_s) {
return Err(ValidationError::Timestamp {
ts: self.ts,
now,
off_by,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sentinels_round_trip_as_none() {
let p = Point::from_bytes(&[0xFF; POINT_LEN]);
assert_eq!(p.acc_dm, None);
assert_eq!(p.spd_cms, None);
assert_eq!(p.brg_cdeg, None);
assert_eq!(p.bat_pct, None);
// 0xFFFF as i16 is -1, a perfectly good altitude, not the sentinel.
assert_eq!(p.alt_m, Some(-1));
assert_eq!(
Point::from_bytes(&{
let mut b = [0u8; POINT_LEN];
b[14..16].copy_from_slice(&ALT_UNKNOWN.to_be_bytes());
b
})
.alt_m,
None
);
}
#[test]
fn out_of_range_values_clamp_rather_than_vanish() {
let p = Point {
acc_dm: Some(u16::MAX),
alt_m: Some(i16::MIN),
spd_cms: Some(u16::MAX),
brg_cdeg: Some(40_000),
bat_pct: Some(200),
..Point::new(0, 0, 0)
};
assert!(!p.is_canonical());
let back = p.canonical();
assert_eq!(back.acc_dm, Some(ACC_MAX));
assert_eq!(back.alt_m, Some(ALT_MIN));
assert_eq!(back.spd_cms, Some(SPD_MAX));
assert_eq!(back.brg_cdeg, Some(BRG_MAX));
assert_eq!(back.bat_pct, Some(100));
assert!(back.is_canonical());
}
#[test]
fn reserved_bytes_are_written_zero() {
let b = Point::new(1, 2, 3).to_bytes();
assert_eq!(&b[22..24], &[0, 0]);
}
#[test]
fn quantization_stays_inside_stated_precision() {
// 1e7 fixed point resolves to ~1.1 cm at the equator; assert the
// encoder does not lose more than one unit.
let lat = 52.520_008_f64;
let e7 = (lat * 1e7).round() as i32;
let p = Point::new(0, e7, 0).canonical();
assert!((f64::from(p.lat_e7) / 1e7 - lat).abs() < 1e-7);
}
#[test]
fn validate_rejects_impossible_coordinates() {
let now = 1_785_000_000;
assert!(Point::new(now, 910_000_000, 0).validate(now, 60).is_err());
assert!(
Point::new(now, 0, -1_810_000_000)
.validate(now, 60)
.is_err()
);
assert!(
Point::new(now, 525_200_000, 134_050_000)
.validate(now, 60)
.is_ok()
);
}
#[test]
fn validate_rejects_timestamps_outside_the_window() {
let now = 1_785_000_000;
assert!(Point::new(now - 61, 0, 0).validate(now, 60).is_err());
assert!(Point::new(now + 61, 0, 0).validate(now, 60).is_err());
assert!(Point::new(now - 60, 0, 0).validate(now, 60).is_ok());
// A zero timestamp must not underflow into "close enough".
assert!(Point::new(0, 0, 0).validate(now, 60).is_err());
}
}
Acrates/otproto/tests/props.proptest-regressions
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc f484ce92166914b8909c369ce9a6099cca28b797c6c8366a79013020464193c6 # shrinks to mut b = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 101, 0, 0, 0]
Acrates/otproto/tests/props.rs
@@ -0,0 +1,189 @@
//! Property tests for the OTP/1 codec.
//!
//! Two shapes of property, and both matter for different reasons:
//!
//! * **Round-trip** — `decode(encode(x)) == x` and `encode(decode(b)) == b`.
//! These pin the codec's meaning.
//! * **Totality** — arbitrary bytes either fail cleanly or produce a message;
//! they never panic. The decoder faces the open internet, so a panic is a
//! remote denial of service. `cargo fuzz run decode` covers the same ground
//! with better coverage guidance; this keeps it honest on every `cargo test`.
use otproto::msg::Direction;
use otproto::point::{Flags, POINT_LEN};
use otproto::{Ack, AckFlags, Header, MAX_POINTS, Message, Nonce, Point, kdf};
use proptest::prelude::*;
/// Any point that survives encoding unchanged, i.e. no field needs clamping.
fn canonical_point() -> impl Strategy<Value = Point> {
(
any::<u32>(),
-otproto::point::LAT_MAX_E7..=otproto::point::LAT_MAX_E7,
-otproto::point::LON_MAX_E7..=otproto::point::LON_MAX_E7,
proptest::option::of(0u16..=65_534),
proptest::option::of(-32_767i16..=32_767),
proptest::option::of(0u16..=65_534),
proptest::option::of(0u16..=35_999),
proptest::option::of(0u8..=100),
any::<u8>(),
)
.prop_map(
|(ts, lat_e7, lon_e7, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags)| Point {
ts,
lat_e7,
lon_e7,
acc_dm,
alt_m,
spd_cms,
brg_cdeg,
bat_pct,
flags: Flags(flags),
},
)
}
/// Any point at all, including values the encoder will clamp.
fn wild_point() -> impl Strategy<Value = Point> {
proptest::array::uniform24(any::<u8>()).prop_map(|b| Point::from_bytes(&b))
}
fn nonce() -> impl Strategy<Value = Nonce> {
proptest::array::uniform12(any::<u8>())
}
proptest! {
#[test]
fn canonical_points_round_trip(p in canonical_point()) {
prop_assert!(p.is_canonical());
prop_assert_eq!(Point::from_bytes(&p.to_bytes()), p);
}
/// The other direction. Not every 24-byte string is a canonical record:
/// battery 101..=254 and bearing 36000..=65534 parse fine but re-encode
/// clamped, since only their sentinel is reserved, not the whole tail of
/// their range. Those two fields are normalised here, and the clamping
/// itself is covered by `canonicalisation_is_idempotent`.
#[test]
fn canonical_point_bytes_round_trip(mut b in proptest::array::uniform24(any::<u8>())) {
let brg = u16::from_be_bytes([b[18], b[19]]);
if brg > 35_999 && brg != 0xFFFF {
b[18..20].copy_from_slice(&35_999u16.to_be_bytes());
}
if b[20] > 100 && b[20] != 0xFF {
b[20] = 100;
}
b[22] = 0;
b[23] = 0;
prop_assert_eq!(Point::from_bytes(&b).to_bytes(), b);
}
/// Clamping is idempotent: canonicalising twice changes nothing more.
#[test]
fn canonicalisation_is_idempotent(p in wild_point()) {
let once = p.canonical();
prop_assert!(once.is_canonical());
prop_assert_eq!(once.canonical(), once);
}
#[test]
fn loc_messages_round_trip(points in prop::collection::vec(canonical_point(), 1..=MAX_POINTS)) {
let msg = Message::Loc(points);
let payload = msg.encode_payload();
prop_assert_eq!(payload.len(), msg.payload_len());
prop_assert_eq!(Message::decode_payload(otproto::MsgType::Loc, &payload).unwrap(), msg);
}
#[test]
fn ack_messages_round_trip(
nonces in prop::collection::vec(nonce(), 1..=MAX_POINTS),
flags in any::<u8>(),
) {
let msg = Message::Ack(Ack { nonces, flags: AckFlags(flags) });
let payload = msg.encode_payload();
prop_assert_eq!(Message::decode_payload(otproto::MsgType::Ack, &payload).unwrap(), msg);
}
#[test]
fn seal_open_round_trips(
points in prop::collection::vec(canonical_point(), 1..=MAX_POINTS),
token_key in proptest::array::uniform32(any::<u8>()),
token_id in any::<u64>(),
n in nonce(),
) {
let k_up = kdf::derive(&token_key, Direction::Up);
let msg = Message::Loc(points);
let dg = otproto::seal_message(&k_up, token_id, n, &msg);
prop_assert!(dg.len() <= otproto::MAX_DATAGRAM);
let (h, back) = otproto::open_message(&k_up, &dg).unwrap();
prop_assert_eq!(h.token_id, token_id);
prop_assert_eq!(h.nonce, n);
prop_assert_eq!(back, msg);
}
/// Any single bit flipped anywhere must be caught. The header is covered
/// because it is the AAD, not because it is separately checksummed.
#[test]
fn any_single_bit_flip_is_detected(
token_key in proptest::array::uniform32(any::<u8>()),
token_id in any::<u64>(),
n in nonce(),
point in canonical_point(),
bit in 0usize..(otproto::HEADER_LEN + 1 + POINT_LEN + otproto::TAG_LEN) * 8,
) {
let k_up = kdf::derive(&token_key, Direction::Up);
let mut dg = otproto::seal_message(&k_up, token_id, n, &Message::Loc(vec![point]));
dg[bit / 8] ^= 1 << (bit % 8);
prop_assert!(otproto::open_message(&k_up, &dg).is_err());
}
/// Totality: arbitrary bytes never panic the header parser.
#[test]
fn peek_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..1300)) {
let _ = Header::peek(&bytes);
}
/// Totality: arbitrary bytes never panic the AEAD layer either.
#[test]
fn open_never_panics(
bytes in prop::collection::vec(any::<u8>(), 0..1300),
token_key in proptest::array::uniform32(any::<u8>()),
) {
let k = kdf::derive(&token_key, Direction::Up);
let _ = otproto::open_message(&k, &bytes);
}
/// Totality on the payload decoder specifically, reached without having to
/// forge a valid tag first — the interesting half of the decoder is behind
/// the AEAD, so fuzzing the datagram alone would almost never get here.
#[test]
fn decode_payload_never_panics(
ty in 1u8..=8,
payload in prop::collection::vec(any::<u8>(), 0..1200),
) {
let ty = otproto::MsgType::try_from(ty).unwrap();
if let Ok(msg) = Message::decode_payload(ty, &payload) {
// Anything that decodes must re-encode to the same length, and for
// types without reserved padding, to the same bytes.
prop_assert_eq!(msg.payload_len(), payload.len());
prop_assert_eq!(msg.msg_type(), ty);
}
}
/// A datagram sealed for one token must not open under another token's key,
/// even with the ciphertext untouched — the header is AAD, so the token id
/// is bound into the tag.
#[test]
fn a_datagram_cannot_be_retargeted(
token_key in proptest::array::uniform32(any::<u8>()),
a in any::<u64>(),
b in any::<u64>(),
n in nonce(),
point in canonical_point(),
) {
prop_assume!(a != b);
let k_up = kdf::derive(&token_key, Direction::Up);
let mut dg = otproto::seal_message(&k_up, a, n, &Message::Loc(vec![point]));
dg[1..9].copy_from_slice(&b.to_be_bytes());
prop_assert!(otproto::open_message(&k_up, &dg).is_err());
}
}
Acrates/otproto/tests/vectors.json
@@ -0,0 +1,1606 @@
{
"protocol": "OTP/1",
"version": 1,
"note": "Generated by `cargo run -p otproto --features serde --example gen_vectors`. Do not edit by hand.",
"header_len": 21,
"tag_len": 16,
"max_datagram": 1200,
"max_points": 40,
"token_id": 81985529216486895,
"token_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"k_up_hex": "52c8535360382dd1d2b9d4b5d605f7c46f8a69fd4b5d62dfd900ca10b8ac6196",
"k_down_hex": "94171a6d07e341d8333b7c0dd809b789b378887cf4890ff1f5520c992d7637bf",
"revocation_master_hex": "e0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
"k_rev_hex": "a2c99bfa84c749a08c3c3b37d18ead4b4d296c608fe168d73b89d24607c60626",
"points": [
{
"name": "all_unknown",
"point": {
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": null,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 0
},
"bytes_hex": "6a64f06a1f4dead007fd70d0ffff8000ffffffffff000000"
},
{
"name": "fully_populated",
"point": {
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": 34,
"spd_cms": 450,
"brg_cdeg": 21400,
"bat_pct": 76,
"flags": 2
},
"bytes_hex": "6a64f06a1f4dead007fd70d00050002201c253984c020000"
},
{
"name": "southern_western_hemisphere",
"point": {
"ts": 1785000042,
"lat_e7": -338688000,
"lon_e7": -1754500000,
"acc_dm": 1200,
"alt_m": -31,
"spd_cms": 0,
"brg_cdeg": 0,
"bat_pct": 0,
"flags": 5
},
"bytes_hex": "6a64f06aebd00800976c746004b0ffe10000000000050000"
},
{
"name": "extremes",
"point": {
"ts": 4294967295,
"lat_e7": 900000000,
"lon_e7": -1800000000,
"acc_dm": 65534,
"alt_m": -32767,
"spd_cms": 65534,
"brg_cdeg": 35999,
"bat_pct": 100,
"flags": 15
},
"bytes_hex": "ffffffff35a4e90094b62e00fffe8001fffe8c9f640f0000"
},
{
"name": "epoch_zero",
"point": {
"ts": 0,
"lat_e7": 0,
"lon_e7": 0,
"acc_dm": null,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 0
},
"bytes_hex": "000000000000000000000000ffff8000ffffffffff000000"
}
],
"datagrams": [
{
"name": "loc_single",
"direction": "up",
"key": "up",
"msg_type": 1,
"nonce_hex": "000102030405060708090a0b",
"header_hex": "110123456789abcdef000102030405060708090a0b",
"payload_hex": "016a64f06a1f4dead007fd70d00050002201c253984c020000",
"datagram_hex": "110123456789abcdef000102030405060708090a0bee7fe4db683bd4169d85b517d4e14fceed13336f3437fe90f2c162c7b9a8073cfefc686999c6fa2a83",
"datagram_len": 62,
"message": {
"type": "loc",
"value": [
{
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": 34,
"spd_cms": 450,
"brg_cdeg": 21400,
"bat_pct": 76,
"flags": 2
}
]
}
},
{
"name": "loc_three_independent",
"direction": "up",
"key": "up",
"msg_type": 1,
"nonce_hex": "101112131415161718191a1b",
"header_hex": "110123456789abcdef101112131415161718191a1b",
"payload_hex": "036a64eff21f4dead007fd70d0ffff8000ffffffffff0000006a64f06a1f4dead007fd70d00050002201c253984c0200006a64f0a61f4dee6807fd74b809c48000ffffffffff060000",
"datagram_hex": "110123456789abcdef101112131415161718191a1bfcba28492d9b93538f030a25fd0c73922802d86dc08bf593234b35d4b339dfe4e584a79e3ad1910312bdb162639b74536eb61bd445ef23a2fa4cb632d5a6177ae14ecdf7f180111dba2ffaae94033ee3895341dd5df5c9929f",
"datagram_len": 110,
"message": {
"type": "loc",
"value": [
{
"ts": 1784999922,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": null,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 0
},
{
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": 34,
"spd_cms": 450,
"brg_cdeg": 21400,
"bat_pct": 76,
"flags": 2
},
{
"ts": 1785000102,
"lat_e7": 525201000,
"lon_e7": 134051000,
"acc_dm": 2500,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": null,
"flags": 6
}
]
}
},
{
"name": "loc_max_points",
"direction": "up",
"key": "up",
"msg_type": 1,
"nonce_hex": "202122232425262728292a2b",
"header_hex": "110123456789abcdef202122232425262728292a2b",
"payload_hex": "286a64f06a1f4dead007fd70d000328000ffffffff640000006a64f0881f4deb3407fd70d000338000ffffffff630000006a64f0a61f4deb9807fd70d000348000ffffffff620000006a64f0c41f4debfc07fd70d000358000ffffffff610000006a64f0e21f4dec6007fd70d000368000ffffffff600000006a64f1001f4decc407fd70d000378000ffffffff5f0000006a64f11e1f4ded2807fd70d000388000ffffffff5e0000006a64f13c1f4ded8c07fd70d000398000ffffffff5d0000006a64f15a1f4dedf007fd70d0003a8000ffffffff5c0000006a64f1781f4dee5407fd70d0003b8000ffffffff5b0000006a64f1961f4deeb807fd70d0003c8000ffffffff5a0000006a64f1b41f4def1c07fd70d0003d8000ffffffff590000006a64f1d21f4def8007fd70d0003e8000ffffffff580000006a64f1f01f4defe407fd70d0003f8000ffffffff570000006a64f20e1f4df04807fd70d000408000ffffffff560000006a64f22c1f4df0ac07fd70d000418000ffffffff550000006a64f24a1f4df11007fd70d000428000ffffffff540000006a64f2681f4df17407fd70d000438000ffffffff530000006a64f2861f4df1d807fd70d000448000ffffffff520000006a64f2a41f4df23c07fd70d000458000ffffffff510000006a64f2c21f4df2a007fd70d000468000ffffffff500000006a64f2e01f4df30407fd70d000478000ffffffff4f0000006a64f2fe1f4df36807fd70d000488000ffffffff4e0000006a64f31c1f4df3cc07fd70d000498000ffffffff4d0000006a64f33a1f4df43007fd70d0004a8000ffffffff4c0000006a64f3581f4df49407fd70d0004b8000ffffffff4b0000006a64f3761f4df4f807fd70d0004c8000ffffffff4a0000006a64f3941f4df55c07fd70d0004d8000ffffffff490000006a64f3b21f4df5c007fd70d0004e8000ffffffff480000006a64f3d01f4df62407fd70d0004f8000ffffffff470000006a64f3ee1f4df68807fd70d000508000ffffffff460000006a64f40c1f4df6ec07fd70d000518000ffffffff450000006a64f42a1f4df75007fd70d000528000ffffffff440000006a64f4481f4df7b407fd70d000538000ffffffff430000006a64f4661f4df81807fd70d000548000ffffffff420000006a64f4841f4df87c07fd70d000558000ffffffff410000006a64f4a21f4df8e007fd70d000568000ffffffff400000006a64f4c01f4df94407fd70d000578000ffffffff3f0000006a64f4de1f4df9a807fd70d000588000ffffffff3e0000006a64f4fc1f4dfa0c07fd70d000598000ffffffff3d000000",
"datagram_hex": "110123456789abcdef202122232425262728292a2bd24428a6fed55b93ae0980e4d45792c41f88e7a5461ab511264a62c55b228627a9c37b15946c830fda93d831337a3836a649adfd1307c8fa78c5800cad4625d0d1a655d2b4622a26b8f171f3411d2df39bea83f5f2d526020e35c0e488a99c5e9c4d052ef96289e9026ca5125583674e317e7ff2ee772f1d9972f3491fb5f9ed20c092007df1c1dcd186363a62d7524f6db4681689f0342e808c1cfa9faf872cb728f9f81c5a61e7a58e4c6223122cd8ce6ad2736c770c40b94a74d19327cb19e321f79fc0dc44da2c0815f756e3cc8a97e824d73f89998a4b6a4633e7c070dfe376d7cfce841dea72af42dbe7f82ada86f0c3021e089ec8795479958e85f5dce0da62bb80752a695f7bfc21d785687e7a13c291d0389c3908d855147a66a071a93e3ea153d44a502c4e128596018cd66bb1d0ae21c2470ebbe0f332c0e36f2575120eb2977d0573ea4bb9cbbd65d4037c18a7d48e55d17581226d8cc34d1459e949155c4c12049aaef715b4de6eb3fe7e8e4edee989553eb873f41e4552b5bf6c24e21866db788a53e18365e49d1fbac65f177c4e2a393fb285f4a06a4c6f0401c71bb85ac025abcdff0a821a3c30f84b76c60b98aea6dc7a9d36829000e5da251cfd6cfc7db36a731f899d0f4215e15a05edb20ae6f60ff7e1bd71a1e424d1855014f5c1b04fc76ad32d1a2c73beb83b4ffb5d88cb4ff8b2486d259558b63131a03a48a51b857124a092419f10fe55ea33648cb08ae0835905007d6048572e48aefa73f7687ae2a9937b754a0924fa83cb2697f14d965de7d80858f0cea02a4b173014000f1683c44309e9303528562c67b367f93f9c9c248704879d606e1dc3a316378da5ca24990e558aa88f52e63da38f7a05e2d7499fc2b412fef499097c3b46f039c98b820673a0e6d9c693a240e4cbac2e590cf5624a6bed7de98ce5dcc47e7d58373335216d050ad73e78b1748057dd568453b25772558f2a9665e2bbace08db2f41541373ee3a6f0ffc181d297cee39bf25ccb36e065fec5cbb9ba61fcbd5883c3f94a52e9eda0c761d1b6d2e2469ff25a741533e288e414448e51ff06f89ce466c1e69136cbfc6ec97a0ffb986d57a087703966a46d97f3ed9948f938b647a5b6f51bb843f6a5aad4261144bbe97bc90c1801a09aafe0746ff5d4d4eaab6ff8589cd08de39bcbfcf2d3badbde4eed2b67732b0ce850c7c8fe1047c60343fa4920a6b41ffee2d3489cd851b9ce729aa682613bcc033bcdf3d2c50e92405b44b3ff366fba7f99d60637642d5f8bd8ca62d2bcd1c4ec5b883b03af4159eefe3617e718f32aaf85cdae7bd412c8813c800bf89293567cde482102bf8b1fedd41b1f1517425d90a2b0f784ba301d",
"datagram_len": 998,
"message": {
"type": "loc",
"value": [
{
"ts": 1785000042,
"lat_e7": 525200080,
"lon_e7": 134050000,
"acc_dm": 50,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 100,
"flags": 0
},
{
"ts": 1785000072,
"lat_e7": 525200180,
"lon_e7": 134050000,
"acc_dm": 51,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 99,
"flags": 0
},
{
"ts": 1785000102,
"lat_e7": 525200280,
"lon_e7": 134050000,
"acc_dm": 52,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 98,
"flags": 0
},
{
"ts": 1785000132,
"lat_e7": 525200380,
"lon_e7": 134050000,
"acc_dm": 53,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 97,
"flags": 0
},
{
"ts": 1785000162,
"lat_e7": 525200480,
"lon_e7": 134050000,
"acc_dm": 54,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 96,
"flags": 0
},
{
"ts": 1785000192,
"lat_e7": 525200580,
"lon_e7": 134050000,
"acc_dm": 55,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 95,
"flags": 0
},
{
"ts": 1785000222,
"lat_e7": 525200680,
"lon_e7": 134050000,
"acc_dm": 56,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 94,
"flags": 0
},
{
"ts": 1785000252,
"lat_e7": 525200780,
"lon_e7": 134050000,
"acc_dm": 57,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 93,
"flags": 0
},
{
"ts": 1785000282,
"lat_e7": 525200880,
"lon_e7": 134050000,
"acc_dm": 58,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 92,
"flags": 0
},
{
"ts": 1785000312,
"lat_e7": 525200980,
"lon_e7": 134050000,
"acc_dm": 59,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 91,
"flags": 0
},
{
"ts": 1785000342,
"lat_e7": 525201080,
"lon_e7": 134050000,
"acc_dm": 60,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 90,
"flags": 0
},
{
"ts": 1785000372,
"lat_e7": 525201180,
"lon_e7": 134050000,
"acc_dm": 61,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 89,
"flags": 0
},
{
"ts": 1785000402,
"lat_e7": 525201280,
"lon_e7": 134050000,
"acc_dm": 62,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 88,
"flags": 0
},
{
"ts": 1785000432,
"lat_e7": 525201380,
"lon_e7": 134050000,
"acc_dm": 63,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 87,
"flags": 0
},
{
"ts": 1785000462,
"lat_e7": 525201480,
"lon_e7": 134050000,
"acc_dm": 64,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 86,
"flags": 0
},
{
"ts": 1785000492,
"lat_e7": 525201580,
"lon_e7": 134050000,
"acc_dm": 65,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 85,
"flags": 0
},
{
"ts": 1785000522,
"lat_e7": 525201680,
"lon_e7": 134050000,
"acc_dm": 66,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 84,
"flags": 0
},
{
"ts": 1785000552,
"lat_e7": 525201780,
"lon_e7": 134050000,
"acc_dm": 67,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 83,
"flags": 0
},
{
"ts": 1785000582,
"lat_e7": 525201880,
"lon_e7": 134050000,
"acc_dm": 68,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 82,
"flags": 0
},
{
"ts": 1785000612,
"lat_e7": 525201980,
"lon_e7": 134050000,
"acc_dm": 69,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 81,
"flags": 0
},
{
"ts": 1785000642,
"lat_e7": 525202080,
"lon_e7": 134050000,
"acc_dm": 70,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 80,
"flags": 0
},
{
"ts": 1785000672,
"lat_e7": 525202180,
"lon_e7": 134050000,
"acc_dm": 71,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 79,
"flags": 0
},
{
"ts": 1785000702,
"lat_e7": 525202280,
"lon_e7": 134050000,
"acc_dm": 72,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 78,
"flags": 0
},
{
"ts": 1785000732,
"lat_e7": 525202380,
"lon_e7": 134050000,
"acc_dm": 73,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 77,
"flags": 0
},
{
"ts": 1785000762,
"lat_e7": 525202480,
"lon_e7": 134050000,
"acc_dm": 74,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 76,
"flags": 0
},
{
"ts": 1785000792,
"lat_e7": 525202580,
"lon_e7": 134050000,
"acc_dm": 75,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 75,
"flags": 0
},
{
"ts": 1785000822,
"lat_e7": 525202680,
"lon_e7": 134050000,
"acc_dm": 76,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 74,
"flags": 0
},
{
"ts": 1785000852,
"lat_e7": 525202780,
"lon_e7": 134050000,
"acc_dm": 77,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 73,
"flags": 0
},
{
"ts": 1785000882,
"lat_e7": 525202880,
"lon_e7": 134050000,
"acc_dm": 78,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 72,
"flags": 0
},
{
"ts": 1785000912,
"lat_e7": 525202980,
"lon_e7": 134050000,
"acc_dm": 79,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 71,
"flags": 0
},
{
"ts": 1785000942,
"lat_e7": 525203080,
"lon_e7": 134050000,
"acc_dm": 80,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 70,
"flags": 0
},
{
"ts": 1785000972,
"lat_e7": 525203180,
"lon_e7": 134050000,
"acc_dm": 81,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 69,
"flags": 0
},
{
"ts": 1785001002,
"lat_e7": 525203280,
"lon_e7": 134050000,
"acc_dm": 82,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 68,
"flags": 0
},
{
"ts": 1785001032,
"lat_e7": 525203380,
"lon_e7": 134050000,
"acc_dm": 83,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 67,
"flags": 0
},
{
"ts": 1785001062,
"lat_e7": 525203480,
"lon_e7": 134050000,
"acc_dm": 84,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 66,
"flags": 0
},
{
"ts": 1785001092,
"lat_e7": 525203580,
"lon_e7": 134050000,
"acc_dm": 85,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 65,
"flags": 0
},
{
"ts": 1785001122,
"lat_e7": 525203680,
"lon_e7": 134050000,
"acc_dm": 86,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 64,
"flags": 0
},
{
"ts": 1785001152,
"lat_e7": 525203780,
"lon_e7": 134050000,
"acc_dm": 87,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 63,
"flags": 0
},
{
"ts": 1785001182,
"lat_e7": 525203880,
"lon_e7": 134050000,
"acc_dm": 88,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 62,
"flags": 0
},
{
"ts": 1785001212,
"lat_e7": 525203980,
"lon_e7": 134050000,
"acc_dm": 89,
"alt_m": null,
"spd_cms": null,
"brg_cdeg": null,
"bat_pct": 61,
"flags": 0
}
]
}
},
{
"name": "ack_single",
"direction": "down",
"key": "down",
"msg_type": 2,
"nonce_hex": "303132333435363738393a3b",
"header_hex": "120123456789abcdef303132333435363738393a3b",
"payload_hex": "01000102030405060708090a0b00",
"datagram_hex": "120123456789abcdef303132333435363738393a3b64c8a72ba8ab580a637fc66a7efeefd375f778c6db7ebcf42696f1bab284",
"datagram_len": 51,
"message": {
"type": "ack",
"value": {
"nonces": [
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
]
],
"flags": 0
}
}
},
{
"name": "ack_config_pending",
"direction": "down",
"key": "down",
"msg_type": 2,
"nonce_hex": "404142434445464748494a4b",
"header_hex": "120123456789abcdef404142434445464748494a4b",
"payload_hex": "02101112131415161718191a1b202122232425262728292a2b01",
"datagram_hex": "120123456789abcdef404142434445464748494a4b55ed5ad3b9272addf354b0ac530973a513f3804962d432e6937beba6da315c481aa13a8efef07f201775",
"datagram_len": 63,
"message": {
"type": "ack",
"value": {
"nonces": [
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
]
],
"flags": 1
}
}
},
{
"name": "ack_max_throttle",
"direction": "down",
"key": "down",
"msg_type": 2,
"nonce_hex": "505152535455565758595a5b",
"header_hex": "120123456789abcdef505152535455565758595a5b",
"payload_hex": "28000102030405060708090a0b101112131415161718191a1b202122232425262728292a2b303132333435363738393a3b404142434445464748494a4b505152535455565758595a5b606162636465666768696a6b707172737475767778797a7b808182838485868788898a8b909192939495969798999a9ba0a1a2a3a4a5a6a7a8a9aaabb0b1b2b3b4b5b6b7b8b9babbc0c1c2c3c4c5c6c7c8c9cacbd0d1d2d3d4d5d6d7d8d9dadbe0e1e2e3e4e5e6e7e8e9eaebf0f1f2f3f4f5f6f7f8f9fafb000102030405060708090a0b101112131415161718191a1b202122232425262728292a2b303132333435363738393a3b404142434445464748494a4b505152535455565758595a5b606162636465666768696a6b707172737475767778797a7b808182838485868788898a8b909192939495969798999a9ba0a1a2a3a4a5a6a7a8a9aaabb0b1b2b3b4b5b6b7b8b9babbc0c1c2c3c4c5c6c7c8c9cacbd0d1d2d3d4d5d6d7d8d9dadbe0e1e2e3e4e5e6e7e8e9eaebf0f1f2f3f4f5f6f7f8f9fafb000102030405060708090a0b101112131415161718191a1b202122232425262728292a2b303132333435363738393a3b404142434445464748494a4b505152535455565758595a5b606162636465666768696a6b707172737475767778797a7b01",
"datagram_hex": "120123456789abcdef505152535455565758595a5b046202da952cecd25faf23b82c58e1275cf883d0d2949d1c457dfd620ab46281d93e21ce7fb5dda4dfb24fa9eb7ae4ece8d81ef9b7d2bd2fe32a089bf98149b627c3c2a44e82a4717af026a3d369f0df612dbf65b391ebac81f2773f2c5204ca2eee5f68ec9719b0011cad9f4ad022318f9a6a804f11befc046382e56778cca51cd26b6e233b9c384cd8bb7702bd889665c02257cbe49a75225db87122056ca186ec5fd79f7face5e088696daa15b7e3f310d820b3f4281a3202374b13873291a71563dc5538fd583d8a96f0fe64184fc344fddd843f3cd000afb0493679464b9910955159f4a86a10f8b4240378eb80780052bcf608be178f290f49d45f0a14b190cbfa9625ed0a8f5fd50dca61e43c3fb5f200146a88eb92e1adefe7e7a484a8810bd79079ea4a18d93b7376ad0e09fdeb6b179c78219a28c0c6bbb7b2997e549c8428d80d85ff8eb336dbf22e1ca800d9870fbe5525d26e327166c39b40b1a5c836514ca2bab96c0f405813c2ab007b8ec4773881144aab916b92cc0c1cb4417a51ccf08c4750798f329a1ed0aa206ce518d5cafd89c5a7dd0244bdc5da988c7f2ec603267d636534782c102bd2fc444620e19ab9288354eb9b3b3d4d9aac1378310bcb9f1f5bbb9e94aa54c5e42aa215a578fc3fc07707f18a4f6c4205fb45bce83200a9c684379944bd87ab70c9f28a",
"datagram_len": 519,
"message": {
"type": "ack",
"value": {
"nonces": [
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
],
[
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
[
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75
],
[
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91
],
[
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107
],
[
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123
],
[
128,
129,
130,
131,
132,
133,
134,
135,
136,
137,
138,
139
],
[
144,
145,
146,
147,
148,
149,
150,
151,
152,
153,
154,
155
],
[
160,
161,
162,
163,
164,
165,
166,
167,
168,
169,
170,
171
],
[
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
186,
187
],
[
192,
193,
194,
195,
196,
197,
198,
199,
200,
201,
202,
203
],
[
208,
209,
210,
211,
212,
213,
214,
215,
216,
217,
218,
219
],
[
224,
225,
226,
227,
228,
229,
230,
231,
232,
233,
234,
235
],
[
240,
241,
242,
243,
244,
245,
246,
247,
248,
249,
250,
251
],
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
],
[
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
[
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75
],
[
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91
],
[
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107
],
[
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123
],
[
128,
129,
130,
131,
132,
133,
134,
135,
136,
137,
138,
139
],
[
144,
145,
146,
147,
148,
149,
150,
151,
152,
153,
154,
155
],
[
160,
161,
162,
163,
164,
165,
166,
167,
168,
169,
170,
171
],
[
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
186,
187
],
[
192,
193,
194,
195,
196,
197,
198,
199,
200,
201,
202,
203
],
[
208,
209,
210,
211,
212,
213,
214,
215,
216,
217,
218,
219
],
[
224,
225,
226,
227,
228,
229,
230,
231,
232,
233,
234,
235
],
[
240,
241,
242,
243,
244,
245,
246,
247,
248,
249,
250,
251
],
[
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11
],
[
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27
],
[
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43
],
[
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
[
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75
],
[
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91
],
[
96,
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107
],
[
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123
]
],
"flags": 1
}
}
},
{
"name": "hello",
"direction": "up",
"key": "up",
"msg_type": 4,
"nonce_hex": "606162636465666768696a6b",
"header_hex": "140123456789abcdef606162636465666768696a6b",
"payload_hex": "001122010001",
"datagram_hex": "140123456789abcdef606162636465666768696a6be793556613e45f7ecd7893ff805bbb0f636315b3742b",
"datagram_len": 43,
"message": {
"type": "hello",
"value": {
"app_version_code": 17,
"os_api_level": 34,
"flags": 1,
"config_version": 1
}
}
},
{
"name": "config_balanced",
"direction": "down",
"key": "down",
"msg_type": 5,
"nonce_hex": "707172737475767778797a7b",
"header_hex": "150123456789abcdef707172737475767778797a7b",
"payload_hex": "000101010384006400142800",
"datagram_hex": "150123456789abcdef707172737475767778797a7b92ad85802373b275dc95893e3885ace8290635fd02fbd2e60be71769",
"datagram_len": 49,
"message": {
"type": "config",
"value": {
"config_version": 1,
"profile": "balanced",
"flags": 1,
"heartbeat_s": 900,
"interval_scale_pct": 100,
"min_distance_m": 20,
"max_points_per_loc": 40
}
}
},
{
"name": "config_battery_saver_paused",
"direction": "down",
"key": "down",
"msg_type": 5,
"nonce_hex": "808182838485868788898a8b",
"header_hex": "150123456789abcdef808182838485868788898a8b",
"payload_hex": "00090002070800fa00641400",
"datagram_hex": "150123456789abcdef808182838485868788898a8b274ce3908a6f175d4062e2bc70204fcbf3f08c8bab6c2bc004865402",
"datagram_len": 49,
"message": {
"type": "config",
"value": {
"config_version": 9,
"profile": "battery_saver",
"flags": 2,
"heartbeat_s": 1800,
"interval_scale_pct": 250,
"min_distance_m": 100,
"max_points_per_loc": 20
}
}
},
{
"name": "config_high_accuracy",
"direction": "down",
"key": "down",
"msg_type": 5,
"nonce_hex": "909192939495969798999a9b",
"header_hex": "150123456789abcdef909192939495969798999a9b",
"payload_hex": "0002020102580032000a2800",
"datagram_hex": "150123456789abcdef909192939495969798999a9bf45dadac47c25bffd827a323e33fcd8086832232daf04e9fb3f658c4",
"datagram_len": 49,
"message": {
"type": "config",
"value": {
"config_version": 2,
"profile": "high_accuracy",
"flags": 1,
"heartbeat_s": 600,
"interval_scale_pct": 50,
"min_distance_m": 10,
"max_points_per_loc": 40
}
}
},
{
"name": "config_get",
"direction": "up",
"key": "up",
"msg_type": 6,
"nonce_hex": "a0a1a2a3a4a5a6a7a8a9aaab",
"header_hex": "160123456789abcdefa0a1a2a3a4a5a6a7a8a9aaab",
"payload_hex": "0001",
"datagram_hex": "160123456789abcdefa0a1a2a3a4a5a6a7a8a9aaab1ddf4edda37226b349f0cf2f15d08f8d496b",
"datagram_len": 39,
"message": {
"type": "config_get",
"value": {
"have_version": 1
}
}
},
{
"name": "ping",
"direction": "up",
"key": "up",
"msg_type": 7,
"nonce_hex": "b0b1b2b3b4b5b6b7b8b9babb",
"header_hex": "170123456789abcdefb0b1b2b3b4b5b6b7b8b9babb",
"payload_hex": "deadbeef0007",
"datagram_hex": "170123456789abcdefb0b1b2b3b4b5b6b7b8b9babbaaa9eb40514d112664d40269b4d168dbbd77ae9957ce",
"datagram_len": 43,
"message": {
"type": "ping",
"value": {
"echo": 3735928559,
"seq": 7
}
}
},
{
"name": "pong",
"direction": "down",
"key": "down",
"msg_type": 8,
"nonce_hex": "c0c1c2c3c4c5c6c7c8c9cacb",
"header_hex": "180123456789abcdefc0c1c2c3c4c5c6c7c8c9cacb",
"payload_hex": "deadbeef0007",
"datagram_hex": "180123456789abcdefc0c1c2c3c4c5c6c7c8c9cacb7d075ab6f083e35ebf55ef3b44d3430a6806381d7a4b",
"datagram_len": 43,
"message": {
"type": "pong",
"value": {
"echo": 3735928559,
"seq": 7
}
}
},
{
"name": "revoked_explicit",
"direction": "down",
"key": "rev",
"msg_type": 9,
"nonce_hex": "d0d1d2d3d4d5d6d7d8d9dadb",
"header_hex": "190123456789abcdefd0d1d2d3d4d5d6d7d8d9dadb",
"payload_hex": "01",
"datagram_hex": "190123456789abcdefd0d1d2d3d4d5d6d7d8d9dadb6998f929776ef06b618c34357aca9de47e",
"datagram_len": 38,
"message": {
"type": "revoked",
"value": {
"reason": "revoked"
}
}
},
{
"name": "revoked_expired",
"direction": "down",
"key": "rev",
"msg_type": 9,
"nonce_hex": "e0e1e2e3e4e5e6e7e8e9eaeb",
"header_hex": "190123456789abcdefe0e1e2e3e4e5e6e7e8e9eaeb",
"payload_hex": "02",
"datagram_hex": "190123456789abcdefe0e1e2e3e4e5e6e7e8e9eaeb77f758cde484776dada217a72eb9be3e18",
"datagram_len": 38,
"message": {
"type": "revoked",
"value": {
"reason": "expired"
}
}
},
{
"name": "revoked_unknown",
"direction": "down",
"key": "rev",
"msg_type": 9,
"nonce_hex": "f0f1f2f3f4f5f6f7f8f9fafb",
"header_hex": "190123456789abcdeff0f1f2f3f4f5f6f7f8f9fafb",
"payload_hex": "03",
"datagram_hex": "190123456789abcdeff0f1f2f3f4f5f6f7f8f9fafbb48f9c80666e141ad8a1d9637cf79d02a4",
"datagram_len": 38,
"message": {
"type": "revoked",
"value": {
"reason": "unknown"
}
}
},
{
"name": "nack_unknown_token",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "000102030405060708090a0b",
"header_hex": "130123456789abcdef000102030405060708090a0b",
"payload_hex": "303132333435363738393a3b0100",
"datagram_hex": "130123456789abcdef000102030405060708090a0b3bb067de27a9d0ff1932ef2884bdb3cf8c68509bd338566335fa9a3150ae",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "unknown_token",
"retry_after_s": 0
}
}
},
{
"name": "nack_malformed",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "101112131415161718191a1b",
"header_hex": "130123456789abcdef101112131415161718191a1b",
"payload_hex": "303132333435363738393a3b0200",
"datagram_hex": "130123456789abcdef101112131415161718191a1b4183e084ad9a89fb168b345b2c86072059c685823f6d60624c17d2d2d8ba",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "malformed",
"retry_after_s": 0
}
}
},
{
"name": "nack_rate_limited",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "202122232425262728292a2b",
"header_hex": "130123456789abcdef202122232425262728292a2b",
"payload_hex": "303132333435363738393a3b031e",
"datagram_hex": "130123456789abcdef202122232425262728292a2b11a89f6b98d57c79682ff412abc9a549099aef7092d8e7e3913f37cb2117",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "rate_limited",
"retry_after_s": 30
}
}
},
{
"name": "nack_storage_full",
"direction": "down",
"key": "down",
"msg_type": 3,
"nonce_hex": "303132333435363738393a3b",
"header_hex": "130123456789abcdef303132333435363738393a3b",
"payload_hex": "303132333435363738393a3b04ff",
"datagram_hex": "130123456789abcdef303132333435363738393a3b55f9941a9f9a6b3b5c4ef55b71012fd33f014bc0c5fc8ad0898acb6209dd",
"datagram_len": 51,
"message": {
"type": "nack",
"value": {
"nonce": [
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59
],
"reason": "storage_full",
"retry_after_s": 255
}
}
}
]
}
Acrates/otproto/tests/vectors.rs
@@ -0,0 +1,386 @@
//! Verifies the committed golden vectors against a freshly built codec.
//!
//! This test deliberately reads `vectors.json` as untyped JSON and rebuilds each
//! message field by field, rather than deserializing straight into
//! [`otproto::Message`]. Two reasons:
//!
//! 1. It runs without the `serde` feature, so `cargo test` covers it by default.
//! 2. It is the same exercise the Kotlin test performs, so this file doubles as
//! the reference for that implementation. A shared `Deserialize` impl would
//! let a renamed field pass here and fail on the phone.
use std::collections::BTreeSet;
use otproto::msg::Direction;
use otproto::point::Flags;
use otproto::{
Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, Message, MsgType,
Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf,
};
use serde_json::Value;
const VECTORS: &str = include_str!("vectors.json");
fn load() -> Value {
serde_json::from_str(VECTORS).expect("vectors.json is valid JSON")
}
fn hex(v: &Value, key: &str) -> Vec<u8> {
hex_str(
v[key]
.as_str()
.unwrap_or_else(|| panic!("{key} is not a string")),
)
}
fn hex_str(s: &str) -> Vec<u8> {
assert!(s.len().is_multiple_of(2), "odd-length hex string {s:?}");
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
.collect()
}
fn u32f(v: &Value, key: &str) -> u32 {
v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u32
}
fn u16f(v: &Value, key: &str) -> u16 {
v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u16
}
fn u8f(v: &Value, key: &str) -> u8 {
v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u8
}
fn opt<T, F: Fn(u64) -> T>(v: &Value, key: &str, f: F) -> Option<T> {
match &v[key] {
Value::Null => None,
other => Some(f(other.as_u64().unwrap_or_else(|| {
// Negative values (altitude) arrive as i64.
other.as_i64().expect("numeric") as u64
}))),
}
}
fn point_from_json(v: &Value) -> Point {
Point {
ts: u32f(v, "ts"),
lat_e7: v["lat_e7"].as_i64().expect("lat_e7") as i32,
lon_e7: v["lon_e7"].as_i64().expect("lon_e7") as i32,
acc_dm: opt(v, "acc_dm", |n| n as u16),
alt_m: match &v["alt_m"] {
Value::Null => None,
other => Some(other.as_i64().expect("alt_m") as i16),
},
spd_cms: opt(v, "spd_cms", |n| n as u16),
brg_cdeg: opt(v, "brg_cdeg", |n| n as u16),
bat_pct: opt(v, "bat_pct", |n| n as u8),
flags: Flags(u8f(v, "flags")),
}
}
fn nonce_from_hex(s: &str) -> Nonce {
hex_str(s).try_into().expect("12-byte nonce")
}
fn nonces_from_json(v: &Value) -> Vec<Nonce> {
v.as_array()
.expect("nonces array")
.iter()
.map(|n| {
let bytes: Vec<u8> = n
.as_array()
.expect("nonce is an array of bytes")
.iter()
.map(|b| b.as_u64().expect("byte") as u8)
.collect();
bytes.try_into().expect("12-byte nonce")
})
.collect()
}
fn message_from_json(v: &Value) -> Message {
let ty = v["type"].as_str().expect("message type");
let val = &v["value"];
match ty {
"loc" => Message::Loc(
val.as_array()
.expect("points")
.iter()
.map(point_from_json)
.collect(),
),
"ack" => Message::Ack(Ack {
nonces: nonces_from_json(&val["nonces"]),
flags: AckFlags(u8f(val, "flags")),
}),
"nack" => Message::Nack(Nack {
nonce: {
let bytes: Vec<u8> = val["nonce"]
.as_array()
.expect("nonce bytes")
.iter()
.map(|b| b.as_u64().expect("byte") as u8)
.collect();
bytes.try_into().expect("12-byte nonce")
},
reason: match val["reason"].as_str().expect("reason") {
"unknown_token" => NackReason::UnknownToken,
"malformed" => NackReason::Malformed,
"rate_limited" => NackReason::RateLimited,
"storage_full" => NackReason::StorageFull,
other => panic!("unknown NACK reason {other:?}"),
},
retry_after_s: u8f(val, "retry_after_s"),
}),
"hello" => Message::Hello(Hello {
app_version_code: u16f(val, "app_version_code"),
os_api_level: u8f(val, "os_api_level"),
flags: HelloFlags(u8f(val, "flags")),
config_version: u16f(val, "config_version"),
}),
"config" => Message::Config(Config {
config_version: u16f(val, "config_version"),
profile: match val["profile"].as_str().expect("profile") {
"battery_saver" => Profile::BatterySaver,
"balanced" => Profile::Balanced,
"high_accuracy" => Profile::HighAccuracy,
other => panic!("unknown profile {other:?}"),
},
flags: ConfigFlags(u8f(val, "flags")),
heartbeat_s: u16f(val, "heartbeat_s"),
interval_scale_pct: u16f(val, "interval_scale_pct"),
min_distance_m: u16f(val, "min_distance_m"),
max_points_per_loc: u8f(val, "max_points_per_loc"),
}),
"config_get" => Message::ConfigGet(ConfigGet {
have_version: u16f(val, "have_version"),
}),
"ping" => Message::Ping(Ping {
echo: u32f(val, "echo"),
seq: u16f(val, "seq"),
}),
"pong" => Message::Pong(Pong {
echo: u32f(val, "echo"),
seq: u16f(val, "seq"),
}),
"revoked" => Message::Revoked(Revoked {
reason: match val["reason"].as_str().expect("reason") {
"revoked" => RevokeReason::Revoked,
"expired" => RevokeReason::Expired,
"unknown" => RevokeReason::Unknown,
other => panic!("unknown revoke reason {other:?}"),
},
}),
other => panic!("unknown message type {other:?}"),
}
}
#[test]
fn file_level_constants_match_this_build() {
let v = load();
assert_eq!(v["protocol"], "OTP/1");
assert_eq!(v["version"].as_u64(), Some(u64::from(otproto::VERSION)));
assert_eq!(v["header_len"].as_u64(), Some(otproto::HEADER_LEN as u64));
assert_eq!(v["tag_len"].as_u64(), Some(otproto::TAG_LEN as u64));
assert_eq!(
v["max_datagram"].as_u64(),
Some(otproto::MAX_DATAGRAM as u64)
);
assert_eq!(v["max_points"].as_u64(), Some(otproto::MAX_POINTS as u64));
}
#[test]
fn key_derivation_matches_the_vectors() {
let v = load();
let token_key: [u8; 32] = hex(&v, "token_key_hex")
.try_into()
.expect("32-byte token key");
let (up, down) = kdf::derive_both(&token_key);
assert_eq!(hex(&v, "k_up_hex"), up, "K_up drifted");
assert_eq!(hex(&v, "k_down_hex"), down, "K_down drifted");
assert_ne!(up, down);
let master: [u8; 32] = hex(&v, "revocation_master_hex")
.try_into()
.expect("32-byte master");
let token_id = v["token_id"].as_u64().expect("token_id");
let rev = otproto::revocation_key(&master, token_id);
assert_eq!(hex(&v, "k_rev_hex"), rev, "K_rev drifted");
// Independent of the token key, which is the property that lets a REVOKED
// notice outlive the token's row.
assert_ne!(rev, up);
assert_ne!(rev, down);
assert_ne!(rev, token_key);
}
#[test]
fn point_records_encode_exactly_as_recorded() {
let v = load();
let points = v["points"].as_array().expect("points array");
assert!(!points.is_empty());
for case in points {
let name = case["name"].as_str().expect("name");
let expected = hex(case, "bytes_hex");
assert_eq!(
expected.len(),
otproto::POINT_LEN,
"{name}: wrong record length"
);
let point = point_from_json(&case["point"]);
assert_eq!(
point.to_bytes().as_slice(),
expected.as_slice(),
"{name}: encode drifted"
);
let decoded = Point::from_bytes(expected.as_slice().try_into().expect("length checked"));
assert_eq!(decoded, point, "{name}: decode drifted");
}
}
#[test]
fn every_datagram_vector_reproduces_byte_for_byte() {
let v = load();
let token_key: [u8; 32] = hex(&v, "token_key_hex")
.try_into()
.expect("32-byte token key");
let token_id = v["token_id"].as_u64().expect("token_id");
let (k_up, k_down) = kdf::derive_both(&token_key);
let master: [u8; 32] = hex(&v, "revocation_master_hex")
.try_into()
.expect("32-byte master");
let k_rev = otproto::revocation_key(&master, token_id);
let cases = v["datagrams"].as_array().expect("datagrams array");
assert!(cases.len() >= 9, "vectors must cover every message type");
let mut covered = BTreeSet::new();
for case in cases {
let name = case["name"].as_str().expect("name");
let message = message_from_json(&case["message"]);
let ty = message.msg_type();
covered.insert(ty as u8);
// The recorded type, direction and key must agree with what this build
// derives from the message itself.
assert_eq!(
u8f(case, "msg_type"),
ty as u8,
"{name}: msg_type disagrees"
);
let dir = ty.direction();
assert_eq!(
case["direction"].as_str(),
Some(match dir {
Direction::Up => "up",
Direction::Down => "down",
}),
"{name}: direction disagrees"
);
// Which key seals this datagram is recorded explicitly, because it is
// not implied by the direction: REVOKED travels downlink but is sealed
// under K_rev so it survives the token's row being deleted.
let (key, key_name) = match ty {
MsgType::Revoked => (&k_rev, "rev"),
_ => match dir {
Direction::Up => (&k_up, "up"),
Direction::Down => (&k_down, "down"),
},
};
assert_eq!(
case["key"].as_str(),
Some(key_name),
"{name}: sealing key disagrees"
);
let nonce = nonce_from_hex(case["nonce_hex"].as_str().expect("nonce_hex"));
let header = Header::new(ty, token_id, nonce);
assert_eq!(
header.to_bytes().as_slice(),
hex(case, "header_hex"),
"{name}: header drifted"
);
let payload = message.encode_payload();
assert_eq!(payload, hex(case, "payload_hex"), "{name}: payload drifted");
let datagram = otproto::seal(key, header, &payload);
assert_eq!(
datagram,
hex(case, "datagram_hex"),
"{name}: datagram drifted"
);
assert_eq!(
datagram.len(),
case["datagram_len"].as_u64().expect("datagram_len") as usize,
"{name}: recorded length is wrong"
);
assert!(
datagram.len() <= otproto::MAX_DATAGRAM,
"{name}: exceeds the datagram budget"
);
// And the other direction: the recorded bytes must open to the recorded
// message. Encoding agreeing with itself would not prove that.
let (h, back) =
otproto::open_message(key, &datagram).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(h, header, "{name}: header did not survive a round trip");
assert_eq!(
back, message,
"{name}: message did not survive a round trip"
);
}
let all: BTreeSet<u8> = MsgType::ALL.iter().map(|t| *t as u8).collect();
assert_eq!(covered, all, "some message type has no golden vector");
}
#[test]
fn vectors_only_open_under_the_key_that_sealed_them() {
let v = load();
let token_key: [u8; 32] = hex(&v, "token_key_hex")
.try_into()
.expect("32-byte token key");
let token_id = v["token_id"].as_u64().expect("token_id");
let (k_up, k_down) = kdf::derive_both(&token_key);
let master: [u8; 32] = hex(&v, "revocation_master_hex")
.try_into()
.expect("32-byte master");
let k_rev = otproto::revocation_key(&master, token_id);
for case in v["datagrams"].as_array().expect("datagrams") {
let name = case["name"].as_str().expect("name");
let datagram = hex(case, "datagram_hex");
let sealed_with = case["key"].as_str().expect("key");
// Every key except the right one must fail. Uplink versus downlink is
// the classic reflection guard; K_rev matters for a different reason —
// if a REVOKED opened under K_down, a device could be told it was
// revoked by anyone holding the token key.
for (name_of, key) in [("up", &k_up), ("down", &k_down), ("rev", &k_rev)] {
if name_of == sealed_with {
continue;
}
assert!(
otproto::open(key, &datagram).is_err(),
"{name}: opened under K_{name_of}, which did not seal it"
);
}
}
// A revocation notice for another token must not open here either: K_rev is
// per-id precisely so one device cannot forge one for another.
let other = otproto::revocation_key(&master, token_id ^ 1);
for case in v["datagrams"].as_array().expect("datagrams") {
if case["key"] != "rev" {
continue;
}
let datagram = hex(case, "datagram_hex");
assert!(
otproto::open(&other, &datagram).is_err(),
"a REVOKED opened under another token's K_rev"
);
}
}
Acrates/otserver/Cargo.toml
@@ -0,0 +1,38 @@
[package]
name = "otserver"
version.workspace = true
edition.workspace = true
[dependencies]
anyhow = "1"
argon2 = "0.5"
axum = "0.8"
base64 = "0.22"
chacha20poly1305 = "0.10"
dashmap = "6"
governor = "0.8"
hex.workspace = true
hkdf = "0.12"
mime_guess = "2"
otproto = { version = "0.1.0", path = "../otproto", features = ["serde"] }
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "http2", "json"] }
rust-embed = "8"
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2 = "0.10"
socket2 = "0.6"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate", "macros"] }
thiserror.workspace = true
time = { version = "0.3", features = ["formatting"] }
tokio = { version = "1", features = ["full"] }
toml = "1.1.3"
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header"] }
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tempfile = "3.27.0"
Acrates/otserver/migrations/0001_init.sql
@@ -0,0 +1,180 @@
-- opentracker initial schema.
--
-- Every table is STRICT: SQLite's default type affinity would happily store the
-- string 'north' in an INTEGER latitude column, and this database is written to
-- by a hot path that must never be the place a type error is discovered.
--
-- The central decision here: **the position stream belongs to the account.** A
-- token is a credential that authorises writing into its owner's stream. It is
-- never an identity — it does not appear in a point's key, in a share, or on the
-- map. One person is one dot, regardless of which pocket the phone is in.
--
-- Coordinates are integers (degrees × 1e7) end to end — protocol, database, JSON,
-- UI — so there is no float-formatting drift anywhere in the system.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
-- NOCASE so 'Marc' and 'marc' are the same account, which is what users
-- assume, and which closes a whole class of impersonation confusion.
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
-- argon2id PHC string; carries its own parameters so the policy can change
-- without invalidating existing hashes.
pw_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
-- Set rather than deleting the row: points and shares reference it.
disabled_at INTEGER,
created_at INTEGER NOT NULL,
-- Sessions and tokens issued before this are treated as revoked, which is
-- how "changing my password logs everything else out" is enforced without
-- having to enumerate them.
pw_changed_at INTEGER NOT NULL
) STRICT;
-- One row per login. Purely a credential plus per-phone telemetry.
CREATE TABLE tokens (
-- The u64 that travels in every datagram header. Random, not sequential:
-- guessing one must not be easier than guessing a key.
token_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- The 32-byte token secret, wrapped with the server key from OT_SECRET_KEY
-- (AAD = token_id), so a stolen .db alone yields no working keys.
key_wrapped BLOB NOT NULL,
name TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
app_version INTEGER,
os_api_level INTEGER,
config_version INTEGER NOT NULL DEFAULT 1,
config_json TEXT NOT NULL DEFAULT '{}',
last_seen_at INTEGER,
last_src_ip TEXT,
last_src_port INTEGER,
last_transport TEXT,
created_at INTEGER NOT NULL,
created_ip TEXT,
revoked_at INTEGER
) STRICT;
CREATE INDEX tokens_user ON tokens(user_id);
-- Drives the 30-day staleness sweep.
CREATE INDEX tokens_last_seen ON tokens(last_seen_at);
CREATE TABLE points (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Unix seconds from the client's wall clock, stored as sent.
ts INTEGER NOT NULL,
lat INTEGER NOT NULL,
lon INTEGER NOT NULL,
acc_dm INTEGER,
alt_m INTEGER,
spd_cms INTEGER,
brg_cdeg INTEGER,
bat_pct INTEGER,
flags INTEGER NOT NULL DEFAULT 0,
recv_at INTEGER NOT NULL,
-- Provenance only: nullable, never in a key, never read by the UI. It exists
-- so "which phone sent this?" is answerable while debugging. Deliberately
-- NOT a foreign key with CASCADE — a token being deleted by the staleness
-- sweep must not take history with it.
src_token_id INTEGER,
-- The dedup that makes retries and replays both harmless. A replayed
-- datagram carries a ts that already exists and collapses into the row
-- already there, which is why this schema needs no replay window.
PRIMARY KEY (user_id, ts)
) STRICT, WITHOUT ROWID;
-- For the retention sweep, which scans by age across all users.
CREATE INDEX points_ts ON points(ts);
-- Current position per *account*, in its own table so the retention GC can never
-- delete the live marker.
CREATE TABLE user_latest (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
lat INTEGER NOT NULL,
lon INTEGER NOT NULL,
acc_dm INTEGER,
alt_m INTEGER,
spd_cms INTEGER,
brg_cdeg INTEGER,
bat_pct INTEGER,
flags INTEGER NOT NULL DEFAULT 0,
recv_at INTEGER NOT NULL,
src_token_id INTEGER
) STRICT;
CREATE TABLE groups (
id INTEGER PRIMARY KEY,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (owner_user_id, name)
) STRICT;
CREATE TABLE group_members (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_at INTEGER NOT NULL,
PRIMARY KEY (group_id, user_id)
) STRICT, WITHOUT ROWID;
-- You share *yourself*, not a phone: there is no per-device dimension here.
CREATE TABLE shares (
id INTEGER PRIMARY KEY,
owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Exactly one of these two.
viewer_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
viewer_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
trail_visible INTEGER NOT NULL DEFAULT 1 CHECK (trail_visible IN (0, 1)),
-- Metres to round the position to before showing it. 0 = exact.
precision_m INTEGER NOT NULL DEFAULT 0,
-- NULL means no expiry. Enforced at query time, never by a background job,
-- so a stalled job can never leak a position.
expires_at INTEGER,
revoked_at INTEGER,
created_at INTEGER NOT NULL,
CHECK ((viewer_user_id IS NULL) <> (viewer_group_id IS NULL)),
CHECK (owner_user_id <> viewer_user_id)
) STRICT;
CREATE INDEX shares_owner ON shares(owner_user_id);
CREATE INDEX shares_viewer_user ON shares(viewer_user_id);
CREATE INDEX shares_viewer_group ON shares(viewer_group_id);
-- OSM tile proxy cache metadata. The bytes live on the filesystem at
-- {cache_dir}/tiles/{z}/{x}/{y}.png; this table is the index and the accounting
-- that makes max_cache_bytes enforceable.
CREATE TABLE tiles (
z INTEGER NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
etag TEXT,
last_modified TEXT,
fetched_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
bytes INTEGER NOT NULL,
last_access INTEGER NOT NULL,
PRIMARY KEY (z, x, y)
) STRICT, WITHOUT ROWID;
-- Drives least-recently-used eviction.
CREATE INDEX tiles_last_access ON tiles(last_access);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
-- Nullable: failed logins have no authenticated user yet.
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
src_ip TEXT
) STRICT;
CREATE INDEX audit_log_at ON audit_log(at);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT, WITHOUT ROWID;
Acrates/otserver/src/api.rs
@@ -0,0 +1,1129 @@
//! The HTTP API.
//!
//! There is **no WebSocket and no fan-out hub**. The web UI polls
//! `GET /api/state` every 5 s while its tab is visible, and that endpoint returns
//! an `ETag` so an unchanged poll is a 304 with no body. For a handful of users
//! that costs less than a broadcast hub, per-connection filter tasks, and
//! lag/resync handling would — and it cannot desynchronise, because there is no
//! second copy of the state to drift.
//!
//! Visibility is resolved in exactly one place, [`visible_user_ids`], used by
//! every read path. Share expiry is enforced there, at query time, never by a
//! background job: a stalled job must not be able to leak a position.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use axum::extract::{ConnectInfo, Path, Query, Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use tower_sessions::Session;
use tracing::warn;
use crate::auth::{self, AuthError, LoginThrottle};
use crate::config::Config;
use crate::db::{Db, now};
use crate::ingest::Ingest;
use crate::keys::KeyVault;
use crate::writer::{WriteHandle, WriteOp};
/// Session key holding the authenticated user id.
const SESSION_USER: &str = "uid";
/// Required on every state-changing request.
///
/// The value is never read. A browser cannot set a custom header on a
/// cross-origin request without a CORS preflight, and this server grants no CORS
/// at all, so its mere presence proves the request came from our own page.
/// Together with `SameSite=Lax` on the session cookie that is the entire CSRF
/// defence: no token to mint, store, rotate, or leak into a log.
const CSRF_HEADER: &str = "x-ot-csrf";
/// Session key holding the moment the session was created, compared against
/// `users.pw_changed_at` so a password change invalidates older sessions without
/// having to enumerate them.
const SESSION_ISSUED: &str = "iat";
pub struct AppState {
pub db: Db,
pub cfg: Config,
pub vault: KeyVault,
pub ingest: Arc<Ingest>,
pub writer: WriteHandle,
/// Shared with the periodic GC task, which sweeps its expired windows.
pub throttle: Arc<LoginThrottle>,
}
pub type Shared = Arc<AppState>;
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub enum ApiError {
Unauthorized,
Forbidden,
NotFound,
BadRequest(String),
TooManyRequests(u64),
/// Anything unexpected. The detail is logged, never returned: an internal
/// error message is a free source of schema and path information.
Internal(anyhow::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "not signed in".to_string()),
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_string()),
Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
Self::TooManyRequests(retry) => (
StatusCode::TOO_MANY_REQUESTS,
format!("too many attempts; try again in {retry}s"),
),
Self::Internal(e) => {
warn!(error = ?e, "internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(ErrorBody { error: message })).into_response()
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
Self::Internal(e)
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
Self::Internal(e.into())
}
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
type ApiResult<T> = Result<T, ApiError>;
// ---------------------------------------------------------------------------
// Session helpers
// ---------------------------------------------------------------------------
/// The authenticated user, or [`ApiError::Unauthorized`].
///
/// Also checks the session against `pw_changed_at`, which is how a password
/// change logs out every other browser without keeping a revocation list.
async fn current_user(state: &Shared, session: &Session) -> ApiResult<i64> {
let uid: i64 = session
.get(SESSION_USER)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session store: {e}")))?
.ok_or(ApiError::Unauthorized)?;
let issued: i64 = session
.get(SESSION_ISSUED)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session store: {e}")))?
.unwrap_or(0);
let row: Option<(i64, Option<i64>)> =
sqlx::query_as("SELECT pw_changed_at, disabled_at FROM users WHERE id = ?")
.bind(uid)
.fetch_optional(&state.db.read)
.await?;
let Some((pw_changed_at, disabled_at)) = row else {
let _ = session.flush().await;
return Err(ApiError::Unauthorized);
};
if disabled_at.is_some() || issued < pw_changed_at {
let _ = session.flush().await;
return Err(ApiError::Unauthorized);
}
Ok(uid)
}
async fn require_admin(state: &Shared, session: &Session) -> ApiResult<i64> {
let uid = current_user(state, session).await?;
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&state.db.read)
.await?;
if is_admin == 0 {
return Err(ApiError::Forbidden);
}
Ok(uid)
}
/// The set of `user_id`s `viewer` may see: themselves, plus anyone reachable
/// through a live share.
///
/// One helper, used by every read path. Per-account positions make this
/// noticeably simpler than a per-device model would: there is no
/// `device_id IS NULL` "all my devices" special case in the join.
async fn visible_user_ids(pool: &SqlitePool, viewer: i64) -> ApiResult<Vec<i64>> {
let now = now();
let ids: Vec<i64> = sqlx::query_scalar(
"SELECT ? AS user_id \
UNION \
SELECT s.owner_user_id FROM shares s \
WHERE s.revoked_at IS NULL \
AND (s.expires_at IS NULL OR s.expires_at > ?) \
AND ( s.viewer_user_id = ? \
OR s.viewer_group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) )",
)
.bind(viewer)
.bind(now)
.bind(viewer)
.bind(viewer)
.fetch_all(pool)
.await?;
Ok(ids)
}
// ---------------------------------------------------------------------------
// Payloads
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
/// `"browser"` (default) or `"device"`. A device login additionally mints an
/// OTP/1 token.
#[serde(default)]
pub purpose: Purpose,
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub platform: Option<String>,
}
#[derive(Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Purpose {
#[default]
Browser,
Device,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub user: Me,
/// Present only for a device login.
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<DeviceCredentials>,
}
/// Everything a phone needs, returned exactly once.
#[derive(Serialize)]
pub struct DeviceCredentials {
pub token_id: u64,
/// base64 of 32 bytes. The only time the server emits this in the clear.
pub token_key: String,
/// base64 of 32 bytes: the key that seals a `REVOKED` notice for this token.
///
/// Separate from `token_key` because it must outlive it. `K_up` and `K_down`
/// derive from the token key and die with the token's row; this one is
/// derived from a server master and the `token_id`, so the server can still
/// speak to a device whose row is gone.
///
/// It has to be issued at login and cannot be retrofitted: a device that
/// never received one can never verify a notice, and the thing that would
/// prompt it to log in again is exactly that notice.
pub revoke_key: String,
pub udp_host: String,
pub udp_port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls_url: Option<String>,
pub config: DeviceConfig,
}
#[derive(Serialize)]
pub struct DeviceConfig {
pub config_version: u16,
pub profile: &'static str,
}
#[derive(Serialize)]
pub struct Me {
pub id: i64,
pub username: String,
pub display_name: String,
pub is_admin: bool,
pub server_time: i64,
}
#[derive(Serialize)]
pub struct PersonState {
pub user_id: i64,
pub display_name: String,
/// True for the viewer's own entry.
pub is_self: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<Position>,
}
#[derive(Serialize)]
pub struct Position {
pub ts: i64,
/// Degrees × 1e7. Integers end to end, so there is no float-formatting drift
/// between the wire, the database, this JSON, and the map.
pub lat_e7: i64,
pub lon_e7: i64,
pub acc_dm: Option<i64>,
pub alt_m: Option<i64>,
pub spd_cms: Option<i64>,
pub brg_cdeg: Option<i64>,
pub bat_pct: Option<i64>,
pub flags: i64,
pub recv_at: i64,
}
#[derive(Serialize)]
pub struct StateResponse {
pub server_time: i64,
pub people: Vec<PersonState>,
}
#[derive(Serialize)]
pub struct TokenInfo {
/// A decimal string, not a number. `token_id` is a full 64-bit random value
/// and JavaScript's `number` is exact only to 2^53, so a JSON number would
/// silently round — and a token id that does not round-trip cannot be
/// revoked. The phone's `DeviceCredentials` keeps the numeric form because
/// its parser has real 64-bit integers.
pub token_id: String,
pub name: String,
pub platform: String,
pub app_version: Option<i64>,
pub os_api_level: Option<i64>,
pub last_seen_at: Option<i64>,
pub last_src_ip: Option<String>,
pub last_transport: Option<String>,
pub created_at: i64,
}
#[derive(Deserialize)]
pub struct TrackQuery {
pub from: Option<i64>,
pub to: Option<i64>,
#[serde(default = "default_max")]
pub max: usize,
}
fn default_max() -> usize {
2000
}
#[derive(Serialize)]
pub struct TrackResponse {
pub user_id: i64,
pub from: i64,
pub to: i64,
/// Google-style encoded polyline at 1e5 precision.
pub polyline: String,
pub point_count: usize,
}
#[derive(Deserialize)]
pub struct PasswordChange {
pub current_password: String,
pub new_password: String,
}
#[derive(Deserialize)]
pub struct CreateUser {
pub username: String,
pub password: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub is_admin: bool,
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
/// Rejects a state-changing request that did not come from our own page.
async fn require_csrf(req: Request, next: Next) -> Response {
if req.method().is_safe() || req.headers().contains_key(CSRF_HEADER) {
return next.run(req).await;
}
(
StatusCode::FORBIDDEN,
Json(ErrorBody {
error: format!("missing {CSRF_HEADER} header"),
}),
)
.into_response()
}
pub fn router(state: Shared) -> Router {
Router::new()
.route("/api/login", post(login))
.route("/api/logout", post(logout))
.route("/api/me", get(me))
.route("/api/me/password", post(change_password))
.route("/api/state", get(state_handler))
.route("/api/tokens", get(list_tokens))
.route("/api/tokens/{token_id}", delete(revoke_one_token))
.route("/api/tokens/revoke-others", post(revoke_others))
.route("/api/users/{user_id}/track", get(track))
.route("/api/users", post(create_user))
// Only the /api routes above; `route_layer` runs nothing when no route
// matches, so the static fallback below is untouched.
.route_layer(middleware::from_fn(require_csrf))
.route("/healthz", get(healthz))
.route("/metrics", get(metrics))
.with_state(state)
// Anything else is the web UI, including deep links it routes itself.
.fallback(crate::web::serve)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn healthz(State(state): State<Shared>) -> ApiResult<Json<serde_json::Value>> {
// A real query, not a constant: "healthy" has to mean the database answers.
let _: i64 = sqlx::query_scalar("SELECT 1")
.fetch_one(&state.db.read)
.await?;
Ok(Json(serde_json::json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"server_time": now(),
"tokens_loaded": state.ingest.active_token_count(),
})))
}
/// Aggregate counters, in Prometheus text format.
///
/// Bound to loopback callers only: these numbers say how much abuse the UDP port
/// is absorbing and how close the writer is to saturation, which is exactly the
/// reconnaissance an attacker would want. Scrape it through the reverse proxy or
/// over an SSH tunnel.
async fn metrics(
State(state): State<Shared>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> ApiResult<String> {
if !peer.ip().is_loopback() {
return Err(ApiError::Forbidden);
}
use std::sync::atomic::Ordering::Relaxed;
let c = &state.ingest.counters;
let mut out = String::new();
for (name, value) in [
("otp_datagrams_received", c.received.load(Relaxed)),
("otp_datagrams_malformed", c.malformed.load(Relaxed)),
("otp_unknown_token", c.unknown_token.load(Relaxed)),
("otp_auth_failed", c.auth_failed.load(Relaxed)),
("otp_rate_limited", c.rate_limited.load(Relaxed)),
("otp_throttled", c.throttled.load(Relaxed)),
("otp_points_accepted", c.points_accepted.load(Relaxed)),
("otp_points_rejected", c.points_rejected.load(Relaxed)),
("otp_acks_sent", c.acks_sent.load(Relaxed)),
("otp_nacks_sent", c.nacks_sent.load(Relaxed)),
("otp_revoked_notices", c.revoked_notices_sent.load(Relaxed)),
// The reflection budget actually spent. If this is nonzero and climbing,
// someone is probing the port with forged token ids.
(
"otp_unverified_notices",
c.unverified_notices_sent.load(Relaxed),
),
("otp_notices_suppressed", c.notices_suppressed.load(Relaxed)),
("otp_silent_drops", c.silent_drops.load(Relaxed)),
] {
out.push_str(&format!("# TYPE {name} counter\n{name} {value}\n"));
}
for (name, value) in [
(
"otp_tokens_loaded",
state.ingest.active_token_count() as u64,
),
(
"otp_limiter_tracked_ips",
state.ingest.limits().tracked_ips() as u64,
),
("otp_writer_capacity_free", state.writer.capacity() as u64),
] {
out.push_str(&format!("# TYPE {name} gauge\n{name} {value}\n"));
}
Ok(out)
}
async fn login(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<LoginRequest>,
) -> ApiResult<Json<LoginResponse>> {
let ip = peer.ip();
let account = match auth::authenticate(
&state.db.read,
&state.writer,
&state.cfg,
&state.throttle,
ip,
&req.username,
req.password,
)
.await
{
Ok(a) => a,
Err(AuthError::LockedOut { retry_after_s }) => {
return Err(ApiError::TooManyRequests(retry_after_s));
}
Err(AuthError::Invalid) => {
audit(&state, None, "login_failed", &req.username, ip).await;
return Err(ApiError::Unauthorized);
}
};
// Rotate the session id on login, so a fixation attempt cannot survive it.
session
.cycle_id()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
let issued = now();
session
.insert(SESSION_USER, account.id)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
session
.insert(SESSION_ISSUED, issued)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
let device = if req.purpose == Purpose::Device {
let name = req.device_name.unwrap_or_else(|| "phone".to_string());
let platform = req.platform.unwrap_or_else(|| "android".to_string());
let minted = auth::mint_token(
&state.db.write,
&state.vault,
&state.ingest,
account.id,
&name,
&platform,
ip,
)
.await?;
audit(&state, Some(account.id), "token_minted", &name, ip).await;
use base64::Engine as _;
Some(DeviceCredentials {
token_id: minted.token_id,
token_key: base64::engine::general_purpose::STANDARD.encode(minted.token_key),
revoke_key: base64::engine::general_purpose::STANDARD
.encode(state.vault.revocation_key(minted.token_id)),
udp_host: state.cfg.public_udp_host.clone(),
udp_port: state.cfg.public_udp_port,
tls_url: state.cfg.public_tls_url.clone(),
config: DeviceConfig {
config_version: minted.config_version,
profile: "balanced",
},
})
} else {
audit(&state, Some(account.id), "login", "browser", ip).await;
None
};
Ok(Json(LoginResponse {
user: Me {
id: account.id,
username: account.username,
display_name: account.display_name,
is_admin: account.is_admin,
server_time: issued,
},
device,
}))
}
async fn logout(session: Session) -> ApiResult<StatusCode> {
session
.flush()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
async fn me(State(state): State<Shared>, session: Session) -> ApiResult<Json<Me>> {
let uid = current_user(&state, &session).await?;
let (username, display_name, is_admin): (String, String, i64) =
sqlx::query_as("SELECT username, display_name, is_admin FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&state.db.read)
.await?;
Ok(Json(Me {
id: uid,
username,
display_name,
is_admin: is_admin != 0,
server_time: now(),
}))
}
async fn change_password(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<PasswordChange>,
) -> ApiResult<StatusCode> {
let uid = current_user(&state, &session).await?;
if req.new_password.chars().count() < 10 {
return Err(ApiError::BadRequest(
"the new password must be at least 10 characters".into(),
));
}
let stored: String = sqlx::query_scalar("SELECT pw_hash FROM users WHERE id = ?")
.bind(uid)
.fetch_one(&state.db.read)
.await?;
if !auth::verify(&state.cfg, stored, req.current_password)
.await?
.ok
{
return Err(ApiError::Unauthorized);
}
let hash = auth::hash_password(&state.cfg, req.new_password).await?;
let at = now();
sqlx::query("UPDATE users SET pw_hash = ?, pw_changed_at = ? WHERE id = ?")
.bind(hash)
.bind(at)
.bind(uid)
.execute(&state.db.write)
.await?;
// A password change logs out every phone and every other browser. Browsers
// are handled by the pw_changed_at comparison in current_user; phones need
// their tokens actually revoked, since they carry a key rather than a cookie.
let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
session
.cycle_id()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
session
.insert(SESSION_ISSUED, at)
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("session: {e}")))?;
audit(
&state,
Some(uid),
"password_changed",
&format!("{revoked} tokens revoked"),
peer.ip(),
)
.await;
Ok(StatusCode::NO_CONTENT)
}
/// Everything the live view needs, in one call.
async fn state_handler(
State(state): State<Shared>,
session: Session,
headers: HeaderMap,
) -> ApiResult<Response> {
let uid = current_user(&state, &session).await?;
let visible = visible_user_ids(&state.db.read, uid).await?;
// One query for everyone visible. A LEFT JOIN so a person with no position
// yet still appears in the list — otherwise they would silently vanish from
// the UI until their first fix, which reads as a bug.
//
// The id set is passed as a JSON array through `json_each` rather than by
// building an `IN (?, ?, ?)` string: the SQL stays a literal, so there is no
// interpolation to audit and the statement cache gets one entry instead of
// one per group size.
let ids_json = serde_json::to_string(&visible).map_err(|e| ApiError::Internal(e.into()))?;
let rows = sqlx::query_as::<_, LatestRow>(
"SELECT u.id, u.display_name, l.ts, l.lat, l.lon, l.acc_dm, l.alt_m, l.spd_cms, \
l.brg_cdeg, l.bat_pct, l.flags, l.recv_at \
FROM users u \
JOIN json_each(?) v ON v.value = u.id \
LEFT JOIN user_latest l ON l.user_id = u.id \
ORDER BY u.display_name",
)
.bind(&ids_json)
.fetch_all(&state.db.read)
.await?;
let people: Vec<PersonState> = rows
.into_iter()
.map(|r| PersonState {
user_id: r.id,
is_self: r.id == uid,
display_name: r.display_name,
position: r.ts.map(|ts| Position {
ts,
lat_e7: r.lat.unwrap_or(0),
lon_e7: r.lon.unwrap_or(0),
acc_dm: r.acc_dm,
alt_m: r.alt_m,
spd_cms: r.spd_cms,
brg_cdeg: r.brg_cdeg,
bat_pct: r.bat_pct,
flags: r.flags.unwrap_or(0),
recv_at: r.recv_at.unwrap_or(ts),
}),
})
.collect();
let body = StateResponse {
server_time: now(),
people,
};
// ETag over the people list only — deliberately *not* including
// `server_time`, which changes every second and would make every poll a 200.
let etag = etag_of(&body.people);
if headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v == etag)
{
return Ok((StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response());
}
Ok((
[
(header::ETAG, etag),
(header::CACHE_CONTROL, "no-store".to_string()),
],
Json(body),
)
.into_response())
}
#[derive(sqlx::FromRow)]
struct LatestRow {
id: i64,
display_name: String,
ts: Option<i64>,
lat: Option<i64>,
lon: Option<i64>,
acc_dm: Option<i64>,
alt_m: Option<i64>,
spd_cms: Option<i64>,
brg_cdeg: Option<i64>,
bat_pct: Option<i64>,
flags: Option<i64>,
recv_at: Option<i64>,
}
/// A weak ETag over the payload's meaningful content.
fn etag_of(people: &[PersonState]) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for p in people {
p.user_id.hash(&mut h);
p.display_name.hash(&mut h);
if let Some(pos) = &p.position {
pos.ts.hash(&mut h);
pos.lat_e7.hash(&mut h);
pos.lon_e7.hash(&mut h);
pos.acc_dm.hash(&mut h);
pos.bat_pct.hash(&mut h);
pos.flags.hash(&mut h);
} else {
0u8.hash(&mut h);
}
}
format!("W/\"{:x}\"", h.finish())
}
async fn list_tokens(
State(state): State<Shared>,
session: Session,
) -> ApiResult<Json<Vec<TokenInfo>>> {
let uid = current_user(&state, &session).await?;
let rows: Vec<TokenRow> = sqlx::query_as(
"SELECT token_id, name, platform, app_version, os_api_level, last_seen_at, last_src_ip, \
last_transport, created_at \
FROM tokens WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
)
.bind(uid)
.fetch_all(&state.db.read)
.await?;
Ok(Json(
rows.into_iter()
.map(|r| TokenInfo {
token_id: (r.token_id as u64).to_string(),
name: r.name,
platform: r.platform,
app_version: r.app_version,
os_api_level: r.os_api_level,
last_seen_at: r.last_seen_at,
last_src_ip: r.last_src_ip,
last_transport: r.last_transport,
created_at: r.created_at,
})
.collect(),
))
}
#[derive(sqlx::FromRow)]
struct TokenRow {
token_id: i64,
name: String,
platform: String,
app_version: Option<i64>,
os_api_level: Option<i64>,
last_seen_at: Option<i64>,
last_src_ip: Option<String>,
last_transport: Option<String>,
created_at: i64,
}
async fn revoke_one_token(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(token_id): Path<String>,
) -> ApiResult<StatusCode> {
let uid = current_user(&state, &session).await?;
// Parsed as the u64 it is on the wire, then bit-cast: SQLite has no unsigned
// integer type, so that cast is how every token id is stored.
let token_id = token_id
.parse::<u64>()
.map_err(|_| ApiError::BadRequest("token id must be a u64".into()))?
as i64;
// Scope the ownership check into the query: fetching then comparing invites
// the check being forgotten on some future path.
let owner: Option<i64> = sqlx::query_scalar("SELECT user_id FROM tokens WHERE token_id = ?")
.bind(token_id)
.fetch_optional(&state.db.read)
.await?;
match owner {
None => return Err(ApiError::NotFound),
Some(o) if o != uid => return Err(ApiError::Forbidden),
Some(_) => {}
}
if !auth::revoke_token(&state.db.write, &state.ingest, token_id).await? {
return Err(ApiError::NotFound);
}
audit(
&state,
Some(uid),
"token_revoked",
&token_id.to_string(),
peer.ip(),
)
.await;
Ok(StatusCode::NO_CONTENT)
}
async fn revoke_others(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
) -> ApiResult<Json<serde_json::Value>> {
let uid = current_user(&state, &session).await?;
// From a browser there is no "current token" to keep, so this revokes every
// phone. The browser's own session is unaffected.
let revoked = auth::revoke_other_tokens(&state.db.write, &state.ingest, uid, None).await?;
audit(
&state,
Some(uid),
"tokens_revoked_all",
&revoked.to_string(),
peer.ip(),
)
.await;
Ok(Json(serde_json::json!({ "revoked": revoked })))
}
async fn track(
State(state): State<Shared>,
session: Session,
Path(user_id): Path<i64>,
Query(q): Query<TrackQuery>,
) -> ApiResult<Json<TrackResponse>> {
let viewer = current_user(&state, &session).await?;
let visible = visible_user_ids(&state.db.read, viewer).await?;
if !visible.contains(&user_id) {
// 403 rather than 404: the caller already knows this user exists if they
// saw them in /api/state, and pretending otherwise buys nothing.
return Err(ApiError::Forbidden);
}
// Trails are only visible when the share says so. Your own trail is always
// yours to see.
if user_id != viewer {
let trail_visible: Option<i64> = sqlx::query_scalar(
"SELECT MAX(s.trail_visible) FROM shares s \
WHERE s.owner_user_id = ? AND s.revoked_at IS NULL \
AND (s.expires_at IS NULL OR s.expires_at > ?) \
AND ( s.viewer_user_id = ? \
OR s.viewer_group_id IN (SELECT group_id FROM group_members WHERE user_id = ?) )",
)
.bind(user_id)
.bind(now())
.bind(viewer)
.bind(viewer)
.fetch_one(&state.db.read)
.await?;
if trail_visible.unwrap_or(0) == 0 {
return Err(ApiError::Forbidden);
}
}
let to = q.to.unwrap_or_else(now);
let from = q.from.unwrap_or(to - 24 * 3_600);
if from >= to {
return Err(ApiError::BadRequest("from must be before to".into()));
}
let max = q.max.clamp(2, 10_000);
let rows: Vec<(i64, i64)> = sqlx::query_as(
"SELECT lat, lon FROM points WHERE user_id = ? AND ts >= ? AND ts <= ? ORDER BY ts",
)
.bind(user_id)
.bind(from)
.bind(to)
.fetch_all(&state.db.read)
.await?;
// Decimate server-side. 2000 points as an encoded polyline is ~10 kB against
// ~60 kB of JSON floats, and the browser has less to draw.
let simplified = crate::polyline::simplify(&rows, max);
let polyline = crate::polyline::encode(&simplified);
Ok(Json(TrackResponse {
user_id,
from,
to,
point_count: simplified.len(),
polyline,
}))
}
async fn create_user(
State(state): State<Shared>,
session: Session,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<CreateUser>,
) -> ApiResult<Json<serde_json::Value>> {
let admin = require_admin(&state, &session).await?;
let username = req.username.trim().to_string();
if username.is_empty() || username.chars().count() > 64 {
return Err(ApiError::BadRequest(
"username must be 1..=64 characters".into(),
));
}
if req.password.chars().count() < 10 {
return Err(ApiError::BadRequest(
"password must be at least 10 characters".into(),
));
}
let hash = auth::hash_password(&state.cfg, req.password).await?;
let at = now();
let result = sqlx::query(
"INSERT INTO users (username, pw_hash, display_name, is_admin, created_at, pw_changed_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&username)
.bind(hash)
.bind(req.display_name.unwrap_or_else(|| username.clone()))
.bind(i64::from(req.is_admin))
.bind(at)
.bind(at)
.execute(&state.db.write)
.await;
let id = match result {
Ok(r) => r.last_insert_rowid(),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
return Err(ApiError::BadRequest("that username is taken".into()));
}
Err(e) => return Err(e.into()),
};
audit(&state, Some(admin), "user_created", &username, peer.ip()).await;
Ok(Json(serde_json::json!({ "id": id, "username": username })))
}
async fn audit(state: &Shared, user_id: Option<i64>, action: &str, detail: &str, ip: IpAddr) {
let _ = state
.writer
.send(WriteOp::Audit {
user_id,
at: now(),
action: action.to_string(),
detail: detail.to_string(),
src_ip: Some(ip.to_string()),
})
.await;
}
#[cfg(test)]
mod tests {
use super::*;
/// The web UI's `web/src/api.ts` types are hand-written. This is what stops
/// them drifting: renaming a field here fails `cargo test` instead of
/// producing `undefined` in a browser at runtime.
///
/// Keys only, not values — the types carry no invariants worth asserting,
/// and a value check would just restate the constructor above it.
fn keys(value: &serde_json::Value) -> Vec<&str> {
let mut k: Vec<&str> = value
.as_object()
.expect("expected a JSON object")
.keys()
.map(String::as_str)
.collect();
k.sort_unstable();
k
}
#[test]
fn the_json_shape_is_the_one_the_web_ui_expects() {
let me = Me {
id: 1,
username: "a".into(),
display_name: "A".into(),
is_admin: false,
server_time: 0,
};
assert_eq!(
keys(&serde_json::to_value(&me).expect("serialize")),
["display_name", "id", "is_admin", "server_time", "username"]
);
let position = Position {
ts: 0,
lat_e7: 0,
lon_e7: 0,
acc_dm: None,
alt_m: None,
spd_cms: None,
brg_cdeg: None,
bat_pct: None,
flags: 0,
recv_at: 0,
};
assert_eq!(
keys(&serde_json::to_value(&position).expect("serialize")),
[
"acc_dm", "alt_m", "bat_pct", "brg_cdeg", "flags", "lat_e7", "lon_e7", "recv_at",
"spd_cms", "ts"
]
);
let person = PersonState {
user_id: 1,
display_name: "A".into(),
is_self: true,
position: Some(position),
};
assert_eq!(
keys(&serde_json::to_value(&person).expect("serialize")),
["display_name", "is_self", "position", "user_id"]
);
// `position` is skipped when absent, which is why the TypeScript field is
// optional rather than nullable.
let no_fix = PersonState {
position: None,
..person
};
assert_eq!(
keys(&serde_json::to_value(&no_fix).expect("serialize")),
["display_name", "is_self", "user_id"]
);
let state = StateResponse {
server_time: 0,
people: vec![],
};
assert_eq!(
keys(&serde_json::to_value(&state).expect("serialize")),
["people", "server_time"]
);
let token = TokenInfo {
token_id: "1".into(),
name: "p".into(),
platform: "android".into(),
app_version: None,
os_api_level: None,
last_seen_at: None,
last_src_ip: None,
last_transport: None,
created_at: 0,
};
assert_eq!(
keys(&serde_json::to_value(&token).expect("serialize")),
[
"app_version",
"created_at",
"last_seen_at",
"last_src_ip",
"last_transport",
"name",
"os_api_level",
"platform",
"token_id"
]
);
let track = TrackResponse {
user_id: 1,
from: 0,
to: 1,
polyline: String::new(),
point_count: 0,
};
assert_eq!(
keys(&serde_json::to_value(&track).expect("serialize")),
["from", "point_count", "polyline", "to", "user_id"]
);
assert_eq!(
keys(&serde_json::to_value(ErrorBody { error: "x".into() }).expect("serialize")),
["error"]
);
}
/// Token ids routinely exceed 2^53, which is why [`TokenInfo::token_id`] is a
/// string. This asserts the reason still holds rather than trusting the
/// comment: if ids ever became small the string could go away.
#[test]
fn token_ids_are_too_large_for_a_javascript_number() {
let big = (0..64)
.map(|_| crate::keys::random_token_id().expect("rng"))
.filter(|id| *id >= (1u64 << 53))
.count();
assert!(
big > 32,
"expected most token ids above 2^53, got {big} of 64"
);
}
/// The exact value a browser must be able to send back and have match.
#[test]
fn a_large_token_id_round_trips_through_its_string_form() {
let id = u64::MAX - 3;
let text = id.to_string();
assert_eq!(text.parse::<u64>().expect("parse") as i64, id as i64);
}
}
Acrates/otserver/src/auth.rs
@@ -0,0 +1,834 @@
//! Passwords, sessions, and token minting.
//!
//! Logging in *is* pairing. There is no QR code, no enrollment token, no
//! out-of-band step: a phone posts credentials and gets back a `token_id` and a
//! 32-byte key, which is exactly the mental model of a browser session. A
//! reinstall is just another login, and because positions belong to the account,
//! the new token writes into the same continuous history.
use std::net::IpAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
// `SaltString::generate` wants the rand_core that password-hash was built
// against, which is not the same major version as the `rand` used elsewhere in
// this crate. Importing it through argon2 keeps the two from being confused.
use argon2::password_hash::rand_core::OsRng as PhOsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use dashmap::DashMap;
use sqlx::SqlitePool;
use crate::config::Config;
use crate::db::now;
use crate::ingest::{Ingest, TokenSlot};
use crate::keys::{KeyVault, random_token_id, random_token_key};
use otproto::RevokeReason;
/// Failed logins allowed per (IP, username) before the pair is locked out.
const MAX_ATTEMPTS: u32 = 5;
const ATTEMPT_WINDOW: Duration = Duration::from_secs(15 * 60);
/// An argon2 hash of a throwaway password, used to spend the same CPU on an
/// unknown username as on a known one.
///
/// Without this, "user not found" returns in microseconds while a real user's
/// verify takes ~50 ms, and the difference enumerates the whole user list.
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$\
Fj5r5rD/hqCvQeYqNSlF9y5FZbTCUYPl5jrCLj/eKz0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthError {
/// Wrong username, wrong password, or a disabled account — deliberately not
/// distinguished, so a caller cannot learn which usernames exist.
Invalid,
/// Too many failures for this (IP, username) pair.
LockedOut { retry_after_s: u64 },
}
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid => write!(f, "invalid username or password"),
Self::LockedOut { retry_after_s } => {
write!(f, "too many attempts; try again in {retry_after_s}s")
}
}
}
}
impl std::error::Error for AuthError {}
#[derive(Debug, Default)]
struct Attempts {
count: u32,
window_started: Option<Instant>,
}
/// Per-(IP, username) failed-login throttle.
#[derive(Default)]
pub struct LoginThrottle {
attempts: DashMap<(IpAddr, String), Attempts>,
}
impl LoginThrottle {
/// Keyed on the pair, not on either alone: keying on IP would let one shared
/// office address lock out a whole team, and keying on username alone would
/// let anyone lock a known user out on purpose.
pub fn check(&self, ip: IpAddr, username: &str) -> Result<(), AuthError> {
let key = (ip, username.to_lowercase());
let now = Instant::now();
if let Some(a) = self.attempts.get(&key)
&& let Some(started) = a.window_started
&& now.duration_since(started) <= ATTEMPT_WINDOW
&& a.count >= MAX_ATTEMPTS
{
return Err(AuthError::LockedOut {
retry_after_s: (ATTEMPT_WINDOW - now.duration_since(started)).as_secs(),
});
}
Ok(())
}
pub fn note_failure(&self, ip: IpAddr, username: &str) {
let key = (ip, username.to_lowercase());
let now = Instant::now();
let mut entry = self.attempts.entry(key).or_default();
match entry.window_started {
Some(started) if now.duration_since(started) <= ATTEMPT_WINDOW => entry.count += 1,
_ => {
entry.window_started = Some(now);
entry.count = 1;
}
}
}
pub fn note_success(&self, ip: IpAddr, username: &str) {
self.attempts.remove(&(ip, username.to_lowercase()));
}
pub fn gc(&self) {
let now = Instant::now();
self.attempts.retain(|_, a| {
a.window_started
.is_some_and(|t| now.duration_since(t) <= ATTEMPT_WINDOW)
});
}
}
/// Argon2id parameters from config.
///
/// 19 MiB / 2 passes / 1 lane is OWASP's second recommended profile and fits a
/// small VM. The parameters are stored inside each PHC hash string, so raising
/// them later does not invalidate anything — [`verify`] re-hashes on the next
/// successful login instead.
fn hasher(cfg: &Config) -> Result<Argon2<'static>> {
let params = Params::new(
cfg.argon2_memory_kib,
cfg.argon2_iterations,
cfg.argon2_parallelism,
None,
)
.map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
/// Hash a password. Runs on a blocking thread: argon2 is deliberately expensive,
/// and ~50 ms of CPU on an async worker would stall every other request on it.
pub async fn hash_password(cfg: &Config, password: String) -> Result<String> {
let argon = hasher(cfg)?;
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut PhOsRng);
argon
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow::anyhow!("hashing failed: {e}"))
})
.await
.context("hashing task panicked")?
}
/// Outcome of a verify, including whether the stored hash should be upgraded.
pub struct Verified {
pub ok: bool,
/// A fresh hash under current policy, when the stored one used older params.
pub rehashed: Option<String>,
}
pub async fn verify(cfg: &Config, stored: String, password: String) -> Result<Verified> {
let argon = hasher(cfg)?;
let want = Params::new(
cfg.argon2_memory_kib,
cfg.argon2_iterations,
cfg.argon2_parallelism,
None,
)
.map_err(|e| anyhow::anyhow!("invalid argon2 parameters: {e}"))?;
tokio::task::spawn_blocking(move || {
let parsed = match PasswordHash::new(&stored) {
Ok(p) => p,
Err(_) => {
// A corrupt hash must still cost the same time as a real one, or
// the failure mode becomes an oracle.
let dummy = PasswordHash::new(DUMMY_HASH).expect("the dummy hash is a literal");
let _ = argon.verify_password(password.as_bytes(), &dummy);
return Ok(Verified {
ok: false,
rehashed: None,
});
}
};
if argon.verify_password(password.as_bytes(), &parsed).is_err() {
return Ok(Verified {
ok: false,
rehashed: None,
});
}
// Transparent upgrade when policy has moved on since this hash was made.
//
// Only the three cost parameters are compared. A parsed `Params` also
// carries an output length and optional keyid/data fields that the freshly
// built one does not, so comparing whole structs would rehash on every
// single login — an easy 50 ms tax to add by accident.
let current: Params = (&parsed).try_into().unwrap_or_else(|_| want.clone());
let costs_differ = current.m_cost() != want.m_cost()
|| current.t_cost() != want.t_cost()
|| current.p_cost() != want.p_cost();
let rehashed = if costs_differ {
let salt = SaltString::generate(&mut PhOsRng);
argon
.hash_password(password.as_bytes(), &salt)
.ok()
.map(|h| h.to_string())
} else {
None
};
Ok(Verified { ok: true, rehashed })
})
.await
.context("verify task panicked")?
}
/// Spend the same CPU as a real verify would, then fail.
///
/// Called when the username does not exist, so that the response time carries no
/// information about which accounts are real.
pub async fn dummy_verify(cfg: &Config, password: String) {
let _ = verify(cfg, DUMMY_HASH.to_string(), password).await;
}
#[derive(Debug)]
pub struct Account {
pub id: i64,
pub username: String,
pub display_name: String,
pub is_admin: bool,
}
/// Look up and authenticate a user.
pub async fn authenticate(
pool: &SqlitePool,
writer: &crate::writer::WriteHandle,
cfg: &Config,
throttle: &LoginThrottle,
ip: IpAddr,
username: &str,
password: String,
) -> Result<Account, AuthError> {
throttle.check(ip, username)?;
let row: Option<(i64, String, String, String, i64, Option<i64>)> = sqlx::query_as(
"SELECT id, username, display_name, pw_hash, is_admin, disabled_at \
FROM users WHERE username = ?",
)
.bind(username)
.fetch_optional(pool)
.await
.map_err(|_| AuthError::Invalid)?;
let Some((id, username_stored, display_name, pw_hash, is_admin, disabled_at)) = row else {
dummy_verify(cfg, password).await;
throttle.note_failure(ip, username);
return Err(AuthError::Invalid);
};
// A disabled account still pays for a full verify, so disabling somebody is
// not observable from outside.
let verified = verify(cfg, pw_hash, password)
.await
.map_err(|_| AuthError::Invalid)?;
if !verified.ok || disabled_at.is_some() {
throttle.note_failure(ip, username);
return Err(AuthError::Invalid);
}
if let Some(new_hash) = verified.rehashed {
let _ = writer
.send(crate::writer::WriteOp::Audit {
user_id: Some(id),
at: now(),
action: "password_rehashed".into(),
detail: String::new(),
src_ip: Some(ip.to_string()),
})
.await;
// Best effort: a failed upgrade must not fail the login.
let _ = sqlx::query("UPDATE users SET pw_hash = ? WHERE id = ?")
.bind(new_hash)
.bind(id)
.execute(pool)
.await;
}
throttle.note_success(ip, username);
Ok(Account {
id,
username: username_stored,
display_name,
is_admin: is_admin != 0,
})
}
/// A freshly minted device credential.
pub struct MintedToken {
pub token_id: u64,
pub token_key: [u8; 32],
pub config_version: u16,
}
/// Mint a token for a login.
///
/// The plaintext key is returned exactly once — here — and stored only wrapped.
/// The caller must hand it to the device and then drop it.
pub async fn mint_token(
pool: &SqlitePool,
vault: &KeyVault,
ingest: &Arc<Ingest>,
user_id: i64,
name: &str,
platform: &str,
ip: IpAddr,
) -> Result<MintedToken> {
let token_id = random_token_id()?;
let token_key = random_token_key()?;
let wrapped = vault.wrap(token_id, &token_key)?;
let config_version: u16 = 1;
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, platform, config_version, \
created_at, created_ip) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(token_id as i64)
.bind(user_id)
.bind(&wrapped)
.bind(name)
.bind(platform)
.bind(i64::from(config_version))
.bind(now())
.bind(ip.to_string())
.execute(pool)
.await
.context("inserting token")?;
// Insert into the ingest cache before returning, so the device's very first
// datagram is served — there is no window where a just-issued token looks
// unknown.
ingest.insert_token(TokenSlot::new(
token_id,
user_id,
&token_key,
config_version,
));
Ok(MintedToken {
token_id,
token_key,
config_version,
})
}
/// Load every usable token into the ingest cache. Called once at startup.
///
/// A token whose key cannot be unwrapped is skipped with a warning rather than
/// aborting startup: that is what a botched `OT_SECRET_KEY` rotation looks like,
/// and refusing to boot would turn a recoverable mistake into an outage.
pub async fn load_tokens(
pool: &SqlitePool,
vault: &KeyVault,
ingest: &Arc<Ingest>,
) -> Result<usize> {
// Revoked tokens are loaded too, flagged. Their keys authorise nothing; they
// exist so a phone that has not noticed yet gets a sealed answer instead of
// silence. A disabled account is treated as a revocation, since the effect on
// the device is the same.
/// `(token_id, user_id, key_wrapped, config_version, revoked_at, disabled_at)`.
type TokenRow = (i64, i64, Vec<u8>, i64, Option<i64>, Option<i64>);
let rows: Vec<TokenRow> = sqlx::query_as(
"SELECT t.token_id, t.user_id, t.key_wrapped, t.config_version, t.revoked_at, \
u.disabled_at \
FROM tokens t JOIN users u ON u.id = t.user_id",
)
.fetch_all(pool)
.await
.context("loading tokens")?;
let mut loaded = 0;
let mut revoked = 0;
for (token_id, user_id, wrapped, config_version, revoked_at, disabled_at) in rows {
match vault.unwrap(token_id as u64, &wrapped) {
Ok(key) => {
let slot = TokenSlot::new(token_id as u64, user_id, &key, config_version as u16);
if revoked_at.is_some() || disabled_at.is_some() {
ingest.insert_token(slot.revoked(RevokeReason::Revoked));
revoked += 1;
} else {
ingest.insert_token(slot);
loaded += 1;
}
}
Err(e) => tracing::warn!(token_id, error = %e, "skipping token with an unusable key"),
}
}
tracing::debug!(revoked, "revoked tokens kept so their devices can be told");
Ok(loaded)
}
/// Revoke one token: database row, then ingest cache.
///
/// The row and its wrapped key are kept, and the cache slot is flagged rather
/// than dropped. That is deliberate: the key is the only thing that lets the
/// server tell this device it has been logged out, in a message no third party
/// could forge. Dropping it would leave silence as the only safe answer.
pub async fn revoke_token(pool: &SqlitePool, ingest: &Arc<Ingest>, token_id: i64) -> Result<bool> {
let affected =
sqlx::query("UPDATE tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL")
.bind(now())
.bind(token_id)
.execute(pool)
.await
.context("revoking token")?
.rows_affected();
ingest.mark_revoked(token_id as u64, RevokeReason::Revoked);
Ok(affected > 0)
}
/// Revoke every token for an account except optionally one.
///
/// This is what "log out all other devices" and a password change both do.
pub async fn revoke_other_tokens(
pool: &SqlitePool,
ingest: &Arc<Ingest>,
user_id: i64,
keep: Option<i64>,
) -> Result<usize> {
let ids: Vec<i64> = sqlx::query_scalar(
"SELECT token_id FROM tokens WHERE user_id = ? AND revoked_at IS NULL AND token_id IS NOT ?",
)
.bind(user_id)
.bind(keep)
.fetch_all(pool)
.await
.context("listing tokens to revoke")?;
for id in &ids {
revoke_token(pool, ingest, *id).await?;
}
Ok(ids.len())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7));
/// Cheap argon2 parameters: these tests are about logic, not about how long a
/// hash takes, and the real parameters make the suite unbearably slow.
fn cfg() -> Config {
Config {
argon2_memory_kib: 8,
argon2_iterations: 1,
argon2_parallelism: 1,
admin_email: "ops@example.net".into(),
..Config::default()
}
}
async fn fixture() -> (
Db,
Arc<Ingest>,
crate::writer::WriteHandle,
tempfile::TempDir,
) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Arc::new(Ingest::new(writer.clone(), 30 * 86_400, None));
(db, ingest, writer, dir)
}
async fn make_user(db: &Db, cfg: &Config, username: &str, password: &str) -> i64 {
let hash = hash_password(cfg, password.to_string())
.await
.expect("hash");
let n = now();
sqlx::query(
"INSERT INTO users (username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (?, ?, ?, ?, ?)",
)
.bind(username)
.bind(hash)
.bind(username)
.bind(n)
.bind(n)
.execute(&db.write)
.await
.expect("user");
sqlx::query_scalar("SELECT id FROM users WHERE username = ?")
.bind(username)
.fetch_one(&db.read)
.await
.expect("id")
}
#[tokio::test]
async fn a_password_verifies_and_a_wrong_one_does_not() {
let cfg = cfg();
let hash = hash_password(&cfg, "correct horse".into())
.await
.expect("hash");
assert!(
verify(&cfg, hash.clone(), "correct horse".into())
.await
.expect("v")
.ok
);
assert!(
!verify(&cfg, hash, "wrong horse".into())
.await
.expect("v")
.ok
);
}
#[tokio::test]
async fn hashes_are_salted() {
let cfg = cfg();
let a = hash_password(&cfg, "same".into()).await.expect("hash");
let b = hash_password(&cfg, "same".into()).await.expect("hash");
assert_ne!(a, b, "two hashes of one password must differ");
}
#[tokio::test]
async fn a_corrupt_stored_hash_fails_closed() {
let cfg = cfg();
let v = verify(&cfg, "not a phc string".into(), "anything".into())
.await
.expect("v");
assert!(!v.ok);
}
#[tokio::test]
async fn a_hash_with_stale_parameters_is_upgraded_on_login() {
// Hash under weak parameters, then verify under stronger ones.
let weak = cfg();
let hash = hash_password(&weak, "pw".into()).await.expect("hash");
let strong = Config {
argon2_iterations: 3,
..weak
};
let v = verify(&strong, hash, "pw".into()).await.expect("v");
assert!(v.ok);
let rehashed = v
.rehashed
.expect("stale parameters should produce a new hash");
assert!(
rehashed.contains("t=3"),
"the new hash should use current parameters: {rehashed}"
);
}
#[tokio::test]
async fn a_hash_with_current_parameters_is_not_rehashed() {
let cfg = cfg();
let hash = hash_password(&cfg, "pw".into()).await.expect("hash");
let v = verify(&cfg, hash, "pw".into()).await.expect("v");
assert!(v.ok);
assert!(
v.rehashed.is_none(),
"no upgrade needed, so no needless write"
);
}
#[tokio::test]
async fn usernames_are_case_insensitive() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "Marc", "pw").await;
let throttle = LoginThrottle::default();
let account = authenticate(&db.read, &writer, &cfg, &throttle, IP, "MARC", "pw".into())
.await
.expect("should authenticate regardless of case");
assert_eq!(
account.username, "Marc",
"the stored spelling is what is returned"
);
}
#[tokio::test]
async fn a_disabled_account_cannot_log_in() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
let id = make_user(&db, &cfg, "gone", "pw").await;
sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?")
.bind(now())
.bind(id)
.execute(&db.write)
.await
.expect("disable");
let throttle = LoginThrottle::default();
let err = authenticate(&db.read, &writer, &cfg, &throttle, IP, "gone", "pw".into())
.await
.expect_err("must be refused");
assert_eq!(
err,
AuthError::Invalid,
"a disabled account must be indistinguishable from a wrong password"
);
}
#[tokio::test]
async fn repeated_failures_lock_the_pair_out() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "victim", "pw").await;
let throttle = LoginThrottle::default();
for _ in 0..MAX_ATTEMPTS {
assert_eq!(
authenticate(
&db.read,
&writer,
&cfg,
&throttle,
IP,
"victim",
"bad".into()
)
.await
.expect_err("wrong password"),
AuthError::Invalid
);
}
let err = authenticate(
&db.read,
&writer,
&cfg,
&throttle,
IP,
"victim",
"pw".into(),
)
.await
.expect_err("should be locked out even with the right password");
assert!(matches!(err, AuthError::LockedOut { .. }));
}
#[tokio::test]
async fn a_lockout_does_not_spread_to_other_addresses() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "victim", "pw").await;
let throttle = LoginThrottle::default();
for _ in 0..MAX_ATTEMPTS {
let _ = authenticate(
&db.read,
&writer,
&cfg,
&throttle,
IP,
"victim",
"bad".into(),
)
.await;
}
let elsewhere = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 1));
authenticate(
&db.read,
&writer,
&cfg,
&throttle,
elsewhere,
"victim",
"pw".into(),
)
.await
.expect("another address must not be locked out — otherwise this is a DoS on the user");
}
#[tokio::test]
async fn a_successful_login_clears_the_failure_count() {
let (db, _ingest, writer, _dir) = fixture().await;
let cfg = cfg();
make_user(&db, &cfg, "u", "pw").await;
let throttle = LoginThrottle::default();
for _ in 0..MAX_ATTEMPTS - 1 {
let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await;
}
authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into())
.await
.expect("still allowed");
for _ in 0..MAX_ATTEMPTS - 1 {
let _ = authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "bad".into()).await;
}
authenticate(&db.read, &writer, &cfg, &throttle, IP, "u", "pw".into())
.await
.expect("the counter should have been reset by the successful login");
}
#[tokio::test]
async fn minting_a_token_makes_it_immediately_usable() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
let minted = mint_token(&db.write, &vault, &ingest, user_id, "Pixel", "android", IP)
.await
.expect("mint");
// A device that logs in and immediately sends a LOC must be served: there
// must be no window where a just-issued token looks unknown.
assert!(
ingest
.key_for(minted.token_id, otproto::msg::Direction::Up)
.is_some(),
"the token must be in the ingest cache before mint_token returns"
);
// And the stored key must round-trip through the vault.
let wrapped: Vec<u8> =
sqlx::query_scalar("SELECT key_wrapped FROM tokens WHERE token_id = ?")
.bind(minted.token_id as i64)
.fetch_one(&db.read)
.await
.expect("wrapped");
assert_eq!(
vault.unwrap(minted.token_id, &wrapped).expect("unwrap"),
minted.token_key
);
assert_ne!(
wrapped.as_slice(),
minted.token_key.as_slice(),
"the plaintext key must never be what is stored"
);
}
#[tokio::test]
async fn tokens_reload_into_the_cache_at_startup() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
let a = mint_token(&db.write, &vault, &ingest, user_id, "A", "android", IP)
.await
.expect("a");
let b = mint_token(&db.write, &vault, &ingest, user_id, "B", "android", IP)
.await
.expect("b");
revoke_token(&db.write, &ingest, b.token_id as i64)
.await
.expect("revoke");
// Simulate a restart: a fresh cache, repopulated from the database.
let (writer2, _t) = crate::writer::spawn(db.write.clone());
let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None));
let loaded = load_tokens(&db.read, &vault, &fresh).await.expect("load");
assert_eq!(loaded, 1, "a revoked token must not come back as active");
assert_eq!(fresh.active_token_count(), 1);
assert!(
fresh
.key_for(a.token_id, otproto::msg::Direction::Up)
.is_some()
);
// The revoked token keeps its key across a restart, flagged. Without it
// the phone that still holds that token could only be met with silence,
// and silence is indistinguishable from a network fault.
assert!(
fresh
.key_for(b.token_id, otproto::msg::Direction::Up)
.is_some(),
"a revoked token must keep its key so its device can be told"
);
}
#[tokio::test]
async fn revoke_others_keeps_the_current_token_only() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
let keep = mint_token(&db.write, &vault, &ingest, user_id, "keep", "android", IP)
.await
.expect("k");
for name in ["a", "b", "c"] {
mint_token(&db.write, &vault, &ingest, user_id, name, "android", IP)
.await
.expect("m");
}
assert_eq!(ingest.active_token_count(), 4);
let revoked = revoke_other_tokens(&db.write, &ingest, user_id, Some(keep.token_id as i64))
.await
.expect("revoke others");
assert_eq!(revoked, 3);
assert_eq!(ingest.active_token_count(), 1);
assert!(
ingest
.key_for(keep.token_id, otproto::msg::Direction::Up)
.is_some()
);
}
#[tokio::test]
async fn a_token_belonging_to_a_disabled_user_is_not_loaded() {
let (db, ingest, _writer, _dir) = fixture().await;
let cfg = cfg();
let user_id = make_user(&db, &cfg, "u", "pw").await;
let vault = KeyVault::for_test([9; 32]);
mint_token(&db.write, &vault, &ingest, user_id, "phone", "android", IP)
.await
.expect("m");
sqlx::query("UPDATE users SET disabled_at = ? WHERE id = ?")
.bind(now())
.bind(user_id)
.execute(&db.write)
.await
.expect("disable");
let (writer2, _t) = crate::writer::spawn(db.write.clone());
let fresh = Arc::new(Ingest::new(writer2, 30 * 86_400, None));
assert_eq!(
load_tokens(&db.read, &vault, &fresh).await.expect("load"),
0,
"disabling an account must stop its phones, not just its browser logins"
);
}
#[test]
fn the_dummy_hash_is_parseable() {
// If this literal ever stops parsing, the unknown-username path silently
// becomes fast and the timing oracle reopens.
PasswordHash::new(DUMMY_HASH).expect("the dummy hash must be a valid PHC string");
}
}
Acrates/otserver/src/config.rs
@@ -0,0 +1,265 @@
//! Configuration: a TOML file with `OT_*` environment overrides.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
/// The placeholder that must be replaced before the server will start.
const CONTACT_PLACEHOLDER: &str = "you@example.com";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
/// HTTP listener. Bound to localhost by default because TLS termination is
/// the reverse proxy's job.
pub http_addr: SocketAddr,
/// OTP/1 UDP listener. **This one is not proxied**: HTTP reverse proxies do
/// not forward UDP, so this port needs its own firewall/NAT rule. Operators
/// get this wrong on day one, which is why the TLS fallback has to be good.
pub udp_addr: SocketAddr,
/// TLS-over-TCP fallback listener for UDP-hostile networks.
pub tls_addr: Option<SocketAddr>,
/// Number of `SO_REUSEPORT` receive tasks. Defaults to `min(cpus, 4)`.
pub udp_workers: Option<usize>,
/// What the login response tells phones to connect to.
pub public_udp_host: String,
pub public_udp_port: u16,
pub public_tls_url: Option<String>,
/// Public base URL, used in the tile proxy's User-Agent and in cookies.
pub base_url: String,
/// Contact address embedded in the tile proxy's User-Agent. The OSM tile
/// policy explicitly prohibits library defaults and unidentified proxies, so
/// the server refuses to start while this is the placeholder.
pub admin_email: String,
pub db_path: PathBuf,
pub cache_dir: PathBuf,
/// Tile cache ceiling. Eviction runs down to 90% of this.
pub max_cache_bytes: u64,
pub tile_upstream_url: String,
/// Hard drop for points older than this.
pub retention_days: u32,
/// Delete tokens with no activity for this long. A phone genuinely idle for
/// a month must log in again — the same contract as an expiring browser
/// session.
pub token_stale_days: u32,
/// Accept timestamps within ±this many days of the server clock.
///
/// The *only* server-side handling of a client timestamp. It is not a
/// correction, not skew detection, and not a security control — the client
/// clock is trusted and stored verbatim. It is a storage bound: a phone whose
/// clock says 2106 would otherwise write rows the retention sweep can never
/// reclaim, and the database would grow without limit. Set it high, or raise
/// it, but do not set it to zero.
pub ts_window_days: u32,
/// Answer a datagram naming an unknown token with a sealed `REVOKED` notice
/// instead of silence.
///
/// This is the one place the server replies to something it could not
/// verify, which makes the UDP port a reflector: source addresses are
/// forgeable, so the reply goes wherever the sender claimed to be. Three
/// things bound it — the notice is 38 bytes and is refused to any shorter
/// request, so it can never amplify; it is rate limited to one per
/// destination address per minute under a global ceiling; and the sender is
/// struck and eventually banned for naming unknown tokens either way.
///
/// It cannot be forged: the notice is sealed with a key derived from the
/// server master and that `token_id`, so no third party and no other device
/// can produce one.
///
/// Turning it off costs nothing in the common case. A revoked token keeps its
/// row and its key, so the ordinary "you are logged out" answer is a fully
/// authenticated `NACK` that never takes this path. This switch matters only
/// when the row is genuinely gone: a restored backup that predates the login,
/// or a rotated `OT_SECRET_KEY`. With it off, those devices get silence until
/// someone opens the app.
pub revocation_notices: bool,
/// Argon2id parameters. 19 MiB / 2 passes / 1 lane is the OWASP-recommended
/// second-choice profile and fits comfortably in a small VM.
pub argon2_memory_kib: u32,
pub argon2_iterations: u32,
pub argon2_parallelism: u32,
}
impl Default for Config {
fn default() -> Self {
Self {
http_addr: "127.0.0.1:7372".parse().expect("literal"),
udp_addr: "0.0.0.0:7373".parse().expect("literal"),
tls_addr: None,
udp_workers: None,
public_udp_host: "localhost".into(),
public_udp_port: 7373,
public_tls_url: None,
base_url: "http://localhost:7372".into(),
admin_email: CONTACT_PLACEHOLDER.into(),
db_path: PathBuf::from("opentracker.db"),
cache_dir: PathBuf::from("cache"),
max_cache_bytes: 1024 * 1024 * 1024,
tile_upstream_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png".into(),
retention_days: 7,
token_stale_days: 30,
ts_window_days: 30,
revocation_notices: true,
argon2_memory_kib: 19 * 1024,
argon2_iterations: 2,
argon2_parallelism: 1,
}
}
}
impl Config {
pub fn load(path: Option<&Path>) -> Result<Self> {
let mut cfg = match path {
Some(p) => {
let text = std::fs::read_to_string(p)
.with_context(|| format!("reading config {}", p.display()))?;
toml::from_str(&text).with_context(|| format!("parsing config {}", p.display()))?
}
None => Self::default(),
};
cfg.apply_env()?;
Ok(cfg)
}
/// `OT_*` overrides, so a systemd unit or container can configure the server
/// without a file.
fn apply_env(&mut self) -> Result<()> {
fn env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.is_empty())
}
fn parse<T: std::str::FromStr>(key: &str, slot: &mut T) -> Result<()>
where
T::Err: std::fmt::Display,
{
if let Some(v) = env(key) {
*slot = v.parse().map_err(|e| anyhow::anyhow!("{key}: {e}"))?;
}
Ok(())
}
parse("OT_HTTP_ADDR", &mut self.http_addr)?;
parse("OT_UDP_ADDR", &mut self.udp_addr)?;
parse("OT_PUBLIC_UDP_HOST", &mut self.public_udp_host)?;
parse("OT_PUBLIC_UDP_PORT", &mut self.public_udp_port)?;
parse("OT_BASE_URL", &mut self.base_url)?;
parse("OT_ADMIN_EMAIL", &mut self.admin_email)?;
parse("OT_DB_PATH", &mut self.db_path)?;
parse("OT_CACHE_DIR", &mut self.cache_dir)?;
parse("OT_MAX_CACHE_BYTES", &mut self.max_cache_bytes)?;
parse("OT_TILE_UPSTREAM_URL", &mut self.tile_upstream_url)?;
parse("OT_RETENTION_DAYS", &mut self.retention_days)?;
parse("OT_TOKEN_STALE_DAYS", &mut self.token_stale_days)?;
parse("OT_REVOCATION_NOTICES", &mut self.revocation_notices)?;
if let Some(v) = env("OT_TLS_ADDR") {
self.tls_addr = Some(v.parse().context("OT_TLS_ADDR")?);
}
if let Some(v) = env("OT_PUBLIC_TLS_URL") {
self.public_tls_url = Some(v);
}
Ok(())
}
/// Checks that would otherwise become confusing runtime failures.
pub fn validate(&self) -> Result<()> {
if self.admin_email == CONTACT_PLACEHOLDER || self.admin_email.is_empty() {
bail!(
"admin_email is still {CONTACT_PLACEHOLDER}. The OSM tile usage policy requires a \
contactable User-Agent, and an unidentified tile proxy gets blocked without \
notice. Set admin_email (or OT_ADMIN_EMAIL) to a real address."
);
}
if !self.tile_upstream_url.contains("{z}")
|| !self.tile_upstream_url.contains("{x}")
|| !self.tile_upstream_url.contains("{y}")
{
bail!("tile_upstream_url must contain {{z}}, {{x}} and {{y}} placeholders");
}
if self.retention_days == 0 {
bail!("retention_days must be at least 1");
}
Ok(())
}
pub fn udp_worker_count(&self) -> usize {
self.udp_workers
.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get().min(4)))
}
/// `User-Agent` for upstream tile requests. The policy prohibits library
/// defaults, so this is deliberately specific and contactable.
///
/// Used by the tile proxy, which lands in a later step; kept here now because
/// [`Config::validate`] already refuses to start without the contact address
/// it embeds, and the two belong together.
#[allow(dead_code)]
pub fn tile_user_agent(&self) -> String {
format!(
"opentracker/{} (self-hosted; +{}; contact: {})",
env!("CARGO_PKG_VERSION"),
self.base_url,
self.admin_email,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_placeholder_contact_blocks_startup() {
let cfg = Config::default();
assert!(
cfg.validate().is_err(),
"placeholder admin_email must be rejected"
);
}
#[test]
fn a_real_contact_passes() {
let cfg = Config {
admin_email: "ops@example.net".into(),
..Config::default()
};
cfg.validate().expect("should validate");
}
#[test]
fn a_tile_url_without_placeholders_is_rejected() {
let cfg = Config {
admin_email: "ops@example.net".into(),
tile_upstream_url: "https://tiles.example.com/map.png".into(),
..Config::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn the_user_agent_identifies_the_deployment() {
let cfg = Config {
admin_email: "ops@example.net".into(),
base_url: "https://track.example.net".into(),
..Config::default()
};
let ua = cfg.tile_user_agent();
assert!(ua.starts_with("opentracker/"));
assert!(ua.contains("track.example.net"));
assert!(ua.contains("ops@example.net"));
}
}
Acrates/otserver/src/db.rs
@@ -0,0 +1,278 @@
//! SQLite setup: pragmas, migrations, and the two-pool split.
//!
//! There are two pools, and the split is the whole performance story:
//!
//! * a **read pool** (4–8 connections) for HTTP handlers, and
//! * a **writer pool of exactly one connection**, owned by the writer task.
//!
//! SQLite allows one writer at a time. Rather than discovering that as
//! `SQLITE_BUSY` under load, the design makes it structural: all writes funnel
//! through one task that batches them. 100 devices reporting once a minute becomes
//! ~4 transactions per second instead of 100 fsyncs, and `SQLITE_BUSY` cannot
//! happen because there is never a second writer to contend with.
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context, Result};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{Executor, SqlitePool};
pub struct Db {
/// For HTTP handlers. Concurrent readers are free under WAL.
pub read: SqlitePool,
/// Single connection, held by the writer task. Nothing else may write.
pub write: SqlitePool,
}
/// Pragmas that are not optional.
///
/// * `journal_mode=WAL` — readers never block the writer, and vice versa.
/// * `synchronous=NORMAL` — with WAL this risks losing the last few
/// *transactions* on an OS crash, not corruption. For location history that is
/// the right trade against an fsync per commit.
/// * `busy_timeout` — belt and braces; the single-writer design should make it
/// unreachable.
/// * `foreign_keys=ON` — off by default in SQLite, which surprises everyone once.
/// * `auto_vacuum=INCREMENTAL` — lets the retention sweep return space in bounded
/// chunks instead of a stop-the-world VACUUM.
fn write_options(path: &Path) -> Result<SqliteConnectOptions> {
Ok(base_options(path)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.pragma("auto_vacuum", "incremental")
// Keep the WAL from growing without bound between checkpoints.
.pragma("journal_size_limit", "67108864")) // 64 MiB
}
/// Options for the read pool.
///
/// Read-only is enforced with `PRAGMA query_only` rather than by opening the file
/// `SQLITE_OPEN_READONLY`. The distinction matters: several of the pragmas above
/// are themselves writes, and a genuinely read-only handle also cannot create the
/// `-shm` file a WAL database needs, so it fails in ways that depend on whether a
/// writer happens to be attached. `query_only` rejects writes at the statement
/// level, which is the property actually wanted here.
fn read_options(path: &Path) -> Result<SqliteConnectOptions> {
Ok(base_options(path)?
.create_if_missing(false)
.pragma("query_only", "ON"))
}
fn base_options(path: &Path) -> Result<SqliteConnectOptions> {
Ok(
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))
.with_context(|| format!("bad database path {}", path.display()))?
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5))
.pragma("mmap_size", "268435456"), // 256 MiB
)
}
impl Db {
pub async fn open(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
// Migrations run on the writer pool: they are writes, and running them
// here means the read pool never sees a half-migrated schema.
let write = SqlitePoolOptions::new()
.max_connections(1)
.min_connections(1)
.connect_with(write_options(path)?)
.await
.with_context(|| format!("opening {} for writing", path.display()))?;
sqlx::migrate!("./migrations")
.run(&write)
.await
.context("running migrations")?;
let read = SqlitePoolOptions::new()
.max_connections(8)
.min_connections(2)
.connect_with(read_options(path)?)
.await
.with_context(|| format!("opening {} for reading", path.display()))?;
Ok(Self { read, write })
}
/// Flush the WAL back into the main database file. Called on shutdown so the
/// on-disk file is self-contained.
pub async fn checkpoint(&self) -> Result<()> {
self.write
.execute("PRAGMA wal_checkpoint(TRUNCATE);")
.await
.context("WAL checkpoint")?;
Ok(())
}
pub async fn close(&self) {
self.read.close().await;
self.write.close().await;
}
}
/// Seconds since the Unix epoch.
///
/// Every timestamp in this system is an `i64` of Unix seconds. No `TEXT`
/// datetimes, no local time, nowhere.
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway database in a temp directory, migrated and ready.
pub async fn test_db() -> (Db, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("test.db")).await.expect("open");
(db, dir)
}
#[tokio::test]
async fn migrations_apply_and_pragmas_take_effect() {
let (db, _dir) = test_db().await;
let journal: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(&db.write)
.await
.expect("journal_mode");
assert_eq!(journal.to_lowercase(), "wal");
let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(&db.write)
.await
.expect("foreign_keys");
assert_eq!(fk, 1, "foreign keys are off by default and must be enabled");
}
#[tokio::test]
async fn every_table_is_strict() {
let (db, _dir) = test_db().await;
let sql: Vec<String> = sqlx::query_scalar(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
AND name NOT LIKE '_sqlx%'",
)
.fetch_all(&db.read)
.await
.expect("schema");
assert!(!sql.is_empty());
for stmt in sql {
assert!(
stmt.to_uppercase().contains("STRICT"),
"table is not STRICT, so type affinity could store a string in an integer \
column:\n{stmt}"
);
}
}
#[tokio::test]
async fn the_read_pool_cannot_write() {
let (db, _dir) = test_db().await;
let err = sqlx::query("INSERT INTO settings (key, value) VALUES ('x', 'y')")
.execute(&db.read)
.await;
assert!(err.is_err(), "the read pool must be read-only");
}
#[tokio::test]
async fn the_points_primary_key_makes_replay_idempotent() {
let (db, _dir) = test_db().await;
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let insert = "INSERT INTO points (user_id, ts, lat, lon, acc_dm, recv_at) \
VALUES (1, 100, 5, 6, ?, 0) \
ON CONFLICT (user_id, ts) DO UPDATE SET \
acc_dm = excluded.acc_dm WHERE excluded.acc_dm < points.acc_dm";
sqlx::query(insert)
.bind(80)
.execute(&db.write)
.await
.expect("first");
// The same point again — a retry or a replay.
sqlx::query(insert)
.bind(80)
.execute(&db.write)
.await
.expect("replay");
// A second phone, same second, worse accuracy: must not win.
sqlx::query(insert)
.bind(200)
.execute(&db.write)
.await
.expect("worse");
// A second phone, same second, better accuracy: must win.
sqlx::query(insert)
.bind(30)
.execute(&db.write)
.await
.expect("better");
let (count, acc): (i64, i64) =
sqlx::query_as("SELECT COUNT(*), MIN(acc_dm) FROM points WHERE user_id = 1")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "a replayed datagram must not create a second row");
assert_eq!(
acc, 30,
"the better-accuracy point must win a same-second collision"
);
}
#[tokio::test]
async fn a_share_must_target_exactly_one_of_user_or_group() {
let (db, _dir) = test_db().await;
for (id, username) in [(1, "a"), (2, "b")] {
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (?, ?, 'x', 'X', 0, 0)",
)
.bind(id)
.bind(username)
.execute(&db.write)
.await
.expect("user");
}
// Neither target.
assert!(
sqlx::query("INSERT INTO shares (owner_user_id, created_at) VALUES (1, 0)")
.execute(&db.write)
.await
.is_err()
);
// Sharing with yourself.
assert!(
sqlx::query(
"INSERT INTO shares (owner_user_id, viewer_user_id, created_at) VALUES (1, 1, 0)"
)
.execute(&db.write)
.await
.is_err()
);
// A real share.
sqlx::query(
"INSERT INTO shares (owner_user_id, viewer_user_id, created_at) VALUES (1, 2, 0)",
)
.execute(&db.write)
.await
.expect("valid share");
}
}
Acrates/otserver/src/ingest.rs
@@ -0,0 +1,1004 @@
//! Transport-agnostic datagram handling.
//!
//! [`Ingest::handle`] is **synchronous** and takes a couple of microseconds. It
//! never touches the database: every active token lives in an in-memory map,
//! loaded at startup and updated on mint/revoke. That is what lets the UDP
//! receive loop stay a tight `recv_from` → `handle` → `send_to` cycle with no
//! `.await` in the middle, and it is why an unknown `token_id` is genuinely
//! unknown rather than merely uncached.
//!
//! The cost is memory proportional to the number of active tokens. At ~90 bytes
//! per slot, a million tokens would be 90 MB; for a self-hosted instance with a
//! handful of users it is a few kilobytes. If that ever stops being true, this is
//! the module to revisit.
//!
//! The same function serves the UDP loop and the TLS fallback listener, so the
//! two transports cannot drift apart in their handling of anything.
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
#[cfg(test)]
use otproto::msg::Direction;
use otproto::{
Ack, AckFlags, DecodeError, Header, Key, MAX_POINTS, Message, Nack, NackReason, Point,
RevokeReason, Revoked, kdf,
};
use rand::TryRngCore;
use rand::rngs::OsRng;
use tracing::{debug, trace};
use crate::limits::Limits;
use crate::writer::{Accepted, WriteHandle, WriteOp};
/// Which transport a datagram arrived on. Recorded per token so the UI can show
/// whether a phone is on UDP or has fallen back to TLS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Udp,
/// Constructed by the TLS-over-TCP fallback listener, which lands in a later
/// step; the recording path is already transport-agnostic so that listener
/// only has to call `Ingest::handle` with this variant.
#[allow(dead_code)]
Tls,
}
impl Transport {
const fn as_str(self) -> &'static str {
match self {
Self::Udp => "udp",
Self::Tls => "tls",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Peer {
pub addr: SocketAddr,
pub transport: Transport,
}
impl Peer {
pub fn ip(&self) -> IpAddr {
self.addr.ip()
}
}
/// Everything needed to serve one token, with no database round trip.
#[derive(Debug)]
pub struct TokenSlot {
pub token_id: u64,
/// Resolved once, at load time, so the write path never has to ask which
/// account a token belongs to.
pub user_id: i64,
pub k_up: Key,
pub k_down: Key,
pub config_version: u16,
/// Revoked tokens stay in the map instead of being removed.
///
/// The key is what lets the server answer at all, and a revoked device is
/// precisely the one it needs to answer. Dropping the slot would leave the
/// only possible reply an unauthenticated one — a reflector — where keeping
/// it makes the reply a sealed `NACK` nobody else could have produced.
///
/// The token authorises nothing from the moment this is set; the key survives
/// only so the server can say so.
pub revoked: Option<RevokeReason>,
}
impl TokenSlot {
pub fn new(token_id: u64, user_id: i64, token_key: &Key, config_version: u16) -> Self {
let (k_up, k_down) = kdf::derive_both(token_key);
Self {
token_id,
user_id,
k_up,
k_down,
config_version,
revoked: None,
}
}
#[must_use]
pub fn revoked(mut self, reason: RevokeReason) -> Self {
self.revoked = Some(reason);
self
}
}
/// Aggregate counters. Per-packet logging would itself be an amplifier, so these
/// are the only per-packet observability, exposed on a localhost-only `/metrics`.
#[derive(Debug, Default)]
pub struct Counters {
pub received: AtomicU64,
pub malformed: AtomicU64,
pub unknown_token: AtomicU64,
pub auth_failed: AtomicU64,
pub rate_limited: AtomicU64,
pub throttled: AtomicU64,
pub points_accepted: AtomicU64,
pub points_rejected: AtomicU64,
pub acks_sent: AtomicU64,
pub nacks_sent: AtomicU64,
pub silent_drops: AtomicU64,
/// Sealed notices to a token the server still holds a key for.
pub revoked_notices_sent: AtomicU64,
/// Notices sent without being able to verify the request. The reflection
/// budget, in other words — worth watching.
pub unverified_notices_sent: AtomicU64,
pub notices_suppressed: AtomicU64,
}
impl Counters {
fn bump(counter: &AtomicU64) {
counter.fetch_add(1, Ordering::Relaxed);
}
fn add(counter: &AtomicU64, n: u64) {
counter.fetch_add(n, Ordering::Relaxed);
}
}
pub struct Ingest {
tokens: DashMap<u64, Arc<TokenSlot>>,
limits: Limits,
writer: WriteHandle,
pub counters: Counters,
/// Accept timestamps within ±this many seconds of the server clock.
ts_window_s: u32,
/// Master for per-token revocation keys, or `None` when unverified notices
/// are switched off in config.
///
/// `Some` is the only thing that lets this server reply to a datagram it
/// cannot verify, so the config switch is represented as the presence of the
/// key rather than as a separate boolean. There is then no way to enable the
/// behaviour by accident.
revocation_master: Option<Key>,
}
impl Ingest {
pub fn new(writer: WriteHandle, ts_window_s: u32, revocation_master: Option<Key>) -> Self {
Self {
tokens: DashMap::new(),
limits: Limits::new(),
writer,
counters: Counters::default(),
ts_window_s,
revocation_master,
}
}
pub fn insert_token(&self, slot: TokenSlot) {
self.tokens.insert(slot.token_id, Arc::new(slot));
}
/// Called by revoke, by password change, and by the staleness sweep.
///
/// The slot stays, flagged. The token stops authorising anything
/// immediately; what survives is only the ability to tell that one device it
/// is finished, in a message nobody else could have sealed.
pub fn mark_revoked(&self, token_id: u64, reason: RevokeReason) {
if let Some(existing) = self.tokens.get(&token_id).map(|s| Arc::clone(&s)) {
if existing.revoked.is_some() {
return;
}
self.tokens.insert(
token_id,
Arc::new(TokenSlot {
revoked: Some(reason),
..*existing
}),
);
}
}
/// Active tokens only — a revoked slot is bookkeeping, not a live device.
pub fn active_token_count(&self) -> usize {
self.tokens.iter().filter(|s| s.revoked.is_none()).count()
}
pub fn limits(&self) -> &Limits {
&self.limits
}
/// Handle one datagram. Returns the bytes to send back, if any.
///
/// `now` is passed in rather than read from the clock so this is testable
/// without sleeping.
pub fn handle(&self, datagram: &[u8], peer: Peer, now: i64) -> Option<Vec<u8>> {
Counters::bump(&self.counters.received);
// 1. Structure. No state, no allocation, no crypto.
let header = match Header::peek(datagram) {
Ok(h) => h,
Err(e) => {
Counters::bump(&self.counters.malformed);
trace!(?e, "dropping malformed datagram");
return self.silent();
}
};
// A downlink type arriving on the uplink is either a bug or someone
// replaying our own traffic back at us. It can never be legitimate.
if !header.msg_type.is_uplink() {
Counters::bump(&self.counters.malformed);
return self.silent();
}
// 2. Per-IP budget and bans.
if let Err(reason) = self.limits.check_ip(peer.ip()) {
Counters::bump(&self.counters.rate_limited);
trace!(?reason, "dropping rate-limited datagram");
return self.silent();
}
// 3. Does this token exist?
let Some(slot) = self.tokens.get(&header.token_id).map(|s| Arc::clone(&s)) else {
Counters::bump(&self.counters.unknown_token);
self.limits.note_unknown_token(peer.ip());
// No key, so no way to verify this datagram — which makes any reply a
// reply to an address the sender merely claimed. See
// [`Self::unverified_notice`] for what makes that tolerable.
return self.unverified_notice(header.token_id, peer, datagram.len());
};
// 4. AEAD. The first expensive step, ~1 µs for a 62-byte packet.
//
// Everything below this line may answer; nothing above it ever does.
// That is not a style rule, it is the anti-reflection defence. UDP source
// addresses are trivially forged, and `token_id` travels in cleartext, so
// anyone who has seen one datagram can name a valid token. If the server
// replied before verifying, an attacker could spoof a victim's address
// and have us send them a packet per junk datagram — laundering the
// attacker's origin and firing at whatever rate they choose.
//
// Which is why the per-token budget is checked *after* this and not
// before, even though that costs an AEAD open on every flooded packet.
// The per-IP budget above absorbs the bulk at no crypto cost.
let payload = match otproto::open(&slot.k_up, datagram) {
Ok((_, payload)) => payload,
Err(DecodeError::AuthFailed) => {
Counters::bump(&self.counters.auth_failed);
self.limits.note_aead_failure(peer.ip());
// Never answer this. The server cannot know who sent it, so a
// reply would be both a forgery oracle and a reflector.
return self.silent();
}
Err(_) => {
Counters::bump(&self.counters.malformed);
return self.silent();
}
};
// 5. Is this token still alive? Checked after AEAD, so the answer is a
// sealed message the real device can trust and nobody else can forge.
// This is the whole reason revoked slots keep their keys.
if let Some(reason) = slot.revoked {
Counters::bump(&self.counters.revoked_notices_sent);
debug!(token_id = slot.token_id, ?reason, "revoked token reported");
return self.seal_reply(
&slot,
Message::Nack(Nack {
nonce: header.nonce,
reason: NackReason::UnknownToken,
retry_after_s: 0,
}),
);
}
// 6. Per-token budget. Authenticated, so this NACK reaches the device
// that actually sent the datagram and nobody else.
if self.limits.check_token(header.token_id).is_err() {
Counters::bump(&self.counters.rate_limited);
return self.nack(&slot, header.nonce, NackReason::RateLimited, 5);
}
let msg = match Message::decode_payload(header.msg_type, &payload) {
Ok(m) => m,
Err(e) => {
Counters::bump(&self.counters.malformed);
debug!(
token_id = header.token_id,
?e,
"authenticated but malformed payload"
);
return self.nack(&slot, header.nonce, NackReason::Malformed, 0);
}
};
// From here the packet is authenticated, so telemetry is safe to record.
self.record_seen(&slot, peer, now);
match msg {
Message::Loc(points) => self.handle_loc(&slot, header, points, now),
Message::Hello(hello) => {
self.writer.try_send(WriteOp::TokenHello {
token_id: slot.token_id as i64,
app_version: i64::from(hello.app_version_code),
os_api_level: i64::from(hello.os_api_level),
});
self.ack(&slot, vec![header.nonce], hello.config_version)
}
Message::ConfigGet(_) => {
// CONFIG is served by the HTTP/state path in this build; a device
// asking gets silence rather than a stale answer, and retries.
// Wired up with the config editor in a later step.
self.silent()
}
Message::Ping(ping) => {
// The echo is opaque to us; copying it back is the whole job.
let pong = Message::Pong(otproto::Pong {
echo: ping.echo,
seq: ping.seq,
});
self.seal_reply(&slot, pong)
}
// Unreachable: the direction check above rejected every downlink type.
Message::Ack(_)
| Message::Nack(_)
| Message::Config(_)
| Message::Pong(_)
| Message::Revoked(_) => {
Counters::bump(&self.counters.malformed);
self.silent()
}
}
}
fn handle_loc(
&self,
slot: &TokenSlot,
header: Header,
points: Vec<Point>,
now: i64,
) -> Option<Vec<u8>> {
// Semantic validation. A point far outside the timestamp window would
// land where the retention sweep never reaches it, so it is dropped
// rather than stored — but the rest of the batch is still kept, because
// one bad fix must not cost the user a whole journey.
let before = points.len();
let accepted: Vec<Point> = points
.into_iter()
.filter(|p| p.validate(now as u32, self.ts_window_s).is_ok())
.collect();
let rejected = before - accepted.len();
Counters::add(&self.counters.points_rejected, rejected as u64);
if accepted.is_empty() {
Counters::bump(&self.counters.malformed);
return self.nack(slot, header.nonce, NackReason::Malformed, 0);
}
Counters::add(&self.counters.points_accepted, accepted.len() as u64);
let accepted_count = accepted.len();
match self.writer.try_send(WriteOp::Points {
user_id: slot.user_id,
src_token_id: slot.token_id as i64,
points: accepted,
recv_at: now,
}) {
Accepted::Yes => {}
Accepted::Saturated => {
// Do not ack what we did not store: the client keeps the points
// queued and retries. THROTTLE tells it to slow down first.
Counters::bump(&self.counters.throttled);
return self.throttled(slot, header.nonce);
}
Accepted::Closed => return self.silent(),
}
trace!(
token_id = slot.token_id,
user_id = slot.user_id,
accepted_count,
"stored points"
);
self.ack(slot, vec![header.nonce], slot.config_version)
}
fn record_seen(&self, slot: &TokenSlot, peer: Peer, now: i64) {
// Best effort: if the writer is saturated, telemetry is the first thing
// worth dropping.
self.writer.try_send(WriteOp::TokenSeen {
token_id: slot.token_id as i64,
at: now,
src_ip: peer.ip().to_string(),
src_port: peer.addr.port(),
transport: peer.transport.as_str(),
});
}
fn ack(
&self,
slot: &TokenSlot,
nonces: Vec<[u8; 12]>,
device_config_version: u16,
) -> Option<Vec<u8>> {
let flags = if device_config_version < slot.config_version {
AckFlags::CONFIG_PENDING
} else {
AckFlags::NONE
};
debug_assert!(nonces.len() <= MAX_POINTS);
let ack = Message::Ack(Ack { nonces, flags });
Counters::bump(&self.counters.acks_sent);
self.seal_reply(slot, ack)
}
/// The writer channel is full, so the points were not stored.
///
/// This is a `NACK`, not an `ACK` with the `THROTTLE` flag, because an `ACK`
/// retires the nonces it names: acking here would tell the client to delete
/// points that never reached the database. `RateLimited` with a retry hint is
/// exactly the "keep them and back off" semantic the client already
/// implements, so saturation costs a delay rather than data.
fn throttled(&self, slot: &TokenSlot, nonce: [u8; 12]) -> Option<Vec<u8>> {
self.nack(slot, nonce, NackReason::RateLimited, 10)
}
fn nack(
&self,
slot: &TokenSlot,
nonce: [u8; 12],
reason: NackReason,
retry_after_s: u8,
) -> Option<Vec<u8>> {
Counters::bump(&self.counters.nacks_sent);
self.seal_reply(
slot,
Message::Nack(Nack {
nonce,
reason,
retry_after_s,
}),
)
}
/// The one reply this server sends without having verified the request.
///
/// The server has no record of this `token_id`, so it cannot open the
/// datagram and cannot know who really sent it. `peer` is whatever the source
/// address claimed, which on UDP is forgeable. Answering therefore makes this
/// port a reflector, and three things are what keep that from mattering:
///
/// 1. **It cannot amplify.** [`Revoked`] is 38 bytes, the smallest message in
/// the protocol, and a request shorter than that gets nothing.
/// 2. **It is rate limited by destination.** The budget is keyed on the
/// address the reply would go to — the victim, for a spoofed packet — at
/// one per minute, under a global ceiling for distributed attempts.
/// 3. **It is optional.** No revocation master configured, no reply.
///
/// It cannot be forged, which is the other half. `K_rev` is derived from a
/// server master and this `token_id`, so a third party cannot produce one and
/// neither can another legitimate device — each only ever learns its own.
///
/// This path exists for the cases where the row is genuinely gone: a database
/// restored from a backup that predates the login, or a rotated server key.
/// The ordinary revocation path keeps the slot and answers with a sealed
/// `NACK`, which needs none of the above.
fn unverified_notice(&self, token_id: u64, peer: Peer, request_len: usize) -> Option<Vec<u8>> {
let Some(master) = self.revocation_master else {
return self.silent();
};
if !otproto::may_answer_unverified(request_len) {
return self.silent();
}
if self.limits.check_notice(peer.ip()).is_err() {
Counters::bump(&self.counters.notices_suppressed);
return self.silent();
}
let mut nonce = [0u8; 12];
if OsRng.try_fill_bytes(&mut nonce).is_err() {
return self.silent();
}
let msg = Message::Revoked(Revoked {
reason: RevokeReason::Unknown,
});
let reply = otproto::seal_message(
&otproto::revocation_key(&master, token_id),
token_id,
nonce,
&msg,
);
debug_assert!(
reply.len() <= request_len,
"an unverified reply must never exceed its request"
);
Counters::bump(&self.counters.unverified_notices_sent);
Some(reply)
}
/// Seal a reply.
///
/// Anti-amplification is not enforced here, because by this point it is
/// already established: a reply is only reached after the datagram passed AEAD
/// verification, so a reflection attacker must hold a live token key, and
/// every reply this server can construct fits in [`otproto::MAX_REPLY`] bytes
/// — a ratio near 1 against any request. The `debug_assert` is a development
/// guard against a future message type outgrowing that budget, not a runtime
/// control.
fn seal_reply(&self, slot: &TokenSlot, msg: Message) -> Option<Vec<u8>> {
debug_assert!(
otproto::fits_reply_budget(otproto::datagram_len(msg.payload_len())),
"{:?} exceeds the reply budget of {} bytes",
msg.msg_type(),
otproto::MAX_REPLY,
);
let mut nonce = [0u8; 12];
if OsRng.try_fill_bytes(&mut nonce).is_err() {
// Without fresh randomness we must not seal anything: reusing a nonce
// under ChaCha20-Poly1305 leaks the keystream.
return self.silent();
}
Some(otproto::seal_message(
&slot.k_down,
slot.token_id,
nonce,
&msg,
))
}
fn silent(&self) -> Option<Vec<u8>> {
Counters::bump(&self.counters.silent_drops);
None
}
/// Derived key for a direction. Test-only: the live paths look up the slot and
/// use both keys, and exposing a key by id elsewhere would invite misuse.
#[cfg(test)]
pub fn key_for(&self, token_id: u64, dir: Direction) -> Option<Key> {
self.tokens.get(&token_id).map(|s| match dir {
Direction::Up => s.k_up,
Direction::Down => s.k_down,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
use otproto::point::Flags;
use otproto::{HEADER_LEN, TAG_LEN};
const TOKEN_ID: u64 = 0x0123_4567_89AB_CDEF;
const TOKEN_KEY: Key = [0x5A; 32];
const REVOCATION_MASTER: Key = [0xC3; 32];
const NOW: i64 = 1_785_000_042;
fn peer() -> Peer {
Peer {
addr: "203.0.113.5:40000".parse().expect("literal"),
transport: Transport::Udp,
}
}
async fn fixture() -> (Ingest, Db, tempfile::TempDir, tokio::task::JoinHandle<()>) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let (writer, task) = crate::writer::spawn(db.write.clone());
let ingest = Ingest::new(writer, 30 * 86_400, Some(REVOCATION_MASTER));
ingest.insert_token(TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 1));
(ingest, db, dir, task)
}
fn seal(msg: &Message, nonce_seed: u8) -> Vec<u8> {
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
otproto::seal_message(&k_up, TOKEN_ID, [nonce_seed; 12], msg)
}
fn loc(ts: u32) -> Message {
Message::Loc(vec![Point {
acc_dm: Some(80),
flags: Flags::NONE,
..Point::new(ts, 525_200_080, 134_050_000)
}])
}
/// The anti-reflection invariant, stated as a test: nothing that fails the
/// AEAD check may produce a reply.
///
/// UDP source addresses are forgeable, so any reply to an unverified
/// datagram is a packet an attacker can aim at a third party. `token_id` is
/// cleartext, so naming a real token costs nothing — which is what makes the
/// per-token rate limit the interesting case here rather than a theoretical
/// one. Its budget is small enough that a flood trips it immediately.
#[tokio::test]
async fn nothing_that_fails_aead_ever_gets_a_reply() {
let (ingest, _db, _dir, _task) = fixture().await;
let valid = seal(&loc(NOW as u32), 1);
// Far past any per-token budget, so the pre-AEAD ordering bug would show
// up here as a rate-limit NACK sent to an unauthenticated sender.
for i in 0..200 {
// A real header naming a real token, with a corrupted tag.
let mut forged = valid.clone();
let last = forged.len() - 1;
forged[last] ^= 1;
forged[HEADER_LEN] ^= i as u8;
assert_eq!(
ingest.handle(&forged, peer(), NOW),
None,
"a datagram that fails AEAD was answered on attempt {i}"
);
// And a header-only datagram, which cannot authenticate at all.
let stub = valid[..HEADER_LEN + TAG_LEN].to_vec();
assert_eq!(
ingest.handle(&stub, peer(), NOW),
None,
"a truncated datagram was answered on attempt {i}"
);
}
assert_eq!(
ingest.counters.acks_sent.load(Ordering::Relaxed)
+ ingest.counters.nacks_sent.load(Ordering::Relaxed),
0,
"the server sent something in response to unauthenticated traffic"
);
}
#[tokio::test]
async fn a_valid_loc_is_acked_and_stored() {
let (ingest, db, _dir, _task) = fixture().await;
let datagram = seal(&loc(NOW as u32), 1);
let reply = ingest.handle(&datagram, peer(), NOW).expect("should ack");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
let (_, msg) = otproto::open_message(&k_down, &reply).expect("ack opens");
match msg {
Message::Ack(ack) => {
assert_eq!(
ack.nonces,
vec![[1u8; 12]],
"the ack must echo the request nonce"
);
}
other => panic!("expected an ACK, got {other:?}"),
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points WHERE user_id = 1")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1);
}
#[tokio::test]
async fn an_unknown_token_is_counted_and_struck() {
let (ingest, _db, _dir, _task) = fixture().await;
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let datagram = otproto::seal_message(&k_up, 0xDEAD_BEEF, [2; 12], &loc(NOW as u32));
// The notice itself is covered by
// `an_unknown_token_draws_one_small_rate_limited_notice`; what matters
// here is that answering did not stop the sender being treated as a
// scanner. Naming unknown ids still earns strikes and eventually a ban.
let _ = ingest.handle(&datagram, peer(), NOW);
assert_eq!(ingest.counters.unknown_token.load(Ordering::Relaxed), 1);
assert_eq!(ingest.counters.points_accepted.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn a_forged_datagram_gets_silence() {
let (ingest, _db, _dir, _task) = fixture().await;
let mut datagram = seal(&loc(NOW as u32), 3);
let last = datagram.len() - 1;
datagram[last] ^= 1;
assert!(
ingest.handle(&datagram, peer(), NOW).is_none(),
"a failed tag must never be answered"
);
assert_eq!(ingest.counters.auth_failed.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn a_downlink_message_arriving_on_the_uplink_is_dropped() {
let (ingest, _db, _dir, _task) = fixture().await;
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
let pong = Message::Pong(otproto::Pong { echo: 1, seq: 3 });
let datagram = otproto::seal_message(&k_down, TOKEN_ID, [4; 12], &pong);
assert!(ingest.handle(&datagram, peer(), NOW).is_none());
}
#[tokio::test]
async fn a_ping_is_answered_with_a_pong_of_no_greater_size() {
let (ingest, _db, _dir, _task) = fixture().await;
let ping = Message::Ping(otproto::Ping {
echo: 0xDEAD_BEEF,
seq: 7,
});
let datagram = seal(&ping, 5);
let reply = ingest.handle(&datagram, peer(), NOW).expect("pong");
assert!(reply.len() <= datagram.len(), "PONG amplified the PING");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &reply).expect("opens").1 {
Message::Pong(p) => {
assert_eq!(p.seq, 7);
assert_eq!(
p.echo, 0xDEAD_BEEF,
"the PING's opaque echo must come back untouched"
);
}
other => panic!("expected a PONG, got {other:?}"),
}
}
#[tokio::test]
async fn a_point_outside_the_timestamp_window_is_rejected() {
let (ingest, db, _dir, _task) = fixture().await;
// Year 2100: far beyond anything the retention sweep would ever reach.
let datagram = seal(&loc(4_102_444_800), 6);
let reply = ingest.handle(&datagram, peer(), NOW).expect("nack");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &reply).expect("opens").1 {
Message::Nack(n) => assert_eq!(n.reason, NackReason::Malformed),
other => panic!("expected a NACK, got {other:?}"),
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 0);
}
#[tokio::test]
async fn one_bad_point_does_not_cost_the_whole_batch() {
let (ingest, db, _dir, _task) = fixture().await;
let good = Point {
acc_dm: Some(80),
..Point::new(NOW as u32, 525_200_080, 134_050_000)
};
let bad = Point::new(NOW as u32, 910_000_000, 0); // impossible latitude
let datagram = seal(&Message::Loc(vec![good, bad]), 7);
assert!(
ingest.handle(&datagram, peer(), NOW).is_some(),
"should still ack"
);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "the good point must survive its bad neighbour");
assert_eq!(ingest.counters.points_rejected.load(Ordering::Relaxed), 1);
}
/// A revoked token keeps its key so this answer is possible at all.
///
/// The alternative — dropping the slot — leaves silence as the only safe
/// reply, and the phone keeps reporting into nothing until someone notices.
#[tokio::test]
async fn a_revoked_token_is_told_so_in_a_message_it_can_verify() {
let (ingest, _db, _dir, _task) = fixture().await;
ingest.mark_revoked(TOKEN_ID, RevokeReason::Revoked);
let datagram = seal(&loc(NOW as u32), 8);
let reply = ingest
.handle(&datagram, peer(), NOW)
.expect("a revoked token must be told, not ignored");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
let (_, msg) = otproto::open_message(&k_down, &reply).expect("sealed under K_down");
match msg {
Message::Nack(nack) => assert_eq!(nack.reason, NackReason::UnknownToken),
other => panic!("expected a NACK, got {other:?}"),
}
// And the points did not land.
assert_eq!(ingest.counters.points_accepted.load(Ordering::Relaxed), 0);
}
/// The unauthenticated path, and everything that bounds it.
#[tokio::test]
async fn an_unknown_token_draws_one_small_rate_limited_notice() {
let (ingest, _db, _dir, _task) = fixture().await;
let stranger = 0xDEAD_BEEF_CAFE_F00D_u64;
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let datagram = otproto::seal_message(&k_up, stranger, [9; 12], &loc(NOW as u32));
let reply = ingest
.handle(&datagram, peer(), NOW)
.expect("an unknown token should draw a notice");
// Never larger than what provoked it: that is what keeps a reflector from
// being an amplifier.
assert!(
reply.len() <= datagram.len(),
"reply {} B for a {} B request",
reply.len(),
datagram.len()
);
// Sealed under this token id's K_rev, which no other device can derive.
let k_rev = otproto::revocation_key(&REVOCATION_MASTER, stranger);
let (header, msg) = otproto::open_message(&k_rev, &reply).expect("sealed under K_rev");
assert_eq!(header.token_id, stranger);
assert_eq!(
msg,
Message::Revoked(Revoked {
reason: RevokeReason::Unknown
})
);
assert!(
otproto::open(
&otproto::revocation_key(&REVOCATION_MASTER, stranger ^ 1),
&reply
)
.is_err(),
"a notice opened under another token's K_rev"
);
// One per destination per minute. Everything after is silence, so a
// spoofed victim is sent a message, not a flood.
for i in 0..50 {
assert!(
ingest.handle(&datagram, peer(), NOW).is_none(),
"a second notice went out on attempt {i}"
);
}
assert_eq!(
ingest
.counters
.unverified_notices_sent
.load(Ordering::Relaxed),
1
);
}
/// A datagram too short to have cost the sender anything earns nothing.
#[tokio::test]
async fn a_minimum_size_datagram_never_draws_a_notice() {
let (ingest, _db, _dir, _task) = fixture().await;
let mut runt = vec![0u8; otproto::MIN_DATAGRAM];
runt[0] = 0x11; // version 1, type LOC
runt[1..9].copy_from_slice(&0xDEAD_BEEF_u64.to_be_bytes());
assert!(ingest.handle(&runt, peer(), NOW).is_none());
assert_eq!(
ingest
.counters
.unverified_notices_sent
.load(Ordering::Relaxed),
0
);
}
/// With no master configured, the server has no way to reply to something it
/// cannot verify — which is the whole of the config switch.
#[tokio::test]
async fn notices_are_off_without_a_revocation_master() {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Ingest::new(writer, 30 * 86_400, None);
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let datagram = otproto::seal_message(&k_up, 0x1234, [4; 12], &loc(NOW as u32));
assert!(ingest.handle(&datagram, peer(), NOW).is_none());
}
#[tokio::test]
async fn a_config_pending_flag_appears_when_the_device_is_behind() {
let (ingest, _db, _dir, _task) = fixture().await;
ingest.insert_token(TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 5));
let hello = Message::Hello(otproto::Hello {
app_version_code: 1,
os_api_level: 34,
flags: otproto::HelloFlags::NONE,
config_version: 2, // behind the server's 5
});
let datagram = seal(&hello, 9);
let reply = ingest.handle(&datagram, peer(), NOW).expect("ack");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &reply).expect("opens").1 {
Message::Ack(a) => assert!(
a.flags.contains(AckFlags::CONFIG_PENDING),
"the device is behind and must be told to fetch config"
),
other => panic!("expected an ACK, got {other:?}"),
}
}
/// The anti-amplification property, as it actually is.
///
/// Not `reply <= request` — that rule was paid for with reserved padding on
/// every `HELLO` and `PING`, to defend against a threat authentication already
/// removes. What holds instead: every reply fits the reply budget, and the
/// resulting ratio is nowhere near enough leverage to be worth reflecting
/// through — and the sender needed a valid token key to get a reply at all,
/// which the silence tests above cover.
#[tokio::test]
async fn replies_stay_inside_the_reply_budget() {
let (ingest, _db, _dir, _task) = fixture().await;
let requests = [
seal(&loc(NOW as u32), 10),
seal(
&Message::Loc(
(0..MAX_POINTS as u32)
.map(|i| Point::new(NOW as u32 - i, 1, 2))
.collect(),
),
11,
),
seal(&Message::Ping(otproto::Ping { echo: 1, seq: 1 }), 12),
seal(
&Message::Hello(otproto::Hello {
app_version_code: 1,
os_api_level: 29,
flags: otproto::HelloFlags::NONE,
config_version: 1,
}),
13,
),
];
for datagram in requests {
if let Some(reply) = ingest.handle(&datagram, peer(), NOW) {
assert!(
otproto::fits_reply_budget(reply.len()),
"a {}-byte request drew a {}-byte reply, over the {}-byte budget",
datagram.len(),
reply.len(),
otproto::MAX_REPLY,
);
let ratio = reply.len() as f64 / datagram.len() as f64;
assert!(
ratio <= 1.5,
"a {}-byte request drew a {}-byte reply, {ratio:.2}x amplification",
datagram.len(),
reply.len(),
);
}
}
}
/// Garbage never panics, and never draws anything but a bounded notice.
///
/// A run of `0x11` bytes parses as a well-formed header for token
/// `0x1111111111111111`, which the server does not have — so the notice path
/// is reachable from pure garbage by construction. That is expected. What
/// must hold is that the reply is never larger than the request and that the
/// budget stops it almost immediately.
#[tokio::test]
async fn garbage_never_panics_and_never_amplifies() {
let (ingest, _db, _dir, _task) = fixture().await;
for len in [0usize, 1, 20, 36, 37, 100, 1200, 1201] {
for fill in [0u8, 0x11, 0xFF] {
let datagram = vec![fill; len];
if let Some(reply) = ingest.handle(&datagram, peer(), NOW) {
assert!(
reply.len() <= datagram.len(),
"len {len} fill {fill} drew a {} B reply",
reply.len()
);
}
}
}
assert!(
ingest
.counters
.unverified_notices_sent
.load(Ordering::Relaxed)
<= 1,
"the per-destination budget should have stopped after the first notice"
);
}
}
Acrates/otserver/src/keys.rs
@@ -0,0 +1,257 @@
//! Wrapping of token secrets at rest.
//!
//! Every `token_key` is stored encrypted with a server key, with the `token_id`
//! as additional data. A stolen `.db` therefore yields no working keys, and a
//! wrapped blob cannot be moved from one token row to another.
//!
//! The server refuses to start without a key, and accepts an old one for a
//! one-shot rotation.
use anyhow::{Context, Result, bail};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use hkdf::Hkdf;
use rand::TryRngCore;
use rand::rngs::OsRng;
use sha2::Sha256;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
/// HKDF label separating the revocation master from the wrapping key. They come
/// from the same secret and must never be the same value.
const REVOCATION_MASTER_INFO: &[u8] = b"otp/1/revoke-master";
/// The server's key-wrapping key(s).
pub struct KeyVault {
current: ChaCha20Poly1305,
/// Accepted for unwrapping only, so a rotation can re-wrap lazily.
previous: Option<ChaCha20Poly1305>,
/// Master for per-token revocation keys. Derived from the same secret rather
/// than configured separately: one required environment variable is enough,
/// and an operator who has to manage two will eventually lose one.
///
/// The consequence is that rotating `OT_SECRET_KEY` also invalidates every
/// `K_rev` already issued. Devices that logged in beforehand stop being able
/// to verify a revocation notice and fall back to silence until their next
/// login — the behaviour they would have had anyway without this mechanism.
revocation_master: otproto::Key,
}
impl KeyVault {
/// Reads `OT_SECRET_KEY` (base64, 32 bytes) or the file it names, plus the
/// optional `OT_SECRET_KEY_OLD`.
pub fn from_env() -> Result<Self> {
let current = load("OT_SECRET_KEY")?.ok_or_else(|| {
anyhow::anyhow!(
"OT_SECRET_KEY is not set. It must be 32 random bytes, base64-encoded, or the path \
to a 0600 file containing them. Generate one with:\n \
head -c32 /dev/urandom | base64\n\
Without it, token keys would sit in the database in the clear."
)
})?;
let previous = load("OT_SECRET_KEY_OLD")?;
Ok(Self {
revocation_master: derive_revocation_master(¤t),
current: ChaCha20Poly1305::new((¤t).into()),
previous: previous.map(|k| ChaCha20Poly1305::new((&k).into())),
})
}
#[cfg(test)]
pub fn for_test(key: [u8; KEY_LEN]) -> Self {
Self {
revocation_master: derive_revocation_master(&key),
current: ChaCha20Poly1305::new((&key).into()),
previous: None,
}
}
/// The master from which every `K_rev` is derived.
///
/// Handed to [`crate::ingest::Ingest`] so the hot path can seal a notice for
/// a `token_id` it has never seen, without a database round trip and without
/// storing anything per token.
pub fn revocation_master(&self) -> otproto::Key {
self.revocation_master
}
/// The key sealing revocation notices for one token. Also what the login
/// response hands the device.
pub fn revocation_key(&self, token_id: u64) -> otproto::Key {
otproto::revocation_key(&self.revocation_master, token_id)
}
/// `nonce || ciphertext || tag`, with the token id as AAD.
pub fn wrap(&self, token_id: u64, token_key: &[u8; KEY_LEN]) -> Result<Vec<u8>> {
let mut nonce = [0u8; NONCE_LEN];
OsRng.try_fill_bytes(&mut nonce).context("OS RNG failed")?;
let sealed = self
.current
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: token_key,
aad: &token_id.to_be_bytes(),
},
)
.map_err(|_| anyhow::anyhow!("wrapping token key failed"))?;
let mut out = Vec::with_capacity(NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
pub fn unwrap(&self, token_id: u64, blob: &[u8]) -> Result<[u8; KEY_LEN]> {
if blob.len() < NONCE_LEN + 16 {
bail!(
"wrapped key for token {token_id} is truncated ({} bytes)",
blob.len()
);
}
let (nonce, sealed) = blob.split_at(NONCE_LEN);
let aad = token_id.to_be_bytes();
for cipher in [Some(&self.current), self.previous.as_ref()]
.into_iter()
.flatten()
{
if let Ok(plain) = cipher.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: sealed,
aad: &aad,
},
) {
return plain.try_into().map_err(|v: Vec<u8>| {
anyhow::anyhow!("token key is {} bytes, want {KEY_LEN}", v.len())
});
}
}
bail!(
"cannot unwrap the key for token {token_id}: neither OT_SECRET_KEY nor \
OT_SECRET_KEY_OLD decrypts it"
)
}
}
fn load(var: &str) -> Result<Option<[u8; KEY_LEN]>> {
let Ok(raw) = std::env::var(var) else {
return Ok(None);
};
if raw.is_empty() {
return Ok(None);
}
// A path is more likely than base64 to contain a '/', so decide on whether
// the value names an existing file rather than on its shape.
let text = if std::path::Path::new(&raw).is_file() {
std::fs::read_to_string(&raw).with_context(|| format!("{var}: reading {raw}"))?
} else {
raw
};
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(text.trim())
.with_context(|| format!("{var} is not valid base64"))?;
if bytes.len() != KEY_LEN {
bail!("{var} decodes to {} bytes, want {KEY_LEN}", bytes.len());
}
Ok(Some(bytes.try_into().expect("length checked")))
}
/// 32 fresh random bytes for a new token secret.
pub fn random_token_key() -> Result<[u8; KEY_LEN]> {
let mut k = [0u8; KEY_LEN];
OsRng.try_fill_bytes(&mut k).context("OS RNG failed")?;
Ok(k)
}
/// A random, non-zero `token_id`.
///
/// Random rather than sequential because the id travels in cleartext in every
/// datagram header: a guessable one would let an attacker enumerate which tokens
/// exist by watching for the absence of a reply.
pub fn random_token_id() -> Result<u64> {
loop {
let mut b = [0u8; 8];
OsRng.try_fill_bytes(&mut b).context("OS RNG failed")?;
let id = u64::from_be_bytes(b);
// 0 is reserved as "unset" in a few places; rejecting it costs nothing.
if id != 0 {
return Ok(id);
}
}
}
fn derive_revocation_master(secret: &[u8; KEY_LEN]) -> otproto::Key {
let hk = Hkdf::<Sha256>::from_prk(secret).expect("32-byte PRK is valid for HKDF-SHA256");
let mut out = [0u8; KEY_LEN];
hk.expand(REVOCATION_MASTER_INFO, &mut out)
.expect("32 bytes is well under HKDF-SHA256's output limit");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_then_unwrap_round_trips() {
let vault = KeyVault::for_test([7; KEY_LEN]);
let key = [0x42; KEY_LEN];
let blob = vault.wrap(99, &key).expect("wrap");
assert_eq!(vault.unwrap(99, &blob).expect("unwrap"), key);
}
#[test]
fn a_blob_cannot_be_moved_to_another_token() {
// The token id is AAD, so a row-swap in the database is detected rather
// than silently cloning a credential onto another token.
let vault = KeyVault::for_test([7; KEY_LEN]);
let blob = vault.wrap(99, &[0x42; KEY_LEN]).expect("wrap");
assert!(vault.unwrap(100, &blob).is_err());
}
#[test]
fn a_tampered_blob_is_rejected() {
let vault = KeyVault::for_test([7; KEY_LEN]);
let mut blob = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
let last = blob.len() - 1;
blob[last] ^= 1;
assert!(vault.unwrap(1, &blob).is_err());
}
#[test]
fn a_wrong_server_key_cannot_unwrap() {
let blob = KeyVault::for_test([7; KEY_LEN])
.wrap(1, &[1; KEY_LEN])
.expect("wrap");
assert!(KeyVault::for_test([8; KEY_LEN]).unwrap(1, &blob).is_err());
}
#[test]
fn truncated_blobs_fail_with_a_clear_error() {
let vault = KeyVault::for_test([7; KEY_LEN]);
assert!(vault.unwrap(1, &[]).is_err());
assert!(vault.unwrap(1, &[0; NONCE_LEN]).is_err());
}
#[test]
fn wrapping_is_randomised() {
// Same key, same token, different ciphertext: the nonce is fresh each
// time, so the database never reveals that two tokens share a secret.
let vault = KeyVault::for_test([7; KEY_LEN]);
let a = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
let b = vault.wrap(1, &[1; KEY_LEN]).expect("wrap");
assert_ne!(a, b);
}
#[test]
fn token_ids_are_never_zero() {
for _ in 0..100 {
assert_ne!(random_token_id().expect("id"), 0);
}
}
}
Acrates/otserver/src/limits.rs
@@ -0,0 +1,332 @@
//! Abuse handling for the open UDP port.
//!
//! Layered in packet-touch order, cheapest check first, so that spending CPU on a
//! packet is always justified by the packet having survived everything cheaper:
//!
//! 1. length / version / type filter (in `otproto`, no state at all)
//! 2. per-IP token bucket
//! 3. unknown-token blocklist
//! 4. per-token token bucket
//! 5. AEAD verification — the first genuinely expensive step (~1 µs)
//! 6. AEAD-failure blocklist
//! 7. writer saturation → `THROTTLE`
//!
//! Two cross-cutting rules:
//!
//! * **Never reply to an unauthenticated packet.** Not with an error, not with a
//! `NACK`. Anything else makes the port a reflector and a forgery oracle.
//! * **The rate limiter must not itself be a memory-exhaustion vector.** Every map
//! here is capacity-capped and swept, because an attacker chooses the keys.
use std::net::IpAddr;
use std::num::NonZeroU32;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use governor::clock::DefaultClock;
use governor::state::keyed::DefaultKeyedStateStore;
use governor::state::{InMemoryState, NotKeyed};
use governor::{Quota, RateLimiter};
/// Sustained packets per second from one IP, and how many may arrive at once.
/// Generous: a single IP can be a whole household behind NAT, all flushing queues
/// after a dead spot.
const IP_RATE: u32 = 25;
const IP_BURST: u32 = 75;
/// Per token. One report per second is already far above any profile; the burst
/// covers a queue flush after an offline stretch.
const TOKEN_RATE: u32 = 3;
const TOKEN_BURST: u32 = 40;
/// Revocation notices, the one reply sent without authenticating the request.
///
/// These numbers are the entire mitigation, so they are deliberately mean. The
/// key is the *destination* address, which for a spoofed datagram is the victim:
/// one packet a minute is a message, not a flood. A revoked device needs exactly
/// one to act, and it retries on its own schedule anyway.
///
/// The global ceiling bounds a distributed spoof, where each victim stays under
/// the per-address limit but the server is still made to emit broadly.
const NOTICE_PER_ADDR_PER_MINUTE: u32 = 1;
const NOTICE_GLOBAL_RATE: u32 = 10;
const NOTICE_GLOBAL_BURST: u32 = 20;
/// Strikes within [`STRIKE_WINDOW`] before an IP is banned for [`BAN_DURATION`].
const UNKNOWN_TOKEN_STRIKES: u32 = 50;
const AEAD_FAIL_STRIKES: u32 = 50;
const STRIKE_WINDOW: Duration = Duration::from_secs(60);
const BAN_DURATION: Duration = Duration::from_secs(600);
/// Hard ceiling on tracked IPs. Reached only under attack; when it is, the map is
/// swept rather than grown, because unbounded growth is the vulnerability.
const MAX_TRACKED_IPS: usize = 100_000;
type Keyed<K> = RateLimiter<K, DefaultKeyedStateStore<K>, DefaultClock>;
type Direct = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
fn quota(rate: u32, burst: u32) -> Quota {
Quota::per_second(NonZeroU32::new(rate).expect("rate is a non-zero literal"))
.allow_burst(NonZeroU32::new(burst).expect("burst is a non-zero literal"))
}
#[derive(Debug, Default)]
struct Strikes {
count: u32,
window_started: Option<Instant>,
banned_until: Option<Instant>,
}
pub struct Limits {
per_ip: Keyed<IpAddr>,
per_token: Keyed<u64>,
notice_per_addr: Keyed<IpAddr>,
notice_global: Direct,
unknown_token: DashMap<IpAddr, Strikes>,
aead_fail: DashMap<IpAddr, Strikes>,
}
/// Why a packet was dropped. Used for aggregate counters only — never logged per
/// packet, because per-packet logging is itself a denial-of-service amplifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Drop {
IpRate,
TokenRate,
Banned,
/// The unauthenticated-notice budget, per destination or global.
NoticeRate,
}
impl Default for Limits {
fn default() -> Self {
Self::new()
}
}
impl Limits {
pub fn new() -> Self {
Self {
per_ip: RateLimiter::keyed(quota(IP_RATE, IP_BURST)),
per_token: RateLimiter::keyed(quota(TOKEN_RATE, TOKEN_BURST)),
notice_per_addr: RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(NOTICE_PER_ADDR_PER_MINUTE).expect("non-zero literal"),
)),
notice_global: RateLimiter::direct(quota(NOTICE_GLOBAL_RATE, NOTICE_GLOBAL_BURST)),
unknown_token: DashMap::new(),
aead_fail: DashMap::new(),
}
}
/// Step 2 and 3: is this source allowed to send anything right now?
pub fn check_ip(&self, ip: IpAddr) -> Result<(), Drop> {
if self.is_banned(ip) {
return Err(Drop::Banned);
}
self.per_ip.check_key(&ip).map_err(|_| Drop::IpRate)
}
/// Per-credential budget, applied *after* the AEAD check.
///
/// Deliberately not before it, even though that would be cheaper. Exceeding
/// this budget produces a NACK, and a reply to an unverified datagram is a
/// reflector: `token_id` is cleartext, so an attacker can name a real token,
/// spoof a victim's source address, and have the server packet the victim.
/// The per-IP budget is the cheap pre-crypto filter; this one is not.
pub fn check_token(&self, token_id: u64) -> Result<(), Drop> {
self.per_token
.check_key(&token_id)
.map_err(|_| Drop::TokenRate)
}
/// Called when a datagram names a token that does not exist.
///
/// Scanning for valid token ids is the cheapest attack against this design —
/// no key needed, just 2⁶⁴ guesses — so it is met with a ban rather than a
/// reply. The port stays silent either way.
pub fn note_unknown_token(&self, ip: IpAddr) {
self.strike(&self.unknown_token, ip, UNKNOWN_TOKEN_STRIKES);
}
/// Called when a datagram named a real token but failed AEAD. Either
/// corruption or forgery; both mean stop listening to this source.
pub fn note_aead_failure(&self, ip: IpAddr) {
self.strike(&self.aead_fail, ip, AEAD_FAIL_STRIKES);
}
fn is_banned(&self, ip: IpAddr) -> bool {
let now = Instant::now();
let banned = |m: &DashMap<IpAddr, Strikes>| {
m.get(&ip)
.and_then(|s| s.banned_until)
.is_some_and(|t| t > now)
};
banned(&self.unknown_token) || banned(&self.aead_fail)
}
fn strike(&self, map: &DashMap<IpAddr, Strikes>, ip: IpAddr, threshold: u32) {
// Sweep before inserting a new key, so the map can never exceed the cap.
if map.len() >= MAX_TRACKED_IPS && !map.contains_key(&ip) {
sweep(map);
if map.len() >= MAX_TRACKED_IPS {
// Still full after sweeping: we are under a wide attack. Dropping
// the strike is the right failure mode — the per-IP rate limiter
// is still holding, and growing this map is what the attacker
// wants.
return;
}
}
let now = Instant::now();
let mut entry = map.entry(ip).or_default();
match entry.window_started {
Some(started) if now.duration_since(started) <= STRIKE_WINDOW => entry.count += 1,
_ => {
entry.window_started = Some(now);
entry.count = 1;
}
}
if entry.count >= threshold {
entry.banned_until = Some(now + BAN_DURATION);
entry.count = 0;
entry.window_started = None;
}
}
/// May we send an unauthenticated revocation notice to `addr` right now?
///
/// Both budgets are checked, and both must pass. This is called with the
/// address the datagram *claimed* to come from, which is exactly the point:
/// an attacker spoofing a victim gets the victim's budget, not their own.
pub fn check_notice(&self, addr: IpAddr) -> Result<(), Drop> {
if self.notice_global.check().is_err() {
return Err(Drop::NoticeRate);
}
self.notice_per_addr
.check_key(&addr)
.map_err(|_| Drop::NoticeRate)
}
/// Periodic maintenance. Drops expired bans and stale rate-limiter state so
/// idle keys do not accumulate.
pub fn gc(&self) {
self.per_ip.retain_recent();
self.per_token.retain_recent();
self.notice_per_addr.retain_recent();
sweep(&self.unknown_token);
sweep(&self.aead_fail);
}
/// For `/metrics`.
pub fn tracked_ips(&self) -> usize {
self.unknown_token.len() + self.aead_fail.len()
}
}
fn sweep(map: &DashMap<IpAddr, Strikes>) {
let now = Instant::now();
map.retain(|_, s| {
let ban_active = s.banned_until.is_some_and(|t| t > now);
let window_active = s
.window_started
.is_some_and(|t| now.duration_since(t) <= STRIKE_WINDOW);
ban_active || window_active
});
}
#[cfg(test)]
mod tests {
use super::*;
const IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 5));
#[test]
fn a_burst_is_allowed_then_the_rate_bites() {
let limits = Limits::new();
for i in 0..IP_BURST {
assert!(
limits.check_ip(IP).is_ok(),
"packet {i} of the burst was dropped"
);
}
assert_eq!(limits.check_ip(IP), Err(Drop::IpRate));
}
#[test]
fn per_token_limits_are_independent_of_each_other() {
let limits = Limits::new();
for _ in 0..TOKEN_BURST {
assert!(limits.check_token(1).is_ok());
}
assert_eq!(limits.check_token(1), Err(Drop::TokenRate));
// A different token is unaffected: one noisy phone must not silence a
// whole household behind the same NAT.
assert!(limits.check_token(2).is_ok());
}
#[test]
fn token_scanning_earns_a_ban() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES {
limits.note_unknown_token(IP);
}
assert_eq!(limits.check_ip(IP), Err(Drop::Banned));
}
#[test]
fn forged_packets_earn_a_ban() {
let limits = Limits::new();
for _ in 0..AEAD_FAIL_STRIKES {
limits.note_aead_failure(IP);
}
assert_eq!(limits.check_ip(IP), Err(Drop::Banned));
}
#[test]
fn a_ban_is_specific_to_the_offending_address() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES {
limits.note_unknown_token(IP);
}
let other = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 9));
assert!(limits.check_ip(other).is_ok());
}
#[test]
fn strikes_below_the_threshold_do_not_ban() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES - 1 {
limits.note_unknown_token(IP);
}
assert!(limits.check_ip(IP).is_ok());
}
#[test]
fn gc_drops_entries_with_nothing_left_to_remember() {
let limits = Limits::new();
limits.note_unknown_token(IP);
assert_eq!(limits.tracked_ips(), 1);
// Force the window to look expired rather than sleeping for a minute.
limits
.unknown_token
.get_mut(&IP)
.expect("entry")
.window_started = Some(Instant::now() - STRIKE_WINDOW * 2);
limits.gc();
assert_eq!(limits.tracked_ips(), 0);
}
#[test]
fn a_ban_survives_gc_until_it_expires() {
let limits = Limits::new();
for _ in 0..UNKNOWN_TOKEN_STRIKES {
limits.note_unknown_token(IP);
}
limits.gc();
assert_eq!(
limits.check_ip(IP),
Err(Drop::Banned),
"gc must not lift an active ban"
);
}
}
Acrates/otserver/src/main.rs
@@ -0,0 +1,296 @@
//! opentracker server: one binary, one SQLite file.
//!
//! Wiring, in dependency order:
//!
//! ```text
//! Db ──▶ writer task ──▶ Ingest ──▶ UDP workers
//! │ │
//! └──▶ axum HTTP ─────────┘ (login mints tokens straight into Ingest)
//! ```
//!
//! The one operational trap worth repeating: **HTTP reverse proxies do not forward
//! UDP.** The HTTP listener belongs behind nginx or Caddy; the UDP port needs its
//! own firewall rule.
mod api;
mod auth;
mod config;
mod db;
mod ingest;
mod keys;
mod limits;
mod polyline;
mod retention;
mod simulate;
mod udp;
mod web;
mod writer;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use tower_sessions::cookie::SameSite;
use tower_sessions::{Expiry, SessionManagerLayer};
use tower_sessions_sqlx_store::SqliteStore;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use crate::api::AppState;
use crate::config::Config;
use crate::db::Db;
use crate::ingest::Ingest;
use crate::keys::KeyVault;
/// Rolling session lifetime.
const SESSION_DAYS: i64 = 30;
struct Args {
config: Option<PathBuf>,
/// Relaxes the cookie's `Secure` requirement so the Vite dev server works
/// over plain HTTP on localhost. Never for production.
dev: bool,
simulate_device: bool,
/// `--create-admin <user> <password>`: bootstrap the first account.
create_admin: Option<(String, String)>,
}
fn parse_args() -> Result<Args> {
let mut args = Args {
config: None,
dev: false,
simulate_device: false,
create_admin: None,
};
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--config" | "-c" => {
args.config = Some(PathBuf::from(it.next().context("--config needs a path")?));
}
"--dev" => args.dev = true,
"--simulate-device" => args.simulate_device = true,
"--create-admin" => {
let user = it.next().context("--create-admin needs a username")?;
let pass = it.next().context("--create-admin needs a password")?;
args.create_admin = Some((user, pass));
}
"--help" | "-h" => {
println!(
"opentracker {}\n\n\
USAGE:\n \
otserver [--config FILE] [--dev] [--simulate-device]\n \
otserver --create-admin USERNAME PASSWORD\n\n\
ENVIRONMENT:\n \
OT_SECRET_KEY required; 32 random bytes, base64, or a path to them\n \
OT_SECRET_KEY_OLD accepted for unwrapping during a key rotation\n \
OT_* override any config field (see config.rs)\n",
env!("CARGO_PKG_VERSION")
);
std::process::exit(0);
}
other => bail!("unknown argument {other:?} (try --help)"),
}
}
Ok(args)
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_env("OT_LOG")
.unwrap_or_else(|_| EnvFilter::new("info,otserver=debug")),
)
.init();
let args = parse_args()?;
let cfg = Config::load(args.config.as_deref())?;
cfg.validate()?;
let vault = KeyVault::from_env()?;
let db = Db::open(&cfg.db_path).await?;
info!(path = %cfg.db_path.display(), "database ready");
let (writer, writer_task) = writer::spawn(db.write.clone());
// Handing over the master is what enables replies to datagrams naming tokens
// this server has no record of. Withheld unless config asks for it, so the
// one reflective path cannot be switched on by accident.
let revocation_master = cfg.revocation_notices.then(|| vault.revocation_master());
if revocation_master.is_some() {
info!(
"revocation_notices enabled: an unknown token draws a rate-limited 38-byte reply. \
See config.rs for the trade."
);
}
let ingest = Arc::new(Ingest::new(
writer.clone(),
cfg.ts_window_days * 86_400,
revocation_master,
));
// One-shot bootstrap, before anything starts listening.
if let Some((username, password)) = args.create_admin {
let hash = auth::hash_password(&cfg, password).await?;
let at = db::now();
sqlx::query(
"INSERT INTO users (username, pw_hash, display_name, is_admin, created_at, pw_changed_at) \
VALUES (?, ?, ?, 1, ?, ?)",
)
.bind(&username)
.bind(hash)
.bind(&username)
.bind(at)
.bind(at)
.execute(&db.write)
.await
.with_context(|| format!("creating admin {username}"))?;
println!("created admin account {username}");
db.checkpoint().await?;
db.close().await;
return Ok(());
}
let loaded = auth::load_tokens(&db.read, &vault, &ingest).await?;
info!(
tokens = loaded,
"loaded device tokens into the ingest cache"
);
// Sessions live in the same SQLite file, on the writer pool. They are
// low-traffic enough not to disturb the batching that exists to protect the
// position write path.
let session_store = SqliteStore::new(db.write.clone());
session_store
.migrate()
.await
.context("migrating the session store")?;
if args.dev {
warn!("--dev: session cookies will not require HTTPS. Never use this in production.");
}
let session_layer = SessionManagerLayer::new(session_store)
.with_name(if args.dev { "otsid" } else { "__Host-otsid" })
.with_http_only(true)
.with_secure(!args.dev)
.with_same_site(SameSite::Lax)
.with_expiry(Expiry::OnInactivity(time::Duration::days(SESSION_DAYS)));
let http_addr = cfg.http_addr;
let udp_addr = cfg.udp_addr;
let workers = cfg.udp_worker_count();
let retention_days = cfg.retention_days;
let token_stale_days = cfg.token_stale_days;
let throttle = Arc::new(auth::LoginThrottle::default());
let state: api::Shared = Arc::new(AppState {
db,
cfg,
vault,
ingest: Arc::clone(&ingest),
writer,
throttle: Arc::clone(&throttle),
});
let udp_tasks = udp::spawn(udp_addr, workers, Arc::clone(&ingest))?;
let retention_task = retention::Retention {
pool: state.db.write.clone(),
retention_days,
token_stale_days,
ingest: Arc::clone(&ingest),
}
.spawn();
let limits_task = retention::spawn_limits_gc(Arc::clone(&ingest), throttle);
let app = api::router(Arc::clone(&state)).layer(session_layer);
let listener = tokio::net::TcpListener::bind(http_addr)
.await
.with_context(|| format!("binding {http_addr}"))?;
info!(addr = %http_addr, "HTTP listener started (put a TLS-terminating proxy in front)");
if args.simulate_device {
// After the listeners are up, so the login cannot race them.
simulate::SimulatedDevice {
base_url: format!("http://{http_addr}"),
username: std::env::var("OT_SIM_USER").unwrap_or_else(|_| "sim".into()),
password: std::env::var("OT_SIM_PASSWORD").unwrap_or_else(|_| "simsimsimsim".into()),
udp_addr: loopback_of(udp_addr),
}
.spawn();
}
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await
.context("HTTP server failed")?;
// Stop the periodic tasks and the receive loops, then let the writer drain so
// nothing already acknowledged is lost, then checkpoint so the .db file on
// disk is self-contained.
info!("shutting down");
retention_task.abort();
limits_task.abort();
for task in udp_tasks {
task.abort();
}
if let Err(e) = state.db.checkpoint().await {
warn!(error = %e, "WAL checkpoint failed");
}
drop(state);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), writer_task).await;
Ok(())
}
/// The simulated device must talk to a routable address; the listener is usually
/// bound to the wildcard, which cannot be a destination.
fn loopback_of(addr: SocketAddr) -> String {
if addr.ip().is_unspecified() {
format!("127.0.0.1:{}", addr.port())
} else {
addr.to_string()
}
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.ok();
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut sig) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
sig.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {},
() = terminate => {},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wildcard_address_is_rewritten_for_the_simulated_device() {
assert_eq!(
loopback_of("0.0.0.0:7373".parse().expect("literal")),
"127.0.0.1:7373"
);
assert_eq!(
loopback_of("192.0.2.1:7373".parse().expect("literal")),
"192.0.2.1:7373"
);
}
}
Acrates/otserver/src/polyline.rs
@@ -0,0 +1,351 @@
//! Trail decimation and encoding.
//!
//! A day of walking at one fix per 25 s is ~3500 points. Sent as JSON floats that
//! is ~100 kB; as a Ramer–Douglas–Peucker-simplified encoded polyline it is a few
//! kilobytes, and the browser has proportionally less to draw. Both halves matter:
//! simplification removes points that would render on top of each other anyway,
//! and the encoding removes the per-number punctuation.
//!
//! Coordinates arrive as degrees × 1e7 (the protocol and database representation)
//! and are encoded at 1e5, which is the polyline format's fixed precision — about
//! 1.1 m, well under the accuracy of any consumer GPS fix.
/// Points are `(lat_e7, lon_e7)`.
type Pt = (i64, i64);
/// Simplify to at most `max` points using Ramer–Douglas–Peucker.
///
/// The tolerance is searched rather than fixed, because the useful question is
/// "how much detail fits in the budget?" and the answer depends on how far the
/// person actually travelled. A fixed ε either keeps too much on a road trip or
/// destroys a walk around a park.
pub fn simplify(points: &[Pt], max: usize) -> Vec<Pt> {
if points.len() <= max {
return points.to_vec();
}
// Ramer–Douglas–Peucker is O(n log n) on a typical track but O(n²) in the
// worst case — a stationary phone reporting a heartbeat all week is exactly
// that case — and the tolerance search below runs it several times. Uniformly
// subsampling first bounds the work at a size where that no longer matters.
// At 8× the budget the subsampling itself removes no visible detail: anything
// it drops would have been RDP's next victim anyway.
let owned;
let points = if points.len() > max.saturating_mul(PRE_DECIMATE_FACTOR) {
owned = uniform(points, max * PRE_DECIMATE_FACTOR);
owned.as_slice()
} else {
points
};
// ~15 m expressed in units of 1e-7 degrees.
let base: f64 = 15.0 * 1e7 / 111_320.0;
// Grow until something fits, to bracket the answer. Plain doubling would stop
// at the *first* tolerance that fits, and on a track with real large-scale
// shape that first hit overshoots: a meander can collapse to its two
// endpoints, which is a straight line where there was a walk. So this phase
// only establishes an upper bound.
let mut lo = 0.0f64; // known to yield more than `max`
let mut hi = base;
let mut best: Option<Vec<Pt>> = None;
for _ in 0..24 {
let out = rdp(points, hi);
if out.len() <= max {
best = Some(out);
break;
}
lo = hi;
hi *= 4.0;
}
// Then bisect, keeping the largest result that still fits: the most detail the
// budget allows, rather than the first thing under it.
if let Some(mut candidate) = best {
for _ in 0..8 {
let mid = (lo + hi) / 2.0;
let out = rdp(points, mid);
if out.len() <= max {
if out.len() >= candidate.len() {
candidate = out;
}
hi = mid;
} else {
lo = mid;
}
}
return candidate;
}
// No tolerance in 24 quadruplings fit the budget. Unreachable for real data;
// uniform sampling keeps the shape far better than an infinite tolerance.
uniform(points, max)
}
/// How much larger than the budget the RDP input may be.
const PRE_DECIMATE_FACTOR: usize = 8;
/// Evenly spaced subsample of at most `max` points, always keeping the last one so
/// a trail still ends where the person is.
fn uniform(points: &[Pt], max: usize) -> Vec<Pt> {
if points.len() <= max {
return points.to_vec();
}
let step = points.len().div_ceil(max);
let mut out: Vec<Pt> = points.iter().copied().step_by(step).collect();
if out.last() != points.last() {
out.push(*points.last().expect("non-empty"));
}
out
}
fn rdp(points: &[Pt], epsilon: f64) -> Vec<Pt> {
if points.len() < 3 {
return points.to_vec();
}
let mut keep = vec![false; points.len()];
keep[0] = true;
keep[points.len() - 1] = true;
// Iterative rather than recursive: a 100k-point input would otherwise be able
// to blow the stack, and this runs on attacker-influenced data volumes.
let mut stack = vec![(0usize, points.len() - 1)];
while let Some((start, end)) = stack.pop() {
if end <= start + 1 {
continue;
}
let mut worst = 0.0;
let mut worst_i = start;
for i in (start + 1)..end {
let d = perpendicular_distance(points[i], points[start], points[end]);
if d > worst {
worst = d;
worst_i = i;
}
}
if worst > epsilon {
keep[worst_i] = true;
stack.push((start, worst_i));
stack.push((worst_i, end));
}
}
points
.iter()
.zip(keep)
.filter_map(|(p, k)| k.then_some(*p))
.collect()
}
fn perpendicular_distance(p: Pt, a: Pt, b: Pt) -> f64 {
let (px, py) = (p.1 as f64, p.0 as f64);
let (ax, ay) = (a.1 as f64, a.0 as f64);
let (bx, by) = (b.1 as f64, b.0 as f64);
let dx = bx - ax;
let dy = by - ay;
let len_sq = dx * dx + dy * dy;
if len_sq == 0.0 {
return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
}
((dx * (ay - py) - (ax - px) * dy).abs()) / len_sq.sqrt()
}
/// Google's encoded polyline algorithm at 1e5 precision.
pub fn encode(points: &[Pt]) -> String {
let mut out = String::with_capacity(points.len() * 6);
let mut prev_lat = 0i64;
let mut prev_lon = 0i64;
for &(lat_e7, lon_e7) in points {
// 1e7 -> 1e5, rounding rather than truncating so error stays centred.
let lat = div_round(lat_e7, 100);
let lon = div_round(lon_e7, 100);
encode_value(lat - prev_lat, &mut out);
encode_value(lon - prev_lon, &mut out);
prev_lat = lat;
prev_lon = lon;
}
out
}
fn div_round(v: i64, d: i64) -> i64 {
if v >= 0 {
(v + d / 2) / d
} else {
-((-v + d / 2) / d)
}
}
fn encode_value(value: i64, out: &mut String) {
let mut v = if value < 0 { !(value << 1) } else { value << 1 };
while v >= 0x20 {
out.push(char::from(((0x20 | (v & 0x1f)) + 63) as u8));
v >>= 5;
}
out.push(char::from((v + 63) as u8));
}
/// Decode. Used by the tests to prove the encoder is reversible, and kept public
/// because a Rust consumer of `/api/users/:id/track` needs it.
#[cfg_attr(not(test), allow(dead_code))]
pub fn decode(s: &str) -> Vec<Pt> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut lat = 0i64;
let mut lon = 0i64;
while i < bytes.len() {
let Some(dlat) = decode_value(bytes, &mut i) else {
break;
};
let Some(dlon) = decode_value(bytes, &mut i) else {
break;
};
lat += dlat;
lon += dlon;
out.push((lat * 100, lon * 100));
}
out
}
#[cfg_attr(not(test), allow(dead_code))]
fn decode_value(bytes: &[u8], i: &mut usize) -> Option<i64> {
let mut shift = 0;
let mut result = 0i64;
loop {
let b = *bytes.get(*i)? as i64 - 63;
*i += 1;
result |= (b & 0x1f) << shift;
shift += 5;
if b < 0x20 {
break;
}
if shift > 60 {
return None; // malformed; refuse to shift forever
}
}
Some(if result & 1 != 0 {
!(result >> 1)
} else {
result >> 1
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_known_vector_matches_the_reference_implementation() {
// The example from Google's polyline documentation, in 1e7 units.
// (38.5, -120.2), (40.7, -120.95), (43.252, -126.453)
let points = vec![
(385_000_000, -1_202_000_000),
(407_000_000, -1_209_500_000),
(432_520_000, -1_264_530_000),
];
assert_eq!(encode(&points), "_p~iF~ps|U_ulLnnqC_mqNvxq`@");
}
#[test]
fn encode_decode_round_trips_within_the_formats_precision() {
let points = vec![
(525_200_080, 134_050_000),
(525_210_000, 134_060_000),
(-338_688_000, -1_754_500_000),
];
let back = decode(&encode(&points));
assert_eq!(back.len(), points.len());
for (a, b) in points.iter().zip(&back) {
// 1e5 precision means the last two 1e7 digits are lost: ≤ 50 units,
// about 0.5 cm. Well inside any GPS accuracy.
assert!((a.0 - b.0).abs() <= 50, "lat drifted: {a:?} vs {b:?}");
assert!((a.1 - b.1).abs() <= 50, "lon drifted: {a:?} vs {b:?}");
}
}
#[test]
fn an_empty_track_encodes_to_an_empty_string() {
assert_eq!(encode(&[]), "");
assert_eq!(decode(""), Vec::<Pt>::new());
}
#[test]
fn decoding_garbage_does_not_panic_or_hang() {
for s in ["~", "?????", "\u{1}\u{2}\u{3}", &"~".repeat(1000)] {
let _ = decode(s);
}
}
#[test]
fn simplify_keeps_short_tracks_untouched() {
let points: Vec<Pt> = (0..10).map(|i| (i * 1000, i * 1000)).collect();
assert_eq!(simplify(&points, 2000), points);
}
#[test]
fn simplify_respects_the_budget() {
// A 20k-point meander: a long sinusoidal path with per-fix jitter on top.
// Large-scale shape matters here — a straight line with noise legitimately
// simplifies to two points, so it would not test anything.
let points: Vec<Pt> = (0..20_000i64)
.map(|i| {
let jitter = if i % 2 == 0 { 300 } else { -300 };
let wave = (5_000_000.0 * ((i as f64) / 300.0).sin()) as i64;
(525_200_000 + i * 40 + jitter, 134_050_000 + wave - jitter)
})
.collect();
let out = simplify(&points, 500);
assert!(out.len() <= 500, "budget exceeded: {} points", out.len());
// And it must actually *use* the budget. Returning two endpoints would
// satisfy the limit while turning a wander into a straight line.
assert!(
out.len() > 250,
"budget under-used: only {} points",
out.len()
);
}
#[test]
fn simplify_keeps_the_endpoints() {
let points: Vec<Pt> = (0..5_000)
.map(|i| (525_200_000 + i * 100, 134_050_000))
.collect();
let out = simplify(&points, 100);
assert_eq!(
out.first(),
points.first(),
"the start of a trail must survive"
);
assert_eq!(out.last(), points.last(), "the end of a trail must survive");
}
#[test]
fn simplify_drops_collinear_interior_points() {
// A dead-straight line: everything between the ends is redundant.
let points: Vec<Pt> = (0..3_000)
.map(|i| (525_200_000 + i * 1_000, 134_050_000))
.collect();
let out = simplify(&points, 2_000);
assert!(
out.len() < 50,
"a straight line should collapse, got {}",
out.len()
);
}
#[test]
fn simplify_survives_a_track_that_never_moves() {
// Every point identical: len_sq == 0 in the distance function.
let points: Vec<Pt> = std::iter::repeat_n((525_200_000, 134_050_000), 5_000).collect();
let out = simplify(&points, 100);
assert!(out.len() <= 100);
}
#[test]
fn negative_coordinates_encode_correctly() {
// The zig-zag encoding of negatives is the classic place to get an
// off-by-one, and half the planet is at a negative longitude.
let points = vec![(-338_688_000, -1_754_500_000)];
let back = decode(&encode(&points));
assert_eq!(back.len(), 1);
assert!(back[0].0 < 0 && back[0].1 < 0);
}
}
Acrates/otserver/src/retention.rs
@@ -0,0 +1,466 @@
//! Periodic housekeeping: point thinning, hard retention, token staleness.
//!
//! Runs every 15 minutes in bounded batches. Bounded matters: an unbounded
//! `DELETE` on a large table holds the write lock for as long as it takes, which
//! on the single-writer design means every device is stalled meanwhile.
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use sqlx::SqlitePool;
use tracing::{debug, info, warn};
use crate::db::now;
use crate::ingest::Ingest;
use otproto::RevokeReason;
const INTERVAL: Duration = Duration::from_secs(15 * 60);
/// Rows touched per statement, so the write lock is never held for long.
const BATCH: i64 = 5_000;
/// Points older than this are thinned to [`THIN_SPACING_S`] apart.
const THIN_AFTER_S: i64 = 24 * 3_600;
const THIN_SPACING_S: i64 = 5 * 60;
pub struct Retention {
pub pool: SqlitePool,
pub retention_days: u32,
pub token_stale_days: u32,
pub ingest: Arc<Ingest>,
}
impl Retention {
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
// Sleep first: startup already has enough to do.
let mut ticker = tokio::time::interval(INTERVAL);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = self.run_once().await {
warn!(error = %e, "retention sweep failed; will retry next tick");
}
}
})
}
pub async fn run_once(&self) -> Result<()> {
let now = now();
let dropped = self.hard_drop(now).await?;
let thinned = self.thin(now).await?;
let tokens = self.expire_tokens(now).await?;
if dropped + thinned > 0 || !tokens.is_empty() {
info!(
dropped,
thinned,
tokens_expired = tokens.len(),
"retention sweep"
);
}
self.incremental_vacuum().await?;
Ok(())
}
/// Points beyond the retention horizon.
///
/// `user_latest` is a separate table precisely so this cannot delete a live
/// marker: a user who has not moved in a fortnight still has a position on the
/// map, even with no surviving `points` row.
async fn hard_drop(&self, now: i64) -> Result<u64> {
let cutoff = now - i64::from(self.retention_days) * 86_400;
let mut total = 0;
loop {
let affected = sqlx::query(
// `points` is WITHOUT ROWID, so there is no rowid to select on;
// the row-value form addresses the primary key directly.
"DELETE FROM points WHERE (user_id, ts) IN \
(SELECT user_id, ts FROM points WHERE ts < ? LIMIT ?)",
)
.bind(cutoff)
.bind(BATCH)
.execute(&self.pool)
.await
.context("dropping expired points")?
.rows_affected();
total += affected;
if affected < BATCH as u64 {
break;
}
}
Ok(total)
}
/// Thin points older than a day down to roughly one per five minutes.
///
/// Keeps the first point of each 5-minute bucket. A day-old trail does not
/// need per-second resolution, and this is where most of the space goes.
async fn thin(&self, now: i64) -> Result<u64> {
let cutoff = now - THIN_AFTER_S;
let mut total = 0;
loop {
let affected = sqlx::query(
"DELETE FROM points WHERE (user_id, ts) IN ( \
SELECT p.user_id, p.ts FROM points p WHERE p.ts < ? AND EXISTS ( \
SELECT 1 FROM points q \
WHERE q.user_id = p.user_id \
AND q.ts / ? = p.ts / ? \
AND q.ts < p.ts \
) LIMIT ? )",
)
.bind(cutoff)
.bind(THIN_SPACING_S)
.bind(THIN_SPACING_S)
.bind(BATCH)
.execute(&self.pool)
.await
.context("thinning points")?
.rows_affected();
total += affected;
if affected < BATCH as u64 {
break;
}
}
Ok(total)
}
/// Delete tokens with no activity for `token_stale_days`.
///
/// A phone genuinely idle for a month has to log in again — the same contract
/// as an expiring browser session. Removing the slot from the ingest cache is
/// the part that actually takes effect immediately; the database row is just
/// bookkeeping.
///
/// `created_at` stands in for `last_seen_at` when a token was minted and never
/// used, so an abandoned login does not live forever.
async fn expire_tokens(&self, now: i64) -> Result<Vec<i64>> {
let cutoff = now - i64::from(self.token_stale_days) * 86_400;
let stale: Vec<i64> = sqlx::query_scalar(
"SELECT token_id FROM tokens \
WHERE COALESCE(last_seen_at, created_at) < ? AND revoked_at IS NULL LIMIT ?",
)
.bind(cutoff)
.bind(BATCH)
.fetch_all(&self.pool)
.await
.context("finding stale tokens")?;
for token_id in &stale {
// Marked, not deleted. A phone that wakes up after a month must be
// told to log in again, and the only message it can trust is one
// sealed with its own key — which deleting the row would destroy.
// A token row is about a hundred bytes; a device that cannot be told
// why it stopped working is a support ticket.
sqlx::query(
"UPDATE tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL",
)
.bind(now)
.bind(token_id)
.execute(&self.pool)
.await
.context("expiring stale token")?;
// History survives regardless: points.src_token_id is deliberately
// not a cascading foreign key, because positions belong to the
// account rather than to the phone that reported them.
self.ingest
.mark_revoked(*token_id as u64, RevokeReason::Expired);
}
Ok(stale)
}
/// Return freed pages to the filesystem a little at a time.
async fn incremental_vacuum(&self) -> Result<()> {
sqlx::query("PRAGMA incremental_vacuum(1000)")
.execute(&self.pool)
.await
.context("incremental vacuum")?;
debug!("incremental vacuum done");
Ok(())
}
}
/// Also on a timer: sweep the state that attacker-chosen keys accumulate in — the
/// UDP rate limiter and the failed-login throttle. Cheap, and the reason neither
/// can become a memory-exhaustion vector itself.
pub fn spawn_limits_gc(
ingest: Arc<Ingest>,
throttle: Arc<crate::auth::LoginThrottle>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(60));
loop {
ticker.tick().await;
ingest.limits().gc();
throttle.gc();
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Db;
async fn fixture() -> (Db, Arc<Ingest>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = Db::open(&dir.path().join("t.db")).await.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Arc::new(Ingest::new(writer, 30 * 86_400, None));
(db, ingest, dir)
}
async fn insert_point(db: &Db, ts: i64) {
sqlx::query("INSERT INTO points (user_id, ts, lat, lon, recv_at) VALUES (1, ?, 1, 2, ?)")
.bind(ts)
.bind(ts)
.execute(&db.write)
.await
.expect("point");
}
fn retention(db: &Db, ingest: Arc<Ingest>) -> Retention {
Retention {
pool: db.write.clone(),
retention_days: 7,
token_stale_days: 30,
ingest,
}
}
#[tokio::test]
async fn points_beyond_the_horizon_are_dropped_and_recent_ones_kept() {
let (db, ingest, _dir) = fixture().await;
let now = now();
insert_point(&db, now - 8 * 86_400).await; // too old
insert_point(&db, now - 60).await; // fresh
retention(&db, ingest).run_once().await.expect("sweep");
let remaining: Vec<i64> = sqlx::query_scalar("SELECT ts FROM points ORDER BY ts")
.fetch_all(&db.read)
.await
.expect("points");
assert_eq!(remaining, vec![now - 60]);
}
#[tokio::test]
async fn the_live_marker_survives_the_retention_horizon() {
let (db, ingest, _dir) = fixture().await;
let ancient = now() - 30 * 86_400;
insert_point(&db, ancient).await;
sqlx::query(
"INSERT INTO user_latest (user_id, ts, lat, lon, recv_at) VALUES (1, ?, 1, 2, ?)",
)
.bind(ancient)
.bind(ancient)
.execute(&db.write)
.await
.expect("latest");
retention(&db, ingest).run_once().await.expect("sweep");
let points: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
let latest: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_latest")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(points, 0, "the old point should be gone");
assert_eq!(
latest, 1,
"a separate table for the live marker is the whole point: GC must not erase someone \
from the map for standing still"
);
}
#[tokio::test]
async fn day_old_points_are_thinned_to_one_per_bucket() {
let (db, ingest, _dir) = fixture().await;
let base = now() - 2 * 86_400;
// Ten points inside a single 5-minute bucket.
for i in 0..10 {
insert_point(&db, base + i * 10).await;
}
// And one in the next bucket, which must be kept too.
insert_point(&db, base + THIN_SPACING_S).await;
retention(&db, ingest).run_once().await.expect("sweep");
let remaining: Vec<i64> = sqlx::query_scalar("SELECT ts FROM points ORDER BY ts")
.fetch_all(&db.read)
.await
.expect("points");
assert_eq!(
remaining.len(),
2,
"each 5-minute bucket should keep exactly one point, got {remaining:?}"
);
assert_eq!(
remaining[0], base,
"the first point of a bucket is the one kept"
);
}
#[tokio::test]
async fn recent_points_are_not_thinned() {
let (db, ingest, _dir) = fixture().await;
let base = now() - 600;
for i in 0..5 {
insert_point(&db, base + i * 10).await;
}
retention(&db, ingest).run_once().await.expect("sweep");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 5, "points inside the last 24h keep full resolution");
}
#[tokio::test]
async fn a_token_idle_for_a_month_is_revoked_but_keeps_its_key() {
let (db, ingest, _dir) = fixture().await;
let stale_id = 1234i64;
let fresh_id = 5678i64;
let now = now();
for (id, last_seen) in [(stale_id, now - 31 * 86_400), (fresh_id, now - 60)] {
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at, last_seen_at) \
VALUES (?, 1, x'00', 'phone', 0, ?)",
)
.bind(id)
.bind(last_seen)
.execute(&db.write)
.await
.expect("token");
ingest.insert_token(crate::ingest::TokenSlot::new(id as u64, 1, &[1; 32], 1));
}
assert_eq!(ingest.active_token_count(), 2);
retention(&db, Arc::clone(&ingest))
.run_once()
.await
.expect("sweep");
// The row survives, marked. Deleting it would destroy the only key that
// can seal a message this phone will believe — and a phone idle for a
// month is exactly the one that needs telling.
let expired: Vec<i64> =
sqlx::query_scalar("SELECT token_id FROM tokens WHERE revoked_at IS NOT NULL")
.fetch_all(&db.read)
.await
.expect("tokens");
assert_eq!(expired, vec![stale_id]);
let live: Vec<i64> =
sqlx::query_scalar("SELECT token_id FROM tokens WHERE revoked_at IS NULL")
.fetch_all(&db.read)
.await
.expect("tokens");
assert_eq!(live, vec![fresh_id]);
assert_eq!(
ingest.active_token_count(),
1,
"an expired token must stop authorising writes immediately"
);
}
/// The sweep must not keep re-expiring what it already expired, or every run
/// would rewrite the same rows forever.
#[tokio::test]
async fn expiring_is_idempotent() {
let (db, ingest, _dir) = fixture().await;
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at) \
VALUES (7, 1, x'00', 'phone', ?)",
)
.bind(now() - 31 * 86_400)
.execute(&db.write)
.await
.expect("token");
let sweep = retention(&db, Arc::clone(&ingest));
sweep.run_once().await.expect("first sweep");
let first: i64 = sqlx::query_scalar("SELECT revoked_at FROM tokens WHERE token_id = 7")
.fetch_one(&db.read)
.await
.expect("revoked_at");
retention(&db, ingest)
.run_once()
.await
.expect("second sweep");
let second: i64 = sqlx::query_scalar("SELECT revoked_at FROM tokens WHERE token_id = 7")
.fetch_one(&db.read)
.await
.expect("revoked_at");
assert_eq!(
first, second,
"the sweep rewrote a token it had already expired"
);
}
#[tokio::test]
async fn a_token_minted_and_never_used_still_expires() {
let (db, ingest, _dir) = fixture().await;
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at) \
VALUES (99, 1, x'00', 'phone', ?)",
)
.bind(now() - 31 * 86_400)
.execute(&db.write)
.await
.expect("token");
retention(&db, ingest).run_once().await.expect("sweep");
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM tokens WHERE revoked_at IS NOT NULL")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "created_at must stand in for a never-used token");
}
#[tokio::test]
async fn expiring_a_token_does_not_delete_history() {
let (db, ingest, _dir) = fixture().await;
let now = now();
sqlx::query(
"INSERT INTO tokens (token_id, user_id, key_wrapped, name, created_at, last_seen_at) \
VALUES (42, 1, x'00', 'phone', 0, ?)",
)
.bind(now - 31 * 86_400)
.execute(&db.write)
.await
.expect("token");
sqlx::query(
"INSERT INTO points (user_id, ts, lat, lon, recv_at, src_token_id) \
VALUES (1, ?, 1, 2, ?, 42)",
)
.bind(now - 60)
.bind(now)
.execute(&db.write)
.await
.expect("point");
retention(&db, ingest).run_once().await.expect("sweep");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(
count, 1,
"positions belong to the account, so losing the token must not lose the trail"
);
}
}
Acrates/otserver/src/simulate.rs
@@ -0,0 +1,201 @@
//! `--simulate-device`: an in-process fake phone.
//!
//! It logs in over the real HTTP API, then walks a synthetic route sending real
//! OTP/1 datagrams over a real UDP socket on loopback. Nothing is stubbed: the
//! same codec, the same AEAD, the same ingest path, the same writer.
//!
//! This is the fastest feedback loop for everything downstream of the protocol —
//! the web UI can be built against a moving marker before a line of Kotlin runs —
//! and because it exercises the genuine encoder, a protocol mistake shows up here
//! rather than on a phone.
use std::time::Duration;
use anyhow::{Context, Result, bail};
use otproto::msg::Direction;
use otproto::point::Flags;
use otproto::{AckFlags, Key, Message, Point, kdf};
use rand::TryRngCore;
use rand::rngs::OsRng;
use tokio::net::UdpSocket;
use tracing::{info, warn};
/// How often the simulated phone reports. Matches the Balanced profile's walking
/// cadence closely enough to be representative.
const REPORT_INTERVAL: Duration = Duration::from_secs(5);
/// Roughly walking pace, in units of 1e-7 degrees per report.
const STEP_E7: i32 = 1_200;
pub struct SimulatedDevice {
pub base_url: String,
pub username: String,
pub password: String,
pub udp_addr: String,
}
struct Credentials {
token_id: u64,
k_up: Key,
k_down: Key,
}
impl SimulatedDevice {
pub fn spawn(self) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = self.run().await {
warn!(error = %e, "simulated device stopped");
}
})
}
async fn run(self) -> Result<()> {
let creds = self.login().await?;
let socket = UdpSocket::bind("0.0.0.0:0")
.await
.context("binding a client socket")?;
socket
.connect(&self.udp_addr)
.await
.with_context(|| format!("connecting to {}", self.udp_addr))?;
info!(token_id = creds.token_id, addr = %self.udp_addr, "simulated device connected");
// Say hello once, exactly as the app does, so the server has a version
// recorded against the token.
let hello = Message::Hello(otproto::Hello {
app_version_code: 0,
os_api_level: 0,
flags: otproto::HelloFlags::FIRST_LAUNCH,
config_version: 1,
});
self.send(&socket, &creds, &hello).await?;
// A lap around a small block near the Brandenburg Gate.
let mut lat = 525_200_080i32;
let mut lon = 134_050_000i32;
let mut leg = 0u32;
let mut ticker = tokio::time::interval(REPORT_INTERVAL);
loop {
ticker.tick().await;
// Four legs of 20 reports each: north, east, south, west.
let (dlat, dlon) = match (leg / 20) % 4 {
0 => (STEP_E7, 0),
1 => (0, STEP_E7),
2 => (-STEP_E7, 0),
_ => (0, -STEP_E7),
};
lat += dlat;
lon += dlon;
leg = leg.wrapping_add(1);
let bearing = match (leg / 20) % 4 {
0 => 0u16,
1 => 9_000,
2 => 18_000,
_ => 27_000,
};
let point = Point {
acc_dm: Some(60),
alt_m: Some(34),
spd_cms: Some(140),
brg_cdeg: Some(bearing),
bat_pct: Some(80u8.saturating_sub((leg / 60) as u8)),
flags: Flags::NONE,
..Point::new(crate::db::now() as u32, lat, lon)
};
self.send(&socket, &creds, &Message::Loc(vec![point]))
.await?;
}
}
async fn login(&self) -> Result<Credentials> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.context("building an HTTP client")?;
let response = client
.post(format!("{}/api/login", self.base_url))
// The server requires this on every state-changing request. The
// value is irrelevant; a cross-origin browser cannot set it.
.header("X-OT-CSRF", "1")
.json(&serde_json::json!({
"username": self.username,
"password": self.password,
"purpose": "device",
"device_name": "simulated",
"platform": "sim",
}))
.send()
.await
.context("posting to /api/login")?;
let status = response.status();
let body: serde_json::Value = response
.json()
.await
.context("reading the login response")?;
if !status.is_success() {
bail!("login failed with {status}: {body}");
}
let device = body
.get("device")
.ok_or_else(|| anyhow::anyhow!("login response has no device credentials"))?;
let token_id = device
.get("token_id")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| anyhow::anyhow!("login response has no token_id"))?;
use base64::Engine as _;
let key_b64 = device
.get("token_key")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow::anyhow!("login response has no token_key"))?;
let key: Key = base64::engine::general_purpose::STANDARD
.decode(key_b64)
.context("token_key is not base64")?
.try_into()
.map_err(|_| anyhow::anyhow!("token_key is not 32 bytes"))?;
Ok(Credentials {
token_id,
k_up: kdf::derive(&key, Direction::Up),
k_down: kdf::derive(&key, Direction::Down),
})
}
/// Send one message and wait briefly for its ACK.
///
/// A missing ACK is logged and otherwise ignored: the simulated device has no
/// durable queue, and the point of running it is to watch the server, not to
/// re-implement the client's retry engine here.
async fn send(&self, socket: &UdpSocket, creds: &Credentials, msg: &Message) -> Result<()> {
let mut nonce = [0u8; 12];
OsRng.try_fill_bytes(&mut nonce).context("OS RNG failed")?;
let datagram = otproto::seal_message(&creds.k_up, creds.token_id, nonce, msg);
socket.send(&datagram).await.context("sending a datagram")?;
let mut buf = vec![0u8; otproto::MAX_DATAGRAM];
match tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut buf)).await {
Ok(Ok(len)) => match otproto::open_message(&creds.k_down, &buf[..len]) {
Ok((_, Message::Ack(ack))) => {
if ack.nonces.first() != Some(&nonce) {
warn!("ACK did not echo the nonce we sent");
}
if ack.flags.contains(AckFlags::CONFIG_PENDING) {
info!("server has a config update pending");
}
}
Ok((_, Message::Nack(nack))) => {
warn!(reason = ?nack.reason, retry_after_s = nack.retry_after_s, "NACK");
}
Ok((_, other)) => warn!(?other, "unexpected reply"),
Err(e) => warn!(error = %e, "could not open the reply"),
},
Ok(Err(e)) => warn!(error = %e, "recv failed"),
Err(_) => warn!("no reply within 2s"),
}
Ok(())
}
}
Acrates/otserver/src/udp.rs
@@ -0,0 +1,177 @@
//! The UDP receive loop.
//!
//! `SO_REUSEPORT` lets several tasks bind the *same* port and have the kernel
//! spread datagrams across them, which is how one port scales past one core
//! without a dispatcher task in the middle. Each worker owns its own socket, so
//! there is no shared state on the hot path at all.
//!
//! The loop deliberately contains no `.await` between receiving and replying: the
//! whole of [`Ingest::handle`] is synchronous, and a full writer channel is
//! answered rather than waited on. Applying backpressure here would turn one slow
//! disk into packet loss for every device at once.
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::{Context, Result};
use socket2::{Domain, Protocol, Socket, Type};
use tokio::net::UdpSocket;
use tracing::{error, info, warn};
use crate::db::now;
use crate::ingest::{Ingest, Peer, Transport};
/// Receive buffer per socket. A queue flush from every device at once arrives as
/// a burst, and the kernel's default (a few hundred kB) drops it on the floor
/// before this process ever sees it.
const RECV_BUFFER_BYTES: usize = 2 * 1024 * 1024;
/// One more than the largest datagram, so an oversized packet is *seen* to be
/// oversized instead of being silently truncated into something that might parse.
const READ_BUFFER: usize = otproto::MAX_DATAGRAM + 1;
fn bind_reuseport(addr: SocketAddr) -> Result<UdpSocket> {
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let socket =
Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)).context("creating socket")?;
socket.set_reuse_address(true).context("SO_REUSEADDR")?;
socket.set_reuse_port(true).context("SO_REUSEPORT")?;
// Best effort: on Linux the kernel doubles the requested value and caps it at
// net.core.rmem_max, so a smaller buffer than asked for is normal and not
// worth failing startup over.
if let Err(e) = socket.set_recv_buffer_size(RECV_BUFFER_BYTES) {
warn!(error = %e, "could not enlarge the UDP receive buffer; bursts may be dropped");
}
socket.set_nonblocking(true).context("set_nonblocking")?;
socket
.bind(&addr.into())
.with_context(|| format!("binding {addr}/udp"))?;
UdpSocket::from_std(socket.into()).context("handing the socket to tokio")
}
/// Spawn `workers` receive tasks on `addr`.
pub fn spawn(
addr: SocketAddr,
workers: usize,
ingest: Arc<Ingest>,
) -> Result<Vec<tokio::task::JoinHandle<()>>> {
let mut tasks = Vec::with_capacity(workers);
for id in 0..workers {
let socket = bind_reuseport(addr)?;
tasks.push(tokio::spawn(run(id, socket, Arc::clone(&ingest))));
}
info!(%addr, workers, "OTP/1 UDP listener started");
// The trap worth stating in the log, because operators hit it on day one and
// the symptom (everything falls back to TLS) is far from the cause.
info!("reminder: HTTP reverse proxies do not forward UDP — {addr} needs its own firewall rule");
Ok(tasks)
}
async fn run(id: usize, socket: UdpSocket, ingest: Arc<Ingest>) {
let mut buf = vec![0u8; READ_BUFFER];
loop {
let (len, peer_addr) = match socket.recv_from(&mut buf).await {
Ok(v) => v,
Err(e) => {
// On UDP a send error can surface here as ICMP-driven
// ECONNREFUSED for a *previous* send. It says nothing about the
// socket's health, so log and keep going rather than exiting the
// worker and silently losing a quarter of the capacity.
warn!(worker = id, error = %e, "recv_from failed");
continue;
}
};
let peer = Peer {
addr: peer_addr,
transport: Transport::Udp,
};
if let Some(reply) = ingest.handle(&buf[..len], peer, now())
&& let Err(e) = socket.send_to(&reply, peer_addr).await
{
// The phone will retry; there is nothing to recover here.
error!(worker = id, %peer_addr, error = %e, "sending reply failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use otproto::msg::Direction;
use otproto::{Key, Message, Point, kdf};
use std::time::Duration;
const TOKEN_ID: u64 = 0xABCD_0123_4567_89EF;
const TOKEN_KEY: Key = [0x33; 32];
/// Round trip a real datagram over a real loopback socket. This is the only
/// test that exercises the socket options and the reply path together.
#[tokio::test]
async fn a_datagram_over_loopback_is_acked() {
let dir = tempfile::tempdir().expect("temp dir");
let db = crate::db::Db::open(&dir.path().join("t.db"))
.await
.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
let (writer, _task) = crate::writer::spawn(db.write.clone());
let ingest = Arc::new(Ingest::new(writer, 30 * 86_400, None));
ingest.insert_token(crate::ingest::TokenSlot::new(TOKEN_ID, 1, &TOKEN_KEY, 1));
// Port 0 lets the OS choose; then read it back, because SO_REUSEPORT
// workers must all bind the *same* concrete port.
let probe = bind_reuseport("127.0.0.1:0".parse().expect("literal")).expect("bind");
let addr = probe.local_addr().expect("local addr");
drop(probe);
let _tasks = spawn(addr, 2, Arc::clone(&ingest)).expect("spawn");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("client bind");
let k_up = kdf::derive(&TOKEN_KEY, Direction::Up);
let msg = Message::Loc(vec![Point {
acc_dm: Some(50),
..Point::new(now() as u32, 525_200_080, 134_050_000)
}]);
let datagram = otproto::seal_message(&k_up, TOKEN_ID, [0x77; 12], &msg);
client.send_to(&datagram, addr).await.expect("send");
let mut buf = vec![0u8; READ_BUFFER];
let len = tokio::time::timeout(Duration::from_secs(2), client.recv(&mut buf))
.await
.expect("no reply within 2s")
.expect("recv");
let k_down = kdf::derive(&TOKEN_KEY, Direction::Down);
match otproto::open_message(&k_down, &buf[..len])
.expect("ack opens")
.1
{
Message::Ack(ack) => assert_eq!(ack.nonces, vec![[0x77u8; 12]]),
other => panic!("expected an ACK, got {other:?}"),
}
assert!(len <= datagram.len(), "the reply amplified the request");
}
#[tokio::test]
async fn several_workers_can_share_one_port() {
let probe = bind_reuseport("127.0.0.1:0".parse().expect("literal")).expect("first bind");
let addr = probe.local_addr().expect("local addr");
// The second bind on the same concrete port is the thing SO_REUSEPORT
// makes legal, and the thing the whole multi-worker design rests on.
let second = bind_reuseport(addr).expect("SO_REUSEPORT should allow a second bind");
assert_eq!(second.local_addr().expect("addr"), addr);
}
}
Acrates/otserver/src/web.rs
@@ -0,0 +1,54 @@
//! Serving the built web UI out of the binary.
//!
//! `rust-embed` compiles `web/dist` in for release builds, so a deployment is
//! still one file. In debug builds it reads from disk instead, so `vite build`
//! output is picked up without a `cargo` rebuild.
//!
//! Anything that is not an embedded asset falls back to `index.html`, because the
//! UI routes client-side and a deep link must not 404.
use axum::http::{StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
#[derive(rust_embed::Embed)]
#[folder = "../../web/dist"]
struct Assets;
/// The message shown when the binary was built without a web UI. Silence here
/// would look like a broken deployment rather than a missing build step.
const NO_BUILD: &str = "the web UI is not built. Run `cd web && bun install && bun run build`.";
pub async fn serve(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
if let Some(response) = asset(path) {
return response;
}
// Never fall back for asset-shaped requests: a missing chunk answered with
// HTML turns a clear 404 into a confusing MIME-type error in the console.
if path.starts_with("assets/") {
return (StatusCode::NOT_FOUND, "not found").into_response();
}
asset("index.html").unwrap_or_else(|| (StatusCode::NOT_FOUND, NO_BUILD).into_response())
}
fn asset(path: &str) -> Option<Response> {
let file = Assets::get(path)?;
let mime = mime_guess::from_path(path).first_or_octet_stream();
// Vite content-hashes everything under assets/, so those are immutable.
// index.html must not be, or a deploy would never reach an open tab.
let cache = if path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
Some(
(
[
(header::CONTENT_TYPE, mime.as_ref()),
(header::CACHE_CONTROL, cache),
],
file.data.into_owned(),
)
.into_response(),
)
}
Acrates/otserver/src/writer.rs
@@ -0,0 +1,489 @@
//! The single writer task.
//!
//! Everything that writes to SQLite sends a [`WriteOp`] down an `mpsc` channel.
//! One task drains it, batching everything that arrives within 250 ms (or 512
//! operations, whichever comes first) into one transaction.
//!
//! Three properties follow, and all three matter:
//!
//! * **No `SQLITE_BUSY`, ever.** There is exactly one writer, so there is nothing
//! to contend with.
//! * **fsyncs are amortised.** 100 devices reporting once a minute is ~4
//! transactions per second, not 100.
//! * **The UDP loop never blocks on the database.** A full channel is answered
//! with `THROTTLE` and the datagram is dropped; the client will retry. Applying
//! backpressure to the receive loop instead would turn a storage stall into
//! packet loss for *every* device, including the ones the server could still
//! serve.
use std::time::Duration;
use anyhow::{Context, Result};
use otproto::Point;
use sqlx::{Sqlite, SqlitePool, Transaction};
use tokio::sync::mpsc;
use tracing::{debug, error, warn};
/// Channel depth. Deep enough to absorb a burst of queue flushes from every
/// device at once, shallow enough that a stalled disk is noticed in seconds
/// rather than after the process has eaten a gigabyte of positions.
const CHANNEL_DEPTH: usize = 8192;
/// Longest a write waits to be committed.
const BATCH_WINDOW: Duration = Duration::from_millis(250);
/// Most operations in one transaction.
const BATCH_MAX: usize = 512;
#[derive(Debug)]
pub enum WriteOp {
/// Points from one authenticated `LOC`, all belonging to one account.
Points {
user_id: i64,
/// Provenance only.
src_token_id: i64,
points: Vec<Point>,
recv_at: i64,
},
/// Per-datagram telemetry for a token. Written on every authenticated
/// packet, which is why it must be batched rather than done inline.
TokenSeen {
token_id: i64,
at: i64,
src_ip: String,
src_port: u16,
transport: &'static str,
},
/// Recorded from `HELLO`.
TokenHello {
token_id: i64,
app_version: i64,
os_api_level: i64,
},
Audit {
user_id: Option<i64>,
at: i64,
action: String,
detail: String,
src_ip: Option<String>,
},
}
/// Send handle. Cheap to clone; hand one to every task that needs to write.
#[derive(Clone)]
pub struct WriteHandle {
tx: mpsc::Sender<WriteOp>,
}
/// Whether a write was accepted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accepted {
Yes,
/// The channel is full. The caller should signal `THROTTLE` and drop.
Saturated,
/// The writer task is gone — we are shutting down.
Closed,
}
impl WriteHandle {
/// Never awaits, never blocks. This is what the UDP loop calls.
pub fn try_send(&self, op: WriteOp) -> Accepted {
match self.tx.try_send(op) {
Ok(()) => Accepted::Yes,
Err(mpsc::error::TrySendError::Full(_)) => Accepted::Saturated,
Err(mpsc::error::TrySendError::Closed(_)) => Accepted::Closed,
}
}
/// For HTTP handlers, which can afford to wait for a slot.
pub async fn send(&self, op: WriteOp) -> Result<()> {
self.tx.send(op).await.context("writer task has stopped")
}
/// Approximate free capacity, for `/metrics` and for deciding when to warn.
pub fn capacity(&self) -> usize {
self.tx.capacity()
}
}
/// Start the writer task. Returns the handle and a join handle for shutdown.
pub fn spawn(pool: SqlitePool) -> (WriteHandle, tokio::task::JoinHandle<()>) {
let (tx, rx) = mpsc::channel(CHANNEL_DEPTH);
let task = tokio::spawn(run(pool, rx));
(WriteHandle { tx }, task)
}
async fn run(pool: SqlitePool, mut rx: mpsc::Receiver<WriteOp>) {
let mut batch: Vec<WriteOp> = Vec::with_capacity(BATCH_MAX);
loop {
// Block until there is something to do — no idle polling.
let Some(first) = rx.recv().await else {
break; // all senders dropped: shutdown
};
batch.push(first);
// Then take whatever else shows up inside the window.
let deadline = tokio::time::Instant::now() + BATCH_WINDOW;
while batch.len() < BATCH_MAX {
match tokio::time::timeout_at(deadline, rx.recv()).await {
Ok(Some(op)) => batch.push(op),
Ok(None) => break, // channel closed; commit what we have
Err(_) => break, // window elapsed
}
}
if let Err(e) = commit(&pool, &mut batch).await {
// Losing a batch of positions is bad but survivable; the clients
// still hold them unacked and will retry. Dying here is not
// survivable, so log loudly and carry on.
error!(error = %e, "write batch failed; clients will retry");
batch.clear();
}
}
// Drain whatever is left so a graceful SIGTERM does not lose points.
while let Ok(op) = rx.try_recv() {
batch.push(op);
if batch.len() >= BATCH_MAX
&& let Err(e) = commit(&pool, &mut batch).await
{
error!(error = %e, "final write batch failed");
batch.clear();
}
}
if !batch.is_empty()
&& let Err(e) = commit(&pool, &mut batch).await
{
error!(error = %e, "final write batch failed");
}
debug!("writer task stopped");
}
async fn commit(pool: &SqlitePool, batch: &mut Vec<WriteOp>) -> Result<()> {
let count = batch.len();
let mut tx = pool.begin().await.context("begin transaction")?;
for op in batch.drain(..) {
apply(&mut tx, op).await?;
}
tx.commit().await.context("commit transaction")?;
if count > BATCH_MAX / 2 {
warn!(
count,
"large write batch — the writer may be falling behind"
);
} else {
debug!(count, "committed write batch");
}
Ok(())
}
async fn apply(tx: &mut Transaction<'_, Sqlite>, op: WriteOp) -> Result<()> {
match op {
WriteOp::Points {
user_id,
src_token_id,
points,
recv_at,
} => {
for p in points {
insert_point(tx, user_id, src_token_id, &p, recv_at).await?;
}
}
WriteOp::TokenSeen {
token_id,
at,
src_ip,
src_port,
transport,
} => {
// The source address is recorded but never used for authentication,
// which is exactly why a Wi-Fi to LTE handoff needs no protocol work.
sqlx::query(
"UPDATE tokens SET last_seen_at = ?, last_src_ip = ?, last_src_port = ?, \
last_transport = ? WHERE token_id = ?",
)
.bind(at)
.bind(src_ip)
.bind(i64::from(src_port))
.bind(transport)
.bind(token_id)
.execute(&mut **tx)
.await
.context("updating token telemetry")?;
}
WriteOp::TokenHello {
token_id,
app_version,
os_api_level,
} => {
sqlx::query("UPDATE tokens SET app_version = ?, os_api_level = ? WHERE token_id = ?")
.bind(app_version)
.bind(os_api_level)
.bind(token_id)
.execute(&mut **tx)
.await
.context("recording HELLO")?;
}
WriteOp::Audit {
user_id,
at,
action,
detail,
src_ip,
} => {
sqlx::query(
"INSERT INTO audit_log (at, user_id, action, detail, src_ip) VALUES (?, ?, ?, ?, ?)",
)
.bind(at)
.bind(user_id)
.bind(action)
.bind(detail)
.bind(src_ip)
.execute(&mut **tx)
.await
.context("writing audit log")?;
}
}
Ok(())
}
/// Insert one point and, if it is newer than what we have, update the live marker.
///
/// The `ON CONFLICT` clause is the load-bearing part of the whole storage design:
///
/// * A **retry or replay** carries a `ts` that already exists, so it collapses
/// into the existing row. This is why the protocol needs no replay window.
/// * **Two phones on one account reporting in the same second** would otherwise
/// resolve to whichever packet landed last, which is arbitrary. The
/// `WHERE excluded.acc_dm < points.acc_dm` guard keeps the better fix.
async fn insert_point(
tx: &mut Transaction<'_, Sqlite>,
user_id: i64,
src_token_id: i64,
p: &Point,
recv_at: i64,
) -> Result<()> {
let ts = i64::from(p.ts);
let acc = p.acc_dm.map(i64::from);
sqlx::query(
"INSERT INTO points \
(user_id, ts, lat, lon, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags, recv_at, src_token_id) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (user_id, ts) DO UPDATE SET \
lat = excluded.lat, lon = excluded.lon, acc_dm = excluded.acc_dm, \
alt_m = excluded.alt_m, spd_cms = excluded.spd_cms, brg_cdeg = excluded.brg_cdeg, \
bat_pct = excluded.bat_pct, flags = excluded.flags, recv_at = excluded.recv_at, \
src_token_id = excluded.src_token_id \
WHERE excluded.acc_dm IS NOT NULL \
AND (points.acc_dm IS NULL OR excluded.acc_dm < points.acc_dm)",
)
.bind(user_id)
.bind(ts)
.bind(p.lat_e7)
.bind(p.lon_e7)
.bind(acc)
.bind(p.alt_m.map(i64::from))
.bind(p.spd_cms.map(i64::from))
.bind(p.brg_cdeg.map(i64::from))
.bind(p.bat_pct.map(i64::from))
.bind(i64::from(p.flags.0))
.bind(recv_at)
.bind(src_token_id)
.execute(&mut **tx)
.await
.context("inserting point")?;
// The live marker only moves forward in client time. A phone that has been
// in a drawer and wakes up with an old queued fix must not drag the dot back.
sqlx::query(
"INSERT INTO user_latest \
(user_id, ts, lat, lon, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags, recv_at, src_token_id) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT (user_id) DO UPDATE SET \
ts = excluded.ts, lat = excluded.lat, lon = excluded.lon, acc_dm = excluded.acc_dm, \
alt_m = excluded.alt_m, spd_cms = excluded.spd_cms, brg_cdeg = excluded.brg_cdeg, \
bat_pct = excluded.bat_pct, flags = excluded.flags, recv_at = excluded.recv_at, \
src_token_id = excluded.src_token_id \
WHERE excluded.ts > user_latest.ts",
)
.bind(user_id)
.bind(ts)
.bind(p.lat_e7)
.bind(p.lon_e7)
.bind(acc)
.bind(p.alt_m.map(i64::from))
.bind(p.spd_cms.map(i64::from))
.bind(p.brg_cdeg.map(i64::from))
.bind(p.bat_pct.map(i64::from))
.bind(i64::from(p.flags.0))
.bind(recv_at)
.bind(src_token_id)
.execute(&mut **tx)
.await
.context("updating live position")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use otproto::point::Flags;
async fn seeded() -> (crate::db::Db, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let db = crate::db::Db::open(&dir.path().join("t.db"))
.await
.expect("open");
sqlx::query(
"INSERT INTO users (id, username, pw_hash, display_name, created_at, pw_changed_at) \
VALUES (1, 'a', 'x', 'A', 0, 0)",
)
.execute(&db.write)
.await
.expect("user");
(db, dir)
}
fn point(ts: u32, lat: i32, acc: Option<u16>) -> Point {
Point {
acc_dm: acc,
flags: Flags::NONE,
..Point::new(ts, lat, 0)
}
}
async fn drain(handle: &WriteHandle, task: tokio::task::JoinHandle<()>) {
drop(handle.clone());
// Dropping the last handle ends the task; the caller keeps one, so use an
// explicit timeout instead of awaiting forever.
let _ = tokio::time::timeout(Duration::from_millis(50), task).await;
}
#[tokio::test]
async fn a_batch_of_points_lands_in_one_transaction() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: (0..40)
.map(|i| point(1000 + i, i as i32, Some(50)))
.collect(),
recv_at: 2000,
})
.await
.expect("send");
tokio::time::sleep(Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 40);
drain(&handle, task).await;
}
#[tokio::test]
async fn the_live_marker_only_moves_forward_in_client_time() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
// Newest first, then an older queued fix — the drawer-phone scenario.
for (ts, lat) in [(2000u32, 100), (1000, 999)] {
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: vec![point(ts, lat, Some(50))],
recv_at: 3000,
})
.await
.expect("send");
}
tokio::time::sleep(Duration::from_millis(400)).await;
let (ts, lat): (i64, i64) =
sqlx::query_as("SELECT ts, lat FROM user_latest WHERE user_id = 1")
.fetch_one(&db.read)
.await
.expect("latest");
assert_eq!(
(ts, lat),
(2000, 100),
"an older fix must not drag the marker back"
);
// Both points are still in the history, though.
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 2);
drain(&handle, task).await;
}
#[tokio::test]
async fn a_replayed_point_is_idempotent() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
for _ in 0..3 {
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: vec![point(1000, 42, Some(80))],
recv_at: 2000,
})
.await
.expect("send");
}
tokio::time::sleep(Duration::from_millis(400)).await;
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM points")
.fetch_one(&db.read)
.await
.expect("count");
assert_eq!(count, 1, "replay must not create duplicate rows");
drain(&handle, task).await;
}
#[tokio::test]
async fn a_same_second_collision_keeps_the_better_accuracy() {
let (db, _dir) = seeded().await;
let (handle, task) = spawn(db.write.clone());
for (acc, lat) in [(200u16, 1), (30, 2), (500, 3)] {
handle
.send(WriteOp::Points {
user_id: 1,
src_token_id: 7,
points: vec![point(1000, lat, Some(acc))],
recv_at: 2000,
})
.await
.expect("send");
}
tokio::time::sleep(Duration::from_millis(400)).await;
let (acc, lat): (i64, i64) =
sqlx::query_as("SELECT acc_dm, lat FROM points WHERE user_id = 1 AND ts = 1000")
.fetch_one(&db.read)
.await
.expect("point");
assert_eq!(
(acc, lat),
(30, 2),
"the better fix must win, whatever the arrival order"
);
drain(&handle, task).await;
}
}
Ajustfile
@@ -0,0 +1,122 @@
# opentracker task runner. `just --list` for the menu.
sdk := env("ANDROID_HOME", env("HOME") / "android-sdk")
scratch := "dev"
default:
@just --list
# --- checks ------------------------------------------------------------------
# Everything CI would run.
check: test clippy fmt-check web-build android-test android-lint
test:
cargo test --workspace
clippy:
cargo clippy --workspace --all-targets -- -D warnings
fmt:
cargo fmt --all
fmt-check:
cargo fmt --all -- --check
# --- protocol ----------------------------------------------------------------
# Regenerate the golden vectors that are the Rust <-> Kotlin contract.
# `just test` then verifies them, and the Android suite decodes the same file.
gen-vectors:
cargo run -p otproto --features serde --example gen_vectors
# Coverage-guided fuzzing. Needs a nightly toolchain and cargo-fuzz:
# rustup toolchain install nightly && cargo install cargo-fuzz
fuzz target="decode" secs="60":
cd crates/otproto/fuzz && cargo +nightly fuzz run {{target}} -- -max_total_time={{secs}} -rss_limit_mb=4096
# --- server ------------------------------------------------------------------
# One-time: a server key and an admin account in ./{{scratch}}.
dev-setup user="sim" password="simsimsimsim":
mkdir -p {{scratch}}
test -f {{scratch}}/secret.key || (head -c32 /dev/urandom | base64 > {{scratch}}/secret.key && chmod 600 {{scratch}}/secret.key)
OT_SECRET_KEY={{scratch}}/secret.key OT_ADMIN_EMAIL=ops@example.net OT_DB_PATH={{scratch}}/ot.db \
cargo run -p otserver -- --create-admin {{user}} {{password}}
# The server plus an in-process fake phone walking a synthetic route over real
# OTP/1 on loopback. The fastest way to see the whole pipeline move.
#
# Serves the UI from web/dist if it has been built. For live reload, run
# `just web-dev` in a second terminal and use http://localhost:5173 instead.
dev:
OT_SECRET_KEY={{scratch}}/secret.key OT_ADMIN_EMAIL=ops@example.net OT_DB_PATH={{scratch}}/ot.db \
OT_HTTP_ADDR=127.0.0.1:7372 OT_UDP_ADDR=127.0.0.1:7373 \
cargo run -p otserver -- --dev --simulate-device
# --- web ---------------------------------------------------------------------
web-install:
cd web && bun install
# Vite with hot reload on :5173, proxying /api to the backend on :7372.
web-dev: web-install
cd web && bun run dev
# Type-check and bundle into web/dist, which the server embeds in release builds.
web-build: web-install
cd web && bun run build
# --- docker ------------------------------------------------------------------
docker-build:
docker build -f Containerfile -t opentracker:dev .
# The whole thing in one container, with the simulated phone walking its route:
# open http://127.0.0.1:7372 and sign in as {{user}}.
#
# 127.0.0.1, not localhost: the port is published on IPv4 only, and localhost
# resolves to ::1 first on this machine.
#
# The key below is a literal on purpose — this is a throwaway demo database. A
# real deployment passes OT_SECRET_KEY from a file or a secret store, and losing
# it makes every stored token key unrecoverable.
docker-demo user="sim" password="simsimsimsim": docker-build
docker volume create opentracker-demo >/dev/null
-docker run --rm -v opentracker-demo:/data \
-e OT_SECRET_KEY=ZGVtby1vbmx5LW5vdC1hLXJlYWwtc2VjcmV0LWtleSE= -e OT_ADMIN_EMAIL=ops@example.net \
opentracker:dev --create-admin {{user}} {{password}}
docker run --rm --name opentracker-demo -p 7372:7372 -p 7373:7373/udp -v opentracker-demo:/data \
-e OT_SECRET_KEY=ZGVtby1vbmx5LW5vdC1hLXJlYWwtc2VjcmV0LWtleSE= -e OT_ADMIN_EMAIL=ops@example.net \
-e OT_SIM_USER={{user}} -e OT_SIM_PASSWORD={{password}} \
opentracker:dev --dev --simulate-device
# Wipes the demo database so `just docker-demo` starts from nothing.
docker-demo-reset:
-docker rm -f opentracker-demo
docker volume rm opentracker-demo
# --- android -----------------------------------------------------------------
# JVM tests, including the golden-vector cross-check against otproto.
android-test:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:testDebugUnitTest
android-lint:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:lintDebug
android-build:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:assembleDebug
android-install: android-build
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:installDebug
adb shell am start -n net.lexcom.opentracker.debug/net.lexcom.opentracker.MainActivity
# Only our own tags, at verbose. Everything else silenced.
logcat:
adb logcat -c && adb logcat OpenTracker:V AndroidRuntime:E '*:S'
# The Compose compiler plugin version must equal the KGP version AGP bundles.
# Run this after any AGP bump and update app/build.gradle.kts to match.
android-kotlin-version:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:buildEnvironment | grep -i kotlin-gradle-plugin
Aopentracker.toml.example
@@ -0,0 +1,88 @@
# opentracker configuration. Every field can also be set as OT_<UPPERCASE>.
# Copy to opentracker.toml and edit. All values shown are the defaults.
# HTTP API and web UI. Localhost by default: nginx or Caddy terminates TLS.
http_addr = "127.0.0.1:7372"
# OTP/1 UDP listener.
#
# THE TRAP: HTTP reverse proxies do not forward UDP. This port must be exposed
# directly by a firewall or NAT rule. If it is not, every phone silently falls
# back to TLS-over-TCP and you lose the whole point of the protocol.
udp_addr = "0.0.0.0:7373"
# TLS-over-TCP fallback for UDP-hostile networks. Also needs its own rule.
# tls_addr = "0.0.0.0:7374"
# What the login response tells phones to connect to. These are the *public*
# names, which are usually not the same as the bind addresses above.
public_udp_host = "localhost"
public_udp_port = 7373
# public_tls_url = "tls://track.example.com:7374"
base_url = "http://localhost:7372"
# REQUIRED. The OSM tile usage policy demands a contactable User-Agent, and an
# unidentified tile proxy is blocked without notice. The server refuses to start
# while this is the placeholder.
admin_email = "you@example.com"
db_path = "opentracker.db"
cache_dir = "cache"
# Tile cache ceiling in bytes; eviction runs down to 90% of it. 1 GiB.
max_cache_bytes = 1073741824
# Point at your own renderer here if you ever run one.
tile_upstream_url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
# Hard drop for points older than this. Points older than 24 h are also thinned
# to roughly one per 5 minutes. The live marker lives in its own table and is
# never affected.
retention_days = 7
# Mark tokens with no activity for this long as revoked. A phone idle for a
# month has to log in again — the same contract as an expiring browser session.
#
# The row and its wrapped key are kept, not deleted. The key is the only thing
# that can seal a message the phone will believe, and a phone idle for a month is
# exactly the one that needs telling. A token row is about a hundred bytes.
token_stale_days = 30
# Accept client timestamps within ±this many days.
#
# This is the only server-side handling of a client timestamp anywhere. Nothing
# corrects a ts, nothing measures clock skew, and no message in the protocol
# carries the server's clock. This bound exists purely so a phone whose clock says
# 2106 cannot write rows the retention sweep will never reach.
ts_window_days = 30
# Answer a datagram naming an unknown token with a sealed REVOKED notice instead
# of silence, so a phone whose token the server has forgotten lands on the login
# screen instead of reporting into nothing.
#
# THE TRADE: this is the one reply the server sends without having verified the
# request, which makes the UDP port a reflector — UDP source addresses are
# forgeable, so the reply goes wherever the sender claimed to be. Three things
# bound it: the notice is 38 bytes and is refused to any shorter request, so it
# can never amplify; it is limited to one per destination address per minute
# under a global ceiling of 10/s; and naming unknown tokens still earns strikes
# and a ban either way. It cannot be forged, because it is sealed with a key
# derived from the server master and that token_id.
#
# Turning it off costs little. A revoked token keeps its row and its key, so the
# ordinary "you are logged out" answer is a fully authenticated NACK that never
# takes this path. This switch only matters when the row is genuinely gone: a
# restored backup predating the login, or a rotated OT_SECRET_KEY. With it off,
# those devices get silence until someone opens the app.
revocation_notices = true
# Argon2id. 19 MiB / 2 passes / 1 lane is OWASP's second recommended profile.
# Raising these later does not invalidate existing hashes: each hash carries its
# own parameters and is transparently upgraded on the next successful login.
argon2_memory_kib = 19456
argon2_iterations = 2
argon2_parallelism = 1
# Number of SO_REUSEPORT receive tasks. Defaults to min(cpus, 4).
# udp_workers = 4
Aweb/bun.lock
@@ -0,0 +1,269 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "opentracker-web",
"dependencies": {
"@solidjs/router": "^1.0.0",
"leaflet": "1.9.4",
"solid-js": "1.9.15",
},
"devDependencies": {
"@types/leaflet": "1.9.22",
"typescript": "7.0.2",
"vite": "8.2.2",
"vite-plugin-solid": "2.11.14",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@solidjs/router": ["@solidjs/router@1.0.0", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.10", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA=="],
"babel-preset-solid": ["babel-preset-solid@1.9.15", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.10" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.15" }, "optionalPeers": ["solid-js"] }, "sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.20", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw=="],
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.416", "", {}, "sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
"seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
"solid-js": ["solid-js@1.9.15", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg=="],
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="],
"vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.14", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
}
}
Aweb/index.html
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>opentracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
Aweb/package.json
@@ -0,0 +1,21 @@
{
"name": "opentracker-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@solidjs/router": "^1.0.0",
"leaflet": "1.9.4",
"solid-js": "1.9.15"
},
"devDependencies": {
"@types/leaflet": "1.9.22",
"typescript": "7.0.2",
"vite": "8.2.2",
"vite-plugin-solid": "2.11.14"
}
}
Aweb/src/App.tsx
@@ -0,0 +1,148 @@
import { createResource, createSignal, Show, onCleanup, type JSX } from "solid-js";
import { A, Route, Router, useNavigate } from "@solidjs/router";
import { api, ApiError, decodePolyline, UNAUTHORIZED, type Me } from "./api";
import { createLiveStore } from "./live";
import { SessionContext } from "./session";
import MapPage from "./MapPage";
import Settings from "./Settings";
const TRAIL_SECONDS = 24 * 3600;
const TRAIL_REFRESH_MS = 60_000;
export default function App() {
return (
<Router root={Shell}>
<Route path="/" component={MapPage} />
<Route path="/settings" component={Settings} />
<Route path="*" component={NotFound} />
</Router>
);
}
function NotFound() {
return (
<main class="center">
<p class="muted">no such page</p>
<A href="/">back to the map</A>
</main>
);
}
/**
* The router root: session, navigation, and the one polling loop.
*
* Signing out is not a redirect. The signed-out shell renders the login form in
* place, so a deep link to /settings survives it — you land where you were
* heading rather than back at the map.
*/
function Shell(props: { children?: JSX.Element }) {
// undefined while the initial /api/me is in flight, null when signed out.
const [me, setMe] = createSignal<Me | null | undefined>(undefined);
api.me()
.then(setMe)
.catch(() => setMe(null));
return (
<Show when={me() !== undefined} fallback={<div class="center">loading…</div>}>
<Show when={me()} fallback={<Login onSignedIn={setMe} />} keyed>
{(user) => (
<SignedIn me={user} onSignedOut={() => setMe(null)}>
{props.children}
</SignedIn>
)}
</Show>
</Show>
);
}
function SignedIn(props: { me: Me; onSignedOut: () => void; children?: JSX.Element }) {
const live = createLiveStore(props.onSignedOut);
const [selected, setSelected] = createSignal<number | null>(props.me.id);
// The trail for whoever is selected. A 403 means the share does not grant
// trail visibility, which is a normal answer, not an error to surface.
const [trail, { refetch }] = createResource(selected, async (id) => {
const to = live.serverTime();
try {
const res = await api.track(id, to - TRAIL_SECONDS, to);
return decodePolyline(res.polyline);
} catch (e) {
if (e instanceof ApiError && (e.status === 403 || e.status === UNAUTHORIZED)) return null;
throw e;
}
});
const timer = setInterval(() => void refetch(), TRAIL_REFRESH_MS);
onCleanup(() => clearInterval(timer));
async function signOut() {
await api.logout().catch(() => {});
props.onSignedOut();
}
return (
<SessionContext.Provider
value={{
me: props.me,
people: live.people,
order: live.order,
serverTime: live.serverTime,
selected,
select: setSelected,
trail: () => trail() ?? null,
}}
>
<div class="shell">
<header>
<strong>opentracker</strong>
<nav>
<A href="/" end activeClass="active">
map
</A>
<A href="/settings" activeClass="active">
settings
</A>
</nav>
<span class="who">{props.me.display_name}</span>
<button onClick={signOut}>sign out</button>
</header>
<Show when={live.error()}>{(msg) => <div class="banner">{msg()}</div>}</Show>
{props.children}
</div>
</SessionContext.Provider>
);
}
function Login(props: { onSignedIn: (me: Me) => void }) {
const [username, setUsername] = createSignal("");
const [password, setPassword] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [busy, setBusy] = createSignal(false);
const navigate = useNavigate();
async function submit(e: Event) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const { user } = await api.login(username(), password());
// A stale bookmark to a route that no longer exists would otherwise sign
// in and land on the 404 page.
if (window.location.pathname === "/login") navigate("/", { replace: true });
props.onSignedIn(user);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<form class="center card login" onSubmit={submit}>
<h1>opentracker</h1>
<input placeholder="username" autocomplete="username" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
<input type="password" placeholder="password" autocomplete="current-password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
<Show when={error()}>{(msg) => <p class="error">{msg()}</p>}</Show>
<button disabled={busy()}>{busy() ? "signing in…" : "sign in"}</button>
</form>
);
}
Aweb/src/Map.tsx
@@ -0,0 +1,95 @@
import { onMount, onCleanup, createEffect } from "solid-js";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import type { PersonState } from "./api";
// Straight to OSM for now. The caching proxy at /tiles/{z}/{x}/{y}.png lands in
// step 14; swapping this string is the whole migration. Attribution is not
// removable either way — it is a condition of the tile policy.
const TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
interface Props {
people: Record<number, PersonState>;
order: () => number[];
selected: () => number | null;
onSelect: (id: number) => void;
trail: () => [number, number][] | null;
}
export default function MapView(props: Props) {
let container!: HTMLDivElement;
// Leaflet is imperative and Solid is fine-grained, so none of this may live in
// reactive state: the map is created once and driven by effects. Wrapping
// Leaflet in components that re-render is the one thing that must not happen.
let map: L.Map | undefined;
let trailLine: L.Polyline | undefined;
const markers = new Map<number, { dot: L.CircleMarker; halo: L.Circle }>();
let fitted = false;
onMount(() => {
map = L.map(container, { zoomControl: true }).setView([52.52, 13.405], 13);
L.tileLayer(TILE_URL, { maxZoom: 19, attribution: ATTRIBUTION }).addTo(map);
});
onCleanup(() => map?.remove());
createEffect(() => {
const ids = props.order();
if (!map) return;
for (const id of ids) {
const person = props.people[id];
const pos = person?.position;
if (!pos) continue;
const latlng = L.latLng(pos.lat_e7 / 1e7, pos.lon_e7 / 1e7);
const colour = person.is_self ? "#1d6fd8" : "#d8461d";
let entry = markers.get(id);
if (!entry) {
const halo = L.circle(latlng, { radius: 0, color: colour, weight: 1, opacity: 0.35, fillOpacity: 0.08 }).addTo(map);
const dot = L.circleMarker(latlng, { radius: 7, color: "#fff", weight: 2, fillColor: colour, fillOpacity: 1 })
.addTo(map)
.on("click", () => props.onSelect(id));
entry = { dot, halo };
markers.set(id, entry);
}
entry.dot.setLatLng(latlng).setStyle({ fillColor: colour });
entry.dot.bindTooltip(person.display_name, { direction: "top", offset: [0, -8] });
entry.halo.setLatLng(latlng).setRadius(pos.acc_dm === null ? 0 : pos.acc_dm / 10);
}
for (const [id, entry] of markers) {
if (props.people[id]?.position) continue;
entry.dot.remove();
entry.halo.remove();
markers.delete(id);
}
// Fit once. Re-fitting on every poll would fight the user's panning.
if (!fitted && markers.size > 0) {
fitted = true;
map.fitBounds(L.latLngBounds([...markers.values()].map((m) => m.dot.getLatLng())).pad(0.3), { maxZoom: 16 });
}
});
createEffect(() => {
const points = props.trail();
if (!map) return;
trailLine?.remove();
trailLine = undefined;
if (points && points.length > 1) {
trailLine = L.polyline(points, { color: "#1d6fd8", weight: 3, opacity: 0.55 }).addTo(map);
}
});
// Recentre when the selection changes.
createEffect(() => {
const id = props.selected();
if (id === null || !map) return;
const entry = markers.get(id);
if (entry) map.panTo(entry.dot.getLatLng());
});
return <div class="map" ref={container} />;
}
Aweb/src/MapPage.tsx
@@ -0,0 +1,37 @@
import { For, Show } from "solid-js";
import { FLAG_CHARGING } from "./api";
import { ago, staleness } from "./live";
import MapView from "./Map";
import { useSession } from "./session";
export default function MapPage() {
const s = useSession();
return (
<main class="split">
<aside>
<For each={s.order()} fallback={<p class="muted">nobody is sharing with you yet</p>}>
{(id) => {
const person = () => s.people[id];
return (
<button class="person" classList={{ active: s.selected() === id }} onClick={() => s.select(id)} disabled={!person()?.position}>
<span class="name">{person()?.display_name}</span>
<Show when={person()?.position} fallback={<span class="muted">no fix yet</span>}>
{(pos) => (
<span class="meta">
<span class={`dot ${staleness(pos().ts, s.serverTime())}`} />
{ago(pos().ts, s.serverTime())} ago
<Show when={pos().acc_dm !== null}>{" · ±" + Math.round(pos().acc_dm! / 10) + " m"}</Show>
<Show when={pos().bat_pct !== null}>{" · " + pos().bat_pct + "%" + (pos().flags & FLAG_CHARGING ? " ⚡" : "")}</Show>
</span>
)}
</Show>
</button>
);
}}
</For>
</aside>
<MapView people={s.people} order={s.order} selected={s.selected} onSelect={s.select} trail={s.trail} />
</main>
);
}
Aweb/src/Settings.tsx
@@ -0,0 +1,93 @@
import { createResource, createSignal, For, Show } from "solid-js";
import { api, type TokenInfo } from "./api";
import { ago } from "./live";
import { useSession } from "./session";
/**
* The only place individual phones are visible.
*
* Positions belong to the account, so a token is a credential and nothing else.
* Listing them with last-seen is what makes a forgotten phone in a drawer — the
* one that drags the live marker back and forth — visible and revocable.
*/
export default function Settings() {
const me = useSession().me;
const [tokens, { refetch }] = createResource(() => api.tokens());
const [message, setMessage] = createSignal<string | null>(null);
const now = Math.floor(Date.now() / 1000);
async function revoke(t: TokenInfo) {
if (!confirm(`Revoke "${t.name}"? That phone has to log in again.`)) return;
await api.revokeToken(t.token_id);
void refetch();
}
async function revokeOthers() {
if (!confirm("Revoke every device token on this account?")) return;
const { revoked } = await api.revokeOthers();
setMessage(`${revoked} token(s) revoked`);
void refetch();
}
return (
<main class="settings">
<section class="card">
<h2>account</h2>
<p class="muted">
{me.display_name} ({me.username}){me.is_admin ? " · admin" : ""}
</p>
<PasswordForm />
</section>
<section class="card">
<h2>devices</h2>
<Show when={message()}>{(m) => <p class="muted">{m()}</p>}</Show>
<For each={tokens()} fallback={<p class="muted">no devices logged in</p>}>
{(t) => (
<div class="token">
<div>
<strong>{t.name}</strong> <span class="muted">{t.platform}</span>
<div class="muted">
{t.last_seen_at ? `seen ${ago(t.last_seen_at, now)} ago` : "never seen"}
{t.last_src_ip ? ` · ${t.last_src_ip}` : ""}
{t.last_transport ? ` · ${t.last_transport}` : ""}
{t.os_api_level ? ` · API ${t.os_api_level}` : ""}
</div>
</div>
<button onClick={() => revoke(t)}>revoke</button>
</div>
)}
</For>
<button onClick={revokeOthers}>log out all devices</button>
</section>
</main>
);
}
function PasswordForm() {
const [current, setCurrent] = createSignal("");
const [next, setNext] = createSignal("");
const [status, setStatus] = createSignal<string | null>(null);
async function submit(e: Event) {
e.preventDefault();
try {
await api.changePassword(current(), next());
// Changing the password revokes every device token, by design.
setStatus("changed — every device must log in again");
setCurrent("");
setNext("");
} catch (e) {
setStatus(e instanceof Error ? e.message : String(e));
}
}
return (
<form class="password" onSubmit={submit}>
<input type="password" placeholder="current password" autocomplete="current-password" value={current()} onInput={(e) => setCurrent(e.currentTarget.value)} />
<input type="password" placeholder="new password (10+ characters)" autocomplete="new-password" value={next()} onInput={(e) => setNext(e.currentTarget.value)} />
<button>change password</button>
<Show when={status()}>{(s) => <p class="muted">{s()}</p>}</Show>
</form>
);
}
Aweb/src/api.ts
@@ -0,0 +1,158 @@
// The whole server contract, in one file.
//
// Types are hand-written to mirror the `Serialize` structs in
// `crates/otserver/src/api.rs`. A Rust test (`api::tests::the_json_shape_is_the
// _one_the_web_ui_expects`) asserts the exact JSON key set of every response
// type, so a renamed field fails `cargo test` instead of failing in a browser.
export interface Me {
id: number;
username: string;
display_name: string;
is_admin: boolean;
server_time: number;
}
export interface Position {
ts: number;
/** Degrees x 1e7. Integers end to end, so no float-formatting drift. */
lat_e7: number;
lon_e7: number;
acc_dm: number | null;
alt_m: number | null;
spd_cms: number | null;
brg_cdeg: number | null;
bat_pct: number | null;
flags: number;
recv_at: number;
}
export interface PersonState {
user_id: number;
display_name: string;
is_self: boolean;
position?: Position;
}
export interface StateResponse {
server_time: number;
people: PersonState[];
}
export interface TokenInfo {
/** A decimal string: a u64 does not fit exactly in a JS number. */
token_id: string;
name: string;
platform: string;
app_version: number | null;
os_api_level: number | null;
last_seen_at: number | null;
last_src_ip: string | null;
last_transport: string | null;
created_at: number;
}
export interface TrackResponse {
user_id: number;
from: number;
to: number;
polyline: string;
point_count: number;
}
/** Point flag bits, matching `otproto::Point`. */
export const FLAG_CHARGING = 1;
export const FLAG_NETWORK_FIX = 2;
export const FLAG_LOW_ACCURACY = 4;
export const FLAG_MOCK = 8;
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
/** Thrown-away sentinel: the caller shows the login screen on a 401. */
export const UNAUTHORIZED = 401;
async function request<T>(method: string, path: string, body?: unknown, etag?: string): Promise<{ data: T | null; etag: string | null }> {
const headers: Record<string, string> = {};
// A custom header a cross-origin page cannot set without a CORS preflight we
// never grant. Combined with SameSite=Lax on the session cookie that is the
// whole CSRF defence; there is no token to store or rotate.
if (method !== "GET") headers["X-OT-CSRF"] = "1";
if (body !== undefined) headers["Content-Type"] = "application/json";
if (etag) headers["If-None-Match"] = etag;
const res = await fetch(path, {
method,
headers,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 304) return { data: null, etag: etag ?? null };
if (!res.ok) {
const message = await res
.json()
.then((b) => (b as { error?: string }).error ?? res.statusText)
.catch(() => res.statusText);
throw new ApiError(res.status, message);
}
const responseEtag = res.headers.get("ETag");
if (res.status === 204) return { data: null, etag: responseEtag };
return { data: (await res.json()) as T, etag: responseEtag };
}
async function json<T>(method: string, path: string, body?: unknown): Promise<T> {
const { data } = await request<T>(method, path, body);
return data as T;
}
export const api = {
login: (username: string, password: string) => json<{ user: Me }>("POST", "/api/login", { username, password }),
logout: () => json<void>("POST", "/api/logout"),
me: () => json<Me>("GET", "/api/me"),
/** Returns null when the ETag matched, meaning nothing changed. */
state: (etag?: string) => request<StateResponse>("GET", "/api/state", undefined, etag),
tokens: () => json<TokenInfo[]>("GET", "/api/tokens"),
revokeToken: (id: string) => json<void>("DELETE", `/api/tokens/${id}`),
revokeOthers: () => json<{ revoked: number }>("POST", "/api/tokens/revoke-others"),
track: (userId: number, from: number, to: number, max = 2000) =>
json<TrackResponse>("GET", `/api/users/${userId}/track?from=${from}&to=${to}&max=${max}`),
changePassword: (current_password: string, new_password: string) =>
json<void>("POST", "/api/me/password", { current_password, new_password }),
};
/**
* Google encoded polyline at 1e5, the format `/track` returns.
*
* The server encodes; nothing here re-encodes, so this is the only half that
* has to exist.
*/
export function decodePolyline(encoded: string): [number, number][] {
const out: [number, number][] = [];
let index = 0;
let lat = 0;
let lon = 0;
while (index < encoded.length) {
for (let i = 0; i < 2; i++) {
let result = 0;
let shift = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const delta = result & 1 ? ~(result >> 1) : result >> 1;
if (i === 0) lat += delta;
else lon += delta;
}
out.push([lat / 1e5, lon / 1e5]);
}
return out;
}
Aweb/src/index.tsx
@@ -0,0 +1,5 @@
import { render } from "solid-js/web";
import App from "./App";
import "./styles.css";
render(() => <App />, document.getElementById("root")!);
Aweb/src/live.ts
@@ -0,0 +1,92 @@
import { createStore } from "solid-js/store";
import { createSignal, onCleanup } from "solid-js";
import { api, ApiError, UNAUTHORIZED, type PersonState } from "./api";
const POLL_MS = 5_000;
/**
* The polling live view.
*
* A store rather than a signal so a position update for one person only touches
* the DOM that reads that person. Polling pauses while the tab is hidden, and
* an unchanged poll is a 304 with no body thanks to the endpoint's ETag.
*/
export function createLiveStore(onUnauthorized: () => void) {
const [people, setPeople] = createStore<Record<number, PersonState>>({});
const [order, setOrder] = createSignal<number[]>([]);
const [serverTime, setServerTime] = createSignal(Math.floor(Date.now() / 1000));
const [error, setError] = createSignal<string | null>(null);
let etag: string | undefined;
let timer: number | undefined;
let stopped = false;
async function poll() {
try {
const res = await api.state(etag);
etag = res.etag ?? undefined;
setError(null);
if (res.data) {
setServerTime(res.data.server_time);
const seen = new Set<number>();
for (const p of res.data.people) {
seen.add(p.user_id);
setPeople(p.user_id, p);
}
for (const id of Object.keys(people).map(Number)) {
if (!seen.has(id)) setPeople(id, undefined!);
}
setOrder(res.data.people.map((p) => p.user_id));
}
} catch (e) {
if (e instanceof ApiError && e.status === UNAUTHORIZED) {
stop();
onUnauthorized();
return;
}
setError(e instanceof Error ? e.message : String(e));
}
schedule();
}
function schedule() {
if (stopped || document.hidden) return;
timer = window.setTimeout(poll, POLL_MS);
}
function stop() {
stopped = true;
clearTimeout(timer);
}
const onVisibility = () => {
clearTimeout(timer);
// Poll immediately on becoming visible: waiting 5 s to refresh a view the
// user just looked at is exactly when staleness is most noticeable.
if (!document.hidden && !stopped) void poll();
};
document.addEventListener("visibilitychange", onVisibility);
onCleanup(() => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
});
void poll();
return { people, order, serverTime, error, refresh: () => void poll() };
}
/** "12s", "4m", "2h", "3d" — compared against the server clock, never the browser's. */
export function ago(ts: number, now: number): string {
const d = Math.max(0, now - ts);
if (d < 60) return `${d}s`;
if (d < 3600) return `${Math.floor(d / 60)}m`;
if (d < 86400) return `${Math.floor(d / 3600)}h`;
return `${Math.floor(d / 86400)}d`;
}
/** Fresh under 15 min, amber to an hour, red beyond. */
export function staleness(ts: number, now: number): "fresh" | "stale" | "old" {
const d = now - ts;
return d < 900 ? "fresh" : d < 3600 ? "stale" : "old";
}
Aweb/src/session.ts
@@ -0,0 +1,28 @@
import { createContext, useContext } from "solid-js";
import type { Accessor } from "solid-js";
import type { Me, PersonState } from "./api";
/**
* What the routed pages need from the shell.
*
* A context rather than props because route components are constructed by the
* router and cannot be handed anything. It holds the *live* store, so both pages
* share one polling loop rather than starting one each.
*/
export interface SessionValue {
me: Me;
people: Record<number, PersonState>;
order: Accessor<number[]>;
serverTime: Accessor<number>;
selected: Accessor<number | null>;
select: (id: number) => void;
trail: Accessor<[number, number][] | null>;
}
export const SessionContext = createContext<SessionValue>();
export function useSession(): SessionValue {
const value = useContext(SessionContext);
if (!value) throw new Error("useSession outside the shell");
return value;
}
Aweb/src/styles.css
@@ -0,0 +1,86 @@
/* One stylesheet, system fonts, dark by preference. No framework. */
:root {
--bg: #fff;
--fg: #16181d;
--muted: #6a7280;
--line: #dde1e7;
--accent: #1d6fd8;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #14161a; --fg: #e6e8ec; --muted: #949aa5; --line: #2a2e36; }
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.45 system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
}
button, input {
font: inherit;
color: inherit;
background: transparent;
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { cursor: default; opacity: 0.6; }
button.active { border-color: var(--accent); color: var(--accent); }
a { color: var(--accent); }
/* The nav is <a> now that routes are real URLs, so it has to look like the
buttons it replaced: middle-click and "copy link" only work on an anchor. */
header nav a {
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: inherit;
text-decoration: none;
}
header nav a:hover { border-color: var(--accent); }
header nav a.active { border-color: var(--accent); color: var(--accent); }
.center { min-height: 100dvh; display: grid; place-content: center; gap: 10px; }
.card { border: 1px solid var(--line); border-radius: 10px; padding: 16px; }
.login { width: min(320px, 90vw); }
.login h1 { margin: 0 0 4px; font-size: 18px; }
.error { color: #d8461d; margin: 0; }
.muted { color: var(--muted); }
.banner { padding: 6px 12px; background: #d8461d22; border-bottom: 1px solid var(--line); }
.shell { display: flex; flex-direction: column; height: 100dvh; }
header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
header nav { display: flex; gap: 6px; }
header .who { margin-left: auto; color: var(--muted); }
.split { flex: 1; display: grid; grid-template-columns: 260px 1fr; min-height: 0; }
aside { border-right: 1px solid var(--line); overflow-y: auto; padding: 8px; display: grid; gap: 6px; align-content: start; }
.map { height: 100%; }
.person { display: grid; gap: 2px; text-align: left; width: 100%; }
.person .name { font-weight: 600; }
.person .meta { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.fresh { background: #2e9e4f; }
.dot.stale { background: #d8a11d; }
.dot.old { background: #d8461d; }
.settings { padding: 16px; display: grid; gap: 16px; max-width: 640px; }
.settings h2 { margin: 0 0 8px; font-size: 15px; }
.token { display: flex; align-items: center; gap: 12px; justify-content: space-between; padding: 8px 0; border-top: 1px solid var(--line); }
.password { display: grid; gap: 8px; margin-top: 12px; }
/* Leaflet's own attribution must stay legible in dark mode; it is not optional. */
.leaflet-container { background: var(--bg); }
.leaflet-control-attribution { background: #ffffffcc !important; color: #16181d !important; }
.leaflet-control-attribution a { color: #1d6fd8 !important; }
Aweb/tsconfig.json
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"noUnusedLocals": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
Aweb/vite.config.ts
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
// Vite rather than `bun build`: Solid's JSX is a compile-time transform
// (babel-plugin-jsx-dom-expressions), not the automatic JSX runtime bun
// implements. bun is still the package manager and the runtime — `bun run dev`.
//
// The dev server proxies /api to the Rust binary so cookies stay same-origin.
// Start the backend with `--dev`, which drops the cookie's Secure requirement.
export default defineConfig({
plugins: [solid()],
server: {
port: 5173,
proxy: { "/api": "http://127.0.0.1:7372" },
},
build: { outDir: "dist", emptyOutDir: true },
});