max / makenotwork
5 files changed,
+833 insertions,
-103 deletions
| @@ -399,6 +399,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 399 | 399 | checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" | |
| 400 | 400 | ||
| 401 | 401 | [[package]] | |
| 402 | + | name = "combine" | |
| 403 | + | version = "4.6.7" | |
| 404 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 405 | + | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" | |
| 406 | + | dependencies = [ | |
| 407 | + | "bytes", | |
| 408 | + | "memchr", | |
| 409 | + | ] | |
| 410 | + | ||
| 411 | + | [[package]] | |
| 402 | 412 | name = "concurrent-queue" | |
| 403 | 413 | version = "2.5.0" | |
| 404 | 414 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -494,6 +504,32 @@ dependencies = [ | |||
| 494 | 504 | ] | |
| 495 | 505 | ||
| 496 | 506 | [[package]] | |
| 507 | + | name = "curve25519-dalek" | |
| 508 | + | version = "4.1.3" | |
| 509 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 510 | + | checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" | |
| 511 | + | dependencies = [ | |
| 512 | + | "cfg-if", | |
| 513 | + | "cpufeatures 0.2.17", | |
| 514 | + | "curve25519-dalek-derive", | |
| 515 | + | "fiat-crypto", | |
| 516 | + | "rustc_version", | |
| 517 | + | "subtle", | |
| 518 | + | "zeroize", | |
| 519 | + | ] | |
| 520 | + | ||
| 521 | + | [[package]] | |
| 522 | + | name = "curve25519-dalek-derive" | |
| 523 | + | version = "0.1.1" | |
| 524 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 525 | + | checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" | |
| 526 | + | dependencies = [ | |
| 527 | + | "proc-macro2", | |
| 528 | + | "quote", | |
| 529 | + | "syn 2.0.117", | |
| 530 | + | ] | |
| 531 | + | ||
| 532 | + | [[package]] | |
| 497 | 533 | name = "deadpool" | |
| 498 | 534 | version = "0.12.3" | |
| 499 | 535 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -636,6 +672,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 636 | 672 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" | |
| 637 | 673 | ||
| 638 | 674 | [[package]] | |
| 675 | + | name = "fiat-crypto" | |
| 676 | + | version = "0.2.9" | |
| 677 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 678 | + | checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" | |
| 679 | + | ||
| 680 | + | [[package]] | |
| 639 | 681 | name = "find-msvc-tools" | |
| 640 | 682 | version = "0.1.9" | |
| 641 | 683 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -660,21 +702,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 660 | 702 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 661 | 703 | ||
| 662 | 704 | [[package]] | |
| 663 | - | name = "foreign-types" | |
| 664 | - | version = "0.3.2" | |
| 665 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 666 | - | checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | |
| 667 | - | dependencies = [ | |
| 668 | - | "foreign-types-shared", | |
| 669 | - | ] | |
| 670 | - | ||
| 671 | - | [[package]] | |
| 672 | - | name = "foreign-types-shared" | |
| 673 | - | version = "0.1.1" | |
| 674 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 675 | - | checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | |
| 676 | - | ||
| 677 | - | [[package]] | |
| 678 | 705 | name = "form_urlencoded" | |
| 679 | 706 | version = "1.2.2" | |
| 680 | 707 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -995,22 +1022,6 @@ dependencies = [ | |||
| 995 | 1022 | ] | |
| 996 | 1023 | ||
| 997 | 1024 | [[package]] | |
| 998 | - | name = "hyper-tls" | |
| 999 | - | version = "0.6.0" | |
| 1000 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1001 | - | checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" | |
| 1002 | - | dependencies = [ | |
| 1003 | - | "bytes", | |
| 1004 | - | "http-body-util", | |
| 1005 | - | "hyper", | |
| 1006 | - | "hyper-util", | |
| 1007 | - | "native-tls", | |
| 1008 | - | "tokio", | |
| 1009 | - | "tokio-native-tls", | |
| 1010 | - | "tower-service", | |
| 1011 | - | ] | |
| 1012 | - | ||
| 1013 | - | [[package]] | |
| 1014 | 1025 | name = "hyper-util" | |
| 1015 | 1026 | version = "0.1.20" | |
| 1016 | 1027 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1221,6 +1232,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1221 | 1232 | checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" | |
| 1222 | 1233 | ||
| 1223 | 1234 | [[package]] | |
| 1235 | + | name = "jni" | |
| 1236 | + | version = "0.22.4" | |
| 1237 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1238 | + | checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" | |
| 1239 | + | dependencies = [ | |
| 1240 | + | "cfg-if", | |
| 1241 | + | "combine", | |
| 1242 | + | "jni-macros", | |
| 1243 | + | "jni-sys", | |
| 1244 | + | "log", | |
| 1245 | + | "simd_cesu8", | |
| 1246 | + | "thiserror", | |
| 1247 | + | "walkdir", | |
| 1248 | + | "windows-link", | |
| 1249 | + | ] | |
| 1250 | + | ||
| 1251 | + | [[package]] | |
| 1252 | + | name = "jni-macros" | |
| 1253 | + | version = "0.22.4" | |
| 1254 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1255 | + | checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" | |
| 1256 | + | dependencies = [ | |
| 1257 | + | "proc-macro2", | |
| 1258 | + | "quote", | |
| 1259 | + | "rustc_version", | |
| 1260 | + | "simd_cesu8", | |
| 1261 | + | "syn 2.0.117", | |
| 1262 | + | ] | |
| 1263 | + | ||
| 1264 | + | [[package]] | |
| 1265 | + | name = "jni-sys" | |
| 1266 | + | version = "0.4.1" | |
| 1267 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1268 | + | checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" | |
| 1269 | + | dependencies = [ | |
| 1270 | + | "jni-sys-macros", | |
| 1271 | + | ] | |
| 1272 | + | ||
| 1273 | + | [[package]] | |
| 1274 | + | name = "jni-sys-macros" | |
| 1275 | + | version = "0.4.1" | |
| 1276 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1277 | + | checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" | |
| 1278 | + | dependencies = [ | |
| 1279 | + | "quote", | |
| 1280 | + | "syn 2.0.117", | |
| 1281 | + | ] | |
| 1282 | + | ||
| 1283 | + | [[package]] | |
| 1224 | 1284 | name = "js-sys" | |
| 1225 | 1285 | version = "0.3.90" | |
| 1226 | 1286 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1340,23 +1400,6 @@ dependencies = [ | |||
| 1340 | 1400 | ] | |
| 1341 | 1401 | ||
| 1342 | 1402 | [[package]] | |
| 1343 | - | name = "native-tls" | |
| 1344 | - | version = "0.2.18" | |
| 1345 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1346 | - | checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" | |
| 1347 | - | dependencies = [ | |
| 1348 | - | "libc", | |
| 1349 | - | "log", | |
| 1350 | - | "openssl", | |
| 1351 | - | "openssl-probe", | |
| 1352 | - | "openssl-sys", | |
| 1353 | - | "schannel", | |
| 1354 | - | "security-framework", | |
| 1355 | - | "security-framework-sys", | |
| 1356 | - | "tempfile", | |
| 1357 | - | ] | |
| 1358 | - | ||
| 1359 | - | [[package]] | |
| 1360 | 1403 | name = "num" | |
| 1361 | 1404 | version = "0.4.3" | |
| 1362 | 1405 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1445,50 +1488,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1445 | 1488 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" | |
| 1446 | 1489 | ||
| 1447 | 1490 | [[package]] | |
| 1448 | - | name = "openssl" | |
| 1449 | - | version = "0.10.75" | |
| 1450 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1451 | - | checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" | |
| 1452 | - | dependencies = [ | |
| 1453 | - | "bitflags", | |
| 1454 | - | "cfg-if", | |
| 1455 | - | "foreign-types", | |
| 1456 | - | "libc", | |
| 1457 | - | "once_cell", | |
| 1458 | - | "openssl-macros", | |
| 1459 | - | "openssl-sys", | |
| 1460 | - | ] | |
| 1461 | - | ||
| 1462 | - | [[package]] | |
| 1463 | - | name = "openssl-macros" | |
| 1464 | - | version = "0.1.1" | |
| 1465 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1466 | - | checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" | |
| 1467 | - | dependencies = [ | |
| 1468 | - | "proc-macro2", | |
| 1469 | - | "quote", | |
| 1470 | - | "syn 2.0.117", | |
| 1471 | - | ] | |
| 1472 | - | ||
| 1473 | - | [[package]] | |
| 1474 | 1491 | name = "openssl-probe" | |
| 1475 | 1492 | version = "0.2.1" | |
| 1476 | 1493 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1477 | 1494 | checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" | |
| 1478 | 1495 | ||
| 1479 | 1496 | [[package]] | |
| 1480 | - | name = "openssl-sys" | |
| 1481 | - | version = "0.9.111" | |
| 1482 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1483 | - | checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" | |
| 1484 | - | dependencies = [ | |
| 1485 | - | "cc", | |
| 1486 | - | "libc", | |
| 1487 | - | "pkg-config", | |
| 1488 | - | "vcpkg", | |
| 1489 | - | ] | |
| 1490 | - | ||
| 1491 | - | [[package]] | |
| 1492 | 1497 | name = "ordered-stream" | |
| 1493 | 1498 | version = "0.2.0" | |
| 1494 | 1499 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1727,21 +1732,21 @@ dependencies = [ | |||
| 1727 | 1732 | "http-body-util", | |
| 1728 | 1733 | "hyper", | |
| 1729 | 1734 | "hyper-rustls", | |
| 1730 | - | "hyper-tls", | |
| 1731 | 1735 | "hyper-util", | |
| 1732 | 1736 | "js-sys", | |
| 1733 | 1737 | "log", | |
| 1734 | 1738 | "mime", | |
| 1735 | - | "native-tls", | |
| 1736 | 1739 | "percent-encoding", | |
| 1737 | 1740 | "pin-project-lite", | |
| 1741 | + | "rustls", | |
| 1738 | 1742 | "rustls-pki-types", | |
| 1743 | + | "rustls-platform-verifier", | |
| 1739 | 1744 | "serde", | |
| 1740 | 1745 | "serde_json", | |
| 1741 | 1746 | "serde_urlencoded", | |
| 1742 | 1747 | "sync_wrapper", | |
| 1743 | 1748 | "tokio", | |
| 1744 | - | "tokio-native-tls", | |
| 1749 | + | "tokio-rustls", | |
| 1745 | 1750 | "tokio-util", | |
| 1746 | 1751 | "tower", | |
| 1747 | 1752 | "tower-http", | |
| @@ -1793,6 +1798,15 @@ dependencies = [ | |||
| 1793 | 1798 | ] | |
| 1794 | 1799 | ||
| 1795 | 1800 | [[package]] | |
| 1801 | + | name = "rustc_version" | |
| 1802 | + | version = "0.4.1" | |
| 1803 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1804 | + | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | |
| 1805 | + | dependencies = [ | |
| 1806 | + | "semver", | |
| 1807 | + | ] | |
| 1808 | + | ||
| 1809 | + | [[package]] | |
| 1796 | 1810 | name = "rustix" | |
| 1797 | 1811 | version = "1.1.4" | |
| 1798 | 1812 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1819,6 +1833,18 @@ dependencies = [ | |||
| 1819 | 1833 | ] | |
| 1820 | 1834 | ||
| 1821 | 1835 | [[package]] | |
| 1836 | + | name = "rustls-native-certs" | |
| 1837 | + | version = "0.8.4" | |
| 1838 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1839 | + | checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" | |
| 1840 | + | dependencies = [ | |
| 1841 | + | "openssl-probe", | |
| 1842 | + | "rustls-pki-types", | |
| 1843 | + | "schannel", | |
| 1844 | + | "security-framework", | |
| 1845 | + | ] | |
| 1846 | + | ||
| 1847 | + | [[package]] | |
| 1822 | 1848 | name = "rustls-pki-types" | |
| 1823 | 1849 | version = "1.14.0" | |
| 1824 | 1850 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1828,6 +1854,33 @@ dependencies = [ | |||
| 1828 | 1854 | ] | |
| 1829 | 1855 | ||
| 1830 | 1856 | [[package]] | |
| 1857 | + | name = "rustls-platform-verifier" | |
| 1858 | + | version = "0.7.0" | |
| 1859 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1860 | + | checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" | |
| 1861 | + | dependencies = [ | |
| 1862 | + | "core-foundation 0.10.1", | |
| 1863 | + | "core-foundation-sys", | |
| 1864 | + | "jni", | |
| 1865 | + | "log", | |
| 1866 | + | "once_cell", | |
| 1867 | + | "rustls", | |
| 1868 | + | "rustls-native-certs", | |
| 1869 | + | "rustls-platform-verifier-android", | |
| 1870 | + | "rustls-webpki", | |
| 1871 | + | "security-framework", | |
| 1872 | + | "security-framework-sys", | |
| 1873 | + | "webpki-root-certs", | |
| 1874 | + | "windows-sys 0.61.2", | |
| 1875 | + | ] | |
| 1876 | + | ||
| 1877 | + | [[package]] | |
| 1878 | + | name = "rustls-platform-verifier-android" | |
| 1879 | + | version = "0.1.1" | |
| 1880 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1881 | + | checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" | |
| 1882 | + | ||
| 1883 | + | [[package]] | |
| 1831 | 1884 | name = "rustls-webpki" | |
| 1832 | 1885 | version = "0.103.13" | |
| 1833 | 1886 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1851,6 +1904,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1851 | 1904 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 1852 | 1905 | ||
| 1853 | 1906 | [[package]] | |
| 1907 | + | name = "same-file" | |
| 1908 | + | version = "1.0.6" | |
| 1909 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1910 | + | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | |
| 1911 | + | dependencies = [ | |
| 1912 | + | "winapi-util", | |
| 1913 | + | ] | |
| 1914 | + | ||
| 1915 | + | [[package]] | |
| 1854 | 1916 | name = "schannel" | |
| 1855 | 1917 | version = "0.1.28" | |
| 1856 | 1918 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2018,6 +2080,22 @@ dependencies = [ | |||
| 2018 | 2080 | ] | |
| 2019 | 2081 | ||
| 2020 | 2082 | [[package]] | |
| 2083 | + | name = "simd_cesu8" | |
| 2084 | + | version = "1.2.0" | |
| 2085 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2086 | + | checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" | |
| 2087 | + | dependencies = [ | |
| 2088 | + | "rustc_version", | |
| 2089 | + | "simdutf8", | |
| 2090 | + | ] | |
| 2091 | + | ||
| 2092 | + | [[package]] | |
| 2093 | + | name = "simdutf8" | |
| 2094 | + | version = "0.1.5" | |
| 2095 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2096 | + | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" | |
| 2097 | + | ||
| 2098 | + | [[package]] | |
| 2021 | 2099 | name = "slab" | |
| 2022 | 2100 | version = "0.4.12" | |
| 2023 | 2101 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2121,6 +2199,7 @@ dependencies = [ | |||
| 2121 | 2199 | "urlencoding", | |
| 2122 | 2200 | "uuid", | |
| 2123 | 2201 | "wiremock", | |
| 2202 | + | "x25519-dalek", | |
| 2124 | 2203 | "zeroize", | |
| 2125 | 2204 | ] | |
| 2126 | 2205 | ||
| @@ -2241,16 +2320,6 @@ dependencies = [ | |||
| 2241 | 2320 | ] | |
| 2242 | 2321 | ||
| 2243 | 2322 | [[package]] | |
| 2244 | - | name = "tokio-native-tls" | |
| 2245 | - | version = "0.3.1" | |
| 2246 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2247 | - | checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" | |
| 2248 | - | dependencies = [ | |
| 2249 | - | "native-tls", | |
| 2250 | - | "tokio", | |
| 2251 | - | ] | |
| 2252 | - | ||
| 2253 | - | [[package]] | |
| 2254 | 2323 | name = "tokio-rustls" | |
| 2255 | 2324 | version = "0.26.4" | |
| 2256 | 2325 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2499,6 +2568,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2499 | 2568 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 2500 | 2569 | ||
| 2501 | 2570 | [[package]] | |
| 2571 | + | name = "walkdir" | |
| 2572 | + | version = "2.5.0" | |
| 2573 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2574 | + | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | |
| 2575 | + | dependencies = [ | |
| 2576 | + | "same-file", | |
| 2577 | + | "winapi-util", | |
| 2578 | + | ] | |
| 2579 | + | ||
| 2580 | + | [[package]] | |
| 2502 | 2581 | name = "want" | |
| 2503 | 2582 | version = "0.3.1" | |
| 2504 | 2583 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2648,6 +2727,24 @@ dependencies = [ | |||
| 2648 | 2727 | ] | |
| 2649 | 2728 | ||
| 2650 | 2729 | [[package]] | |
| 2730 | + | name = "webpki-root-certs" | |
| 2731 | + | version = "1.0.9" | |
| 2732 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2733 | + | checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" | |
| 2734 | + | dependencies = [ | |
| 2735 | + | "rustls-pki-types", | |
| 2736 | + | ] | |
| 2737 | + | ||
| 2738 | + | [[package]] | |
| 2739 | + | name = "winapi-util" | |
| 2740 | + | version = "0.1.11" | |
| 2741 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2742 | + | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 2743 | + | dependencies = [ | |
| 2744 | + | "windows-sys 0.61.2", | |
| 2745 | + | ] | |
| 2746 | + | ||
| 2747 | + | [[package]] | |
| 2651 | 2748 | name = "windows-core" | |
| 2652 | 2749 | version = "0.62.2" | |
| 2653 | 2750 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3013,6 +3110,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3013 | 3110 | checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" | |
| 3014 | 3111 | ||
| 3015 | 3112 | [[package]] | |
| 3113 | + | name = "x25519-dalek" | |
| 3114 | + | version = "2.0.1" | |
| 3115 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3116 | + | checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" | |
| 3117 | + | dependencies = [ | |
| 3118 | + | "curve25519-dalek", | |
| 3119 | + | "rand_core 0.6.4", | |
| 3120 | + | "zeroize", | |
| 3121 | + | ] | |
| 3122 | + | ||
| 3123 | + | [[package]] | |
| 3016 | 3124 | name = "yoke" | |
| 3017 | 3125 | version = "0.8.1" | |
| 3018 | 3126 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3133,6 +3241,20 @@ name = "zeroize" | |||
| 3133 | 3241 | version = "1.8.2" | |
| 3134 | 3242 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3135 | 3243 | checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" | |
| 3244 | + | dependencies = [ | |
| 3245 | + | "zeroize_derive", | |
| 3246 | + | ] | |
| 3247 | + | ||
| 3248 | + | [[package]] | |
| 3249 | + | name = "zeroize_derive" | |
| 3250 | + | version = "1.5.0" | |
| 3251 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3252 | + | checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" | |
| 3253 | + | dependencies = [ | |
| 3254 | + | "proc-macro2", | |
| 3255 | + | "quote", | |
| 3256 | + | "syn 2.0.117", | |
| 3257 | + | ] | |
| 3136 | 3258 | ||
| 3137 | 3259 | [[package]] | |
| 3138 | 3260 | name = "zerotrie" |
| @@ -26,6 +26,12 @@ argon2 = "0.5" | |||
| 26 | 26 | rand = "0.10" | |
| 27 | 27 | base64 = "0.22" | |
| 28 | 28 | zeroize = "1" | |
| 29 | + | # X25519 ECDH — the only asymmetric primitive in the crate, used solely by the | |
| 30 | + | # group-key layer (`identity.rs`) to seal a Group Content Key to a member's public | |
| 31 | + | # key. Pure-Rust (curve25519-dalek), no aws-lc/ring. Deliberately just the curve op: | |
| 32 | + | # the GCK itself is sealed with the XChaCha20-Poly1305 above, not a sealed-box crate. | |
| 33 | + | # `static_secrets` enables the reusable `StaticSecret` (a member's long-lived key). | |
| 34 | + | x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] } | |
| 29 | 35 | ||
| 30 | 36 | # PKCE (OAuth2) challenge hashing | |
| 31 | 37 | sha2 = "0.11" |
| @@ -299,18 +299,38 @@ const WIRE_V2_TAG_BYTES: &[u8] = b"sk2:"; | |||
| 299 | 299 | /// unrepresentable at the type level rather than a per-call-site discipline. | |
| 300 | 300 | #[derive(Clone, Copy, Debug)] | |
| 301 | 301 | pub enum AeadContext<'a> { | |
| 302 | - | /// A sync change entry, bound to its `(table, row_id)` address. | |
| 302 | + | /// A personal sync change entry, bound to its `(table, row_id)` address. | |
| 303 | 303 | Entry { table: &'a str, row_id: &'a str }, | |
| 304 | + | /// A group sync change entry, bound to its `(group_id, table, row_id)` | |
| 305 | + | /// address. Sealed under the group's GCK (see [`crate::identity`]), not the | |
| 306 | + | /// personal `master_key`. Folding `group_id` into the AAD makes a ciphertext | |
| 307 | + | /// non-relocatable across groups as well as across tables and rows: a server | |
| 308 | + | /// that moves a group-entry ciphertext into a different group's changelog | |
| 309 | + | /// changes its AAD, so it fails to open. | |
| 310 | + | GroupEntry { | |
| 311 | + | group_id: &'a str, | |
| 312 | + | table: &'a str, | |
| 313 | + | row_id: &'a str, | |
| 314 | + | }, | |
| 304 | 315 | /// A content-addressed blob, bound to its content hash. | |
| 305 | 316 | Blob { hash: &'a str }, | |
| 306 | 317 | } | |
| 307 | 318 | ||
| 308 | 319 | impl<'a> AeadContext<'a> { | |
| 309 | - | /// Bind a change entry to its `(table, row_id)` address. | |
| 320 | + | /// Bind a personal change entry to its `(table, row_id)` address. | |
| 310 | 321 | pub fn entry(table: &'a str, row_id: &'a str) -> Self { | |
| 311 | 322 | AeadContext::Entry { table, row_id } | |
| 312 | 323 | } | |
| 313 | 324 | ||
| 325 | + | /// Bind a group change entry to its `(group_id, table, row_id)` address. | |
| 326 | + | pub fn group_entry(group_id: &'a str, table: &'a str, row_id: &'a str) -> Self { | |
| 327 | + | AeadContext::GroupEntry { | |
| 328 | + | group_id, | |
| 329 | + | table, | |
| 330 | + | row_id, | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 314 | 334 | /// Bind a blob to its content hash. | |
| 315 | 335 | pub fn blob(hash: &'a str) -> Self { | |
| 316 | 336 | AeadContext::Blob { hash } | |
| @@ -322,6 +342,11 @@ impl<'a> AeadContext<'a> { | |||
| 322 | 342 | fn aad(&self) -> Result<Vec<u8>> { | |
| 323 | 343 | match self { | |
| 324 | 344 | AeadContext::Entry { table, row_id } => aad_for_entry(table, row_id), | |
| 345 | + | AeadContext::GroupEntry { | |
| 346 | + | group_id, | |
| 347 | + | table, | |
| 348 | + | row_id, | |
| 349 | + | } => aad_for_group_entry(group_id, table, row_id), | |
| 325 | 350 | AeadContext::Blob { hash } => Ok(hash.as_bytes().to_vec()), | |
| 326 | 351 | } | |
| 327 | 352 | } | |
| @@ -349,8 +374,36 @@ fn aad_for_entry(table: &str, row_id: &str) -> Result<Vec<u8>> { | |||
| 349 | 374 | Ok(aad) | |
| 350 | 375 | } | |
| 351 | 376 | ||
| 377 | + | /// Canonical associated-data encoding binding a group entry's ciphertext to its | |
| 378 | + | /// `(group_id, table, row_id)` address: `group_id || 0x1f || table || 0x1f || | |
| 379 | + | /// row_id`. Rejects any field containing `0x1f`, which keeps the encoding | |
| 380 | + | /// injective (as with [`aad_for_entry`]). The three-field form always carries | |
| 381 | + | /// exactly two separators where a personal [`aad_for_entry`] carries exactly one, | |
| 382 | + | /// so a group-entry AAD can never coincide with a personal-entry AAD. | |
| 383 | + | fn aad_for_group_entry(group_id: &str, table: &str, row_id: &str) -> Result<Vec<u8>> { | |
| 384 | + | if group_id.as_bytes().contains(&0x1f) | |
| 385 | + | || table.as_bytes().contains(&0x1f) | |
| 386 | + | || row_id.as_bytes().contains(&0x1f) | |
| 387 | + | { | |
| 388 | + | return Err(SyncKitError::Crypto( | |
| 389 | + | "group_id, table, or row_id contains the 0x1f AAD separator".into(), | |
| 390 | + | )); | |
| 391 | + | } | |
| 392 | + | let mut aad = Vec::with_capacity(group_id.len() + table.len() + row_id.len() + 2); | |
| 393 | + | aad.extend_from_slice(group_id.as_bytes()); | |
| 394 | + | aad.push(0x1f); | |
| 395 | + | aad.extend_from_slice(table.as_bytes()); | |
| 396 | + | aad.push(0x1f); | |
| 397 | + | aad.extend_from_slice(row_id.as_bytes()); | |
| 398 | + | Ok(aad) | |
| 399 | + | } | |
| 400 | + | ||
| 352 | 401 | /// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`. | |
| 353 | - | fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> { | |
| 402 | + | /// | |
| 403 | + | /// `pub(crate)` so the group-key layer ([`crate::identity`]) can seal a Group | |
| 404 | + | /// Content Key under a freshly derived (ECDH) key with explicit AAD, reusing the | |
| 405 | + | /// one audited AEAD this crate ships rather than open-coding a second one. | |
| 406 | + | pub(crate) fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> { | |
| 354 | 407 | let mut nonce_bytes = [0u8; NONCE_SIZE]; | |
| 355 | 408 | rand::rng().fill_bytes(&mut nonce_bytes); | |
| 356 | 409 | ||
| @@ -374,7 +427,10 @@ fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec | |||
| 374 | 427 | } | |
| 375 | 428 | ||
| 376 | 429 | /// AEAD open of a `nonce || ciphertext || tag` blob with the given AAD. | |
| 377 | - | fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> { | |
| 430 | + | /// | |
| 431 | + | /// `pub(crate)` counterpart to [`seal`]; see its note. Used by | |
| 432 | + | /// [`crate::identity`] to open a Group Content Key grant. | |
| 433 | + | pub(crate) fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> { | |
| 378 | 434 | if blob.len() < NONCE_SIZE + 16 { | |
| 379 | 435 | // Minimum: nonce + poly1305 tag (empty plaintext) | |
| 380 | 436 | return Err(SyncKitError::Crypto("ciphertext too short".into())); | |
| @@ -1801,4 +1857,59 @@ mod tests { | |||
| 1801 | 1857 | assert!(parse_blob_header(&v2).is_err()); | |
| 1802 | 1858 | assert!(!is_chunked_blob(&v2)); | |
| 1803 | 1859 | } | |
| 1860 | + | ||
| 1861 | + | // ── Group-entry AAD binding ── | |
| 1862 | + | ||
| 1863 | + | #[test] | |
| 1864 | + | fn group_entry_roundtrips_under_gck() { | |
| 1865 | + | let gck = generate_master_key(); | |
| 1866 | + | let ctx = AeadContext::group_entry("grp1", "tasks", "row-9"); | |
| 1867 | + | let wire = encrypt_data_aad(b"shared task", &gck, &ctx).unwrap(); | |
| 1868 | + | let out = decrypt_data_aad(&wire, &gck, &ctx).unwrap(); | |
| 1869 | + | assert_eq!(out, b"shared task"); | |
| 1870 | + | } | |
| 1871 | + | ||
| 1872 | + | #[test] | |
| 1873 | + | fn group_entry_relocated_to_another_group_fails() { | |
| 1874 | + | let gck = generate_master_key(); | |
| 1875 | + | let wire = encrypt_data_aad( | |
| 1876 | + | b"shared task", | |
| 1877 | + | &gck, | |
| 1878 | + | &AeadContext::group_entry("grp1", "tasks", "row-9"), | |
| 1879 | + | ) | |
| 1880 | + | .unwrap(); | |
| 1881 | + | // Same GCK and same (table, row_id), but a different group_id: a server | |
| 1882 | + | // that filed this ciphertext under another group cannot make it open. | |
| 1883 | + | let moved = AeadContext::group_entry("grp2", "tasks", "row-9"); | |
| 1884 | + | assert!(matches!( | |
| 1885 | + | decrypt_data_aad(&wire, &gck, &moved), | |
| 1886 | + | Err(SyncKitError::DecryptionFailed) | |
| 1887 | + | )); | |
| 1888 | + | } | |
| 1889 | + | ||
| 1890 | + | #[test] | |
| 1891 | + | fn group_entry_and_personal_entry_aad_never_collide() { | |
| 1892 | + | // A personal Entry and a GroupEntry with the same table/row_id must not | |
| 1893 | + | // share associated data (the separator-count invariant), so a ciphertext | |
| 1894 | + | // cannot cross the personal/group boundary. | |
| 1895 | + | let personal = AeadContext::entry("tasks", "row-9"); | |
| 1896 | + | let group = AeadContext::group_entry("", "tasks", "row-9"); | |
| 1897 | + | assert_ne!(personal.aad().unwrap(), group.aad().unwrap()); | |
| 1898 | + | ||
| 1899 | + | let gck = generate_master_key(); | |
| 1900 | + | let wire = encrypt_data_aad(b"x", &gck, &group).unwrap(); | |
| 1901 | + | assert!(decrypt_data_aad(&wire, &gck, &personal).is_err()); | |
| 1902 | + | } | |
| 1903 | + | ||
| 1904 | + | #[test] | |
| 1905 | + | fn group_entry_rejects_separator_byte_in_any_field() { | |
| 1906 | + | let gck = generate_master_key(); | |
| 1907 | + | for ctx in [ | |
| 1908 | + | AeadContext::group_entry("g\u{1f}x", "tasks", "r"), | |
| 1909 | + | AeadContext::group_entry("g", "ta\u{1f}sks", "r"), | |
| 1910 | + | AeadContext::group_entry("g", "tasks", "r\u{1f}ow"), | |
| 1911 | + | ] { | |
| 1912 | + | assert!(encrypt_data_aad(b"x", &gck, &ctx).is_err()); | |
| 1913 | + | } | |
| 1914 | + | } | |
| 1804 | 1915 | } |
| @@ -0,0 +1,487 @@ | |||
| 1 | + | //! Group identity keys and Group Content Key (GCK) grants. | |
| 2 | + | //! | |
| 3 | + | //! This is the one asymmetric layer in SyncKit. Everything else in the crate is | |
| 4 | + | //! symmetric (a passphrase-derived `master_key` per user). Groups break the | |
| 5 | + | //! "one key, one owner" assumption: several users need a shared symmetric key — | |
| 6 | + | //! the **Group Content Key** — that the server never sees. Handing that key to a | |
| 7 | + | //! member who shares no prior secret with the group admin is, by definition, | |
| 8 | + | //! public-key crypto. See the wiki design note. | |
| 9 | + | //! <!-- wiki: synckit-groups-design --> | |
| 10 | + | //! | |
| 11 | + | //! ## The pieces | |
| 12 | + | //! | |
| 13 | + | //! - [`IdentityKeypair`] — a per-user X25519 keypair. The [`IdentityPublicKey`] | |
| 14 | + | //! is the value a member shares with an admin (non-secret, safe to transmit in | |
| 15 | + | //! the clear). The private half is wrapped under the user's existing | |
| 16 | + | //! `master_key` for storage ([`IdentityKeypair::wrap_secret`]), mirroring the | |
| 17 | + | //! master-key lifecycle. | |
| 18 | + | //! - The **GCK** — a random 32-byte symmetric key ([`generate_group_key`]), | |
| 19 | + | //! playing the role `master_key` plays for personal data. Group changelog | |
| 20 | + | //! entries are sealed under it. | |
| 21 | + | //! - A **grant** — the GCK sealed to one member's public key | |
| 22 | + | //! ([`seal_gck_to_member`] / [`open_gck_grant`]). Adding a member is: seal the | |
| 23 | + | //! GCK to their pubkey, upload one grant. Removing a member is a GCK rotation | |
| 24 | + | //! that re-seals to the *remaining* members' stored pubkeys with no member | |
| 25 | + | //! interaction — the payoff of the asymmetric design. | |
| 26 | + | //! | |
| 27 | + | //! ## Grant construction (ephemeral-static ECDH, an ECIES/sealed-box shape) | |
| 28 | + | //! | |
| 29 | + | //! For each grant the sender mints a throwaway (ephemeral) X25519 keypair, does | |
| 30 | + | //! ECDH against the member's public key, derives a symmetric key from the shared | |
| 31 | + | //! secret via SHA-256 (X25519 output must never be used as a key directly), and | |
| 32 | + | //! seals the GCK with the crate's XChaCha20-Poly1305. The wire form is | |
| 33 | + | //! `ephemeral_public[32] || nonce[24] || ciphertext || tag[16]`, base64-encoded. | |
| 34 | + | //! The ephemeral secret is discarded after one seal, so a later compromise of the | |
| 35 | + | //! sender's long-term key cannot recover past grants. | |
| 36 | + | //! | |
| 37 | + | //! The KDF binds both public keys (`ephemeral` and `recipient`) so a grant cannot | |
| 38 | + | //! be re-pointed at a different recipient, and the AEAD's associated data binds | |
| 39 | + | //! `(group_id, gck_version)` so a grant cannot be replayed as a different group or | |
| 40 | + | //! key generation. | |
| 41 | + | ||
| 42 | + | use base64::{Engine, engine::general_purpose::STANDARD as B64}; | |
| 43 | + | use rand::Rng; | |
| 44 | + | use sha2::{Digest, Sha256}; | |
| 45 | + | use x25519_dalek::{PublicKey, StaticSecret}; | |
| 46 | + | use zeroize::Zeroize; | |
| 47 | + | ||
| 48 | + | use crate::crypto::{self, ENCRYPTION_OVERHEAD, ZeroizeOnDrop}; | |
| 49 | + | use crate::error::{Result, SyncKitError}; | |
| 50 | + | ||
| 51 | + | /// Size of an X25519 key (public or secret scalar) in bytes. | |
| 52 | + | const X25519_KEY_LEN: usize = 32; | |
| 53 | + | ||
| 54 | + | /// Domain-separation label mixed into the grant KDF, so the derived key is | |
| 55 | + | /// specific to this construction and version. | |
| 56 | + | const GRANT_KDF_DOMAIN: &[u8] = b"synckit-group-grant-v1"; | |
| 57 | + | ||
| 58 | + | /// Mint a fresh Group Content Key (GCK): 32 random bytes. | |
| 59 | + | /// | |
| 60 | + | /// A thin, intention-revealing alias of [`crypto::generate_master_key`] — a GCK | |
| 61 | + | /// is structurally the same 256-bit symmetric key as a personal `master_key`, | |
| 62 | + | /// just shared across a group instead of owned by one user. | |
| 63 | + | pub fn generate_group_key() -> [u8; X25519_KEY_LEN] { | |
| 64 | + | crypto::generate_master_key() | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// A user's long-lived X25519 identity keypair. | |
| 68 | + | /// | |
| 69 | + | /// The public key is shared with group admins; the secret opens grants sealed to | |
| 70 | + | /// it. The secret zeroizes on drop (via `x25519-dalek`'s `zeroize` feature). | |
| 71 | + | pub struct IdentityKeypair { | |
| 72 | + | secret: StaticSecret, | |
| 73 | + | public: PublicKey, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | impl std::fmt::Debug for IdentityKeypair { | |
| 77 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 78 | + | // Never render the secret. The public key is safe to show. | |
| 79 | + | f.debug_struct("IdentityKeypair") | |
| 80 | + | .field("public", &self.public_key().to_base64()) | |
| 81 | + | .field("secret", &"[REDACTED]") | |
| 82 | + | .finish() | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | impl IdentityKeypair { | |
| 87 | + | /// Generate a new random identity keypair. | |
| 88 | + | /// | |
| 89 | + | /// Seeds the scalar from the crate's existing `rand` (not `x25519-dalek`'s | |
| 90 | + | /// own RNG), so the whole crate keeps a single randomness source. | |
| 91 | + | pub fn generate() -> Self { | |
| 92 | + | let mut scalar = [0u8; X25519_KEY_LEN]; | |
| 93 | + | rand::rng().fill_bytes(&mut scalar); | |
| 94 | + | let secret = StaticSecret::from(scalar); | |
| 95 | + | scalar.zeroize(); | |
| 96 | + | let public = PublicKey::from(&secret); | |
| 97 | + | Self { secret, public } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// This keypair's public key — the value a member shares with an admin. | |
| 101 | + | pub fn public_key(&self) -> IdentityPublicKey { | |
| 102 | + | IdentityPublicKey(self.public.to_bytes()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Wrap the identity secret under the user's `master_key` for storage | |
| 106 | + | /// (synced ciphertext and/or keychain), returning a base64 blob. The server | |
| 107 | + | /// only ever sees this ciphertext. | |
| 108 | + | pub fn wrap_secret(&self, master_key: &[u8; X25519_KEY_LEN]) -> Result<String> { | |
| 109 | + | let mut scalar = self.secret.to_bytes(); | |
| 110 | + | let wrapped = crypto::encrypt_data(&scalar, master_key); | |
| 111 | + | scalar.zeroize(); | |
| 112 | + | wrapped | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Recover an identity keypair from a [`wrap_secret`](Self::wrap_secret) blob | |
| 116 | + | /// using the user's `master_key`. Wrong key or corrupt blob fails closed. | |
| 117 | + | pub fn unwrap_secret(wrapped: &str, master_key: &[u8; X25519_KEY_LEN]) -> Result<Self> { | |
| 118 | + | let mut scalar_vec = crypto::decrypt_data(wrapped, master_key)?; | |
| 119 | + | if scalar_vec.len() != X25519_KEY_LEN { | |
| 120 | + | scalar_vec.zeroize(); | |
| 121 | + | return Err(SyncKitError::InvalidEnvelope( | |
| 122 | + | "identity secret has wrong length".into(), | |
| 123 | + | )); | |
| 124 | + | } | |
| 125 | + | let mut scalar = [0u8; X25519_KEY_LEN]; | |
| 126 | + | scalar.copy_from_slice(&scalar_vec); | |
| 127 | + | scalar_vec.zeroize(); | |
| 128 | + | let secret = StaticSecret::from(scalar); | |
| 129 | + | scalar.zeroize(); | |
| 130 | + | let public = PublicKey::from(&secret); | |
| 131 | + | Ok(Self { secret, public }) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// The raw public-key bytes, for KDF binding on the open path. | |
| 135 | + | fn public_bytes(&self) -> [u8; X25519_KEY_LEN] { | |
| 136 | + | self.public.to_bytes() | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// A user's X25519 public key: the non-secret value shared with a group admin so | |
| 141 | + | /// the GCK can be sealed to it. | |
| 142 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 143 | + | pub struct IdentityPublicKey([u8; X25519_KEY_LEN]); | |
| 144 | + | ||
| 145 | + | impl IdentityPublicKey { | |
| 146 | + | /// The raw 32 key bytes. | |
| 147 | + | pub fn as_bytes(&self) -> &[u8; X25519_KEY_LEN] { | |
| 148 | + | &self.0 | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Base64 encoding, for out-of-band sharing (paste into an admin's UI). | |
| 152 | + | pub fn to_base64(&self) -> String { | |
| 153 | + | B64.encode(self.0) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Parse a base64-encoded public key. Rejects anything but exactly 32 bytes. | |
| 157 | + | pub fn from_base64(encoded: &str) -> Result<Self> { | |
| 158 | + | let bytes = B64.decode(encoded)?; | |
| 159 | + | Self::from_bytes(&bytes) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | fn from_bytes(bytes: &[u8]) -> Result<Self> { | |
| 163 | + | if bytes.len() != X25519_KEY_LEN { | |
| 164 | + | return Err(SyncKitError::InvalidArgument(format!( | |
| 165 | + | "identity public key must be {X25519_KEY_LEN} bytes, got {}", | |
| 166 | + | bytes.len() | |
| 167 | + | ))); | |
| 168 | + | } | |
| 169 | + | let mut key = [0u8; X25519_KEY_LEN]; | |
| 170 | + | key.copy_from_slice(bytes); | |
| 171 | + | Ok(Self(key)) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Associated data binding a grant to its `(group_id, gck_version)`, so a valid | |
| 176 | + | /// grant cannot be replayed against a different group or a different key | |
| 177 | + | /// generation. Injective as long as `group_id` contains no `0x1f`, which we | |
| 178 | + | /// reject (matching [`crypto`]'s entry-AAD discipline). | |
| 179 | + | fn grant_aad(group_id: &str, gck_version: i32) -> Result<Vec<u8>> { | |
| 180 | + | if group_id.as_bytes().contains(&0x1f) { | |
| 181 | + | return Err(SyncKitError::Crypto( | |
| 182 | + | "group_id contains the 0x1f AAD separator".into(), | |
| 183 | + | )); | |
| 184 | + | } | |
| 185 | + | let mut aad = Vec::with_capacity(group_id.len() + 1 + 4); | |
| 186 | + | aad.extend_from_slice(group_id.as_bytes()); | |
| 187 | + | aad.push(0x1f); | |
| 188 | + | aad.extend_from_slice(&gck_version.to_le_bytes()); | |
| 189 | + | Ok(aad) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Derive the grant's AEAD key from the ECDH shared secret, binding both public | |
| 193 | + | /// keys so the key is unique to this (ephemeral, recipient) pair. SHA-256 is the | |
| 194 | + | /// KDF: X25519 output must be hashed, never used as a key directly. | |
| 195 | + | fn derive_grant_key( | |
| 196 | + | shared_secret: &[u8; X25519_KEY_LEN], | |
| 197 | + | ephemeral_public: &[u8; X25519_KEY_LEN], | |
| 198 | + | recipient_public: &[u8; X25519_KEY_LEN], | |
| 199 | + | ) -> ZeroizeOnDrop { | |
| 200 | + | let mut hasher = Sha256::new(); | |
| 201 | + | hasher.update(GRANT_KDF_DOMAIN); | |
| 202 | + | hasher.update(ephemeral_public); | |
| 203 | + | hasher.update(recipient_public); | |
| 204 | + | hasher.update(shared_secret); | |
| 205 | + | let digest = hasher.finalize(); | |
| 206 | + | let mut key = [0u8; X25519_KEY_LEN]; | |
| 207 | + | key.copy_from_slice(&digest); | |
| 208 | + | ZeroizeOnDrop(key) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// Seal a Group Content Key to a member's public key, producing an opaque | |
| 212 | + | /// base64 grant the admin uploads to the server. | |
| 213 | + | /// | |
| 214 | + | /// `group_id` and `gck_version` are bound as associated data (not secret; they | |
| 215 | + | /// scope the grant). One fresh ephemeral key is used per call. | |
| 216 | + | pub fn seal_gck_to_member( | |
| 217 | + | gck: &[u8; X25519_KEY_LEN], | |
| 218 | + | member_public: &IdentityPublicKey, | |
| 219 | + | group_id: &str, | |
| 220 | + | gck_version: i32, | |
| 221 | + | ) -> Result<String> { | |
| 222 | + | let aad = grant_aad(group_id, gck_version)?; | |
| 223 | + | ||
| 224 | + | let mut ephemeral_scalar = [0u8; X25519_KEY_LEN]; | |
| 225 | + | rand::rng().fill_bytes(&mut ephemeral_scalar); | |
| 226 | + | let ephemeral_secret = StaticSecret::from(ephemeral_scalar); | |
| 227 | + | ephemeral_scalar.zeroize(); | |
| 228 | + | let ephemeral_public = PublicKey::from(&ephemeral_secret); | |
| 229 | + | ||
| 230 | + | let recipient_public = PublicKey::from(*member_public.as_bytes()); | |
| 231 | + | let shared = ephemeral_secret.diffie_hellman(&recipient_public); | |
| 232 | + | let key = derive_grant_key( | |
| 233 | + | shared.as_bytes(), | |
| 234 | + | ephemeral_public.as_bytes(), | |
| 235 | + | member_public.as_bytes(), | |
| 236 | + | ); | |
| 237 | + | ||
| 238 | + | let sealed = crypto::seal(gck, &key.0, &aad)?; | |
| 239 | + | ||
| 240 | + | let mut grant = Vec::with_capacity(X25519_KEY_LEN + sealed.len()); | |
| 241 | + | grant.extend_from_slice(ephemeral_public.as_bytes()); | |
| 242 | + | grant.extend_from_slice(&sealed); | |
| 243 | + | Ok(B64.encode(grant)) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Open a grant sealed to this keypair, recovering the Group Content Key. | |
| 247 | + | /// | |
| 248 | + | /// `group_id` and `gck_version` must match what the grant was sealed under (they | |
| 249 | + | /// are authenticated as associated data). A grant sealed to a different member, a | |
| 250 | + | /// tampered grant, or a mismatched group/version all fail closed. | |
| 251 | + | pub fn open_gck_grant( | |
| 252 | + | grant: &str, | |
| 253 | + | recipient: &IdentityKeypair, | |
| 254 | + | group_id: &str, | |
| 255 | + | gck_version: i32, | |
| 256 | + | ) -> Result<[u8; X25519_KEY_LEN]> { | |
| 257 | + | let aad = grant_aad(group_id, gck_version)?; | |
| 258 | + | ||
| 259 | + | let raw = B64.decode(grant)?; | |
| 260 | + | if raw.len() < X25519_KEY_LEN + ENCRYPTION_OVERHEAD { | |
| 261 | + | return Err(SyncKitError::Crypto("grant too short".into())); | |
| 262 | + | } | |
| 263 | + | let (ephemeral_bytes, sealed) = raw.split_at(X25519_KEY_LEN); | |
| 264 | + | let mut ephemeral_public = [0u8; X25519_KEY_LEN]; | |
| 265 | + | ephemeral_public.copy_from_slice(ephemeral_bytes); | |
| 266 | + | ||
| 267 | + | let shared = recipient | |
| 268 | + | .secret | |
| 269 | + | .diffie_hellman(&PublicKey::from(ephemeral_public)); | |
| 270 | + | let recipient_public = recipient.public_bytes(); | |
| 271 | + | let key = derive_grant_key(shared.as_bytes(), &ephemeral_public, &recipient_public); | |
| 272 | + | ||
| 273 | + | let mut plaintext = crypto::open(sealed, &key.0, &aad)?; | |
| 274 | + | if plaintext.len() != X25519_KEY_LEN { | |
| 275 | + | plaintext.zeroize(); | |
| 276 | + | return Err(SyncKitError::InvalidEnvelope( | |
| 277 | + | "grant plaintext has wrong length".into(), | |
| 278 | + | )); | |
| 279 | + | } | |
| 280 | + | let mut gck = [0u8; X25519_KEY_LEN]; | |
| 281 | + | gck.copy_from_slice(&plaintext); | |
| 282 | + | plaintext.zeroize(); | |
| 283 | + | Ok(gck) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | #[cfg(test)] | |
| 287 | + | mod tests { | |
| 288 | + | use super::*; | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn generate_produces_distinct_keypairs() { | |
| 292 | + | let a = IdentityKeypair::generate(); | |
| 293 | + | let b = IdentityKeypair::generate(); | |
| 294 | + | assert_ne!( | |
| 295 | + | a.public_key(), | |
| 296 | + | b.public_key(), | |
| 297 | + | "two generated identities must differ" | |
| 298 | + | ); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | #[test] | |
| 302 | + | fn public_key_base64_roundtrip() { | |
| 303 | + | let kp = IdentityKeypair::generate(); | |
| 304 | + | let pk = kp.public_key(); | |
| 305 | + | let restored = IdentityPublicKey::from_base64(&pk.to_base64()).unwrap(); | |
| 306 | + | assert_eq!(pk, restored); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | #[test] | |
| 310 | + | fn public_key_from_base64_rejects_wrong_length() { | |
| 311 | + | let short = B64.encode([0u8; 16]); | |
| 312 | + | assert!(matches!( | |
| 313 | + | IdentityPublicKey::from_base64(&short), | |
| 314 | + | Err(SyncKitError::InvalidArgument(_)) | |
| 315 | + | )); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn wrap_unwrap_secret_roundtrip_preserves_identity() { | |
| 320 | + | let master_key = crypto::generate_master_key(); | |
| 321 | + | let kp = IdentityKeypair::generate(); | |
| 322 | + | let wrapped = kp.wrap_secret(&master_key).unwrap(); | |
| 323 | + | ||
| 324 | + | let recovered = IdentityKeypair::unwrap_secret(&wrapped, &master_key).unwrap(); | |
| 325 | + | // Same public key means the same secret scalar round-tripped. | |
| 326 | + | assert_eq!(kp.public_key(), recovered.public_key()); | |
| 327 | + | ||
| 328 | + | // And the recovered keypair can open a grant sealed to the original. | |
| 329 | + | let gck = generate_group_key(); | |
| 330 | + | let grant = seal_gck_to_member(&gck, &kp.public_key(), "g1", 1).unwrap(); | |
| 331 | + | assert_eq!(open_gck_grant(&grant, &recovered, "g1", 1).unwrap(), gck); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn unwrap_secret_with_wrong_master_key_fails() { | |
| 336 | + | let master_key = crypto::generate_master_key(); | |
| 337 | + | let wrong_key = crypto::generate_master_key(); | |
| 338 | + | let kp = IdentityKeypair::generate(); | |
| 339 | + | let wrapped = kp.wrap_secret(&master_key).unwrap(); | |
| 340 | + | assert!(IdentityKeypair::unwrap_secret(&wrapped, &wrong_key).is_err()); | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | #[test] | |
| 344 | + | fn grant_roundtrip_recovers_gck() { | |
| 345 | + | let member = IdentityKeypair::generate(); | |
| 346 | + | let gck = generate_group_key(); | |
| 347 | + | let grant = seal_gck_to_member(&gck, &member.public_key(), "group-1", 1).unwrap(); | |
| 348 | + | let opened = open_gck_grant(&grant, &member, "group-1", 1).unwrap(); | |
| 349 | + | assert_eq!(opened, gck); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn add_member_flow_every_member_recovers_same_gck() { | |
| 354 | + | // The admin mints one GCK and seals a grant to each member's pubkey. | |
| 355 | + | let gck = generate_group_key(); | |
| 356 | + | let members: Vec<IdentityKeypair> = (0..3).map(|_| IdentityKeypair::generate()).collect(); | |
| 357 | + | ||
| 358 | + | for member in &members { | |
| 359 | + | let grant = seal_gck_to_member(&gck, &member.public_key(), "team", 1).unwrap(); | |
| 360 | + | assert_eq!(open_gck_grant(&grant, member, "team", 1).unwrap(), gck); | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | #[test] | |
| 365 | + | fn each_seal_uses_a_fresh_ephemeral_key() { | |
| 366 | + | // Two grants of the same GCK to the same member must differ (fresh | |
| 367 | + | // ephemeral + fresh nonce each time), yet both open to the GCK. | |
| 368 | + | let member = IdentityKeypair::generate(); | |
| 369 | + | let gck = generate_group_key(); | |
| 370 | + | let g1 = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); | |
| 371 | + | let g2 = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); | |
| 372 | + | assert_ne!(g1, g2, "grants must not be deterministic"); | |
| 373 | + | assert_eq!(open_gck_grant(&g1, &member, "g", 1).unwrap(), gck); | |
| 374 | + | assert_eq!(open_gck_grant(&g2, &member, "g", 1).unwrap(), gck); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[test] | |
| 378 | + | fn grant_for_another_member_cannot_be_opened() { | |
| 379 | + | let alice = IdentityKeypair::generate(); | |
| 380 | + | let mallory = IdentityKeypair::generate(); | |
| 381 | + | let gck = generate_group_key(); | |
| 382 | + | let grant = seal_gck_to_member(&gck, &alice.public_key(), "g", 1).unwrap(); | |
| 383 | + | // Mallory holds a valid identity but was not the sealing target. | |
| 384 | + | assert!(matches!( | |
| 385 | + | open_gck_grant(&grant, &mallory, "g", 1), | |
| 386 | + | Err(SyncKitError::DecryptionFailed) | |
| 387 | + | )); | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | #[test] | |
| 391 | + | fn grant_rejects_mismatched_group_or_version() { | |
| 392 | + | let member = IdentityKeypair::generate(); | |
| 393 | + | let gck = generate_group_key(); | |
| 394 | + | let grant = seal_gck_to_member(&gck, &member.public_key(), "group-a", 1).unwrap(); | |
| 395 | + | // Right key, wrong AAD context: the tag fails to authenticate. | |
| 396 | + | assert!(matches!( | |
| 397 | + | open_gck_grant(&grant, &member, "group-b", 1), | |
| 398 | + | Err(SyncKitError::DecryptionFailed) | |
| 399 | + | )); | |
| 400 | + | assert!(matches!( | |
| 401 | + | open_gck_grant(&grant, &member, "group-a", 2), | |
| 402 | + | Err(SyncKitError::DecryptionFailed) | |
| 403 | + | )); | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | #[test] | |
| 407 | + | fn tampered_grant_fails_closed() { | |
| 408 | + | let member = IdentityKeypair::generate(); | |
| 409 | + | let gck = generate_group_key(); | |
| 410 | + | let grant = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); | |
| 411 | + | ||
| 412 | + | let mut raw = B64.decode(&grant).unwrap(); | |
| 413 | + | // Flip a byte in the ciphertext region (past the 32-byte ephemeral key | |
| 414 | + | // and 24-byte nonce). | |
| 415 | + | let idx = X25519_KEY_LEN + 24 + 1; | |
| 416 | + | raw[idx] ^= 0xFF; | |
| 417 | + | let tampered = B64.encode(&raw); | |
| 418 | + | ||
| 419 | + | assert!(matches!( | |
| 420 | + | open_gck_grant(&tampered, &member, "g", 1), | |
| 421 | + | Err(SyncKitError::DecryptionFailed) | |
| 422 | + | )); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn tampered_ephemeral_key_fails_closed() { | |
| 427 | + | let member = IdentityKeypair::generate(); | |
| 428 | + | let gck = generate_group_key(); | |
| 429 | + | let grant = seal_gck_to_member(&gck, &member.public_key(), "g", 1).unwrap(); | |
| 430 | + | ||
| 431 | + | let mut raw = B64.decode(&grant).unwrap(); | |
| 432 | + | raw[0] ^= 0xFF; // corrupt the ephemeral public key → wrong derived key | |
| 433 | + | let tampered = B64.encode(&raw); | |
| 434 | + | ||
| 435 | + | assert!(open_gck_grant(&tampered, &member, "g", 1).is_err()); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | #[test] | |
| 439 | + | fn grant_too_short_is_rejected() { | |
| 440 | + | let member = IdentityKeypair::generate(); | |
| 441 | + | let tiny = B64.encode([0u8; X25519_KEY_LEN + 8]); | |
| 442 | + | assert!(matches!( | |
| 443 | + | open_gck_grant(&tiny, &member, "g", 1), | |
| 444 | + | Err(SyncKitError::Crypto(_)) | |
| 445 | + | )); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 449 | + | fn group_id_with_separator_byte_is_rejected() { | |
| 450 | + | let member = IdentityKeypair::generate(); | |
| 451 | + | let gck = generate_group_key(); | |
| 452 | + | let bad_group = "a\u{1f}b"; | |
| 453 | + | assert!(seal_gck_to_member(&gck, &member.public_key(), bad_group, 1).is_err()); | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | #[test] | |
| 457 | + | fn rotation_reseals_to_remaining_members_without_interaction() { | |
| 458 | + | // Simulate member removal: a new GCK generation is sealed to the | |
| 459 | + | // remaining members' stored pubkeys. The removed member's old grant does | |
| 460 | + | // not open the new GCK. | |
| 461 | + | let keep = IdentityKeypair::generate(); | |
| 462 | + | let removed = IdentityKeypair::generate(); | |
| 463 | + | ||
| 464 | + | let gck_v1 = generate_group_key(); | |
| 465 | + | let removed_grant_v1 = seal_gck_to_member(&gck_v1, &removed.public_key(), "g", 1).unwrap(); | |
| 466 | + | assert_eq!( | |
| 467 | + | open_gck_grant(&removed_grant_v1, &removed, "g", 1).unwrap(), | |
| 468 | + | gck_v1 | |
| 469 | + | ); | |
| 470 | + | ||
| 471 | + | // Admin rotates: new GCK, re-sealed only to `keep`, version bumped. | |
| 472 | + | let gck_v2 = generate_group_key(); | |
| 473 | + | assert_ne!(gck_v1, gck_v2); | |
| 474 | + | let keep_grant_v2 = seal_gck_to_member(&gck_v2, &keep.public_key(), "g", 2).unwrap(); | |
| 475 | + | assert_eq!( | |
| 476 | + | open_gck_grant(&keep_grant_v2, &keep, "g", 2).unwrap(), | |
| 477 | + | gck_v2 | |
| 478 | + | ); | |
| 479 | + | ||
| 480 | + | // The removed member has no v2 grant; their v1 grant still only yields the | |
| 481 | + | // old key, which the rotated group no longer uses. | |
| 482 | + | assert_eq!( | |
| 483 | + | open_gck_grant(&removed_grant_v1, &removed, "g", 1).unwrap(), | |
| 484 | + | gck_v1 | |
| 485 | + | ); | |
| 486 | + | } | |
| 487 | + | } |
| @@ -71,6 +71,7 @@ pub mod client; | |||
| 71 | 71 | pub mod conflict; | |
| 72 | 72 | pub mod crypto; | |
| 73 | 73 | pub mod error; | |
| 74 | + | pub mod identity; | |
| 74 | 75 | pub mod ids; | |
| 75 | 76 | pub mod keystore; | |
| 76 | 77 | pub mod oauth; | |
| @@ -92,6 +93,9 @@ pub use conflict::{ | |||
| 92 | 93 | resolve_field_merge, resolve_lww, | |
| 93 | 94 | }; | |
| 94 | 95 | pub use error::{Result, SyncKitError}; | |
| 96 | + | pub use identity::{ | |
| 97 | + | IdentityKeypair, IdentityPublicKey, generate_group_key, open_gck_grant, seal_gck_to_member, | |
| 98 | + | }; | |
| 95 | 99 | pub use ids::{AppId, DeviceId, UserId}; | |
| 96 | 100 | pub use oauth::{Pkce, generate_oauth_state, generate_pkce, states_match}; | |
| 97 | 101 | #[cfg(feature = "store")] |