From 39cc5d2e7c5067d9fa5b0f51df7ba34426a54e91 Mon Sep 17 00:00:00 2001 From: anoracleofra-code Date: Thu, 26 Mar 2026 17:48:01 -0600 Subject: [PATCH] fix: compile privacy-core Rust library in Docker backend image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MLS gate encryption system requires libprivacy_core.so — a Rust shared library that was only compiled locally on the dev machine. Docker users got "active gate identity is not mapped into the MLS group" because the library was never built or included in the image. Add a multi-stage Docker build: - Stage 1: rust:1.87-slim-bookworm compiles privacy-core to .so - Stage 2: copies libprivacy_core.so into the Python backend image - Set PRIVACY_CORE_LIB env var so Python finds the library Also track the privacy-core Rust source (Cargo.toml, Cargo.lock, src/lib.rs) in git — they were previously untracked, which is why the Docker build never had access to them. Add root .dockerignore to exclude build caches and large directories from the Docker build context. --- .dockerignore | 23 + backend/Dockerfile | 16 + privacy-core/Cargo.lock | 1142 ++++++++++++++++++++++++++++++ privacy-core/Cargo.toml | 17 + privacy-core/src/lib.rs | 1447 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 2645 insertions(+) create mode 100644 .dockerignore create mode 100644 privacy-core/Cargo.lock create mode 100644 privacy-core/Cargo.toml create mode 100644 privacy-core/src/lib.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b39f1f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Exclude build artifacts, caches, and large directories from Docker context +.git/ +.git_backup/ +node_modules/ +.next/ +__pycache__/ +*.pyc +venv/ +.venv/ +.ruff_cache/ + +# privacy-core build caches (source is needed, artifacts are not) +privacy-core/target/ +privacy-core/target-test/ +privacy-core/.codex-tmp/ + +# Large data/cache files +*.db +*.sqlite +*.xlsx +*.log +extra/ +prototype/ diff --git a/backend/Dockerfile b/backend/Dockerfile index ae724f4..68f473f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,3 +1,16 @@ +# ---- Stage 1: Compile privacy-core Rust library ---- +FROM rust:1.87-slim-bookworm AS rust-builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY privacy-core /build/privacy-core +WORKDIR /build/privacy-core +RUN cargo build --release --lib \ + && ls -la target/release/libprivacy_core.so + +# ---- Stage 2: Python backend ---- FROM python:3.11-slim-bookworm WORKDIR /app @@ -35,6 +48,9 @@ RUN npm ci --omit=dev # Clean up workspace scaffold RUN rm -rf /workspace +# Copy compiled privacy-core library from Rust builder stage +COPY --from=rust-builder /build/privacy-core/target/release/libprivacy_core.so /app/libprivacy_core.so +ENV PRIVACY_CORE_LIB=/app/libprivacy_core.so # Create a non-root user for security # Grant write access to /app so the auto-updater can extract files diff --git a/privacy-core/Cargo.lock b/privacy-core/Cargo.lock new file mode 100644 index 0000000..15459eb --- /dev/null +++ b/privacy-core/Cargo.lock @@ -0,0 +1,1142 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "debug_tree" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1ec383f2d844902d3c34e4253ba11ae48513cdaddc565cf1a6518db09a8e57" +dependencies = [ + "once_cell", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid 0.9.6", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mls-rs" +version = "0.54.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "async-trait", + "cfg-if", + "debug_tree", + "futures", + "getrandom", + "hex", + "itertools", + "maybe-async", + "mls-rs-codec", + "mls-rs-core", + "mls-rs-identity-x509", + "portable-atomic", + "portable-atomic-util", + "rand_core", + "serde", + "spin", + "subtle", + "thiserror", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "mls-rs-codec" +version = "0.7.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "itertools", + "mls-rs-codec-derive", + "thiserror", + "wasm-bindgen", +] + +[[package]] +name = "mls-rs-codec-derive" +version = "0.2.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mls-rs-core" +version = "0.26.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "async-trait", + "hex", + "maybe-async", + "mls-rs-codec", + "serde", + "thiserror", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "mls-rs-crypto-hpke" +version = "0.20.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "async-trait", + "cfg-if", + "maybe-async", + "mls-rs-core", + "mls-rs-crypto-traits", + "thiserror", + "zeroize", +] + +[[package]] +name = "mls-rs-crypto-rustcrypto" +version = "0.21.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "aead", + "aes-gcm", + "async-trait", + "chacha20poly1305", + "ed25519-dalek", + "generic-array", + "getrandom", + "hkdf", + "hmac", + "maybe-async", + "mls-rs-core", + "mls-rs-crypto-hpke", + "mls-rs-crypto-traits", + "p256", + "p384", + "rand_core", + "sec1 0.8.0", + "sha2", + "thiserror", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "mls-rs-crypto-traits" +version = "0.21.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "async-trait", + "maybe-async", + "mls-rs-core", + "zeroize", +] + +[[package]] +name = "mls-rs-identity-x509" +version = "0.20.0" +source = "git+https://github.com/awslabs/mls-rs?rev=027d9051437f88b81f4214c5a0a3a8fd7bbb8501#027d9051437f88b81f4214c5a0a3a8fd7bbb8501" +dependencies = [ + "async-trait", + "maybe-async", + "mls-rs-core", + "thiserror", + "wasm-bindgen", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "critical-section", +] + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "privacy-core" +version = "0.1.0" +dependencies = [ + "mls-rs", + "mls-rs-crypto-rustcrypto", + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46b9a5ab87780a3189a1d704766579517a04ad59de653b7aad7d38e8a15f7dc" +dependencies = [ + "der 0.8.0", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/privacy-core/Cargo.toml b/privacy-core/Cargo.toml new file mode 100644 index 0000000..9837d7e --- /dev/null +++ b/privacy-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "privacy-core" +version = "0.1.0" +edition = "2021" +description = "Rust privacy core for ShadowBroker / Infonet private messaging primitives" +license = "MIT" +publish = false + +[lib] +name = "privacy_core" +crate-type = ["cdylib", "rlib"] + +[dependencies] +mls-rs = { git = "https://github.com/awslabs/mls-rs", rev = "027d9051437f88b81f4214c5a0a3a8fd7bbb8501", package = "mls-rs", default-features = false, features = ["std", "private_message"] } +mls-rs-crypto-rustcrypto = { git = "https://github.com/awslabs/mls-rs", rev = "027d9051437f88b81f4214c5a0a3a8fd7bbb8501", package = "mls-rs-crypto-rustcrypto", default-features = false, features = ["std"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/privacy-core/src/lib.rs b/privacy-core/src/lib.rs new file mode 100644 index 0000000..10df6dc --- /dev/null +++ b/privacy-core/src/lib.rs @@ -0,0 +1,1447 @@ +//! Privacy Core skeleton for the ShadowBroker / Infonet migration. +//! +//! Sprint 1 scope is intentionally narrow: +//! - keep private protocol state opaque to Python +//! - expose only handle-based FFI +//! - prove the repo has a single Rust home for MLS group operations +//! - use in-memory provider/storage only for now +//! +//! This crate follows the architecture docs in `extra/docs-internal/` and keeps +//! group/session state on the Rust side. Persistent storage is deferred to a +//! later sprint. + +use std::collections::{hash_map::DefaultHasher, HashMap, VecDeque}; +use std::hash::{Hash, Hasher}; +use std::panic::{self, AssertUnwindSafe}; +use std::slice; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use mls_rs::client_builder::{BaseConfig, WithCryptoProvider, WithIdentityProvider}; +use mls_rs::group::{Group, ReceivedMessage}; +use mls_rs::identity::{ + basic::{BasicCredential, BasicIdentityProvider}, + SigningIdentity, +}; +use mls_rs::mls_rs_codec::{MlsDecode, MlsEncode}; +use mls_rs::{CipherSuite, CipherSuiteProvider, Client, CryptoProvider, ExtensionList, MlsMessage}; +use mls_rs_crypto_rustcrypto::RustCryptoProvider; +use serde::Serialize; + +type IdentityHandle = u64; +type KeyPackageHandle = u64; +type GroupHandle = u64; +type CommitHandle = u64; +type DMSessionHandle = u64; +type FamilyId = u64; +type MemberRef = u32; + +type PrivacyConfig = + WithIdentityProvider>; +type PrivacyClient = Client; +type PrivacyGroup = Group; + +const CIPHER_SUITE: CipherSuite = CipherSuite::CURVE25519_AES128; +const VERSION: &str = concat!("privacy-core/", env!("CARGO_PKG_VERSION")); +const MAX_KEY_PACKAGE_SIZE: usize = 65_536; +const MAX_DM_PLAINTEXT_SIZE: usize = 65_536; +const MAX_GROUP_PLAINTEXT_SIZE: usize = 65_536; +const MAX_IDENTITIES: usize = 1_024; +const MAX_GROUPS: usize = 512; +const MAX_DM_SESSIONS: usize = 512; +const MAX_PENDING_DM_OUTPUTS: usize = 256; +const PENDING_DM_OUTPUT_TTL: Duration = Duration::from_secs(10); + +#[repr(C)] +pub struct ByteBuffer { + pub data: *mut u8, + pub len: usize, +} + +impl ByteBuffer { + fn empty() -> Self { + Self { + data: std::ptr::null_mut(), + len: 0, + } + } +} + +#[derive(Clone)] +struct IdentityState { + client: PrivacyClient, + signing_identity: SigningIdentity, + label: Vec, +} + +#[derive(Clone)] +struct KeyPackageState { + message: MlsMessage, + owner_identity: Option, +} + +struct GroupState { + family_id: FamilyId, + owner_identity: IdentityHandle, + group: PrivacyGroup, +} + +struct CommitState { + family_id: FamilyId, + commit_message: Vec, + welcome_messages: Vec>, + joined_group_handles: Vec, +} + +struct DMSessionState { + owner_identity: IdentityHandle, + group: PrivacyGroup, + welcome_message: Vec, +} + +#[derive(Serialize)] +struct PublicBundle { + label: String, + cipher_suite: &'static str, + signing_public_key: Vec, + credential: Vec, +} + +#[derive(Serialize)] +struct HandleStats { + identities: usize, + groups: usize, + dm_sessions: usize, + max_identities: usize, + max_groups: usize, + max_dm_sessions: usize, +} + +// Monotonic counter starting at 1. Handle 0 is the FFI error sentinel. +// Wraparound at 2^64 is not handled and is assumed unreachable in practice. +static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); +static NEXT_FAMILY_ID: AtomicU64 = AtomicU64::new(1); +static LAST_ERROR: OnceLock> = OnceLock::new(); +static IDENTITIES: OnceLock>> = OnceLock::new(); +static KEY_PACKAGES: OnceLock>> = OnceLock::new(); +static GROUPS: OnceLock>> = OnceLock::new(); +static COMMITS: OnceLock>> = OnceLock::new(); +static DM_SESSIONS: OnceLock>> = OnceLock::new(); +static FAMILIES: OnceLock>>> = OnceLock::new(); +static EXPORTED_KEY_PACKAGES: OnceLock, IdentityHandle>>> = OnceLock::new(); +static PENDING_DM_OUTPUTS: OnceLock, Instant)>>> = + OnceLock::new(); +static PENDING_DM_OUTPUT_LOOKUPS: OnceLock< + Mutex>>, +> = OnceLock::new(); +static PENDING_DM_OUTPUT_COUNTERS: OnceLock>> = OnceLock::new(); + +fn identities() -> &'static Mutex> { + IDENTITIES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn key_packages() -> &'static Mutex> { + KEY_PACKAGES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn groups() -> &'static Mutex> { + GROUPS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn commits() -> &'static Mutex> { + COMMITS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn dm_sessions() -> &'static Mutex> { + DM_SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn families() -> &'static Mutex>> { + FAMILIES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn exported_key_packages() -> &'static Mutex, IdentityHandle>> { + EXPORTED_KEY_PACKAGES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn pending_dm_outputs() -> &'static Mutex, Instant)>> { + PENDING_DM_OUTPUTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn pending_dm_output_lookups() -> &'static Mutex>> { + PENDING_DM_OUTPUT_LOOKUPS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn pending_dm_output_counters() -> &'static Mutex> { + PENDING_DM_OUTPUT_COUNTERS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn last_error() -> &'static Mutex { + LAST_ERROR.get_or_init(|| Mutex::new(String::new())) +} + +fn next_handle() -> u64 { + NEXT_HANDLE.fetch_add(1, Ordering::Relaxed) +} + +fn next_family_id() -> u64 { + NEXT_FAMILY_ID.fetch_add(1, Ordering::Relaxed) +} + +fn set_last_error(message: impl Into) { + *last_error().lock().expect("last error mutex poisoned") = message.into(); +} + +fn clear_last_error() { + set_last_error(""); +} + +fn wipe_bytes(bytes: &mut [u8]) { + bytes.fill(0); +} + +fn wipe_vec(bytes: &mut Vec) { + if !bytes.is_empty() { + wipe_bytes(bytes.as_mut_slice()); + } +} + +fn to_buffer(mut bytes: Vec) -> ByteBuffer { + if bytes.is_empty() { + return ByteBuffer::empty(); + } + let len = bytes.len(); + let ptr = bytes.as_mut_ptr(); + std::mem::forget(bytes); + ByteBuffer { data: ptr, len } +} + +fn from_buffer(buffer: ByteBuffer) { + if buffer.data.is_null() || buffer.len == 0 { + return; + } + unsafe { + let mut bytes = Vec::from_raw_parts(buffer.data, buffer.len, buffer.len); + wipe_vec(&mut bytes); + } +} + +fn bytes_from_raw<'a>(ptr: *const u8, len: usize) -> Result<&'a [u8], String> { + // SAFETY: len is checked before ptr is dereferenced. Do not reorder these checks. + if len == 0 { + return Ok(&[]); + } + if ptr.is_null() { + return Err("received null pointer for non-empty buffer".to_string()); + } + Ok(unsafe { slice::from_raw_parts(ptr, len) }) +} + +fn map_err(err: E) -> String { + err.to_string() +} + +fn make_client(label: &[u8]) -> Result<(PrivacyClient, SigningIdentity), String> { + let crypto_provider = RustCryptoProvider::default(); + let cipher_suite_provider = crypto_provider + .cipher_suite_provider(CIPHER_SUITE) + .ok_or_else(|| "cipher suite is not supported by RustCrypto provider".to_string())?; + let (secret, public) = cipher_suite_provider + .signature_key_generate() + .map_err(map_err)?; + let credential = BasicCredential::new(label.to_vec()); + let signing_identity = SigningIdentity::new(credential.into_credential(), public); + let client = Client::builder() + .identity_provider(BasicIdentityProvider::new()) + .crypto_provider(crypto_provider) + .signing_identity(signing_identity.clone(), secret, CIPHER_SUITE) + .build(); + Ok((client, signing_identity)) +} + +fn family_handles(family_id: FamilyId) -> Vec { + families() + .lock() + .expect("families mutex poisoned") + .get(&family_id) + .cloned() + .unwrap_or_default() +} + +fn register_group_handle( + family_id: FamilyId, + owner_identity: IdentityHandle, + group: PrivacyGroup, +) -> Result { + let handle = next_handle(); + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + if groups_guard.len() >= MAX_GROUPS && !groups_guard.contains_key(&handle) { + return Err("maximum group limit reached".to_string()); + } + groups_guard.insert( + handle, + GroupState { + family_id, + owner_identity, + group, + }, + ); + drop(groups_guard); + families() + .lock() + .expect("families mutex poisoned") + .entry(family_id) + .or_default() + .push(handle); + Ok(handle) +} + +fn process_commit_for_family( + family_id: FamilyId, + commit_message: &MlsMessage, + actor_handle: GroupHandle, + skip_handles: &[GroupHandle], +) -> Result<(), String> { + let handles = family_handles(family_id); + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + for handle in handles { + if handle == actor_handle || skip_handles.contains(&handle) { + continue; + } + if let Some(state) = groups_guard.get_mut(&handle) { + state + .group + .process_incoming_message(commit_message.clone()) + .map_err(map_err)?; + } + } + Ok(()) +} + +fn remove_group_handles(handles_to_remove: &[GroupHandle]) { + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + let mut families_guard = families().lock().expect("families mutex poisoned"); + for handle in handles_to_remove { + if let Some(state) = groups_guard.remove(handle) { + if let Some(entries) = families_guard.get_mut(&state.family_id) { + entries.retain(|candidate| candidate != handle); + } + } + } +} + +pub fn create_identity() -> Result { + let handle = next_handle(); + let label = format!("identity-{handle}").into_bytes(); + let (client, signing_identity) = make_client(&label)?; + let mut guard = identities().lock().expect("identities mutex poisoned"); + if guard.len() >= MAX_IDENTITIES { + return Err("identity limit reached".to_string()); + } + guard.insert( + handle, + IdentityState { + client, + signing_identity, + label, + }, + ); + Ok(handle) +} + +pub fn export_key_package(identity: IdentityHandle) -> Result, String> { + let identities_guard = identities().lock().expect("identities mutex poisoned"); + let identity_state = identities_guard + .get(&identity) + .ok_or_else(|| format!("unknown identity handle: {identity}"))?; + let message = identity_state + .client + .generate_key_package_message(Default::default(), Default::default(), None) + .map_err(map_err)?; + let bytes = message.mls_encode_to_vec().map_err(map_err)?; + drop(identities_guard); + exported_key_packages() + .lock() + .expect("key package export mutex poisoned") + .insert(bytes.clone(), identity); + Ok(bytes) +} + +pub fn import_key_package(data: &[u8]) -> Result { + if data.len() > MAX_KEY_PACKAGE_SIZE { + return Err(format!( + "key package exceeds maximum size: {} > {} bytes", + data.len(), + MAX_KEY_PACKAGE_SIZE + )); + } + let mut cursor = data; + let message = MlsMessage::mls_decode(&mut cursor).map_err(map_err)?; + let owner_identity = exported_key_packages() + .lock() + .expect("key package export mutex poisoned") + .get(data) + .copied(); + let handle = next_handle(); + key_packages() + .lock() + .expect("key packages mutex poisoned") + .insert( + handle, + KeyPackageState { + message, + owner_identity, + }, + ); + Ok(handle) +} + +pub fn create_group(creator: IdentityHandle) -> Result { + let identities_guard = identities().lock().expect("identities mutex poisoned"); + let identity_state = identities_guard + .get(&creator) + .ok_or_else(|| format!("unknown identity handle: {creator}"))?; + let group = identity_state + .client + .create_group(ExtensionList::default(), Default::default(), None) + .map_err(map_err)?; + drop(identities_guard); + let family_id = next_family_id(); + let handle = next_handle(); + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + if groups_guard.len() >= MAX_GROUPS { + return Err("group limit reached".to_string()); + } + groups_guard.insert( + handle, + GroupState { + family_id, + owner_identity: creator, + group, + }, + ); + drop(groups_guard); + families() + .lock() + .expect("families mutex poisoned") + .entry(family_id) + .or_default() + .push(handle); + Ok(handle) +} + +pub fn add_member(group_handle: GroupHandle, key_package: KeyPackageHandle) -> Result { + let package_state = key_packages() + .lock() + .expect("key packages mutex poisoned") + .get(&key_package) + .cloned() + .ok_or_else(|| format!("unknown key package handle: {key_package}"))?; + + let family_id = { + let groups_guard = groups().lock().expect("groups mutex poisoned"); + groups_guard + .get(&group_handle) + .map(|state| state.family_id) + .ok_or_else(|| format!("unknown group handle: {group_handle}"))? + }; + + let commit_output = { + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + let group_state = groups_guard + .get_mut(&group_handle) + .ok_or_else(|| format!("unknown group handle: {group_handle}"))?; + let output = group_state + .group + .commit_builder() + .add_member(package_state.message.clone()) + .map_err(map_err)? + .build() + .map_err(map_err)?; + group_state.group.apply_pending_commit().map_err(map_err)?; + output + }; + + let commit_message = commit_output.commit_message.clone(); + process_commit_for_family(family_id, &commit_message, group_handle, &[])?; + + let welcome = commit_output + .welcome_messages + .first() + .cloned() + .ok_or_else(|| "add_member did not produce a welcome message".to_string())?; + + let joined_group_handles = if let Some(owner_identity) = package_state.owner_identity { + let recipient_client = { + let identities_guard = identities().lock().expect("identities mutex poisoned"); + identities_guard + .get(&owner_identity) + .map(|state| state.client.clone()) + .ok_or_else(|| { + format!( + "missing identity for imported key package owner: {}", + owner_identity + ) + })? + }; + + let (joined_group, _) = recipient_client + .join_group(None, &welcome, None) + .map_err(map_err)?; + + vec![register_group_handle(family_id, owner_identity, joined_group)?] + } else { + Vec::new() + }; + let commit_handle = next_handle(); + commits() + .lock() + .expect("commits mutex poisoned") + .insert( + commit_handle, + CommitState { + family_id, + commit_message: commit_output.commit_message.mls_encode_to_vec().map_err(map_err)?, + welcome_messages: commit_output + .welcome_messages + .iter() + .map(|message| message.mls_encode_to_vec().map_err(map_err)) + .collect::, _>>()?, + joined_group_handles, + }, + ); + Ok(commit_handle) +} + +pub fn remove_member(group_handle: GroupHandle, member_ref: MemberRef) -> Result { + let (family_id, target_signing_identity) = { + let groups_guard = groups().lock().expect("groups mutex poisoned"); + let group_state = groups_guard + .get(&group_handle) + .ok_or_else(|| format!("unknown group handle: {group_handle}"))?; + let member = group_state + .group + .member_at_index(member_ref) + .ok_or_else(|| format!("no member at index {member_ref}"))?; + (group_state.family_id, member.signing_identity) + }; + + let handles_to_remove = { + let groups_guard = groups().lock().expect("groups mutex poisoned"); + family_handles(family_id) + .into_iter() + .filter(|handle| { + groups_guard + .get(handle) + .and_then(|state| state.group.current_member_signing_identity().ok()) + .map(|identity| identity == &target_signing_identity) + .unwrap_or(false) + }) + .collect::>() + }; + + let commit_output = { + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + let group_state = groups_guard + .get_mut(&group_handle) + .ok_or_else(|| format!("unknown group handle: {group_handle}"))?; + let output = group_state + .group + .commit_builder() + .remove_member(member_ref) + .map_err(map_err)? + .build() + .map_err(map_err)?; + group_state.group.apply_pending_commit().map_err(map_err)?; + output + }; + + let commit_message = commit_output.commit_message.clone(); + process_commit_for_family(family_id, &commit_message, group_handle, &handles_to_remove)?; + remove_group_handles(&handles_to_remove); + + let commit_handle = next_handle(); + commits() + .lock() + .expect("commits mutex poisoned") + .insert( + commit_handle, + CommitState { + family_id, + commit_message: commit_output.commit_message.mls_encode_to_vec().map_err(map_err)?, + welcome_messages: commit_output + .welcome_messages + .iter() + .map(|message| message.mls_encode_to_vec().map_err(map_err)) + .collect::, _>>()?, + joined_group_handles: Vec::new(), + }, + ); + Ok(commit_handle) +} + +pub fn encrypt_group_message(group_handle: GroupHandle, plaintext: &[u8]) -> Result, String> { + if plaintext.len() > MAX_GROUP_PLAINTEXT_SIZE { + return Err(format!( + "group plaintext too large: {} bytes (max {})", + plaintext.len(), + MAX_GROUP_PLAINTEXT_SIZE + )); + } + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + let group_state = groups_guard + .get_mut(&group_handle) + .ok_or_else(|| format!("unknown group handle: {group_handle}"))?; + group_state + .group + .encrypt_application_message(plaintext, Vec::new()) + .map_err(map_err)? + .mls_encode_to_vec() + .map_err(map_err) +} + +pub fn decrypt_group_message(group_handle: GroupHandle, ciphertext: &[u8]) -> Result, String> { + let mut cursor = ciphertext; + let message = MlsMessage::mls_decode(&mut cursor).map_err(map_err)?; + let mut groups_guard = groups().lock().expect("groups mutex poisoned"); + let group_state = groups_guard + .get_mut(&group_handle) + .ok_or_else(|| format!("unknown group handle: {group_handle}"))?; + match group_state + .group + .process_incoming_message(message) + .map_err(map_err)? + { + ReceivedMessage::ApplicationMessage(description) => Ok(description.data().to_vec()), + other => Err(format!("expected application message, received {other:?}")), + } +} + +pub fn create_dm_session( + initiator_identity: IdentityHandle, + responder_key_package: KeyPackageHandle, +) -> Result { + let identities_guard = identities().lock().expect("identities mutex poisoned"); + let identity_state = identities_guard + .get(&initiator_identity) + .ok_or_else(|| format!("unknown identity handle: {initiator_identity}"))?; + let initiator_client = identity_state.client.clone(); + drop(identities_guard); + + let package_state = key_packages() + .lock() + .expect("key packages mutex poisoned") + .get(&responder_key_package) + .cloned() + .ok_or_else(|| format!("unknown key package handle: {responder_key_package}"))?; + + let mut group = initiator_client + .create_group(ExtensionList::default(), Default::default(), None) + .map_err(map_err)?; + let output = group + .commit_builder() + .add_member(package_state.message.clone()) + .map_err(map_err)? + .build() + .map_err(map_err)?; + group.apply_pending_commit().map_err(map_err)?; + + let welcome = output + .welcome_messages + .first() + .cloned() + .ok_or_else(|| "dm session creation did not produce a welcome message".to_string())? + .mls_encode_to_vec() + .map_err(map_err)?; + + let handle = next_handle(); + let mut sessions_guard = dm_sessions().lock().expect("dm sessions mutex poisoned"); + if sessions_guard.len() >= MAX_DM_SESSIONS { + return Err("dm session limit reached".to_string()); + } + sessions_guard.insert( + handle, + DMSessionState { + owner_identity: initiator_identity, + group, + welcome_message: welcome, + }, + ); + Ok(handle) +} + +pub fn dm_encrypt(session: DMSessionHandle, plaintext: &[u8]) -> Result, String> { + if plaintext.len() > MAX_DM_PLAINTEXT_SIZE { + return Err("plaintext exceeds maximum size".to_string()); + } + let mut sessions_guard = dm_sessions().lock().expect("dm sessions mutex poisoned"); + let state = sessions_guard + .get_mut(&session) + .ok_or_else(|| format!("unknown dm session handle: {session}"))?; + state + .group + .encrypt_application_message(plaintext, Vec::new()) + .map_err(map_err)? + .mls_encode_to_vec() + .map_err(map_err) +} + +pub fn dm_decrypt(session: DMSessionHandle, ciphertext: &[u8]) -> Result, String> { + let mut cursor = ciphertext; + let message = MlsMessage::mls_decode(&mut cursor).map_err(map_err)?; + let mut sessions_guard = dm_sessions().lock().expect("dm sessions mutex poisoned"); + let state = sessions_guard + .get_mut(&session) + .ok_or_else(|| format!("unknown dm session handle: {session}"))?; + match state + .group + .process_incoming_message(message) + .map_err(map_err)? + { + ReceivedMessage::ApplicationMessage(description) => Ok(description.data().to_vec()), + other => Err(format!("expected application message, received {other:?}")), + } +} + +pub fn dm_session_welcome(session: DMSessionHandle) -> Result, String> { + let sessions_guard = dm_sessions().lock().expect("dm sessions mutex poisoned"); + let state = sessions_guard + .get(&session) + .ok_or_else(|| format!("unknown dm session handle: {session}"))?; + if state.welcome_message.is_empty() { + return Err("dm session does not have a welcome message".to_string()); + } + Ok(state.welcome_message.clone()) +} + +pub fn join_dm_session( + responder_identity: IdentityHandle, + welcome_bytes: &[u8], +) -> Result { + let identities_guard = identities().lock().expect("identities mutex poisoned"); + let identity_state = identities_guard + .get(&responder_identity) + .ok_or_else(|| format!("unknown identity handle: {responder_identity}"))?; + let responder_client = identity_state.client.clone(); + drop(identities_guard); + + let mut cursor = welcome_bytes; + let welcome = MlsMessage::mls_decode(&mut cursor).map_err(map_err)?; + let (group, _) = responder_client.join_group(None, &welcome, None).map_err(map_err)?; + let handle = next_handle(); + let mut sessions_guard = dm_sessions().lock().expect("dm sessions mutex poisoned"); + if sessions_guard.len() >= MAX_DM_SESSIONS { + return Err("dm session limit reached".to_string()); + } + sessions_guard.insert( + handle, + DMSessionState { + owner_identity: responder_identity, + group, + welcome_message: welcome_bytes.to_vec(), + }, + ); + Ok(handle) +} + +pub fn release_dm_session(handle: DMSessionHandle) -> Result { + let Ok(mut sessions_guard) = dm_sessions().lock() else { + return Err("dm sessions mutex poisoned".to_string()); + }; + Ok(if sessions_guard.remove(&handle).is_some() { 1 } else { 0 }) +} + +pub fn export_public_bundle(identity: IdentityHandle) -> Result, String> { + let identities_guard = identities().lock().expect("identities mutex poisoned"); + let state = identities_guard + .get(&identity) + .ok_or_else(|| format!("unknown identity handle: {identity}"))?; + let bundle = PublicBundle { + label: String::from_utf8_lossy(&state.label).to_string(), + cipher_suite: "CURVE25519_AES128", + signing_public_key: state.signing_identity.signature_key.as_bytes().to_vec(), + credential: state + .signing_identity + .credential + .mls_encode_to_vec() + .map_err(map_err)?, + }; + serde_json::to_vec(&bundle).map_err(map_err) +} + +fn handle_stats_json() -> Result, String> { + let stats = HandleStats { + identities: identities().lock().expect("identities mutex poisoned").len(), + groups: groups().lock().expect("groups mutex poisoned").len(), + dm_sessions: dm_sessions().lock().expect("dm sessions mutex poisoned").len(), + max_identities: MAX_IDENTITIES, + max_groups: MAX_GROUPS, + max_dm_sessions: MAX_DM_SESSIONS, + }; + serde_json::to_vec(&stats).map_err(map_err) +} + +fn commit_message_bytes(commit: CommitHandle) -> Result, String> { + let commits_guard = commits().lock().expect("commits mutex poisoned"); + let state = commits_guard + .get(&commit) + .ok_or_else(|| format!("unknown commit handle: {commit}"))?; + Ok(state.commit_message.clone()) +} + +fn commit_welcome_message_bytes(commit: CommitHandle, index: usize) -> Result, String> { + let commits_guard = commits().lock().expect("commits mutex poisoned"); + let state = commits_guard + .get(&commit) + .ok_or_else(|| format!("unknown commit handle: {commit}"))?; + state + .welcome_messages + .get(index) + .cloned() + .ok_or_else(|| format!("no welcome message at index {index}")) +} + +fn commit_joined_group_handle(commit: CommitHandle, index: usize) -> Result { + let commits_guard = commits().lock().expect("commits mutex poisoned"); + let state = commits_guard + .get(&commit) + .ok_or_else(|| format!("unknown commit handle: {commit}"))?; + state + .joined_group_handles + .get(index) + .copied() + .ok_or_else(|| format!("no joined group handle at index {index}")) +} + +fn with_handle_result(operation: F) -> u64 +where + F: FnOnce() -> Result, +{ + clear_last_error(); + match panic::catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(handle)) => handle, + Ok(Err(error)) => { + set_last_error(error); + 0 + } + Err(_) => { + set_last_error("privacy-core panicked across the FFI boundary"); + 0 + } + } +} + +fn with_bool_result(operation: F) -> bool +where + F: FnOnce() -> Result, +{ + clear_last_error(); + match panic::catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + set_last_error(error); + false + } + Err(_) => { + set_last_error("privacy-core panicked across the FFI boundary"); + false + } + } +} + +fn with_bytes_result(operation: F) -> ByteBuffer +where + F: FnOnce() -> Result, String>, +{ + clear_last_error(); + match panic::catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(bytes)) => to_buffer(bytes), + Ok(Err(error)) => { + set_last_error(error); + ByteBuffer::empty() + } + Err(_) => { + set_last_error("privacy-core panicked across the FFI boundary"); + ByteBuffer::empty() + } + } +} + +fn with_i64_result(operation: F) -> i64 +where + F: FnOnce() -> Result, +{ + clear_last_error(); + match panic::catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + set_last_error(error); + -1 + } + Err(_) => { + set_last_error("privacy-core panicked across the FFI boundary"); + -1 + } + } +} + +fn with_i32_result(operation: F) -> i32 +where + F: FnOnce() -> Result, +{ + clear_last_error(); + match panic::catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + set_last_error(error); + 0 + } + Err(_) => { + set_last_error("privacy-core panicked across the FFI boundary"); + 0 + } + } +} + +fn write_to_output_buffer(bytes: &[u8], out_buf: *mut u8, out_cap: usize) -> Result { + let required = i64::try_from(bytes.len()).map_err(|_| "output too large".to_string())?; + if out_buf.is_null() || out_cap == 0 { + return Ok(required); + } + if out_cap < bytes.len() { + return Err(format!( + "output buffer too small: need {} bytes, got {}", + bytes.len(), + out_cap + )); + } + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_buf, bytes.len()); + } + Ok(required) +} + +fn input_hash(bytes: &[u8]) -> u64 { + let mut hasher = DefaultHasher::new(); + bytes.hash(&mut hasher); + hasher.finish() +} + +fn cache_key(session: u64, opcode: u8, counter: u64) -> u64 { + let mut hasher = DefaultHasher::new(); + session.hash(&mut hasher); + opcode.hash(&mut hasher); + counter.hash(&mut hasher); + hasher.finish() +} + +fn next_pending_output_key(opcode: u8, session: u64) -> Result<(u8, u64, u64), String> { + let mut counters = pending_dm_output_counters() + .lock() + .map_err(|_| "pending dm output counters mutex poisoned".to_string())?; + let counter = counters.entry((opcode, session)).or_insert(0); + *counter = counter.saturating_add(1); + Ok((opcode, session, cache_key(session, opcode, *counter))) +} + +fn prune_pending_outputs(now: Instant) { + let mut expired: Vec<(u8, u64, u64)> = Vec::new(); + { + let mut pending = pending_dm_outputs() + .lock() + .expect("pending dm outputs mutex poisoned"); + let expired_keys: Vec<(u8, u64, u64)> = pending + .iter() + .filter_map(|(key, (_bytes, inserted_at))| { + if now.duration_since(*inserted_at) > PENDING_DM_OUTPUT_TTL { + Some(*key) + } else { + None + } + }) + .collect(); + for key in expired_keys { + if let Some((mut bytes, _inserted_at)) = pending.remove(&key) { + wipe_vec(&mut bytes); + } + expired.push(key); + } + } + if expired.is_empty() { + return; + } + let mut lookups = pending_dm_output_lookups() + .lock() + .expect("pending dm output lookup mutex poisoned"); + lookups.retain(|_, queue| { + queue.retain(|key| !expired.contains(key)); + !queue.is_empty() + }); +} + +fn stage_or_write_output( + opcode: u8, + session: u64, + input_fingerprint: u64, + out_buf: *mut u8, + out_cap: usize, + producer: F, +) -> Result +where + F: FnOnce() -> Result, String>, +{ + let now = Instant::now(); + let lookup_key = (opcode, session, input_fingerprint); + if out_buf.is_null() || out_cap == 0 { + let bytes = producer()?; + let required = i64::try_from(bytes.len()).map_err(|_| "output too large".to_string())?; + prune_pending_outputs(now); + let output_key = next_pending_output_key(opcode, session)?; + let mut pending = pending_dm_outputs() + .lock() + .expect("pending dm outputs mutex poisoned"); + if pending.len() >= MAX_PENDING_DM_OUTPUTS { + return Err("pending output buffer full — cannot enqueue".into()); + } + pending.insert(output_key, (bytes, now)); + drop(pending); + pending_dm_output_lookups() + .lock() + .expect("pending dm output lookup mutex poisoned") + .entry(lookup_key) + .or_default() + .push_back(output_key); + return Ok(required); + } + + prune_pending_outputs(now); + let output_key = { + let mut lookups = pending_dm_output_lookups() + .lock() + .expect("pending dm output lookup mutex poisoned"); + let mut remove_lookup = false; + let next = if let Some(queue) = lookups.get_mut(&lookup_key) { + let next = queue.pop_front(); + remove_lookup = queue.is_empty(); + next + } else { + None + }; + if remove_lookup { + lookups.remove(&lookup_key); + } + next + }; + let mut bytes = if let Some(output_key) = output_key { + if let Some((bytes, _inserted_at)) = pending_dm_outputs() + .lock() + .expect("pending dm outputs mutex poisoned") + .remove(&output_key) + { + bytes + } else { + producer()? + } + } else { + producer()? + }; + let written = write_to_output_buffer(&bytes, out_buf, out_cap); + wipe_vec(&mut bytes); + Ok(written?) +} + +#[no_mangle] +pub extern "C" fn privacy_core_version() -> ByteBuffer { + to_buffer(VERSION.as_bytes().to_vec()) +} + +#[no_mangle] +pub extern "C" fn privacy_core_last_error_message() -> ByteBuffer { + let message = last_error().lock().expect("last error mutex poisoned").clone(); + to_buffer(message.into_bytes()) +} + +#[no_mangle] +pub extern "C" fn privacy_core_free_buffer(buffer: ByteBuffer) { + from_buffer(buffer); +} + +#[no_mangle] +pub extern "C" fn privacy_core_create_identity() -> u64 { + with_handle_result(create_identity) +} + +#[no_mangle] +pub extern "C" fn privacy_core_export_key_package(identity: u64) -> ByteBuffer { + with_bytes_result(|| export_key_package(identity)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_import_key_package(data: *const u8, len: usize) -> u64 { + with_handle_result(|| import_key_package(bytes_from_raw(data, len)?)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_create_group(identity: u64) -> u64 { + with_handle_result(|| create_group(identity)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_add_member(group: u64, key_package: u64) -> u64 { + with_handle_result(|| add_member(group, key_package)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_remove_member(group: u64, member_ref: u32) -> u64 { + with_handle_result(|| remove_member(group, member_ref)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_encrypt_group_message( + group: u64, + plaintext: *const u8, + len: usize, +) -> ByteBuffer { + with_bytes_result(|| encrypt_group_message(group, bytes_from_raw(plaintext, len)?)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_decrypt_group_message( + group: u64, + ciphertext: *const u8, + len: usize, +) -> ByteBuffer { + with_bytes_result(|| decrypt_group_message(group, bytes_from_raw(ciphertext, len)?)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_export_public_bundle(identity: u64) -> ByteBuffer { + with_bytes_result(|| export_public_bundle(identity)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_handle_stats(out_buf: *mut u8, out_cap: usize) -> i64 { + with_i64_result(|| stage_or_write_output(4, 0, 0, out_buf, out_cap, handle_stats_json)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_commit_message_bytes(commit: u64) -> ByteBuffer { + with_bytes_result(|| commit_message_bytes(commit)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_commit_welcome_message_bytes(commit: u64, index: usize) -> ByteBuffer { + with_bytes_result(|| commit_welcome_message_bytes(commit, index)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_commit_joined_group_handle(commit: u64, index: usize) -> u64 { + with_handle_result(|| commit_joined_group_handle(commit, index)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_create_dm_session(initiator_identity: u64, responder_key_package: u64) -> i64 { + with_i64_result(|| create_dm_session(initiator_identity, responder_key_package).map(|handle| handle as i64)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_dm_encrypt( + session: u64, + plaintext: *const u8, + len: usize, + out_buf: *mut u8, + out_cap: usize, +) -> i64 { + with_i64_result(|| { + let plaintext = bytes_from_raw(plaintext, len)?; + stage_or_write_output(1, session, input_hash(plaintext), out_buf, out_cap, || { + dm_encrypt(session, plaintext) + }) + }) +} + +#[no_mangle] +pub extern "C" fn privacy_core_dm_decrypt( + session: u64, + ciphertext: *const u8, + len: usize, + out_buf: *mut u8, + out_cap: usize, +) -> i64 { + with_i64_result(|| { + let ciphertext = bytes_from_raw(ciphertext, len)?; + stage_or_write_output(2, session, input_hash(ciphertext), out_buf, out_cap, || { + dm_decrypt(session, ciphertext) + }) + }) +} + +#[no_mangle] +pub extern "C" fn privacy_core_dm_session_welcome( + session: u64, + out_buf: *mut u8, + out_cap: usize, +) -> i64 { + with_i64_result(|| stage_or_write_output(3, session, 0, out_buf, out_cap, || dm_session_welcome(session))) +} + +#[no_mangle] +pub extern "C" fn privacy_core_join_dm_session( + responder_identity: u64, + welcome: *const u8, + len: usize, +) -> i64 { + with_i64_result(|| join_dm_session(responder_identity, bytes_from_raw(welcome, len)?).map(|handle| handle as i64)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_release_dm_session(session: u64) -> i32 { + with_i32_result(|| release_dm_session(session)) +} + +#[no_mangle] +pub extern "C" fn privacy_core_release_identity(handle: u64) -> bool { + with_bool_result(|| { + let Ok(mut guard) = identities().lock() else { + return Err("identities mutex poisoned".to_string()); + }; + Ok(guard.remove(&handle).is_some()) + }) +} + +#[no_mangle] +pub extern "C" fn privacy_core_release_key_package(handle: u64) -> bool { + with_bool_result(|| { + let Ok(mut guard) = key_packages().lock() else { + return Err("key packages mutex poisoned".to_string()); + }; + Ok(guard.remove(&handle).is_some()) + }) +} + +#[no_mangle] +pub extern "C" fn privacy_core_release_group(handle: u64) -> bool { + with_bool_result(|| { + let Ok(mut groups_guard) = groups().lock() else { + return Err("groups mutex poisoned".to_string()); + }; + let removed = groups_guard.remove(&handle); + drop(groups_guard); + + if let Some(state) = removed { + let Ok(mut families_guard) = families().lock() else { + return Err("families mutex poisoned".to_string()); + }; + if let Some(entries) = families_guard.get_mut(&state.family_id) { + entries.retain(|candidate| candidate != &handle); + } + Ok(true) + } else { + Ok(false) + } + }) +} + +#[no_mangle] +pub extern "C" fn privacy_core_release_commit(handle: u64) -> bool { + with_bool_result(|| { + let Ok(mut guard) = commits().lock() else { + return Err("commits mutex poisoned".to_string()); + }; + Ok(guard.remove(&handle).is_some()) + }) +} + +#[no_mangle] +pub extern "C" fn privacy_core_reset_all_state() -> bool { + with_bool_result(|| { + let Ok(mut identities_guard) = identities().lock() else { + return Err("identities mutex poisoned".to_string()); + }; + identities_guard.clear(); + drop(identities_guard); + + let Ok(mut key_packages_guard) = key_packages().lock() else { + return Err("key packages mutex poisoned".to_string()); + }; + key_packages_guard.clear(); + drop(key_packages_guard); + + let Ok(mut groups_guard) = groups().lock() else { + return Err("groups mutex poisoned".to_string()); + }; + groups_guard.clear(); + drop(groups_guard); + + let Ok(mut commits_guard) = commits().lock() else { + return Err("commits mutex poisoned".to_string()); + }; + commits_guard.clear(); + drop(commits_guard); + + let Ok(mut dm_sessions_guard) = dm_sessions().lock() else { + return Err("dm sessions mutex poisoned".to_string()); + }; + dm_sessions_guard.clear(); + drop(dm_sessions_guard); + + let Ok(mut families_guard) = families().lock() else { + return Err("families mutex poisoned".to_string()); + }; + families_guard.clear(); + drop(families_guard); + + let Ok(mut exported_guard) = exported_key_packages().lock() else { + return Err("exported key packages mutex poisoned".to_string()); + }; + exported_guard.clear(); + drop(exported_guard); + + let Ok(mut pending_outputs_guard) = pending_dm_outputs().lock() else { + return Err("pending dm outputs mutex poisoned".to_string()); + }; + for (_key, (mut bytes, _inserted_at)) in pending_outputs_guard.drain() { + wipe_vec(&mut bytes); + } + drop(pending_outputs_guard); + + let Ok(mut pending_lookup_guard) = pending_dm_output_lookups().lock() else { + return Err("pending dm output lookup mutex poisoned".to_string()); + }; + pending_lookup_guard.clear(); + drop(pending_lookup_guard); + + let Ok(mut pending_counter_guard) = pending_dm_output_counters().lock() else { + return Err("pending dm output counters mutex poisoned".to_string()); + }; + pending_counter_guard.clear(); + drop(pending_counter_guard); + + clear_last_error(); + Ok(true) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn test_lock() -> &'static Mutex<()> { + static TEST_LOCK: OnceLock> = OnceLock::new(); + TEST_LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn dm_session_round_trip() { + let _guard = test_lock().lock().expect("test lock poisoned"); + privacy_core_reset_all_state(); + + let alice = create_identity().expect("alice identity"); + let bob = create_identity().expect("bob identity"); + let bob_key_package = export_key_package(bob).expect("bob key package"); + let bob_package_handle = import_key_package(&bob_key_package).expect("import bob key package"); + + let alice_session = create_dm_session(alice, bob_package_handle).expect("alice session"); + let welcome = dm_session_welcome(alice_session).expect("welcome"); + let bob_session = join_dm_session(bob, &welcome).expect("bob session"); + + let ct1 = dm_encrypt(alice_session, b"hello bob").expect("encrypt alice->bob"); + let pt1 = dm_decrypt(bob_session, &ct1).expect("decrypt alice->bob"); + assert_eq!(pt1, b"hello bob"); + + let ct2 = dm_encrypt(bob_session, b"hello alice").expect("encrypt bob->alice"); + let pt2 = dm_decrypt(alice_session, &ct2).expect("decrypt bob->alice"); + assert_eq!(pt2, b"hello alice"); + + assert_eq!(release_dm_session(alice_session).expect("release alice"), 1); + assert_eq!(release_dm_session(bob_session).expect("release bob"), 1); + assert_eq!(release_dm_session(alice_session).expect("release missing"), 0); + } + + #[test] + fn identity_limit_rejects_overflow() { + let _guard = test_lock().lock().expect("test lock poisoned"); + privacy_core_reset_all_state(); + + for _ in 0..MAX_IDENTITIES { + create_identity().expect("identity within limit"); + } + + assert_eq!( + create_identity().expect_err("identity overflow"), + "identity limit reached" + ); + } + + #[test] + fn group_encrypt_rejects_oversized_plaintext() { + let _guard = test_lock().lock().expect("test lock poisoned"); + privacy_core_reset_all_state(); + + let owner = create_identity().expect("owner identity"); + let group = create_group(owner).expect("group"); + + let ok_plaintext = vec![b'a'; 60 * 1024]; + assert!(encrypt_group_message(group, &ok_plaintext).is_ok()); + + let too_large = vec![b'b'; 100 * 1024]; + let err = encrypt_group_message(group, &too_large).expect_err("oversized group plaintext"); + assert!(err.contains("group plaintext too large")); + } + + #[test] + fn add_member_respects_group_limit_when_join_registers_new_handle() { + let _guard = test_lock().lock().expect("test lock poisoned"); + privacy_core_reset_all_state(); + + let owner = create_identity().expect("owner identity"); + let recipient = create_identity().expect("recipient identity"); + let recipient_bundle = export_key_package(recipient).expect("recipient bundle"); + let recipient_package = import_key_package(&recipient_bundle).expect("recipient package"); + + let mut last_group = 0; + for _ in 0..MAX_GROUPS { + last_group = create_group(owner).expect("group within limit"); + } + + let err = add_member(last_group, recipient_package).expect_err("group limit overflow"); + assert_eq!(err, "maximum group limit reached"); + } + + #[test] + fn staged_outputs_keep_sequential_same_session_requests_distinct() { + let _guard = test_lock().lock().expect("test lock poisoned"); + privacy_core_reset_all_state(); + + let first_required = stage_or_write_output(1, 77, 99, std::ptr::null_mut(), 0, || { + Ok(b"first-output".to_vec()) + }) + .expect("stage first"); + let second_required = stage_or_write_output(1, 77, 99, std::ptr::null_mut(), 0, || { + Ok(b"second-output".to_vec()) + }) + .expect("stage second"); + + assert_eq!(first_required, 12); + assert_eq!(second_required, 13); + + let mut first_buf = [0u8; 32]; + let first_written = stage_or_write_output(1, 77, 99, first_buf.as_mut_ptr(), first_buf.len(), || { + Ok(b"unexpected".to_vec()) + }) + .expect("retrieve first"); + assert_eq!(first_written, 12); + assert_eq!(&first_buf[..first_written as usize], b"first-output"); + + let mut second_buf = [0u8; 32]; + let second_written = + stage_or_write_output(1, 77, 99, second_buf.as_mut_ptr(), second_buf.len(), || { + Ok(b"unexpected".to_vec()) + }) + .expect("retrieve second"); + assert_eq!(second_written, 13); + assert_eq!(&second_buf[..second_written as usize], b"second-output"); + } +}