An encrypted personal cloud built on the AT Protocol.

onsolidate DNS and transport into opake-core behind feature flags

opake-dns (one function) and opake-transport (one struct) didn't justify
separate crates. Both now live in opake-core gated behind `dns` and
`reqwest-transport` features, combined as `native` for CLI and AppView.
WASM builds compile core without either — no reqwest or hickory-resolver
in the dep tree.

Adds resolve_pds_for_login_with_dns() to unify the DNS-first resolution
pattern that CLI and AppView were duplicating inline.

sans-self.org 7b1f50e2 e9ff7c0b

Waiting for spindle ...
+609 -52
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Consolidate DNS and transport into opake-core, unify handle resolution [#185](https://issues.opake.app/issues/185.html) 15 16 - Build web login, callback, setup, and recover routes [#173](https://issues.opake.app/issues/173.html) 16 17 - Add device-to-device key pairing via PDS [#183](https://issues.opake.app/issues/183.html) 17 18 - Wire authenticated API layer for PDS and AppView [#174](https://issues.opake.app/issues/174.html)
+220 -9
Cargo.lock
··· 625 625 ] 626 626 627 627 [[package]] 628 + name = "enum-as-inner" 629 + version = "0.6.1" 630 + source = "registry+https://github.com/rust-lang/crates.io-index" 631 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 632 + dependencies = [ 633 + "heck", 634 + "proc-macro2", 635 + "quote", 636 + "syn", 637 + ] 638 + 639 + [[package]] 628 640 name = "env_filter" 629 641 version = "1.0.0" 630 642 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 756 768 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 757 769 758 770 [[package]] 771 + name = "futures-io" 772 + version = "0.3.32" 773 + source = "registry+https://github.com/rust-lang/crates.io-index" 774 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 775 + 776 + [[package]] 759 777 name = "futures-macro" 760 778 version = "0.3.32" 761 779 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 863 881 "parking_lot", 864 882 "portable-atomic", 865 883 "quanta", 866 - "rand", 884 + "rand 0.9.2", 867 885 "smallvec", 868 886 "spinning_top", 869 887 "web-time", ··· 932 950 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 933 951 934 952 [[package]] 953 + name = "hickory-proto" 954 + version = "0.24.4" 955 + source = "registry+https://github.com/rust-lang/crates.io-index" 956 + checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 957 + dependencies = [ 958 + "async-trait", 959 + "cfg-if", 960 + "data-encoding", 961 + "enum-as-inner", 962 + "futures-channel", 963 + "futures-io", 964 + "futures-util", 965 + "idna", 966 + "ipnet", 967 + "once_cell", 968 + "rand 0.8.5", 969 + "thiserror 1.0.69", 970 + "tinyvec", 971 + "tokio", 972 + "tracing", 973 + "url", 974 + ] 975 + 976 + [[package]] 977 + name = "hickory-resolver" 978 + version = "0.24.4" 979 + source = "registry+https://github.com/rust-lang/crates.io-index" 980 + checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 981 + dependencies = [ 982 + "cfg-if", 983 + "futures-util", 984 + "hickory-proto", 985 + "ipconfig", 986 + "lru-cache", 987 + "once_cell", 988 + "parking_lot", 989 + "rand 0.8.5", 990 + "resolv-conf", 991 + "smallvec", 992 + "thiserror 1.0.69", 993 + "tokio", 994 + "tracing", 995 + ] 996 + 997 + [[package]] 935 998 name = "hkdf" 936 999 version = "0.12.4" 937 1000 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1063 1126 "libc", 1064 1127 "percent-encoding", 1065 1128 "pin-project-lite", 1066 - "socket2", 1129 + "socket2 0.6.2", 1067 1130 "tokio", 1068 1131 "tower-service", 1069 1132 "tracing", ··· 1215 1278 ] 1216 1279 1217 1280 [[package]] 1281 + name = "ipconfig" 1282 + version = "0.3.2" 1283 + source = "registry+https://github.com/rust-lang/crates.io-index" 1284 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1285 + dependencies = [ 1286 + "socket2 0.5.10", 1287 + "widestring", 1288 + "windows-sys 0.48.0", 1289 + "winreg", 1290 + ] 1291 + 1292 + [[package]] 1218 1293 name = "ipnet" 1219 1294 version = "2.11.0" 1220 1295 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1326 1401 ] 1327 1402 1328 1403 [[package]] 1404 + name = "linked-hash-map" 1405 + version = "0.5.6" 1406 + source = "registry+https://github.com/rust-lang/crates.io-index" 1407 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 1408 + 1409 + [[package]] 1329 1410 name = "linux-raw-sys" 1330 1411 version = "0.12.1" 1331 1412 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1353 1434 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1354 1435 1355 1436 [[package]] 1437 + name = "lru-cache" 1438 + version = "0.1.2" 1439 + source = "registry+https://github.com/rust-lang/crates.io-index" 1440 + checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 1441 + dependencies = [ 1442 + "linked-hash-map", 1443 + ] 1444 + 1445 + [[package]] 1356 1446 name = "lru-slab" 1357 1447 version = "0.1.2" 1358 1448 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1489 1579 "base64", 1490 1580 "ed25519-dalek", 1491 1581 "getrandom 0.2.17", 1582 + "hickory-resolver", 1492 1583 "hkdf", 1493 1584 "log", 1494 1585 "opake-derive", 1495 1586 "p256", 1587 + "reqwest", 1496 1588 "serde", 1497 1589 "serde_json", 1498 1590 "sha2", ··· 1726 1818 "quinn-udp", 1727 1819 "rustc-hash", 1728 1820 "rustls", 1729 - "socket2", 1821 + "socket2 0.6.2", 1730 1822 "thiserror 2.0.18", 1731 1823 "tokio", 1732 1824 "tracing", ··· 1743 1835 "bytes", 1744 1836 "getrandom 0.3.4", 1745 1837 "lru-slab", 1746 - "rand", 1838 + "rand 0.9.2", 1747 1839 "ring", 1748 1840 "rustc-hash", 1749 1841 "rustls", ··· 1764 1856 "cfg_aliases", 1765 1857 "libc", 1766 1858 "once_cell", 1767 - "socket2", 1859 + "socket2 0.6.2", 1768 1860 "tracing", 1769 1861 "windows-sys 0.60.2", 1770 1862 ] ··· 1786 1878 1787 1879 [[package]] 1788 1880 name = "rand" 1881 + version = "0.8.5" 1882 + source = "registry+https://github.com/rust-lang/crates.io-index" 1883 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1884 + dependencies = [ 1885 + "libc", 1886 + "rand_chacha 0.3.1", 1887 + "rand_core 0.6.4", 1888 + ] 1889 + 1890 + [[package]] 1891 + name = "rand" 1789 1892 version = "0.9.2" 1790 1893 source = "registry+https://github.com/rust-lang/crates.io-index" 1791 1894 checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1792 1895 dependencies = [ 1793 - "rand_chacha", 1896 + "rand_chacha 0.9.0", 1794 1897 "rand_core 0.9.5", 1795 1898 ] 1796 1899 1797 1900 [[package]] 1798 1901 name = "rand_chacha" 1902 + version = "0.3.1" 1903 + source = "registry+https://github.com/rust-lang/crates.io-index" 1904 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1905 + dependencies = [ 1906 + "ppv-lite86", 1907 + "rand_core 0.6.4", 1908 + ] 1909 + 1910 + [[package]] 1911 + name = "rand_chacha" 1799 1912 version = "0.9.0" 1800 1913 source = "registry+https://github.com/rust-lang/crates.io-index" 1801 1914 checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" ··· 1907 2020 ] 1908 2021 1909 2022 [[package]] 2023 + name = "resolv-conf" 2024 + version = "0.7.6" 2025 + source = "registry+https://github.com/rust-lang/crates.io-index" 2026 + checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" 2027 + 2028 + [[package]] 1910 2029 name = "rfc6979" 1911 2030 version = "0.4.0" 1912 2031 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2298 2417 2299 2418 [[package]] 2300 2419 name = "socket2" 2420 + version = "0.5.10" 2421 + source = "registry+https://github.com/rust-lang/crates.io-index" 2422 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 2423 + dependencies = [ 2424 + "libc", 2425 + "windows-sys 0.52.0", 2426 + ] 2427 + 2428 + [[package]] 2429 + name = "socket2" 2301 2430 version = "0.6.2" 2302 2431 source = "registry+https://github.com/rust-lang/crates.io-index" 2303 2432 checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" ··· 2476 2605 "parking_lot", 2477 2606 "pin-project-lite", 2478 2607 "signal-hook-registry", 2479 - "socket2", 2608 + "socket2 0.6.2", 2480 2609 "tokio-macros", 2481 2610 "windows-sys 0.61.2", 2482 2611 ] ··· 2600 2729 "hyper-util", 2601 2730 "percent-encoding", 2602 2731 "pin-project", 2603 - "socket2", 2732 + "socket2 0.6.2", 2604 2733 "sync_wrapper", 2605 2734 "tokio", 2606 2735 "tokio-stream", ··· 2725 2854 "http", 2726 2855 "httparse", 2727 2856 "log", 2728 - "rand", 2857 + "rand 0.9.2", 2729 2858 "rustls", 2730 2859 "rustls-pki-types", 2731 2860 "sha1", ··· 2938 3067 ] 2939 3068 2940 3069 [[package]] 3070 + name = "widestring" 3071 + version = "1.2.1" 3072 + source = "registry+https://github.com/rust-lang/crates.io-index" 3073 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 3074 + 3075 + [[package]] 2941 3076 name = "winapi" 2942 3077 version = "0.3.9" 2943 3078 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3038 3173 3039 3174 [[package]] 3040 3175 name = "windows-sys" 3176 + version = "0.48.0" 3177 + source = "registry+https://github.com/rust-lang/crates.io-index" 3178 + checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 3179 + dependencies = [ 3180 + "windows-targets 0.48.5", 3181 + ] 3182 + 3183 + [[package]] 3184 + name = "windows-sys" 3041 3185 version = "0.52.0" 3042 3186 source = "registry+https://github.com/rust-lang/crates.io-index" 3043 3187 checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" ··· 3080 3224 3081 3225 [[package]] 3082 3226 name = "windows-targets" 3227 + version = "0.48.5" 3228 + source = "registry+https://github.com/rust-lang/crates.io-index" 3229 + checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 3230 + dependencies = [ 3231 + "windows_aarch64_gnullvm 0.48.5", 3232 + "windows_aarch64_msvc 0.48.5", 3233 + "windows_i686_gnu 0.48.5", 3234 + "windows_i686_msvc 0.48.5", 3235 + "windows_x86_64_gnu 0.48.5", 3236 + "windows_x86_64_gnullvm 0.48.5", 3237 + "windows_x86_64_msvc 0.48.5", 3238 + ] 3239 + 3240 + [[package]] 3241 + name = "windows-targets" 3083 3242 version = "0.52.6" 3084 3243 source = "registry+https://github.com/rust-lang/crates.io-index" 3085 3244 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" ··· 3119 3278 3120 3279 [[package]] 3121 3280 name = "windows_aarch64_gnullvm" 3281 + version = "0.48.5" 3282 + source = "registry+https://github.com/rust-lang/crates.io-index" 3283 + checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 3284 + 3285 + [[package]] 3286 + name = "windows_aarch64_gnullvm" 3122 3287 version = "0.52.6" 3123 3288 source = "registry+https://github.com/rust-lang/crates.io-index" 3124 3289 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" ··· 3137 3302 3138 3303 [[package]] 3139 3304 name = "windows_aarch64_msvc" 3305 + version = "0.48.5" 3306 + source = "registry+https://github.com/rust-lang/crates.io-index" 3307 + checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 3308 + 3309 + [[package]] 3310 + name = "windows_aarch64_msvc" 3140 3311 version = "0.52.6" 3141 3312 source = "registry+https://github.com/rust-lang/crates.io-index" 3142 3313 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" ··· 3152 3323 version = "0.42.2" 3153 3324 source = "registry+https://github.com/rust-lang/crates.io-index" 3154 3325 checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 3326 + 3327 + [[package]] 3328 + name = "windows_i686_gnu" 3329 + version = "0.48.5" 3330 + source = "registry+https://github.com/rust-lang/crates.io-index" 3331 + checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 3155 3332 3156 3333 [[package]] 3157 3334 name = "windows_i686_gnu" ··· 3182 3359 version = "0.42.2" 3183 3360 source = "registry+https://github.com/rust-lang/crates.io-index" 3184 3361 checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 3362 + 3363 + [[package]] 3364 + name = "windows_i686_msvc" 3365 + version = "0.48.5" 3366 + source = "registry+https://github.com/rust-lang/crates.io-index" 3367 + checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 3185 3368 3186 3369 [[package]] 3187 3370 name = "windows_i686_msvc" ··· 3203 3386 3204 3387 [[package]] 3205 3388 name = "windows_x86_64_gnu" 3389 + version = "0.48.5" 3390 + source = "registry+https://github.com/rust-lang/crates.io-index" 3391 + checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 3392 + 3393 + [[package]] 3394 + name = "windows_x86_64_gnu" 3206 3395 version = "0.52.6" 3207 3396 source = "registry+https://github.com/rust-lang/crates.io-index" 3208 3397 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" ··· 3221 3410 3222 3411 [[package]] 3223 3412 name = "windows_x86_64_gnullvm" 3413 + version = "0.48.5" 3414 + source = "registry+https://github.com/rust-lang/crates.io-index" 3415 + checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 3416 + 3417 + [[package]] 3418 + name = "windows_x86_64_gnullvm" 3224 3419 version = "0.52.6" 3225 3420 source = "registry+https://github.com/rust-lang/crates.io-index" 3226 3421 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" ··· 3239 3434 3240 3435 [[package]] 3241 3436 name = "windows_x86_64_msvc" 3437 + version = "0.48.5" 3438 + source = "registry+https://github.com/rust-lang/crates.io-index" 3439 + checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 3440 + 3441 + [[package]] 3442 + name = "windows_x86_64_msvc" 3242 3443 version = "0.52.6" 3243 3444 source = "registry+https://github.com/rust-lang/crates.io-index" 3244 3445 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" ··· 3254 3455 version = "0.7.14" 3255 3456 source = "registry+https://github.com/rust-lang/crates.io-index" 3256 3457 checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" 3458 + 3459 + [[package]] 3460 + name = "winreg" 3461 + version = "0.50.0" 3462 + source = "registry+https://github.com/rust-lang/crates.io-index" 3463 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 3464 + dependencies = [ 3465 + "cfg-if", 3466 + "windows-sys 0.48.0", 3467 + ] 3257 3468 3258 3469 [[package]] 3259 3470 name = "wit-bindgen"
+1 -1
crates/opake-appview/Cargo.toml
··· 10 10 path = "src/main.rs" 11 11 12 12 [dependencies] 13 - opake-core = { path = "../opake-core" } 13 + opake-core = { path = "../opake-core", features = ["native"] } 14 14 axum = "0.8" 15 15 base64.workspace = true 16 16 chrono.workspace = true
+78
crates/opake-appview/src/api/resolve.rs
··· 1 + // Handle resolution endpoint — server-side DNS TXT + core resolution. 2 + // 3 + // The browser can't do DNS lookups, so this endpoint does it on behalf of 4 + // authenticated web clients. Resolution order: 5 + // 1. DNS TXT `_atproto.{handle}` (via opake-core dns feature) 6 + // 2. .well-known/atproto-did → DID doc (via opake-core) 7 + // 3. Bluesky public API resolveHandle → DID doc (via opake-core) 8 + 9 + use std::sync::Arc; 10 + 11 + use axum::extract::{Query, State}; 12 + use axum::http::StatusCode; 13 + use axum::response::{IntoResponse, Json}; 14 + use serde::{Deserialize, Serialize}; 15 + 16 + use crate::state::AppState; 17 + 18 + #[derive(Debug, Deserialize)] 19 + pub struct ResolveParams { 20 + pub handle: String, 21 + } 22 + 23 + #[derive(Debug, Serialize)] 24 + #[serde(rename_all = "camelCase")] 25 + pub struct ResolveResponse { 26 + pub did: String, 27 + pub pds_url: String, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub handle: Option<String>, 30 + } 31 + 32 + pub async fn handle_resolve( 33 + State(_state): State<Arc<AppState>>, 34 + Query(params): Query<ResolveParams>, 35 + ) -> impl IntoResponse { 36 + use crate::api::types::ErrorResponse; 37 + 38 + if params.handle.is_empty() { 39 + return ( 40 + StatusCode::BAD_REQUEST, 41 + Json( 42 + serde_json::to_value(ErrorResponse { 43 + error: "missing required parameter: handle".into(), 44 + }) 45 + .expect("ErrorResponse serializes"), 46 + ), 47 + ) 48 + .into_response(); 49 + } 50 + 51 + // Try DNS first (fastest), then fall through to core's resolution chain 52 + // which handles .well-known and bsky public API fallback. 53 + let transport = opake_core::client::ReqwestTransport::new(); 54 + match opake_core::resolve::resolve_pds_for_login_with_dns(&transport, &params.handle).await { 55 + Ok((did, pds_url, handle)) => ( 56 + StatusCode::OK, 57 + Json( 58 + serde_json::to_value(ResolveResponse { 59 + did, 60 + pds_url, 61 + handle, 62 + }) 63 + .expect("ResolveResponse serializes"), 64 + ), 65 + ) 66 + .into_response(), 67 + Err(e) => ( 68 + StatusCode::NOT_FOUND, 69 + Json( 70 + serde_json::to_value(ErrorResponse { 71 + error: format!("could not resolve handle: {e}"), 72 + }) 73 + .expect("ErrorResponse serializes"), 74 + ), 75 + ) 76 + .into_response(), 77 + } 78 + }
+1 -1
crates/opake-cli/Cargo.toml
··· 10 10 path = "src/main.rs" 11 11 12 12 [dependencies] 13 - opake-core = { path = "../opake-core" } 13 + opake-core = { path = "../opake-core", features = ["native"] } 14 14 anyhow.workspace = true 15 15 base64.workspace = true 16 16 chrono.workspace = true
+1 -1
crates/opake-cli/src/commands/download.rs
··· 12 12 use crate::identity; 13 13 use crate::keyring_store; 14 14 use crate::session::{self, CommandContext}; 15 - use crate::transport::ReqwestTransport; 15 + use opake_core::client::ReqwestTransport; 16 16 17 17 #[derive(Args)] 18 18 /// Download and decrypt a file
+1 -1
crates/opake-cli/src/commands/inbox.rs
··· 5 5 use crate::commands::Execute; 6 6 use crate::identity; 7 7 use crate::session::CommandContext; 8 - use crate::transport::ReqwestTransport; 8 + use opake_core::client::ReqwestTransport; 9 9 10 10 #[derive(Args)] 11 11 /// List grants shared with you (via appview)
+1 -1
crates/opake-cli/src/commands/keyring.rs
··· 11 11 use crate::identity; 12 12 use crate::keyring_store; 13 13 use crate::session::{self, CommandContext}; 14 - use crate::transport::ReqwestTransport; 14 + use opake_core::client::ReqwestTransport; 15 15 16 16 #[derive(Args)] 17 17 /// Manage keyrings for group-based access control
+12 -11
crates/opake-cli/src/commands/login.rs
··· 1 + use crate::config::{AccountConfig, FileStorage}; 2 + use crate::identity; 3 + use crate::utils::prefixed_get_env; 1 4 use anyhow::Result; 2 5 use chrono::Utc; 3 6 use clap::Args; 4 7 use log::debug; 8 + use opake_core::client::ReqwestTransport; 5 9 use opake_core::client::Transport; 6 10 use opake_core::client::{Session, XrpcClient}; 7 11 use opake_core::crypto::OsRng; 8 - use opake_core::resolve::resolve_pds_for_login; 9 - 10 - use crate::config::{AccountConfig, FileStorage}; 11 - use crate::identity; 12 - use crate::transport::ReqwestTransport; 13 - use crate::utils::prefixed_get_env; 14 12 15 13 /// Resolve password from env var or a fallback function (e.g. stdin prompt). 16 14 pub fn resolve_password( ··· 66 64 None => { 67 65 println!("Resolving PDS for {}...", self.identifier); 68 66 let transport = ReqwestTransport::new(); 69 - let (did, pds, handle) = resolve_pds_for_login(&transport, &self.identifier) 70 - .await 71 - .map_err(|e| { 72 - anyhow::anyhow!("failed to resolve PDS for '{}': {e}", self.identifier) 73 - })?; 67 + let (did, pds, handle) = opake_core::resolve::resolve_pds_for_login_with_dns( 68 + &transport, 69 + &self.identifier, 70 + ) 71 + .await 72 + .map_err(|e| { 73 + anyhow::anyhow!("failed to resolve PDS for '{}': {e}", self.identifier) 74 + })?; 74 75 debug!("resolved: did={did}, pds={pds}, handle={handle:?}"); 75 76 println!("Found PDS: {pds}"); 76 77 (pds, did, handle)
+1 -1
crates/opake-cli/src/commands/resolve.rs
··· 6 6 7 7 use crate::commands::Execute; 8 8 use crate::session::CommandContext; 9 - use crate::transport::ReqwestTransport; 9 + use opake_core::client::ReqwestTransport; 10 10 11 11 #[derive(Args)] 12 12 /// Resolve a user's DID and encryption public key
+1 -1
crates/opake-cli/src/commands/share.rs
··· 10 10 use crate::commands::Execute; 11 11 use crate::identity; 12 12 use crate::session::{self, CommandContext}; 13 - use crate::transport::ReqwestTransport; 13 + use opake_core::client::ReqwestTransport; 14 14 15 15 #[derive(Args)] 16 16 /// Share a document with another user
-1
crates/opake-cli/src/main.rs
··· 4 4 mod keyring_store; 5 5 mod oauth; 6 6 mod session; 7 - mod transport; 8 7 pub mod utils; 9 8 10 9 use clap::{Parser, Subcommand};
+1 -1
crates/opake-cli/src/oauth.rs
··· 19 19 20 20 use crate::commands::login::ensure_identity_and_publish; 21 21 use crate::config::{AccountConfig, FileStorage}; 22 - use crate::transport::ReqwestTransport; 22 + use opake_core::client::ReqwestTransport; 23 23 24 24 /// Attempt a full OAuth login flow. Returns `Err` if the PDS doesn't support 25 25 /// OAuth discovery, so the caller can fall back to password auth.
+1 -1
crates/opake-cli/src/session.rs
··· 2 2 use opake_core::client::{Session, XrpcClient}; 3 3 4 4 use crate::config::{resolve_handle_or_did, FileStorage}; 5 - use crate::transport::ReqwestTransport; 5 + use opake_core::client::ReqwestTransport; 6 6 7 7 /// Resolved account context passed to every command. 8 8 #[derive(Debug)]
+8 -2
crates/opake-cli/src/transport.rs crates/opake-core/src/client/reqwest_transport.rs
··· 1 1 // reqwest-based Transport implementation for native (non-WASM) targets. 2 2 3 - use opake_core::client::{HttpMethod, HttpRequest, HttpResponse, RequestBody, Transport}; 4 - use opake_core::error::Error; 3 + use crate::client::{HttpMethod, HttpRequest, HttpResponse, RequestBody, Transport}; 4 + use crate::error::Error; 5 5 6 6 pub struct ReqwestTransport { 7 7 http: reqwest::Client, ··· 12 12 Self { 13 13 http: reqwest::Client::new(), 14 14 } 15 + } 16 + } 17 + 18 + impl Default for ReqwestTransport { 19 + fn default() -> Self { 20 + Self::new() 15 21 } 16 22 } 17 23
+6
crates/opake-core/Cargo.toml
··· 7 7 8 8 [features] 9 9 test-utils = [] 10 + reqwest-transport = ["dep:reqwest"] 11 + dns = ["dep:hickory-resolver"] 12 + native = ["reqwest-transport", "dns"] 10 13 11 14 [dependencies] 12 15 opake-derive = { path = "../opake-derive" } ··· 16 19 serde.workspace = true 17 20 serde_json.workspace = true 18 21 thiserror.workspace = true 22 + 23 + reqwest = { workspace = true, optional = true } 24 + hickory-resolver = { version = "0.24", optional = true } 19 25 20 26 aes-gcm = "0.10" 21 27 p256 = { version = "0.13", features = ["ecdsa", "jwk"] }
+32
crates/opake-core/src/client/did.rs
··· 35 35 // Unauthenticated free functions 36 36 // --------------------------------------------------------------------------- 37 37 38 + /// Resolve a handle to a DID via `GET https://{handle}/.well-known/atproto-did`. 39 + /// Unauthenticated — direct HTTP to the handle's domain. Response is plain text. 40 + pub async fn resolve_handle_wellknown( 41 + transport: &impl Transport, 42 + handle: &str, 43 + ) -> Result<String, Error> { 44 + debug!("resolving handle {} via .well-known/atproto-did", handle); 45 + 46 + let response = transport 47 + .send(HttpRequest { 48 + method: HttpMethod::Get, 49 + url: format!("https://{handle}/.well-known/atproto-did"), 50 + headers: vec![], 51 + body: None, 52 + }) 53 + .await?; 54 + 55 + check_response(&response)?; 56 + 57 + let did = String::from_utf8(response.body) 58 + .map_err(|e| Error::InvalidRecord(format!("invalid UTF-8 in .well-known response: {e}")))?; 59 + let did = did.trim().to_string(); 60 + 61 + if !did.starts_with("did:") { 62 + return Err(Error::InvalidRecord(format!( 63 + ".well-known/atproto-did response is not a DID: {did}" 64 + ))); 65 + } 66 + 67 + Ok(did) 68 + } 69 + 38 70 /// Resolve a handle to a DID via `com.atproto.identity.resolveHandle`. 39 71 /// Unauthenticated — can be called against any PDS. 40 72 pub async fn resolve_handle(
+58
crates/opake-core/src/client/did_tests.rs
··· 13 13 response(200, body) 14 14 } 15 15 16 + // -- resolve_handle_wellknown -- 17 + 18 + #[tokio::test] 19 + async fn resolve_wellknown_happy_path() { 20 + let mock = MockTransport::new(); 21 + mock.enqueue(HttpResponse { 22 + status: 200, 23 + headers: vec![], 24 + body: b"did:plc:abc123".to_vec(), 25 + }); 26 + 27 + let did = resolve_handle_wellknown(&mock, "alice.test").await.unwrap(); 28 + assert_eq!(did, "did:plc:abc123"); 29 + 30 + let reqs = mock.requests(); 31 + assert_eq!(reqs.len(), 1); 32 + assert_eq!(reqs[0].url, "https://alice.test/.well-known/atproto-did"); 33 + } 34 + 35 + #[tokio::test] 36 + async fn resolve_wellknown_trims_whitespace() { 37 + let mock = MockTransport::new(); 38 + mock.enqueue(HttpResponse { 39 + status: 200, 40 + headers: vec![], 41 + body: b" did:plc:abc123\n".to_vec(), 42 + }); 43 + 44 + let did = resolve_handle_wellknown(&mock, "alice.test").await.unwrap(); 45 + assert_eq!(did, "did:plc:abc123"); 46 + } 47 + 48 + #[tokio::test] 49 + async fn resolve_wellknown_rejects_non_did() { 50 + let mock = MockTransport::new(); 51 + mock.enqueue(HttpResponse { 52 + status: 200, 53 + headers: vec![], 54 + body: b"not-a-did".to_vec(), 55 + }); 56 + 57 + let err = resolve_handle_wellknown(&mock, "alice.test") 58 + .await 59 + .unwrap_err(); 60 + assert!(err.to_string().contains("not a DID")); 61 + } 62 + 63 + #[tokio::test] 64 + async fn resolve_wellknown_404() { 65 + let mock = MockTransport::new(); 66 + mock.enqueue(response(404, "Not Found")); 67 + 68 + let err = resolve_handle_wellknown(&mock, "noserver.test") 69 + .await 70 + .unwrap_err(); 71 + assert!(matches!(err, Error::NotFound(_))); 72 + } 73 + 16 74 // -- resolve_handle -- 17 75 18 76 #[tokio::test]
+30
crates/opake-core/src/client/dns.rs
··· 1 + // DNS TXT handle resolution for the AT Protocol. 2 + // 3 + // Queries `_atproto.{handle}` for a TXT record containing `did=did:...`. 4 + // Returns None on any failure — callers fall back to HTTP-based resolution. 5 + 6 + use hickory_resolver::TokioAsyncResolver; 7 + use log::debug; 8 + 9 + /// Resolve a handle to a DID via DNS TXT record at `_atproto.{handle}`. 10 + /// Returns `None` on any failure (timeout, NXDOMAIN, parse error). 11 + pub async fn resolve_handle_dns(handle: &str) -> Option<String> { 12 + let name = format!("_atproto.{handle}"); 13 + debug!("DNS TXT lookup: {name}"); 14 + 15 + let resolver = TokioAsyncResolver::tokio_from_system_conf().ok()?; 16 + let response = resolver.txt_lookup(&name).await.ok()?; 17 + 18 + for record in response.iter() { 19 + let txt = record.to_string(); 20 + if let Some(did) = txt.strip_prefix("did=") { 21 + if did.starts_with("did:") { 22 + debug!("DNS TXT resolved {handle} → {did}"); 23 + return Some(did.to_string()); 24 + } 25 + } 26 + } 27 + 28 + debug!("no valid did= TXT record found for {name}"); 29 + None 30 + }
+8
crates/opake-core/src/client/mod.rs
··· 2 2 mod appview_auth; 3 3 mod appview_types; 4 4 mod did; 5 + #[cfg(feature = "dns")] 6 + mod dns; 5 7 pub mod dpop; 6 8 mod list; 7 9 pub mod oauth_discovery; 8 10 pub mod oauth_token; 11 + #[cfg(feature = "reqwest-transport")] 12 + mod reqwest_transport; 9 13 mod transport; 10 14 mod xrpc; 11 15 ··· 13 17 pub use appview_auth::*; 14 18 pub use appview_types::*; 15 19 pub use did::*; 20 + #[cfg(feature = "dns")] 21 + pub use dns::resolve_handle_dns; 16 22 pub use list::*; 23 + #[cfg(feature = "reqwest-transport")] 24 + pub use reqwest_transport::ReqwestTransport; 17 25 pub use transport::*; 18 26 pub use xrpc::*;
+11
crates/opake-core/src/client/xrpc/mod.rs
··· 181 181 session_refreshed: bool, 182 182 } 183 183 184 + #[cfg(feature = "reqwest-transport")] 185 + impl XrpcClient<super::ReqwestTransport> { 186 + pub fn reqwest(base_url: String) -> Self { 187 + Self::new(super::ReqwestTransport::new(), base_url) 188 + } 189 + 190 + pub fn reqwest_with_session(base_url: String, session: Session) -> Self { 191 + Self::with_session(super::ReqwestTransport::new(), base_url, session) 192 + } 193 + } 194 + 184 195 impl<T: Transport> XrpcClient<T> { 185 196 pub fn new(transport: T, base_url: String) -> Self { 186 197 Self {
+136 -20
crates/opake-core/src/resolve.rs
··· 7 7 use log::debug; 8 8 9 9 use crate::client::{ 10 - get_record_public, pds_from_did_document, resolve_did_document, resolve_handle, Transport, 11 - XrpcClient, 10 + get_record_public, pds_from_did_document, resolve_did_document, resolve_handle, 11 + resolve_handle_wellknown, Transport, XrpcClient, 12 12 }; 13 13 14 14 /// Public Bluesky API — used for handle resolution when no PDS is known yet. ··· 47 47 debug!("input is already a DID: {}", handle_or_did); 48 48 handle_or_did.to_string() 49 49 } else { 50 - debug!("resolving handle {} via public API", handle_or_did); 51 - resolve_handle(transport, BSKY_PUBLIC_API, handle_or_did).await? 50 + match resolve_handle_wellknown(transport, handle_or_did).await { 51 + Ok(did) => { 52 + debug!("resolved via .well-known: {}", did); 53 + did 54 + } 55 + Err(_) => { 56 + debug!(".well-known failed, falling back to public API"); 57 + resolve_handle(transport, BSKY_PUBLIC_API, handle_or_did).await? 58 + } 59 + } 52 60 }; 53 61 54 62 debug!("fetching DID document for {}", did); ··· 65 73 Ok((did, pds_url, handle)) 66 74 } 67 75 76 + /// Like `resolve_pds_for_login`, but tries DNS TXT resolution first. 77 + /// 78 + /// DNS is the fastest path — a single `_atproto.{handle}` TXT lookup that 79 + /// skips the `.well-known` and `resolveHandle` HTTP round-trips entirely. 80 + /// Falls through to HTTP-based resolution on any DNS failure. 81 + #[cfg(feature = "dns")] 82 + pub async fn resolve_pds_for_login_with_dns( 83 + transport: &impl Transport, 84 + handle_or_did: &str, 85 + ) -> Result<(String, String, Option<String>), Error> { 86 + let identifier = if !handle_or_did.starts_with("did:") { 87 + crate::client::resolve_handle_dns(handle_or_did) 88 + .await 89 + .unwrap_or_else(|| handle_or_did.to_string()) 90 + } else { 91 + handle_or_did.to_string() 92 + }; 93 + resolve_pds_for_login(transport, &identifier).await 94 + } 95 + 68 96 /// Full resolution: input → DID → PDS → public key. 69 97 /// 70 98 /// If `input` starts with `did:`, it's used directly. Otherwise it's treated ··· 79 107 debug!("input is already a DID: {}", input); 80 108 input.to_string() 81 109 } else { 82 - debug!("resolving handle: {}", input); 83 - resolve_handle(transport, caller_pds_url, input).await? 110 + match resolve_handle_wellknown(transport, input).await { 111 + Ok(did) => { 112 + debug!("resolved via .well-known: {}", did); 113 + did 114 + } 115 + Err(_) => { 116 + debug!(".well-known failed, falling back to caller PDS"); 117 + resolve_handle(transport, caller_pds_url, input).await? 118 + } 119 + } 84 120 }; 85 121 86 122 // Step 2: Fetch DID document ··· 204 240 entry.to_string() 205 241 } 206 242 243 + fn wellknown_404() -> HttpResponse { 244 + HttpResponse { 245 + status: 404, 246 + headers: vec![], 247 + body: b"Not Found".to_vec(), 248 + } 249 + } 250 + 207 251 #[tokio::test] 208 - async fn resolve_from_handle() { 252 + async fn resolve_from_handle_via_wellknown() { 209 253 let mock = MockTransport::new(); 210 254 let pubkey = [42u8; 32]; 211 255 212 - // 1. resolveHandle → DID 213 - mock.enqueue(success(r#"{"did":"did:plc:target"}"#)); 256 + // 1. .well-known/atproto-did → DID (skips resolveHandle) 257 + mock.enqueue(HttpResponse { 258 + status: 200, 259 + headers: vec![], 260 + body: b"did:plc:target".to_vec(), 261 + }); 214 262 // 2. DID document 215 263 mock.enqueue(success(&did_document_json( 216 264 "did:plc:target", ··· 228 276 assert_eq!(result.handle.as_deref(), Some("alice.test")); 229 277 assert_eq!(result.pds_url, "https://pds.alice.example.com"); 230 278 assert_eq!(result.public_key, pubkey); 231 - assert_eq!(result.algo, "x25519"); 232 279 233 280 let reqs = mock.requests(); 234 281 assert_eq!(reqs.len(), 3); 235 - assert!(reqs[0].url.contains("resolveHandle")); 236 - assert!(reqs[1].url.contains("plc.directory")); 237 - assert!(reqs[2].url.contains("pds.alice.example.com")); 282 + assert!(reqs[0].url.contains(".well-known/atproto-did")); 283 + } 284 + 285 + #[tokio::test] 286 + async fn resolve_from_handle_wellknown_fallback() { 287 + let mock = MockTransport::new(); 288 + let pubkey = [42u8; 32]; 289 + 290 + // 1. .well-known → 404 291 + mock.enqueue(wellknown_404()); 292 + // 2. resolveHandle → DID 293 + mock.enqueue(success(r#"{"did":"did:plc:target"}"#)); 294 + // 3. DID document 295 + mock.enqueue(success(&did_document_json( 296 + "did:plc:target", 297 + "alice.test", 298 + "https://pds.alice.example.com", 299 + ))); 300 + // 4. Public key record 301 + mock.enqueue(success(&public_key_record_json(&pubkey))); 302 + 303 + let result = resolve_identity(&mock, "https://pds.caller", "alice.test") 304 + .await 305 + .unwrap(); 306 + 307 + assert_eq!(result.did, "did:plc:target"); 308 + assert_eq!(result.handle.as_deref(), Some("alice.test")); 309 + assert_eq!(result.pds_url, "https://pds.alice.example.com"); 310 + assert_eq!(result.public_key, pubkey); 311 + assert_eq!(result.algo, "x25519"); 312 + 313 + let reqs = mock.requests(); 314 + assert_eq!(reqs.len(), 4); 315 + assert!(reqs[0].url.contains(".well-known/atproto-did")); 316 + assert!(reqs[1].url.contains("resolveHandle")); 317 + assert!(reqs[2].url.contains("plc.directory")); 318 + assert!(reqs[3].url.contains("pds.alice.example.com")); 238 319 } 239 320 240 321 #[tokio::test] ··· 342 423 } 343 424 344 425 #[tokio::test] 345 - async fn login_resolve_from_handle() { 426 + async fn login_resolve_via_wellknown() { 346 427 let mock = MockTransport::new(); 347 428 348 - // 1. resolveHandle via public API → DID 349 - mock.enqueue(success(r#"{"did":"did:plc:alice"}"#)); 429 + // 1. .well-known → DID 430 + mock.enqueue(HttpResponse { 431 + status: 200, 432 + headers: vec![], 433 + body: b"did:plc:alice".to_vec(), 434 + }); 350 435 // 2. DID document 351 436 mock.enqueue(success(&did_document_json( 352 437 "did:plc:alice", ··· 364 449 365 450 let reqs = mock.requests(); 366 451 assert_eq!(reqs.len(), 2); 367 - assert!(reqs[0].url.contains("resolveHandle")); 368 - assert!(reqs[0].url.contains("public.api.bsky.app")); 369 - assert!(reqs[1].url.contains("plc.directory")); 452 + assert!(reqs[0].url.contains(".well-known/atproto-did")); 453 + } 454 + 455 + #[tokio::test] 456 + async fn login_resolve_from_handle_wellknown_fallback() { 457 + let mock = MockTransport::new(); 458 + 459 + // 1. .well-known → 404 460 + mock.enqueue(wellknown_404()); 461 + // 2. resolveHandle via public API → DID 462 + mock.enqueue(success(r#"{"did":"did:plc:alice"}"#)); 463 + // 3. DID document 464 + mock.enqueue(success(&did_document_json( 465 + "did:plc:alice", 466 + "alice.bsky.social", 467 + "https://morel.us-east.host.bsky.network", 468 + ))); 469 + 470 + let (did, pds, handle) = resolve_pds_for_login(&mock, "alice.bsky.social") 471 + .await 472 + .unwrap(); 473 + 474 + assert_eq!(did, "did:plc:alice"); 475 + assert_eq!(pds, "https://morel.us-east.host.bsky.network"); 476 + assert_eq!(handle.as_deref(), Some("alice.bsky.social")); 477 + 478 + let reqs = mock.requests(); 479 + assert_eq!(reqs.len(), 3); 480 + assert!(reqs[0].url.contains(".well-known/atproto-did")); 481 + assert!(reqs[1].url.contains("resolveHandle")); 482 + assert!(reqs[1].url.contains("public.api.bsky.app")); 483 + assert!(reqs[2].url.contains("plc.directory")); 370 484 } 371 485 372 486 #[tokio::test] ··· 393 507 async fn login_resolve_handle_not_found() { 394 508 let mock = MockTransport::new(); 395 509 396 - // resolveHandle returns 400 (unknown handle) 510 + // 1. .well-known → 404 511 + mock.enqueue(wellknown_404()); 512 + // 2. resolveHandle returns 400 (unknown handle) 397 513 mock.enqueue(HttpResponse { 398 514 status: 400, 399 515 headers: vec![],