this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add upload command with session and identity persistence

Implement the full upload flow: read file, encrypt with AES-256-GCM,
upload blob to PDS, wrap content key to owner's X25519 pubkey, create
document record. Files >50MB are rejected before upload.

Split config.rs into config/session/identity modules with shared JSON
persistence helpers. Login now saves session to disk and generates an
X25519 keypair on first use. All commands load the authenticated client
from the saved session.

Update reqwest 0.12 → 0.13 (rustls-tls → rustls feature rename).
Add Document::new() constructor to keep schema version in one place.
Re-export OsRng and x25519_dalek types from opake-core so the CLI
doesn't depend on crypto crates directly.

+1215 -65
+4
CHANGELOG.md
··· 12 12 ### Fixed 13 13 14 14 ### Changed 15 + - Update outdated dependencies (reqwest 0.13, toml) (#29) 16 + - Test upload command against real PDS (#28) 17 + - Add file upload with client-side encryption (#5) 18 + - Add local keystore for session and key persistence (#9) 15 19 - Add asymmetric key wrapping (ECDH-ES+A256KW) (#4) 16 20 - Add AES-256-GCM content encryption and decryption (#3) 17 21 - Fix WASM compilation for opake-core by enabling getrandom js feature (#27)
+562 -21
Cargo.lock
··· 56 56 ] 57 57 58 58 [[package]] 59 + name = "android_system_properties" 60 + version = "0.1.5" 61 + source = "registry+https://github.com/rust-lang/crates.io-index" 62 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 63 + dependencies = [ 64 + "libc", 65 + ] 66 + 67 + [[package]] 59 68 name = "anstream" 60 69 version = "0.6.21" 61 70 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 116 125 version = "1.1.2" 117 126 source = "registry+https://github.com/rust-lang/crates.io-index" 118 127 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 128 + 129 + [[package]] 130 + name = "autocfg" 131 + version = "1.5.0" 132 + source = "registry+https://github.com/rust-lang/crates.io-index" 133 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 134 + 135 + [[package]] 136 + name = "aws-lc-rs" 137 + version = "1.16.0" 138 + source = "registry+https://github.com/rust-lang/crates.io-index" 139 + checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" 140 + dependencies = [ 141 + "aws-lc-sys", 142 + "zeroize", 143 + ] 144 + 145 + [[package]] 146 + name = "aws-lc-sys" 147 + version = "0.37.1" 148 + source = "registry+https://github.com/rust-lang/crates.io-index" 149 + checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" 150 + dependencies = [ 151 + "cc", 152 + "cmake", 153 + "dunce", 154 + "fs_extra", 155 + ] 119 156 120 157 [[package]] 121 158 name = "base64" ··· 157 194 checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" 158 195 dependencies = [ 159 196 "find-msvc-tools", 197 + "jobserver", 198 + "libc", 160 199 "shlex", 161 200 ] 201 + 202 + [[package]] 203 + name = "cesu8" 204 + version = "1.1.0" 205 + source = "registry+https://github.com/rust-lang/crates.io-index" 206 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 162 207 163 208 [[package]] 164 209 name = "cfg-if" ··· 173 218 checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 174 219 175 220 [[package]] 221 + name = "chrono" 222 + version = "0.4.44" 223 + source = "registry+https://github.com/rust-lang/crates.io-index" 224 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 225 + dependencies = [ 226 + "iana-time-zone", 227 + "num-traits", 228 + "serde", 229 + "windows-link", 230 + ] 231 + 232 + [[package]] 176 233 name = "cipher" 177 234 version = "0.4.4" 178 235 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 223 280 checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" 224 281 225 282 [[package]] 283 + name = "cmake" 284 + version = "0.1.57" 285 + source = "registry+https://github.com/rust-lang/crates.io-index" 286 + checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" 287 + dependencies = [ 288 + "cc", 289 + ] 290 + 291 + [[package]] 226 292 name = "colorchoice" 227 293 version = "1.0.4" 228 294 source = "registry+https://github.com/rust-lang/crates.io-index" 229 295 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 230 296 231 297 [[package]] 298 + name = "combine" 299 + version = "4.6.7" 300 + source = "registry+https://github.com/rust-lang/crates.io-index" 301 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 302 + dependencies = [ 303 + "bytes", 304 + "memchr", 305 + ] 306 + 307 + [[package]] 308 + name = "core-foundation" 309 + version = "0.10.1" 310 + source = "registry+https://github.com/rust-lang/crates.io-index" 311 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 312 + dependencies = [ 313 + "core-foundation-sys", 314 + "libc", 315 + ] 316 + 317 + [[package]] 318 + name = "core-foundation-sys" 319 + version = "0.8.7" 320 + source = "registry+https://github.com/rust-lang/crates.io-index" 321 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 322 + 323 + [[package]] 232 324 name = "cpufeatures" 233 325 version = "0.2.17" 234 326 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 306 398 ] 307 399 308 400 [[package]] 401 + name = "dunce" 402 + version = "1.0.5" 403 + source = "registry+https://github.com/rust-lang/crates.io-index" 404 + checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 405 + 406 + [[package]] 309 407 name = "env_filter" 310 408 version = "1.0.0" 311 409 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 329 427 ] 330 428 331 429 [[package]] 430 + name = "equivalent" 431 + version = "1.0.2" 432 + source = "registry+https://github.com/rust-lang/crates.io-index" 433 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 434 + 435 + [[package]] 332 436 name = "errno" 333 437 version = "0.3.14" 334 438 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 337 441 "libc", 338 442 "windows-sys 0.61.2", 339 443 ] 444 + 445 + [[package]] 446 + name = "fastrand" 447 + version = "2.3.0" 448 + source = "registry+https://github.com/rust-lang/crates.io-index" 449 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 340 450 341 451 [[package]] 342 452 name = "fiat-crypto" ··· 360 470 ] 361 471 362 472 [[package]] 473 + name = "fs_extra" 474 + version = "1.3.0" 475 + source = "registry+https://github.com/rust-lang/crates.io-index" 476 + checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 477 + 478 + [[package]] 363 479 name = "futures-channel" 364 480 version = "0.3.32" 365 481 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 440 556 ] 441 557 442 558 [[package]] 559 + name = "hashbrown" 560 + version = "0.16.1" 561 + source = "registry+https://github.com/rust-lang/crates.io-index" 562 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 563 + 564 + [[package]] 443 565 name = "heck" 444 566 version = "0.5.0" 445 567 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 537 659 "tokio", 538 660 "tokio-rustls", 539 661 "tower-service", 540 - "webpki-roots", 541 662 ] 542 663 543 664 [[package]] ··· 564 685 ] 565 686 566 687 [[package]] 688 + name = "iana-time-zone" 689 + version = "0.1.65" 690 + source = "registry+https://github.com/rust-lang/crates.io-index" 691 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 692 + dependencies = [ 693 + "android_system_properties", 694 + "core-foundation-sys", 695 + "iana-time-zone-haiku", 696 + "js-sys", 697 + "log", 698 + "wasm-bindgen", 699 + "windows-core", 700 + ] 701 + 702 + [[package]] 703 + name = "iana-time-zone-haiku" 704 + version = "0.1.2" 705 + source = "registry+https://github.com/rust-lang/crates.io-index" 706 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 707 + dependencies = [ 708 + "cc", 709 + ] 710 + 711 + [[package]] 567 712 name = "icu_collections" 568 713 version = "2.1.1" 569 714 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 666 811 ] 667 812 668 813 [[package]] 814 + name = "indexmap" 815 + version = "2.13.0" 816 + source = "registry+https://github.com/rust-lang/crates.io-index" 817 + checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" 818 + dependencies = [ 819 + "equivalent", 820 + "hashbrown", 821 + ] 822 + 823 + [[package]] 669 824 name = "inout" 670 825 version = "0.1.4" 671 826 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 727 882 ] 728 883 729 884 [[package]] 885 + name = "jni" 886 + version = "0.21.1" 887 + source = "registry+https://github.com/rust-lang/crates.io-index" 888 + checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 889 + dependencies = [ 890 + "cesu8", 891 + "cfg-if", 892 + "combine", 893 + "jni-sys", 894 + "log", 895 + "thiserror 1.0.69", 896 + "walkdir", 897 + "windows-sys 0.45.0", 898 + ] 899 + 900 + [[package]] 901 + name = "jni-sys" 902 + version = "0.3.0" 903 + source = "registry+https://github.com/rust-lang/crates.io-index" 904 + checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 905 + 906 + [[package]] 907 + name = "jobserver" 908 + version = "0.1.34" 909 + source = "registry+https://github.com/rust-lang/crates.io-index" 910 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 911 + dependencies = [ 912 + "getrandom 0.3.4", 913 + "libc", 914 + ] 915 + 916 + [[package]] 730 917 name = "js-sys" 731 918 version = "0.3.91" 732 919 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 741 928 version = "0.2.182" 742 929 source = "registry+https://github.com/rust-lang/crates.io-index" 743 930 checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" 931 + 932 + [[package]] 933 + name = "linux-raw-sys" 934 + version = "0.12.1" 935 + source = "registry+https://github.com/rust-lang/crates.io-index" 936 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 744 937 745 938 [[package]] 746 939 name = "litemap" ··· 776 969 checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 777 970 778 971 [[package]] 972 + name = "mime" 973 + version = "0.3.17" 974 + source = "registry+https://github.com/rust-lang/crates.io-index" 975 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 976 + 977 + [[package]] 978 + name = "mime_guess" 979 + version = "2.0.5" 980 + source = "registry+https://github.com/rust-lang/crates.io-index" 981 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 982 + dependencies = [ 983 + "mime", 984 + "unicase", 985 + ] 986 + 987 + [[package]] 779 988 name = "mio" 780 989 version = "1.1.1" 781 990 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 787 996 ] 788 997 789 998 [[package]] 999 + name = "num-traits" 1000 + version = "0.2.19" 1001 + source = "registry+https://github.com/rust-lang/crates.io-index" 1002 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 1003 + dependencies = [ 1004 + "autocfg", 1005 + ] 1006 + 1007 + [[package]] 790 1008 name = "once_cell" 791 1009 version = "1.21.3" 792 1010 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 803 1021 version = "0.1.0" 804 1022 dependencies = [ 805 1023 "anyhow", 1024 + "base64", 1025 + "chrono", 806 1026 "clap", 807 1027 "env_logger", 808 1028 "log", 1029 + "mime_guess", 809 1030 "opake-core", 810 1031 "reqwest", 811 1032 "serde", 812 1033 "serde_json", 1034 + "tempfile", 813 1035 "tokio", 1036 + "toml", 814 1037 ] 815 1038 816 1039 [[package]] ··· 826 1049 "serde", 827 1050 "serde_json", 828 1051 "sha2", 829 - "thiserror", 1052 + "thiserror 2.0.18", 830 1053 "x25519-dalek", 831 1054 ] 832 1055 ··· 835 1058 version = "0.3.1" 836 1059 source = "registry+https://github.com/rust-lang/crates.io-index" 837 1060 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 1061 + 1062 + [[package]] 1063 + name = "openssl-probe" 1064 + version = "0.2.1" 1065 + source = "registry+https://github.com/rust-lang/crates.io-index" 1066 + checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 838 1067 839 1068 [[package]] 840 1069 name = "parking_lot" ··· 945 1174 "rustc-hash", 946 1175 "rustls", 947 1176 "socket2", 948 - "thiserror", 1177 + "thiserror 2.0.18", 949 1178 "tokio", 950 1179 "tracing", 951 1180 "web-time", ··· 957 1186 source = "registry+https://github.com/rust-lang/crates.io-index" 958 1187 checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 959 1188 dependencies = [ 1189 + "aws-lc-rs", 960 1190 "bytes", 961 1191 "getrandom 0.3.4", 962 1192 "lru-slab", ··· 966 1196 "rustls", 967 1197 "rustls-pki-types", 968 1198 "slab", 969 - "thiserror", 1199 + "thiserror 2.0.18", 970 1200 "tinyvec", 971 1201 "tracing", 972 1202 "web-time", ··· 1079 1309 1080 1310 [[package]] 1081 1311 name = "reqwest" 1082 - version = "0.12.28" 1312 + version = "0.13.2" 1083 1313 source = "registry+https://github.com/rust-lang/crates.io-index" 1084 - checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 1314 + checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" 1085 1315 dependencies = [ 1086 1316 "base64", 1087 1317 "bytes", ··· 1099 1329 "quinn", 1100 1330 "rustls", 1101 1331 "rustls-pki-types", 1332 + "rustls-platform-verifier", 1102 1333 "serde", 1103 1334 "serde_json", 1104 - "serde_urlencoded", 1105 1335 "sync_wrapper", 1106 1336 "tokio", 1107 1337 "tokio-rustls", ··· 1112 1342 "wasm-bindgen", 1113 1343 "wasm-bindgen-futures", 1114 1344 "web-sys", 1115 - "webpki-roots", 1116 1345 ] 1117 1346 1118 1347 [[package]] ··· 1145 1374 ] 1146 1375 1147 1376 [[package]] 1377 + name = "rustix" 1378 + version = "1.1.4" 1379 + source = "registry+https://github.com/rust-lang/crates.io-index" 1380 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 1381 + dependencies = [ 1382 + "bitflags", 1383 + "errno", 1384 + "libc", 1385 + "linux-raw-sys", 1386 + "windows-sys 0.61.2", 1387 + ] 1388 + 1389 + [[package]] 1148 1390 name = "rustls" 1149 1391 version = "0.23.37" 1150 1392 source = "registry+https://github.com/rust-lang/crates.io-index" 1151 1393 checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" 1152 1394 dependencies = [ 1395 + "aws-lc-rs", 1153 1396 "once_cell", 1154 - "ring", 1155 1397 "rustls-pki-types", 1156 1398 "rustls-webpki", 1157 1399 "subtle", ··· 1159 1401 ] 1160 1402 1161 1403 [[package]] 1404 + name = "rustls-native-certs" 1405 + version = "0.8.3" 1406 + source = "registry+https://github.com/rust-lang/crates.io-index" 1407 + checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" 1408 + dependencies = [ 1409 + "openssl-probe", 1410 + "rustls-pki-types", 1411 + "schannel", 1412 + "security-framework", 1413 + ] 1414 + 1415 + [[package]] 1162 1416 name = "rustls-pki-types" 1163 1417 version = "1.14.0" 1164 1418 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1169 1423 ] 1170 1424 1171 1425 [[package]] 1426 + name = "rustls-platform-verifier" 1427 + version = "0.6.2" 1428 + source = "registry+https://github.com/rust-lang/crates.io-index" 1429 + checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" 1430 + dependencies = [ 1431 + "core-foundation", 1432 + "core-foundation-sys", 1433 + "jni", 1434 + "log", 1435 + "once_cell", 1436 + "rustls", 1437 + "rustls-native-certs", 1438 + "rustls-platform-verifier-android", 1439 + "rustls-webpki", 1440 + "security-framework", 1441 + "security-framework-sys", 1442 + "webpki-root-certs", 1443 + "windows-sys 0.61.2", 1444 + ] 1445 + 1446 + [[package]] 1447 + name = "rustls-platform-verifier-android" 1448 + version = "0.1.1" 1449 + source = "registry+https://github.com/rust-lang/crates.io-index" 1450 + checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" 1451 + 1452 + [[package]] 1172 1453 name = "rustls-webpki" 1173 1454 version = "0.103.9" 1174 1455 source = "registry+https://github.com/rust-lang/crates.io-index" 1175 1456 checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" 1176 1457 dependencies = [ 1458 + "aws-lc-rs", 1177 1459 "ring", 1178 1460 "rustls-pki-types", 1179 1461 "untrusted", ··· 1186 1468 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 1187 1469 1188 1470 [[package]] 1189 - name = "ryu" 1190 - version = "1.0.23" 1471 + name = "same-file" 1472 + version = "1.0.6" 1473 + source = "registry+https://github.com/rust-lang/crates.io-index" 1474 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1475 + dependencies = [ 1476 + "winapi-util", 1477 + ] 1478 + 1479 + [[package]] 1480 + name = "schannel" 1481 + version = "0.1.28" 1191 1482 source = "registry+https://github.com/rust-lang/crates.io-index" 1192 - checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 1483 + checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 1484 + dependencies = [ 1485 + "windows-sys 0.61.2", 1486 + ] 1193 1487 1194 1488 [[package]] 1195 1489 name = "scopeguard" ··· 1198 1492 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1199 1493 1200 1494 [[package]] 1495 + name = "security-framework" 1496 + version = "3.7.0" 1497 + source = "registry+https://github.com/rust-lang/crates.io-index" 1498 + checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" 1499 + dependencies = [ 1500 + "bitflags", 1501 + "core-foundation", 1502 + "core-foundation-sys", 1503 + "libc", 1504 + "security-framework-sys", 1505 + ] 1506 + 1507 + [[package]] 1508 + name = "security-framework-sys" 1509 + version = "2.17.0" 1510 + source = "registry+https://github.com/rust-lang/crates.io-index" 1511 + checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" 1512 + dependencies = [ 1513 + "core-foundation-sys", 1514 + "libc", 1515 + ] 1516 + 1517 + [[package]] 1201 1518 name = "semver" 1202 1519 version = "1.0.27" 1203 1520 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1247 1564 ] 1248 1565 1249 1566 [[package]] 1250 - name = "serde_urlencoded" 1251 - version = "0.7.1" 1567 + name = "serde_spanned" 1568 + version = "0.6.9" 1252 1569 source = "registry+https://github.com/rust-lang/crates.io-index" 1253 - checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1570 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 1254 1571 dependencies = [ 1255 - "form_urlencoded", 1256 - "itoa", 1257 - "ryu", 1258 1572 "serde", 1259 1573 ] 1260 1574 ··· 1357 1671 ] 1358 1672 1359 1673 [[package]] 1674 + name = "tempfile" 1675 + version = "3.26.0" 1676 + source = "registry+https://github.com/rust-lang/crates.io-index" 1677 + checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" 1678 + dependencies = [ 1679 + "fastrand", 1680 + "getrandom 0.3.4", 1681 + "once_cell", 1682 + "rustix", 1683 + "windows-sys 0.61.2", 1684 + ] 1685 + 1686 + [[package]] 1687 + name = "thiserror" 1688 + version = "1.0.69" 1689 + source = "registry+https://github.com/rust-lang/crates.io-index" 1690 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1691 + dependencies = [ 1692 + "thiserror-impl 1.0.69", 1693 + ] 1694 + 1695 + [[package]] 1360 1696 name = "thiserror" 1361 1697 version = "2.0.18" 1362 1698 source = "registry+https://github.com/rust-lang/crates.io-index" 1363 1699 checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 1364 1700 dependencies = [ 1365 - "thiserror-impl", 1701 + "thiserror-impl 2.0.18", 1702 + ] 1703 + 1704 + [[package]] 1705 + name = "thiserror-impl" 1706 + version = "1.0.69" 1707 + source = "registry+https://github.com/rust-lang/crates.io-index" 1708 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1709 + dependencies = [ 1710 + "proc-macro2", 1711 + "quote", 1712 + "syn", 1366 1713 ] 1367 1714 1368 1715 [[package]] ··· 1440 1787 ] 1441 1788 1442 1789 [[package]] 1790 + name = "toml" 1791 + version = "0.8.23" 1792 + source = "registry+https://github.com/rust-lang/crates.io-index" 1793 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 1794 + dependencies = [ 1795 + "serde", 1796 + "serde_spanned", 1797 + "toml_datetime", 1798 + "toml_edit", 1799 + ] 1800 + 1801 + [[package]] 1802 + name = "toml_datetime" 1803 + version = "0.6.11" 1804 + source = "registry+https://github.com/rust-lang/crates.io-index" 1805 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 1806 + dependencies = [ 1807 + "serde", 1808 + ] 1809 + 1810 + [[package]] 1811 + name = "toml_edit" 1812 + version = "0.22.27" 1813 + source = "registry+https://github.com/rust-lang/crates.io-index" 1814 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 1815 + dependencies = [ 1816 + "indexmap", 1817 + "serde", 1818 + "serde_spanned", 1819 + "toml_datetime", 1820 + "toml_write", 1821 + "winnow", 1822 + ] 1823 + 1824 + [[package]] 1825 + name = "toml_write" 1826 + version = "0.1.2" 1827 + source = "registry+https://github.com/rust-lang/crates.io-index" 1828 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 1829 + 1830 + [[package]] 1443 1831 name = "tower" 1444 1832 version = "0.5.3" 1445 1833 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1516 1904 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 1517 1905 1518 1906 [[package]] 1907 + name = "unicase" 1908 + version = "2.9.0" 1909 + source = "registry+https://github.com/rust-lang/crates.io-index" 1910 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 1911 + 1912 + [[package]] 1519 1913 name = "unicode-ident" 1520 1914 version = "1.0.24" 1521 1915 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1568 1962 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1569 1963 1570 1964 [[package]] 1965 + name = "walkdir" 1966 + version = "2.5.0" 1967 + source = "registry+https://github.com/rust-lang/crates.io-index" 1968 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1969 + dependencies = [ 1970 + "same-file", 1971 + "winapi-util", 1972 + ] 1973 + 1974 + [[package]] 1571 1975 name = "want" 1572 1976 version = "0.3.1" 1573 1977 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1671 2075 ] 1672 2076 1673 2077 [[package]] 1674 - name = "webpki-roots" 2078 + name = "webpki-root-certs" 1675 2079 version = "1.0.6" 1676 2080 source = "registry+https://github.com/rust-lang/crates.io-index" 1677 - checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" 2081 + checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" 1678 2082 dependencies = [ 1679 2083 "rustls-pki-types", 1680 2084 ] 1681 2085 1682 2086 [[package]] 2087 + name = "winapi-util" 2088 + version = "0.1.11" 2089 + source = "registry+https://github.com/rust-lang/crates.io-index" 2090 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 2091 + dependencies = [ 2092 + "windows-sys 0.61.2", 2093 + ] 2094 + 2095 + [[package]] 2096 + name = "windows-core" 2097 + version = "0.62.2" 2098 + source = "registry+https://github.com/rust-lang/crates.io-index" 2099 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 2100 + dependencies = [ 2101 + "windows-implement", 2102 + "windows-interface", 2103 + "windows-link", 2104 + "windows-result", 2105 + "windows-strings", 2106 + ] 2107 + 2108 + [[package]] 2109 + name = "windows-implement" 2110 + version = "0.60.2" 2111 + source = "registry+https://github.com/rust-lang/crates.io-index" 2112 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 2113 + dependencies = [ 2114 + "proc-macro2", 2115 + "quote", 2116 + "syn", 2117 + ] 2118 + 2119 + [[package]] 2120 + name = "windows-interface" 2121 + version = "0.59.3" 2122 + source = "registry+https://github.com/rust-lang/crates.io-index" 2123 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 2124 + dependencies = [ 2125 + "proc-macro2", 2126 + "quote", 2127 + "syn", 2128 + ] 2129 + 2130 + [[package]] 1683 2131 name = "windows-link" 1684 2132 version = "0.2.1" 1685 2133 source = "registry+https://github.com/rust-lang/crates.io-index" 1686 2134 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1687 2135 1688 2136 [[package]] 2137 + name = "windows-result" 2138 + version = "0.4.1" 2139 + source = "registry+https://github.com/rust-lang/crates.io-index" 2140 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 2141 + dependencies = [ 2142 + "windows-link", 2143 + ] 2144 + 2145 + [[package]] 2146 + name = "windows-strings" 2147 + version = "0.5.1" 2148 + source = "registry+https://github.com/rust-lang/crates.io-index" 2149 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 2150 + dependencies = [ 2151 + "windows-link", 2152 + ] 2153 + 2154 + [[package]] 2155 + name = "windows-sys" 2156 + version = "0.45.0" 2157 + source = "registry+https://github.com/rust-lang/crates.io-index" 2158 + checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 2159 + dependencies = [ 2160 + "windows-targets 0.42.2", 2161 + ] 2162 + 2163 + [[package]] 1689 2164 name = "windows-sys" 1690 2165 version = "0.52.0" 1691 2166 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1714 2189 1715 2190 [[package]] 1716 2191 name = "windows-targets" 2192 + version = "0.42.2" 2193 + source = "registry+https://github.com/rust-lang/crates.io-index" 2194 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 2195 + dependencies = [ 2196 + "windows_aarch64_gnullvm 0.42.2", 2197 + "windows_aarch64_msvc 0.42.2", 2198 + "windows_i686_gnu 0.42.2", 2199 + "windows_i686_msvc 0.42.2", 2200 + "windows_x86_64_gnu 0.42.2", 2201 + "windows_x86_64_gnullvm 0.42.2", 2202 + "windows_x86_64_msvc 0.42.2", 2203 + ] 2204 + 2205 + [[package]] 2206 + name = "windows-targets" 1717 2207 version = "0.52.6" 1718 2208 source = "registry+https://github.com/rust-lang/crates.io-index" 1719 2209 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" ··· 1744 2234 "windows_x86_64_gnullvm 0.53.1", 1745 2235 "windows_x86_64_msvc 0.53.1", 1746 2236 ] 2237 + 2238 + [[package]] 2239 + name = "windows_aarch64_gnullvm" 2240 + version = "0.42.2" 2241 + source = "registry+https://github.com/rust-lang/crates.io-index" 2242 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 1747 2243 1748 2244 [[package]] 1749 2245 name = "windows_aarch64_gnullvm" ··· 1759 2255 1760 2256 [[package]] 1761 2257 name = "windows_aarch64_msvc" 2258 + version = "0.42.2" 2259 + source = "registry+https://github.com/rust-lang/crates.io-index" 2260 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 2261 + 2262 + [[package]] 2263 + name = "windows_aarch64_msvc" 1762 2264 version = "0.52.6" 1763 2265 source = "registry+https://github.com/rust-lang/crates.io-index" 1764 2266 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" ··· 1771 2273 1772 2274 [[package]] 1773 2275 name = "windows_i686_gnu" 2276 + version = "0.42.2" 2277 + source = "registry+https://github.com/rust-lang/crates.io-index" 2278 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 2279 + 2280 + [[package]] 2281 + name = "windows_i686_gnu" 1774 2282 version = "0.52.6" 1775 2283 source = "registry+https://github.com/rust-lang/crates.io-index" 1776 2284 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" ··· 1792 2300 version = "0.53.1" 1793 2301 source = "registry+https://github.com/rust-lang/crates.io-index" 1794 2302 checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 2303 + 2304 + [[package]] 2305 + name = "windows_i686_msvc" 2306 + version = "0.42.2" 2307 + source = "registry+https://github.com/rust-lang/crates.io-index" 2308 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 1795 2309 1796 2310 [[package]] 1797 2311 name = "windows_i686_msvc" ··· 1807 2321 1808 2322 [[package]] 1809 2323 name = "windows_x86_64_gnu" 2324 + version = "0.42.2" 2325 + source = "registry+https://github.com/rust-lang/crates.io-index" 2326 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 2327 + 2328 + [[package]] 2329 + name = "windows_x86_64_gnu" 1810 2330 version = "0.52.6" 1811 2331 source = "registry+https://github.com/rust-lang/crates.io-index" 1812 2332 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" ··· 1819 2339 1820 2340 [[package]] 1821 2341 name = "windows_x86_64_gnullvm" 2342 + version = "0.42.2" 2343 + source = "registry+https://github.com/rust-lang/crates.io-index" 2344 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 2345 + 2346 + [[package]] 2347 + name = "windows_x86_64_gnullvm" 1822 2348 version = "0.52.6" 1823 2349 source = "registry+https://github.com/rust-lang/crates.io-index" 1824 2350 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" ··· 1828 2354 version = "0.53.1" 1829 2355 source = "registry+https://github.com/rust-lang/crates.io-index" 1830 2356 checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 2357 + 2358 + [[package]] 2359 + name = "windows_x86_64_msvc" 2360 + version = "0.42.2" 2361 + source = "registry+https://github.com/rust-lang/crates.io-index" 2362 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 1831 2363 1832 2364 [[package]] 1833 2365 name = "windows_x86_64_msvc" ··· 1840 2372 version = "0.53.1" 1841 2373 source = "registry+https://github.com/rust-lang/crates.io-index" 1842 2374 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 2375 + 2376 + [[package]] 2377 + name = "winnow" 2378 + version = "0.7.14" 2379 + source = "registry+https://github.com/rust-lang/crates.io-index" 2380 + checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" 2381 + dependencies = [ 2382 + "memchr", 2383 + ] 1843 2384 1844 2385 [[package]] 1845 2386 name = "wit-bindgen"
+2
Cargo.toml
··· 7 7 version = "0.1.0" 8 8 9 9 [workspace.dependencies] 10 + base64 = "0.22" 11 + chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } 10 12 log = "0.4" 11 13 serde = { version = "1", features = ["derive"] } 12 14 serde_json = "1"
+8 -1
crates/opake-cli/Cargo.toml
··· 10 10 11 11 [dependencies] 12 12 opake-core = { path = "../opake-core" } 13 + base64.workspace = true 14 + chrono.workspace = true 13 15 clap = { version = "4", features = ["derive"] } 16 + mime_guess = "2" 14 17 tokio = { version = "1", features = ["full"] } 18 + toml = "0.8" 15 19 anyhow = "1" 16 20 env_logger = "0.11" 17 21 log.workspace = true 18 - reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 22 + reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } 19 23 serde.workspace = true 20 24 serde_json.workspace = true 25 + 26 + [dev-dependencies] 27 + tempfile = "3"
+1 -1
crates/opake-cli/src/commands/download.rs
··· 18 18 19 19 impl Execute for DownloadCommand { 20 20 async fn execute(self) -> Result<()> { 21 - let _client = crate::config::load_client()?; 21 + let _client = crate::session::load_client()?; 22 22 anyhow::bail!("download not yet implemented (tracking: chainlink #6)") 23 23 } 24 24 }
+11 -3
crates/opake-cli/src/commands/login.rs
··· 4 4 use opake_core::client::XrpcClient; 5 5 6 6 use crate::commands::Execute; 7 - use crate::config; 7 + use crate::identity; 8 + use crate::session; 8 9 use crate::transport::ReqwestTransport; 9 10 use crate::utils::prefixed_get_env; 10 11 ··· 50 51 })?; 51 52 52 53 let transport = ReqwestTransport::new(); 53 - let mut client = XrpcClient::new(transport, self.pds); 54 + let mut client = XrpcClient::new(transport, self.pds.clone()); 54 55 55 56 let session = client.login(self.identifier.trim(), &password).await?; 56 57 57 - config::save_session(session)?; 58 + session::save_session(session, &self.pds)?; 59 + 60 + let (_, generated) = 61 + identity::ensure_identity(&session.did, &mut opake_core::crypto::OsRng)?; 62 + 63 + if generated { 64 + println!("Generated new encryption keypair"); 65 + } 58 66 59 67 println!("Logged in as {}", session.handle); 60 68
+1 -1
crates/opake-cli/src/commands/ls.rs
··· 13 13 14 14 impl Execute for LsCommand { 15 15 async fn execute(self) -> Result<()> { 16 - let _client = crate::config::load_client()?; 16 + let _client = crate::session::load_client()?; 17 17 anyhow::bail!("ls not yet implemented (tracking: chainlink #7)") 18 18 } 19 19 }
+1 -1
crates/opake-cli/src/commands/rm.rs
··· 12 12 13 13 impl Execute for RmCommand { 14 14 async fn execute(self) -> Result<()> { 15 - let _client = crate::config::load_client()?; 15 + let _client = crate::session::load_client()?; 16 16 anyhow::bail!("rm not yet implemented (tracking: chainlink #8)") 17 17 } 18 18 }
+123 -3
crates/opake-cli/src/commands/upload.rs
··· 1 + use std::fs; 1 2 use std::path::PathBuf; 2 3 3 - use anyhow::Result; 4 + use anyhow::{Context, Result}; 5 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 6 + use chrono::Utc; 4 7 use clap::Args; 8 + use log::debug; 9 + use opake_core::crypto::{self, OsRng}; 10 + use opake_core::records::{AtBytes, DirectEncryption, Document, Encryption, EncryptionEnvelope}; 5 11 6 12 use crate::commands::Execute; 13 + use crate::identity; 14 + use crate::session; 15 + 16 + // 50MB, Bluesky PDS default (I think). We might want to make this dynamic at some point. 17 + const MAX_BLOB_SIZE: u64 = 50 * 1024 * 1024; 18 + const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document"; 7 19 8 20 #[derive(Args)] 9 21 /// Upload and encrypt a file ··· 22 34 23 35 impl Execute for UploadCommand { 24 36 async fn execute(self) -> Result<()> { 25 - let _client = crate::config::load_client()?; 26 - anyhow::bail!("upload not yet implemented (tracking: chainlink #5)") 37 + if self.keyring.is_some() { 38 + anyhow::bail!("--keyring not yet supported (tracking: chainlink #21)"); 39 + } 40 + 41 + let client = session::load_client()?; 42 + let id = identity::load_identity()?; 43 + let owner_pubkey = id.public_key_bytes()?; 44 + 45 + let plaintext = 46 + fs::read(&self.path).context(format!("failed to read {}", self.path.display()))?; 47 + 48 + let file_size = plaintext.len() as u64; 49 + anyhow::ensure!( 50 + file_size <= MAX_BLOB_SIZE, 51 + "file is {} bytes — PDS blob limit is {} bytes (50 MB)", 52 + file_size, 53 + MAX_BLOB_SIZE 54 + ); 55 + 56 + let filename = self 57 + .path 58 + .file_name() 59 + .map(|n| n.to_string_lossy().to_string()) 60 + .unwrap_or_else(|| "unnamed".into()); 61 + 62 + let mime_type = mime_guess::from_path(&self.path) 63 + .first_raw() 64 + .unwrap_or("application/octet-stream"); 65 + 66 + debug!( 67 + "encrypting {} ({} bytes, {})", 68 + filename, file_size, mime_type 69 + ); 70 + 71 + let rng = &mut OsRng; 72 + let content_key = crypto::generate_content_key(rng); 73 + let payload = crypto::encrypt_blob(&content_key, &plaintext, rng)?; 74 + 75 + debug!( 76 + "uploading encrypted blob ({} bytes)", 77 + payload.ciphertext.len() 78 + ); 79 + 80 + let blob_ref = client 81 + .upload_blob(payload.ciphertext, "application/octet-stream") 82 + .await?; 83 + 84 + let wrapped_key = crypto::wrap_key(&content_key, &owner_pubkey, &id.did, rng)?; 85 + 86 + let document = Document { 87 + mime_type: Some(mime_type.into()), 88 + size: Some(file_size), 89 + tags: self.tags, 90 + visibility: Some("private".into()), 91 + ..Document::new( 92 + filename.clone(), 93 + blob_ref, 94 + Encryption::Direct(DirectEncryption { 95 + envelope: EncryptionEnvelope { 96 + algo: "aes-256-gcm".into(), 97 + nonce: AtBytes { 98 + encoded: BASE64.encode(payload.nonce), 99 + }, 100 + keys: vec![wrapped_key], 101 + }, 102 + }), 103 + Utc::now().to_rfc3339(), 104 + ) 105 + }; 106 + 107 + let record_ref = client.create_record(DOCUMENT_COLLECTION, &document).await?; 108 + 109 + println!("{} → {}", filename, record_ref.uri); 110 + Ok(()) 111 + } 112 + } 113 + 114 + #[cfg(test)] 115 + mod tests { 116 + use super::*; 117 + 118 + #[test] 119 + fn rejects_nonexistent_file() { 120 + let rt = tokio::runtime::Runtime::new().unwrap(); 121 + let cmd = UploadCommand { 122 + path: PathBuf::from("/tmp/opake-test-nonexistent-file-abc123"), 123 + keyring: None, 124 + tags: vec![], 125 + }; 126 + let result = rt.block_on(cmd.execute()); 127 + assert!(result.is_err()); 128 + let err = result.unwrap_err().to_string(); 129 + // Fails at either session loading or file reading depending on env 130 + assert!( 131 + err.contains("failed to read") || err.contains("run `opake login` first"), 132 + "unexpected error: {err}" 133 + ); 134 + } 135 + 136 + #[test] 137 + fn mime_detection_works() { 138 + assert_eq!( 139 + mime_guess::from_path("photo.jpg").first_raw(), 140 + Some("image/jpeg") 141 + ); 142 + assert_eq!( 143 + mime_guess::from_path("doc.pdf").first_raw(), 144 + Some("application/pdf") 145 + ); 146 + assert_eq!(mime_guess::from_path("mystery").first_raw(), None); 27 147 } 28 148 }
+108 -32
crates/opake-cli/src/config.rs
··· 1 + use std::fs; 1 2 use std::path::PathBuf; 2 3 3 - use opake_core::client::{Session, XrpcClient}; 4 + use anyhow::Context; 5 + use serde::de::DeserializeOwned; 4 6 use serde::{Deserialize, Serialize}; 5 7 6 - use crate::transport::ReqwestTransport; 7 - 8 8 /// Persistent CLI configuration (PDS URL, preferences). 9 9 #[derive(Debug, Serialize, Deserialize)] 10 10 pub struct Config { 11 11 pub pds_url: String, 12 12 } 13 13 14 - /// Where Opake stores its state on disk. 15 - fn data_dir() -> PathBuf { 14 + /// Where Opake stores its state on disk. Overridable via `OPAKE_DATA_DIR` 15 + /// for testing — production code never sets this. 16 + pub fn data_dir() -> PathBuf { 17 + if let Ok(dir) = std::env::var("OPAKE_DATA_DIR") { 18 + return PathBuf::from(dir); 19 + } 16 20 let home = std::env::var("HOME").expect("HOME not set"); 17 21 PathBuf::from(home).join(".config").join("opake") 18 22 } 19 23 20 - pub fn config_path() -> PathBuf { 21 - data_dir().join("config.toml") 24 + /// Create the data directory if it doesn't exist. 25 + pub fn ensure_data_dir() -> anyhow::Result<()> { 26 + let dir = data_dir(); 27 + if !dir.exists() { 28 + fs::create_dir_all(&dir) 29 + .with_context(|| format!("failed to create data directory: {}", dir.display()))?; 30 + } 31 + Ok(()) 22 32 } 23 33 24 - pub fn session_path() -> PathBuf { 25 - data_dir().join("session.json") 34 + /// Serialize a value to a JSON file in the data directory. 35 + pub fn save_json<T: Serialize>(filename: &str, value: &T) -> anyhow::Result<()> { 36 + ensure_data_dir()?; 37 + let json = serde_json::to_string_pretty(value) 38 + .with_context(|| format!("failed to serialize {filename}"))?; 39 + fs::write(data_dir().join(filename), json) 40 + .with_context(|| format!("failed to write {filename}")) 26 41 } 27 42 28 - /// Restore a saved session and build an authenticated XRPC client. 29 - pub fn load_client() -> anyhow::Result<XrpcClient<ReqwestTransport>> { 30 - todo!( 31 - "read session from {}, reconstruct client", 32 - session_path().display() 33 - ) 43 + /// Deserialize a value from a JSON file in the data directory. 44 + pub fn load_json<T: DeserializeOwned>(filename: &str) -> anyhow::Result<T> { 45 + let path = data_dir().join(filename); 46 + let content = fs::read_to_string(&path) 47 + .with_context(|| format!("no {filename} found: run `opake login` first"))?; 48 + serde_json::from_str(&content).with_context(|| format!("failed to parse {filename}")) 34 49 } 35 50 36 - pub fn save_session(session: &Session) -> anyhow::Result<()> { 37 - todo!("write session to {}", session_path().display()) 51 + pub fn save_config(config: &Config) -> anyhow::Result<()> { 52 + ensure_data_dir()?; 53 + let content = toml::to_string_pretty(config).context("failed to serialize config")?; 54 + fs::write(data_dir().join("config.toml"), content).context("failed to write config.toml") 55 + } 56 + 57 + pub fn load_config() -> anyhow::Result<Config> { 58 + let path = data_dir().join("config.toml"); 59 + let content = fs::read_to_string(&path) 60 + .with_context(|| format!("no config at {}: run `opake login` first", path.display()))?; 61 + toml::from_str(&content).context("failed to parse config.toml") 38 62 } 39 63 40 64 #[cfg(test)] 41 65 mod tests { 42 66 use super::*; 67 + use crate::utils::test_harness::with_test_dir; 43 68 44 - fn fake_session() -> Session { 45 - Session { 46 - did: "did:plc:test123".into(), 47 - handle: "alice.test".into(), 48 - access_jwt: "eyJ.access.token".into(), 49 - refresh_jwt: "eyJ.refresh.token".into(), 50 - } 69 + #[test] 70 + fn save_and_load_config_roundtrip() { 71 + with_test_dir(|_| { 72 + let config = Config { 73 + pds_url: "https://pds.test".into(), 74 + }; 75 + save_config(&config).unwrap(); 76 + 77 + let loaded = load_config().unwrap(); 78 + assert_eq!(loaded.pds_url, "https://pds.test"); 79 + }); 51 80 } 52 81 53 82 #[test] 54 - #[should_panic(expected = "not yet implemented")] 55 - fn test_save_session_writes_to_disk() { 56 - // will pass once #9 replaces the todo!() with real persistence 57 - save_session(&fake_session()).unwrap(); 83 + fn load_config_without_file_errors() { 84 + with_test_dir(|_| { 85 + let result = load_config(); 86 + assert!(result.is_err()); 87 + let err = result.unwrap_err().to_string(); 88 + assert!(err.contains("opake login"), "expected login hint: {err}"); 89 + }); 90 + } 91 + 92 + #[test] 93 + fn ensure_data_dir_creates_directory() { 94 + with_test_dir(|dir| { 95 + let target = dir.path().join("nested"); 96 + std::env::set_var("OPAKE_DATA_DIR", &target); 97 + assert!(!target.exists()); 98 + ensure_data_dir().unwrap(); 99 + assert!(target.exists()); 100 + }); 58 101 } 59 102 60 103 #[test] 61 - #[should_panic(expected = "not yet implemented")] 62 - fn test_load_client_restores_session() { 63 - // will pass once #9 implements session loading 64 - load_client().unwrap(); 104 + fn load_config_rejects_garbage_content() { 105 + with_test_dir(|_| { 106 + ensure_data_dir().unwrap(); 107 + fs::write(data_dir().join("config.toml"), "not valid toml {{{").unwrap(); 108 + let result = load_config(); 109 + assert!(result.is_err()); 110 + }); 111 + } 112 + 113 + #[test] 114 + fn load_config_rejects_valid_toml_wrong_schema() { 115 + with_test_dir(|_| { 116 + ensure_data_dir().unwrap(); 117 + fs::write(data_dir().join("config.toml"), "[section]\nkey = 42\n").unwrap(); 118 + let result = load_config(); 119 + assert!(result.is_err()); 120 + }); 121 + } 122 + 123 + #[test] 124 + fn load_config_rejects_empty_file() { 125 + with_test_dir(|_| { 126 + ensure_data_dir().unwrap(); 127 + fs::write(data_dir().join("config.toml"), "").unwrap(); 128 + let result = load_config(); 129 + assert!(result.is_err()); 130 + }); 131 + } 132 + 133 + #[test] 134 + fn load_config_rejects_binary_noise() { 135 + with_test_dir(|_| { 136 + ensure_data_dir().unwrap(); 137 + fs::write(data_dir().join("config.toml"), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 138 + let result = load_config(); 139 + assert!(result.is_err()); 140 + }); 65 141 } 66 142 }
+225
crates/opake-cli/src/identity.rs
··· 1 + use anyhow::Context; 2 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 3 + use log::info; 4 + use opake_core::crypto::{ 5 + CryptoRng, RngCore, X25519DalekPublicKey, X25519DalekStaticSecret, X25519PrivateKey, 6 + X25519PublicKey, 7 + }; 8 + use serde::{Deserialize, Serialize}; 9 + 10 + use crate::config; 11 + 12 + const FILENAME: &str = "identity.json"; 13 + 14 + /// X25519 encryption keypair, stored as base64 in `identity.json`. 15 + #[derive(Debug, Serialize, Deserialize)] 16 + pub struct Identity { 17 + pub did: String, 18 + pub public_key: String, 19 + pub private_key: String, 20 + } 21 + 22 + impl Identity { 23 + pub fn public_key_bytes(&self) -> anyhow::Result<X25519PublicKey> { 24 + let bytes = BASE64 25 + .decode(&self.public_key) 26 + .context("invalid base64 in identity public_key")?; 27 + let key: X25519PublicKey = bytes.try_into().map_err(|v: Vec<u8>| { 28 + anyhow::anyhow!("public key is {} bytes, expected 32", v.len()) 29 + })?; 30 + Ok(key) 31 + } 32 + 33 + #[allow(dead_code)] // used by download (#6) 34 + pub fn private_key_bytes(&self) -> anyhow::Result<X25519PrivateKey> { 35 + let bytes = BASE64 36 + .decode(&self.private_key) 37 + .context("invalid base64 in identity private_key")?; 38 + let key: X25519PrivateKey = bytes.try_into().map_err(|v: Vec<u8>| { 39 + anyhow::anyhow!("private key is {} bytes, expected 32", v.len()) 40 + })?; 41 + Ok(key) 42 + } 43 + } 44 + 45 + pub fn save_identity(identity: &Identity) -> anyhow::Result<()> { 46 + config::save_json(FILENAME, identity) 47 + } 48 + 49 + pub fn load_identity() -> anyhow::Result<Identity> { 50 + config::load_json(FILENAME) 51 + } 52 + 53 + /// Return the existing identity if it matches `did`, otherwise generate a new 54 + /// X25519 keypair, save it, and return it. The boolean indicates whether a new 55 + /// keypair was generated. 56 + pub fn ensure_identity( 57 + did: &str, 58 + rng: &mut (impl CryptoRng + RngCore), 59 + ) -> anyhow::Result<(Identity, bool)> { 60 + if let Ok(existing) = load_identity() { 61 + if existing.did == did { 62 + return Ok((existing, false)); 63 + } 64 + info!( 65 + "identity DID mismatch (stored {}, logged in as {}) — generating new keypair", 66 + existing.did, did 67 + ); 68 + } 69 + 70 + let private_secret = X25519DalekStaticSecret::random_from_rng(&mut *rng); 71 + let public_key = X25519DalekPublicKey::from(&private_secret); 72 + 73 + let identity = Identity { 74 + did: did.to_string(), 75 + public_key: BASE64.encode(public_key.as_bytes()), 76 + private_key: BASE64.encode(private_secret.to_bytes()), 77 + }; 78 + save_identity(&identity)?; 79 + Ok((identity, true)) 80 + } 81 + 82 + #[cfg(test)] 83 + mod tests { 84 + use super::*; 85 + use crate::utils::test_harness::with_test_dir; 86 + use opake_core::crypto::OsRng; 87 + use std::fs; 88 + 89 + fn file_path() -> std::path::PathBuf { 90 + config::data_dir().join(FILENAME) 91 + } 92 + 93 + #[test] 94 + fn save_and_load_identity_roundtrip() { 95 + with_test_dir(|_| { 96 + let identity = Identity { 97 + did: "did:plc:test".into(), 98 + public_key: BASE64.encode([1u8; 32]), 99 + private_key: BASE64.encode([2u8; 32]), 100 + }; 101 + save_identity(&identity).unwrap(); 102 + 103 + let loaded = load_identity().unwrap(); 104 + assert_eq!(loaded.did, identity.did); 105 + assert_eq!(loaded.public_key, identity.public_key); 106 + assert_eq!(loaded.private_key, identity.private_key); 107 + 108 + assert_eq!(loaded.public_key_bytes().unwrap(), [1u8; 32]); 109 + assert_eq!(loaded.private_key_bytes().unwrap(), [2u8; 32]); 110 + }); 111 + } 112 + 113 + #[test] 114 + fn ensure_identity_generates_when_missing() { 115 + with_test_dir(|_| { 116 + let (identity, generated) = ensure_identity("did:plc:new", &mut OsRng).unwrap(); 117 + assert!(generated); 118 + assert_eq!(identity.did, "did:plc:new"); 119 + assert_eq!(identity.public_key_bytes().unwrap().len(), 32); 120 + assert_eq!(identity.private_key_bytes().unwrap().len(), 32); 121 + }); 122 + } 123 + 124 + #[test] 125 + fn ensure_identity_returns_existing_when_did_matches() { 126 + with_test_dir(|_| { 127 + let (first, generated) = ensure_identity("did:plc:same", &mut OsRng).unwrap(); 128 + assert!(generated); 129 + 130 + let (second, generated) = ensure_identity("did:plc:same", &mut OsRng).unwrap(); 131 + assert!(!generated); 132 + assert_eq!(first.public_key, second.public_key); 133 + assert_eq!(first.private_key, second.private_key); 134 + }); 135 + } 136 + 137 + #[test] 138 + fn ensure_identity_regenerates_on_did_mismatch() { 139 + with_test_dir(|_| { 140 + let (first, _) = ensure_identity("did:plc:alice", &mut OsRng).unwrap(); 141 + let (second, generated) = ensure_identity("did:plc:bob", &mut OsRng).unwrap(); 142 + assert!(generated); 143 + assert_eq!(second.did, "did:plc:bob"); 144 + assert_ne!(first.public_key, second.public_key); 145 + }); 146 + } 147 + 148 + #[test] 149 + fn load_identity_rejects_garbage_json() { 150 + with_test_dir(|_| { 151 + config::ensure_data_dir().unwrap(); 152 + fs::write(file_path(), "not json {{{").unwrap(); 153 + assert!(load_identity().is_err()); 154 + }); 155 + } 156 + 157 + #[test] 158 + fn load_identity_rejects_valid_json_wrong_schema() { 159 + with_test_dir(|_| { 160 + config::ensure_data_dir().unwrap(); 161 + fs::write(file_path(), r#"{"color": "blue"}"#).unwrap(); 162 + assert!(load_identity().is_err()); 163 + }); 164 + } 165 + 166 + #[test] 167 + fn load_identity_rejects_empty_file() { 168 + with_test_dir(|_| { 169 + config::ensure_data_dir().unwrap(); 170 + fs::write(file_path(), "").unwrap(); 171 + assert!(load_identity().is_err()); 172 + }); 173 + } 174 + 175 + #[test] 176 + fn load_identity_rejects_binary_noise() { 177 + with_test_dir(|_| { 178 + config::ensure_data_dir().unwrap(); 179 + fs::write(file_path(), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 180 + assert!(load_identity().is_err()); 181 + }); 182 + } 183 + 184 + #[test] 185 + fn public_key_bytes_rejects_bad_base64() { 186 + let identity = Identity { 187 + did: "did:plc:test".into(), 188 + public_key: "not!valid!base64!!!".into(), 189 + private_key: BASE64.encode([0u8; 32]), 190 + }; 191 + assert!(identity.public_key_bytes().is_err()); 192 + } 193 + 194 + #[test] 195 + fn private_key_bytes_rejects_bad_base64() { 196 + let identity = Identity { 197 + did: "did:plc:test".into(), 198 + public_key: BASE64.encode([0u8; 32]), 199 + private_key: "~~~garbage~~~".into(), 200 + }; 201 + assert!(identity.private_key_bytes().is_err()); 202 + } 203 + 204 + #[test] 205 + fn public_key_bytes_rejects_wrong_length() { 206 + let identity = Identity { 207 + did: "did:plc:test".into(), 208 + public_key: BASE64.encode([0u8; 16]), // 16 bytes, not 32 209 + private_key: BASE64.encode([0u8; 32]), 210 + }; 211 + let err = identity.public_key_bytes().unwrap_err().to_string(); 212 + assert!(err.contains("16 bytes"), "expected length in error: {err}"); 213 + } 214 + 215 + #[test] 216 + fn private_key_bytes_rejects_wrong_length() { 217 + let identity = Identity { 218 + did: "did:plc:test".into(), 219 + public_key: BASE64.encode([0u8; 32]), 220 + private_key: BASE64.encode([0u8; 64]), // 64 bytes, not 32 221 + }; 222 + let err = identity.private_key_bytes().unwrap_err().to_string(); 223 + assert!(err.contains("64 bytes"), "expected length in error: {err}"); 224 + } 225 + }
+2
crates/opake-cli/src/main.rs
··· 1 1 mod commands; 2 2 mod config; 3 + mod identity; 4 + mod session; 3 5 mod transport; 4 6 pub mod utils; 5 7
+122
crates/opake-cli/src/session.rs
··· 1 + use log::info; 2 + use opake_core::client::{Session, XrpcClient}; 3 + 4 + use crate::config::{self, Config}; 5 + use crate::transport::ReqwestTransport; 6 + 7 + const FILENAME: &str = "session.json"; 8 + 9 + /// Save the session and PDS URL to disk after successful login. 10 + pub fn save_session(session: &Session, pds_url: &str) -> anyhow::Result<()> { 11 + config::save_json(FILENAME, session)?; 12 + config::save_config(&Config { 13 + pds_url: pds_url.to_string(), 14 + })?; 15 + info!("session saved"); 16 + Ok(()) 17 + } 18 + 19 + fn load_session() -> anyhow::Result<Session> { 20 + config::load_json(FILENAME) 21 + } 22 + 23 + /// Restore a saved session and build an authenticated XRPC client. 24 + pub fn load_client() -> anyhow::Result<XrpcClient<ReqwestTransport>> { 25 + let config = config::load_config()?; 26 + let session = load_session()?; 27 + let transport = ReqwestTransport::new(); 28 + Ok(XrpcClient::with_session(transport, config.pds_url, session)) 29 + } 30 + 31 + #[cfg(test)] 32 + mod tests { 33 + use super::*; 34 + use crate::utils::test_harness::with_test_dir; 35 + use std::fs; 36 + 37 + fn fake_session() -> Session { 38 + Session { 39 + did: "did:plc:test123".into(), 40 + handle: "alice.test".into(), 41 + access_jwt: "eyJ.access.token".into(), 42 + refresh_jwt: "eyJ.refresh.token".into(), 43 + } 44 + } 45 + 46 + fn file_path() -> std::path::PathBuf { 47 + config::data_dir().join(FILENAME) 48 + } 49 + 50 + #[test] 51 + fn save_and_load_session_roundtrip() { 52 + with_test_dir(|_| { 53 + let session = fake_session(); 54 + save_session(&session, "https://pds.test").unwrap(); 55 + 56 + let config = config::load_config().unwrap(); 57 + assert_eq!(config.pds_url, "https://pds.test"); 58 + 59 + let loaded = load_session().unwrap(); 60 + assert_eq!(loaded.did, session.did); 61 + assert_eq!(loaded.handle, session.handle); 62 + assert_eq!(loaded.access_jwt, session.access_jwt); 63 + assert_eq!(loaded.refresh_jwt, session.refresh_jwt); 64 + }); 65 + } 66 + 67 + #[test] 68 + fn load_client_without_session_errors() { 69 + with_test_dir(|_| { 70 + assert!(load_client().is_err()); 71 + }); 72 + } 73 + 74 + #[test] 75 + fn load_session_rejects_garbage_json() { 76 + with_test_dir(|_| { 77 + config::ensure_data_dir().unwrap(); 78 + fs::write(file_path(), "not json at all {{{").unwrap(); 79 + assert!(load_session().is_err()); 80 + }); 81 + } 82 + 83 + #[test] 84 + fn load_session_rejects_valid_json_wrong_schema() { 85 + with_test_dir(|_| { 86 + config::ensure_data_dir().unwrap(); 87 + fs::write(file_path(), r#"{"name": "bob", "age": 42}"#).unwrap(); 88 + assert!(load_session().is_err()); 89 + }); 90 + } 91 + 92 + #[test] 93 + fn load_session_rejects_empty_file() { 94 + with_test_dir(|_| { 95 + config::ensure_data_dir().unwrap(); 96 + fs::write(file_path(), "").unwrap(); 97 + assert!(load_session().is_err()); 98 + }); 99 + } 100 + 101 + #[test] 102 + fn load_session_rejects_binary_noise() { 103 + with_test_dir(|_| { 104 + config::ensure_data_dir().unwrap(); 105 + fs::write(file_path(), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap(); 106 + assert!(load_session().is_err()); 107 + }); 108 + } 109 + 110 + #[test] 111 + fn load_session_rejects_partial_session() { 112 + with_test_dir(|_| { 113 + config::ensure_data_dir().unwrap(); 114 + fs::write( 115 + file_path(), 116 + r#"{"did": "did:plc:x", "handle": "a", "accessJwt": "tok"}"#, 117 + ) 118 + .unwrap(); 119 + assert!(load_session().is_err()); 120 + }); 121 + } 122 + }
+18
crates/opake-cli/src/utils.rs
··· 8 8 std::env::var(format_env_key(key)).ok() 9 9 } 10 10 11 + /// Test helpers for modules that need to override `OPAKE_DATA_DIR`. 12 + /// A global mutex prevents parallel tests from stomping each other's env var. 13 + #[cfg(test)] 14 + pub mod test_harness { 15 + use std::sync::Mutex; 16 + use tempfile::TempDir; 17 + 18 + static ENV_LOCK: Mutex<()> = Mutex::new(()); 19 + 20 + pub fn with_test_dir(f: impl FnOnce(&TempDir)) { 21 + let _guard = ENV_LOCK.lock().unwrap(); 22 + let dir = TempDir::new().unwrap(); 23 + unsafe { std::env::set_var("OPAKE_DATA_DIR", dir.path()) }; 24 + f(&dir); 25 + unsafe { std::env::remove_var("OPAKE_DATA_DIR") }; 26 + } 27 + } 28 + 11 29 #[cfg(test)] 12 30 mod tests { 13 31 use super::*;
+5 -2
crates/opake-core/src/crypto.rs
··· 22 22 use crate::error::Error; 23 23 use crate::records::{AtBytes, WrappedKey, SCHEMA_VERSION}; 24 24 25 - /// Re-export so callers don't need a direct rand_core dependency. 26 - pub use aes_gcm::aead::rand_core::{CryptoRng, RngCore}; 25 + /// Re-export so callers don't need direct rand_core / x25519_dalek dependencies. 26 + pub use aes_gcm::aead::rand_core::{CryptoRng, OsRng, RngCore}; 27 + pub use x25519_dalek::{ 28 + PublicKey as X25519DalekPublicKey, StaticSecret as X25519DalekStaticSecret, 29 + }; 27 30 28 31 const WRAP_ALGO: &str = "x25519-hkdf-a256kw"; 29 32 const CONTENT_KEY_LEN: usize = 32;
+22
crates/opake-core/src/records.rs
··· 143 143 pub modified_at: Option<String>, 144 144 } 145 145 146 + impl Document { 147 + /// Construct a new document with the current schema version and sensible 148 + /// defaults for optional fields. Callers set tags/parent/description/etc. 149 + /// via struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 150 + pub fn new(name: String, blob: BlobRef, encryption: Encryption, created_at: String) -> Self { 151 + Self { 152 + version: SCHEMA_VERSION, 153 + name, 154 + mime_type: None, 155 + size: None, 156 + blob, 157 + encryption, 158 + tags: Vec::new(), 159 + parent: None, 160 + description: None, 161 + visibility: None, 162 + created_at, 163 + modified_at: None, 164 + } 165 + } 166 + } 167 + 146 168 // --------------------------------------------------------------------------- 147 169 // app.opake.cloud.grant 148 170 // ---------------------------------------------------------------------------