Skip to main content

max / makenotwork

synckit: add group identity keys and GCK grants (Groups phase 1) The asymmetric layer for shared, end-to-end-encrypted group changelogs. Everything else in the crate is symmetric; groups need a shared Group Content Key (GCK) handed to members who share no prior secret with the admin, which is public-key crypto. - identity.rs: per-user X25519 IdentityKeypair (wrap/unwrap the secret under the existing master_key), IdentityPublicKey (base64 sharing), generate_group_key(), and seal_gck_to_member/open_gck_grant. Grants are ephemeral-static ECDH + SHA-256 KDF + the existing XChaCha20-Poly1305; the KDF binds both public keys and the AEAD binds (group_id, gck_version). - crypto.rs: AeadContext::GroupEntry { group_id, table, row_id } so group changelog entries are position-bound under the GCK and cannot be relocated across groups; seal/open exposed pub(crate) for reuse. - x25519-dalek is the only new dep: pure-Rust, curve op only, no sealed-box crate. Cargo.lock also reconciles the pre-existing rustls-no-provider reqwest config that had gone stale in the committed lock. Design: wiki synckit-groups-design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 17:48 UTC
Commit: 7b4d16d8e8b4c6371fd4fe19c641b91e708e3a70
Parent: 8fc4b75
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")]