diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2203dae..64e929a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,10 @@ name: CI +# Unless we are on the main branch, the workflow should stop and yield to a new run if new code is pushed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'main')}} + on: push: branches: [main] @@ -17,7 +22,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Rust uses: dtolnay/rust-toolchain@master @@ -25,11 +30,20 @@ jobs: toolchain: 1.92.0 components: clippy,rustfmt + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Check code formatting run: cargo fmt -- --check - - name: Run clippy - run: cargo clippy --workspace --all-targets --all-features + - name: Run clippy (all features) + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Run clippy (default features) + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Run clippy (no default features) + run: cargo clippy --workspace --all-targets --no-default-features -- -D warnings swift-build-and-test: name: Swift Build & Foreign Binding Tests @@ -39,7 +53,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Rust uses: dtolnay/rust-toolchain@master @@ -53,12 +67,55 @@ jobs: with: xcode-version: "16.2" - - name: Run Swift foreign binding tests + # Includes temporary downstream UniFFI callback vtable patch: + # https://github.com/mozilla/uniffi-rs/pull/2821 + - name: Run Swift foreign binding tests (with temporary UniFFI ASan workaround) run: ./swift/test_swift.sh + - name: Install SwiftLint + run: | + brew install swiftlint + + - name: Lint Swift Tests + run: swiftlint swift/tests + + kotlin-build-and-test: + name: Kotlin Build & Foreign Binding Tests + runs-on: arc-public-8xlarge-amd64-runner # uses the same runner as the release workflow to ensure consistency + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.92.0 + + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + + - name: Install ktlint + run: | + curl -sSLO https://github.com/pinterest/ktlint/releases/latest/download/ktlint && + chmod a+x ktlint && + sudo mv ktlint /usr/local/bin/ + + - name: Build and test Kotlin bindings + run: ./kotlin/test_kotlin.sh + + - name: Lint Kotlin Tests + run: | + ktlint kotlin/walletkit-tests/src/test/kotlin + test: name: Tests - runs-on: ubuntu-latest + runs-on: arc-public-xlarge-arm64-runner permissions: contents: read @@ -71,7 +128,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Rust uses: dtolnay/rust-toolchain@master @@ -85,8 +142,10 @@ jobs: env: WORLDCHAIN_SEPOLIA_RPC_URL: ${{ secrets.WORLDCHAIN_SEPOLIA_RPC_URL || 'https://worldchain-sepolia.g.alchemy.com/public' }} WORLDCHAIN_RPC_URL: ${{ secrets.WORLDCHAIN_RPC_URL || 'https://worldchain-mainnet.g.alchemy.com/public' }} + # we don't do --all-features because `compress-zkeys` is very expensive for the CI and doesn't need to be tested on every PR + # we add the remainder of non-default features to include them in tests run: | - cargo test --all --all-features + cargo test --workspace --features walletkit-core/legacy-nullifiers - name: Build non-default features run: | @@ -107,12 +166,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - uses: EmbarkStudios/cargo-deny-action@v2 with: command: check ${{ matrix.checks }} - rust-version: stable + rust-version: 1.92.0 docs: name: Check docs @@ -122,7 +181,7 @@ jobs: env: RUSTDOCFLAGS: -Dwarnings steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/install@cargo-docs-rs - run: | diff --git a/.github/workflows/initiate-release.yml b/.github/workflows/initiate-release.yml index 57ac4a5d9..543fb4d15 100644 --- a/.github/workflows/initiate-release.yml +++ b/.github/workflows/initiate-release.yml @@ -20,7 +20,7 @@ jobs: outputs: new_version: ${{ steps.version.outputs.new_version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: main @@ -29,8 +29,8 @@ jobs: env: BUMP_TYPE: ${{ github.event.inputs.bump_type }} run: | - # Get current version from Cargo.toml - CURRENT_VERSION=$(grep -m 1 'version = ' Cargo.toml | cut -d '"' -f 2) + # Get current version from workspace package in Cargo.toml + CURRENT_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.workspace_members[0]' | cut -d '#' -f2) # Ensure CURRENT_VERSION is in semantic versioning format if [[ ! "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -38,6 +38,8 @@ jobs: exit 1 fi + cargo metadata --no-deps --format-version 1 | jq -r '.workspace_members' + # Split version into components IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d41638ba..d95ec7080 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Get new version id: version @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.pre-release-checks.outputs.commit_sha }} # to ensure all builds are consistent @@ -67,15 +67,17 @@ jobs: targets: aarch64-apple-ios-sim,aarch64-apple-ios,x86_64-apple-ios components: rustfmt - - name: Build the project (iOS) - run: ./build_swift.sh + # Includes temporary downstream UniFFI callback vtable patch: + # https://github.com/mozilla/uniffi-rs/pull/2821 + - name: Build the project (iOS, with temporary UniFFI ASan workaround) + run: ./swift/build_swift.sh - name: Compress XCFramework binary run: | - zip -r WalletKit.xcframework.zip WalletKit.xcframework + zip -r WalletKit.xcframework.zip swift/WalletKit.xcframework - name: Checkout swift repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: worldcoin/walletkit-swift token: ${{ secrets.WALLETKIT_BOT_TOKEN }} @@ -112,11 +114,11 @@ jobs: run: | # Copy non-binary source files - cp -r Sources/ target-repo/Sources + cp -r swift/Sources/ target-repo/Sources # Prepare Package.swift brew install swiftlint - ./archive_swift.sh --asset-url "$ASSET_URL" --checksum "$CHECKSUM" --release-version "$NEW_VERSION" + ./swift/archive_swift.sh --asset-url "$ASSET_URL" --checksum "$CHECKSUM" --release-version "$NEW_VERSION" cp Package.swift target-repo/ # Commit changes @@ -133,8 +135,11 @@ jobs: prepare-kotlin: name: Prepare Kotlin - runs-on: ubuntu-22.04-32core + runs-on: arc-public-8xlarge-amd64-runner needs: [pre-release-checks] + env: + CARGO_HOME: /home/runner/_work/_cargo + RUSTUP_HOME: /home/runner/_work/_rustup permissions: contents: write # to upload artifacts @@ -149,8 +154,11 @@ jobs: - target: i686-linux-android steps: + - name: Add Cargo to PATH + run: echo "$CARGO_HOME/bin" >> $GITHUB_PATH + - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.pre-release-checks.outputs.commit_sha }} # to ensure all builds are consistent @@ -167,7 +175,7 @@ jobs: - name: Build for target run: | - CROSS_NO_WARNINGS=0 cross build -p walletkit --target ${{ matrix.settings.target }} --release --locked + CROSS_NO_WARNINGS=0 cross build -p walletkit --target ${{ matrix.settings.target }} --release --locked --features compress-zkeys - name: Upload artifact uses: actions/upload-artifact@v4 @@ -186,7 +194,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.pre-release-checks.outputs.commit_sha }} # to ensure all builds are consistent @@ -210,7 +218,7 @@ jobs: - name: Move artifacts run: | - mkdir -p kotlin/lib/src/main/jniLibs && cd kotlin/lib/src/main/jniLibs + mkdir -p kotlin/walletkit/src/main/jniLibs && cd kotlin/walletkit/src/main/jniLibs mkdir armeabi-v7a arm64-v8a x86 x86_64 mv /home/runner/work/walletkit/walletkit/android-armv7-linux-androideabi/libwalletkit.so ./armeabi-v7a/libwalletkit.so mv /home/runner/work/walletkit/walletkit/android-aarch64-linux-android/libwalletkit.so ./arm64-v8a/libwalletkit.so @@ -219,11 +227,11 @@ jobs: - name: Generate bindings working-directory: kotlin - run: cargo run -p uniffi-bindgen generate ./lib/src/main/jniLibs/arm64-v8a/libwalletkit.so --library --language kotlin --no-format --out-dir lib/src/main/java + run: cargo run -p uniffi-bindgen generate ./walletkit/src/main/jniLibs/arm64-v8a/libwalletkit.so --library --language kotlin --no-format --out-dir walletkit/src/main/java - name: Publish working-directory: kotlin - run: ./gradlew lib:publish -PversionName=${{ needs.pre-release-checks.outputs.new_version }} + run: ./gradlew walletkit:publish env: GITHUB_ACTOR: wld-walletkit-bot GITHUB_TOKEN: ${{ github.token }} @@ -243,16 +251,13 @@ jobs: make_latest: true - name: Create Release in swift repo - uses: softprops/action-gh-release@v2 - with: - repository: worldcoin/walletkit-swift - token: ${{ secrets.WALLETKIT_BOT_TOKEN }} - name: ${{ needs.pre-release-checks.outputs.new_version }} - tag_name: ${{ needs.pre-release-checks.outputs.new_version }} - body: | - ## Version ${{ needs.pre-release-checks.outputs.new_version }} - For full release notes, see the [main repo release](https://github.com/worldcoin/walletkit/releases/tag/${{ needs.pre-release-checks.outputs.new_version }}). - make_latest: true + env: + GH_TOKEN: ${{ secrets.WALLETKIT_BOT_TOKEN }} + run: | + gh release edit ${{ needs.pre-release-checks.outputs.new_version }} \ + --repo worldcoin/walletkit-swift \ + --draft=false \ + --latest publish-to-crates-io: needs: [pre-release-checks, create-github-release] @@ -264,7 +269,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.pre-release-checks.outputs.commit_sha }} # to ensure all builds are consistent @@ -275,7 +280,7 @@ jobs: - uses: rust-lang/crates-io-auth-action@v1 id: auth - + - name: Publish to crates.io env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/.gitignore b/.gitignore index a59439236..7a07856f4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,16 @@ target/ # Swift build outputs are not committed to this repo. WalletKit.xcframework/ Sources/ - +swift/WalletKit.xcframework/ +swift/Sources/ +swift/ios_build/ +swift/local_build/ +swift/tests/Sources/ +swift/tests/.build/ +# Kotlin bindings and native libs +kotlin/libs/ +kotlin/walletkit/src/main/java/uniffi/ +kotlin/walletkit-tests/build/ .build/ .env @@ -21,4 +30,8 @@ Sources/ cache/ **/out/build-info +# Allow storage cache module sources. +!walletkit-core/src/storage/cache/ +!walletkit-core/src/storage/cache/** + # NOTE: Cargo.lock is not ignored because it is used for FFI builds (Swift & Kotlin) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..de955d6eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# WalletKit Agent Guidelines + +## UniFFI Naming + +Never name a UniFFI-exported method `to_string`. UniFFI maps Rust's `to_string` to Kotlin's `toString`, which conflicts with `Any.toString()` and causes a compilation error (`'toString' hides member of supertype 'Any' and needs 'override' modifier`). Use a descriptive name instead (e.g., `to_hex_string`, `to_decimal_string`, `to_json`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6cafa5c7..d774acbfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,9 +17,9 @@ Thank you for your interest in contributing to our project! This document provid curl -L https://foundry.paradigm.xyz | bash foundryup ``` -3. Run tests to ensure everything is working as expected. It's important to run with `all-features` as integration tests have dependencies on non-default features. +3. Run tests to ensure everything is working as expected. Note: `compress-zkeys` is excluded because ARK point decompression is expensive and only needed for release builds. ```bash - cargo test --all --all-features + cargo test --workspace ``` ## Code of Conduct diff --git a/Cargo.lock b/Cargo.lock index 6a56252c7..0d1d9bb34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,36 +4,46 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -61,9 +71,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e502b004e05578e537ce0284843ba3dfaf6a0d5c530f5c20454411aded561289" +checksum = "4973038846323e4e69a433916522195dce2947770076c03078fc21c80ea0f1c4" dependencies = [ "alloy-consensus", "alloy-contract", @@ -81,9 +91,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.1" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5848366a4f08dca1caca0a6151294a4799fe2e59ba25df100491d92e0b921b1c" +checksum = "90f374d3c6d729268bbe2d0e0ff992bb97898b2df756691a62ee1d5f0506bc39" dependencies = [ "alloy-primitives", "num_enum", @@ -92,9 +102,9 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3a590d13de3944675987394715f37537b50b856e3b23a0e66e97d963edbf38" +checksum = "b0c0dc44157867da82c469c13186015b86abef209bf0e41625e4b68bac61d728" dependencies = [ "alloy-eips", "alloy-primitives", @@ -114,14 +124,14 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-consensus-any" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f28f769d5ea999f0d8a105e434f483456a15b4e1fcb08edbbbe1650a497ff6d" +checksum = "ba4cdb42df3871cd6b346d6a938ec2ba69a9a0f49d1f82714bc5c48349268434" dependencies = [ "alloy-consensus", "alloy-eips", @@ -133,9 +143,9 @@ dependencies = [ [[package]] name = "alloy-contract" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990fa65cd132a99d3c3795a82b9f93ec82b81c7de3bab0bf26ca5c73286f7186" +checksum = "ca63b7125a981415898ffe2a2a696c83696c9c6bdb1671c8a912946bbd8e49e7" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -150,14 +160,14 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-core" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca96214615ec8cf3fa2a54b32f486eb49100ca7fe7eb0b8c1137cd316e7250a" +checksum = "23e8604b0c092fabc80d075ede181c9b9e596249c70b99253082d7e689836529" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", @@ -168,9 +178,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdff496dd4e98a81f4861e66f7eaf5f2488971848bb42d9c892f871730245c8" +checksum = "cc2db5c583aaef0255aa63a4fe827f826090142528bba48d1bf4119b62780cad" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -192,7 +202,7 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -217,18 +227,31 @@ dependencies = [ "alloy-rlp", "borsh", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3231de68d5d6e75332b7489cfcc7f4dfabeba94d990a10e4b923af0e6623540" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", ] [[package]] name = "alloy-eips" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09535cbc646b0e0c6fcc12b7597eaed12cf86dff4c4fba9507a61e71b94f30eb" +checksum = "b9f7ef09f21bd1e9cb8a686f168cb4a206646804567f0889eadb8dcc4c9288c8" dependencies = [ "alloy-eip2124", "alloy-eip2930", "alloy-eip7702", + "alloy-eip7928", "alloy-primitives", "alloy-rlp", "alloy-serde", @@ -240,14 +263,14 @@ dependencies = [ "serde", "serde_with", "sha2", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-genesis" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1005520ccf89fa3d755e46c1d992a9e795466c2e7921be2145ef1f749c5727de" +checksum = "7c9cf3b99f46615fbf7dc1add0c96553abb7bf88fc9ec70dfbe7ad0b47ba7fe8" dependencies = [ "alloy-eips", "alloy-primitives", @@ -258,9 +281,9 @@ dependencies = [ [[package]] name = "alloy-hardforks" -version = "0.2.2" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40cc82a2283e3ce6317bc1f0134ea50d20e8c1965393045ee952fb28a65ddbd" +checksum = "3165210652f71dfc094b051602bafd691f506c54050a174b1cba18fb5ef706a3" dependencies = [ "alloy-chains", "alloy-eip2124", @@ -271,9 +294,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5513d5e6bd1cba6bdcf5373470f559f320c05c8c59493b6e98912fbe6733943f" +checksum = "e9dbe713da0c737d9e5e387b0ba790eb98b14dd207fe53eef50e19a5a8ec3dac" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -283,24 +306,24 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b626409c98ba43aaaa558361bca21440c88fd30df7542c7484b9c7a1489cdb" +checksum = "ff42cd777eea61f370c0b10f2648a1c81e0b783066cd7269228aa993afd487f7" dependencies = [ "alloy-primitives", "alloy-sol-types", - "http 1.3.1", + "http 1.4.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] [[package]] name = "alloy-network" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89924fdcfeee0e0fa42b1f10af42f92802b5d16be614a70897382565663bf7cf" +checksum = "8cbca04f9b410fdc51aaaf88433cbac761213905a65fe832058bcf6690585762" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -319,14 +342,14 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-network-primitives" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0dbe56ff50065713ff8635d8712a0895db3ad7f209db9793ad8fcb6b1734aa" +checksum = "42d6d15e069a8b11f56bef2eccbad2a873c6dd4d4c81d04dda29710f5ea52f04" dependencies = [ "alloy-consensus", "alloy-eips", @@ -337,9 +360,9 @@ dependencies = [ [[package]] name = "alloy-node-bindings" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "287de64d2236ca3f36b5eb03a39903f62a74848ae78a6ec9d0255eebb714f077" +checksum = "091dc8117d84de3a9ac7ec97f2c4d83987e24d485b478d26aa1ec455d7d52f7d" dependencies = [ "alloy-genesis", "alloy-hardforks", @@ -352,44 +375,44 @@ dependencies = [ "rand 0.8.5", "serde_json", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", ] [[package]] name = "alloy-primitives" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355bf68a433e0fd7f7d33d5a9fc2583fde70bf5c530f63b80845f8da5505cf28" +checksum = "de3b431b4e72cd8bd0ec7a50b4be18e73dab74de0dba180eef171055e5d5926e" dependencies = [ "alloy-rlp", "bytes", "cfg-if", "const-hex", "derive_more", - "foldhash", - "getrandom 0.3.2", - "hashbrown 0.16.0", - "indexmap 2.9.0", + "foldhash 0.2.0", + "getrandom 0.4.1", + "hashbrown 0.16.1", + "indexmap 2.13.0", "itoa", "k256", "keccak-asm", "paste", "proptest", - "rand 0.9.1", + "rand 0.9.2", + "rapidhash", "ruint", "rustc-hash", "serde", "sha3", - "tiny-keccak", ] [[package]] name = "alloy-provider" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b56f7a77513308a21a2ba0e9d57785a9d9d2d609e77f4e71a78a1192b83ff2d" +checksum = "d181c8cc7cf4805d7e589bf4074d56d55064fa1a979f005a45a62b047616d870" dependencies = [ "alloy-chains", "alloy-consensus", @@ -416,10 +439,10 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.12.22", + "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -428,9 +451,9 @@ dependencies = [ [[package]] name = "alloy-rlp" -version = "0.3.11" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6c1d995bff8d011f7cd6c81820d51825e6e06d6db73914c1630ecf544d83d6" +checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" dependencies = [ "alloy-rlp-derive", "arrayvec", @@ -439,20 +462,20 @@ dependencies = [ [[package]] name = "alloy-rlp-derive" -version = "0.3.11" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a40e1ef334153322fd878d07e86af7a529bcb86b2439525920a88eba87bcf943" +checksum = "ce8849c74c9ca0f5a03da1c865e3eb6f768df816e67dd3721a398a8a7e398011" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "alloy-rpc-client" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff01723afc25ec4c5b04de399155bef7b6a96dfde2475492b1b7b4e7a4f46445" +checksum = "f2792758a93ae32a32e9047c843d536e1448044f78422d71bf7d7c05149e103f" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -460,7 +483,7 @@ dependencies = [ "alloy-transport-http", "futures", "pin-project", - "reqwest 0.12.22", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -473,9 +496,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-anvil" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e82145856df8abb1fefabef58cdec0f7d9abf337d4abd50c1ed7e581634acdd" +checksum = "e0a3100b76987c1b1dc81f3abe592b7edc29e92b1242067a69d65e0030b35cf9" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -485,9 +508,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-any" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212ca1c1dab27f531d3858f8b1a2d6bfb2da664be0c1083971078eb7b71abe4b" +checksum = "dd720b63f82b457610f2eaaf1f32edf44efffe03ae25d537632e7d23e7929e1a" dependencies = [ "alloy-consensus-any", "alloy-rpc-types-eth", @@ -496,9 +519,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-eth" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5715d0bf7efbd360873518bd9f6595762136b5327a9b759a8c42ccd9b5e44945" +checksum = "9b2dc411f13092f237d2bf6918caf80977fc2f51485f9b90cb2a2f956912c8c9" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -512,14 +535,14 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-serde" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed8531cae8d21ee1c6571d0995f8c9f0652a6ef6452fde369283edea6ab7138" +checksum = "e2ce1e0dbf7720eee747700e300c99aac01b1a95bb93f493a01e78ee28bb1a37" dependencies = [ "alloy-primitives", "serde", @@ -528,9 +551,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb10ccd49d0248df51063fce6b716f68a315dd912d55b32178c883fd48b4021d" +checksum = "2425c6f314522c78e8198979c8cbf6769362be4da381d4152ea8eefce383535d" dependencies = [ "alloy-primitives", "async-trait", @@ -538,14 +561,14 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-signer-local" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4d992d44e6c414ece580294abbadb50e74cfd4eaa69787350a4dfd4b20eaa1b" +checksum = "c3ecb71ee53d8d9c3fa7bac17542c8116ebc7a9726c91b1bf333ec3d04f5a789" dependencies = [ "alloy-consensus", "alloy-network", @@ -554,47 +577,47 @@ dependencies = [ "async-trait", "k256", "rand 0.8.5", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-sol-macro" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ce480400051b5217f19d6e9a82d9010cdde20f1ae9c00d53591e4a1afbb312" +checksum = "ab81bab693da9bb79f7a95b64b394718259fdd7e41dceeced4cad57cb71c4f6a" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d792e205ed3b72f795a8044c52877d2e6b6e9b1d13f431478121d8d4eaa9028" +checksum = "489f1620bb7e2483fb5819ed01ab6edc1d2f93939dce35a5695085a1afd1d699" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.9.0", + "indexmap 2.13.0", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.101", + "sha3", + "syn 2.0.114", "syn-solidity", - "tiny-keccak", ] [[package]] name = "alloy-sol-macro-input" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd1247a8f90b465ef3f1207627547ec16940c35597875cdc09c49d58b19693c" +checksum = "56cef806ad22d4392c5fc83cf8f2089f988eb99c7067b4e0c6f1971fc1cca318" dependencies = [ "alloy-json-abi", "const-hex", @@ -604,15 +627,15 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.101", + "syn 2.0.114", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "954d1b2533b9b2c7959652df3076954ecb1122a28cc740aa84e7b0a49f6ac0a9" +checksum = "a6df77fea9d6a2a75c0ef8d2acbdfd92286cc599983d3175ccdc170d3433d249" dependencies = [ "serde", "winnow", @@ -620,9 +643,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70319350969a3af119da6fb3e9bddb1bce66c9ea933600cb297c8b1850ad2a3c" +checksum = "64612d29379782a5dde6f4b6570d9c756d734d760c0c94c254d361e678a6591f" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -632,9 +655,9 @@ dependencies = [ [[package]] name = "alloy-transport" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50a9516736d22dd834cc2240e5bf264f338667cc1d9e514b55ec5a78b987ca" +checksum = "fa186e560d523d196580c48bf00f1bf62e63041f28ecf276acc22f8b27bb9f53" dependencies = [ "alloy-json-rpc", "auto_impl", @@ -645,7 +668,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower", "tracing", @@ -655,13 +678,14 @@ dependencies = [ [[package]] name = "alloy-transport-http" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a18b541a6197cf9a084481498a766fdf32fefda0c35ea6096df7d511025e9f1" +checksum = "aa501ad58dd20acddbfebc65b52e60f05ebf97c52fa40d1b35e91f5e2da0ad0e" dependencies = [ "alloy-json-rpc", "alloy-transport", - "reqwest 0.12.22", + "itertools 0.14.0", + "reqwest 0.12.28", "serde_json", "tower", "tracing", @@ -670,9 +694,9 @@ dependencies = [ [[package]] name = "alloy-trie" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428aa0f0e0658ff091f8f667c406e034b431cb10abd39de4f507520968acc499" +checksum = "4d7fd448ab0a017de542de1dcca7a58e7019fe0e7a34ed3f9543ebddf6aceffa" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -681,19 +705,20 @@ dependencies = [ "nybbles", "serde", "smallvec", + "thiserror 2.0.18", "tracing", ] [[package]] name = "alloy-tx-macros" -version = "1.4.3" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2289a842d02fe63f8c466db964168bb2c7a9fdfb7b24816dbb17d45520575fb" +checksum = "6fa0c53e8c1e1ef4d01066b01c737fb62fc9397ab52c6e7bb5669f97d281b9bc" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -707,15 +732,24 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "ark-bn254" @@ -790,7 +824,7 @@ checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -824,7 +858,7 @@ dependencies = [ "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "itertools 0.13.0", "num-bigint", "num-integer", @@ -920,7 +954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -958,7 +992,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -1019,7 +1053,7 @@ dependencies = [ "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "rayon", ] @@ -1119,7 +1153,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -1195,11 +1229,11 @@ dependencies = [ [[package]] name = "askama" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ - "askama_derive 0.13.1", + "askama_derive 0.14.0", "itoa", "percent-encoding", "serde", @@ -1208,11 +1242,11 @@ dependencies = [ [[package]] name = "askama" -version = "0.14.0" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" dependencies = [ - "askama_derive 0.14.0", + "askama_macros", "itoa", "percent-encoding", "serde", @@ -1221,11 +1255,11 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" dependencies = [ - "askama_parser 0.13.0", + "askama_parser 0.14.0", "basic-toml", "memchr", "proc-macro2", @@ -1233,16 +1267,16 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "askama_derive" -version = "0.14.0" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" dependencies = [ - "askama_parser 0.14.0", + "askama_parser 0.15.4", "basic-toml", "memchr", "proc-macro2", @@ -1250,14 +1284,23 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.101", + "syn 2.0.114", +] + +[[package]] +name = "askama_macros" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" +dependencies = [ + "askama_derive 0.15.4", ] [[package]] name = "askama_parser" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" dependencies = [ "memchr", "serde", @@ -1267,13 +1310,14 @@ dependencies = [ [[package]] name = "askama_parser" -version = "0.14.0" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" dependencies = [ - "memchr", + "rustc-hash", "serde", "serde_derive", + "unicode-ident", "winnow", ] @@ -1289,9 +1333,9 @@ dependencies = [ [[package]] name = "async-compat" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bab94bde396a3f7b4962e396fdad640e241ed797d4d8d77fc8c237d14c58fc0" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" dependencies = [ "futures-core", "futures-io", @@ -1302,13 +1346,12 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.23" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b37fc50485c4f3f736a4fb14199f6d5f5ba008d7f28fe710306c92780f004c07" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" dependencies = [ - "brotli", - "futures-core", - "memchr", + "compression-codecs", + "compression-core", "pin-project-lite", "tokio", ] @@ -1332,18 +1375,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -1369,20 +1412,20 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.3" +version = "1.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", "zeroize", @@ -1390,9 +1433,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.36.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -1413,9 +1456,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -1423,7 +1466,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -1446,9 +1489,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "basic-toml" @@ -1485,15 +1528,15 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-io" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative", @@ -1507,9 +1550,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitvec" @@ -1534,15 +1577,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures", ] [[package]] @@ -1556,9 +1600,9 @@ dependencies = [ [[package]] name = "blst" -version = "0.3.14" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c79a94619fade3c0b887670333513a67ac28a6a7e653eb260bf0d4103db38d" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" dependencies = [ "cc", "glob", @@ -1586,14 +1630,14 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "brotli" -version = "8.0.1" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1612,9 +1656,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.17.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byte-slice-cast" @@ -1624,9 +1668,9 @@ checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytemuck" -version = "1.23.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -1636,18 +1680,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] [[package]] name = "c-kzg" -version = "2.1.1" +version = "2.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7318cfa722931cb5fe0838b98d3ce5621e75f6a6408abc21721d80de9223f2e4" +checksum = "e00bf4b112b07b505472dbefd19e37e53307e2bfed5a79e0cc161d58ccd0e687" dependencies = [ "blst", "cc", @@ -1660,11 +1704,11 @@ dependencies = [ [[package]] name = "camino" -version = "1.1.9" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1684,17 +1728,17 @@ checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", "cargo-platform", - "semver 1.0.26", + "semver 1.0.27", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "cc" -version = "1.2.52" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -1704,9 +1748,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -1714,18 +1758,42 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[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 = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.0", + "windows-link", ] [[package]] @@ -1755,17 +1823,27 @@ dependencies = [ "half", ] +[[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 = "circom-witness-rs" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef3c1ad2fd98272ce36f3db32543dbeec6497e04c5e71640602e1cda1698c318" +checksum = "35778373aee12ef3d04966187eeae7a04f1451c9226058311f21488df6f28780" dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", "ark-serialize 0.5.0", "byteorder", - "cxx", "cxx-build", "eyre", "hex", @@ -1780,9 +1858,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.37" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -1790,9 +1868,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstyle", "clap_lex", @@ -1801,21 +1879,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" @@ -1828,15 +1906,18 @@ dependencies = [ [[package]] name = "cobs" -version = "0.2.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", @@ -1872,9 +1953,9 @@ dependencies = [ [[package]] name = "colored" -version = "3.0.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ "windows-sys 0.48.0", ] @@ -1889,17 +1970,32 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "const-hex" -version = "1.14.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0485bab839b018a8f1723fc5391819fea5f8f0f32288ef8a735fd096b6160c" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" dependencies = [ "cfg-if", "cpufeatures", - "hex", "proptest", - "serde", + "serde_core", ] [[package]] @@ -1910,9 +2006,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "const_format" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" dependencies = [ "const_format_proc_macros", ] @@ -1930,9 +2026,18 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "core-foundation" @@ -1961,9 +2066,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -1974,6 +2079,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -2007,9 +2121,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -2025,83 +2139,38 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] [[package]] -name = "cxx" -version = "1.0.180" +name = "ctor" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ecd70e33fb57b5fec1608d572bf8dc382f5385a19529056b32307a29ac329be" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ - "cc", - "cxxbridge-cmd", - "cxxbridge-flags", - "cxxbridge-macro", - "foldhash", - "link-cplusplus", + "quote", + "syn 2.0.114", ] [[package]] name = "cxx-build" -version = "1.0.158" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a8232661d66dcf713394726157d3cfe0a89bfc85f52d6e9f9bbc2306797fe7" +checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" dependencies = [ "cc", "codespan-reporting", + "indexmap 2.13.0", "proc-macro2", "quote", "scratch", - "syn 2.0.101", -] - -[[package]] -name = "cxxbridge-cmd" -version = "1.0.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64320fd0856fdf2421f8404b67d41e91291cbcfa3d57457b390f0a2618ee9a68" -dependencies = [ - "clap", - "codespan-reporting", - "indexmap 2.9.0", - "proc-macro2", - "quote", - "syn 2.0.101", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e40964f209961217b972415a8e3a0c23299076a0b2dfe79fa8366b5e5c833e" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51afdec15d8072d1b69f54f645edaf54250088a7e54c4fe493016781278136bd" -dependencies = [ - "indexmap 2.9.0", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.101", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", + "syn 2.0.114", ] [[package]] @@ -2110,22 +2179,8 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.101", + "darling_core", + "darling_macro", ] [[package]] @@ -2140,18 +2195,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.101", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2160,9 +2204,9 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2197,12 +2241,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -2218,33 +2262,46 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.4.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73f2692d4bd3cac41dca28934a39894200c9fabf49586d77d0e5954af1d7902" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] -name = "derive_more" -version = "2.0.1" +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn 2.0.101", + "rustc_version 0.4.1", + "syn 2.0.114", "unicode-xid", ] @@ -2277,7 +2334,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2294,9 +2351,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" @@ -2322,7 +2379,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2384,27 +2441,27 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "enum-ordinalize" -version = "4.3.0" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2415,12 +2472,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2471,11 +2528,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixed-hash" @@ -2489,12 +2557,28 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2503,9 +2587,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2587,7 +2671,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2639,42 +2723,55 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-timers" @@ -2712,9 +2809,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -2722,7 +2819,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.9.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2731,17 +2828,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.9.0", + "http 1.4.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2750,12 +2847,13 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] @@ -2790,23 +2888,25 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", + "serde_core", ] [[package]] @@ -2831,24 +2931,21 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -dependencies = [ - "serde", -] [[package]] name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] @@ -2859,6 +2956,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[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" @@ -2881,12 +2987,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -2908,7 +3013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -2919,7 +3024,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -2946,14 +3051,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.9", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -2962,20 +3067,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "h2 0.4.10", - "http 1.3.1", + "futures-core", + "h2 0.4.13", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -2997,41 +3104,39 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", - "http 1.3.1", - "hyper 1.6.0", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls 0.23.27", + "rustls 0.23.37", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 0.26.11", + "webpki-roots 1.0.6", ] [[package]] name = "hyper-util" -version = "0.1.13" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.9", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3039,9 +3144,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -3063,21 +3168,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -3086,98 +3192,66 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "ident_case" @@ -3187,9 +3261,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -3198,9 +3272,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -3223,14 +3297,14 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "indenter" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" @@ -3245,24 +3319,23 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.16.1", "serde", + "serde_core", ] [[package]] -name = "io-uring" -version = "0.7.8" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "libc", + "generic-array", ] [[package]] @@ -3273,9 +3346,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -3310,9 +3383,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" @@ -3320,15 +3393,15 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -3351,18 +3424,18 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] [[package]] name = "keccak-asm" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +checksum = "b646a74e746cd25045aa0fd42f4f7f78aa6d119380182c7e63a5593c4ab8df6f" dependencies = [ "digest 0.10.7", "sha3-asm", @@ -3374,54 +3447,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.172" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] -name = "link-cplusplus" -version = "1.0.12" +name = "libredox" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "cc", + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.1", ] [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" @@ -3429,7 +3509,7 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -3440,9 +3520,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mach2" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] @@ -3455,7 +3535,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -3469,15 +3549,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" dependencies = [ "libc", ] @@ -3517,22 +3597,23 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -3554,21 +3635,22 @@ dependencies = [ [[package]] name = "mockito" -version = "1.7.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" dependencies = [ "assert-json-diff", "bytes", "colored", - "futures-util", - "http 1.3.1", + "futures-core", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.8.1", "hyper-util", "log", - "rand 0.9.1", + "pin-project-lite", + "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", @@ -3605,7 +3687,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3621,9 +3703,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -3646,9 +3728,9 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ "hermit-abi", "libc", @@ -3656,29 +3738,30 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "nybbles" -version = "0.4.1" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675b3a54e5b12af997abc8b6638b0aee51a28caedab70d4967e0d5db3a3f1d06" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" dependencies = [ "alloy-rlp", "cfg-if", @@ -3690,9 +3773,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -3703,17 +3786,23 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "owo-colors" -version = "4.2.0" +version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1036865bb9422d3300cf723f657c2851d0e9ab12567854b1f4eba3d77decf564" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" [[package]] name = "parity-scale-codec" -version = "3.7.4" +version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9fde3d0718baf5bc92f577d652001da0f8d54cd03a7974e118d04fc888dc23d" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" dependencies = [ "arrayvec", "bitvec", @@ -3727,21 +3816,21 @@ dependencies = [ [[package]] name = "parity-scale-codec-derive" -version = "3.7.4" +version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581c837bb6b9541ce7faa9377c20616e4fb7650f6b0f68bc93c827ee504fb7b3" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -3749,15 +3838,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -3768,18 +3857,17 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", - "thiserror 2.0.17", "ucd-trie", ] @@ -3800,7 +3888,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -3825,17 +3913,34 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[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 = "postcard" -version = "1.1.1" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" dependencies = [ "cobs", "embedded-io 0.4.0", @@ -3844,6 +3949,15 @@ dependencies = [ "serde", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3856,7 +3970,17 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.25", + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", ] [[package]] @@ -3872,9 +3996,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ "toml_edit", ] @@ -3898,31 +4022,30 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "proptest" -version = "1.6.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cae93065090804185d3b75f0bf93b8eeda30c7a9b4a33d3bdb3988d6229e50" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.0", - "lazy_static", + "bitflags 2.10.0", "num-traits", - "rand 0.8.5", - "rand_chacha 0.3.1", + "rand 0.9.2", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3938,9 +4061,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -3948,9 +4071,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.27", - "socket2 0.5.9", - "thiserror 2.0.17", + "rustls 0.23.37", + "socket2 0.5.10", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3958,20 +4081,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "getrandom 0.3.2", + "getrandom 0.3.4", "lru-slab", - "rand 0.9.1", + "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.27", + "rustls 0.23.37", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3979,32 +4102,32 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.12" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.9", + "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radium" @@ -4026,12 +4149,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", "serde", ] @@ -4052,7 +4175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -4061,33 +4184,42 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.4", "serde", ] [[package]] name = "rand_xorshift" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84816e4c99c467e92cf984ee6328caa976dfecd33a673544489d79ca2caaefe5" +dependencies = [ + "rustversion", ] [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -4095,9 +4227,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -4105,18 +4237,47 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.12" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ - "bitflags 2.9.0", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] name = "regex" -version = "1.11.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4126,9 +4287,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4137,9 +4298,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -4152,7 +4313,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -4184,36 +4345,34 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "async-compression", "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.27", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-rustls 0.26.2", - "tokio-util", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -4221,7 +4380,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.0", + "webpki-roots 1.0.6", ] [[package]] @@ -4242,7 +4401,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -4258,6 +4417,16 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "ruint" version = "1.17.2" @@ -4279,7 +4448,7 @@ dependencies = [ "primitive-types", "proptest", "rand 0.8.5", - "rand 0.9.1", + "rand 0.9.2", "rlp", "ruint-macro", "serde_core", @@ -4295,9 +4464,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" @@ -4326,20 +4495,20 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.26", + "semver 1.0.27", ] [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4356,16 +4525,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.2", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -4381,9 +4550,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -4401,9 +4570,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.2" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7149975849f1abb3832b246010ef62ccc80d3a76169517ada7188252b9cfb437" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -4413,15 +4582,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", "quick-error", @@ -4431,9 +4600,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -4445,16 +4614,40 @@ dependencies = [ ] [[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scratch" -version = "1.0.8" +name = "schemars" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scratch" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" [[package]] name = "scroll" @@ -4473,7 +4666,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -4631,7 +4824,7 @@ dependencies = [ "proc-macro2", "quote", "semaphore-rs-depth-config", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -4676,7 +4869,7 @@ dependencies = [ "ark-bn254 0.4.0", "ark-ec 0.4.2", "ark-groth16 0.4.0", - "getrandom 0.2.16", + "getrandom 0.2.17", "hex", "lazy_static", "ruint", @@ -4770,11 +4963,12 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ "serde", + "serde_core", ] [[package]] @@ -4813,19 +5007,29 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", ] [[package]] @@ -4842,17 +5046,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.9.0", - "serde", - "serde_derive", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -4860,14 +5065,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -4914,9 +5119,9 @@ dependencies = [ [[package]] name = "sha3-asm" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +checksum = "b31139435f327c93c6038ed350ae4588e2c70a13d50599509fee6349967ba35a" dependencies = [ "cc", "cfg-if", @@ -4947,6 +5152,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "similar" version = "2.7.0" @@ -4955,24 +5166,21 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -4985,9 +5193,9 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", @@ -4995,12 +5203,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5022,11 +5230,23 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -5051,15 +5271,14 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -5081,9 +5300,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.101" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -5092,14 +5311,14 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.4.1" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff790eb176cc81bb8936aed0f7b9f14fc4670069a2d371b3e3b0ecce908b2cb3" +checksum = "53f425ae0b12e2f5ae65542e00898d500d4d318b4baf09f40fd0d410454e9947" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -5125,7 +5344,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -5134,7 +5353,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7dddc5f0fee506baf8b9fdb989e242f17e4b11c61dfbb0635b705217199eea" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.10.0", "byteorder", "enum-as-inner", "libc", @@ -5177,35 +5396,24 @@ dependencies = [ [[package]] name = "taceo-ark-serde-compat" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "070074111809a0c9344738814302f6271bd79bb38cc1427e889c2b063e66e5e0" -dependencies = [ - "ark-bn254 0.5.0", - "ark-ec 0.5.0", - "serde", - "taceo-ark-babyjubjub", - "thiserror 2.0.17", -] - -[[package]] -name = "taceo-ark-serde-compat" -version = "0.2.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9085f4762d38839ca1ab55c7eab2a24961b8eb1fdb639fabd1d015703a389f13" +checksum = "07528b4dd1a0c9e49ef352f96219c611af0aa2f7cbd97ddb7276dcf3c2cf8dd0" dependencies = [ "ark-bn254 0.5.0", "ark-ec 0.5.0", "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "num-bigint", "serde", "taceo-ark-babyjubjub", ] [[package]] name = "taceo-circom-types" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3561b87890d0e3f4787fd08fc07d96c7457139b22b64fa22b6a61e8d8e16cd4" +checksum = "677eb3ed8275b2f179d4b1a93126a51c5b4f409c5ea9d7bc50398b13e517e30b" dependencies = [ "ark-bn254 0.5.0", "ark-ec 0.5.0", @@ -5220,16 +5428,16 @@ dependencies = [ "rayon", "serde", "serde_json", - "taceo-ark-serde-compat 0.2.1", - "thiserror 2.0.17", + "taceo-ark-serde-compat", + "thiserror 2.0.18", "tracing", ] [[package]] name = "taceo-eddsa-babyjubjub" -version = "0.5.0" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791e41273cc025e07fbca9fcd1d143a3d8cbe3515de8aa6dab91549c9a0af245" +checksum = "75dbec63f7a89093b4116a7164e6a92d3cda33dec248da8c8a5922a80a06e7dd" dependencies = [ "ark-ec 0.5.0", "ark-ff 0.5.0", @@ -5240,16 +5448,16 @@ dependencies = [ "rand 0.8.5", "serde", "taceo-ark-babyjubjub", - "taceo-ark-serde-compat 0.1.0", - "taceo-poseidon2 0.1.0", + "taceo-ark-serde-compat", + "taceo-poseidon2", "zeroize", ] [[package]] name = "taceo-groth16" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3efa0cf1b7e336c54092e291fbc8403cccc89947737a6fb6f97398882bef9c6" +checksum = "a4983857c95d20ca2dc0400a3a116e6931c012ecc4b78ccede8238cfb0c298e3" dependencies = [ "ark-ec 0.5.0", "ark-ff 0.5.0", @@ -5264,9 +5472,9 @@ dependencies = [ [[package]] name = "taceo-groth16-material" -version = "0.1.1" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e2e97f306dc2aa157c36ce4da3812ee0310ee41686b38a6d199f3bdaeff219" +checksum = "936b1e6b8a77f931796917501fe13a2ae93304e7eb384ed29d80ddc370b011bd" dependencies = [ "ark-bn254 0.5.0", "ark-ec 0.5.0", @@ -5277,46 +5485,59 @@ dependencies = [ "circom-witness-rs", "eyre", "hex", + "postcard", "rand 0.8.5", "ruint", "sha2", "taceo-circom-types", "taceo-groth16", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] [[package]] name = "taceo-groth16-sol" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70bc9cde94809b52a212c19afef9ea9a8a649580652424512161822a0eeaaef9" +checksum = "7c6a7b90f2ecb6db1212557550890d9d9d114447688f6146d726024ec7a3410b" dependencies = [ "alloy-primitives", "ark-bn254 0.5.0", "ark-ec 0.5.0", "ark-ff 0.5.0", "ark-groth16 0.5.0", - "askama 0.14.0", + "askama 0.15.4", "eyre", "ruint", ] +[[package]] +name = "taceo-oprf" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "412211d4d43aeb060c369a69213bd7ae941f769cc4361df83195d2a7936f22d5" +dependencies = [ + "taceo-oprf-client", + "taceo-oprf-core", + "taceo-oprf-types", +] + [[package]] name = "taceo-oprf-client" -version = "0.2.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a1966b345496c8242d244765b71d76d78a1fc024ce37396340324a51edda0d5" +checksum = "42bbcb77821be63e228fa7ad408f03b212f91011c4ac5d4122d5a22126bcfc57" dependencies = [ "ark-ec 0.5.0", "ciborium", "futures", + "http 1.4.0", "serde", "taceo-ark-babyjubjub", "taceo-oprf-core", "taceo-oprf-types", - "taceo-poseidon2 0.2.0", - "thiserror 2.0.17", + "taceo-poseidon2", + "thiserror 2.0.18", "tokio", "tokio-tungstenite", "tracing", @@ -5325,9 +5546,9 @@ dependencies = [ [[package]] name = "taceo-oprf-core" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6cbb4724789bb571f35f370798d369bf2074beaa251db85654d5a43a8bdc5c" +checksum = "d8105a6fa70adb6872ea2eef5ad21bfc5c943d90dc43332347ec021cd44987cb" dependencies = [ "ark-ec 0.5.0", "ark-ff 0.5.0", @@ -5339,50 +5560,38 @@ dependencies = [ "serde", "subtle", "taceo-ark-babyjubjub", - "taceo-ark-serde-compat 0.2.1", - "taceo-poseidon2 0.2.0", + "taceo-ark-serde-compat", + "taceo-poseidon2", "uuid", "zeroize", ] [[package]] name = "taceo-oprf-types" -version = "0.3.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbe351598d0162b33b8903d1eb4885cb13dcfec9cbda7a430039d4b46473699" +checksum = "278668dc50fdfb2ec5e9391031b8b98690d1b230da483e4ce15bdb52ede4acca" dependencies = [ "alloy", "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "async-trait", "eyre", - "http 1.3.1", + "http 1.4.0", "serde", "taceo-ark-babyjubjub", - "taceo-ark-serde-compat 0.2.1", + "taceo-ark-serde-compat", "taceo-circom-types", "taceo-groth16-sol", "taceo-oprf-core", - "tracing", "uuid", ] [[package]] name = "taceo-poseidon2" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5f09fc2f1c68aafd3f544313fe7e34d33b35aa666bc9e6b7a22e94b9833140" -dependencies = [ - "ark-bn254 0.5.0", - "ark-ff 0.5.0", - "ark-std 0.5.0", - "num-bigint", - "num-traits", -] - -[[package]] -name = "taceo-poseidon2" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbf106fb8682ee4e057872a18f431828bd467c28d2ead469e4c84dbf6ce5ec6" +checksum = "ac59d3df4c827b3496bff929aebd6440997a5c2e946f46ff4fdd76f318447581" dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", @@ -5397,17 +5606,28 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" -version = "3.20.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5439,11 +5659,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -5454,28 +5674,27 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] @@ -5489,30 +5708,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -5529,9 +5748,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -5539,9 +5758,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -5554,32 +5773,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", - "slab", - "socket2 0.6.0", + "socket2 0.6.2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -5594,19 +5810,19 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.27", + "rustls 0.23.37", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -5616,12 +5832,10 @@ dependencies = [ [[package]] name = "tokio-test" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ - "async-stream", - "bytes", "futures-core", "tokio", "tokio-stream", @@ -5635,19 +5849,19 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", - "rustls 0.23.27", + "rustls 0.23.37", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.4", "tungstenite", "webpki-roots 0.26.11", ] [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -5658,35 +5872,60 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.11" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "serde", + "indexmap 2.13.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.9" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] [[package]] name = "toml_edit" -version = "0.22.26" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.13.0", "toml_datetime", + "toml_parser", "winnow", ] +[[package]] +name = "toml_parser" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -5699,17 +5938,22 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.9.0", + "async-compression", + "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -5729,9 +5973,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -5740,20 +5984,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -5766,7 +6010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" dependencies = [ "tracing", - "tracing-subscriber 0.3.20", + "tracing-subscriber 0.3.22", ] [[package]] @@ -5791,9 +6035,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -5821,22 +6065,22 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.3.1", + "http 1.4.0", "httparse", "log", - "rand 0.9.1", - "rustls 0.23.27", + "rand 0.9.2", + "rustls 0.23.37", "rustls-pki-types", "sha1", - "thiserror 2.0.17", + "thiserror 2.0.18", "utf-8", ] [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -5864,15 +6108,21 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -5882,9 +6132,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uniffi" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd1d240101ba3b9d7532ae86d9cb64d9a7ff63e13a2b7b9e94a32a601d8233" +checksum = "b8c6dec3fc6645f71a16a3fa9ff57991028153bd194ca97f4b55e610c73ce66a" dependencies = [ "anyhow", "camino", @@ -5899,26 +6149,26 @@ dependencies = [ [[package]] name = "uniffi-bindgen" -version = "0.3.3" +version = "0.7.2" dependencies = [ "uniffi", ] [[package]] name = "uniffi_bindgen" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d0525f06d749ea80d8049dc0bb038bb87941e3d909eefa76b6f0a5589b59ac5" +checksum = "4ed0150801958d4825da56a41c71f000a457ac3a4613fa9647df78ac4b6b6881" dependencies = [ "anyhow", - "askama 0.13.1", + "askama 0.14.0", "camino", "cargo_metadata", "fs-err", "glob", "goblin", "heck", - "indexmap 2.9.0", + "indexmap 2.13.0", "once_cell", "serde", "tempfile", @@ -5932,9 +6182,9 @@ dependencies = [ [[package]] name = "uniffi_build" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aed2f0204e942bb9c11c9f11a323b4abf70cf11b2e5957d60b3f2728430f6c6f" +checksum = "b78fd9271a4c2e85bd2c266c5a9ede1fac676eb39fd77f636c27eaf67426fd5f" dependencies = [ "anyhow", "camino", @@ -5943,9 +6193,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3fa8eb4d825b4ed095cb13483cba6927c3002b9eb603cef9b7688758cc3772e" +checksum = "b0ef62e69762fbb9386dcb6c87cd3dd05d525fa8a3a579a290892e60ddbda47e" dependencies = [ "anyhow", "async-compat", @@ -5956,22 +6206,22 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b547d69d699e52f2129fde4b57ae0d00b5216e59ed5b56097c95c86ba06095" +checksum = "98f51ebca0d9a4b2aa6c644d5ede45c56f73906b96403c08a1985e75ccb64a01" dependencies = [ "anyhow", - "indexmap 2.9.0", + "indexmap 2.13.0", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "uniffi_macros" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1de72edc8cb9201c7d650e3678840d143e4499004571aac49e6cb1b17da43" +checksum = "db9d12529f1223d014fd501e5f29ca0884d15d6ed5ddddd9f506e55350327dc3" dependencies = [ "camino", "fs-err", @@ -5979,16 +6229,16 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.101", + "syn 2.0.114", "toml", "uniffi_meta", ] [[package]] name = "uniffi_meta" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acc9204632f6a555b2cba7c8852c5523bc1aa5f3eff605c64af5054ea28b72e" +checksum = "9df6d413db2827c68588f8149d30d49b71d540d46539e435b23a7f7dbd4d4f86" dependencies = [ "anyhow", "siphasher", @@ -5998,22 +6248,22 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b5336a9a925b358183837d31541d12590b7fcec373256d3770de02dff24c69" +checksum = "a806dddc8208f22efd7e95a5cdf88ed43d0f3271e8f63b47e757a8bbdb43b63a" dependencies = [ "anyhow", "heck", - "indexmap 2.9.0", + "indexmap 2.13.0", "tempfile", "uniffi_internal_macros", ] [[package]] name = "uniffi_udl" -version = "0.29.2" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f95e73373d85f04736bc51997d3e6855721144ec4384cae9ca8513c80615e129" +checksum = "0d1a7339539bf6f6fa3e9b534dece13f778bda2d54b1a6d4e40b4d6090ac26e7" dependencies = [ "anyhow", "textwrap", @@ -6021,6 +6271,16 @@ dependencies = [ "weedle2", ] +[[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 = "untrusted" version = "0.9.0" @@ -6029,14 +6289,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -6045,12 +6306,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -6059,13 +6314,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.4.1", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -6102,7 +6357,7 @@ dependencies = [ [[package]] name = "walletkit" -version = "0.3.3" +version = "0.7.2" dependencies = [ "uniffi", "walletkit-core", @@ -6110,32 +6365,60 @@ dependencies = [ [[package]] name = "walletkit-core" -version = "0.3.3" +version = "0.7.2" dependencies = [ "alloy", "alloy-core", "alloy-primitives", + "backon", + "base64 0.22.1", + "chacha20poly1305", "chrono", + "ciborium", + "ctor", "dotenvy", + "eyre", "hex", + "hkdf", "log", "mockito", "rand 0.8.5", "regex", - "reqwest 0.12.22", + "reqwest 0.12.28", "ruint", - "rustls 0.23.27", + "rustls 0.23.37", "secrecy", "semaphore-rs", "serde", "serde_json", + "sha2", "strum", "subtle", - "thiserror 2.0.17", + "taceo-oprf", + "thiserror 2.0.18", "tokio", "tokio-test", + "tracing", + "tracing-log", + "tracing-subscriber 0.3.22", "uniffi", + "uuid", + "walletkit-db", "world-id-core", + "zeroize", +] + +[[package]] +name = "walletkit-db" +version = "0.7.2" +dependencies = [ + "cc", + "hex", + "sha2", + "sqlite-wasm-rs", + "tempfile", + "zeroize", + "zip", ] [[package]] @@ -6149,52 +6432,49 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen" -version = "0.2.100" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.101", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -6203,9 +6483,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6213,31 +6493,65 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.101", - "wasm-bindgen-backend", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver 1.0.27", +] + [[package]] name = "wasmtimer" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0048ad49a55b9deb3953841fa1fc5858f0efbcb7a18868c899a360269fac1b23" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" dependencies = [ "futures", "js-sys", @@ -6249,9 +6563,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -6279,14 +6593,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.0", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -6302,15 +6616,15 @@ dependencies = [ [[package]] name = "widestring" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ "windows-sys 0.48.0", ] @@ -6326,67 +6640,61 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.1.1", + "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "windows-link" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" - -[[package]] -name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.1.1", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.1.1", + "windows-link", ] [[package]] @@ -6409,11 +6717,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -6440,13 +6757,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -6459,6 +6793,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -6471,6 +6811,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -6483,12 +6829,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -6501,6 +6859,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -6513,6 +6877,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -6525,6 +6895,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -6537,11 +6913,17 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" -version = "0.7.10" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -6557,97 +6939,205 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.9.0", + "wit-bindgen-rust-macro", ] [[package]] -name = "world-id-core" -version = "0.3.0" +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f9c1f4fac4bb2f031c09d2ee82d613d39044995d2a22041ac8e2a4142ac99bd" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver 1.0.27", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "world-id-authenticator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a2f1c50923bc65b243355f4663e1ab6bbfa9f8ac918a7db65003cead2600f0" dependencies = [ "alloy", "anyhow", - "ark-bn254 0.5.0", - "ark-ec 0.5.0", - "ark-ff 0.5.0", "ark-serialize 0.5.0", "backon", - "chrono", - "ciborium", "eyre", - "hex", - "k256", "rand 0.8.5", - "reqwest 0.12.22", + "reqwest 0.12.28", "ruint", - "rustls 0.23.27", + "rustls 0.23.37", "secrecy", "serde", "serde_json", - "strum", "taceo-ark-babyjubjub", - "taceo-circom-types", "taceo-eddsa-babyjubjub", "taceo-groth16-material", - "taceo-oprf-client", - "taceo-oprf-core", - "taceo-oprf-types", - "taceo-poseidon2 0.1.0", - "thiserror 1.0.69", + "taceo-oprf", + "taceo-poseidon2", + "thiserror 2.0.18", "tokio", - "tracing", - "tracing-subscriber 0.3.20", - "webpki-roots 1.0.0", + "webpki-roots 1.0.6", + "world-id-primitives", + "world-id-proof", +] + +[[package]] +name = "world-id-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4212c964ed123b51202435a98695bce9b228b40f4f39f6e77ccfb1d32a83208a" +dependencies = [ + "taceo-eddsa-babyjubjub", + "world-id-authenticator", "world-id-primitives", + "world-id-proof", ] [[package]] name = "world-id-primitives" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88da834bcf446406db353dfe7a7a3bddefdbb6f4ddb11ee908fa3ac9849b4aa6" +checksum = "13939e35b7f9a77ad4f7cf268a1a5cd29597a4cb60377395457eafd8df0b5839" dependencies = [ + "alloy", "alloy-primitives", "ark-bn254 0.5.0", "ark-ff 0.5.0", "ark-groth16 0.5.0", - "ark-serialize 0.5.0", "arrayvec", + "eyre", + "getrandom 0.2.17", "hex", "k256", "rand 0.8.5", "ruint", + "secrecy", "serde", "serde_json", "sha3", + "strum", "taceo-ark-babyjubjub", - "taceo-ark-serde-compat 0.2.1", + "taceo-ark-serde-compat", "taceo-circom-types", "taceo-eddsa-babyjubjub", "taceo-groth16-material", - "taceo-oprf-types", - "taceo-poseidon2 0.1.0", - "thiserror 1.0.69", + "taceo-groth16-sol", + "taceo-oprf", + "taceo-poseidon2", + "thiserror 2.0.18", "url", + "uuid", ] [[package]] -name = "write16" -version = "1.0.0" +name = "world-id-proof" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "cf858169c8dec236c7e95661381829cc270d8c70d8eb34b3bb9312fa0cad0097" +dependencies = [ + "ark-bn254 0.5.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-groth16 0.5.0", + "ark-serialize 0.5.0", + "eyre", + "rand 0.8.5", + "rayon", + "reqwest 0.12.28", + "taceo-ark-babyjubjub", + "taceo-circom-types", + "taceo-eddsa-babyjubjub", + "taceo-groth16-material", + "taceo-oprf", + "taceo-poseidon2", + "tar", + "thiserror 2.0.18", + "tracing", + "world-id-primitives", + "zeroize", + "zstd", +] [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" @@ -6658,13 +7148,22 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -6672,54 +7171,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.25" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ - "zerocopy-derive 0.8.25", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -6739,35 +7218,46 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", ] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -6776,11 +7266,74 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.13.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index 59b4b1093..bec6fa982 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [workspace] -members = ["uniffi-bindgen","walletkit-core", "walletkit"] +members = ["uniffi-bindgen", "walletkit-core", "walletkit", "walletkit-db"] resolver = "2" [workspace.package] -version = "0.3.3" +version = "0.7.2" license = "MIT" edition = "2021" authors = ["World Contributors"] readme = "./README.md" -homepage = "https://docs.world.org" # TODO: Update to specific WalletKit page -rust-version = "1.91" # MSRV +homepage = "https://docs.world.org" # TODO: Update to specific WalletKit page +rust-version = "1.91" # MSRV repository = "https://github.com/worldcoin/walletkit" exclude = ["tests/", "uniffi-bindgen/"] keywords = ["ZKP", "WorldID", "World", "Identity", "Semaphore"] @@ -17,11 +17,22 @@ categories = ["api-bindings", "cryptography::cryptocurrencies"] [workspace.dependencies] -alloy-core = { version = "1", default-features = false, features = ["sol-types"] } +alloy-core = { version = "1", default-features = false, features = [ + "sol-types", +] } alloy-primitives = { version = "1", default-features = false } -walletkit-core = { version = "0.3.3", path = "walletkit-core", default-features = false } -uniffi = { version = "0.29", features = ["build", "tokio"] } -world-id-core = { version = "0.3", default-features = false, features = ["authenticator", "embed-zkeys"] } +walletkit-core = { version = "0.7.2", path = "walletkit-core", default-features = false } +uniffi = { version = "0.31", features = ["build", "tokio"] } +world-id-core = { version = "0.5", default-features = false } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } + +[workspace.lints.rust] +missing_docs = "deny" +dead_code = "deny" [profile.release] opt-level = 'z' # Optimize for size. diff --git a/Cross.toml b/Cross.toml index 1bc3e3439..d65322e09 100644 --- a/Cross.toml +++ b/Cross.toml @@ -5,17 +5,20 @@ passthrough = [ "RUSTUP_HOME", # override rustup home to prevent host permission issues "CARGO_HOME", # override cargo home to prevent host permission issues ] + # max-page-size=16384: # Android 15 (API 35) introduces support for 16KB page sizes to improve performance on devices with larger RAM. # Apps with native libraries MUST be compiled with 16KB ELF alignment or they will crash on startup # on devices configured with 16KB page sizes. This flag ensures proper alignment for both 32-bit and 64-bit targets. # Reference: https://developer.android.com/guide/practices/page-sizes -RUSTFLAGS = "-C link-arg=-Wl,-z,max-page-size=16384" +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] -[build] -pre-build = [ - "dpkg --add-architecture $CROSS_DEB_ARCH", - "apt-get update && apt-get --assume-yes install pkg-config:$CROSS_DEB_ARCH", - "export PKG_CONFIG_ALLOW_CROSS=1", - "export PKG_CONFIG_LIBDIR=/usr/lib/$CROSS_DEB_ARCH/pkgconfig:/usr/lib/$CROSS_DEB_ARCH/lib/pkgconfig:/usr/share/pkgconfig", -] +[target.armv7-linux-androideabi] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.i686-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] diff --git a/README.md b/README.md index b137f4d6a..992fb044c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ WalletKit's bindings for Kotlin are distributed through GitHub packages. ```kotlin dependencies { /// ... - implementation "org.world:walletkit-android:VERSION" + implementation "org.world:walletkit:VERSION" } ``` @@ -62,18 +62,41 @@ Replace `VERSION` with the desired WalletKit version. To test local changes before publishing a release, use the build script to compile the Rust library, generate UniFFI bindings, and publish a SNAPSHOT to Maven Local: ```bash -./build_android_local.sh 0.3.1-SNAPSHOT +./kotlin/build_android_local.sh 0.3.1-SNAPSHOT ``` -> **Note**: The script sets `RUSTUP_HOME` and `CARGO_HOME` to `/tmp` by default to avoid Docker permission issues when using `cross`. You can override them by exporting your own values. +Example with custom Rust locations: +```bash +RUSTUP_HOME=~/.rustup CARGO_HOME=~/.cargo ./kotlin/build_android_local.sh 0.1.0-SNAPSHOT +``` + +> **Note**: The script can be run from any working directory (it resolves its own location). It sets `RUSTUP_HOME` and `CARGO_HOME` to `/tmp` by default to avoid Docker permission issues when using `cross`. You can override them by exporting your own values. This will: 1. Build the Rust library for all Android architectures (arm64-v8a, armeabi-v7a, x86_64, x86) 2. Generate Kotlin UniFFI bindings -3. Publish to `~/.m2/repository/org/world/walletkit-android/` +3. Publish to `~/.m2/repository/org/world/walletkit/` In your consuming project, ensure `mavenLocal()` is included in your repositories and update your dependency version to the SNAPSHOT version (e.g., `0.3.1-SNAPSHOT`). +## Development + +### Linting + +WalletKit uses feature flags (e.g. `semaphore`, `storage`) that gate code paths with `#[cfg]`. To catch warnings across all configurations, run clippy three ways: + +```bash +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo clippy --workspace --all-targets -- -D warnings +cargo clippy --workspace --all-targets --no-default-features -- -D warnings +``` + +CI runs all three checks. Formatting: + +```bash +cargo fmt -- --check +``` + ## Overview WalletKit is broken down into separate crates, offering the following functionality. @@ -110,26 +133,35 @@ async fn example() { ## 🛠️ Logging -WalletKit includes logging functionality that can be integrated with foreign language bindings. The logging system allows you to capture debug information and operational logs from the library. +WalletKit uses `tracing` internally for all first-party logging and instrumentation. +To integrate with iOS/Android logging systems, initialize WalletKit logging with a +foreign `Logger` bridge. + +`Logger` is intentionally minimal and level-aware: + +- `log(level, message)` receives all log messages. +- `level` is one of: `Trace`, `Debug`, `Info`, `Warn`, `Error`. + +This preserves severity semantics for native apps while still allowing +forwarding to Datadog, Crashlytics, `os_log`, Android `Log`, or any custom sink. ### Basic Usage -Implement the `Logger` trait and set it as the global logger: +Implement the `Logger` trait and initialize logging once at app startup: ```rust -use walletkit_core::logger::{Logger, LogLevel, set_logger}; +use walletkit_core::logger::{init_logging, LogLevel, Logger}; use std::sync::Arc; struct MyLogger; impl Logger for MyLogger { fn log(&self, level: LogLevel, message: String) { - println!("[{:?}] {}", level, message); + println!("[{level:?}] {message}"); } } -// Set the logger once at application startup -set_logger(Arc::new(MyLogger)); +init_logging(Arc::new(MyLogger), Some(LogLevel::Debug)); ``` ### Swift Integration @@ -139,14 +171,23 @@ class WalletKitLoggerBridge: WalletKit.Logger { static let shared = WalletKitLoggerBridge() func log(level: WalletKit.LogLevel, message: String) { - // Forward to your app's logging system - Log.log(level.toCoreLevel(), message) + switch level { + case .trace, .debug: + Log.debug(message) + case .info: + Log.info(message) + case .warn: + Log.warn(message) + case .error: + Log.error(message) + @unknown default: + Log.error(message) + } } } -// Set up the logger in your app delegate -public func setupWalletKitLogger() { - WalletKit.setLogger(logger: WalletKitLoggerBridge.shared) +public func setupWalletKitLogging() { + WalletKit.initLogging(logger: WalletKitLoggerBridge.shared, level: .debug) } ``` @@ -159,13 +200,20 @@ class WalletKitLoggerBridge : WalletKit.Logger { } override fun log(level: WalletKit.LogLevel, message: String) { - // Forward to your app's logging system - Log.log(level.toCoreLevel(), message) + when (level) { + WalletKit.LogLevel.TRACE, WalletKit.LogLevel.DEBUG -> + Log.d("WalletKit", message) + WalletKit.LogLevel.INFO -> + Log.i("WalletKit", message) + WalletKit.LogLevel.WARN -> + Log.w("WalletKit", message) + WalletKit.LogLevel.ERROR -> + Log.e("WalletKit", message) + } } } -// Set up the logger in your application -fun setupWalletKitLogger() { - WalletKit.setLogger(WalletKitLoggerBridge.shared) +fun setupWalletKitLogging() { + WalletKit.initLogging(WalletKitLoggerBridge.shared, WalletKit.LogLevel.DEBUG) } ``` diff --git a/audits/2026-01-cure53.pdf b/audits/2026-01-cure53.pdf new file mode 100644 index 000000000..39fbb3435 Binary files /dev/null and b/audits/2026-01-cure53.pdf differ diff --git a/build_swift.sh b/build_swift.sh deleted file mode 100755 index 14d80e759..000000000 --- a/build_swift.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -set -e - -# Creates a Swift build of the `WalletKit` library. -# This script is intended to be run in a GitHub Actions workflow. -# When a release is created, the output is committed to the github.com/worldcoin/walletkit-swift repo. - -echo "Building WalletKit.xcframework" - -BASE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -rm -rf $BASE_PATH/ios_build -rm -rf $BASE_PATH/WalletKit.xcframework -mkdir -p $BASE_PATH/ios_build/bindings -mkdir -p $BASE_PATH/ios_build/target/universal-ios-sim/release -mkdir -p $BASE_PATH/Sources/WalletKit - - -export IPHONEOS_DEPLOYMENT_TARGET="13.0" -export RUSTFLAGS="-C link-arg=-Wl,-application_extension" -cargo build --package walletkit --target aarch64-apple-ios-sim --release --features v4 -cargo build --package walletkit --target aarch64-apple-ios --release --features v4 -cargo build --package walletkit --target x86_64-apple-ios --release --features v4 - -echo "Rust packages built. Combining into a single binary." - -lipo -create target/aarch64-apple-ios-sim/release/libwalletkit.a \ - target/x86_64-apple-ios/release/libwalletkit.a \ - -output $BASE_PATH/ios_build/target/universal-ios-sim/release/libwalletkit.a - -lipo -info $BASE_PATH/ios_build/target/universal-ios-sim/release/libwalletkit.a - -echo "Generating Swift bindings." - -cargo run -p uniffi-bindgen generate \ - target/aarch64-apple-ios-sim/release/libwalletkit.dylib \ - --library \ - --language swift \ - --no-format \ - --out-dir $BASE_PATH/ios_build/bindings - -mv $BASE_PATH/ios_build/bindings/walletkit.swift $BASE_PATH/Sources/WalletKit/ -mv $BASE_PATH/ios_build/bindings/walletkit_core.swift $BASE_PATH/Sources/WalletKit/ - -mkdir $BASE_PATH/ios_build/Headers -mkdir -p $BASE_PATH/ios_build/Headers/WalletKit - -mv $BASE_PATH/ios_build/bindings/walletkitFFI.h $BASE_PATH/ios_build/Headers/WalletKit -mv $BASE_PATH/ios_build/bindings/walletkit_coreFFI.h $BASE_PATH/ios_build/Headers/WalletKit - -# Combine both modulemaps into one -cat $BASE_PATH/ios_build/bindings/walletkitFFI.modulemap > $BASE_PATH/ios_build/Headers/WalletKit/module.modulemap -echo "" >> $BASE_PATH/ios_build/Headers/WalletKit/module.modulemap -cat $BASE_PATH/ios_build/bindings/walletkit_coreFFI.modulemap >> $BASE_PATH/ios_build/Headers/WalletKit/module.modulemap - -echo "Creating xcframework." - -xcodebuild -create-xcframework \ - -library target/aarch64-apple-ios/release/libwalletkit.a -headers $BASE_PATH/ios_build/Headers \ - -library $BASE_PATH/ios_build/target/universal-ios-sim/release/libwalletkit.a -headers $BASE_PATH/ios_build/Headers \ - -output $BASE_PATH/WalletKit.xcframework - -rm -rf $BASE_PATH/ios_build diff --git a/clippy.toml b/clippy.toml deleted file mode 100644 index 0f89f2eb6..000000000 --- a/clippy.toml +++ /dev/null @@ -1 +0,0 @@ -msrv = "1.86" \ No newline at end of file diff --git a/deny.toml b/deny.toml index 3c4245e45..3f267f5b2 100644 --- a/deny.toml +++ b/deny.toml @@ -7,8 +7,7 @@ unknown-registry = "deny" [bans] deny = [ - { name = "openssl-sys", reason = "increases complexity for foreign binding compilation and bundle size" }, - { name = "openssl", reason = "increases complexity for foreign binding compilation and bundle size" } + { name = "openssl", reason = "increases complexity for foreign binding compilation and bundle size" }, ] [licenses] @@ -30,7 +29,7 @@ allow = [ "CDLA-Permissive-2.0", "ISC", "MIT", - "MPL-2.0", # Although this is copyleft, it is scoped to modifying the original files + "MPL-2.0", # Although this is copyleft, it is scoped to modifying the original files "OpenSSL", "Unicode-3.0", "Unlicense", @@ -40,7 +39,7 @@ allow = [ # Ignore unmaintained required crates warning [advisories] ignore = [ - "RUSTSEC-2024-0388", # Unmaintained `derivative` (2024-11-22) + "RUSTSEC-2024-0388", # Unmaintained `derivative` (2024-11-22) "RUSTSEC-2024-0436", # Unmaintained `paste` (2025-04-04) "RUSTSEC-2023-0089", # Unmaintained `atomic-polyfill` (2025-06-03) "RUSTSEC-2025-0055", # `tracing-subscriber` < 0.3.20, requires bumps on `semaphore-rs` (and Ark deps) (2025-09-09) diff --git a/kotlin/README.md b/kotlin/README.md new file mode 100644 index 000000000..adf15f084 --- /dev/null +++ b/kotlin/README.md @@ -0,0 +1,27 @@ +# Kotlin for WalletKit + +This folder contains support files for WalletKit to work in Kotlin: + +1. Script to build Kotlin/JNA bindings. +2. Foreign tests (JUnit) for Kotlin in the `walletkit-tests` module. + +## Building the Kotlin project + +```bash + # run from the walletkit directory + ./kotlin/build_kotlin.sh +``` + +## Running foreign tests for Kotlin + +```bash + # run from the walletkit directory + ./kotlin/test_kotlin.sh +``` + +## Kotlin project structure + +The Kotlin project has two members: + +- `walletkit`: The main WalletKit library with UniFFI bindings for Kotlin. +- `walletkit-tests`: Unit tests to assert the Kotlin bindings behave as intended (foreign tests). diff --git a/kotlin/build.sh b/kotlin/build.sh index ecbc71b37..7ddfa0eb3 100755 --- a/kotlin/build.sh +++ b/kotlin/build.sh @@ -1,38 +1,42 @@ #!/bin/bash set -e +## This script is only used for local builds. For production releases, the code is in the CI workflow. + echo "Building WalletKit Android SDK..." +CARGO_FEATURES="compress-zkeys" + # Create jniLibs directories -mkdir -p ./lib/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86} +mkdir -p ./walletkit/src/main/jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86} # Build for all Android architectures echo "Building for aarch64-linux-android..." -cross build -p walletkit --release --target=aarch64-linux-android --features v4 +cross build -p walletkit --release --target=aarch64-linux-android --features "$CARGO_FEATURES" echo "Building for armv7-linux-androideabi..." -cross build -p walletkit --release --target=armv7-linux-androideabi --features v4 +cross build -p walletkit --release --target=armv7-linux-androideabi --features "$CARGO_FEATURES" echo "Building for x86_64-linux-android..." -cross build -p walletkit --release --target=x86_64-linux-android --features v4 +cross build -p walletkit --release --target=x86_64-linux-android --features "$CARGO_FEATURES" echo "Building for i686-linux-android..." -cross build -p walletkit --release --target=i686-linux-android --features v4 +cross build -p walletkit --release --target=i686-linux-android --features "$CARGO_FEATURES" # Move .so files to jniLibs echo "Moving native libraries..." -mv ../target/aarch64-linux-android/release/libwalletkit.so ./lib/src/main/jniLibs/arm64-v8a/libwalletkit.so -mv ../target/armv7-linux-androideabi/release/libwalletkit.so ./lib/src/main/jniLibs/armeabi-v7a/libwalletkit.so -mv ../target/x86_64-linux-android/release/libwalletkit.so ./lib/src/main/jniLibs/x86_64/libwalletkit.so -mv ../target/i686-linux-android/release/libwalletkit.so ./lib/src/main/jniLibs/x86/libwalletkit.so +mv ../target/aarch64-linux-android/release/libwalletkit.so ./walletkit/src/main/jniLibs/arm64-v8a/libwalletkit.so +mv ../target/armv7-linux-androideabi/release/libwalletkit.so ./walletkit/src/main/jniLibs/armeabi-v7a/libwalletkit.so +mv ../target/x86_64-linux-android/release/libwalletkit.so ./walletkit/src/main/jniLibs/x86_64/libwalletkit.so +mv ../target/i686-linux-android/release/libwalletkit.so ./walletkit/src/main/jniLibs/x86/libwalletkit.so # Generate Kotlin bindings echo "Generating Kotlin bindings..." cargo run -p uniffi-bindgen generate \ - ./lib/src/main/jniLibs/arm64-v8a/libwalletkit.so \ + ./walletkit/src/main/jniLibs/arm64-v8a/libwalletkit.so \ --library \ --language kotlin \ --no-format \ - --out-dir lib/src/main/java + --out-dir walletkit/src/main/java echo "✅ Build complete!" diff --git a/build_android_local.sh b/kotlin/build_android_local.sh similarity index 74% rename from build_android_local.sh rename to kotlin/build_android_local.sh index 7398387fc..23e2a2d99 100755 --- a/build_android_local.sh +++ b/kotlin/build_android_local.sh @@ -19,17 +19,19 @@ VERSION="$1" echo "Using version: $VERSION" # Build using kotlin/build.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + echo "Building WalletKit SDK..." -cd kotlin ./build.sh # Publish to Maven Local echo "Publishing to Maven Local..." -./gradlew :lib:publishToMavenLocal -PversionName="$VERSION" +./gradlew :walletkit:publishToMavenLocal -PversionName="$VERSION" echo "" echo "✅ Successfully published $VERSION to Maven Local!" -echo "Published to: ~/.m2/repository/org/world/walletkit-android/$VERSION/" +echo "Published to: ~/.m2/repository/org/world/walletkit/$VERSION/" echo "" echo "To use in your project:" -echo " implementation 'org.world:walletkit-android:$VERSION'" +echo " implementation 'org.world:walletkit:$VERSION'" diff --git a/kotlin/build_kotlin.sh b/kotlin/build_kotlin.sh new file mode 100755 index 000000000..cfd8c3a90 --- /dev/null +++ b/kotlin/build_kotlin.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates Kotlin/JNA bindings for the `walletkit` library. +# This mirrors the Bedrock Kotlin build flow. + +PROJECT_ROOT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KOTLIN_DIR="$PROJECT_ROOT_PATH/kotlin" +JAVA_SRC_DIR="$KOTLIN_DIR/walletkit/src/main/java" +LIBS_DIR="$KOTLIN_DIR/libs" +CARGO_FEATURES="compress-zkeys" + +# Clean previous artifacts +rm -rf "$JAVA_SRC_DIR" "$LIBS_DIR" +mkdir -p "$JAVA_SRC_DIR" "$LIBS_DIR" + +echo "🟢 Building Rust cdylib for host platform" +cargo build --package walletkit --release --features "$CARGO_FEATURES" + +# Determine the correct library file extension and copy it +if [[ "$OSTYPE" == "darwin"* ]]; then + LIB_FILE="$PROJECT_ROOT_PATH/target/release/libwalletkit.dylib" + cp "$LIB_FILE" "$LIBS_DIR/" + echo "📦 Copied libwalletkit.dylib for macOS" +elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + LIB_FILE="$PROJECT_ROOT_PATH/target/release/libwalletkit.so" + cp "$LIB_FILE" "$LIBS_DIR/" + echo "📦 Copied libwalletkit.so for Linux" +else + echo "❌ Unsupported OS: $OSTYPE" + exit 1 +fi + +echo "🟡 Generating Kotlin bindings via uniffi-bindgen" +cargo run -p uniffi-bindgen -- generate \ + "$LIB_FILE" \ + --language kotlin \ + --library \ + --crate walletkit_core \ + --out-dir "$JAVA_SRC_DIR" + +echo "✅ Kotlin bindings written to $JAVA_SRC_DIR" diff --git a/kotlin/lib/build.gradle.kts b/kotlin/lib/build.gradle.kts index eddfce6f9..734522be3 100644 --- a/kotlin/lib/build.gradle.kts +++ b/kotlin/lib/build.gradle.kts @@ -46,7 +46,7 @@ afterEvaluate { publications { create("maven") { groupId = "org.world" - artifactId = "walletkit-android" + artifactId = "walletkit" version = if (project.hasProperty("versionName")) { project.property("versionName") as String diff --git a/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts index 73668b1b0..e3860c3bb 100644 --- a/kotlin/settings.gradle.kts +++ b/kotlin/settings.gradle.kts @@ -14,6 +14,7 @@ pluginManagement { plugins { id("com.android.library") version "8.3.0" id("org.jetbrains.kotlin.android") version "1.9.22" + id("org.jetbrains.kotlin.jvm") version "1.9.22" } } @@ -25,4 +26,5 @@ dependencyResolutionManagement { } rootProject.name = "walletkit" -include("lib") +include("walletkit") +include("walletkit-tests") diff --git a/kotlin/test_kotlin.sh b/kotlin/test_kotlin.sh new file mode 100755 index 000000000..cfa043579 --- /dev/null +++ b/kotlin/test_kotlin.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=========================================" +echo "Running Kotlin/JVM Tests" +echo "=========================================" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[0;33m' +NC='\033[0m' # No Color + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +TEST_RESULTS_DIR="$ROOT_DIR/kotlin/walletkit-tests/build/test-results/test" +rm -rf "$TEST_RESULTS_DIR" + +cd "$ROOT_DIR" + +# Set JAVA_HOME if not already set (for CI environments) +if [ -z "${JAVA_HOME:-}" ]; then + if [ -d "/opt/homebrew/Cellar/openjdk@17" ]; then + # macOS with Homebrew - find latest 17.x version + LATEST_JDK=$(ls -v /opt/homebrew/Cellar/openjdk@17 | grep "^17\." | tail -n 1) + if [ -n "$LATEST_JDK" ]; then + export JAVA_HOME="/opt/homebrew/Cellar/openjdk@17/$LATEST_JDK/libexec/openjdk.jdk/Contents/Home" + echo -e "${BLUE}🔧 Set JAVA_HOME to: $JAVA_HOME${NC}" + else + echo -e "${YELLOW}⚠️ No OpenJDK 17.x found in Homebrew${NC}" + fi + elif command -v java >/dev/null 2>&1; then + JAVA_PATH=$(which java) + export JAVA_HOME=$(dirname $(dirname $(readlink -f $JAVA_PATH))) + echo -e "${BLUE}🔧 Detected JAVA_HOME: $JAVA_HOME${NC}" + else + echo -e "${YELLOW}⚠️ JAVA_HOME not set and Java not found in PATH${NC}" + fi +fi + +echo -e "${BLUE}🔨 Step 1: Building Kotlin bindings with build_kotlin.sh${NC}" +"$ROOT_DIR/kotlin/build_kotlin.sh" + +echo -e "${GREEN}✅ Kotlin bindings built${NC}" + +echo -e "${BLUE}📦 Step 2: Setting up Gradle test environment${NC}" +cd "$ROOT_DIR/kotlin" + +# Generate Gradle wrapper if missing +if [ ! -f "gradlew" ]; then + echo "Gradle wrapper missing, generating..." + GRADLE_VERSION="${GRADLE_VERSION:-8.14.3}" + DIST_URL="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" + TMP_DIR="$(mktemp -d)" + ZIP_PATH="$TMP_DIR/gradle-${GRADLE_VERSION}.zip" + UNZIP_DIR="$TMP_DIR/unzip" + + echo "Downloading Gradle ${GRADLE_VERSION}..." + curl -sSL "$DIST_URL" -o "$ZIP_PATH" + mkdir -p "$UNZIP_DIR" + if command -v unzip >/dev/null 2>&1; then + unzip -q "$ZIP_PATH" -d "$UNZIP_DIR" + else + (cd "$UNZIP_DIR" && jar xvf "$ZIP_PATH" >/dev/null) + fi + + echo "Bootstrapping wrapper with Gradle ${GRADLE_VERSION}..." + "$UNZIP_DIR/gradle-${GRADLE_VERSION}/bin/gradle" wrapper --gradle-version "$GRADLE_VERSION" + + rm -rf "$TMP_DIR" +fi +echo -e "${GREEN}✅ Gradle test environment ready${NC}" + +echo "" +echo -e "${BLUE}🧪 Step 3: Running Kotlin tests with verbose output...${NC}" +echo "" + +./gradlew --no-daemon walletkit-tests:test --info --continue + +echo "" +echo "📊 Test Results Summary:" +echo "========================" + +if [ -d "$TEST_RESULTS_DIR" ]; then + echo "✅ Test results found in: $TEST_RESULTS_DIR" + TOTAL_TESTS=$(find "$TEST_RESULTS_DIR" -name "*.xml" -exec grep -l "testcase" {} \; | wc -l | tr -d ' ') + if [ "$TOTAL_TESTS" -gt 0 ]; then + echo "📋 Total test files: $TOTAL_TESTS" + PASSED=$(find "$TEST_RESULTS_DIR" -name "*.xml" -exec grep -o "tests=\"[0-9]*\"" {} \; | cut -d'"' -f2 | awk '{sum+=$1} END {print sum+0}') + FAILURES=$(find "$TEST_RESULTS_DIR" -name "*.xml" -exec grep -o "failures=\"[0-9]*\"" {} \; | cut -d'"' -f2 | awk '{sum+=$1} END {print sum+0}') + ERRORS=$(find "$TEST_RESULTS_DIR" -name "*.xml" -exec grep -o "errors=\"[0-9]*\"" {} \; | cut -d'"' -f2 | awk '{sum+=$1} END {print sum+0}') + + echo "✅ Tests passed: $PASSED" + echo "❌ Tests failed: $FAILURES" + echo "⚠️ Test errors: $ERRORS" + + if [ "$FAILURES" -gt 0 ] || [ "$ERRORS" -gt 0 ]; then + echo "" + echo -e "${YELLOW}⚠️ Some tests failed${NC}" + exit 1 + else + echo "" + echo -e "${GREEN}🎉 All tests passed!${NC}" + exit 0 + fi + fi +else + echo "⚠️ No test results found" + echo "" + echo -e "${RED}✗ Could not determine test results${NC}" + exit 1 +fi diff --git a/kotlin/walletkit-tests/build.gradle.kts b/kotlin/walletkit-tests/build.gradle.kts new file mode 100644 index 000000000..06c9fce69 --- /dev/null +++ b/kotlin/walletkit-tests/build.gradle.kts @@ -0,0 +1,31 @@ +// This build.gradle uses a JVM-only testing engine for unit testing. +// Note this is separate from the build.gradle used for building and publishing the actual library. + +plugins { + kotlin("jvm") +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("net.java.dev.jna:jna:5.13.0") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") +} + +sourceSets { + test { + kotlin.srcDirs( + "$rootDir/walletkit/src/main/java/uniffi/walletkit_core" + ) + } +} + +tasks.test { + useJUnit() + systemProperty("jna.library.path", "${rootDir}/libs") + reports.html.required.set(false) +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt new file mode 100644 index 000000000..30fb959b1 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt @@ -0,0 +1,25 @@ +package org.world.walletkit + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AtomicBlobStoreTests { + @Test + fun writeReadDelete() { + val root = tempDirectory() + val store = FileBlobStore(root) + val path = "account_keys.bin" + val payload = byteArrayOf(1, 2, 3, 4) + + store.writeAtomic(path, payload) + val readBack = store.read(path) + assertEquals(payload.toList(), readBack?.toList()) + + store.delete(path) + assertNull(store.read(path)) + + root.deleteRecursively() + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt new file mode 100644 index 000000000..a7c994cc1 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt @@ -0,0 +1,251 @@ +package org.world.walletkit + +import uniffi.walletkit_core.CredentialStore +import uniffi.walletkit_core.StorageException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CredentialStoreTests { + @Test + fun methodsRequireInit() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + assertFailsWith { + store.listCredentials(issuerSchemaId = null, now = 100UL) + } + assertFailsWith { + store.merkleCacheGet(validUntil = 100UL) + } + + root.deleteRecursively() + } + + @Test + fun initRejectsLeafIndexMismatch() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val error = + assertFailsWith { + store.`init`(leafIndex = 43UL, now = 101UL) + } + assertEquals(42UL, error.`expected`) + assertEquals(43UL, error.`provided`) + + root.deleteRecursively() + } + + @Test + fun initIsIdempotentForSameLeafIndex() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val credentialId = + store.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + + store.`init`(leafIndex = 42UL, now = 101UL) + + val records = store.listCredentials(issuerSchemaId = null, now = 102UL) + assertEquals(1, records.size) + assertEquals(credentialId, records.single().credentialId) + + root.deleteRecursively() + } + + @Test + fun storeAndCacheFlows() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + assertNull(store.merkleCacheGet(validUntil = 100UL)) + + val credentialId = + store.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = byteArrayOf(4, 5, 6), + now = 100UL, + ) + + val records = store.listCredentials(issuerSchemaId = null, now = 101UL) + assertEquals(1, records.size) + val record = records[0] + assertEquals(credentialId, record.credentialId) + assertEquals(7UL, record.issuerSchemaId) + assertEquals(1_800_000_000UL, record.expiresAt) + + val proofBytes = byteArrayOf(9, 9, 9) + store.merkleCachePut( + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + val cached = + store.merkleCacheGet( + validUntil = 110UL, + ) + assertEquals(proofBytes.toList(), cached?.toList()) + val expired = store.merkleCacheGet(validUntil = 161UL) + assertNull(expired) + + root.deleteRecursively() + } + + @Test + fun listCredentialsFiltersByIssuerSchemaId() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 7UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_900_000_000UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_900_000_000UL, + associatedData = null, + now = 101UL, + ) + + val filtered = store.listCredentials(issuerSchemaId = 7UL, now = 102UL) + assertEquals(1, filtered.size) + assertEquals(7UL, filtered.single().issuerSchemaId) + + root.deleteRecursively() + } + + @Test + fun expiredCredentialsAreFilteredOut() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 7UL, expiresAt = 120UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 120UL, + associatedData = null, + now = 100UL, + ) + store.storeCredential( + credential = sampleCredential(issuerSchemaId = 8UL, expiresAt = 1_800_000_000UL), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 101UL, + ) + + val records = store.listCredentials(issuerSchemaId = null, now = 121UL) + assertEquals(1, records.size) + assertEquals(8UL, records.single().issuerSchemaId) + + root.deleteRecursively() + } + + @Test + fun storagePathsMatchWorldIdLayout() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + val paths = store.storagePaths() + assertEquals(root.absolutePath, paths.rootPathString()) + assertTrue(paths.worldidDirPathString().endsWith("/worldid")) + assertTrue(paths.vaultDbPathString().endsWith("/worldid/account.vault.sqlite")) + assertTrue(paths.cacheDbPathString().endsWith("/worldid/account.cache.sqlite")) + assertTrue(paths.lockPathString().endsWith("/worldid/lock")) + + root.deleteRecursively() + } + + @Test + fun merkleCachePutRefreshesExistingEntry() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + val firstProof = byteArrayOf(1, 2, 3) + val refreshedProof = byteArrayOf(4, 5, 6) + store.merkleCachePut( + proofBytes = firstProof, + now = 100UL, + ttlSeconds = 10UL, + ) + store.merkleCachePut( + proofBytes = refreshedProof, + now = 101UL, + ttlSeconds = 60UL, + ) + + val cached = store.merkleCacheGet(validUntil = 120UL) + assertContentEquals(refreshedProof, cached) + + root.deleteRecursively() + } + + @Test + fun reopenPersistsVaultAndCache() { + val root = tempDirectory() + val keyBytes = randomKeystoreKeyBytes() + val firstStore = + CredentialStore.fromProviderArc( + InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), + ) + + firstStore.`init`(leafIndex = 42UL, now = 100UL) + val credentialId = + firstStore.storeCredential( + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), + expiresAt = 1_800_000_000UL, + associatedData = null, + now = 100UL, + ) + val proofBytes = byteArrayOf(9, 9, 9) + firstStore.merkleCachePut( + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + + val reopenedStore = + CredentialStore.fromProviderArc( + InMemoryStorageProvider(root, InMemoryDeviceKeystore(keyBytes)), + ) + reopenedStore.`init`(leafIndex = 42UL, now = 101UL) + + val records = reopenedStore.listCredentials(issuerSchemaId = null, now = 102UL) + assertEquals(1, records.size) + assertEquals(credentialId, records.single().credentialId) + assertContentEquals(proofBytes, reopenedStore.merkleCacheGet(validUntil = 120UL)) + + root.deleteRecursively() + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt new file mode 100644 index 000000000..ac19ea5a4 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt @@ -0,0 +1,44 @@ +package org.world.walletkit + +import kotlin.test.Test +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class DeviceKeystoreTests { + @Test + fun sealAndOpenRoundTrip() { + val keystore = InMemoryDeviceKeystore() + val associatedData = "ad".encodeToByteArray() + val plaintext = "hello".encodeToByteArray() + + val ciphertext = keystore.seal(associatedData, plaintext) + val opened = keystore.openSealed(associatedData, ciphertext) + + assertTrue(opened.contentEquals(plaintext)) + } + + @Test + fun associatedDataMismatchFails() { + val keystore = InMemoryDeviceKeystore() + val plaintext = "secret".encodeToByteArray() + val ciphertext = keystore.seal("ad-1".encodeToByteArray(), plaintext) + + assertFails { + keystore.openSealed("ad-2".encodeToByteArray(), ciphertext) + } + } + + @Test + fun reopenWithSameKeyMaterialCanOpenCiphertext() { + val keyBytes = randomKeystoreKeyBytes() + val firstKeystore = InMemoryDeviceKeystore(keyBytes) + val secondKeystore = InMemoryDeviceKeystore(keyBytes) + val associatedData = "ad".encodeToByteArray() + val plaintext = "hello".encodeToByteArray() + + val ciphertext = firstKeystore.seal(associatedData, plaintext) + val opened = secondKeystore.openSealed(associatedData, ciphertext) + + assertTrue(opened.contentEquals(plaintext)) + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt new file mode 100644 index 000000000..91270e6cb --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt @@ -0,0 +1,47 @@ +package org.world.walletkit + +import kotlin.test.Test +import kotlin.test.assertTrue +import uniffi.walletkit_core.LogLevel +import uniffi.walletkit_core.Logger +import uniffi.walletkit_core.emitLog +import uniffi.walletkit_core.initLogging + +private class CapturingLogger : Logger { + private val lock = Any() + private val entries = mutableListOf>() + + override fun log( + level: LogLevel, + message: String, + ) { + synchronized(lock) { + entries.add(level to message) + } + } + + fun snapshot(): List> = + synchronized(lock) { + entries.toList() + } +} + +class SimpleTest { + @Test + fun initLoggingForwardsLevelAndMessage() { + val logger = CapturingLogger() + initLogging(logger, LogLevel.INFO) + emitLog(LogLevel.INFO, "bridge test") + + Thread.sleep(50) + + val entries = logger.snapshot() + assertTrue(entries.isNotEmpty(), "expected at least one bridged log entry") + + val hasBridgedMessage = + entries.any { (level, message) -> + level == LogLevel.INFO && message.contains("bridge test") + } + assertTrue(hasBridgedMessage, "expected info-level bridged log") + } +} diff --git a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt new file mode 100644 index 000000000..fc22656b5 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt @@ -0,0 +1,134 @@ +package org.world.walletkit + +import java.io.File +import java.security.SecureRandom +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.Credential +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.FieldElement +import uniffi.walletkit_core.StorageException +import uniffi.walletkit_core.StoragePaths +import uniffi.walletkit_core.StorageProvider + +fun tempDirectory(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "walletkit-tests-${UUID.randomUUID()}") + dir.mkdirs() + return dir +} + +fun randomKeystoreKeyBytes(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } + +fun sampleCredential( + issuerSchemaId: ULong = 7UL, + expiresAt: ULong = 1_800_000_000UL, +): Credential { + val credentialJson = + """ + {"id":13758530325042616850,"version":"V1","issuer_schema_id":$issuerSchemaId,"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":$expiresAt,"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} + """.trimIndent() + return Credential.fromBytes(credentialJson.encodeToByteArray()) +} + +fun sampleBlindingFactor(): FieldElement = FieldElement.fromU64(17UL) + +class InMemoryDeviceKeystore( + keyBytes: ByteArray = randomKeystoreKeyBytes(), +) : DeviceKeystore { + private val keyBytes = keyBytes.copyOf() + + override fun seal( + associatedData: ByteArray, + plaintext: ByteArray, + ): ByteArray = + try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val key = SecretKeySpec(keyBytes, "AES") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(associatedData) + val ciphertext = cipher.doFinal(plaintext) + val iv = cipher.iv + val output = ByteArray(1 + iv.size + ciphertext.size) + output[0] = iv.size.toByte() + System.arraycopy(iv, 0, output, 1, iv.size) + System.arraycopy(ciphertext, 0, output, 1 + iv.size, ciphertext.size) + output + } catch (error: Exception) { + throw StorageException.Keystore("keystore seal failed: ${error.message}") + } + + override fun openSealed( + associatedData: ByteArray, + ciphertext: ByteArray, + ): ByteArray { + if (ciphertext.isEmpty()) { + throw StorageException.Keystore("keystore ciphertext is empty") + } + val ivLen = ciphertext[0].toInt() and 0xFF + if (ciphertext.size < 1 + ivLen) { + throw StorageException.Keystore("keystore ciphertext too short") + } + return try { + val iv = ciphertext.copyOfRange(1, 1 + ivLen) + val payload = ciphertext.copyOfRange(1 + ivLen, ciphertext.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val key = SecretKeySpec(keyBytes, "AES") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + cipher.updateAAD(associatedData) + cipher.doFinal(payload) + } catch (error: Exception) { + throw StorageException.Keystore("keystore open failed: ${error.message}") + } + } +} + +class FileBlobStore( + private val baseDir: File, +) : AtomicBlobStore { + override fun read(path: String): ByteArray? { + val file = File(baseDir, path) + return if (file.exists()) file.readBytes() else null + } + + override fun writeAtomic( + path: String, + bytes: ByteArray, + ) { + val file = File(baseDir, path) + file.parentFile?.mkdirs() + val temp = File(file.parentFile ?: baseDir, "${file.name}.tmp-${UUID.randomUUID()}") + temp.writeBytes(bytes) + if (file.exists()) { + file.delete() + } + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + temp.delete() + } + } + + override fun delete(path: String) { + val file = File(baseDir, path) + if (file.exists() && !file.delete()) { + throw StorageException.BlobStore("delete failed") + } + } +} + +class InMemoryStorageProvider( + private val root: File, + private val keystoreImpl: DeviceKeystore = InMemoryDeviceKeystore(), +) : StorageProvider { + private val blobStore = FileBlobStore(File(root, "worldid")) + private val paths = StoragePaths.fromRoot(root.absolutePath) + + override fun keystore(): DeviceKeystore = keystoreImpl + + override fun blobStore(): AtomicBlobStore = blobStore + + override fun paths(): StoragePaths = paths +} diff --git a/kotlin/walletkit/.gitignore b/kotlin/walletkit/.gitignore new file mode 100644 index 000000000..dc7bdfe18 --- /dev/null +++ b/kotlin/walletkit/.gitignore @@ -0,0 +1,2 @@ +/build +/src/main/java/uniffi/ diff --git a/kotlin/walletkit/build.gradle.kts b/kotlin/walletkit/build.gradle.kts new file mode 100644 index 000000000..bf8e92db6 --- /dev/null +++ b/kotlin/walletkit/build.gradle.kts @@ -0,0 +1,84 @@ +import java.io.ByteArrayOutputStream + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("maven-publish") +} + +kotlin { + jvmToolchain(17) +} + +android { + namespace = "org.world.walletkit" + compileSdk = 33 + + defaultConfig { + minSdk = 23 + @Suppress("deprecation") + targetSdk = 33 + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +afterEvaluate { + publishing { + publications { + create("maven") { + groupId = "org.world" + artifactId = "walletkit" + + // Read version from Cargo.toml (allow override via -PversionName) + version = if (project.hasProperty("versionName")) { + project.property("versionName") as String + } else { + val cargoToml = file("../../Cargo.toml") + val versionRegex = """version\s*=\s*"([^"]+)"""".toRegex() + val cargoContent = cargoToml.readText() + versionRegex.find(cargoContent)?.groupValues?.get(1) + ?: throw GradleException("Could not find version in Cargo.toml") + } + + afterEvaluate { + from(components["release"]) + } + } + } + + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/worldcoin/walletkit") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + } +} + +dependencies { + // UniFFI requires JNA for native calls (AAR to avoid jar+aar duplicates) + implementation("net.java.dev.jna:jna:5.13.0@aar") + implementation("androidx.core:core-ktx:1.8.0") + implementation("androidx.appcompat:appcompat:1.4.1") + implementation("com.google.android.material:material:1.5.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") +} diff --git a/kotlin/walletkit/consumer-rules.pro b/kotlin/walletkit/consumer-rules.pro new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/kotlin/walletkit/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/kotlin/walletkit/src/main/jniLibs/.gitignore b/kotlin/walletkit/src/main/jniLibs/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/kotlin/walletkit/src/main/jniLibs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt new file mode 100644 index 000000000..2b3a30405 --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt @@ -0,0 +1,57 @@ +package org.world.walletkit.storage + +import java.io.File +import java.io.IOException +import java.util.UUID +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.StorageException + +class AndroidAtomicBlobStore( + private val baseDir: File +) : AtomicBlobStore { + override fun read(path: String): ByteArray? { + val file = File(baseDir, path) + if (!file.exists()) { + return null + } + return try { + file.readBytes() + } catch (error: IOException) { + throw StorageException.BlobStore("read failed: ${error.message}") + } + } + + override fun writeAtomic(path: String, bytes: ByteArray) { + val file = File(baseDir, path) + val parent = file.parentFile + if (parent != null && !parent.exists()) { + parent.mkdirs() + } + val temp = File( + parent ?: baseDir, + "${file.name}.tmp-${UUID.randomUUID()}" + ) + try { + temp.writeBytes(bytes) + if (file.exists() && !file.delete()) { + throw StorageException.BlobStore("failed to remove existing file") + } + if (!temp.renameTo(file)) { + temp.copyTo(file, overwrite = true) + temp.delete() + } + } catch (error: Exception) { + throw StorageException.BlobStore("write failed: ${error.message}") + } + } + + override fun delete(path: String) { + val file = File(baseDir, path) + if (!file.exists()) { + return + } + if (!file.delete()) { + throw StorageException.BlobStore("delete failed") + } + } +} diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt new file mode 100644 index 000000000..98f6a8e8b --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt @@ -0,0 +1,91 @@ +package org.world.walletkit.storage + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.StorageException + +class AndroidDeviceKeystore( + private val alias: String = "walletkit_device_key" +) : DeviceKeystore { + private val lock = Any() + + override fun seal(associatedData: ByteArray, plaintext: ByteArray): ByteArray { + try { + val key = getOrCreateKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(associatedData) + val ciphertext = cipher.doFinal(plaintext) + val iv = cipher.iv + val output = ByteArray(1 + iv.size + ciphertext.size) + output[0] = iv.size.toByte() + System.arraycopy(iv, 0, output, 1, iv.size) + System.arraycopy(ciphertext, 0, output, 1 + iv.size, ciphertext.size) + return output + } catch (error: Exception) { + throw StorageException.Keystore("keystore seal failed: ${error.message}") + } + } + + override fun openSealed( + associatedData: ByteArray, + ciphertext: ByteArray + ): ByteArray { + if (ciphertext.isEmpty()) { + throw StorageException.Keystore("keystore ciphertext is empty") + } + val ivLen = ciphertext[0].toInt() and 0xFF + if (ciphertext.size < 1 + ivLen) { + throw StorageException.Keystore("keystore ciphertext too short") + } + try { + val key = getOrCreateKey() + val iv = ciphertext.copyOfRange(1, 1 + ivLen) + val payload = ciphertext.copyOfRange(1 + ivLen, ciphertext.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + cipher.updateAAD(associatedData) + return cipher.doFinal(payload) + } catch (error: Exception) { + throw StorageException.Keystore("keystore open failed: ${error.message}") + } + } + + private fun getOrCreateKey(): SecretKey { + synchronized(lock) { + try { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + val existing = keyStore.getKey(alias, null) as? SecretKey + if (existing != null) { + return existing + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore" + ) + val spec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setRandomizedEncryptionRequired(true) + .build() + keyGenerator.init(spec) + return keyGenerator.generateKey() + } catch (error: Exception) { + throw StorageException.Keystore("keystore init failed: ${error.message}") + } + } + } +} diff --git a/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt new file mode 100644 index 000000000..f8eaa6bb4 --- /dev/null +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt @@ -0,0 +1,38 @@ +package org.world.walletkit.storage + +import android.content.Context +import java.io.File +import uniffi.walletkit_core.AtomicBlobStore +import uniffi.walletkit_core.DeviceKeystore +import uniffi.walletkit_core.StorageException +import uniffi.walletkit_core.StoragePaths +import uniffi.walletkit_core.StorageProvider + +class AndroidStorageProvider( + private val rootDir: File, + private val keystoreImpl: AndroidDeviceKeystore = AndroidDeviceKeystore(), + private val blobStoreImpl: AndroidAtomicBlobStore = + AndroidAtomicBlobStore(File(rootDir, "worldid")) +) : StorageProvider { + private val pathsImpl: StoragePaths = StoragePaths.fromRoot(rootDir.absolutePath) + + init { + val worldidDir = File(rootDir, "worldid") + if (!worldidDir.exists() && !worldidDir.mkdirs()) { + throw StorageException.BlobStore("failed to create storage directory") + } + } + + override fun keystore(): DeviceKeystore = keystoreImpl + + override fun blobStore(): AtomicBlobStore = blobStoreImpl + + override fun paths(): StoragePaths = pathsImpl +} + +object WalletKitStorage { + fun defaultProvider(context: Context): AndroidStorageProvider { + val root = File(context.filesDir, "walletkit") + return AndroidStorageProvider(root) + } +} diff --git a/swift/README.md b/swift/README.md new file mode 100644 index 000000000..8f0e979b9 --- /dev/null +++ b/swift/README.md @@ -0,0 +1,57 @@ +# Swift for WalletKit + +This folder contains Swift support files for WalletKit: + +1. Script to cross-compile and build Swift bindings. +2. Script to build a Swift package for local development. +3. Foreign tests (XCTest suite) for Swift under `tests/`. + +## Building the Swift bindings + +To build the Swift project for release/distribution: + +```bash + # run from the walletkit directory + ./swift/build_swift.sh +``` + +## Testing WalletKit locally + +To build a Swift package that can be imported locally via Swift Package Manager: + +```bash + # run from the walletkit directory + ./swift/local_swift.sh +``` + +This creates a complete Swift package in `swift/local_build/` that you can import in your iOS project. + +## Integration via Package.swift + +Add the local package to your Package.swift dependencies: + +```swift +dependencies: [ + .package(name: "WalletKit", path: "../../../walletkit/swift/local_build"), + // ... other dependencies +], +``` + +Then add it to specific targets that need WalletKit functionality: + +```swift +.target( + name: "YourTarget", + dependencies: [ + .product(name: "WalletKit", package: "WalletKit"), + // ... other dependencies + ] +), +``` + +## Running foreign tests for Swift + +```bash + # run from the walletkit directory + ./swift/test_swift.sh +``` diff --git a/archive_swift.sh b/swift/archive_swift.sh similarity index 93% rename from archive_swift.sh rename to swift/archive_swift.sh index 53e0e506f..6a1351577 100755 --- a/archive_swift.sh +++ b/swift/archive_swift.sh @@ -77,11 +77,11 @@ let package = Package( targets: [ .target( name: "WalletKit", - dependencies: ["walletkit_FFI"], + dependencies: ["walletkit_coreFFI"], path: "Sources/WalletKit" ), .binaryTarget( - name: "walletkit_FFI", + name: "walletkit_coreFFI", url: "$ASSET_URL", checksum: "$CHECKSUM" ) @@ -89,7 +89,7 @@ let package = Package( ) EOF -swiftlint lint --autocorrect Package.swift +swiftlint lint --autocorrect Package.swift echo "" -echo "✅ Package.swift built successfully for version $RELEASE_VERSION!" \ No newline at end of file +echo "✅ Package.swift built successfully for version $RELEASE_VERSION!" diff --git a/swift/build_swift.sh b/swift/build_swift.sh new file mode 100755 index 000000000..f661e0bfb --- /dev/null +++ b/swift/build_swift.sh @@ -0,0 +1,131 @@ +#!/bin/bash +set -e + +# Creates a Swift build of the `WalletKit` library. +# This script can be used directly or called by other scripts. +# +# Usage: build_swift.sh [OUTPUT_DIR] +# OUTPUT_DIR: Directory where the XCFramework should be placed (default: swift/) + +PROJECT_ROOT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_PATH="$PROJECT_ROOT_PATH/swift" # The base path for the Swift build +PACKAGE_NAME="walletkit" +TARGET_DIR="$PROJECT_ROOT_PATH/target" +SUPPORT_SOURCES_DIR="$BASE_PATH/support" +CARGO_FEATURES="compress-zkeys" + +# Default values +OUTPUT_DIR="$BASE_PATH" # Default to BASE_PATH if not provided +FRAMEWORK="WalletKit.xcframework" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --help|-h) + echo "Usage: $0 [OUTPUT_DIR]" + echo "" + echo "Arguments:" + echo " OUTPUT_DIR Directory where the XCFramework should be placed (default: swift/)" + echo "" + exit 0 + ;; + *) + # Assume it's the output directory if it doesn't start with -- + if [[ ! "$1" =~ ^-- ]]; then + OUTPUT_DIR="$1" + else + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + fi + shift + ;; + esac +done + +# Resolve OUTPUT_DIR to absolute path if it's relative +if [[ "$OUTPUT_DIR" != /* ]]; then + OUTPUT_DIR="$BASE_PATH/$OUTPUT_DIR" +fi + +SWIFT_SOURCES_DIR="$OUTPUT_DIR/Sources/WalletKit" +SWIFT_HEADERS_DIR="$BASE_PATH/ios_build/Headers/WalletKit" +FRAMEWORK_OUTPUT="$OUTPUT_DIR/$FRAMEWORK" + +echo "Building $FRAMEWORK to $FRAMEWORK_OUTPUT" + +# Clean up previous builds +rm -rf "$BASE_PATH/ios_build" +rm -rf "$FRAMEWORK_OUTPUT" + +# Create necessary directories +mkdir -p "$BASE_PATH/ios_build/bindings" +mkdir -p "$BASE_PATH/ios_build/target/universal-ios-sim/release" +mkdir -p "$SWIFT_SOURCES_DIR" +mkdir -p "$SWIFT_HEADERS_DIR" + +echo "Building Rust packages for iOS targets..." + +export IPHONEOS_DEPLOYMENT_TARGET="13.0" +export RUSTFLAGS="-C link-arg=-Wl,-application_extension \ + -C link-arg=-Wl,-dead_strip \ + -C link-arg=-Wl,-dead_strip_dylibs \ + -C embed-bitcode=no" + +# Build for all iOS targets +cargo build --package $PACKAGE_NAME --target aarch64-apple-ios-sim --release \ + --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" --features "$CARGO_FEATURES" +cargo build --package $PACKAGE_NAME --target aarch64-apple-ios --release \ + --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" --features "$CARGO_FEATURES" +cargo build --package $PACKAGE_NAME --target x86_64-apple-ios --release \ + --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" --features "$CARGO_FEATURES" +echo "Rust packages built. Combining simulator targets into universal binary..." + +# Create universal binary for simulators +lipo -create "$TARGET_DIR/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.a" \ + "$TARGET_DIR/x86_64-apple-ios/release/lib${PACKAGE_NAME}.a" \ + -output $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a + +lipo -info $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a + +echo "Generating Swift bindings..." + +# Generate Swift bindings using uniffi +cargo run -p uniffi-bindgen --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" \ + --target-dir "$TARGET_DIR" -- generate \ + "$TARGET_DIR/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.dylib" \ + --library \ + --crate walletkit_core \ + --language swift \ + --no-format \ + --out-dir $BASE_PATH/ios_build/bindings + +# Move generated Swift file to Sources directory +mv $BASE_PATH/ios_build/bindings/walletkit_core.swift ${SWIFT_SOURCES_DIR}/walletkit.swift + +# Temporary workaround for UniFFI Swift callback vtable ASan crash. +# Upstream fix: https://github.com/mozilla/uniffi-rs/pull/2821 +# Remove this once the upstream PR is merged and released. +python3 "$BASE_PATH/patch_uniffi_swift_vtables.py" "${SWIFT_SOURCES_DIR}/walletkit.swift" + +# Copy support Swift sources for the WalletKit module. +if [ -d "$SUPPORT_SOURCES_DIR" ]; then + rsync -a "$SUPPORT_SOURCES_DIR"/ "$SWIFT_SOURCES_DIR"/ +fi + +# Move headers +mv $BASE_PATH/ios_build/bindings/walletkit_coreFFI.h $SWIFT_HEADERS_DIR/ +cat $BASE_PATH/ios_build/bindings/walletkit_coreFFI.modulemap > $SWIFT_HEADERS_DIR/module.modulemap + +echo "Creating XCFramework..." + +# Create XCFramework +xcodebuild -create-xcframework \ + -library "$TARGET_DIR/aarch64-apple-ios/release/lib${PACKAGE_NAME}.a" -headers $BASE_PATH/ios_build/Headers \ + -library $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a -headers $BASE_PATH/ios_build/Headers \ + -output $FRAMEWORK_OUTPUT + +# Clean up intermediate build files +rm -rf $BASE_PATH/ios_build + +echo "✅ Swift framework built successfully at: $FRAMEWORK_OUTPUT" diff --git a/swift/local_swift.sh b/swift/local_swift.sh new file mode 100755 index 000000000..11b74dcab --- /dev/null +++ b/swift/local_swift.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -e + +# Creates a Swift package of the `WalletKit` library for local development. +# This script builds the library and sets up the proper structure for importing +# via Swift Package Manager using a local file:// URL. +# All artifacts are placed in swift/local_build to keep the repo clean. + +PROJECT_ROOT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_PATH="$PROJECT_ROOT_PATH/swift" # The base path for the Swift build +LOCAL_BUILD_PATH="$BASE_PATH/local_build" # Local build artifacts directory +FRAMEWORK="WalletKit.xcframework" + +echo "Building $FRAMEWORK for local iOS development" + +# Clean up previous builds +rm -rf "$LOCAL_BUILD_PATH" + +# Create the local build directory +mkdir -p "$LOCAL_BUILD_PATH" + +echo "Running core Swift build..." + +# Call the main build script with local build directory +bash "$BASE_PATH/build_swift.sh" "$LOCAL_BUILD_PATH" + +echo "Creating Package.swift for local development..." + +# Create Package.swift for local development +cat > $LOCAL_BUILD_PATH/Package.swift << EOF +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "WalletKit", + platforms: [ + .iOS(.v13) + ], + products: [ + .library( + name: "WalletKit", + targets: ["WalletKit"]), + ], + targets: [ + .target( + name: "WalletKit", + dependencies: ["walletkit_coreFFI"], + path: "Sources/WalletKit" + ), + .binaryTarget( + name: "walletkit_coreFFI", + path: "WalletKit.xcframework" + ) + ] +) +EOF + +echo "" +echo "✅ Swift package built successfully!" +echo "" +echo "📦 Package location: $LOCAL_BUILD_PATH" +echo "" +echo "To use this package in your iOS app:" +echo "1. In Xcode, go to File → Add Package Dependencies..." +echo "2. Click 'Add Local...' and select the local_build directory: $LOCAL_BUILD_PATH" +echo "3. Or add it to your Package.swift dependencies:" +echo " .package(path: \"$LOCAL_BUILD_PATH\")" +echo "" +echo "The package exports the 'WalletKit' library that you can import in your Swift code." diff --git a/swift/patch_uniffi_swift_vtables.py b/swift/patch_uniffi_swift_vtables.py new file mode 100644 index 000000000..1b52b45a4 --- /dev/null +++ b/swift/patch_uniffi_swift_vtables.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Patch generated UniFFI Swift bindings to avoid 1-element vtable arrays. + +Why: +- UniFFI generates callback vtables as `[VTableType] = [VTableType(...)]`. +- On newer iOS/Swift toolchains with ASan enabled, this pattern can trigger + heap-buffer-overflow during static initialization. +- This patch matches the upstream fix from: + https://github.com/mozilla/uniffi-rs/pull/2821 +- This is a temporary downstream workaround until that PR is merged and + released in UniFFI. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +INTERFACES = [ + ( + "AtomicBlobStore", + "UniffiVTableCallbackInterfaceAtomicBlobStore", + "uniffi_walletkit_core_fn_init_callback_vtable_atomicblobstore", + ), + ( + "DeviceKeystore", + "UniffiVTableCallbackInterfaceDeviceKeystore", + "uniffi_walletkit_core_fn_init_callback_vtable_devicekeystore", + ), + ( + "Logger", + "UniffiVTableCallbackInterfaceLogger", + "uniffi_walletkit_core_fn_init_callback_vtable_logger", + ), + ( + "StorageProvider", + "UniffiVTableCallbackInterfaceStorageProvider", + "uniffi_walletkit_core_fn_init_callback_vtable_storageprovider", + ), +] + + +def patch_interface(text: str, interface: str, vtable_type: str, init_fn: str) -> tuple[str, bool]: + """Patch a single callback interface block.""" + old_static = f" static let vtable: [{vtable_type}] = [{vtable_type}(" + new_static = f" static let vtable: {vtable_type} = {vtable_type}(" + if old_static not in text: + return text, False + text = text.replace(old_static, new_static, 1) + + old_tail = ( + " )]\n" + "}\n\n" + f"private func uniffiCallbackInit{interface}() {{\n" + f" {init_fn}(UniffiCallbackInterface{interface}.vtable)\n" + "}\n" + ) + new_tail = ( + " )\n" + "\n" + " // Rust stores this pointer for future callback invocations, so it must live\n" + " // for the process lifetime (not just for the init function call).\n" + f" static let vtablePtr: UnsafePointer<{vtable_type}> = {{\n" + f" let ptr = UnsafeMutablePointer<{vtable_type}>.allocate(capacity: 1)\n" + " ptr.initialize(to: vtable)\n" + " return UnsafePointer(ptr)\n" + " }()\n" + "}\n\n" + f"private func uniffiCallbackInit{interface}() {{\n" + f" {init_fn}(UniffiCallbackInterface{interface}.vtablePtr)\n" + "}\n" + ) + + if old_tail not in text: + raise RuntimeError(f"Found vtable static for {interface}, but init function pattern did not match") + text = text.replace(old_tail, new_tail, 1) + return text, True + + +def patch_file(path: Path) -> int: + text = path.read_text(encoding="utf-8") + patched_count = 0 + + for interface, vtable_type, init_fn in INTERFACES: + text, patched = patch_interface(text, interface, vtable_type, init_fn) + if patched: + patched_count += 1 + + if patched_count == 0: + raise RuntimeError("No UniFFI callback vtable patterns found to patch") + if patched_count != len(INTERFACES): + raise RuntimeError( + f"Partially patched file ({patched_count}/{len(INTERFACES)} interfaces). " + "Refusing to continue." + ) + + path.write_text(text, encoding="utf-8") + return patched_count + + +def main() -> int: + parser = argparse.ArgumentParser(description="Patch UniFFI Swift callback vtable initialization") + parser.add_argument("swift_file", help="Path to generated walletkit.swift") + args = parser.parse_args() + + swift_path = Path(args.swift_file) + if not swift_path.exists(): + raise FileNotFoundError(f"Swift file not found: {swift_path}") + + patched_count = patch_file(swift_path) + print(f"Patched {patched_count} UniFFI callback interfaces in {swift_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/swift/support/IOSAtomicBlobStore.swift b/swift/support/IOSAtomicBlobStore.swift new file mode 100644 index 000000000..b7a350015 --- /dev/null +++ b/swift/support/IOSAtomicBlobStore.swift @@ -0,0 +1,48 @@ +import Foundation + +public final class IOSAtomicBlobStore: AtomicBlobStore { + private let baseURL: URL + private let fileManager = FileManager.default + + public init(baseURL: URL) { + self.baseURL = baseURL + } + + public func read(path: String) throws -> Data? { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.BlobStore("read failed: \(error)") + } + } + + public func writeAtomic(path: String, bytes: Data) throws { + let url = baseURL.appendingPathComponent(path) + let parent = url.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: parent, + withIntermediateDirectories: true + ) + try bytes.write(to: url, options: .atomic) + } catch { + throw StorageError.BlobStore("write failed: \(error)") + } + } + + public func delete(path: String) throws { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + throw StorageError.BlobStore("delete failed: file not found") + } + do { + try fileManager.removeItem(at: url) + } catch { + throw StorageError.BlobStore("delete failed: \(error)") + } + } +} diff --git a/swift/support/IOSDeviceKeystore.swift b/swift/support/IOSDeviceKeystore.swift new file mode 100644 index 000000000..d94753e14 --- /dev/null +++ b/swift/support/IOSDeviceKeystore.swift @@ -0,0 +1,131 @@ +import CryptoKit +import Foundation +import Security + +public final class IOSDeviceKeystore: DeviceKeystore { + private let service: String + private let account: String + private let lock = NSLock() + private static let fallbackLock = NSLock() + private static var fallbackKeys: [String: Data] = [:] + + public init( + service: String = "walletkit.devicekeystore", + account: String = "default" + ) { + self.service = service + self.account = account + } + + public func seal(associatedData: Data, plaintext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.seal( + plaintext, + using: key, + authenticating: associatedData + ) + guard let combined = sealedBox.combined else { + throw StorageError.Keystore("missing AES-GCM combined payload") + } + return combined + } + + public func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + return try AES.GCM.open( + sealedBox, + using: key, + authenticating: associatedData + ) + } + + private func loadOrCreateKey() throws -> SymmetricKey { + lock.lock() + defer { lock.unlock() } + + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.Keystore("random key generation failed: \(status)") + } + let keyData = Data(bytes) + + let addStatus = SecItemAdd( + keychainAddQuery(keyData: keyData) as CFDictionary, + nil + ) + if addStatus == errSecDuplicateItem { + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + throw StorageError.Keystore("keychain item duplicated but unreadable") + } + if addStatus == errSecMissingEntitlement { + Self.setFallbackKey(id: fallbackKeyId(), data: keyData) + return SymmetricKey(data: keyData) + } + guard addStatus == errSecSuccess else { + throw StorageError.Keystore("keychain add failed: \(addStatus)") + } + + return SymmetricKey(data: keyData) + } + + private func loadKeyData() throws -> Data? { + var query = keychainBaseQuery() + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + if status == errSecMissingEntitlement { + return Self.fallbackKey(id: fallbackKeyId()) + } + guard status == errSecSuccess else { + throw StorageError.Keystore("keychain read failed: \(status)") + } + guard let data = item as? Data else { + throw StorageError.Keystore("keychain read returned non-data") + } + return data + } + + private func keychainBaseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + } + + private func keychainAddQuery(keyData: Data) -> [String: Any] { + var query = keychainBaseQuery() + query[kSecValueData as String] = keyData + return query + } + + private func fallbackKeyId() -> String { + "\(service)::\(account)" + } + + private static func fallbackKey(id: String) -> Data? { + fallbackLock.lock() + defer { fallbackLock.unlock() } + return fallbackKeys[id] + } + + private static func setFallbackKey(id: String, data: Data) { + fallbackLock.lock() + defer { fallbackLock.unlock() } + fallbackKeys[id] = data + } +} diff --git a/swift/support/IOSStorageProvider.swift b/swift/support/IOSStorageProvider.swift new file mode 100644 index 000000000..5d8bd807a --- /dev/null +++ b/swift/support/IOSStorageProvider.swift @@ -0,0 +1,59 @@ +import Foundation + +public final class IOSStorageProvider: StorageProvider { + private let keystoreImpl: IOSDeviceKeystore + private let blobStoreImpl: IOSAtomicBlobStore + private let pathsImpl: StoragePaths + + public init( + rootDirectory: URL, + keystoreService: String = "walletkit.devicekeystore", + keystoreAccount: String = "default" + ) throws { + let worldidDir = rootDirectory.appendingPathComponent("worldid", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: worldidDir, + withIntermediateDirectories: true + ) + } catch { + throw StorageError.BlobStore("failed to create storage directory: \(error)") + } + + self.pathsImpl = StoragePaths.fromRoot(root: rootDirectory.path) + self.keystoreImpl = IOSDeviceKeystore( + service: keystoreService, + account: keystoreAccount + ) + self.blobStoreImpl = IOSAtomicBlobStore(baseURL: worldidDir) + } + + public func keystore() -> DeviceKeystore { + keystoreImpl + } + + public func blobStore() -> AtomicBlobStore { + blobStoreImpl + } + + public func paths() -> StoragePaths { + pathsImpl + } +} + +public enum WalletKitStorage { + public static func makeDefaultProvider( + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) throws -> IOSStorageProvider { + let fileManager = FileManager.default + guard let appSupport = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw StorageError.BlobStore("missing application support directory") + } + let bundleId = bundleIdentifier ?? "walletkit" + let root = appSupport.appendingPathComponent(bundleId, isDirectory: true) + return try IOSStorageProvider(rootDirectory: root) + } +} diff --git a/swift/test_swift.sh b/swift/test_swift.sh index a2778f8c2..22b964137 100755 --- a/swift/test_swift.sh +++ b/swift/test_swift.sh @@ -28,10 +28,10 @@ SOURCES_PATH_NAME="/Sources/WalletKit/" echo -e "${BLUE}🔨 Step 1: Building Swift bindings${NC}" # Run the build_swift.sh script from parent directory # Must cd to the repository root first because build script expects to run from there -cd "$BASE_PATH/.." && bash ./build_swift.sh +cd "$BASE_PATH/.." && bash ./swift/build_swift.sh # Check if the XCFramework was created -if [ ! -d "$BASE_PATH/../WalletKit.xcframework" ]; then +if [ ! -d "$BASE_PATH/WalletKit.xcframework" ]; then echo -e "${RED}✗ Failed to build XCFramework${NC}" exit 1 fi @@ -42,19 +42,11 @@ echo -e "${BLUE}📦 Step 2: Copying generated Swift files to test package${NC}" mkdir -p "$TESTS_PATH/$SOURCES_PATH_NAME" # Copy the generated Swift files to the test package -if [ -f "$BASE_PATH/../Sources/WalletKit/walletkit.swift" ]; then - cp "$BASE_PATH/../Sources/WalletKit/walletkit.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" +if [ -f "$BASE_PATH/Sources/WalletKit/walletkit.swift" ]; then + cp "$BASE_PATH/Sources/WalletKit/walletkit.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" echo -e "${GREEN}✅ walletkit.swift copied to test package${NC}" else - echo -e "${RED}✗ Could not find generated Swift bindings at: $BASE_PATH/../Sources/WalletKit/walletkit.swift${NC}" - exit 1 -fi - -if [ -f "$BASE_PATH/../Sources/WalletKit/walletkit_core.swift" ]; then - cp "$BASE_PATH/../Sources/WalletKit/walletkit_core.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" - echo -e "${GREEN}✅ walletkit_core.swift copied to test package${NC}" -else - echo -e "${RED}✗ Could not find generated Swift bindings at: $BASE_PATH/../Sources/WalletKit/walletkit_core.swift${NC}" + echo -e "${RED}✗ Could not find generated Swift bindings at: $BASE_PATH/Sources/WalletKit/walletkit.swift${NC}" exit 1 fi diff --git a/swift/tests/Package.swift b/swift/tests/Package.swift index 419c6b7ec..c90e62479 100644 --- a/swift/tests/Package.swift +++ b/swift/tests/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "WalletKitForeignTestPackage", platforms: [ .iOS(.v13), - .macOS(.v12), + .macOS(.v12) ], products: [ .library( @@ -21,12 +21,12 @@ let package = Package( ), .binaryTarget( name: "WalletKitFFI", - path: "../../WalletKit.xcframework" + path: "../WalletKit.xcframework" ), .testTarget( name: "WalletKitTests", dependencies: ["WalletKit"], path: "WalletKitTests" - ), + ) ] ) diff --git a/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift new file mode 100644 index 000000000..ff9f285df --- /dev/null +++ b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest +@testable import WalletKit + +final class AtomicBlobStoreTests: XCTestCase { + func testWriteReadDelete() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let store = TestIOSAtomicBlobStore(baseURL: root) + let path = "account_keys.bin" + let payload = Data([1, 2, 3, 4]) + + try store.writeAtomic(path: path, bytes: payload) + let readBack = try store.read(path: path) + + XCTAssertEqual(readBack, payload) + + try store.delete(path: path) + let afterDelete = try store.read(path: path) + XCTAssertNil(afterDelete) + } +} diff --git a/swift/tests/WalletKitTests/AuthenticatorTests.swift b/swift/tests/WalletKitTests/AuthenticatorTests.swift deleted file mode 100644 index 411e42e63..000000000 --- a/swift/tests/WalletKitTests/AuthenticatorTests.swift +++ /dev/null @@ -1,386 +0,0 @@ -import XCTest -@testable import WalletKit - -final class AuthenticatorTests: XCTestCase { - - let testRpcUrl = "https://worldchain-sepolia.g.alchemy.com/public" - - // MARK: - Helper Functions - - func generateRandomSeed() -> Data { - var bytes = [UInt8](repeating: 0, count: 32) - for i in 0..<32 { - bytes[i] = UInt8.random(in: 0...255) - } - return Data(bytes) - } - - // MARK: - U256Wrapper Tests - - func testU256WrapperFromU64() { - let value: UInt64 = 12345 - let u256 = U256Wrapper.fromU64(value: value) - XCTAssertEqual(u256.toDecimalString(), "12345") - } - - func testU256WrapperFromU32() { - let value: UInt32 = 54321 - let u256 = U256Wrapper.fromU32(value: value) - XCTAssertEqual(u256.toDecimalString(), "54321") - } - - func testU256WrapperFromU64MaxValue() { - // Test with max u64 value - let maxU64 = UInt64.max - let u256 = U256Wrapper.fromU64(value: maxU64) - XCTAssertEqual(u256.toDecimalString(), "18446744073709551615") - XCTAssertEqual(u256.toHexString(), "0x000000000000000000000000000000000000000000000000ffffffffffffffff") - } - - func testU256WrapperFromU32MaxValue() { - // Test with max u32 value - let maxU32 = UInt32.max - let u256 = U256Wrapper.fromU32(value: maxU32) - XCTAssertEqual(u256.toDecimalString(), "4294967295") - } - - func testU256WrapperTryFromHexString() throws { - let hexString = "0x1a2b3c4d5e6f" - let u256 = try U256Wrapper.tryFromHexString(hexString: hexString) - XCTAssertNotNil(u256) - // Verify the hex round-trips correctly - XCTAssertTrue(u256.toHexString().hasSuffix("1a2b3c4d5e6f")) - } - - func testU256WrapperTryFromHexStringWithoutPrefix() throws { - let hexString = "1a2b3c4d5e6f" - let u256 = try U256Wrapper.tryFromHexString(hexString: hexString) - XCTAssertNotNil(u256) - } - - func testU256WrapperDeterministicHexParsing() throws { - // Test with known values from Rust tests - let testCases: [(String, String, String)] = [ - ( - "0x0000000000000000000000000000000000000000000000000000000000000001", - "1", - "0x0000000000000000000000000000000000000000000000000000000000000001" - ), - ( - "0x000000000000000000000000000000000000000000000000000000000000002a", - "42", - "0x000000000000000000000000000000000000000000000000000000000000002a" - ), - ( - "0x00000000000000000000000000000000000000000000000000000000000f423f", - "999999", - "0x00000000000000000000000000000000000000000000000000000000000f423f" - ), - ( - "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6", - "80084422859880547211683076133703299733277748156566366325829078699459944778998", - "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6" - ), - ] - - for (hexInput, expectedDecimal, expectedHex) in testCases { - let u256 = try U256Wrapper.tryFromHexString(hexString: hexInput) - XCTAssertEqual(u256.toDecimalString(), expectedDecimal, "Decimal mismatch for \(hexInput)") - XCTAssertEqual(u256.toHexString(), expectedHex, "Hex mismatch for \(hexInput)") - } - } - - func testU256WrapperHexRoundTrip() throws { - // Test that parsing and formatting hex strings round-trips correctly - let hexStrings = [ - "0x0000000000000000000000000000000000000000000000000000000000000001", - "0x00000000000000000000000000000000000000000000000000000000000000ff", - "0x0000000000000000000000000000000000000000000000000000000000001234", - "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ] - - for hexString in hexStrings { - let u256 = try U256Wrapper.tryFromHexString(hexString: hexString) - XCTAssertEqual(u256.toHexString(), hexString, "Round-trip failed for \(hexString)") - } - } - - func testU256WrapperInvalidHexString() { - XCTAssertThrowsError(try U256Wrapper.tryFromHexString(hexString: "0xZZZ")) { error in - XCTAssertTrue(error is WalletKitError) - } - } - - func testU256WrapperInvalidHexStrings() { - // Test multiple invalid inputs - let invalidInputs = [ - "0xZZZZ", - "1g", - "not a hex string", - "0xGGGG", - ] - - for invalidInput in invalidInputs { - XCTAssertThrowsError(try U256Wrapper.tryFromHexString(hexString: invalidInput)) { error in - XCTAssertTrue(error is WalletKitError, "Should throw WalletKitError for: \(invalidInput)") - } - } - } - - func testU256WrapperEmptyString() throws { - // Empty string parses as 0 (after trimming "0x", "" is passed to radix parser) - let u256 = try U256Wrapper.tryFromHexString(hexString: "") - XCTAssertEqual(u256.toDecimalString(), "0") - XCTAssertEqual(u256.toHexString(), "0x0000000000000000000000000000000000000000000000000000000000000000") - } - - func testU256WrapperFromLimbs() throws { - // Test with simple value [1, 0, 0, 0] - let limbs: [UInt64] = [1, 0, 0, 0] - let u256 = try U256Wrapper.fromLimbs(limbs: limbs) - XCTAssertEqual(u256.toDecimalString(), "1") - } - - func testU256WrapperFromLimbsComplexValue() throws { - // Test with complex limb values from Rust tests - let limbs: [UInt64] = [1, 0, 0, 2161727821137838080] - let u256 = try U256Wrapper.fromLimbs(limbs: limbs) - XCTAssertEqual( - u256.toHexString(), - "0x1e00000000000000000000000000000000000000000000000000000000000001" - ) - } - - func testU256WrapperFromLimbsInvalidLength() { - // Must be exactly 4 limbs - XCTAssertThrowsError(try U256Wrapper.fromLimbs(limbs: [1, 0, 0])) { error in - XCTAssertTrue(error is WalletKitError) - } - - XCTAssertThrowsError(try U256Wrapper.fromLimbs(limbs: [1, 0, 0, 0, 5])) { error in - XCTAssertTrue(error is WalletKitError) - } - - XCTAssertThrowsError(try U256Wrapper.fromLimbs(limbs: [])) { error in - XCTAssertTrue(error is WalletKitError) - } - } - - func testU256WrapperToHexString() { - let u256 = U256Wrapper.fromU64(value: 42) - let hexString = u256.toHexString() - // Should be padded to 66 characters (0x + 64 hex digits) - XCTAssertEqual(hexString.count, 66) - XCTAssertTrue(hexString.hasPrefix("0x")) - XCTAssertTrue(hexString.hasSuffix("2a")) - } - - func testU256WrapperToHexStringPadding() { - // Test that small values are properly padded - let testCases: [(UInt64, String)] = [ - (1, "0x0000000000000000000000000000000000000000000000000000000000000001"), - (2, "0x0000000000000000000000000000000000000000000000000000000000000002"), - (255, "0x00000000000000000000000000000000000000000000000000000000000000ff"), - ] - - for (value, expectedHex) in testCases { - let u256 = U256Wrapper.fromU64(value: value) - XCTAssertEqual(u256.toHexString(), expectedHex) - } - } - - func testU256WrapperIntoLimbs() { - let u256 = U256Wrapper.fromU64(value: 12345) - let limbs = u256.intoLimbs() - XCTAssertEqual(limbs.count, 4) - XCTAssertEqual(limbs[0], 12345) - XCTAssertEqual(limbs[1], 0) - XCTAssertEqual(limbs[2], 0) - XCTAssertEqual(limbs[3], 0) - } - - func testU256WrapperLimbsRoundTrip() throws { - // Test that converting to/from limbs round-trips correctly - let originalLimbs: [UInt64] = [12345, 67890, 11111, 22222] - let u256 = try U256Wrapper.fromLimbs(limbs: originalLimbs) - let resultLimbs = u256.intoLimbs() - - XCTAssertEqual(resultLimbs, originalLimbs) - } - - func testU256WrapperZeroValue() { - let u256 = U256Wrapper.fromU64(value: 0) - XCTAssertEqual(u256.toDecimalString(), "0") - XCTAssertEqual(u256.toHexString(), "0x0000000000000000000000000000000000000000000000000000000000000000") - - let limbs = u256.intoLimbs() - XCTAssertEqual(limbs, [0, 0, 0, 0]) - } - - func testU256WrapperMultipleConversions() throws { - // Test creating U256 from different sources and verifying consistency - let value: UInt64 = 999999 - - let fromU64 = U256Wrapper.fromU64(value: value) - let fromHex = try U256Wrapper.tryFromHexString( - hexString: "0x00000000000000000000000000000000000000000000000000000000000f423f" - ) - let fromLimbs = try U256Wrapper.fromLimbs(limbs: [999999, 0, 0, 0]) - - // All should produce the same decimal string - XCTAssertEqual(fromU64.toDecimalString(), "999999") - XCTAssertEqual(fromHex.toDecimalString(), "999999") - XCTAssertEqual(fromLimbs.toDecimalString(), "999999") - - // All should produce the same hex string - let expectedHex = "0x00000000000000000000000000000000000000000000000000000000000f423f" - XCTAssertEqual(fromU64.toHexString(), expectedHex) - XCTAssertEqual(fromHex.toHexString(), expectedHex) - XCTAssertEqual(fromLimbs.toHexString(), expectedHex) - } - - // MARK: - Authenticator Initialization Tests - - func testInvalidSeedEmpty() async { - let emptySeed = Data() - - await XCTAssertThrowsErrorAsync( - try await Authenticator.initWithDefaults( - seed: emptySeed, - rpcUrl: testRpcUrl, - environment: .staging - ) - ) { error in - if let walletError = error as? WalletKitError, - case .InvalidInput(let attribute, _) = walletError { - XCTAssertEqual(attribute, "seed") - } else { - XCTFail("Expected InvalidInput for seed, got \(error)") - } - } - } - - func testInvalidSeedTooShort() async { - let shortSeed = Data(repeating: 0, count: 16) - - await XCTAssertThrowsErrorAsync( - try await Authenticator.initWithDefaults( - seed: shortSeed, - rpcUrl: testRpcUrl, - environment: .staging - ) - ) { error in - if let walletError = error as? WalletKitError, - case .InvalidInput(let attribute, _) = walletError { - XCTAssertEqual(attribute, "seed") - } else { - XCTFail("Expected InvalidInput for seed, got \(error)") - } - } - } - - func testInvalidSeedTooLong() async { - let longSeed = Data(repeating: 0, count: 64) - - await XCTAssertThrowsErrorAsync( - try await Authenticator.initWithDefaults( - seed: longSeed, - rpcUrl: testRpcUrl, - environment: .staging - ) - ) { error in - if let walletError = error as? WalletKitError, - case .InvalidInput(let attribute, _) = walletError { - XCTAssertEqual(attribute, "seed") - } else { - XCTFail("Expected InvalidInput for seed, got \(error)") - } - } - } - - func testInvalidRpcUrlEmpty() async { - let seed = generateRandomSeed() - - await XCTAssertThrowsErrorAsync( - try await Authenticator.initWithDefaults( - seed: seed, - rpcUrl: "", - environment: .staging - ) - ) { error in - if let walletError = error as? WalletKitError, - case .InvalidInput(let attribute, _) = walletError { - XCTAssertEqual(attribute, "rpc_url") - } else { - XCTFail("Expected InvalidInput for rpc_url, got \(error)") - } - } - } - - func testMultipleEnvironments() async { - let seed = generateRandomSeed() - let environments: [Environment] = [.staging, .production] - - for environment in environments { - await XCTAssertThrowsErrorAsync( - try await Authenticator.initWithDefaults( - seed: seed, - rpcUrl: testRpcUrl, - environment: environment - ) - ) { error in - // Should throw an error for non-existent account in any environment - XCTAssertTrue(error is WalletKitError, "Should throw WalletKitError for \(environment)") - } - } - } - - func testValidSeedLength() { - let validSeed = Data(repeating: 0, count: 32) - XCTAssertEqual(validSeed.count, 32, "Valid seed should be 32 bytes") - } - - func testGenerateRandomSeedLength() { - let seed = generateRandomSeed() - XCTAssertEqual(seed.count, 32, "Generated seed should be 32 bytes") - } - - func testGenerateRandomSeedRandomness() { - // Generate multiple seeds and verify they're different - let seed1 = generateRandomSeed() - let seed2 = generateRandomSeed() - let seed3 = generateRandomSeed() - - XCTAssertNotEqual(seed1, seed2, "Seeds should be random and different") - XCTAssertNotEqual(seed2, seed3, "Seeds should be random and different") - XCTAssertNotEqual(seed1, seed3, "Seeds should be random and different") - } - - // MARK: - Helper for async error assertions - - func XCTAssertThrowsErrorAsync( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, - line: UInt = #line, - _ errorHandler: (_ error: Error) -> Void = { _ in } - ) async { - do { - _ = try await expression() - XCTFail(message(), file: file, line: line) - } catch { - errorHandler(error) - } - } - - // MARK: - Environment Tests - - func testEnvironmentValues() { - // Just verify environments exist and can be created - let staging = Environment.staging - let production = Environment.production - - XCTAssertNotNil(staging) - XCTAssertNotNil(production) - } -} diff --git a/swift/tests/WalletKitTests/CredentialStoreTests.swift b/swift/tests/WalletKitTests/CredentialStoreTests.swift new file mode 100644 index 000000000..487fb7cb7 --- /dev/null +++ b/swift/tests/WalletKitTests/CredentialStoreTests.swift @@ -0,0 +1,301 @@ +import Foundation +import XCTest +@testable import WalletKit + +final class CredentialStoreTests: XCTestCase { + private let account = "test-account" + + func testMethodsRequireInit() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + XCTAssertThrowsError(try store.listCredentials( + issuerSchemaId: Optional.none, + now: 100 + )) { error in + XCTAssertEqual(error as? StorageError, .NotInitialized) + } + XCTAssertThrowsError(try store.merkleCacheGet(validUntil: 100)) { error in + XCTAssertEqual(error as? StorageError, .NotInitialized) + } + } + + func testInitRejectsLeafIndexMismatch() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + + XCTAssertThrowsError(try store.`init`(leafIndex: 43, now: 101)) { error in + guard case let .InvalidLeafIndex(expected, provided) = error as? StorageError else { + return XCTFail("Expected InvalidLeafIndex, got \(error)") + } + XCTAssertEqual(expected, 42) + XCTAssertEqual(provided, 43) + } + } + + func testInitIsIdempotentForSameLeafIndex() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + let credentialId = try store.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + + try store.`init`(leafIndex: 42, now: 101) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 102) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].credentialId, credentialId) + } + + func testStoreAndCacheFlows() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) + let blobStore = TestIOSAtomicBlobStore(baseURL: worldidDir) + let paths = StoragePaths.fromRoot(root: root.path) + + let store = try CredentialStore.newWithComponents( + paths: paths, + keystore: keystore, + blobStore: blobStore + ) + + try store.`init`(leafIndex: 42, now: 100) + XCTAssertNil(try store.merkleCacheGet(validUntil: 100)) + + let credentialId = try store.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: Data([4, 5, 6]), + now: 100 + ) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 101) + XCTAssertEqual(records.count, 1) + let record = records[0] + XCTAssertEqual(record.credentialId, credentialId) + XCTAssertEqual(record.issuerSchemaId, 7) + XCTAssertEqual(record.expiresAt, 1_800_000_000) + + let proofBytes = Data([9, 9, 9]) + try store.merkleCachePut( + proofBytes: proofBytes, + now: 100, + ttlSeconds: 60 + ) + let cached = try store.merkleCacheGet( + validUntil: 110 + ) + XCTAssertEqual(cached, proofBytes) + let expired = try store.merkleCacheGet(validUntil: 161) + XCTAssertNil(expired) + } + + func testListCredentialsFiltersByIssuerSchemaId() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 7, expiresAt: 1_800_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_900_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_900_000_000, + associatedData: nil, + now: 101 + ) + + let filtered = try store.listCredentials(issuerSchemaId: 7, now: 102) + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered[0].issuerSchemaId, 7) + } + + func testExpiredCredentialsAreFilteredOut() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 7, expiresAt: 120), + blindingFactor: sampleBlindingFactor(), + expiresAt: 120, + associatedData: nil, + now: 100 + ) + _ = try store.storeCredential( + credential: sampleCredential(issuerSchemaId: 8, expiresAt: 1_800_000_000), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 101 + ) + + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 121) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].issuerSchemaId, 8) + } + + func testStoragePathsMatchWorldIdLayout() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + let paths = try store.storagePaths() + XCTAssertEqual(paths.rootPathString(), root.path) + XCTAssertTrue(paths.worldidDirPathString().hasSuffix("/worldid")) + XCTAssertTrue(paths.vaultDbPathString().hasSuffix("/worldid/account.vault.sqlite")) + XCTAssertTrue(paths.cacheDbPathString().hasSuffix("/worldid/account.cache.sqlite")) + XCTAssertTrue(paths.lockPathString().hasSuffix("/worldid/lock")) + } + + func testMerkleCachePutRefreshesExistingEntry() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let store = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + + try store.`init`(leafIndex: 42, now: 100) + try store.merkleCachePut(proofBytes: Data([1, 2, 3]), now: 100, ttlSeconds: 10) + try store.merkleCachePut(proofBytes: Data([4, 5, 6]), now: 101, ttlSeconds: 60) + + let cached = try store.merkleCacheGet(validUntil: 120) + XCTAssertEqual(cached, Data([4, 5, 6])) + } + + func testReopenPersistsVaultAndCache() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let firstStore = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + try firstStore.`init`(leafIndex: 42, now: 100) + let credentialId = try firstStore.storeCredential( + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), + expiresAt: 1_800_000_000, + associatedData: nil, + now: 100 + ) + let proofBytes = Data([9, 9, 9]) + try firstStore.merkleCachePut(proofBytes: proofBytes, now: 100, ttlSeconds: 60) + + let reopenedStore = try CredentialStore.newWithComponents( + paths: StoragePaths.fromRoot(root: root.path), + keystore: TestIOSDeviceKeystore(service: service, account: account), + blobStore: TestIOSAtomicBlobStore( + baseURL: root.appendingPathComponent("worldid", isDirectory: true) + ) + ) + try reopenedStore.`init`(leafIndex: 42, now: 101) + + let records = try reopenedStore.listCredentials( + issuerSchemaId: Optional.none, + now: 102 + ) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records[0].credentialId, credentialId) + XCTAssertEqual(try reopenedStore.merkleCacheGet(validUntil: 120), proofBytes) + } +} diff --git a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift new file mode 100644 index 000000000..ad110842b --- /dev/null +++ b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift @@ -0,0 +1,70 @@ +import CryptoKit +import Foundation +import Security +import XCTest +@testable import WalletKit + +final class DeviceKeystoreTests: XCTestCase { + private let account = "test-account" + + func testSealAndOpenRoundTrip() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let associatedData = Data("ad".utf8) + let plaintext = Data("hello".utf8) + + let ciphertext = try keystore.seal( + associatedData: associatedData, + plaintext: plaintext + ) + let opened = try keystore.openSealed( + associatedData: associatedData, + ciphertext: ciphertext + ) + + XCTAssertEqual(opened, plaintext) + } + + func testAssociatedDataMismatchFails() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = TestIOSDeviceKeystore(service: service, account: account) + let plaintext = Data("secret".utf8) + + let ciphertext = try keystore.seal( + associatedData: Data("ad-1".utf8), + plaintext: plaintext + ) + + XCTAssertThrowsError( + try keystore.openSealed( + associatedData: Data("ad-2".utf8), + ciphertext: ciphertext + ) + ) + } + + func testReopenWithSameIdentityCanOpenCiphertext() throws { + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let firstKeystore = TestIOSDeviceKeystore(service: service, account: account) + let secondKeystore = TestIOSDeviceKeystore(service: service, account: account) + let associatedData = Data("ad".utf8) + let plaintext = Data("hello".utf8) + + let ciphertext = try firstKeystore.seal( + associatedData: associatedData, + plaintext: plaintext + ) + let opened = try secondKeystore.openSealed( + associatedData: associatedData, + ciphertext: ciphertext + ) + + XCTAssertEqual(opened, plaintext) + } +} diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/SimpleTest.swift new file mode 100644 index 000000000..7da1dce8a --- /dev/null +++ b/swift/tests/WalletKitTests/SimpleTest.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import WalletKit + +private final class CapturingLogger: WalletKit.Logger { + private let lock = NSLock() + private var entries: [(WalletKit.LogLevel, String)] = [] + + func log(level: WalletKit.LogLevel, message: String) { + lock.lock() + entries.append((level, message)) + lock.unlock() + } + + func snapshot() -> [(WalletKit.LogLevel, String)] { + lock.lock() + defer { lock.unlock() } + return entries + } +} + +final class SimpleTest: XCTestCase { + func testInitLoggingForwardsLevelAndMessage() { + let logger = CapturingLogger() + WalletKit.initLogging(logger: logger, level: .info) + WalletKit.emitLog(level: .info, message: "bridge test") + + Thread.sleep(forTimeInterval: 0.05) + + let entries = logger.snapshot() + XCTAssertFalse(entries.isEmpty, "expected at least one bridged log entry") + + let hasBridgedMessage = entries.contains { level, message in + level == .info && message.contains("bridge test") + } + XCTAssertTrue(hasBridgedMessage, "expected info-level bridged log") + } +} diff --git a/swift/tests/WalletKitTests/TestHelpers.swift b/swift/tests/WalletKitTests/TestHelpers.swift new file mode 100644 index 000000000..69a359fc3 --- /dev/null +++ b/swift/tests/WalletKitTests/TestHelpers.swift @@ -0,0 +1,40 @@ +import Foundation +import Security +@testable import WalletKit + +func makeTempDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "walletkit-tests-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +func uniqueKeystoreService() -> String { + "walletkit.devicekeystore.test.\(UUID().uuidString)" +} + +func deleteKeychainItem(service: String, account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) +} + +func sampleCredential( + issuerSchemaId: UInt64 = 7, + expiresAt: UInt64 = 1_800_000_000 +) throws -> Credential { + let sampleCredentialJSON = """ + {"id":13758530325042616850,"version":"V1","issuer_schema_id":\(issuerSchemaId),"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":\(expiresAt),"claims":["0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000","0x0000000000000000000000000000000000000000000000000000000000000000"],"associated_data_hash":"0x0000000000000000000000000000000000000000000000000000000000000000","signature":null,"issuer":"0100000000000000000000000000000000000000000000000000000000000000"} + """ + let bytes = Data(sampleCredentialJSON.utf8) + return try Credential.fromBytes(bytes: bytes) +} + +func sampleBlindingFactor() -> FieldElement { + FieldElement.fromU64(value: 17) +} diff --git a/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift b/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift new file mode 100644 index 000000000..e196af900 --- /dev/null +++ b/swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift @@ -0,0 +1,49 @@ +import Foundation +@testable import WalletKit + +final class TestIOSAtomicBlobStore: AtomicBlobStore { + private let baseURL: URL + private let fileManager = FileManager.default + + init(baseURL: URL) { + self.baseURL = baseURL + } + + func read(path: String) throws -> Data? { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + do { + return try Data(contentsOf: url) + } catch { + throw StorageError.BlobStore("read failed: \(error)") + } + } + + func writeAtomic(path: String, bytes: Data) throws { + let url = baseURL.appendingPathComponent(path) + let parent = url.deletingLastPathComponent() + do { + try fileManager.createDirectory( + at: parent, + withIntermediateDirectories: true + ) + try bytes.write(to: url, options: .atomic) + } catch { + throw StorageError.BlobStore("write failed: \(error)") + } + } + + func delete(path: String) throws { + let url = baseURL.appendingPathComponent(path) + guard fileManager.fileExists(atPath: url.path) else { + throw StorageError.BlobStore("delete failed: file not found") + } + do { + try fileManager.removeItem(at: url) + } catch { + throw StorageError.BlobStore("delete failed: \(error)") + } + } +} diff --git a/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift b/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift new file mode 100644 index 000000000..279242842 --- /dev/null +++ b/swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift @@ -0,0 +1,132 @@ +import CryptoKit +import Foundation +import Security +@testable import WalletKit + +final class TestIOSDeviceKeystore: DeviceKeystore { + private let service: String + private let account: String + private let lock = NSLock() + private static let fallbackLock = NSLock() + private static var fallbackKeys: [String: Data] = [:] + + init( + service: String = "walletkit.devicekeystore", + account: String = "default" + ) { + self.service = service + self.account = account + } + + func seal(associatedData: Data, plaintext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.seal( + plaintext, + using: key, + authenticating: associatedData + ) + guard let combined = sealedBox.combined else { + throw StorageError.Keystore("missing AES-GCM combined payload") + } + return combined + } + + func openSealed(associatedData: Data, ciphertext: Data) throws -> Data { + let key = try loadOrCreateKey() + let sealedBox = try AES.GCM.SealedBox(combined: ciphertext) + return try AES.GCM.open( + sealedBox, + using: key, + authenticating: associatedData + ) + } + + private func loadOrCreateKey() throws -> SymmetricKey { + lock.lock() + defer { lock.unlock() } + + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + + var bytes = [UInt8](repeating: 0, count: 32) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw StorageError.Keystore("random key generation failed: \(status)") + } + let keyData = Data(bytes) + + let addStatus = SecItemAdd( + keychainAddQuery(keyData: keyData) as CFDictionary, + nil + ) + if addStatus == errSecDuplicateItem { + if let data = try loadKeyData() { + return SymmetricKey(data: data) + } + throw StorageError.Keystore("keychain item duplicated but unreadable") + } + if addStatus == errSecMissingEntitlement { + Self.setFallbackKey(id: fallbackKeyId(), data: keyData) + return SymmetricKey(data: keyData) + } + guard addStatus == errSecSuccess else { + throw StorageError.Keystore("keychain add failed: \(addStatus)") + } + + return SymmetricKey(data: keyData) + } + + private func loadKeyData() throws -> Data? { + var query = keychainBaseQuery() + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + if status == errSecMissingEntitlement { + return Self.fallbackKey(id: fallbackKeyId()) + } + guard status == errSecSuccess else { + throw StorageError.Keystore("keychain read failed: \(status)") + } + guard let data = item as? Data else { + throw StorageError.Keystore("keychain read returned non-data") + } + return data + } + + private func keychainBaseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + } + + private func keychainAddQuery(keyData: Data) -> [String: Any] { + var query = keychainBaseQuery() + query[kSecValueData as String] = keyData + return query + } + + private func fallbackKeyId() -> String { + "\(service)::\(account)" + } + + private static func fallbackKey(id: String) -> Data? { + fallbackLock.lock() + defer { fallbackLock.unlock() } + return fallbackKeys[id] + } + + private static func setFallbackKey(id: String, data: Data) { + fallbackLock.lock() + defer { fallbackLock.unlock() } + fallbackKeys[id] = data + } +} diff --git a/uniffi-bindgen/Cargo.toml b/uniffi-bindgen/Cargo.toml index f67423338..985c99747 100644 --- a/uniffi-bindgen/Cargo.toml +++ b/uniffi-bindgen/Cargo.toml @@ -21,4 +21,7 @@ name = "uniffi-bindgen" path = "./src/uniffi_bindgen.rs" [dependencies] -uniffi = { workspace = true, features = ["cli"] } \ No newline at end of file +uniffi = { workspace = true, features = ["cli"] } + +[lints] +workspace = true diff --git a/uniffi-bindgen/src/uniffi_bindgen.rs b/uniffi-bindgen/src/uniffi_bindgen.rs index a01b54706..c5f89b922 100644 --- a/uniffi-bindgen/src/uniffi_bindgen.rs +++ b/uniffi-bindgen/src/uniffi_bindgen.rs @@ -1,3 +1,5 @@ +//! `UniFFI` binding generation binary for `WalletKit`. + fn main() { uniffi::uniffi_bindgen_main(); } diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index 926775e49..1e139eb30 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -23,49 +23,95 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } alloy-primitives = { workspace = true } +backon = "1.6" +base64 = { version = "0.22", optional = true } +ctor = "0.2" hex = "0.4" +hkdf = { version = "0.12", optional = true } log = "0.4" +rand = { version = "0.8", optional = true } reqwest = { version = "0.12", default-features = false, features = [ - "json", - "brotli", - "rustls-tls", + "json", + "brotli", + "rustls-tls", ] } ruint = { version = "1.17", default-features = false, features = [ - "alloc", - "ark-ff-04", + "alloc", + "ark-ff-04", ] } +rustls = { version = "0.23", features = ["ring"] } secrecy = "0.10" semaphore-rs = { version = "0.5" } serde = "1" serde_json = "1" +sha2 = { version = "0.10", optional = true } strum = { version = "0.27", features = ["derive"] } subtle = "2" thiserror = "2" tokio = { version = "1", features = ["sync"] } +tracing = "0.1" +tracing-log = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +zeroize = "1" +uuid = { version = "1.10", features = ["v4"], optional = true } uniffi = { workspace = true, features = ["build", "tokio"] } -world-id-core = { workspace = true, optional = true } +taceo-oprf = { version = "0.7", default-features = false, features = ["client"] } +world-id-core = { workspace = true, features = [ + "authenticator", + "embed-zkeys", + "zstd-compress-zkeys", +] } +ciborium = { version = "0.2.2", optional = true } +walletkit-db = { path = "../walletkit-db", optional = true } [dev-dependencies] -alloy = { version = "1", default-features = false, features = ["getrandom", "json", "contract", "node-bindings", "signer-local"] } +alloy = { version = "1", default-features = false, features = [ + "getrandom", + "json", + "contract", + "node-bindings", + "signer-local", +] } +chacha20poly1305 = "0.10" chrono = "0.4.41" dotenvy = "0.15.7" +eyre = "0.6" mockito = "1.6" regex = "1.11" rustls = { version = "0.23", features = ["ring"] } +taceo-oprf = { version = "0.7", default-features = false } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tokio-test = "0.4" -world-id-core = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } rand = "0.8" - [features] -default = ["common-apps", "semaphore", "v4"] +default = ["common-apps", "semaphore", "storage", "issuers"] + +# Enables point-compression on all verifying keys. This results in ~2x smaller bundle size, but decompression is very expensive, +# using it requires proper handling to ensure decompression is done once and cached. By default walletkit-swift and walletkit-android +# ship with compressed keys. But this is disabled for tests. +compress-zkeys = ["world-id-core/compress-zkeys"] +issuers = ["dep:base64"] +storage = [ + "dep:ciborium", + "dep:hkdf", + "dep:rand", + "dep:sha2", + "dep:uuid", + "dep:walletkit-db", +] + +# SECTION: V3 Feature Flags common-apps = [] -http-tests = [] semaphore = ["semaphore-rs/depth_30"] -v4 = ["world-id-core"] # Before conventions were introduced for external nullifiers with `app_id` & `action`, raw field elements were used. # This feature flag adds support to operate with such external nullifiers. legacy-nullifiers = [] +[lints] +workspace = true + [package.metadata.docs.rs] no-default-features = true +features = ["issuers", "storage"] diff --git a/walletkit-core/src/authenticator.rs b/walletkit-core/src/authenticator.rs deleted file mode 100644 index fcf2cffaf..000000000 --- a/walletkit-core/src/authenticator.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! The Authenticator is the main component with which users interact with the World ID Protocol. - -use alloy_primitives::Address; -use world_id_core::{ - primitives::Config, types::GatewayRequestState, Authenticator as CoreAuthenticator, - InitializingAuthenticator as CoreInitializingAuthenticator, -}; - -use crate::{ - defaults::DefaultConfig, error::WalletKitError, - primitives::ParseFromForeignBinding, Environment, U256Wrapper, -}; - -/// The Authenticator is the main component with which users interact with the World ID Protocol. -#[derive(Debug, uniffi::Object)] -pub struct Authenticator(CoreAuthenticator); - -#[uniffi::export(async_runtime = "tokio")] -impl Authenticator { - /// Initializes a new Authenticator from a seed and with SDK defaults. - /// - /// The user's World ID must already be registered in the `WorldIDRegistry`, - /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned. - /// - /// # Errors - /// See `CoreAuthenticator::init` for potential errors. - #[uniffi::constructor] - pub async fn init_with_defaults( - seed: &[u8], - rpc_url: Option, - environment: &Environment, - ) -> Result { - let config = Config::from_environment(environment, rpc_url)?; - let authenticator = CoreAuthenticator::init(seed, config).await?; - Ok(Self(authenticator)) - } - - /// Initializes a new Authenticator from a seed and config. - /// - /// The user's World ID must already be registered in the `WorldIDRegistry`, - /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned. - /// - /// # Errors - /// Will error if the provided seed is not valid or if the config is not valid. - #[uniffi::constructor] - pub async fn init(seed: &[u8], config: &str) -> Result { - let config = - Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { - attribute: "config".to_string(), - reason: "Invalid config".to_string(), - })?; - let authenticator = CoreAuthenticator::init(seed, config).await?; - Ok(Self(authenticator)) - } - - /// Initializes (if the World ID already exists) or registers a new World ID with SDK defaults. - /// - /// This method will block until the registration is in a final state (success or terminal error). - /// See `CoreAuthenticator::init_or_register` for more details. - /// - /// # Errors - /// See `CoreAuthenticator::init_or_register` for potential errors. - #[uniffi::constructor] - pub async fn init_or_register_with_defaults( - seed: &[u8], - rpc_url: Option, - environment: &Environment, - recovery_address: Option, - ) -> Result { - let recovery_address = - Address::parse_from_ffi_optional(recovery_address, "recovery_address")?; - - let config = Config::from_environment(environment, rpc_url)?; - - let authenticator = - CoreAuthenticator::init_or_register(seed, config, recovery_address).await?; - - Ok(Self(authenticator)) - } - - /// Initializes (if the World ID already exists) or registers a new World ID. - /// - /// This method will block until the registration is in a final state (success or terminal error). - /// See `CoreAuthenticator::init_or_register` for more details. - /// - /// # Errors - /// See `CoreAuthenticator::init_or_register` for potential errors. - #[uniffi::constructor] - pub async fn init_or_register( - seed: &[u8], - config: &str, - recovery_address: Option, - ) -> Result { - let recovery_address = - Address::parse_from_ffi_optional(recovery_address, "recovery_address")?; - - let config = - Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { - attribute: "config".to_string(), - reason: "Invalid config".to_string(), - })?; - - let authenticator = - CoreAuthenticator::init_or_register(seed, config, recovery_address).await?; - - Ok(Self(authenticator)) - } - - /// Returns the packed account data for the holder's World ID. - /// - /// The packed account data is a 256 bit integer which includes the user's leaf index, their recovery counter, - /// and their pubkey id/commitment. - #[must_use] - pub fn packed_account_data(&self) -> U256Wrapper { - self.0.packed_account_data.into() - } - - /// Returns the leaf index for the holder's World ID. - /// - /// This is the index in the Merkle tree where the holder's World ID account is registered. It - /// should only be used inside the authenticator and never shared. - #[must_use] - pub fn leaf_index(&self) -> U256Wrapper { - self.0.leaf_index().into() - } - - /// Returns the Authenticator's `onchain_address`. - /// - /// See `world_id_core::Authenticator::onchain_address` for more details. - #[must_use] - pub fn onchain_address(&self) -> String { - self.0.onchain_address().to_string() - } - - /// Returns the packed account data for the holder's World ID fetching it from the on-chain registry. - /// - /// # Errors - /// Will error if the provided RPC URL is not valid or if there are RPC call failures. - pub async fn get_packed_account_data_remote( - &self, - ) -> Result { - let client = reqwest::Client::new(); // TODO: reuse client - let packed_account_data = CoreAuthenticator::get_packed_account_data( - self.0.onchain_address(), - self.0.registry().as_deref(), - &self.0.config, - &client, - ) - .await?; - Ok(packed_account_data.into()) - } -} - -/// Registration status for a World ID being created through the gateway. -#[derive(Debug, Clone, uniffi::Enum)] -pub enum RegistrationStatus { - /// Request queued but not yet batched. - Queued, - /// Request currently being batched. - Batching, - /// Request submitted on-chain. - Submitted, - /// Request finalized on-chain. The World ID is now registered. - Finalized, - /// Request failed during processing. - Failed { - /// Error message returned by the gateway. - error: String, - /// Specific error code, if available. - error_code: Option, - }, -} - -impl From for RegistrationStatus { - fn from(state: GatewayRequestState) -> Self { - match state { - GatewayRequestState::Queued => Self::Queued, - GatewayRequestState::Batching => Self::Batching, - GatewayRequestState::Submitted { .. } => Self::Submitted, - GatewayRequestState::Finalized { .. } => Self::Finalized, - GatewayRequestState::Failed { error, error_code } => Self::Failed { - error, - error_code: error_code.map(|c| c.to_string()), - }, - } - } -} - -/// Represents an Authenticator in the process of being initialized. -/// -/// The account is not yet registered in the `WorldIDRegistry` contract. -/// Use this for non-blocking registration flows where you want to poll the status yourself. -#[derive(uniffi::Object)] -pub struct InitializingAuthenticator(CoreInitializingAuthenticator); - -#[uniffi::export(async_runtime = "tokio")] -impl InitializingAuthenticator { - /// Registers a new World ID with SDK defaults. - /// - /// This returns immediately and does not wait for registration to complete. - /// The returned `InitializingAuthenticator` can be used to poll the registration status. - /// - /// # Errors - /// See `CoreAuthenticator::register` for potential errors. - #[uniffi::constructor] - pub async fn register_with_defaults( - seed: &[u8], - rpc_url: Option, - environment: &Environment, - recovery_address: Option, - ) -> Result { - let recovery_address = - Address::parse_from_ffi_optional(recovery_address, "recovery_address")?; - - let config = Config::from_environment(environment, rpc_url)?; - - let initializing_authenticator = - CoreAuthenticator::register(seed, config, recovery_address).await?; - - Ok(Self(initializing_authenticator)) - } - - /// Registers a new World ID. - /// - /// This returns immediately and does not wait for registration to complete. - /// The returned `InitializingAuthenticator` can be used to poll the registration status. - /// - /// # Errors - /// See `CoreAuthenticator::register` for potential errors. - #[uniffi::constructor] - pub async fn register( - seed: &[u8], - config: &str, - recovery_address: Option, - ) -> Result { - let recovery_address = - Address::parse_from_ffi_optional(recovery_address, "recovery_address")?; - - let config = - Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { - attribute: "config".to_string(), - reason: "Invalid config".to_string(), - })?; - - let initializing_authenticator = - CoreAuthenticator::register(seed, config, recovery_address).await?; - - Ok(Self(initializing_authenticator)) - } - - /// Polls the registration status from the gateway. - /// - /// # Errors - /// Will error if the network request fails or the gateway returns an error. - pub async fn poll_status(&self) -> Result { - let status = self.0.poll_status().await?; - Ok(status.into()) - } -} - -#[cfg(test)] -mod tests { - use alloy::primitives::address; - - use super::*; - - #[tokio::test] - async fn test_init_with_config() { - // Install default crypto provider for rustls - let _ = rustls::crypto::ring::default_provider().install_default(); - - let mut mock_server = mockito::Server::new_async().await; - - // Mock eth_call to return account data indicating account exists - mock_server - .mock("POST", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "result": "0x0000000000000000000000000000000000000000000000000000000000000001" - }) - .to_string(), - ) - .create_async() - .await; - - let seed = [2u8; 32]; - let config = Config::new( - Some(mock_server.url()), - 480, - address!("0xd66aFbf92d684B4404B1ed3e9aDA85353c178dE2"), - "https://world-id-indexer.stage-crypto.worldcoin.org".to_string(), - "https://world-id-gateway.stage-crypto.worldcoin.org".to_string(), - vec![], - 2, - ) - .unwrap(); - let config = serde_json::to_string(&config).unwrap(); - Authenticator::init(&seed, &config).await.unwrap(); - drop(mock_server); - } -} diff --git a/walletkit-core/src/authenticator/mod.rs b/walletkit-core/src/authenticator/mod.rs new file mode 100644 index 000000000..c1320965a --- /dev/null +++ b/walletkit-core/src/authenticator/mod.rs @@ -0,0 +1,571 @@ +//! The Authenticator is the main component with which users interact with the World ID Protocol. + +use alloy_primitives::Address; +use rand::rngs::OsRng; +use std::sync::Arc; +use world_id_core::{ + api_types::{GatewayErrorCode, GatewayRequestState}, + primitives::Config, + requests::{ProofResponse as CoreProofResponse, ResponseItem}, + Authenticator as CoreAuthenticator, Credential as CoreCredential, + FieldElement as CoreFieldElement, + InitializingAuthenticator as CoreInitializingAuthenticator, +}; + +#[cfg(feature = "storage")] +use crate::storage::{CredentialStore, StoragePaths}; +use crate::{ + defaults::DefaultConfig, + error::WalletKitError, + primitives::ParseFromForeignBinding, + requests::{ProofRequest, ProofResponse}, + Environment, FieldElement, Region, U256Wrapper, +}; + +#[cfg(feature = "storage")] +mod with_storage; + +type Groth16Materials = ( + Arc, + Arc, +); + +#[cfg(not(feature = "storage"))] +/// Loads embedded Groth16 query/nullifier material for authenticator initialization. +/// +/// # Errors +/// Returns an error if embedded material cannot be loaded or verified. +fn load_embedded_materials() -> Result { + let query_material = + world_id_core::proof::load_embedded_query_material().map_err(|error| { + WalletKitError::Groth16MaterialEmbeddedLoad { + error: error.to_string(), + } + })?; + let nullifier_material = world_id_core::proof::load_embedded_nullifier_material() + .map_err(|error| { + WalletKitError::Groth16MaterialEmbeddedLoad { + error: error.to_string(), + } + })?; + + Ok((Arc::new(query_material), Arc::new(nullifier_material))) +} + +#[cfg(feature = "storage")] +/// Loads cached Groth16 query/nullifier material from the provided storage paths. +/// +/// # Errors +/// Returns an error if cached material cannot be loaded or verified. +fn load_cached_materials( + paths: &StoragePaths, +) -> Result { + let query_zkey = paths.query_zkey_path(); + let nullifier_zkey = paths.nullifier_zkey_path(); + let query_graph = paths.query_graph_path(); + let nullifier_graph = paths.nullifier_graph_path(); + + let query_material = load_query_material_from_cache(&query_zkey, &query_graph)?; + let nullifier_material = + load_nullifier_material_from_cache(&nullifier_zkey, &nullifier_graph)?; + + Ok((Arc::new(query_material), Arc::new(nullifier_material))) +} + +#[cfg(feature = "storage")] +/// Loads cached query material from zkey/graph paths. +/// +/// # Errors +/// Returns an error if the cached query material cannot be loaded or verified. +fn load_query_material_from_cache( + query_zkey: &std::path::Path, + query_graph: &std::path::Path, +) -> Result { + world_id_core::proof::load_query_material_from_paths(query_zkey, query_graph) + .map_err(|error| WalletKitError::Groth16MaterialCacheInvalid { + path: format!( + "{} and {}", + query_zkey.to_string_lossy(), + query_graph.to_string_lossy() + ), + error: error.to_string(), + }) +} + +#[cfg(feature = "storage")] +#[expect( + clippy::unnecessary_wraps, + reason = "Temporary wrapper until world-id-core returns Result for nullifier path loader" +)] +/// Loads cached nullifier material from zkey/graph paths. +/// +/// # Errors +/// This currently mirrors a panicking upstream API and does not return an error path yet. +/// It is intentionally wrapped in `Result` for forward compatibility with upstream. +fn load_nullifier_material_from_cache( + nullifier_zkey: &std::path::Path, + nullifier_graph: &std::path::Path, +) -> Result { + // TODO: Switch to error mapping once world-id-core exposes + // `load_nullifier_material_from_paths` as `Result` instead of panicking. + Ok(world_id_core::proof::load_nullifier_material_from_paths( + nullifier_zkey, + nullifier_graph, + )) +} + +/// The Authenticator is the main component with which users interact with the World ID Protocol. +#[derive(Debug, uniffi::Object)] +pub struct Authenticator { + inner: CoreAuthenticator, + #[cfg(feature = "storage")] + store: Arc, +} + +#[uniffi::export(async_runtime = "tokio")] +impl Authenticator { + /// Returns the packed account data for the holder's World ID. + /// + /// The packed account data is a 256 bit integer which includes the user's leaf index, their recovery counter, + /// and their pubkey id/commitment. + #[must_use] + pub fn packed_account_data(&self) -> U256Wrapper { + self.inner.packed_account_data.into() + } + + /// Returns the leaf index for the holder's World ID. + /// + /// This is the index in the Merkle tree where the holder's World ID account is registered. It + /// should only be used inside the authenticator and never shared. + #[must_use] + pub fn leaf_index(&self) -> u64 { + self.inner.leaf_index() + } + + /// Returns the Authenticator's `onchain_address`. + /// + /// See `world_id_core::Authenticator::onchain_address` for more details. + #[must_use] + pub fn onchain_address(&self) -> String { + self.inner.onchain_address().to_string() + } + + /// Returns the packed account data for the holder's World ID fetching it from the on-chain registry. + /// + /// # Errors + /// Will error if the provided RPC URL is not valid or if there are RPC call failures. + pub async fn get_packed_account_data_remote( + &self, + ) -> Result { + let client = reqwest::Client::new(); // TODO: reuse client + let packed_account_data = CoreAuthenticator::get_packed_account_data( + self.inner.onchain_address(), + self.inner.registry().as_deref(), + &self.inner.config, + &client, + ) + .await?; + Ok(packed_account_data.into()) + } + + /// Generates a blinding factor for a Credential sub (through OPRF Nodes). + /// + /// See [`CoreAuthenticator::generate_credential_blinding_factor`] for more details. + /// + /// # Errors + /// + /// - Will generally error if there are network issues or if the OPRF Nodes return an error. + /// - Raises an error if the OPRF Nodes configuration is not correctly set. + pub async fn generate_credential_blinding_factor_remote( + &self, + issuer_schema_id: u64, + ) -> Result { + Ok(self + .inner + .generate_credential_blinding_factor(issuer_schema_id) + .await + .map(Into::into)?) + } + + /// Compute the `sub` for a credential from the authenticator's leaf index and a `blinding_factor`. + #[must_use] + pub fn compute_credential_sub( + &self, + blinding_factor: &FieldElement, + ) -> FieldElement { + CoreCredential::compute_sub(self.inner.leaf_index(), blinding_factor.0).into() + } +} + +#[cfg(not(feature = "storage"))] +#[uniffi::export(async_runtime = "tokio")] +impl Authenticator { + /// Initializes a new Authenticator from a seed and with SDK defaults. + /// + /// The user's World ID must already be registered in the `WorldIDRegistry`, + /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned. + /// + /// # Errors + /// See `CoreAuthenticator::init` for potential errors. + #[uniffi::constructor] + pub async fn init_with_defaults( + seed: &[u8], + rpc_url: Option, + environment: &Environment, + region: Option, + ) -> Result { + let config = Config::from_environment(environment, rpc_url, region)?; + let (query_material, nullifier_material) = load_embedded_materials()?; + let authenticator = + CoreAuthenticator::init(seed, config, query_material, nullifier_material) + .await?; + Ok(Self { + inner: authenticator, + }) + } + + /// Initializes a new Authenticator from a seed and config. + /// + /// The user's World ID must already be registered in the `WorldIDRegistry`, + /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned. + /// + /// # Errors + /// Will error if the provided seed is not valid or if the config is not valid. + #[uniffi::constructor] + pub async fn init(seed: &[u8], config: &str) -> Result { + let config = + Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { + attribute: "config".to_string(), + reason: "Invalid config".to_string(), + })?; + let (query_material, nullifier_material) = load_embedded_materials()?; + let authenticator = + CoreAuthenticator::init(seed, config, query_material, nullifier_material) + .await?; + Ok(Self { + inner: authenticator, + }) + } +} + +#[cfg(feature = "storage")] +#[uniffi::export(async_runtime = "tokio")] +impl Authenticator { + /// Initializes a new Authenticator from a seed and with SDK defaults. + /// + /// The user's World ID must already be registered in the `WorldIDRegistry`, + /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned. + /// + /// # Errors + /// See `CoreAuthenticator::init` for potential errors. + #[uniffi::constructor] + pub async fn init_with_defaults( + seed: &[u8], + rpc_url: Option, + environment: &Environment, + region: Option, + paths: Arc, + store: Arc, + ) -> Result { + let config = Config::from_environment(environment, rpc_url, region)?; + let (query_material, nullifier_material) = + load_cached_materials(paths.as_ref())?; + let authenticator = + CoreAuthenticator::init(seed, config, query_material, nullifier_material) + .await?; + Ok(Self { + inner: authenticator, + store, + }) + } + + /// Initializes a new Authenticator from a seed and config. + /// + /// The user's World ID must already be registered in the `WorldIDRegistry`, + /// otherwise a [`WalletKitError::AccountDoesNotExist`] error will be returned. + /// + /// # Errors + /// Will error if the provided seed is not valid or if the config is not valid. + #[uniffi::constructor] + pub async fn init( + seed: &[u8], + config: &str, + paths: Arc, + store: Arc, + ) -> Result { + let config = + Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { + attribute: "config".to_string(), + reason: "Invalid config".to_string(), + })?; + let (query_material, nullifier_material) = + load_cached_materials(paths.as_ref())?; + let authenticator = + CoreAuthenticator::init(seed, config, query_material, nullifier_material) + .await?; + Ok(Self { + inner: authenticator, + store, + }) + } + + /// Generates a proof for the given proof request. + /// + /// # Errors + /// Returns an error if proof generation fails. + pub async fn generate_proof( + &self, + proof_request: &ProofRequest, + now: Option, + ) -> Result { + let now = if let Some(n) = now { + n + } else { + let start = std::time::SystemTime::now(); + start + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| WalletKitError::Generic { + error: format!("Critical. Unable to determine SystemTime: {e}"), + })? + .as_secs() + }; + + // First check if the request can be fulfilled and which credentials should be used + let credential_list = self.store.list_credentials(None, now)?; + let credential_list = credential_list + .into_iter() + .map(|cred| cred.issuer_schema_id) + .collect::>(); + let credentials_to_prove = proof_request + .0 + .credentials_to_prove(&credential_list) + .ok_or(WalletKitError::UnfulfillableRequest)?; + + let (inclusion_proof, key_set) = + self.fetch_inclusion_proof_with_cache(now).await?; + + // Next, generate the nullifier and check the replay guard + let nullifier = self + .inner + .generate_nullifier(&proof_request.0, inclusion_proof, key_set) + .await?; + + // NOTE: In a normal flow this error can not be triggered since OPRF nodes have their own + // replay protection so the function will fail before this when attempting to generate the nullifier + if self + .store + .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)? + { + return Err(WalletKitError::NullifierReplay); + } + + let mut responses: Vec = vec![]; + + for request_item in credentials_to_prove { + let (credential, blinding_factor) = self + .store + .get_credential(request_item.issuer_schema_id, now)? + .ok_or(WalletKitError::CredentialNotIssued)?; + + let session_id_r_seed = CoreFieldElement::random(&mut OsRng); // TODO: Properly fetch session seed from cache + + let response_item = self.inner.generate_single_proof( + nullifier.clone(), + request_item, + &credential, + blinding_factor.0, + session_id_r_seed, + proof_request.0.session_id, + proof_request.0.created_at, + )?; + responses.push(response_item); + } + + let response = CoreProofResponse { + id: proof_request.0.id.clone(), + version: world_id_core::requests::RequestVersion::V1, + responses, + error: None, + session_id: None, // TODO: This needs to be computed to be shareable + }; + + proof_request + .0 + .validate_response(&response) + .map_err(|err| WalletKitError::ResponseValidation(err.to_string()))?; + + self.store + .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?; + + Ok(response.into()) + } +} + +/// Registration status for a World ID being created through the gateway. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum RegistrationStatus { + /// Request queued but not yet batched. + Queued, + /// Request currently being batched. + Batching, + /// Request submitted on-chain. + Submitted, + /// Request finalized on-chain. The World ID is now registered. + Finalized, + /// Request failed during processing. + Failed { + /// Error message returned by the gateway. + error: String, + /// Specific error code, if available. + error_code: Option, + }, +} + +impl From for RegistrationStatus { + fn from(state: GatewayRequestState) -> Self { + match state { + GatewayRequestState::Queued => Self::Queued, + GatewayRequestState::Batching => Self::Batching, + GatewayRequestState::Submitted { .. } => Self::Submitted, + GatewayRequestState::Finalized { .. } => Self::Finalized, + GatewayRequestState::Failed { error, error_code } => Self::Failed { + error, + error_code: error_code.map(|c: GatewayErrorCode| c.to_string()), + }, + } + } +} + +/// Represents an Authenticator in the process of being initialized. +/// +/// The account is not yet registered in the `WorldIDRegistry` contract. +/// Use this for non-blocking registration flows where you want to poll the status yourself. +#[derive(uniffi::Object)] +pub struct InitializingAuthenticator(CoreInitializingAuthenticator); + +#[uniffi::export(async_runtime = "tokio")] +impl InitializingAuthenticator { + /// Registers a new World ID with SDK defaults. + /// + /// This returns immediately and does not wait for registration to complete. + /// The returned `InitializingAuthenticator` can be used to poll the registration status. + /// + /// # Errors + /// See `CoreAuthenticator::register` for potential errors. + #[uniffi::constructor] + pub async fn register_with_defaults( + seed: &[u8], + rpc_url: Option, + environment: &Environment, + region: Option, + recovery_address: Option, + ) -> Result { + let recovery_address = + Address::parse_from_ffi_optional(recovery_address, "recovery_address")?; + + let config = Config::from_environment(environment, rpc_url, region)?; + + let initializing_authenticator = + CoreAuthenticator::register(seed, config, recovery_address).await?; + + Ok(Self(initializing_authenticator)) + } + + /// Registers a new World ID. + /// + /// This returns immediately and does not wait for registration to complete. + /// The returned `InitializingAuthenticator` can be used to poll the registration status. + /// + /// # Errors + /// See `CoreAuthenticator::register` for potential errors. + #[uniffi::constructor] + pub async fn register( + seed: &[u8], + config: &str, + recovery_address: Option, + ) -> Result { + let recovery_address = + Address::parse_from_ffi_optional(recovery_address, "recovery_address")?; + + let config = + Config::from_json(config).map_err(|_| WalletKitError::InvalidInput { + attribute: "config".to_string(), + reason: "Invalid config".to_string(), + })?; + + let initializing_authenticator = + CoreAuthenticator::register(seed, config, recovery_address).await?; + + Ok(Self(initializing_authenticator)) + } + + /// Polls the registration status from the gateway. + /// + /// # Errors + /// Will error if the network request fails or the gateway returns an error. + pub async fn poll_status(&self) -> Result { + let status = self.0.poll_status().await?; + Ok(status.into()) + } +} + +#[cfg(all(test, feature = "storage"))] +mod tests { + use super::*; + use crate::storage::cache_embedded_groth16_material; + use crate::storage::tests_utils::{ + cleanup_test_storage, temp_root_path, InMemoryStorageProvider, + }; + use alloy::primitives::address; + + #[tokio::test] + async fn test_init_with_config_and_storage() { + // Install default crypto provider for rustls + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut mock_server = mockito::Server::new_async().await; + + // Mock eth_call to return account data indicating account exists + mock_server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000001" + }) + .to_string(), + ) + .create_async() + .await; + + let seed = [2u8; 32]; + let config = Config::new( + Some(mock_server.url()), + 480, + address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"), + "https://world-id-indexer.stage-crypto.worldcoin.org".to_string(), + "https://world-id-gateway.stage-crypto.worldcoin.org".to_string(), + vec![], + 2, + ) + .unwrap(); + let config = serde_json::to_string(&config).unwrap(); + + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("store"); + store.init(42, 100).expect("init storage"); + cache_embedded_groth16_material(store.storage_paths().expect("paths")) + .expect("cache material"); + + let paths = store.storage_paths().expect("paths"); + Authenticator::init(&seed, &config, paths, Arc::new(store)) + .await + .unwrap(); + drop(mock_server); + cleanup_test_storage(&root); + } +} diff --git a/walletkit-core/src/authenticator/with_storage.rs b/walletkit-core/src/authenticator/with_storage.rs new file mode 100644 index 000000000..72e30f943 --- /dev/null +++ b/walletkit-core/src/authenticator/with_storage.rs @@ -0,0 +1,131 @@ +use serde::{Deserialize, Serialize}; +use world_id_core::primitives::authenticator::AuthenticatorPublicKeySet; +use world_id_core::primitives::merkle::MerkleInclusionProof; +use world_id_core::primitives::TREE_DEPTH; + +use crate::error::WalletKitError; + +use super::Authenticator; + +/// The amount of time a Merkle inclusion proof remains valid in the cache. +const MERKLE_PROOF_VALIDITY_SECONDS: u64 = 60 * 15; + +#[uniffi::export] +impl Authenticator { + /// Initializes storage using the authenticator's leaf index. + /// + /// # Errors + /// + /// Returns an error if the leaf index is invalid or storage initialization fails. + pub fn init_storage(&self, now: u64) -> Result<(), WalletKitError> { + self.store.init(self.leaf_index(), now)?; + Ok(()) + } +} + +impl Authenticator { + /// Fetches a [`MerkleInclusionProof`] from the indexer, or from cache if it's available and fresh. + /// + /// # Errors + /// + /// Returns an error if fetching or caching the proof fails. + pub(crate) async fn fetch_inclusion_proof_with_cache( + &self, + now: u64, + ) -> Result< + (MerkleInclusionProof, AuthenticatorPublicKeySet), + WalletKitError, + > { + // If there is a cached inclusion proof, return it + if let Some(bytes) = self.store.merkle_cache_get(now)? { + if let Some(cached) = CachedInclusionProof::deserialize(&bytes) { + if cached.inclusion_proof.leaf_index == self.leaf_index() { + return Ok((cached.inclusion_proof, cached.authenticator_keyset)); + } + } + } + + // Otherwise, fetch from the indexer and cache it + let (inclusion_proof, authenticator_keyset) = + self.inner.fetch_inclusion_proof().await?; + let payload = CachedInclusionProof { + inclusion_proof: inclusion_proof.clone(), + authenticator_keyset: authenticator_keyset.clone(), + }; + let payload = payload.serialize()?; + + if let Err(e) = + self.store + .merkle_cache_put(payload, now, MERKLE_PROOF_VALIDITY_SECONDS) + { + tracing::error!("Failed to cache Merkle inclusion proof: {e}"); + } + + Ok((inclusion_proof, authenticator_keyset)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedInclusionProof { + inclusion_proof: MerkleInclusionProof, + authenticator_keyset: AuthenticatorPublicKeySet, +} + +impl CachedInclusionProof { + fn serialize(&self) -> Result, WalletKitError> { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(self, &mut bytes).map_err(|err| { + WalletKitError::SerializationError { + error: err.to_string(), + } + })?; + Ok(bytes) + } + + fn deserialize(bytes: &[u8]) -> Option { + ciborium::de::from_reader(bytes).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::tests_utils::{ + cleanup_test_storage, temp_root_path, InMemoryStorageProvider, + }; + use crate::storage::CredentialStore; + use world_id_core::FieldElement; + + #[test] + fn test_cached_inclusion_round_trip() { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("store"); + store.init(42, 100).expect("init storage"); + + let siblings = [FieldElement::from(0u64); TREE_DEPTH]; + let root_fe = FieldElement::from(123u64); + let inclusion_proof = MerkleInclusionProof::new(root_fe, 42, siblings); + let authenticator_keyset = + AuthenticatorPublicKeySet::new(vec![]).expect("key set"); + let payload = CachedInclusionProof { + inclusion_proof, + authenticator_keyset, + }; + let payload_bytes = payload.serialize().expect("serialize"); + + store + .merkle_cache_put(payload_bytes, 100, 60) + .expect("cache put"); + let now = 110; + let cached = store + .merkle_cache_get(now) + .expect("cache get") + .expect("cache hit"); + let decoded = CachedInclusionProof::deserialize(&cached).expect("decode"); + assert_eq!(decoded.inclusion_proof.leaf_index, 42); + assert_eq!(decoded.inclusion_proof.root, root_fe); + assert_eq!(decoded.authenticator_keyset.len(), 0); + cleanup_test_storage(&root); + } +} diff --git a/walletkit-core/src/credential.rs b/walletkit-core/src/credential.rs new file mode 100644 index 000000000..350b6c155 --- /dev/null +++ b/walletkit-core/src/credential.rs @@ -0,0 +1,87 @@ +//! FFI-friendly wrapper around [`CoreCredential`]. + +use std::ops::Deref; + +use world_id_core::Credential as CoreCredential; + +use crate::error::WalletKitError; +use crate::FieldElement; + +/// A wrapper around [`CoreCredential`] to enable FFI interoperability. +/// +/// Encapsulates the credential and exposes accessors for fields that FFI +/// callers need. +#[derive(Debug, Clone, uniffi::Object)] +pub struct Credential(CoreCredential); + +#[uniffi::export] +impl Credential { + /// Deserializes a `Credential` from a JSON byte blob. + /// + /// # Errors + /// + /// Returns an error if the bytes cannot be deserialized. + #[uniffi::constructor] + #[allow(clippy::needless_pass_by_value)] + pub fn from_bytes(bytes: Vec) -> Result { + let credential: CoreCredential = + serde_json::from_slice(&bytes).map_err(|e| { + WalletKitError::InvalidInput { + attribute: "credential_bytes".to_string(), + reason: format!("Failed to deserialize credential: {e}"), + } + })?; + Ok(Self(credential)) + } + + /// Returns the credential's `sub` field element. + #[must_use] + pub fn sub(&self) -> FieldElement { + self.0.sub.into() + } + + /// Returns the credential's issuer schema ID. + #[must_use] + pub const fn issuer_schema_id(&self) -> u64 { + self.0.issuer_schema_id + } +} + +impl Credential { + /// Serializes the credential to a JSON byte blob for storage. + /// + /// # Errors + /// + /// Returns an error if serialization fails. + pub fn to_bytes(&self) -> Result, WalletKitError> { + serde_json::to_vec(&self.0).map_err(|e| WalletKitError::SerializationError { + error: format!("Failed to serialize credential: {e}"), + }) + } + + /// Returns the credential's `genesis_issued_at` timestamp. + #[must_use] + pub const fn genesis_issued_at(&self) -> u64 { + self.0.genesis_issued_at + } +} + +impl From for Credential { + fn from(val: CoreCredential) -> Self { + Self(val) + } +} + +impl From for CoreCredential { + fn from(val: Credential) -> Self { + val.0 + } +} + +impl Deref for Credential { + type Target = CoreCredential; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/walletkit-core/src/defaults.rs b/walletkit-core/src/defaults.rs index fefca5300..eb953864b 100644 --- a/walletkit-core/src/defaults.rs +++ b/walletkit-core/src/defaults.rs @@ -1,57 +1,73 @@ use alloy_primitives::{address, Address}; -use world_id_core::primitives::{Config, PrimitiveError}; +use world_id_core::primitives::Config; -use crate::{error::WalletKitError, Environment}; +use crate::{error::WalletKitError, Environment, Region}; +/// The World ID Registry contract address on World Chain Mainnet. pub static WORLD_ID_REGISTRY: Address = - address!("0xb64a1F443C9a18Cd3865C3c9Be871946617C0d75"); + address!("0x8556d07D75025f286fe757C7EeEceC40D54FA16D"); +const OPRF_NODE_COUNT: usize = 5; + +/// Generates the list of OPRF node URLs for a given region and environment. +fn oprf_node_urls(region: Region, environment: &Environment) -> Vec { + let env_segment = match environment { + Environment::Staging => ".staging", + Environment::Production => "", + }; + + (0..OPRF_NODE_COUNT) + .map(|i| { + format!("https://node{i}.{region}{env_segment}.world.oprf.taceo.network") + }) + .collect() +} + +fn indexer_url(region: Region, environment: &Environment) -> String { + let domain = match environment { + Environment::Staging => "worldcoin.dev", + Environment::Production => "world.org", + }; + format!("https://indexer.{region}.id-infra.{domain}") +} + +/// Build a [`Config`] from well-known defaults for a given [`Environment`]. pub trait DefaultConfig { + /// Returns a config populated with the default URLs and addresses for the given environment. + /// + /// # Errors + /// + /// Returns [`WalletKitError`] if the configuration cannot be constructed (e.g. invalid RPC URL). fn from_environment( environment: &Environment, rpc_url: Option, + region: Option, ) -> Result where Self: Sized; } -fn map_config_error(e: PrimitiveError) -> WalletKitError { - if let PrimitiveError::InvalidInput { attribute, reason } = e { - return WalletKitError::InvalidInput { attribute, reason }; - } - WalletKitError::Generic { - error: format!("Config initialization error: {e}"), - } -} - impl DefaultConfig for Config { fn from_environment( environment: &Environment, rpc_url: Option, + region: Option, ) -> Result { - // TODO: Add all correct values + let region = region.unwrap_or_default(); + match environment { Environment::Staging => Self::new( rpc_url, 480, // Staging also runs on World Chain Mainnet by default WORLD_ID_REGISTRY, - "https://world-id-indexer.stage-crypto.worldcoin.org".to_string(), - "https://world-id-gateway.stage-crypto.worldcoin.org".to_string(), - vec![], - 2, + indexer_url(region, environment), + "https://gateway.id-infra.worldcoin.dev".to_string(), + oprf_node_urls(region, environment), + 3, ) - .map_err(map_config_error), + .map_err(WalletKitError::from), - Environment::Production => Self::new( - rpc_url, - 480, - WORLD_ID_REGISTRY, - "https://world-id-indexer.crypto.worldcoin.org".to_string(), - "https://world-id-gateway.crypto.worldcoin.org".to_string(), - vec![], - 2, - ) - .map_err(map_config_error), + Environment::Production => todo!("There is no production environment yet"), } } } diff --git a/walletkit-core/src/error.rs b/walletkit-core/src/error.rs index b94bc7ac5..2141eaf69 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -1,6 +1,9 @@ use thiserror::Error; -#[cfg(feature = "v4")] +use world_id_core::primitives::PrimitiveError; + +#[cfg(feature = "storage")] +use crate::storage::StorageError; use world_id_core::AuthenticatorError; /// Error outputs from `WalletKit` @@ -45,7 +48,7 @@ pub enum WalletKitError { }, /// Unhandled error generating a Zero-Knowledge Proof - #[error("proof_generation_error")] + #[error("proof_generation_error: {error}")] ProofGeneration { /// The error message from the proof generation error: String, @@ -77,12 +80,43 @@ pub enum WalletKitError { UnauthorizedAuthenticator, /// An unexpected error occurred with the Authenticator - #[error("unexpected_authenticator_error")] + #[error("unexpected_authenticator_error: {error}")] AuthenticatorError { /// The error message from the authenticator error: String, }, + /// The request could not be fulfilled with the credentials the user has available + #[error("unfulfillable_request")] + UnfulfillableRequest, + + /// The response generated didn't match the request + /// + /// This occurs if the response doesn't match the requested proofs - e.g. by ids + /// or doesn't satisfy the contraints declared in the request + #[error("invalid response: {0}")] + ResponseValidation(String), + + /// The generated nullifier has already been used in a proof submission and cannot be used again + #[error("nullifier_replay")] + NullifierReplay, + + /// Cached Groth16 material could not be parsed or verified. + #[error("groth16_material_cache_invalid")] + Groth16MaterialCacheInvalid { + /// Input path(s) used for loading. + path: String, + /// Underlying error message. + error: String, + }, + + /// Failed to load embedded Groth16 material. + #[error("groth16_material_embedded_load")] + Groth16MaterialEmbeddedLoad { + /// Underlying error message. + error: String, + }, + /// An unexpected error occurred #[error("unexpected_error: {error}")] Generic { @@ -99,6 +133,29 @@ impl From for WalletKitError { } } +impl From for WalletKitError { + fn from(error: PrimitiveError) -> Self { + match error { + PrimitiveError::InvalidInput { attribute, reason } => { + Self::InvalidInput { attribute, reason } + } + PrimitiveError::Serialization(error) => Self::SerializationError { error }, + PrimitiveError::Deserialization(reason) => Self::InvalidInput { + attribute: "deserialization".to_string(), + reason, + }, + PrimitiveError::NotInField => Self::InvalidInput { + attribute: "field_element".to_string(), + reason: "Provided value is not in the field".to_string(), + }, + PrimitiveError::OutOfBounds => Self::InvalidInput { + attribute: "index".to_string(), + reason: "Provided index is out of bounds".to_string(), + }, + } + } +} + impl From for WalletKitError { fn from(error: semaphore_rs::protocol::ProofError) -> Self { Self::ProofGeneration { @@ -107,7 +164,15 @@ impl From for WalletKitError { } } -#[cfg(feature = "v4")] +#[cfg(feature = "storage")] +impl From for WalletKitError { + fn from(error: StorageError) -> Self { + Self::Generic { + error: error.to_string(), + } + } +} + impl From for WalletKitError { fn from(error: AuthenticatorError) -> Self { match error { @@ -128,17 +193,11 @@ impl From for WalletKitError { error: body, status: Some(status.as_u16()), }, - AuthenticatorError::PrimitiveError(error) => { - use world_id_core::primitives::PrimitiveError; - match error { - PrimitiveError::InvalidInput { attribute, reason } => { - Self::InvalidInput { attribute, reason } - } - _ => Self::Generic { - error: error.to_string(), - }, - } - } + AuthenticatorError::PrimitiveError(error) => Self::from(error), + + AuthenticatorError::ProofError(error) => Self::ProofGeneration { + error: error.to_string(), + }, _ => Self::AuthenticatorError { error: error.to_string(), diff --git a/walletkit-core/src/field_element.rs b/walletkit-core/src/field_element.rs new file mode 100644 index 000000000..2ea02aade --- /dev/null +++ b/walletkit-core/src/field_element.rs @@ -0,0 +1,175 @@ +//! `FieldElement` represents an element in a finite field used in the World ID Protocol's +//! zero-knowledge proofs. +use std::ops::Deref; +use std::str::FromStr; + +use world_id_core::FieldElement as CoreFieldElement; + +use crate::error::WalletKitError; + +/// A wrapper around `FieldElement` to enable FFI interoperability. +/// +/// `FieldElement` represents an element in a finite field used in the World ID Protocol's +/// zero-knowledge proofs. This wrapper allows the type to be safely passed across FFI boundaries +/// while maintaining proper serialization and deserialization semantics. +/// +/// Field elements are typically 32 bytes when serialized. +#[allow(clippy::module_name_repetitions)] +#[derive(Debug, Clone, uniffi::Object)] +pub struct FieldElement(pub CoreFieldElement); + +#[uniffi::export] +impl FieldElement { + /// Creates a `FieldElement` from raw bytes (big-endian). + /// + /// # Errors + /// + /// Returns an error if the bytes cannot be deserialized into a valid field element. + #[uniffi::constructor] + pub fn from_bytes(bytes: Vec) -> Result { + let len = bytes.len(); + let val: [u8; 32] = + bytes.try_into().map_err(|_| WalletKitError::InvalidInput { + attribute: "field_element".to_string(), + reason: format!("Expected 32 bytes for field element, got {len}"), + })?; + + let field_element = CoreFieldElement::from_be_bytes(&val)?; + Ok(Self(field_element)) + } + + /// Creates a `FieldElement` from a `u64` value. + /// + /// This is useful for testing or when working with small field element values. + #[must_use] + #[uniffi::constructor] + pub fn from_u64(value: u64) -> Self { + Self(CoreFieldElement::from(value)) + } + + /// Serializes the field element to bytes (big-endian). + /// + /// Returns a byte vector representing the field element. + #[must_use] + pub fn to_bytes(&self) -> Vec { + self.0.to_be_bytes().to_vec() + } + + /// Creates a `FieldElement` from a hex string. + /// + /// The hex string can optionally start with "0x". + /// + /// # Errors + /// + /// Returns an error if the hex string is invalid or cannot be parsed. + #[uniffi::constructor] + pub fn try_from_hex_string(hex_string: &str) -> Result { + let fe = CoreFieldElement::from_str(hex_string)?; + Ok(Self(fe)) + } + + /// Converts the field element to a hex-encoded, padded string. + #[must_use] + pub fn to_hex_string(&self) -> String { + self.0.to_string() + } +} + +impl From for CoreFieldElement { + fn from(val: FieldElement) -> Self { + val.0 + } +} + +impl From for FieldElement { + fn from(val: CoreFieldElement) -> Self { + Self(val) + } +} + +impl From for FieldElement { + fn from(value: u64) -> Self { + Self(CoreFieldElement::from(value)) + } +} + +impl Deref for FieldElement { + type Target = CoreFieldElement; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_u64() { + let fe = FieldElement::from_u64(42); + let bytes = fe.to_bytes(); + assert!(!bytes.is_empty()); + assert_eq!(bytes[31], 0x2a); + } + + #[test] + fn test_round_trip_bytes() { + let original = FieldElement::from_u64(12345); + let bytes = original.to_bytes(); + let restored = FieldElement::from_bytes(bytes).unwrap(); + + // Compare the serialized forms since FieldElement doesn't implement PartialEq + let original_bytes = original.to_bytes(); + let restored_bytes = restored.to_bytes(); + assert_eq!(original_bytes, restored_bytes); + } + + #[test] + fn test_hex_round_trip() { + let original = FieldElement::from_u64(999); + let hex = original.to_hex_string(); + let restored = FieldElement::try_from_hex_string(&hex).unwrap(); + + let original_bytes = original.to_bytes(); + let restored_bytes = restored.to_bytes(); + assert_eq!(original_bytes, restored_bytes); + } + + #[test] + fn test_hex_string_with_and_without_0x() { + let fe = FieldElement::from_u64(255); + let hex = fe.to_hex_string(); + + // Should work with 0x prefix + let with_prefix = FieldElement::try_from_hex_string(&hex).unwrap(); + + // Should also work without 0x prefix + let hex_no_prefix = hex.trim_start_matches("0x"); + let without_prefix = FieldElement::try_from_hex_string(hex_no_prefix).unwrap(); + + let with_bytes = with_prefix.to_bytes(); + let without_bytes = without_prefix.to_bytes(); + assert_eq!(with_bytes, without_bytes); + } + + #[test] + fn test_invalid_hex_string() { + assert!(FieldElement::try_from_hex_string("0xZZZZ").is_err()); + assert!(FieldElement::try_from_hex_string("not hex").is_err()); + } + + /// Ensures encoding is consistent with different round trips + #[test] + fn test_encoding_round_trip() { + let sub_one = CoreFieldElement::from(42u64); + let sub_two = FieldElement::from(sub_one); + + assert_eq!(sub_one, *sub_two); + assert_eq!(sub_one.to_string(), sub_two.to_hex_string()); + + let sub_three = + FieldElement::try_from_hex_string(&sub_two.to_hex_string()).unwrap(); + assert_eq!(sub_one, *sub_three); + } +} diff --git a/walletkit-core/src/http_request.rs b/walletkit-core/src/http_request.rs new file mode 100644 index 000000000..233e22079 --- /dev/null +++ b/walletkit-core/src/http_request.rs @@ -0,0 +1,171 @@ +use std::time::Duration; + +use backon::{ExponentialBuilder, Retryable}; +use reqwest::{Method, RequestBuilder, Response}; + +use crate::error::WalletKitError; + +/// A simple wrapper on an HTTP client for making requests. Sets sensible defaults such as timeouts, +/// user-agent & ensuring HTTPS, and applies retry middleware for transient failures. +pub struct Request { + client: reqwest::Client, + timeout: Duration, + max_retries: u32, +} + +impl Request { + /// Initializes a new `Request` instance. + pub(crate) fn new() -> Self { + let client = reqwest::Client::new(); + let timeout = Duration::from_secs(5); + let max_retries = 3; // total attempts = 4 + Self { + client, + timeout, + max_retries, + } + } + + /// Creates a request builder with defaults applied. + pub(crate) fn req(&self, method: Method, url: &str) -> RequestBuilder { + #[cfg(not(test))] + assert!(url.starts_with("https")); + + self.client + .request(method, url) + .timeout(self.timeout) + .header( + "User-Agent", + format!("walletkit-core/{}", env!("CARGO_PKG_VERSION")), + ) + } + + /// Creates a GET request builder with defaults applied. + #[allow(dead_code)] + pub(crate) fn get(&self, url: &str) -> RequestBuilder { + self.req(Method::GET, url) + } + + /// Creates a POST request builder with defaults applied. + pub(crate) fn post(&self, url: &str) -> RequestBuilder { + self.req(Method::POST, url) + } + + /// Handles sending a request built by `req`/`get`/`post` with retries for transient failures. + pub(crate) async fn handle( + &self, + request_builder: RequestBuilder, + ) -> Result { + if request_builder.try_clone().is_none() { + return execute_request_builder(request_builder) + .await + .map_err(Into::into); + } + + let backoff = ExponentialBuilder::default() + .with_min_delay(Duration::from_millis(200)) + .with_max_delay(Duration::from_secs(2)) + .with_max_times(self.max_retries as usize); + + let template = request_builder; + + (|| async { + let request_builder = template.try_clone().expect( + "request_builder must be cloneable after initial handle() guard", + ); + execute_request_builder(request_builder).await + }) + .retry(backoff) + .when(|err: &RequestHandleError| err.is_retryable()) + .await + .map_err(Into::into) + } +} + +#[derive(Debug)] +struct RequestHandleError { + url: String, + status: Option, + error: String, + retryable: bool, +} + +impl RequestHandleError { + const fn retryable(url: String, status: Option, error: String) -> Self { + Self { + url, + status, + error, + retryable: true, + } + } + + const fn permanent(url: String, status: Option, error: String) -> Self { + Self { + url, + status, + error, + retryable: false, + } + } + + const fn is_retryable(&self) -> bool { + self.retryable + } +} + +impl From for WalletKitError { + fn from(value: RequestHandleError) -> Self { + Self::NetworkError { + url: value.url, + status: value.status, + error: value.error, + } + } +} + +async fn execute_request_builder( + request_builder: RequestBuilder, +) -> Result { + let (client, request) = request_builder.build_split(); + let request = request.map_err(|err| { + RequestHandleError::permanent( + err.url().map_or_else( + || "".to_string(), + std::string::ToString::to_string, + ), + None, + format!("request build failed: {err}"), + ) + })?; + let url = request.url().to_string(); + + match client.execute(request).await { + Ok(resp) => { + let status = resp.status().as_u16(); + if status == 429 || (500..600).contains(&status) { + return Err(RequestHandleError::retryable( + url, + Some(status), + format!("request error with bad status code {status}"), + )); + } + Ok(resp) + } + Err(err) => { + if err.is_timeout() || err.is_connect() { + return Err(RequestHandleError::retryable( + url, + None, + format!("request timeout/connect error: {err}"), + )); + } + + Err(RequestHandleError::permanent( + url, + None, + format!("request failed: {err}"), + )) + } + } +} diff --git a/walletkit-core/src/issuers/mod.rs b/walletkit-core/src/issuers/mod.rs new file mode 100644 index 000000000..6f7d04e14 --- /dev/null +++ b/walletkit-core/src/issuers/mod.rs @@ -0,0 +1,5 @@ +//! Credential issuers for World ID. + +mod tfh_nfc; + +pub use tfh_nfc::TfhNfcIssuer; diff --git a/walletkit-core/src/issuers/tfh_nfc.rs b/walletkit-core/src/issuers/tfh_nfc.rs new file mode 100644 index 000000000..8864f70cc --- /dev/null +++ b/walletkit-core/src/issuers/tfh_nfc.rs @@ -0,0 +1,173 @@ +//! TFH NFC credential issuer (passport, eID, MNC). +use crate::Credential; +use crate::{error::WalletKitError, http_request::Request, Environment}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Deserialize; +use std::collections::HashMap; + +/// Response from NFC refresh endpoint +#[derive(Debug, Clone, Deserialize)] +struct NfcRefreshResponse { + result: NfcRefreshResultRaw, +} + +/// Raw credential wrapper (base64-encoded JSON) +#[derive(Debug, Clone, Deserialize)] +struct NfcRefreshResultRaw { + credential: String, +} + +impl NfcRefreshResultRaw { + fn parse(&self) -> Result { + let credential_bytes = STANDARD.decode(&self.credential).map_err(|e| { + WalletKitError::SerializationError { + error: format!("Failed to decode NFC base64 credential: {e}"), + } + })?; + + Credential::from_bytes(credential_bytes).map_err(|e| { + WalletKitError::SerializationError { + error: format!("Failed to deserialize NFC credential: {e}"), + } + }) + } +} + +/// TFH NFC credential issuer API client +#[derive(uniffi::Object)] +pub struct TfhNfcIssuer { + base_url: String, + request: Request, +} + +#[uniffi::export] +impl TfhNfcIssuer { + /// Create a new TFH NFC issuer for the specified environment + #[uniffi::constructor] + #[must_use] + pub fn new(environment: &Environment) -> Self { + let base_url = match environment { + Environment::Staging => "https://nfc.stage-crypto.worldcoin.org", + Environment::Production => "https://nfc.crypto.worldcoin.org", + } + .to_string(); + + Self { + base_url, + request: Request::new(), + } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl TfhNfcIssuer { + /// Refresh an NFC credential (migrate PCP to v4). + /// + /// Calls the `/v2/refresh` endpoint and returns a parsed [`Credential`]. + /// + /// # Errors + /// + /// Returns error on network failure or invalid response. + pub async fn refresh_nfc_credential( + &self, + request_body: &str, + headers: HashMap, + ) -> Result { + let url = format!("{}/v2/refresh", self.base_url); + + let mut request_builder = self + .request + .post(&url) + .header("Content-Type", "application/json") + .body(request_body.to_string()); + for (name, value) in &headers { + request_builder = request_builder.header(name, value); + } + let response = self.request.handle(request_builder).await?; + + let status = response.status(); + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + return Err(WalletKitError::NetworkError { + url, + status: Some(status.as_u16()), + error: format!("NFC refresh failed: {error_body}"), + }); + } + + let refresh_response: NfcRefreshResponse = + response + .json() + .await + .map_err(|e| WalletKitError::SerializationError { + error: format!("Failed to parse NFC refresh response: {e}"), + })?; + + refresh_response.result.parse() + } +} + +#[cfg(test)] +impl TfhNfcIssuer { + /// Create an issuer with a custom base URL (for testing). + #[must_use] + pub fn with_base_url(base_url: &str) -> Self { + Self { + base_url: base_url.to_string(), + request: Request::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_staging_url() { + let issuer = TfhNfcIssuer::new(&Environment::Staging); + assert_eq!(issuer.base_url, "https://nfc.stage-crypto.worldcoin.org"); + } + + #[test] + fn test_production_url() { + let issuer = TfhNfcIssuer::new(&Environment::Production); + assert_eq!(issuer.base_url, "https://nfc.crypto.worldcoin.org"); + } + + #[test] + fn test_parse_credential() { + let core_cred = world_id_core::Credential::new(); + let credential_json = serde_json::to_vec(&core_cred).unwrap(); + let credential_base64 = STANDARD.encode(&credential_json); + + let raw = NfcRefreshResultRaw { + credential: credential_base64, + }; + + let parsed = raw.parse().unwrap(); + assert_eq!(parsed.version, core_cred.version); + assert_eq!(parsed.issuer_schema_id(), core_cred.issuer_schema_id); + } + + #[test] + fn test_parse_credential_invalid_base64() { + let raw = NfcRefreshResultRaw { + credential: "not valid base64!!!".to_string(), + }; + + let err = raw.parse().unwrap_err(); + assert!(matches!(err, WalletKitError::SerializationError { .. })); + } + + #[test] + fn test_parse_credential_invalid_json() { + let raw = NfcRefreshResultRaw { + credential: STANDARD.encode(b"not valid json"), + }; + + let err = raw.parse().unwrap_err(); + assert!(matches!(err, WalletKitError::SerializationError { .. })); + } +} diff --git a/walletkit-core/src/lib.rs b/walletkit-core/src/lib.rs index d0eb23956..4b857460d 100644 --- a/walletkit-core/src/lib.rs +++ b/walletkit-core/src/lib.rs @@ -11,15 +11,21 @@ //! println!("{}", proof.to_json().unwrap()); // the JSON output can be passed to the Developer Portal, World ID contracts, etc. for verification //! } //! ``` -#![deny( - clippy::all, - clippy::pedantic, - clippy::nursery, - missing_docs, - dead_code -)] -use strum::EnumString; +use strum::{Display, EnumString}; + +/// Library initialization function called automatically on load. +/// +/// Installs the ring crypto provider as the default for rustls. +/// Uses the `ctor` crate to ensure this runs when the dynamic library loads, +/// before any user code executes. +#[cfg(not(test))] +#[ctor::ctor] +fn init() { + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install default crypto provider"); +} /// Represents the environment in which a World ID is being presented and used. /// @@ -35,6 +41,21 @@ pub enum Environment { Production, } +/// Region for node selection. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, uniffi::Enum, EnumString, Display, +)] +#[strum(serialize_all = "lowercase")] +pub enum Region { + /// United States + Us, + /// Europe (default) + #[default] + Eu, + /// Asia-Pacific + Ap, +} + pub(crate) mod primitives; mod credential_type; @@ -49,13 +70,23 @@ pub mod logger; mod u256; pub use u256::U256Wrapper; -#[cfg(feature = "v4")] +mod field_element; +pub use field_element::FieldElement; + +mod credential; +pub use credential::Credential; + +/// Credential storage primitives for World ID v4. +#[cfg(feature = "storage")] +pub mod storage; + mod authenticator; -#[cfg(feature = "v4")] pub use authenticator::{Authenticator, InitializingAuthenticator, RegistrationStatus}; -#[cfg(feature = "v4")] -pub(crate) mod defaults; +/// Default configuration values for each [`Environment`]. +pub mod defaults; + +pub mod requests; //////////////////////////////////////////////////////////////////////////////// // Legacy modules @@ -71,11 +102,15 @@ pub mod proof; #[cfg(feature = "common-apps")] pub mod common_apps; +/// Credential issuers for World ID (NFC, etc.) +#[cfg(feature = "issuers")] +pub mod issuers; + //////////////////////////////////////////////////////////////////////////////// // Private modules //////////////////////////////////////////////////////////////////////////////// +mod http_request; mod merkle_tree; -mod request; uniffi::setup_scaffolding!("walletkit_core"); diff --git a/walletkit-core/src/logger.rs b/walletkit-core/src/logger.rs index 45d3e1883..d39f45691 100644 --- a/walletkit-core/src/logger.rs +++ b/walletkit-core/src/logger.rs @@ -1,202 +1,286 @@ -use std::sync::{Arc, OnceLock}; +use std::{ + fmt, + sync::{mpsc, Arc, Mutex, OnceLock}, + thread, +}; -/// Trait representing a logger that can log messages at various levels. -/// -/// This trait should be implemented by any logger that wants to receive log messages. -/// It is exported via `UniFFI` for use in foreign languages. +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::{ + layer::{Context, SubscriberExt}, + registry::LookupSpan, + EnvFilter, Layer, Registry, +}; + +/// Trait representing the minimal foreign logging bridge used by `WalletKit`. /// -/// # Examples +/// `WalletKit` emits tracing events and forwards formatted messages with an +/// explicit severity `level`. /// -/// Implementing the `Logger` trait: +/// # Rust example /// /// ```rust -/// use walletkit_core::logger::{Logger, LogLevel}; +/// use std::sync::Arc; +/// use walletkit_core::logger::{init_logging, LogLevel, Logger}; /// -/// struct MyLogger; +/// struct AppLogger; /// -/// impl Logger for MyLogger { +/// impl Logger for AppLogger { /// fn log(&self, level: LogLevel, message: String) { -/// println!("[{:?}] {}", level, message); +/// println!("[{level:?}] {message}"); /// } /// } +/// +/// init_logging(Arc::new(AppLogger), Some(LogLevel::Debug)); /// ``` /// -/// ## Swift +/// # Swift example /// /// ```swift -/// class WalletKitLoggerBridge: WalletKit.Logger { +/// final class WalletKitLoggerBridge: WalletKit.Logger { /// static let shared = WalletKitLoggerBridge() /// /// func log(level: WalletKit.LogLevel, message: String) { -/// Log.log(level.toCoreLevel(), message) +/// switch level { +/// case .trace, .debug: +/// print("[DEBUG] \(message)") +/// case .info: +/// print("[INFO] \(message)") +/// case .warn: +/// print("[WARN] \(message)") +/// case .error: +/// fputs("[ERROR] \(message)\n", stderr) +/// @unknown default: +/// fputs("[UNKNOWN] \(message)\n", stderr) +/// } /// } /// } /// -/// public func setupWalletKitLogger() { -/// WalletKit.setLogger(logger: WalletKitLoggerBridge.shared) -/// } -/// ``` -/// -/// ### In app delegate -/// -/// ```swift -/// setupWalletKitLogger() // Call this only once!!! +/// WalletKit.initLogging(logger: WalletKitLoggerBridge.shared, level: .debug) /// ``` #[uniffi::export(with_foreign)] pub trait Logger: Sync + Send { - /// Logs a message at the specified log level. - /// - /// # Arguments - /// - /// * `level` - The severity level of the log message. - /// * `message` - The log message to be recorded. + /// Receives a log `message` with its corresponding `level`. fn log(&self, level: LogLevel, message: String); } -/// Enumeration of possible log levels. -/// -/// This enum represents the severity levels that can be used when logging messages. -#[derive(Debug, Clone, uniffi::Enum)] +/// Enumeration of possible log levels for foreign logger callbacks. +#[derive(Debug, Clone, Copy, uniffi::Enum)] pub enum LogLevel { - /// Designates very low priority, often extremely detailed messages. + /// Very detailed diagnostic messages. Trace, - /// Designates lower priority debugging information. + /// Debug-level messages. Debug, - /// Designates informational messages that highlight the progress of the application. + /// Informational messages. Info, - /// Designates potentially harmful situations. + /// Warning messages. Warn, - /// Designates error events that might still allow the application to continue running. + /// Error messages. Error, } -/// A logger that forwards log messages to a user-provided `Logger` implementation. -/// -/// This struct implements the `log::Log` trait and integrates with the Rust `log` crate. -struct ForeignLogger; - -impl log::Log for ForeignLogger { - /// Determines if a log message with the specified metadata should be logged. - /// - /// This implementation logs all messages. Modify this method to implement log level filtering. - /// - /// # Arguments - /// - /// * `_metadata` - Metadata about the log message. - fn enabled(&self, _metadata: &log::Metadata) -> bool { - // Currently, we log all messages. Adjust this if you need to filter messages. - true +const fn log_level(level: Level) -> LogLevel { + match level { + Level::TRACE => LogLevel::Trace, + Level::DEBUG => LogLevel::Debug, + Level::INFO => LogLevel::Info, + Level::WARN => LogLevel::Warn, + Level::ERROR => LogLevel::Error, } +} - /// Logs a record. - /// - /// This method is called by the `log` crate when a log message needs to be logged. - /// It forwards the log message to the user-provided `Logger` implementation if available. - /// - /// # Arguments - /// - /// * `record` - The log record containing the message and metadata. - fn log(&self, record: &log::Record) { - // Determine if the record originates from the "walletkit" module. - let is_record_from_walletkit = record - .module_path() - .is_some_and(|module_path| module_path.starts_with("walletkit")); - - // Determine if the log level is Debug or Trace. - let is_debug_or_trace_level = - record.level() == log::Level::Debug || record.level() == log::Level::Trace; - - // Skip logging Debug or Trace level messages that are not from the "walletkit" module. - if is_debug_or_trace_level && !is_record_from_walletkit { - return; +#[derive(Default)] +struct EventFieldVisitor { + message: Option, + fields: Vec<(String, String)>, +} + +impl tracing::field::Visit for EventFieldVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + let value = format!("{value:?}"); + if field.name() == "message" { + self.message = Some(value); + } else { + self.fields.push((field.name().to_string(), value)); } + } - // Forward the log message to the user-provided logger if available. - if let Some(logger) = LOGGER_INSTANCE.get() { - let level = log_level(record.level()); - let message = format!("{}", record.args()); - logger.log(level, message); + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.message = Some(value.to_string()); } else { - // Handle the case when the logger is not set. - eprintln!("Logger not set: {}", record.args()); + self.fields + .push((field.name().to_string(), value.to_string())); } } +} - /// Flushes any buffered records. - /// - /// This implementation does nothing because buffering is not used. - fn flush(&self) {} +/// Forwards walletkit tracing events to the foreign logger. +struct ForeignLoggerLayer; + +struct LogEvent { + level: LogLevel, + message: String, } -/// Converts a `log::Level` to a `LogLevel`. -/// -/// This function maps the log levels from the `log` crate to your own `LogLevel` enum. -/// -/// # Arguments -/// -/// * `level` - The `log::Level` to convert. -/// -/// # Returns -/// -/// A corresponding `LogLevel`. -const fn log_level(level: log::Level) -> LogLevel { +// Log events are pushed into this channel by `ForeignLoggerLayer::on_event` +// and delivered to the foreign callback on a dedicated thread. +// +// This architecture is required because UniFFI foreign callbacks crash with +// EXC_BAD_ACCESS when invoked synchronously from within a UniFFI future-poll +// context (`rust_call_with_out_status`). The nested FFI boundary crossing +// corrupts state. By decoupling collection from delivery through a channel, +// the tracing layer never makes an FFI call — it only pushes to an in-process +// queue — and the dedicated delivery thread calls `Logger::log` from a clean +// stack with no active FFI frames. +static LOG_CHANNEL: OnceLock>> = OnceLock::new(); +static LOGGING_INITIALIZED: OnceLock<()> = OnceLock::new(); + +impl Layer for ForeignLoggerLayer +where + S: Subscriber + for<'span> LookupSpan<'span>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let Some(sender) = LOG_CHANNEL.get() else { + return; + }; + + let mut visitor = EventFieldVisitor::default(); + event.record(&mut visitor); + let metadata = event.metadata(); + + let mut message = visitor.message.unwrap_or_default(); + if !visitor.fields.is_empty() { + let extras = visitor + .fields + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join(" "); + if message.is_empty() { + message = extras; + } else { + message = format!("{message} {extras}"); + } + } + if message.is_empty() { + message = metadata.name().to_string(); + } + + let formatted = format!("{} {message}", metadata.target()); + + if let Ok(sender) = sender.lock() { + let _ = sender.send(LogEvent { + level: log_level(*metadata.level()), + message: formatted, + }); + } + } +} + +const fn log_level_filter(level: LogLevel) -> &'static str { match level { - log::Level::Error => LogLevel::Error, - log::Level::Warn => LogLevel::Warn, - log::Level::Info => LogLevel::Info, - log::Level::Debug => LogLevel::Debug, - log::Level::Trace => LogLevel::Trace, + LogLevel::Trace => "trace", + LogLevel::Debug => "debug", + LogLevel::Info => "info", + LogLevel::Warn => "warn", + LogLevel::Error => "error", } } -/// A global instance of the user-provided logger. -/// -/// This static variable holds the logger provided by the user and is accessed by `ForeignLogger` to forward log messages. -static LOGGER_INSTANCE: OnceLock> = OnceLock::new(); +// Only these crates are promoted to the caller-requested level. +// Everything else stays at the baseline (`info`). This avoids +// flooding the logger with internal noise from infrastructure crates. +const APP_CRATES: &[&str] = &[ + "walletkit", + "walletkit_core", + "world_id_core", + "world_id_proof", + "world_id_authenticator", + "world_id_primitives", + "taceo_oprf", + "taceo_oprf_client", + "taceo_oprf_core", + "taceo_oprf_types", + "semaphore_rs", +]; -/// Sets the global logger. -/// -/// This function allows you to provide your own implementation of the `Logger` trait. -/// It initializes the logging system and should be called before any logging occurs. -/// -/// # Arguments -/// -/// * `logger` - An `Arc` containing your logger implementation. -/// -/// # Panics -/// -/// Panics if the logger has already been set. -/// -/// # Note -/// -/// If the logger has already been set, this function will print a message and do nothing. -#[uniffi::export] -pub fn set_logger(logger: Arc) { - match LOGGER_INSTANCE.set(logger) { - Ok(()) => (), - Err(_) => println!("Logger already set"), +fn build_env_filter(level: Option) -> EnvFilter { + if let Ok(filter) = EnvFilter::try_from_default_env() { + return filter; + } + + let level_str = level.map_or("info", log_level_filter); + + let needs_per_crate = matches!(level, Some(LogLevel::Trace | LogLevel::Debug)); + if !needs_per_crate { + return EnvFilter::new(level_str); } - // Initialize the logger system. - if let Err(e) = init_logger() { - eprintln!("Failed to set logger: {e}"); + // e.g. "walletkit=debug,walletkit_core=debug,...,info" + let mut directives = String::new(); + for crate_name in APP_CRATES { + directives.push_str(crate_name); + directives.push('='); + directives.push_str(level_str); + directives.push(','); } + directives.push_str("info"); + EnvFilter::new(directives) } -/// Initializes the logger system. +/// Emits a message at the given level through `WalletKit`'s tracing pipeline. /// -/// This function sets up the global logger with the `ForeignLogger` implementation and sets the maximum log level. +/// Useful for verifying that the logging bridge is wired up correctly. +#[uniffi::export] +pub fn emit_log(level: LogLevel, message: String) { + let message = message.into_boxed_str(); + let message = message.as_ref(); + + match level { + LogLevel::Trace => tracing::trace!(target: "walletkit", "{message}"), + LogLevel::Debug => tracing::debug!(target: "walletkit", "{message}"), + LogLevel::Info => tracing::info!(target: "walletkit", "{message}"), + LogLevel::Warn => tracing::warn!(target: "walletkit", "{message}"), + LogLevel::Error => tracing::error!(target: "walletkit", "{message}"), + } +} + +/// Initializes `WalletKit` tracing and registers a foreign logger sink. /// -/// # Returns +/// `level` controls the minimum severity for `WalletKit` and its direct +/// dependencies (taceo, world-id, semaphore). All other crates remain at +/// `Info` regardless of this setting. Pass `None` to default to `Info`. +/// The `RUST_LOG` environment variable, when set, always takes precedence. /// -/// A `Result` indicating success or failure. +/// This function is idempotent. The first call wins; subsequent calls are no-ops. /// -/// # Errors +/// # Panics /// -/// Returns a `log::SetLoggerError` if the logger could not be set (e.g., if a logger was already set). -fn init_logger() -> Result<(), log::SetLoggerError> { - static LOGGER: ForeignLogger = ForeignLogger; - log::set_logger(&LOGGER)?; - log::set_max_level(log::LevelFilter::Trace); - Ok(()) +/// Panics if the dedicated logger delivery thread cannot be spawned. +#[uniffi::export] +pub fn init_logging(logger: Arc, level: Option) { + if LOGGING_INITIALIZED.get().is_some() { + return; + } + + let (tx, rx) = mpsc::channel::(); + let _ = LOG_CHANNEL.set(Mutex::new(tx)); + + thread::Builder::new() + .name("walletkit-logger".into()) + .spawn(move || { + for event in rx { + logger.log(event.level, event.message); + } + }) + .expect("failed to spawn walletkit logger thread"); + + let _ = tracing_log::LogTracer::init(); + + let filter = build_env_filter(level); + let subscriber = Registry::default().with(filter).with(ForeignLoggerLayer); + + if tracing::subscriber::set_global_default(subscriber).is_ok() { + let _ = LOGGING_INITIALIZED.set(()); + } } diff --git a/walletkit-core/src/merkle_tree.rs b/walletkit-core/src/merkle_tree.rs index 510dc74d5..9a96f4c1e 100644 --- a/walletkit-core/src/merkle_tree.rs +++ b/walletkit-core/src/merkle_tree.rs @@ -1,7 +1,7 @@ use semaphore_rs::poseidon_tree::Proof; use serde::{Deserialize, Serialize}; -use crate::{error::WalletKitError, request::Request, u256::U256Wrapper}; +use crate::{error::WalletKitError, http_request::Request, u256::U256Wrapper}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -56,7 +56,7 @@ impl MerkleTreeProof { }; let request = Request::new(); - let http_response = request.post(url.clone(), body).await?; + let http_response = request.handle(request.post(&url).json(&body)).await?; let status = http_response.status(); diff --git a/walletkit-core/src/proof.rs b/walletkit-core/src/proof.rs index 1199b05cc..90b182bd2 100644 --- a/walletkit-core/src/proof.rs +++ b/walletkit-core/src/proof.rs @@ -1,14 +1,12 @@ use crate::error::WalletKitError; use alloy_core::sol_types::SolValue; +#[cfg(feature = "semaphore")] +use semaphore_rs::protocol::{generate_nullifier_hash, generate_proof}; use semaphore_rs::{ - hash_to_field, identity, - packed_proof::PackedProof, - protocol::{generate_nullifier_hash, generate_proof, Proof}, + hash_to_field, identity, packed_proof::PackedProof, protocol::Proof, MODULUS, }; -use semaphore_rs::MODULUS; - use serde::Serialize; use crate::{ @@ -323,15 +321,13 @@ impl ProofOutput { /// /// # Errors /// Returns an error if proof generation fails +#[cfg(feature = "semaphore")] pub fn generate_proof_with_semaphore_identity( identity: &identity::Identity, merkle_tree_proof: &MerkleTreeProof, context: &ProofContext, ) -> Result { - #[cfg(not(feature = "semaphore"))] - return Err(WalletKitError::SemaphoreNotEnabled); - - let merkle_root = merkle_tree_proof.merkle_root; // clone the value + let merkle_root = merkle_tree_proof.merkle_root; let external_nullifier_hash = context.external_nullifier.into(); let nullifier_hash = @@ -353,6 +349,21 @@ pub fn generate_proof_with_semaphore_identity( }) } +/// Generates a Semaphore ZKP for a specific Semaphore identity using the relevant provided context. +/// +/// **Requires the `semaphore` feature flag.** +/// +/// # Errors +/// Returns `SemaphoreNotEnabled` when the `semaphore` feature is not enabled. +#[cfg(not(feature = "semaphore"))] +pub const fn generate_proof_with_semaphore_identity( + _identity: &identity::Identity, + _merkle_tree_proof: &MerkleTreeProof, + _context: &ProofContext, +) -> Result { + Err(WalletKitError::SemaphoreNotEnabled) +} + #[cfg(test)] mod external_nullifier_tests { use alloy_core::primitives::address; diff --git a/walletkit-core/src/request.rs b/walletkit-core/src/request.rs deleted file mode 100644 index 94a19cf14..000000000 --- a/walletkit-core/src/request.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::time::Duration; - -use serde::ser::Serialize; - -use crate::error::WalletKitError; - -/// A simple wrapper on an HTTP client for making requests. Sets sensible defaults such as timeouts, -/// user-agent & ensuring HTTPS, and applies retry middleware for transient failures. -pub struct Request { - client: reqwest::Client, - timeout: Duration, - max_retries: u32, -} - -impl Request { - /// Initializes a new `Request` instance. - pub(crate) fn new() -> Self { - let client = reqwest::Client::new(); - let timeout = Duration::from_secs(5); - let max_retries = 3; // total attempts = 4 - Self { - client, - timeout, - max_retries, - } - } - - /// Makes a POST request to a given URL with a JSON body. Retries are handled internally for - /// transient failures such as timeouts, 5xx responses, and rate limiting (429) - pub(crate) async fn post( - &self, - url: String, - body: T, - ) -> Result - where - T: Serialize + Send + Sync, - { - #[cfg(not(test))] - assert!(url.starts_with("https")); - - let mut attempt = 0; - - loop { - let result = self - .client - .post(&url) - .timeout(self.timeout) - .header( - "User-Agent", - format!("walletkit-core/{}", env!("CARGO_PKG_VERSION")), - ) - .json(&body) - .send() - .await; - - match result { - Ok(resp) => { - let status = resp.status().as_u16(); - // Retry on 429 and 5xx - if status == 429 || (500..600).contains(&status) { - if attempt >= self.max_retries { - return Err(WalletKitError::NetworkError { - url, - status: Some(status), - error: format!( - "request error with bad status code {status}" - ), - }); - } - attempt += 1; - // No sleep to keep runtime-agnostic - continue; - } - - return Ok(resp); - } - Err(err) => { - // Retry on timeouts/connect errors - if err.is_timeout() || err.is_connect() { - if attempt >= self.max_retries { - return Err(WalletKitError::NetworkError { - url, - status: None, - error: format!( - "request timeout/connect error after all retries: {err}" - ), - }); - } - attempt += 1; - continue; - } - return Err(WalletKitError::NetworkError { - url, - status: None, - error: format!("request failed after all retries: {err}"), - }); - } - } - } - } -} diff --git a/walletkit-core/src/requests.rs b/walletkit-core/src/requests.rs new file mode 100644 index 000000000..f5acb66de --- /dev/null +++ b/walletkit-core/src/requests.rs @@ -0,0 +1,107 @@ +//! Proof requests and responses in World ID v4. + +use world_id_core::requests::{ + ProofRequest as CoreProofRequest, ProofResponse as CoreProofResponse, +}; + +use crate::error::WalletKitError; + +/// A request from the RP to the Authenticator. See [`CoreProofRequest`] for more details. +/// This is a wrapper type to expose to foreign language bindings. +#[derive(Debug, Clone, uniffi::Object)] +pub struct ProofRequest(pub(crate) CoreProofRequest); + +#[uniffi::export] +impl ProofRequest { + /// Deserializes a `ProofRequest` from a JSON string. + /// + /// # Errors + /// Returns an error if the JSON is invalid or cannot be parsed. + #[uniffi::constructor] + pub fn from_json(json: &str) -> Result { + let core_request: CoreProofRequest = + serde_json::from_str(json).map_err(|e| WalletKitError::Generic { + error: format!("invalid proof request json: {e}"), + })?; + Ok(Self(core_request)) + } + + /// Serializes the proof request to a JSON string. + /// + /// # Errors + /// Returns an error if serialization fails. + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0).map_err(|e| WalletKitError::Generic { + error: format!("critical unexpected error serializing to json: {e}"), + }) + } + + /// Returns the unique identifier for this request. + #[must_use] + pub fn id(&self) -> String { + self.0.id.clone() + } + + /// Returns the request format version as a `u8`. + #[must_use] + pub const fn version(&self) -> u8 { + self.0.version as u8 + } +} + +/// A response from the Authenticator to the RP. See [`CoreProofResponse`] for more details. +/// +/// This is a wrapper type to expose to foreign language bindings. +#[derive(Debug, Clone, uniffi::Object)] +pub struct ProofResponse(pub CoreProofResponse); + +#[uniffi::export] +impl ProofResponse { + /// Serializes the proof response to a JSON string. + /// + /// # Errors + /// Returns an error if serialization fails. + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0).map_err(|e| WalletKitError::Generic { + error: format!("critical unexpected error serializing to json: {e}"), + }) + } + + /// Returns the unique identifier for this response. + #[must_use] + pub fn id(&self) -> String { + self.0.id.clone() + } + + /// Returns the response format version as a `u8`. + #[must_use] + pub const fn version(&self) -> u8 { + self.0.version as u8 + } + + /// Returns the top-level error message, if the entire proof request failed. + #[must_use] + pub fn error(&self) -> Option { + self.0.error.clone() + } +} + +impl ProofResponse { + /// Consumes the wrapper and returns the inner `CoreProofResponse`. + #[must_use] + pub fn into_inner(self) -> CoreProofResponse { + self.0 + } +} + +impl From for ProofRequest { + fn from(core_request: CoreProofRequest) -> Self { + Self(core_request) + } +} + +impl From for ProofResponse { + fn from(core_response: CoreProofResponse) -> Self { + Self(core_response) + } +} diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs new file mode 100644 index 000000000..cc7ad078e --- /dev/null +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -0,0 +1,89 @@ +//! Cache DB maintenance helpers (integrity checks, rebuilds). + +use std::fs; +use std::path::Path; + +use crate::storage::error::StorageResult; +use walletkit_db::cipher; +use walletkit_db::Connection; +use zeroize::Zeroizing; + +use super::schema; +use super::util::{map_db_err, map_io_err}; + +/// Opens the cache DB or rebuilds it if integrity checks fail. +/// +/// # Errors +/// +/// Returns an error if the database cannot be opened or rebuilt. +pub(super) fn open_or_rebuild( + path: &Path, + k_intermediate: &Zeroizing<[u8; 32]>, +) -> StorageResult { + match open_prepared(path, k_intermediate) { + Ok(conn) => { + let integrity_ok = + cipher::integrity_check(&conn).map_err(|e| map_db_err(&e))?; + if integrity_ok { + Ok(conn) + } else { + drop(conn); + rebuild(path, k_intermediate) + } + } + Err(err) => rebuild(path, k_intermediate).map_or_else(|_| Err(err), Ok), + } +} + +/// Opens the cache DB, applies encryption settings, and ensures schema. +/// +/// # Errors +/// +/// Returns an error if the DB cannot be opened or configured. +fn open_prepared( + path: &Path, + k_intermediate: &Zeroizing<[u8; 32]>, +) -> StorageResult { + let conn = cipher::open_encrypted(path, k_intermediate, false) + .map_err(|e| map_db_err(&e))?; + schema::ensure_schema(&conn)?; + Ok(conn) +} + +/// Rebuilds the cache database by deleting and recreating it. +/// +/// # Errors +/// +/// Returns an error if deletion or re-open fails. +fn rebuild( + path: &Path, + k_intermediate: &Zeroizing<[u8; 32]>, +) -> StorageResult { + delete_cache_files(path)?; + open_prepared(path, k_intermediate) +} + +/// Deletes the cache DB and its WAL/SHM sidecar files if present. +/// +/// # Errors +/// +/// Returns an error for IO failures other than missing files. +fn delete_cache_files(path: &Path) -> StorageResult<()> { + delete_if_exists(path)?; + delete_if_exists(&path.with_extension("sqlite-wal"))?; + delete_if_exists(&path.with_extension("sqlite-shm"))?; + Ok(()) +} + +/// Deletes the file at `path` if it exists. +/// +/// # Errors +/// +/// Returns an error for IO failures other than missing files. +fn delete_if_exists(path: &Path) -> StorageResult<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(map_io_err(&err)), + } +} diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs new file mode 100644 index 000000000..3fd75b679 --- /dev/null +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -0,0 +1,36 @@ +//! Merkle proof cache helpers. + +use crate::storage::{cache::util::CACHE_KEY_PREFIX_MERKLE, error::StorageResult}; +use walletkit_db::Connection; + +use super::util::{ + cache_entry_times, get_cache_entry, prune_expired_entries, upsert_cache_entry, +}; + +/// Fetches a cached Merkle proof if it is still valid. +/// +/// # Errors +/// +/// Returns an error if the query or conversion fails. +pub(super) fn get( + conn: &Connection, + valid_until: u64, +) -> StorageResult>> { + get_cache_entry(conn, &[CACHE_KEY_PREFIX_MERKLE], valid_until, None) +} + +/// Inserts or replaces a cached Merkle proof with a TTL. +/// +/// # Errors +/// +/// Returns an error if pruning or insert fails. +pub(super) fn put( + conn: &Connection, + proof_bytes: &[u8], + now: u64, + ttl_seconds: u64, +) -> StorageResult<()> { + prune_expired_entries(conn, now)?; + let times = cache_entry_times(now, ttl_seconds)?; + upsert_cache_entry(conn, &[CACHE_KEY_PREFIX_MERKLE], proof_bytes, times) +} diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs new file mode 100644 index 000000000..1b06a6d7e --- /dev/null +++ b/walletkit-core/src/storage/cache/mod.rs @@ -0,0 +1,247 @@ +//! Encrypted cache database for credential storage. + +use std::path::Path; + +use crate::storage::error::StorageResult; +use crate::storage::lock::StorageLockGuard; +use walletkit_db::Connection; +use zeroize::Zeroizing; + +mod maintenance; +mod merkle; +mod nullifiers; +mod schema; +mod session; +mod util; + +/// Encrypted cache database wrapper. +/// +/// Stores non-authoritative, regenerable data (proof cache, session keys, replay guard) +/// to improve performance without affecting correctness if rebuilt. +#[derive(Debug)] +pub struct CacheDb { + conn: Connection, +} + +impl CacheDb { + /// Opens or creates the encrypted cache database at `path`. + /// + /// If integrity checks fail, the cache is rebuilt since its contents can be + /// regenerated from authoritative sources. + /// + /// # Errors + /// + /// Returns an error if the database cannot be opened or rebuilt. + pub fn new( + path: &Path, + k_intermediate: &Zeroizing<[u8; 32]>, + _lock: &StorageLockGuard, + ) -> StorageResult { + let conn = maintenance::open_or_rebuild(path, k_intermediate)?; + Ok(Self { conn }) + } + + /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. + /// + /// Returns `None` when missing or expired so callers can refetch from the + /// indexer without relying on stale proofs. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult>> { + merkle::get(&self.conn, valid_until) + } + + /// Inserts a cached Merkle proof with a TTL. + /// Uses the database current time for `inserted_at`. + /// + /// Existing entries for the same (registry, root, leaf index) are replaced. + /// + /// # Errors + /// + /// Returns an error if the insert fails. + #[allow(clippy::needless_pass_by_value)] + pub fn merkle_cache_put( + &mut self, + _lock: &StorageLockGuard, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + merkle::put(&self.conn, proof_bytes.as_ref(), now, ttl_seconds) + } + + /// Fetches a cached session key if present. + /// + /// This value is the per-RP session seed (aka `session_id_r_seed` in the + /// protocol). It is derived from `K_intermediate` and `rp_id` and is used to + /// derive the per-session `r` that feeds the sessionId commitment. The cache + /// is an optional performance hint and may be missing or expired. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn session_key_get(&self, rp_id: [u8; 32]) -> StorageResult> { + session::get(&self.conn, rp_id) + } + + /// Stores a session key with a TTL. + /// + /// The key is cached per relying party (`rp_id`) and replaced on insert. + /// + /// # Errors + /// + /// Returns an error if the insert fails. + pub fn session_key_put( + &mut self, + _lock: &StorageLockGuard, + rp_id: [u8; 32], + k_session: [u8; 32], + ttl_seconds: u64, + ) -> StorageResult<()> { + session::put(&self.conn, rp_id, k_session, ttl_seconds) + } + + /// Checks whether a replay guard entry exists for the given nullifier. + /// + /// # Returns + /// - bool: true if a replay guard entry exists (hence signalling a nullifier replay), false otherwise. + /// + /// # Errors + /// + /// Returns an error if the query to the cache unexpectedly fails. + pub fn is_nullifier_replay( + &self, + nullifier: [u8; 32], + now: u64, + ) -> StorageResult { + nullifiers::is_nullifier_replay(&self.conn, nullifier, now) + } + + /// After a proof has been successfully generated, creates a replay guard entry + /// locally to avoid future replays of the same nullifier. + /// + /// # Errors + /// + /// Returns an error if the query to the cache unexpectedly fails. + pub fn replay_guard_set( + &mut self, + _lock: &StorageLockGuard, + nullifier: [u8; 32], + now: u64, + ) -> StorageResult<()> { + nullifiers::replay_guard_set(&self.conn, nullifier, now) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::lock::StorageLock; + use std::fs; + use std::path::PathBuf; + use std::time::Duration; + use uuid::Uuid; + + fn temp_cache_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-cache-{}.sqlite", Uuid::new_v4())); + path + } + + fn cleanup_cache_files(path: &Path) { + let _ = fs::remove_file(path); + let wal_path = path.with_extension("sqlite-wal"); + let shm_path = path.with_extension("sqlite-shm"); + let _ = fs::remove_file(wal_path); + let _ = fs::remove_file(shm_path); + } + + fn temp_lock_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-cache-lock-{}.lock", Uuid::new_v4())); + path + } + + fn cleanup_lock_file(path: &Path) { + let _ = fs::remove_file(path); + } + + #[test] + fn test_cache_create_and_open() { + let path = temp_cache_path(); + let key = Zeroizing::new([0x11u8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let db = CacheDb::new(&path, &key, &guard).expect("create cache"); + drop(db); + CacheDb::new(&path, &key, &guard).expect("open cache"); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_cache_rebuild_on_corruption() { + let path = temp_cache_path(); + let key = Zeroizing::new([0x22u8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let mut db = CacheDb::new(&path, &key, &guard).expect("create cache"); + let rp_id = [0x01u8; 32]; + let k_session = [0x02u8; 32]; + db.session_key_put(&guard, rp_id, k_session, 1000) + .expect("put session key"); + drop(db); + + fs::write(&path, b"corrupt").expect("corrupt cache file"); + + let db = CacheDb::new(&path, &key, &guard).expect("rebuild cache"); + let value = db.session_key_get(rp_id).expect("get session key"); + assert!(value.is_none()); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_merkle_cache_ttl() { + let path = temp_cache_path(); + let key = Zeroizing::new([0x33u8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let mut db = CacheDb::new(&path, &key, &guard).expect("create cache"); + db.merkle_cache_put(&guard, vec![1, 2, 3], 100, 10) + .expect("put merkle proof"); + let valid_until = 105; + let hit = db.merkle_cache_get(valid_until).expect("get merkle proof"); + assert!(hit.is_some()); + let miss = db.merkle_cache_get(111).expect("get merkle proof"); + assert!(miss.is_none()); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_session_cache_ttl() { + let path = temp_cache_path(); + let key = Zeroizing::new([0x44u8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let mut db = CacheDb::new(&path, &key, &guard).expect("create cache"); + let rp_id = [0x55u8; 32]; + let k_session = [0x66u8; 32]; + db.session_key_put(&guard, rp_id, k_session, 1) + .expect("put session key"); + let hit = db.session_key_get(rp_id).expect("get session key"); + assert!(hit.is_some()); + std::thread::sleep(Duration::from_secs(2)); + let miss = db.session_key_get(rp_id).expect("get session key"); + assert!(miss.is_none()); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } +} diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs new file mode 100644 index 000000000..b5e6bc7fb --- /dev/null +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -0,0 +1,74 @@ +//! Used-nullifier cache helpers for replay protection. +//! +//! Tracks request ids and nullifiers to enforce single-use disclosures while +//! remaining idempotent for retries within the TTL window. + +use crate::storage::error::StorageResult; +use walletkit_db::Connection; + +use super::util::{ + cache_entry_times, get_cache_entry, get_cache_entry_tx, insert_cache_entry_tx, + map_db_err, prune_expired_entries_tx, replay_nullifier_key, +}; + +/// The time to wait before a replayed request starts being enforced. +/// +/// This delay in enforcement of non-replay nullifiers allows for issues +/// that may cause proofs not to reach RPs and prevent users from getting locked out +/// of performing a particular action. +/// +/// FUTURE: Parametrize this as a configuration option. +const REPLAY_REQUEST_NBF_SECONDS: u64 = 600; // 10 minutes + +static REPLAY_REQUEST_TTL_SECONDS: u64 = 60 * 60 * 24 * 365; // 1 year + +/// Checks whether a replay guard entry exists for the given nullifier. +/// +/// # Returns +/// - bool: true if a replay guard entry exists (hence signalling a nullifier replay), false otherwise. +/// +/// # Errors +/// +/// Returns an error if the query to the cache unexpectedly fails. +pub(super) fn is_nullifier_replay( + conn: &Connection, + nullifier: [u8; 32], + now: u64, +) -> StorageResult { + let key = replay_nullifier_key(nullifier); + let nbf = now.saturating_sub(REPLAY_REQUEST_NBF_SECONDS); + let result = get_cache_entry(conn, key.as_slice(), now, Some(nbf))?; + Ok(result.is_some()) +} + +/// After a proof has been successfully generated, creates a replay guard entry +/// locally to avoid future replays of the same nullifier. +/// +/// This operation is idempotent - if an entry already exists and hasn't expired, +/// it will not be re-inserted (maintains the original insertion time). +pub(super) fn replay_guard_set( + conn: &Connection, + nullifier: [u8; 32], + now: u64, +) -> StorageResult<()> { + let tx = conn + .transaction_immediate() + .map_err(|err| map_db_err(&err))?; + prune_expired_entries_tx(&tx, now)?; + + let key = replay_nullifier_key(nullifier); + + // Check if entry already exists (idempotency check) + let existing = get_cache_entry_tx(&tx, key.as_slice(), now, None)?; + if existing.is_some() { + // Entry already exists and hasn't expired - this is idempotent, just return success + tx.commit().map_err(|err| map_db_err(&err))?; + return Ok(()); + } + + // Insert new entry + let times = cache_entry_times(now, REPLAY_REQUEST_TTL_SECONDS)?; + insert_cache_entry_tx(&tx, key.as_slice(), &[0x1], times)?; + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) +} diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs new file mode 100644 index 000000000..c12b2f517 --- /dev/null +++ b/walletkit-core/src/storage/cache/schema.rs @@ -0,0 +1,103 @@ +//! Cache database schema management. + +use crate::storage::error::StorageResult; +use walletkit_db::{params, Connection}; + +use super::util::map_db_err; + +const CACHE_SCHEMA_VERSION: i64 = 2; + +/// Ensures the cache schema is present and at the expected version. +/// +/// # Errors +/// +/// Returns an error if schema creation or migration fails. +pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS cache_meta ( + schema_version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + );", + ) + .map_err(|err| map_db_err(&err))?; + + let existing = conn + .query_row_optional( + "SELECT schema_version FROM cache_meta LIMIT 1;", + &[], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + + match existing { + Some(version) if version == CACHE_SCHEMA_VERSION => { + ensure_entries_schema(conn)?; + } + Some(_) => { + reset_schema(conn)?; + } + None => { + ensure_entries_schema(conn)?; + insert_meta(conn)?; + } + } + Ok(()) +} + +/// Ensures the `cache_entries` table and indexes exist. +/// +/// # Errors +/// +/// Returns an error if schema creation fails. +fn ensure_entries_schema(conn: &Connection) -> StorageResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS cache_entries ( + key_bytes BLOB NOT NULL, + value_bytes BLOB NOT NULL, + inserted_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (key_bytes) + ); + + CREATE INDEX IF NOT EXISTS idx_cache_entries_expiry + ON cache_entries (expires_at);", + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +/// Drops legacy cache tables and recreates the current schema. +/// +/// # Errors +/// +/// Returns an error if the reset or re-init fails. +fn reset_schema(conn: &Connection) -> StorageResult<()> { + conn.execute_batch( + "DROP TABLE IF EXISTS used_nullifiers; + DROP TABLE IF EXISTS merkle_proof_cache; + DROP TABLE IF EXISTS session_keys; + DROP TABLE IF EXISTS cache_entries;", + ) + .map_err(|err| map_db_err(&err))?; + ensure_entries_schema(conn)?; + conn.execute("DELETE FROM cache_meta;", &[]) + .map_err(|err| map_db_err(&err))?; + insert_meta(conn)?; + Ok(()) +} + +/// Inserts the current schema version into `cache_meta`. +/// +/// # Errors +/// +/// Returns an error if the insert fails. +fn insert_meta(conn: &Connection) -> StorageResult<()> { + conn.execute( + "INSERT INTO cache_meta (schema_version, created_at, updated_at) + VALUES (?1, strftime('%s','now'), strftime('%s','now'))", + params![CACHE_SCHEMA_VERSION], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs new file mode 100644 index 000000000..a9df20303 --- /dev/null +++ b/walletkit-core/src/storage/cache/session.rs @@ -0,0 +1,61 @@ +//! Session key cache helpers. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::storage::error::{StorageError, StorageResult}; +use walletkit_db::Connection; + +use super::util::{ + cache_entry_times, get_cache_entry, parse_fixed_bytes, prune_expired_entries, + session_cache_key, upsert_cache_entry, +}; + +/// Fetches a cached session key if it is still valid. +/// +/// # Errors +/// +/// Returns an error if the query fails or the cached bytes are malformed. +pub(super) fn get( + conn: &Connection, + rp_id: [u8; 32], +) -> StorageResult> { + let now = current_unix_timestamp()?; + let key = session_cache_key(rp_id); + let raw = get_cache_entry(conn, key.as_slice(), now, None)?; + match raw { + Some(bytes) => Ok(Some(parse_fixed_bytes::<32>(&bytes, "k_session")?)), + None => Ok(None), + } +} + +/// Stores a session key with a TTL. +/// +/// # Errors +/// +/// Returns an error if pruning or insert fails. +pub(super) fn put( + conn: &Connection, + rp_id: [u8; 32], + k_session: [u8; 32], + ttl_seconds: u64, +) -> StorageResult<()> { + let now = current_unix_timestamp()?; + let key = session_cache_key(rp_id); + prune_expired_entries(conn, now)?; + let times = cache_entry_times(now, ttl_seconds)?; + upsert_cache_entry(conn, key.as_slice(), k_session.as_ref(), times) +} + +/// Returns the current unix timestamp in seconds. +/// +/// # Errors +/// +/// Returns an error if system time is before the unix epoch. +fn current_unix_timestamp() -> StorageResult { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|err| { + StorageError::CacheDb(format!("system time before unix epoch: {err}")) + })?; + Ok(duration.as_secs()) +} diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs new file mode 100644 index 000000000..a5020aa8a --- /dev/null +++ b/walletkit-core/src/storage/cache/util.rs @@ -0,0 +1,240 @@ +//! Shared helpers for cache database operations. + +use std::io; + +use crate::storage::error::{StorageError, StorageResult}; +use walletkit_db::{params, Connection, DbError, Transaction}; + +/// Maps a database error into a cache storage error. +pub(super) fn map_db_err(err: &DbError) -> StorageError { + StorageError::CacheDb(err.to_string()) +} + +/// Maps an IO error into a cache storage error. +pub(super) fn map_io_err(err: &io::Error) -> StorageError { + StorageError::CacheDb(err.to_string()) +} + +/// Parses a fixed-length array from the provided bytes. +/// +/// # Errors +/// +/// Returns an error if the input length does not match `N`. +pub(super) fn parse_fixed_bytes( + bytes: &[u8], + label: &str, +) -> StorageResult<[u8; N]> { + if bytes.len() != N { + return Err(StorageError::CacheDb(format!( + "{label} length mismatch: expected {N}, got {}", + bytes.len() + ))); + } + let mut out = [0u8; N]; + out.copy_from_slice(bytes); + Ok(out) +} + +pub(super) const CACHE_KEY_PREFIX_MERKLE: u8 = 0x01; +pub(super) const CACHE_KEY_PREFIX_SESSION: u8 = 0x02; +pub(super) const CACHE_KEY_PREFIX_REPLAY_NULLIFIER: u8 = 0x03; + +/// Timestamps for cache entry insertion and expiry. +#[derive(Clone, Copy, Debug)] +pub(super) struct CacheEntryTimes { + pub inserted_at: i64, + pub expires_at: i64, +} + +/// Builds timestamps for cache entry inserts. +/// +/// # Errors +/// +/// Returns an error if timestamps do not fit into `i64`. +pub(super) fn cache_entry_times( + now: u64, + ttl_seconds: u64, +) -> StorageResult { + let expires_at = expiry_timestamp(now, ttl_seconds); + Ok(CacheEntryTimes { + inserted_at: to_i64(now, "now")?, + expires_at: to_i64(expires_at, "expires_at")?, + }) +} + +/// Removes expired cache entries before inserting new ones. +/// +/// # Errors +/// +/// Returns an error if the deletion fails. +pub(super) fn prune_expired_entries(conn: &Connection, now: u64) -> StorageResult<()> { + let now_i64 = to_i64(now, "now")?; + conn.execute( + "DELETE FROM cache_entries WHERE expires_at <= ?1", + params![now_i64], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +/// Prunes expired cache entries within a transaction. +pub(super) fn prune_expired_entries_tx( + tx: &Transaction<'_>, + now: u64, +) -> StorageResult<()> { + let now_i64 = to_i64(now, "now")?; + tx.execute( + "DELETE FROM cache_entries WHERE expires_at <= ?1", + params![now_i64], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +/// Inserts or replaces a cache entry row. +/// +/// # Errors +/// +/// Returns an error if the insert fails. +pub(super) fn upsert_cache_entry( + conn: &Connection, + key: &[u8], + value: &[u8], + times: CacheEntryTimes, +) -> StorageResult<()> { + conn.execute( + "INSERT OR REPLACE INTO cache_entries ( + key_bytes, + value_bytes, + inserted_at, + expires_at + ) VALUES (?1, ?2, ?3, ?4)", + params![key, value, times.inserted_at, times.expires_at,], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +/// Inserts a cache entry row within a transaction. +/// +/// # Errors +/// +/// Returns an error if the insert fails. +pub(super) fn insert_cache_entry_tx( + tx: &Transaction<'_>, + key: &[u8], + value: &[u8], + times: CacheEntryTimes, +) -> StorageResult<()> { + tx.execute( + "INSERT INTO cache_entries ( + key_bytes, + value_bytes, + inserted_at, + expires_at + ) VALUES (?1, ?2, ?3, ?4)", + params![key, value, times.inserted_at, times.expires_at,], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +/// Fetches a cache entry if it is still valid. +/// +/// Optionally, filters out entries that were inserted before a specific time (`insertion_before`). +/// +/// # Errors +/// +/// Returns an error if the query or conversion fails. +pub(super) fn get_cache_entry( + conn: &Connection, + key: &[u8], + now: u64, + insertion_before: Option, +) -> StorageResult>> { + let now = to_i64(now, "now")?; + + if let Some(insertion_before) = insertion_before { + let insertion_before = to_i64(insertion_before, "insertion_before")?; + conn.query_row_optional( + "SELECT value_bytes FROM cache_entries WHERE key_bytes = ?1 AND expires_at > ?2 AND inserted_at < ?3", + params![key, now, insertion_before], + |stmt| Ok(stmt.column_blob(0)), + ) + .map_err(|err| map_db_err(&err)) + } else { + conn.query_row_optional( + "SELECT value_bytes FROM cache_entries WHERE key_bytes = ?1 AND expires_at > ?2", + params![key, now], + |stmt| Ok(stmt.column_blob(0)), + ) + .map_err(|err| map_db_err(&err)) + } +} + +/// Fetches a cache entry within a transaction. +pub(super) fn get_cache_entry_tx( + tx: &Transaction<'_>, + key: &[u8], + now: u64, + insertion_before: Option, +) -> StorageResult>> { + let now = to_i64(now, "now")?; + + if let Some(insertion_before) = insertion_before { + let insertion_before = to_i64(insertion_before, "insertion_before")?; + let mut stmt = tx.prepare( + "SELECT value_bytes FROM cache_entries WHERE key_bytes = ?1 AND expires_at > ?2 AND inserted_at < ?3", + ).map_err(|err| map_db_err(&err))?; + stmt.bind_values(params![key, now, insertion_before]) + .map_err(|err| map_db_err(&err))?; + match stmt.step().map_err(|err| map_db_err(&err))? { + walletkit_db::StepResult::Row(row) => Ok(Some(row.column_blob(0))), + walletkit_db::StepResult::Done => Ok(None), + } + } else { + let mut stmt = tx.prepare( + "SELECT value_bytes FROM cache_entries WHERE key_bytes = ?1 AND expires_at > ?2", + ).map_err(|err| map_db_err(&err))?; + stmt.bind_values(params![key, now]) + .map_err(|err| map_db_err(&err))?; + match stmt.step().map_err(|err| map_db_err(&err))? { + walletkit_db::StepResult::Row(row) => Ok(Some(row.column_blob(0))), + walletkit_db::StepResult::Done => Ok(None), + } + } +} + +/// Builds a cache key by prefixing the payload with a type byte. +fn cache_key_with_prefix(prefix: u8, payload: &[u8]) -> Vec { + let mut key = Vec::with_capacity(1 + payload.len()); + key.push(prefix); + key.extend_from_slice(payload); + key +} + +/// Builds the cache key for a session key entry. +pub(super) fn session_cache_key(rp_id: [u8; 32]) -> Vec { + cache_key_with_prefix(CACHE_KEY_PREFIX_SESSION, rp_id.as_ref()) +} + +/// Builds the cache key for a replay-guard nullifier entry. +pub(super) fn replay_nullifier_key(nullifier: [u8; 32]) -> Vec { + cache_key_with_prefix(CACHE_KEY_PREFIX_REPLAY_NULLIFIER, nullifier.as_ref()) +} + +/// Computes an expiry timestamp using saturating addition. +pub(super) const fn expiry_timestamp(now: u64, ttl_seconds: u64) -> u64 { + now.saturating_add(ttl_seconds) +} + +/// Converts a `u64` into `i64` for `SQLite` parameter bindings. +/// +/// # Errors +/// +/// Returns an error if the value cannot fit into `i64`. +pub(super) fn to_i64(value: u64, label: &str) -> StorageResult { + i64::try_from(value).map_err(|_| { + StorageError::CacheDb(format!("{label} out of range for i64: {value}")) + }) +} diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs new file mode 100644 index 000000000..54ffe8896 --- /dev/null +++ b/walletkit-core/src/storage/credential_storage.rs @@ -0,0 +1,682 @@ +//! Storage facade implementing the credential storage API. + +use std::sync::{Arc, Mutex}; + +use world_id_core::FieldElement as CoreFieldElement; + +use super::error::{StorageError, StorageResult}; +use super::keys::StorageKeys; +use super::lock::{StorageLock, StorageLockGuard}; +use super::paths::StoragePaths; +use super::traits::StorageProvider; +use super::traits::{AtomicBlobStore, DeviceKeystore}; +use super::types::CredentialRecord; +use super::{CacheDb, VaultDb}; +use crate::{Credential, FieldElement}; + +/// Concrete storage implementation backed by `SQLCipher` databases. +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] +pub struct CredentialStore { + inner: Mutex, +} + +impl std::fmt::Debug for CredentialStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CredentialStore").finish() + } +} + +struct CredentialStoreInner { + lock: StorageLock, + keystore: Arc, + blob_store: Arc, + paths: StoragePaths, + state: Option, +} + +struct StorageState { + #[allow(dead_code)] + keys: StorageKeys, + vault: VaultDb, + cache: CacheDb, + leaf_index: u64, +} + +impl CredentialStoreInner { + /// Creates a new storage handle from a platform provider. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + pub fn from_provider(provider: &dyn StorageProvider) -> StorageResult { + let paths = provider.paths(); + Self::new( + paths.as_ref().clone(), + provider.keystore(), + provider.blob_store(), + ) + } + + /// Creates a new storage handle from explicit components. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + pub fn new( + paths: StoragePaths, + keystore: Arc, + blob_store: Arc, + ) -> StorageResult { + let lock = StorageLock::open(&paths.lock_path())?; + Ok(Self { + lock, + keystore, + blob_store, + paths, + state: None, + }) + } + + fn guard(&self) -> StorageResult { + self.lock.lock() + } + + fn state(&self) -> StorageResult<&StorageState> { + self.state.as_ref().ok_or(StorageError::NotInitialized) + } + + fn state_mut(&mut self) -> StorageResult<&mut StorageState> { + self.state.as_mut().ok_or(StorageError::NotInitialized) + } +} + +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)] +impl CredentialStore { + /// Creates a new storage handle from explicit components. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] + pub fn new_with_components( + paths: Arc, + keystore: Arc, + blob_store: Arc, + ) -> StorageResult { + let paths = Arc::try_unwrap(paths).unwrap_or_else(|arc| (*arc).clone()); + let inner = CredentialStoreInner::new(paths, keystore, blob_store)?; + Ok(Self { + inner: Mutex::new(inner), + }) + } + + /// Creates a new storage handle from a platform provider. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] + #[allow(clippy::needless_pass_by_value)] + pub fn from_provider_arc( + provider: Arc, + ) -> StorageResult { + let inner = CredentialStoreInner::from_provider(provider.as_ref())?; + Ok(Self { + inner: Mutex::new(inner), + }) + } + + /// Returns the storage paths used by this handle. + /// + /// # Errors + /// + /// Returns an error if the storage mutex is poisoned. + pub fn storage_paths(&self) -> StorageResult> { + self.lock_inner().map(|inner| Arc::new(inner.paths.clone())) + } + + /// Initializes storage and validates the account leaf index. + /// + /// # Errors + /// + /// Returns an error if initialization fails or the leaf index mismatches. + pub fn init(&self, leaf_index: u64, now: u64) -> StorageResult<()> { + let mut inner = self.lock_inner()?; + inner.init(leaf_index, now) + } + + /// Lists active credential metadata, optionally filtered by issuer schema ID. + /// + /// # Errors + /// + /// Returns an error if the credential query fails. + pub fn list_credentials( + &self, + issuer_schema_id: Option, + now: u64, + ) -> StorageResult> { + self.lock_inner()?.list_credentials(issuer_schema_id, now) + } + + /// Stores a credential and optional associated data. + /// + /// # Errors + /// + /// Returns an error if the credential cannot be stored. + pub fn store_credential( + &self, + credential: &Credential, + blinding_factor: &FieldElement, + expires_at: u64, + associated_data: Option>, + now: u64, + ) -> StorageResult { + self.lock_inner()?.store_credential( + credential, + blinding_factor, + expires_at, + associated_data, + now, + ) + } + + /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. + /// + /// # Errors + /// + /// Returns an error if the cache lookup fails. + pub fn merkle_cache_get(&self, valid_until: u64) -> StorageResult>> { + self.lock_inner()?.merkle_cache_get(valid_until) + } + + /// Inserts a cached Merkle proof with a TTL. + /// + /// # Errors + /// + /// Returns an error if the cache insert fails. + pub fn merkle_cache_put( + &self, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + self.lock_inner()? + .merkle_cache_put(proof_bytes, now, ttl_seconds) + } +} + +/// Implementation not exposed to foreign bindings +impl CredentialStore { + fn lock_inner( + &self, + ) -> StorageResult> { + self.inner + .lock() + .map_err(|_| StorageError::Lock("storage mutex poisoned".to_string())) + } + + /// Retrieves a full credential including raw bytes by issuer schema ID. + /// + /// Returns the most recent non-expired credential matching the issuer schema ID. + /// + /// # Errors + /// + /// Returns an error if the credential query fails. + pub fn get_credential( + &self, + issuer_schema_id: u64, + now: u64, + ) -> StorageResult> { + self.lock_inner()?.get_credential(issuer_schema_id, now) + } + + /// Checks whether a replay guard entry exists for the given nullifier. + /// + /// # Returns + /// - bool: true if a replay guard entry exists (hence signalling a nullifier replay), false otherwise. + /// + /// # Errors + /// + /// Returns an error if the query to the cache unexpectedly fails. + pub fn is_nullifier_replay( + &self, + nullifier: CoreFieldElement, + now: u64, + ) -> StorageResult { + self.lock_inner()?.is_nullifier_replay(nullifier, now) + } + + /// After a proof has been successfully generated, creates a replay guard entry + /// locally to avoid future replays of the same nullifier. + /// + /// # Errors + /// + /// Returns an error if the query to the cache unexpectedly fails. + pub fn replay_guard_set( + &self, + nullifier: CoreFieldElement, + now: u64, + ) -> StorageResult<()> { + self.lock_inner()?.replay_guard_set(nullifier, now) + } +} + +impl CredentialStoreInner { + fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()> { + let guard = self.guard()?; + if let Some(state) = &mut self.state { + state.vault.init_leaf_index(&guard, leaf_index, now)?; + state.leaf_index = leaf_index; + return Ok(()); + } + + let keys = StorageKeys::init( + self.keystore.as_ref(), + self.blob_store.as_ref(), + &guard, + now, + )?; + let k_intermediate = keys.intermediate_key(); + let vault = VaultDb::new(&self.paths.vault_db_path(), &k_intermediate, &guard)?; + let cache = CacheDb::new(&self.paths.cache_db_path(), &k_intermediate, &guard)?; + let mut state = StorageState { + keys, + vault, + cache, + leaf_index, + }; + state.vault.init_leaf_index(&guard, leaf_index, now)?; + self.state = Some(state); + Ok(()) + } + + fn list_credentials( + &self, + issuer_schema_id: Option, + now: u64, + ) -> StorageResult> { + let state = self.state()?; + state.vault.list_credentials(issuer_schema_id, now) + } + + fn get_credential( + &self, + issuer_schema_id: u64, + now: u64, + ) -> StorageResult> { + let state = self.state()?; + if let Some((credential_bytes, blinding_factor_bytes)) = state + .vault + .fetch_credential_and_blinding_factor(issuer_schema_id, now)? + { + let credential = Credential::from_bytes(credential_bytes).map_err(|e| { + StorageError::Serialization(format!( + "Critical. Failed to deserialize credential: {e}" + )) + })?; + + let blinding_factor = CoreFieldElement::from_be_bytes( + &blinding_factor_bytes.try_into().map_err(|_| { + StorageError::Serialization( + "Critical. Blinding factor has invalid length".to_string(), + ) + })?, + ) + .map_err(|e| { + StorageError::Serialization(format!( + "Critical. Failed to deserialize blinding factor: {e}" + )) + })?; + return Ok(Some((credential, blinding_factor.into()))); + } + Ok(None) + } + + fn store_credential( + &mut self, + credential: &Credential, + blinding_factor: &FieldElement, + expires_at: u64, + associated_data: Option>, + now: u64, + ) -> StorageResult { + let issuer_schema_id = credential.issuer_schema_id(); + let genesis_issued_at = credential.genesis_issued_at(); + let credential_blob = credential + .to_bytes() + .map_err(|e| StorageError::Serialization(e.to_string()))?; + let subject_blinding_factor = blinding_factor.to_bytes(); + + let guard = self.guard()?; + let state = self.state_mut()?; + state.vault.store_credential( + &guard, + issuer_schema_id, + subject_blinding_factor, + genesis_issued_at, + expires_at, + credential_blob, + associated_data, + now, + ) + } + + fn merkle_cache_get(&self, valid_until: u64) -> StorageResult>> { + let state = self.state()?; + state.cache.merkle_cache_get(valid_until) + } + + fn merkle_cache_put( + &mut self, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + let guard = self.guard()?; + let state = self.state_mut()?; + state + .cache + .merkle_cache_put(&guard, proof_bytes, now, ttl_seconds) + } + + /// Checks whether a replay guard entry exists for the given nullifier. + /// + /// # Returns + /// - bool: true if a replay guard entry exists (hence signalling a nullifier replay), false otherwise. + /// + /// # Errors + /// + /// Returns an error if the query to the cache unexpectedly fails. + fn is_nullifier_replay( + &self, + nullifier: CoreFieldElement, + now: u64, + ) -> StorageResult { + let nullifier = nullifier.to_be_bytes(); + let state = self.state()?; + state.cache.is_nullifier_replay(nullifier, now) + } + + /// After a proof has been successfully generated, creates a replay guard entry + /// locally to avoid future replays of the same nullifier. + /// + /// # Errors + /// + /// Returns an error if the query to the cache unexpectedly fails. + fn replay_guard_set( + &mut self, + nullifier: CoreFieldElement, + now: u64, + ) -> StorageResult<()> { + let guard = self.guard()?; + let nullifier = nullifier.to_be_bytes(); + let state = self.state_mut()?; + state.cache.replay_guard_set(&guard, nullifier, now) + } +} + +impl CredentialStore { + /// Creates a new storage handle from a platform provider. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + pub fn from_provider(provider: &dyn StorageProvider) -> StorageResult { + let inner = CredentialStoreInner::from_provider(provider)?; + Ok(Self { + inner: Mutex::new(inner), + }) + } + + /// Creates a new storage handle from explicit components. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + pub fn new( + paths: StoragePaths, + keystore: Arc, + blob_store: Arc, + ) -> StorageResult { + let inner = CredentialStoreInner::new(paths, keystore, blob_store)?; + Ok(Self { + inner: Mutex::new(inner), + }) + } + + /// Returns the storage paths used by this handle. + /// + /// # Errors + /// + /// Returns an error if the storage mutex is poisoned. + pub fn paths(&self) -> StorageResult { + self.lock_inner().map(|inner| inner.paths.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::tests_utils::{ + cleanup_test_storage, temp_root_path, InMemoryStorageProvider, + }; + + #[test] + fn test_replay_guard_field_element_serialization() { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let paths = provider.paths().as_ref().clone(); + let keystore = provider.keystore(); + let blob_store = provider.blob_store(); + + let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) + .expect("create inner"); + inner.init(42, 1000).expect("init storage"); + + // Create a FieldElement from a known value + let nullifier = CoreFieldElement::from(123_456_789u64); + + // Set a replay guard + inner + .replay_guard_set(nullifier, 1000) + .expect("set replay guard"); + + // The same FieldElement should be properly serialized and found after the grace period + let exists_after_grace = inner + .is_nullifier_replay(nullifier, 1601) + .expect("check replay guard"); + assert!( + exists_after_grace, + "Replay guard should exist after grace period (10 minutes)" + ); + + cleanup_test_storage(&root); + } + + #[test] + fn test_replay_guard_grace_period() { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let paths = provider.paths().as_ref().clone(); + let keystore = provider.keystore(); + let blob_store = provider.blob_store(); + + let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) + .expect("create inner"); + inner.init(42, 1000).expect("init storage"); + + let nullifier = CoreFieldElement::from(999u64); + let set_time = 1000u64; + + // Set a replay guard at time 1000 + inner + .replay_guard_set(nullifier, set_time) + .expect("set replay guard"); + + // Within grace period (< 10 minutes): should return false + // Grace period is 600 seconds (10 minutes) + let check_time_1min = set_time + 60; // 1 minute later + let exists_1min = inner + .is_nullifier_replay(nullifier, check_time_1min) + .expect("check at 1 minute"); + assert!( + !exists_1min, + "Replay guard should NOT be enforced during grace period (1 minute)" + ); + + let check_time_ten_min = set_time + 601; // 10 minutes later + let exists_ten_min = inner + .is_nullifier_replay(nullifier, check_time_ten_min) + .expect("check at 9 minutes"); + assert!( + exists_ten_min, + "Replay guard should be enforced during grace period (10 minutes)" + ); + + cleanup_test_storage(&root); + } + + #[test] + fn test_replay_guard_expiration() { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let paths = provider.paths().as_ref().clone(); + let keystore = provider.keystore(); + let blob_store = provider.blob_store(); + + let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) + .expect("create inner"); + inner.init(42, 1000).expect("init storage"); + + let nullifier = CoreFieldElement::from(555u64); + let set_time = 3000u64; + + // Set a replay guard at time 3000 + inner + .replay_guard_set(nullifier, set_time) + .expect("set replay guard"); + + // After expiration (> 1 year): should return false + let one_year_seconds = 365 * 24 * 60 * 60; // 31,536,000 seconds + + // Just before expiration: should still exist + let check_time_before_exp = set_time + one_year_seconds - 1; + let exists_before_exp = inner + .is_nullifier_replay(nullifier, check_time_before_exp) + .expect("check before expiration"); + assert!( + exists_before_exp, + "Replay guard SHOULD exist just before expiration" + ); + + // After expiration: should not exist + let check_time_at_exp = set_time + one_year_seconds + 1; + let exists_at_exp = inner + .is_nullifier_replay(nullifier, check_time_at_exp) + .expect("check at expiration"); + assert!( + !exists_at_exp, + "Replay guard should NOT exist at expiration (1 year)" + ); + + cleanup_test_storage(&root); + } + + #[test] + fn test_replay_guard_idempotency() { + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let paths = provider.paths().as_ref().clone(); + let keystore = provider.keystore(); + let blob_store = provider.blob_store(); + + let mut inner = CredentialStoreInner::new(paths, keystore, blob_store).unwrap(); + inner.init(42, 1000).expect("init storage"); + + let nullifier = CoreFieldElement::from(12345u64); + let first_set_time = 1000u64; + + // Set a replay guard at time 1000 + inner.replay_guard_set(nullifier, first_set_time).unwrap(); + + // Try to set the same nullifier again at time 1060 (5 minutes later) + let second_set_time = first_set_time + 300; + inner + .replay_guard_set(nullifier, second_set_time) + .expect("second set should be idempotent"); + + // Check at time 1601 (10+ minutes from first set) + // This is past the grace period from the FIRST insertion + let check_time_after_grace = first_set_time + 601; + let exists_after_grace = inner + .is_nullifier_replay(nullifier, check_time_after_grace) + .expect("check after grace"); + assert!( + exists_after_grace, + "Replay guard SHOULD be enforced - past grace period from FIRST insertion" + ); + + cleanup_test_storage(&root); + } + + #[test] + fn test_get_credential() { + use world_id_core::Credential as CoreCredential; + + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let paths = provider.paths().as_ref().clone(); + let keystore = provider.keystore(); + let blob_store = provider.blob_store(); + + let mut inner = CredentialStoreInner::new(paths, keystore, blob_store) + .expect("create inner"); + inner.init(42, 1000).expect("init storage"); + + // Store a test credential + let issuer_schema_id = 123u64; + let blinding_factor = FieldElement::from(42u64); + let expires_at = 2000u64; + let core_cred = CoreCredential::new() + .issuer_schema_id(issuer_schema_id) + .genesis_issued_at(1000); + let credential: Credential = core_cred.into(); + let associated_data = Some(vec![6, 7, 8]); + + inner + .store_credential( + &credential, + &blinding_factor, + expires_at, + associated_data, + 1000, + ) + .expect("store credential"); + + // Retrieve the credential + let (credential, _blinding_factor) = inner + .get_credential(issuer_schema_id, 1000) + .expect("get credential") + .expect("credential should exist"); + + // Verify the retrieved data + assert_eq!(credential.issuer_schema_id(), issuer_schema_id); + + // Verify non-existent credential returns None + let non_existent = inner + .get_credential(999u64, 1000) + .expect("get credential query should succeed"); + assert!( + non_existent.is_none(), + "Non-existent credential should return None" + ); + + // Verify expired credential returns None + let expired = inner + .get_credential(issuer_schema_id, 2001) + .expect("get credential query should succeed"); + assert!(expired.is_none(), "Expired credential should return None"); + + cleanup_test_storage(&root); + } +} diff --git a/walletkit-core/src/storage/envelope.rs b/walletkit-core/src/storage/envelope.rs new file mode 100644 index 000000000..abad8e190 --- /dev/null +++ b/walletkit-core/src/storage/envelope.rs @@ -0,0 +1,73 @@ +//! Account key envelope persistence helpers. + +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use super::error::{StorageError, StorageResult}; + +const ENVELOPE_VERSION: u32 = 1; + +#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +pub(crate) struct AccountKeyEnvelope { + pub(crate) version: u32, + pub(crate) wrapped_k_intermediate: Vec, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +impl AccountKeyEnvelope { + pub(crate) const fn new(wrapped_k_intermediate: Vec, now: u64) -> Self { + Self { + version: ENVELOPE_VERSION, + wrapped_k_intermediate, + created_at: now, + updated_at: now, + } + } + + pub(crate) fn serialize(&self) -> StorageResult> { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(self, &mut bytes) + .map_err(|err| StorageError::Serialization(err.to_string()))?; + Ok(bytes) + } + + pub(crate) fn deserialize(bytes: &[u8]) -> StorageResult { + let envelope: Self = ciborium::de::from_reader(bytes) + .map_err(|err| StorageError::Serialization(err.to_string()))?; + if envelope.version != ENVELOPE_VERSION { + return Err(StorageError::UnsupportedEnvelopeVersion(envelope.version)); + } + Ok(envelope) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_envelope_round_trip() { + let envelope = AccountKeyEnvelope::new(vec![1, 2, 3], 123); + let bytes = envelope.serialize().expect("serialize"); + let decoded = AccountKeyEnvelope::deserialize(&bytes).expect("deserialize"); + assert_eq!(decoded.version, ENVELOPE_VERSION); + assert_eq!(decoded.wrapped_k_intermediate, vec![1, 2, 3]); + assert_eq!(decoded.created_at, 123); + assert_eq!(decoded.updated_at, 123); + } + + #[test] + fn test_envelope_version_mismatch() { + let mut envelope = AccountKeyEnvelope::new(vec![1, 2, 3], 123); + envelope.version = ENVELOPE_VERSION + 1; + let bytes = envelope.serialize().expect("serialize"); + match AccountKeyEnvelope::deserialize(&bytes) { + Err(StorageError::UnsupportedEnvelopeVersion(version)) => { + assert_eq!(version, ENVELOPE_VERSION + 1); + } + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("expected error"), + } + } +} diff --git a/walletkit-core/src/storage/error.rs b/walletkit-core/src/storage/error.rs new file mode 100644 index 000000000..2c735be9f --- /dev/null +++ b/walletkit-core/src/storage/error.rs @@ -0,0 +1,90 @@ +//! Error types for credential storage components. + +use thiserror::Error; + +/// Result type for storage operations. +pub type StorageResult = Result; + +/// Errors raised by credential storage primitives. +#[derive(Debug, Error)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Error))] +pub enum StorageError { + /// Errors coming from the device keystore. + #[error("keystore error: {0}")] + Keystore(String), + + /// Errors coming from the blob store. + #[error("blob store error: {0}")] + BlobStore(String), + + /// Errors coming from the storage lock. + #[error("storage lock error: {0}")] + Lock(String), + + /// Serialization/deserialization failures. + #[error("serialization error: {0}")] + Serialization(String), + + /// Cryptographic failures (AEAD, HKDF, etc.). + #[error("crypto error: {0}")] + Crypto(String), + + /// Invalid or malformed account key envelope. + #[error("invalid envelope: {0}")] + InvalidEnvelope(String), + + /// Unsupported envelope version. + #[error("unsupported envelope version: {0}")] + UnsupportedEnvelopeVersion(u32), + + /// Errors coming from the vault database. + #[error("vault db error: {0}")] + VaultDb(String), + + /// Errors coming from the cache database. + #[error("cache db error: {0}")] + CacheDb(String), + + /// Leaf index mismatch during initialization. + #[error("leaf index mismatch: expected {expected}, got {provided}")] + InvalidLeafIndex { + /// Leaf index stored in the vault. + expected: u64, + /// Leaf index provided by the caller. + provided: u64, + }, + + /// Vault database integrity check failed. + #[error("vault integrity check failed: {0}")] + CorruptedVault(String), + + /// Storage has not been initialized yet. + #[error("storage not initialized")] + NotInitialized, + + /// Nullifier already disclosed for a different request. + #[error("nullifier already disclosed")] + NullifierAlreadyDisclosed, + + /// Credential not found in the vault. + #[error("credential not found")] + CredentialNotFound, + + /// Corrupted cache entry + #[error("corrupted cache entry at {key_prefix}")] + CorruptedCacheEntry { + /// The prefix of the corrupted cache entry (identifies the type of entry). + key_prefix: u8, + }, + + /// Unexpected `UniFFI` callback error. + #[error("unexpected uniffi callback error: {0}")] + UnexpectedUniFFICallbackError(String), +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for StorageError { + fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::UnexpectedUniFFICallbackError(error.reason) + } +} diff --git a/walletkit-core/src/storage/groth16_cache.rs b/walletkit-core/src/storage/groth16_cache.rs new file mode 100644 index 000000000..1c050175a --- /dev/null +++ b/walletkit-core/src/storage/groth16_cache.rs @@ -0,0 +1,154 @@ +//! Helpers for caching embedded Groth16 material under [`StoragePaths`]. + +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, + sync::Arc, +}; + +use sha2::{Digest, Sha256}; + +use super::{StorageError, StoragePaths, StorageResult}; + +/// Writes embedded Groth16 material to the cache paths managed by [`StoragePaths`]. +/// +/// This operation is idempotent and atomically rewrites all managed files. +/// +/// # Errors +/// +/// Returns an error if embedded material cannot be loaded or cache files cannot be written. +#[uniffi::export] +#[allow(clippy::needless_pass_by_value)] +pub fn cache_embedded_groth16_material(paths: Arc) -> StorageResult<()> { + if has_valid_cached_material(paths.as_ref())? { + return Ok(()); + } + + let files = world_id_core::proof::load_embedded_circuit_files() + .map_err(|error| StorageError::CacheDb(error.to_string()))?; + + fs::create_dir_all(paths.groth16_dir()) + .map_err(|error| StorageError::CacheDb(error.to_string()))?; + + write_atomic(&paths.query_zkey_path(), &files.query_zkey)?; + write_atomic(&paths.nullifier_zkey_path(), &files.nullifier_zkey)?; + write_atomic(&paths.query_graph_path(), &files.query_graph)?; + write_atomic(&paths.nullifier_graph_path(), &files.nullifier_graph)?; + + Ok(()) +} + +fn has_valid_cached_material(paths: &StoragePaths) -> StorageResult { + let entries = [ + ( + paths.query_zkey_path(), + world_id_core::proof::QUERY_ZKEY_FINGERPRINT, + ), + ( + paths.nullifier_zkey_path(), + world_id_core::proof::NULLIFIER_ZKEY_FINGERPRINT, + ), + ( + paths.query_graph_path(), + world_id_core::proof::QUERY_GRAPH_FINGERPRINT, + ), + ( + paths.nullifier_graph_path(), + world_id_core::proof::NULLIFIER_GRAPH_FINGERPRINT, + ), + ]; + + for (path, expected_fingerprint) in entries { + if !path.is_file() { + return Ok(false); + } + + let actual_fingerprint = file_sha256_hex(&path)?; + if actual_fingerprint != expected_fingerprint { + return Ok(false); + } + } + + Ok(true) +} + +fn file_sha256_hex(path: &Path) -> StorageResult { + let mut file = fs::File::open(path) + .map_err(|error| StorageError::CacheDb(error.to_string()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 16 * 1024]; + loop { + let bytes_read = file + .read(&mut buffer) + .map_err(|error| StorageError::CacheDb(error.to_string()))?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + Ok(hex::encode(hasher.finalize())) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> StorageResult<()> { + let tmp_path = PathBuf::from(format!("{}.tmp", path.to_string_lossy())); + fs::write(&tmp_path, bytes) + .map_err(|error| StorageError::CacheDb(error.to_string()))?; + fs::rename(&tmp_path, path) + .map_err(|error| StorageError::CacheDb(error.to_string())) +} + +#[cfg(test)] +mod tests { + use std::{fs, sync::Arc}; + + use super::cache_embedded_groth16_material; + use crate::storage::StoragePaths; + + fn temp_root() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-groth16-cache-{}", uuid::Uuid::new_v4())); + path + } + + #[test] + fn test_cache_embedded_groth16_material_writes_all_files() { + let root = temp_root(); + let paths = Arc::new(StoragePaths::new(&root)); + + cache_embedded_groth16_material(paths.clone()) + .expect("cache embedded material"); + + assert!(paths.groth16_dir().is_dir()); + assert!(paths.query_zkey_path().is_file()); + assert!(paths.nullifier_zkey_path().is_file()); + assert!(paths.query_graph_path().is_file()); + assert!(paths.nullifier_graph_path().is_file()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn test_cache_embedded_groth16_material_is_idempotent() { + let root = temp_root(); + let paths = Arc::new(StoragePaths::new(&root)); + + cache_embedded_groth16_material(paths.clone()).expect("first cache"); + let first_query_len = fs::metadata(paths.query_zkey_path()) + .expect("query zkey metadata") + .len(); + + cache_embedded_groth16_material(paths.clone()).expect("second cache"); + let second_query_len = fs::metadata(paths.query_zkey_path()) + .expect("query zkey metadata") + .len(); + + assert_eq!(first_query_len, second_query_len); + assert!(paths.nullifier_zkey_path().is_file()); + assert!(paths.query_graph_path().is_file()); + assert!(paths.nullifier_graph_path().is_file()); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs new file mode 100644 index 000000000..fa5d532f2 --- /dev/null +++ b/walletkit-core/src/storage/keys.rs @@ -0,0 +1,167 @@ +//! Key hierarchy management for credential storage. + +use rand::{rngs::OsRng, RngCore}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use super::{ + envelope::AccountKeyEnvelope, + error::{StorageError, StorageResult}, + lock::StorageLockGuard, + traits::{AtomicBlobStore, DeviceKeystore}, + ACCOUNT_KEYS_FILENAME, ACCOUNT_KEY_ENVELOPE_AD, +}; + +/// In-memory account keys derived from the account key envelope. +/// +/// Keys are held in memory for the lifetime of the storage handle. +#[derive(Zeroize, ZeroizeOnDrop)] +#[allow(clippy::struct_field_names)] +pub struct StorageKeys { + intermediate_key: [u8; 32], +} + +impl StorageKeys { + /// Initializes storage keys by opening or creating the account key envelope. + /// + /// # Errors + /// + /// Returns an error if the envelope cannot be read, decrypted, or parsed, + /// or if persistence to the blob store fails. + pub fn init( + keystore: &dyn DeviceKeystore, + blob_store: &dyn AtomicBlobStore, + _lock: &StorageLockGuard, + now: u64, + ) -> StorageResult { + if let Some(bytes) = blob_store.read(ACCOUNT_KEYS_FILENAME.to_string())? { + let envelope = AccountKeyEnvelope::deserialize(&bytes)?; + let wrapped_k_intermediate = envelope.wrapped_k_intermediate.clone(); + let k_intermediate_bytes = Zeroizing::new(keystore.open_sealed( + ACCOUNT_KEY_ENVELOPE_AD.to_vec(), + wrapped_k_intermediate, + )?); + let k_intermediate = + parse_key_32(k_intermediate_bytes.as_slice(), "K_intermediate")?; + Ok(Self { + intermediate_key: k_intermediate, + }) + } else { + let k_intermediate = random_key(); + let wrapped_k_intermediate = keystore + .seal(ACCOUNT_KEY_ENVELOPE_AD.to_vec(), k_intermediate.to_vec())?; + let envelope = AccountKeyEnvelope::new(wrapped_k_intermediate, now); + let bytes = envelope.serialize()?; + blob_store.write_atomic(ACCOUNT_KEYS_FILENAME.to_string(), bytes)?; + Ok(Self { + intermediate_key: k_intermediate, + }) + } + } + + /// Returns the intermediate key wrapped in [`Zeroizing`] so the caller's + /// copy is automatically zeroed on drop. Treat this as sensitive material. + #[must_use] + pub fn intermediate_key(&self) -> Zeroizing<[u8; 32]> { + Zeroizing::new(self.intermediate_key) + } +} + +fn random_key() -> [u8; 32] { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + key +} + +fn parse_key_32(bytes: &[u8], label: &str) -> StorageResult<[u8; 32]> { + if bytes.len() != 32 { + return Err(StorageError::InvalidEnvelope(format!( + "{label} length mismatch: expected 32, got {}", + bytes.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(bytes); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::lock::StorageLock; + use crate::storage::tests_utils::{InMemoryBlobStore, InMemoryKeystore}; + use uuid::Uuid; + + fn temp_lock_path() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-keys-lock-{}.lock", Uuid::new_v4())); + path + } + + #[test] + fn test_storage_keys_round_trip() { + let keystore = InMemoryKeystore::new(); + let blob_store = InMemoryBlobStore::new(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let keys_first = + StorageKeys::init(&keystore, &blob_store, &guard, 100).expect("init"); + let keys_second = + StorageKeys::init(&keystore, &blob_store, &guard, 200).expect("init"); + + assert_eq!(keys_first.intermediate_key, keys_second.intermediate_key); + let _ = std::fs::remove_file(lock_path); + } + + #[test] + fn test_storage_keys_keystore_mismatch_fails() { + let keystore = InMemoryKeystore::new(); + let blob_store = InMemoryBlobStore::new(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); + + let other_keystore = InMemoryKeystore::new(); + match StorageKeys::init(&other_keystore, &blob_store, &guard, 456) { + Err( + StorageError::Crypto(_) + | StorageError::InvalidEnvelope(_) + | StorageError::Keystore(_), + ) => {} + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("expected error"), + } + let _ = std::fs::remove_file(lock_path); + } + + #[test] + fn test_storage_keys_tampered_envelope_fails() { + let keystore = InMemoryKeystore::new(); + let blob_store = InMemoryBlobStore::new(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + StorageKeys::init(&keystore, &blob_store, &guard, 123).expect("init"); + + let mut bytes = blob_store + .read(ACCOUNT_KEYS_FILENAME.to_string()) + .expect("read") + .expect("present"); + bytes[0] ^= 0xFF; + blob_store + .write_atomic(ACCOUNT_KEYS_FILENAME.to_string(), bytes) + .expect("write"); + + match StorageKeys::init(&keystore, &blob_store, &guard, 456) { + Err( + StorageError::Serialization(_) + | StorageError::Crypto(_) + | StorageError::UnsupportedEnvelopeVersion(_), + ) => {} + Err(err) => panic!("unexpected error: {err}"), + Ok(_) => panic!("expected error"), + } + let _ = std::fs::remove_file(lock_path); + } +} diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-core/src/storage/lock.rs new file mode 100644 index 000000000..25fdb7a90 --- /dev/null +++ b/walletkit-core/src/storage/lock.rs @@ -0,0 +1,335 @@ +//! Storage lock for serializing writes. +//! +//! On native platforms (Unix, Windows) a file-based `flock`/`LockFileEx` lock +//! is used to serialize writes across processes. +//! +//! On WASM targets the lock is a no-op because the runtime is single-threaded +//! (sqlite-wasm-rs is compiled with `SQLITE_THREADSAFE=0`) and runs in a +//! dedicated Web Worker. + +use std::path::Path; + +use super::error::{StorageError, StorageResult}; + +// WASM: no-op lock (single-threaded worker, SQLITE_THREADSAFE=0) + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::*; + + /// No-op storage lock for WASM. + #[derive(Debug, Clone)] + pub struct StorageLock; + + /// No-op lock guard. + #[derive(Debug)] + pub struct StorageLockGuard; + + impl StorageLock { + pub fn open(_path: &Path) -> StorageResult { + Ok(Self) + } + + pub fn lock(&self) -> StorageResult { + Ok(StorageLockGuard) + } + + pub fn try_lock(&self) -> StorageResult> { + Ok(Some(StorageLockGuard)) + } + } +} + +// Native: file-backed exclusive lock (flock on Unix, LockFileEx on Windows) + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use super::{Path, StorageError, StorageResult}; + use std::fs::{self, File, OpenOptions}; + use std::sync::Arc; + + /// A file-backed lock that serializes storage mutations across processes. + #[derive(Debug, Clone)] + pub struct StorageLock { + file: Arc, + } + + /// Guard that holds an exclusive lock for its lifetime. + #[derive(Debug)] + pub struct StorageLockGuard { + file: Arc, + } + + impl StorageLock { + /// Opens or creates the lock file at `path`. + /// + /// # Errors + /// + /// Returns an error if the file cannot be opened or created. + pub fn open(path: &Path) -> StorageResult { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| map_io_err(&err))?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|err| map_io_err(&err))?; + Ok(Self { + file: Arc::new(file), + }) + } + + /// Acquires the exclusive lock. + /// + /// # Errors + /// + /// Returns an error if the lock cannot be acquired. + pub fn lock(&self) -> StorageResult { + lock_exclusive(&self.file).map_err(|err| map_io_err(&err))?; + Ok(StorageLockGuard { + file: Arc::clone(&self.file), + }) + } + + /// Attempts to acquire the exclusive lock without blocking. + /// + /// # Errors + /// + /// Returns an error if the lock attempt fails for reasons other than + /// the lock being held by another process. + pub fn try_lock(&self) -> StorageResult> { + if try_lock_exclusive(&self.file).map_err(|err| map_io_err(&err))? { + Ok(Some(StorageLockGuard { + file: Arc::clone(&self.file), + })) + } else { + Ok(None) + } + } + } + + impl Drop for StorageLockGuard { + fn drop(&mut self) { + let _ = unlock(&self.file); + } + } + + fn map_io_err(err: &std::io::Error) -> StorageError { + StorageError::Lock(err.to_string()) + } + + // ── Unix flock ────────────────────────────────────────────────────── + + #[cfg(unix)] + fn lock_exclusive(file: &File) -> std::io::Result<()> { + let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); + let result = unsafe { flock(fd, LOCK_EX) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(unix)] + fn try_lock_exclusive(file: &File) -> std::io::Result { + let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); + let result = unsafe { flock(fd, LOCK_EX | LOCK_NB) }; + if result == 0 { + Ok(true) + } else { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + Ok(false) + } else { + Err(err) + } + } + } + + #[cfg(unix)] + fn unlock(file: &File) -> std::io::Result<()> { + let fd = std::os::unix::io::AsRawFd::as_raw_fd(file); + let result = unsafe { flock(fd, LOCK_UN) }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(unix)] + use std::os::raw::c_int; + + #[cfg(unix)] + const LOCK_EX: c_int = 2; + #[cfg(unix)] + const LOCK_NB: c_int = 4; + #[cfg(unix)] + const LOCK_UN: c_int = 8; + + #[cfg(unix)] + extern "C" { + fn flock(fd: c_int, operation: c_int) -> c_int; + } + + // ── Windows LockFileEx ────────────────────────────────────────────── + + #[cfg(windows)] + fn lock_exclusive(file: &File) -> std::io::Result<()> { + lock_file(file, 0) + } + + #[cfg(windows)] + fn try_lock_exclusive(file: &File) -> std::io::Result { + match lock_file(file, LOCKFILE_FAIL_IMMEDIATELY) { + Ok(()) => Ok(true), + Err(err) => { + if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION) { + Ok(false) + } else { + Err(err) + } + } + } + } + + #[cfg(windows)] + fn unlock(file: &File) -> std::io::Result<()> { + let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE; + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + let result = unsafe { UnlockFileEx(handle, 0, 1, 0, &mut overlapped) }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(windows)] + fn lock_file(file: &File, flags: u32) -> std::io::Result<()> { + let handle = std::os::windows::io::AsRawHandle::as_raw_handle(file) as HANDLE; + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + let result = unsafe { + LockFileEx( + handle, + LOCKFILE_EXCLUSIVE_LOCK | flags, + 0, + 1, + 0, + &mut overlapped, + ) + }; + if result != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + } + + #[cfg(windows)] + type HANDLE = *mut std::ffi::c_void; + + #[cfg(windows)] + #[repr(C)] + struct OVERLAPPED { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + h_event: HANDLE, + } + + #[cfg(windows)] + const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x2; + #[cfg(windows)] + const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x1; + #[cfg(windows)] + const ERROR_LOCK_VIOLATION: i32 = 33; + + #[cfg(windows)] + extern "system" { + fn LockFileEx( + h_file: HANDLE, + flags: u32, + reserved: u32, + bytes_to_lock_low: u32, + bytes_to_lock_high: u32, + overlapped: *mut OVERLAPPED, + ) -> i32; + fn UnlockFileEx( + h_file: HANDLE, + reserved: u32, + bytes_to_unlock_low: u32, + bytes_to_unlock_high: u32, + overlapped: *mut OVERLAPPED, + ) -> i32; + } +} + +pub use imp::{StorageLock, StorageLockGuard}; + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn temp_lock_path() -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-lock-{}.lock", Uuid::new_v4())); + path + } + + #[test] + fn test_lock_is_exclusive() { + let path = temp_lock_path(); + let lock_a = StorageLock::open(&path).expect("open lock"); + let guard = lock_a.lock().expect("acquire lock"); + + let lock_b = StorageLock::open(&path).expect("open lock"); + let blocked = lock_b.try_lock().expect("try lock"); + assert!(blocked.is_none()); + + drop(guard); + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn test_lock_serializes_across_threads() { + let path = temp_lock_path(); + let lock = StorageLock::open(&path).expect("open lock"); + + let (locked_tx, locked_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (released_tx, released_rx) = std::sync::mpsc::channel(); + + let path_clone = path.clone(); + let thread_a = std::thread::spawn(move || { + let guard = lock.lock().expect("lock in thread"); + locked_tx.send(()).expect("signal locked"); + release_rx.recv().expect("wait release"); + drop(guard); + released_tx.send(()).expect("signal released"); + let _ = std::fs::remove_file(path_clone); + }); + + locked_rx.recv().expect("wait locked"); + let lock_b = StorageLock::open(&path).expect("open lock"); + let blocked = lock_b.try_lock().expect("try lock"); + assert!(blocked.is_none()); + + release_tx.send(()).expect("release"); + released_rx.recv().expect("wait released"); + + let guard = lock_b.try_lock().expect("try lock"); + assert!(guard.is_some()); + + thread_a.join().expect("thread join"); + } +} diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs new file mode 100644 index 000000000..ad5ce92ac --- /dev/null +++ b/walletkit-core/src/storage/mod.rs @@ -0,0 +1,33 @@ +//! Credential storage primitives: key envelope and key hierarchy helpers. + +pub mod cache; +pub mod credential_storage; +pub mod envelope; +pub mod error; +pub mod groth16_cache; +pub mod keys; +pub mod lock; +pub mod paths; +pub mod traits; +pub mod types; +pub mod vault; + +pub use cache::CacheDb; +pub use credential_storage::CredentialStore; +pub use error::{StorageError, StorageResult}; +pub use groth16_cache::cache_embedded_groth16_material; +pub use keys::StorageKeys; +pub use lock::{StorageLock, StorageLockGuard}; +pub use paths::StoragePaths; +pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; +pub use types::{ + BlobKind, ContentId, CredentialRecord, Nullifier, ReplayGuardKind, + ReplayGuardResult, RequestId, +}; +pub use vault::VaultDb; + +pub(crate) const ACCOUNT_KEYS_FILENAME: &str = "account_keys.bin"; +pub(crate) const ACCOUNT_KEY_ENVELOPE_AD: &[u8] = b"worldid:account-key-envelope"; + +#[cfg(test)] +pub(crate) mod tests_utils; diff --git a/walletkit-core/src/storage/paths.rs b/walletkit-core/src/storage/paths.rs new file mode 100644 index 000000000..ee37944c4 --- /dev/null +++ b/walletkit-core/src/storage/paths.rs @@ -0,0 +1,213 @@ +//! Storage path helpers. + +use std::path::{Path, PathBuf}; + +const VAULT_FILENAME: &str = "account.vault.sqlite"; +const CACHE_FILENAME: &str = "account.cache.sqlite"; +const LOCK_FILENAME: &str = "lock"; +const GROTH16_DIRNAME: &str = "groth16"; +const QUERY_ZKEY_FILENAME: &str = "OPRFQuery.arks.zkey"; +const NULLIFIER_ZKEY_FILENAME: &str = "OPRFNullifier.arks.zkey"; +const QUERY_GRAPH_FILENAME: &str = "OPRFQueryGraph.bin"; +const NULLIFIER_GRAPH_FILENAME: &str = "OPRFNullifierGraph.bin"; + +/// Paths for credential storage artifacts under `/worldid`. +#[derive(Debug, Clone)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] +pub struct StoragePaths { + root: PathBuf, + worldid_dir: PathBuf, +} + +impl StoragePaths { + /// Builds storage paths rooted at `root`. + #[must_use] + pub fn new(root: impl AsRef) -> Self { + let root = root.as_ref().to_path_buf(); + let worldid_dir = root.join("worldid"); + Self { root, worldid_dir } + } + + /// Returns the storage root directory. + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + /// Returns the World ID storage directory. + #[must_use] + pub fn worldid_dir(&self) -> &Path { + &self.worldid_dir + } + + /// Returns the path to the vault database. + #[must_use] + pub fn vault_db_path(&self) -> PathBuf { + self.worldid_dir.join(VAULT_FILENAME) + } + + /// Returns the path to the cache database. + #[must_use] + pub fn cache_db_path(&self) -> PathBuf { + self.worldid_dir.join(CACHE_FILENAME) + } + + /// Returns the path to the lock file. + #[must_use] + pub fn lock_path(&self) -> PathBuf { + self.worldid_dir.join(LOCK_FILENAME) + } + + /// Returns the path to the Groth16 material directory. + #[must_use] + pub fn groth16_dir(&self) -> PathBuf { + self.worldid_dir.join(GROTH16_DIRNAME) + } + + /// Returns the path to the query zkey file. + #[must_use] + pub fn query_zkey_path(&self) -> PathBuf { + self.groth16_dir().join(QUERY_ZKEY_FILENAME) + } + + /// Returns the path to the nullifier zkey file. + #[must_use] + pub fn nullifier_zkey_path(&self) -> PathBuf { + self.groth16_dir().join(NULLIFIER_ZKEY_FILENAME) + } + + /// Returns the path to the query graph file. + #[must_use] + pub fn query_graph_path(&self) -> PathBuf { + self.groth16_dir().join(QUERY_GRAPH_FILENAME) + } + + /// Returns the path to the nullifier graph file. + #[must_use] + pub fn nullifier_graph_path(&self) -> PathBuf { + self.groth16_dir().join(NULLIFIER_GRAPH_FILENAME) + } +} + +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)] +impl StoragePaths { + /// Builds storage paths rooted at `root`. + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] + #[must_use] + pub fn from_root(root: String) -> Self { + Self::new(PathBuf::from(root)) + } + + /// Returns the storage root directory as a string. + #[must_use] + pub fn root_path_string(&self) -> String { + self.root.to_string_lossy().to_string() + } + + /// Returns the World ID storage directory as a string. + #[must_use] + pub fn worldid_dir_path_string(&self) -> String { + self.worldid_dir.to_string_lossy().to_string() + } + + /// Returns the path to the vault database as a string. + #[must_use] + pub fn vault_db_path_string(&self) -> String { + self.vault_db_path().to_string_lossy().to_string() + } + + /// Returns the path to the cache database as a string. + #[must_use] + pub fn cache_db_path_string(&self) -> String { + self.cache_db_path().to_string_lossy().to_string() + } + + /// Returns the path to the lock file as a string. + #[must_use] + pub fn lock_path_string(&self) -> String { + self.lock_path().to_string_lossy().to_string() + } + + /// Returns the path to the Groth16 material directory as a string. + #[must_use] + pub fn groth16_dir_path_string(&self) -> String { + self.groth16_dir().to_string_lossy().to_string() + } + + /// Returns the path to the query zkey file as a string. + #[must_use] + pub fn query_zkey_path_string(&self) -> String { + self.query_zkey_path().to_string_lossy().to_string() + } + + /// Returns the path to the nullifier zkey file as a string. + #[must_use] + pub fn nullifier_zkey_path_string(&self) -> String { + self.nullifier_zkey_path().to_string_lossy().to_string() + } + + /// Returns the path to the query graph file as a string. + #[must_use] + pub fn query_graph_path_string(&self) -> String { + self.query_graph_path().to_string_lossy().to_string() + } + + /// Returns the path to the nullifier graph file as a string. + #[must_use] + pub fn nullifier_graph_path_string(&self) -> String { + self.nullifier_graph_path().to_string_lossy().to_string() + } +} + +#[cfg(test)] +mod tests { + use super::StoragePaths; + use std::path::PathBuf; + + #[test] + fn test_groth16_paths() { + let root = PathBuf::from("/tmp/walletkit-paths"); + let paths = StoragePaths::new(&root); + let worldid = root.join("worldid"); + let groth16 = worldid.join("groth16"); + + assert_eq!(paths.groth16_dir(), groth16); + assert_eq!(paths.query_zkey_path(), groth16.join("OPRFQuery.arks.zkey")); + assert_eq!( + paths.nullifier_zkey_path(), + groth16.join("OPRFNullifier.arks.zkey") + ); + assert_eq!(paths.query_graph_path(), groth16.join("OPRFQueryGraph.bin")); + assert_eq!( + paths.nullifier_graph_path(), + groth16.join("OPRFNullifierGraph.bin") + ); + } + + #[test] + fn test_groth16_path_strings() { + let root = PathBuf::from("/tmp/walletkit-paths"); + let paths = StoragePaths::new(&root); + + assert_eq!( + paths.groth16_dir_path_string(), + paths.groth16_dir().to_string_lossy() + ); + assert_eq!( + paths.query_zkey_path_string(), + paths.query_zkey_path().to_string_lossy() + ); + assert_eq!( + paths.nullifier_zkey_path_string(), + paths.nullifier_zkey_path().to_string_lossy() + ); + assert_eq!( + paths.query_graph_path_string(), + paths.query_graph_path().to_string_lossy() + ); + assert_eq!( + paths.nullifier_graph_path_string(), + paths.nullifier_graph_path().to_string_lossy() + ); + } +} diff --git a/walletkit-core/src/storage/tests_utils.rs b/walletkit-core/src/storage/tests_utils.rs new file mode 100644 index 000000000..f6b334cc2 --- /dev/null +++ b/walletkit-core/src/storage/tests_utils.rs @@ -0,0 +1,186 @@ +//! Test helpers for credential storage. + +use std::{ + collections::HashMap, + fs, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + Key, XChaCha20Poly1305, XNonce, +}; +use rand::{rngs::OsRng, RngCore}; +use uuid::Uuid; + +use std::path::Path; + +use super::{ + error::StorageError, + paths::StoragePaths, + traits::{DeviceKeystore, StorageProvider}, + AtomicBlobStore, +}; + +pub struct InMemoryKeystore { + key: [u8; 32], +} + +pub fn temp_root_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-replay-guard-{}", Uuid::new_v4())); + path +} + +pub fn cleanup_test_storage(root: &Path) { + let paths = StoragePaths::new(root); + let vault = paths.vault_db_path(); + let cache = paths.cache_db_path(); + let lock = paths.lock_path(); + let _ = fs::remove_file(&vault); + let _ = fs::remove_file(vault.with_extension("sqlite-wal")); + let _ = fs::remove_file(vault.with_extension("sqlite-shm")); + let _ = fs::remove_file(&cache); + let _ = fs::remove_file(cache.with_extension("sqlite-wal")); + let _ = fs::remove_file(cache.with_extension("sqlite-shm")); + let _ = fs::remove_file(lock); + let _ = fs::remove_dir_all(paths.worldid_dir()); + let _ = fs::remove_dir_all(paths.root()); +} + +impl InMemoryKeystore { + pub fn new() -> Self { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + Self { key } + } +} + +impl Default for InMemoryKeystore { + fn default() -> Self { + Self::new() + } +} + +impl DeviceKeystore for InMemoryKeystore { + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> Result, StorageError> { + let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.key)); + let mut nonce_bytes = [0u8; 24]; + OsRng.fill_bytes(&mut nonce_bytes); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce_bytes), + Payload { + msg: &plaintext, + aad: &associated_data, + }, + ) + .map_err(|err| StorageError::Crypto(err.to_string()))?; + let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + fn open_sealed( + &self, + associated_data: Vec, + ciphertext: Vec, + ) -> Result, StorageError> { + if ciphertext.len() < 24 { + return Err(StorageError::InvalidEnvelope( + "keystore ciphertext too short".to_string(), + )); + } + let (nonce_bytes, payload) = ciphertext.split_at(24); + let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.key)); + cipher + .decrypt( + XNonce::from_slice(nonce_bytes), + Payload { + msg: payload, + aad: &associated_data, + }, + ) + .map_err(|err| StorageError::Crypto(err.to_string())) + } +} + +pub struct InMemoryBlobStore { + blobs: Mutex>>, +} + +impl InMemoryBlobStore { + pub fn new() -> Self { + Self { + blobs: Mutex::new(HashMap::new()), + } + } +} + +impl Default for InMemoryBlobStore { + fn default() -> Self { + Self::new() + } +} + +impl AtomicBlobStore for InMemoryBlobStore { + fn read(&self, path: String) -> Result>, StorageError> { + let guard = self + .blobs + .lock() + .map_err(|_| StorageError::BlobStore("mutex poisoned".to_string()))?; + Ok(guard.get(&path).cloned()) + } + + fn write_atomic(&self, path: String, bytes: Vec) -> Result<(), StorageError> { + self.blobs + .lock() + .map_err(|_| StorageError::BlobStore("mutex poisoned".to_string()))? + .insert(path, bytes); + Ok(()) + } + + fn delete(&self, path: String) -> Result<(), StorageError> { + self.blobs + .lock() + .map_err(|_| StorageError::BlobStore("mutex poisoned".to_string()))? + .remove(&path); + Ok(()) + } +} + +pub struct InMemoryStorageProvider { + keystore: Arc, + blob_store: Arc, + paths: Arc, +} + +impl InMemoryStorageProvider { + pub fn new(root: impl AsRef) -> Self { + Self { + keystore: Arc::new(InMemoryKeystore::new()), + blob_store: Arc::new(InMemoryBlobStore::new()), + paths: Arc::new(StoragePaths::new(root)), + } + } +} + +impl StorageProvider for InMemoryStorageProvider { + fn keystore(&self) -> Arc { + self.keystore.clone() + } + + fn blob_store(&self) -> Arc { + self.blob_store.clone() + } + + fn paths(&self) -> Arc { + Arc::clone(&self.paths) + } +} diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs new file mode 100644 index 000000000..928566ed2 --- /dev/null +++ b/walletkit-core/src/storage/traits.rs @@ -0,0 +1,90 @@ +//! Platform interfaces for credential storage. +//! +//! ## Key structure +//! +//! - `K_device`: device-bound root key managed by `DeviceKeystore`. +//! - `account_keys.bin`: account key envelope stored via `AtomicBlobStore` and +//! containing `DeviceKeystore::seal` of `K_intermediate` with associated data +//! `worldid:account-key-envelope`. +//! - `K_intermediate`: 32-byte per-account key unsealed at init and kept in +//! memory for the lifetime of the storage handle. +//! - `SQLCipher` databases: `account.vault.sqlite` (authoritative) and +//! `account.cache.sqlite` (non-authoritative) are opened with `K_intermediate`. +//! - Derived keys: per relying-party session keys may be derived from +//! `K_intermediate` and cached in `account.cache.sqlite` for performance. +//! cached in `account.cache.sqlite` for performance. + +use std::sync::Arc; + +use super::error::StorageResult; +use super::paths::StoragePaths; + +/// Device keystore interface used to seal and open account keys. +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] +pub trait DeviceKeystore: Send + Sync { + /// Seals plaintext under the device-bound key, authenticating `associated_data`. + /// + /// The associated data is not encrypted, but it is integrity-protected as part + /// of the seal operation. Any mismatch when opening must fail. + /// + /// # Errors + /// + /// Returns an error if the keystore refuses the operation or the seal fails. + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> StorageResult>; + + /// Opens ciphertext under the device-bound key, verifying `associated_data`. + /// + /// The same associated data used during sealing must be supplied or the open + /// operation must fail. + /// + /// # Errors + /// + /// Returns an error if authentication fails or the keystore cannot open. + fn open_sealed( + &self, + associated_data: Vec, + ciphertext: Vec, + ) -> StorageResult>; +} + +/// Atomic blob store for small binary files (e.g., `account_keys.bin`). +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] +pub trait AtomicBlobStore: Send + Sync { + /// Reads the blob at `path`, if present. + /// + /// # Errors + /// + /// Returns an error if the read fails. + fn read(&self, path: String) -> StorageResult>>; + + /// Writes bytes atomically to `path`. + /// + /// # Errors + /// + /// Returns an error if the write fails. + fn write_atomic(&self, path: String, bytes: Vec) -> StorageResult<()>; + + /// Deletes the blob at `path`. + /// + /// # Errors + /// + /// Returns an error if the delete fails. + fn delete(&self, path: String) -> StorageResult<()>; +} + +/// Provider responsible for platform-specific storage components and paths. +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] +pub trait StorageProvider: Send + Sync { + /// Returns the device keystore implementation. + fn keystore(&self) -> Arc; + + /// Returns the blob store implementation. + fn blob_store(&self) -> Arc; + + /// Returns the storage paths selected by the platform. + fn paths(&self) -> Arc; +} diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs new file mode 100644 index 000000000..f9bcec5d6 --- /dev/null +++ b/walletkit-core/src/storage/types.rs @@ -0,0 +1,79 @@ +//! Public types for credential storage. + +use super::error::{StorageError, StorageResult}; + +/// Kind of blob stored in the vault. +/// +/// Blob records (stored in the `blob_objects` table) carry a kind tag that +/// distinguishes credential payloads from associated data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +#[repr(u8)] +pub enum BlobKind { + /// Credential blob payload. + CredentialBlob = 1, + /// Associated data payload. + AssociatedData = 2, +} + +impl BlobKind { + pub(crate) const fn as_i64(self) -> i64 { + self as i64 + } +} + +impl TryFrom for BlobKind { + type Error = StorageError; + + fn try_from(value: i64) -> StorageResult { + match value { + 1 => Ok(Self::CredentialBlob), + 2 => Ok(Self::AssociatedData), + _ => Err(StorageError::VaultDb(format!("invalid blob kind {value}"))), + } + } +} + +/// Content identifier for stored blobs. +pub type ContentId = [u8; 32]; + +/// Request identifier for replay guard. +pub type RequestId = [u8; 32]; + +/// Nullifier identifier used for replay safety. +pub type Nullifier = [u8; 32]; + +/// In-memory representation of stored credential metadata. +/// +/// This is intentionally small and excludes blobs; full credential payloads can +/// be fetched separately to avoid heavy list queries. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct CredentialRecord { + /// Credential identifier. + pub credential_id: u64, + /// Issuer schema identifier. + pub issuer_schema_id: u64, + /// Expiry timestamp (seconds). + pub expires_at: u64, +} + +/// FFI-friendly replay guard result kind. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] +pub enum ReplayGuardKind { + /// Stored bytes for the first disclosure of a request. + Fresh, + /// Stored bytes replayed for an existing request. + Replay, +} + +/// Replay guard result. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ReplayGuardResult { + /// Result kind. + pub kind: ReplayGuardKind, + /// Stored proof package bytes. + pub bytes: Vec, +} diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs new file mode 100644 index 000000000..7f45041af --- /dev/null +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -0,0 +1,47 @@ +//! Vault database helpers for content addressing and type conversion. + +use sha2::{Digest, Sha256}; + +use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::types::{BlobKind, ContentId, CredentialRecord}; +use walletkit_db::{DbError, Row}; + +const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; + +pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> ContentId { + let mut hasher = Sha256::new(); + hasher.update(CONTENT_ID_PREFIX); + hasher.update([blob_kind as u8]); + hasher.update(plaintext); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} + +pub(super) fn map_record(row: &Row<'_, '_>) -> StorageResult { + let credential_id = row.column_i64(0); + let issuer_schema_id = row.column_i64(1); + let expires_at = row.column_i64(2); + Ok(CredentialRecord { + credential_id: to_u64(credential_id, "credential_id")?, + issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, + expires_at: to_u64(expires_at, "expires_at")?, + }) +} + +pub(super) fn to_i64(value: u64, label: &str) -> StorageResult { + i64::try_from(value).map_err(|_| { + StorageError::VaultDb(format!("{label} out of range for i64: {value}")) + }) +} + +pub(super) fn to_u64(value: i64, label: &str) -> StorageResult { + u64::try_from(value).map_err(|_| { + StorageError::VaultDb(format!("{label} out of range for u64: {value}")) + }) +} + +pub(super) fn map_db_err(err: &DbError) -> StorageError { + StorageError::VaultDb(err.to_string()) +} diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs new file mode 100644 index 000000000..645557964 --- /dev/null +++ b/walletkit-core/src/storage/vault/mod.rs @@ -0,0 +1,288 @@ +//! Encrypted vault database for credential storage. + +mod helpers; +mod schema; +#[cfg(test)] +mod tests; + +use std::path::Path; + +use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::lock::StorageLockGuard; +use crate::storage::types::{BlobKind, CredentialRecord}; +use helpers::{compute_content_id, map_db_err, map_record, to_i64, to_u64}; +use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; +use walletkit_db::cipher; +use walletkit_db::{params, Connection, StepResult, Value}; +use zeroize::Zeroizing; + +/// Encrypted vault database wrapper. +#[derive(Debug)] +pub struct VaultDb { + conn: Connection, +} + +impl VaultDb { + /// Opens or creates the encrypted vault database at `path`. + /// + /// # Errors + /// + /// Returns an error if the database cannot be opened, keyed, or initialized. + pub fn new( + path: &Path, + k_intermediate: &Zeroizing<[u8; 32]>, + _lock: &StorageLockGuard, + ) -> StorageResult { + let conn = cipher::open_encrypted(path, k_intermediate, false) + .map_err(|e| map_db_err(&e))?; + ensure_schema(&conn)?; + let db = Self { conn }; + if !db.check_integrity()? { + return Err(StorageError::CorruptedVault( + "integrity_check failed".to_string(), + )); + } + Ok(db) + } + + /// Initializes or validates the leaf index for this vault. + /// + /// The leaf index is the account's position in the registry tree and must be + /// consistent for all subsequent operations. A mismatch returns an error. + /// + /// # Errors + /// + /// Returns an error if the stored leaf index does not match. + pub fn init_leaf_index( + &mut self, + _lock: &StorageLockGuard, + leaf_index: u64, + now: u64, + ) -> StorageResult<()> { + let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; + let now_i64 = to_i64(now, "now")?; + let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; + let stored = tx + .query_row( + "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) + VALUES (?1, ?2, ?3, ?3) + ON CONFLICT(schema_version) DO UPDATE SET + leaf_index = CASE + WHEN vault_meta.leaf_index IS NULL + THEN excluded.leaf_index + ELSE vault_meta.leaf_index + END + RETURNING leaf_index", + params![ + VAULT_SCHEMA_VERSION, + leaf_index_i64, + now_i64, + ], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + if stored != leaf_index_i64 { + let expected = to_u64(stored, "leaf_index")?; + return Err(StorageError::InvalidLeafIndex { + expected, + provided: leaf_index, + }); + } + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) + } + + /// Stores a credential and optional associated data. + /// + /// Blob content is deduplicated by content id to avoid storing identical + /// payloads multiple times. + /// + /// # Errors + /// + /// Returns an error if any insert fails. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::needless_pass_by_value)] + pub fn store_credential( + &mut self, + _lock: &StorageLockGuard, + issuer_schema_id: u64, + subject_blinding_factor: Vec, + genesis_issued_at: u64, + expires_at: u64, + credential_blob: Vec, + associated_data: Option>, + now: u64, + ) -> StorageResult { + let credential_blob_id = + compute_content_id(BlobKind::CredentialBlob, &credential_blob); + let associated_data_id = associated_data + .as_ref() + .map(|bytes| compute_content_id(BlobKind::AssociatedData, bytes)); + let now_i64 = to_i64(now, "now")?; + let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?; + let genesis_issued_at_i64 = to_i64(genesis_issued_at, "genesis_issued_at")?; + let expires_at_i64 = to_i64(expires_at, "expires_at")?; + + let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; + tx.execute( + "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) + VALUES (?1, ?2, ?3, ?4)", + params![ + credential_blob_id.as_ref(), + BlobKind::CredentialBlob.as_i64(), + now_i64, + credential_blob.as_slice(), + ], + ) + .map_err(|err| map_db_err(&err))?; + + if let Some(data) = associated_data { + let cid = associated_data_id.as_ref().ok_or_else(|| { + StorageError::VaultDb("associated data CID must be present".to_string()) + })?; + tx.execute( + "INSERT OR IGNORE INTO blob_objects (content_id, blob_kind, created_at, bytes) + VALUES (?1, ?2, ?3, ?4)", + params![ + cid.as_ref(), + BlobKind::AssociatedData.as_i64(), + now_i64, + data.as_slice(), + ], + ) + .map_err(|err| map_db_err(&err))?; + } + + let ad_cid_value: Value = associated_data_id + .as_ref() + .map_or(Value::Null, |cid| Value::Blob(cid.to_vec())); + + let credential_id = tx + .query_row( + "INSERT INTO credential_records ( + issuer_schema_id, + subject_blinding_factor, + genesis_issued_at, + expires_at, + updated_at, + credential_blob_cid, + associated_data_cid + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + RETURNING credential_id", + params![ + issuer_schema_id_i64, + subject_blinding_factor, + genesis_issued_at_i64, + expires_at_i64, + now_i64, + credential_blob_id.as_ref(), + ad_cid_value, + ], + |stmt| Ok(stmt.column_i64(0)), + ) + .map_err(|err| map_db_err(&err))?; + + tx.commit().map_err(|err| map_db_err(&err))?; + to_u64(credential_id, "credential_id") + } + + /// Lists active credential metadata, optionally filtered by issuer schema. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn list_credentials( + &self, + issuer_schema_id: Option, + now: u64, + ) -> StorageResult> { + let expires = to_i64(now, "now")?; + let issuer_schema_id_i64 = issuer_schema_id + .map(|value| to_i64(value, "issuer_schema_id")) + .transpose()?; + + let mut records = Vec::new(); + + if let Some(issuer_id) = issuer_schema_id_i64 { + let sql = "SELECT + cr.credential_id, + cr.issuer_schema_id, + cr.expires_at + FROM credential_records cr + WHERE cr.expires_at > ?1 + AND cr.issuer_schema_id = ?2 + ORDER BY cr.updated_at DESC"; + let mut stmt = self.conn.prepare(sql).map_err(|err| map_db_err(&err))?; + stmt.bind_values(params![expires, issuer_id]) + .map_err(|err| map_db_err(&err))?; + while let StepResult::Row(row) = + stmt.step().map_err(|err| map_db_err(&err))? + { + records.push(map_record(&row)?); + } + } else { + let sql = "SELECT + cr.credential_id, + cr.issuer_schema_id, + cr.expires_at + FROM credential_records cr + WHERE cr.expires_at > ?1 + ORDER BY cr.updated_at DESC"; + let mut stmt = self.conn.prepare(sql).map_err(|err| map_db_err(&err))?; + stmt.bind_values(params![expires]) + .map_err(|err| map_db_err(&err))?; + while let StepResult::Row(row) = + stmt.step().map_err(|err| map_db_err(&err))? + { + records.push(map_record(&row)?); + } + } + Ok(records) + } + + /// Retrieves the credential bytes and blinding factor by issuer schema ID. + /// + /// Returns the most recent non-expired credential matching the issuer schema ID. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn fetch_credential_and_blinding_factor( + &self, + issuer_schema_id: u64, + now: u64, + ) -> StorageResult, Vec)>> { + let expires = to_i64(now, "now")?; + let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?; + + let sql = "SELECT + cr.subject_blinding_factor, + blob.bytes as credential_blob + FROM credential_records cr + INNER JOIN blob_objects blob ON cr.credential_blob_cid = blob.content_id + WHERE cr.expires_at > ?1 AND cr.issuer_schema_id = ?2 + ORDER BY cr.updated_at DESC + LIMIT 1"; + + let mut stmt = self.conn.prepare(sql).map_err(|err| map_db_err(&err))?; + stmt.bind_values(params![expires, issuer_schema_id_i64]) + .map_err(|err| map_db_err(&err))?; + match stmt.step().map_err(|err| map_db_err(&err))? { + StepResult::Row(row) => { + let blinding_factor = row.column_blob(0); + let credential_blob = row.column_blob(1); + Ok(Some((credential_blob, blinding_factor))) + } + StepResult::Done => Ok(None), + } + } + + /// Runs an integrity check on the vault database. + /// + /// # Errors + /// + /// Returns an error if the check cannot be executed. + pub fn check_integrity(&self) -> StorageResult { + cipher::integrity_check(&self.conn).map_err(|e| map_db_err(&e)) + } +} diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs new file mode 100644 index 000000000..4fdde1465 --- /dev/null +++ b/walletkit-core/src/storage/vault/schema.rs @@ -0,0 +1,58 @@ +//! Vault database schema management. + +use crate::storage::error::StorageResult; +use walletkit_db::Connection; + +use super::helpers::map_db_err; + +pub(super) const VAULT_SCHEMA_VERSION: i64 = 1; + +pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS vault_meta ( + schema_version INTEGER NOT NULL, + leaf_index INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_meta_schema_version + ON vault_meta (schema_version); + + CREATE TRIGGER IF NOT EXISTS vault_meta_set_updated_at + AFTER UPDATE ON vault_meta + FOR EACH ROW + BEGIN + UPDATE vault_meta + SET updated_at = CAST(strftime('%s','now') AS INTEGER) + WHERE schema_version = NEW.schema_version; + END; + + CREATE TABLE IF NOT EXISTS credential_records ( + credential_id INTEGER NOT NULL PRIMARY KEY, + issuer_schema_id INTEGER NOT NULL, + subject_blinding_factor BLOB NOT NULL, + genesis_issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + credential_blob_cid BLOB NOT NULL, + associated_data_cid BLOB + ); + + CREATE INDEX IF NOT EXISTS idx_cred_by_issuer_schema + ON credential_records (issuer_schema_id, updated_at DESC); + + CREATE INDEX IF NOT EXISTS idx_cred_by_expiry + ON credential_records (expires_at); + + CREATE TABLE IF NOT EXISTS blob_objects ( + content_id BLOB NOT NULL, + blob_kind INTEGER NOT NULL, + created_at INTEGER NOT NULL, + bytes BLOB NOT NULL, + PRIMARY KEY (content_id) + );", + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs new file mode 100644 index 000000000..ee9618735 --- /dev/null +++ b/walletkit-core/src/storage/vault/tests.rs @@ -0,0 +1,303 @@ +//! Vault database unit tests. + +use super::helpers::{compute_content_id, map_db_err}; +use super::*; +use crate::storage::lock::StorageLock; +use std::fs; +use std::path::{Path, PathBuf}; +use uuid::Uuid; +use zeroize::Zeroizing; + +fn temp_vault_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-vault-{}.sqlite", Uuid::new_v4())); + path +} + +fn cleanup_vault_files(path: &Path) { + let _ = fs::remove_file(path); + let wal_path = path.with_extension("sqlite-wal"); + let shm_path = path.with_extension("sqlite-shm"); + let _ = fs::remove_file(wal_path); + let _ = fs::remove_file(shm_path); +} + +fn temp_lock_path() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-vault-lock-{}.lock", Uuid::new_v4())); + path +} + +fn cleanup_lock_file(path: &Path) { + let _ = fs::remove_file(path); +} + +fn sample_blinding_factor() -> Vec { + [0x11u8; 32].to_vec() +} + +#[test] +fn test_vault_create_and_open() { + let path = temp_vault_path(); + let key = Zeroizing::new([0x42u8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let db = VaultDb::new(&path, &key, &guard).expect("create vault"); + drop(db); + VaultDb::new(&path, &key, &guard).expect("open vault"); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_vault_wrong_key_fails() { + let path = temp_vault_path(); + let key = Zeroizing::new([0x01u8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + VaultDb::new(&path, &key, &guard).expect("create vault"); + let wrong_key = Zeroizing::new([0x02u8; 32]); + let err = VaultDb::new(&path, &wrong_key, &guard).expect_err("wrong key"); + match err { + StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} + _ => panic!("unexpected error: {err}"), + } + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_leaf_index_set_once() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x03u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.init_leaf_index(&guard, 42, 100) + .expect("init leaf index"); + db.init_leaf_index(&guard, 42, 200) + .expect("init leaf index again"); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_leaf_index_immutable() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x04u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.init_leaf_index(&guard, 7, 100).expect("init leaf index"); + let err = db.init_leaf_index(&guard, 8, 200).expect_err("mismatch"); + match err { + StorageError::InvalidLeafIndex { .. } => {} + _ => panic!("unexpected error: {err}"), + } + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_store_credential_without_associated_data() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x05u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + let credential_id = db + .store_credential( + &guard, + 10, + sample_blinding_factor(), + 123, + 2000, + b"credential".to_vec(), + None, + 1000, + ) + .expect("store credential"); + let records = db.list_credentials(None, 1000).expect("list credentials"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].credential_id, credential_id); + assert_eq!(records[0].issuer_schema_id, 10); + assert_eq!(records[0].expires_at, 2000); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_store_credential_with_associated_data() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x06u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.store_credential( + &guard, + 11, + sample_blinding_factor(), + 456, + 2000, + b"credential-2".to_vec(), + Some(b"associated".to_vec()), + 1000, + ) + .expect("store credential"); + let records = db.list_credentials(None, 1000).expect("list credentials"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].issuer_schema_id, 11); + assert_eq!(records[0].expires_at, 2000); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_content_id_determinism() { + let a = compute_content_id(BlobKind::CredentialBlob, b"data"); + let b = compute_content_id(BlobKind::CredentialBlob, b"data"); + assert_eq!(a, b); +} + +#[test] +fn test_content_id_deduplication() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x07u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.store_credential( + &guard, + 12, + sample_blinding_factor(), + 1, + 2000, + b"same".to_vec(), + None, + 1000, + ) + .expect("store credential"); + db.store_credential( + &guard, + 12, + sample_blinding_factor(), + 1, + 2000, + b"same".to_vec(), + None, + 1001, + ) + .expect("store credential"); + let count = db + .conn + .query_row("SELECT COUNT(*) FROM blob_objects", &[], |stmt| { + Ok(stmt.column_i64(0)) + }) + .map_err(|err| map_db_err(&err)) + .expect("count blobs"); + assert_eq!(count, 1); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_list_credentials_by_issuer() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x08u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.store_credential( + &guard, + 100, + sample_blinding_factor(), + 1, + 2000, + b"issuer-a".to_vec(), + None, + 1000, + ) + .expect("store credential"); + db.store_credential( + &guard, + 200, + sample_blinding_factor(), + 1, + 2000, + b"issuer-b".to_vec(), + None, + 1000, + ) + .expect("store credential"); + let records = db + .list_credentials(Some(200), 1000) + .expect("list credentials"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].issuer_schema_id, 200); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_list_credentials_excludes_expired() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x09u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); + db.store_credential( + &guard, + 300, + sample_blinding_factor(), + 1, + 900, + b"expired".to_vec(), + None, + 1000, + ) + .expect("store credential"); + let records = db.list_credentials(None, 1000).expect("list credentials"); + assert!(records.is_empty()); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_vault_integrity_check() { + let path = temp_vault_path(); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + let key = Zeroizing::new([0x0Au8; 32]); + let db = VaultDb::new(&path, &key, &guard).expect("create vault"); + assert!(db.check_integrity().expect("integrity")); + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} + +#[test] +fn test_vault_corruption_handling() { + let path = temp_vault_path(); + let key = Zeroizing::new([0x0Bu8; 32]); + let lock_path = temp_lock_path(); + let lock = StorageLock::open(&lock_path).expect("open lock"); + let guard = lock.lock().expect("lock"); + VaultDb::new(&path, &key, &guard).expect("create vault"); + fs::write(&path, b"corrupt").expect("corrupt file"); + let err = VaultDb::new(&path, &key, &guard).expect_err("corrupt vault"); + match err { + StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} + _ => panic!("unexpected error: {err}"), + } + cleanup_vault_files(&path); + cleanup_lock_file(&lock_path); +} diff --git a/walletkit-core/src/world_id.rs b/walletkit-core/src/world_id.rs index f6a694c91..d630f3894 100644 --- a/walletkit-core/src/world_id.rs +++ b/walletkit-core/src/world_id.rs @@ -314,124 +314,3 @@ mod tests { assert!(device_commitment != commitment); } } - -#[cfg(feature = "http-tests")] -#[cfg(test)] -/// Integration tests that require HTTP calls to other services. -mod http_tests { - use semaphore_rs::protocol::Proof; - use serde::Serialize; - - use super::*; - - #[tokio::test] - async fn test_proof_verification_with_sign_up_sequencer() { - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - /// Reference: - struct VerifyProofRequest { - root: String, - signal_hash: String, - nullifier_hash: String, - external_nullifier_hash: String, - /// Full unpacked Semaphore proof. - /// Reference: - proof: Proof, - } - - let world_id = WorldId::new(b"not_a_real_secret", &Environment::Staging); - let context = ProofContext::new( - "app_id", - None, - Some("test-signal".to_string()), - CredentialType::Device, - ); - - let proof = world_id.generate_proof(&context).await.unwrap(); - - let request = VerifyProofRequest { - root: proof.merkle_root.to_hex_string(), - signal_hash: context.signal_hash.to_hex_string(), - nullifier_hash: proof.nullifier_hash.to_hex_string(), - external_nullifier_hash: context.external_nullifier.to_hex_string(), - proof: proof.raw_proof, - }; - - let client = reqwest::Client::new(); - - let response = client - .post( - "https://signup-phone-ethereum.stage-crypto.worldcoin.org/v2/semaphore-proof/verify", - ) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&request).unwrap()) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), 200); - assert!(response.json::().await.unwrap()["valid"] - .as_bool() - .unwrap()); - } - - #[tokio::test] - async fn test_proof_verification_with_developer_portal() { - #[derive(Serialize)] - /// Reference: - struct VerifyProofRequest { - merkle_root: String, - signal_hash: String, - nullifier_hash: String, - action: String, - /// Developer Portal expects the stringified packed proof. - proof: String, - verification_level: String, - } - - let world_id = WorldId::new(b"not_a_real_secret", &Environment::Staging); - let context = ProofContext::new( - "app_staging_509648994ab005fe79c4ddd0449606ca", - Some("action-1".to_string()), - Some("test-signal".to_string()), - CredentialType::Device, - ); - - let proof = world_id.generate_proof(&context).await.unwrap(); - - let request = VerifyProofRequest { - merkle_root: proof.merkle_root.to_hex_string(), - signal_hash: context.signal_hash.to_hex_string(), - nullifier_hash: proof.nullifier_hash.to_hex_string(), - action: "action-1".to_string(), - proof: proof.get_proof_as_string(), - verification_level: CredentialType::Device.to_string(), - }; - - let client = reqwest::Client::new(); - - let response = client - .post("https://staging-developer.worldcoin.org/api/v2/verify/app_staging_509648994ab005fe79c4ddd0449606ca") - .header("Content-Type", "application/json") - .header("User-Agent", format!("walletkit-core/{}", env!("CARGO_PKG_VERSION"))) // Developer Portal requires a User-Agent - .body(serde_json::to_string(&request).unwrap()) - .send() - .await - .unwrap(); - - let status = response.status(); - - if status != 200 { - println!("received status from developer portal: {status}"); - let body = response.text().await.unwrap(); - println!("received body from developer portal: {body}"); - panic!("test was unsuccessful"); - } - - assert!( - response.json::().await.unwrap()["success"] - .as_bool() - .unwrap() - ); - } -} diff --git a/walletkit-core/tests/authenticator_integration.rs b/walletkit-core/tests/authenticator_integration.rs index 5581fa606..7012d23b5 100644 --- a/walletkit-core/tests/authenticator_integration.rs +++ b/walletkit-core/tests/authenticator_integration.rs @@ -1,19 +1,23 @@ +#![cfg(feature = "storage")] +#![allow(missing_docs)] + +mod common; + use alloy::node_bindings::AnvilInstance; -use alloy::primitives::{address, U256}; +use alloy::primitives::U256; use alloy::providers::ProviderBuilder; use alloy::signers::local::PrivateKeySigner; +use walletkit_core::defaults::WORLD_ID_REGISTRY; use walletkit_core::error::WalletKitError; +use walletkit_core::storage::cache_embedded_groth16_material; use walletkit_core::{Authenticator, Environment}; use world_id_core::world_id_registry::WorldIdRegistry; -const WORLD_ID_REGISTRY: alloy::primitives::Address = - address!("0xb64a1F443C9a18Cd3865C3c9Be871946617C0d75"); - fn setup_anvil() -> AnvilInstance { dotenvy::dotenv().ok(); - let rpc_url = std::env::var("WORLDCHAIN_RPC_URL").expect( - "WORLDCHAIN_RPC_URL not set. Copy .env.example to .env and add your RPC URL", - ); + let rpc_url = std::env::var("WORLDCHAIN_RPC_URL").unwrap_or_else(|_| { + "https://worldchain-mainnet.g.alchemy.com/public".to_string() + }); let anvil = alloy::node_bindings::Anvil::new().fork(rpc_url).spawn(); println!( @@ -31,12 +35,18 @@ async fn test_authenticator_integration() { let anvil = setup_anvil(); let authenticator_seeder = PrivateKeySigner::random(); + let store = common::create_test_credential_store(); + let paths = store.storage_paths().unwrap(); + cache_embedded_groth16_material(paths.clone()).expect("cache groth16 material"); // When account doesn't exist, this should fail let authenticator = Authenticator::init_with_defaults( authenticator_seeder.to_bytes().as_slice(), Some(anvil.endpoint()), &Environment::Staging, + None, + paths.clone(), + store.clone(), ) .await .unwrap_err(); @@ -54,7 +64,7 @@ async fn test_authenticator_integration() { let tx = registry .createAccount( - address!("0x0000000000000000000000000000000000000001"), // recovery address + alloy::primitives::Address::with_last_byte(1), // recovery address vec![authenticator_seeder.address()], vec![U256::from(1)], // pubkeys U256::from(1), // commitment @@ -70,6 +80,9 @@ async fn test_authenticator_integration() { authenticator_seeder.to_bytes().as_slice(), Some(anvil.endpoint()), &Environment::Staging, + None, + paths, + store, ) .await .unwrap(); diff --git a/walletkit-core/tests/common.rs b/walletkit-core/tests/common.rs new file mode 100644 index 000000000..4fdefb68e --- /dev/null +++ b/walletkit-core/tests/common.rs @@ -0,0 +1,214 @@ +#![cfg(feature = "storage")] +#![allow( + missing_docs, + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::must_use_candidate +)] + +//! Common test utilities shared across integration tests. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + Key, XChaCha20Poly1305, XNonce, +}; +use rand::{rngs::OsRng, RngCore}; +use uuid::Uuid; +use walletkit_core::storage::{ + AtomicBlobStore, CredentialStore, DeviceKeystore, StorageError, StoragePaths, + StorageProvider, +}; +use world_id_core::{Credential as CoreCredential, FieldElement as CoreFieldElement}; + +#[allow(dead_code, reason = "used in tests")] +pub fn build_base_credential( + issuer_schema_id: u64, + leaf_index: u64, + genesis_issued_at: u64, + expires_at: u64, + credential_sub_blinding_factor: CoreFieldElement, +) -> CoreCredential { + let sub = CoreCredential::compute_sub(leaf_index, credential_sub_blinding_factor); + CoreCredential::new() + .issuer_schema_id(issuer_schema_id) + .subject(sub) + .genesis_issued_at(genesis_issued_at) + .expires_at(expires_at) +} + +pub struct InMemoryKeystore { + key: [u8; 32], +} + +impl InMemoryKeystore { + pub fn new() -> Self { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + Self { key } + } +} + +impl Default for InMemoryKeystore { + fn default() -> Self { + Self::new() + } +} + +impl DeviceKeystore for InMemoryKeystore { + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> Result, StorageError> { + let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.key)); + let mut nonce_bytes = [0u8; 24]; + OsRng.fill_bytes(&mut nonce_bytes); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce_bytes), + Payload { + msg: &plaintext, + aad: &associated_data, + }, + ) + .map_err(|err| StorageError::Crypto(err.to_string()))?; + let mut out = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + fn open_sealed( + &self, + associated_data: Vec, + ciphertext: Vec, + ) -> Result, StorageError> { + if ciphertext.len() < 24 { + return Err(StorageError::InvalidEnvelope( + "keystore ciphertext too short".to_string(), + )); + } + let (nonce_bytes, payload) = ciphertext.split_at(24); + let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.key)); + cipher + .decrypt( + XNonce::from_slice(nonce_bytes), + Payload { + msg: payload, + aad: &associated_data, + }, + ) + .map_err(|err| StorageError::Crypto(err.to_string())) + } +} + +pub struct InMemoryBlobStore { + blobs: Mutex>>, +} + +impl InMemoryBlobStore { + pub fn new() -> Self { + Self { + blobs: Mutex::new(HashMap::new()), + } + } +} + +impl Default for InMemoryBlobStore { + fn default() -> Self { + Self::new() + } +} + +impl AtomicBlobStore for InMemoryBlobStore { + fn read(&self, path: String) -> Result>, StorageError> { + let guard = self + .blobs + .lock() + .map_err(|_| StorageError::BlobStore("mutex poisoned".to_string()))?; + Ok(guard.get(&path).cloned()) + } + + fn write_atomic(&self, path: String, bytes: Vec) -> Result<(), StorageError> { + self.blobs + .lock() + .map_err(|_| StorageError::BlobStore("mutex poisoned".to_string()))? + .insert(path, bytes); + Ok(()) + } + + fn delete(&self, path: String) -> Result<(), StorageError> { + self.blobs + .lock() + .map_err(|_| StorageError::BlobStore("mutex poisoned".to_string()))? + .remove(&path); + Ok(()) + } +} + +pub struct InMemoryStorageProvider { + keystore: Arc, + blob_store: Arc, + paths: Arc, +} + +impl InMemoryStorageProvider { + pub fn new(root: impl AsRef) -> Self { + Self { + keystore: Arc::new(InMemoryKeystore::new()), + blob_store: Arc::new(InMemoryBlobStore::new()), + paths: Arc::new(StoragePaths::new(root)), + } + } +} + +impl StorageProvider for InMemoryStorageProvider { + fn keystore(&self) -> Arc { + self.keystore.clone() + } + + fn blob_store(&self) -> Arc { + self.blob_store.clone() + } + + fn paths(&self) -> Arc { + Arc::clone(&self.paths) + } +} + +pub fn temp_root() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-test-{}", Uuid::new_v4())); + path +} + +#[allow(dead_code, reason = "used in tests")] +pub fn create_test_credential_store() -> Arc { + let root = temp_root(); + let provider = InMemoryStorageProvider::new(&root); + Arc::new( + CredentialStore::from_provider(&provider).expect("create credential store"), + ) +} + +#[allow(dead_code, reason = "used in tests")] +pub fn cleanup_storage(root: &Path) { + use std::fs; + let paths = StoragePaths::new(root); + let vault = paths.vault_db_path(); + let cache = paths.cache_db_path(); + let lock = paths.lock_path(); + let _ = fs::remove_file(&vault); + let _ = fs::remove_file(vault.with_extension("sqlite-wal")); + let _ = fs::remove_file(vault.with_extension("sqlite-shm")); + let _ = fs::remove_file(&cache); + let _ = fs::remove_file(cache.with_extension("sqlite-wal")); + let _ = fs::remove_file(cache.with_extension("sqlite-shm")); + let _ = fs::remove_file(lock); + let _ = fs::remove_dir_all(paths.worldid_dir()); + let _ = fs::remove_dir_all(paths.root()); +} diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs new file mode 100644 index 000000000..ac55dc229 --- /dev/null +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -0,0 +1,52 @@ +#![cfg(feature = "storage")] +#![allow(missing_docs)] + +mod common; + +use rand::rngs::OsRng; +use walletkit_core::storage::CredentialStore; +use walletkit_core::Credential; +use world_id_core::{Credential as CoreCredential, FieldElement as CoreFieldElement}; + +#[test] +fn test_storage_flow_end_to_end() { + let root = common::temp_root(); + let provider = common::InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("store"); + + store.init(42, 100).expect("init"); + + let blinding_factor = CoreFieldElement::random(&mut OsRng); + let core_cred = CoreCredential::new() + .issuer_schema_id(7) + .genesis_issued_at(1_700_000_000); + let credential: Credential = core_cred.into(); + + let credential_id = store + .store_credential( + &credential, + &blinding_factor.into(), + 1_800_000_000, + Some(vec![4, 5, 6]), + 100, + ) + .expect("store credential"); + + let records = store.list_credentials(None, 101).expect("list credentials"); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record.credential_id, credential_id); + assert_eq!(record.issuer_schema_id, 7); + assert_eq!(record.expires_at, 1_800_000_000); + + store + .merkle_cache_put(vec![9, 9], 100, 10) + .expect("cache put"); + let now = 105; + let hit = store.merkle_cache_get(now).expect("cache get"); + assert_eq!(hit, Some(vec![9, 9])); + let miss = store.merkle_cache_get(111).expect("cache get"); + assert!(miss.is_none()); + + common::cleanup_storage(&root); +} diff --git a/walletkit-core/tests/proof_generation_integration.rs b/walletkit-core/tests/proof_generation_integration.rs new file mode 100644 index 000000000..d958e62f0 --- /dev/null +++ b/walletkit-core/tests/proof_generation_integration.rs @@ -0,0 +1,308 @@ +#![cfg(feature = "storage")] +#![allow( + missing_docs, + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::must_use_candidate, + clippy::similar_names, + clippy::too_many_lines +)] + +//! End-to-end integration test for `Authenticator::generate_proof` (World ID v4) +//! using **staging infrastructure** (real OPRF nodes, indexer, gateway, on-chain registries). +//! +//! Prerequisites: +//! - A registered RP on the staging `RpRegistry` contract (hardcoded below) +//! - A registered issuer on the staging `CredentialSchemaIssuerRegistry` (hardcoded below) +//! - Staging OPRF key-gen must have picked up both registrations +//! +//! Run with: +//! `cargo test --test proof_generation_integration --features default -- --ignored` + +mod common; + +use std::{ + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use rand::rngs::OsRng; + +use alloy::providers::ProviderBuilder; +use alloy::signers::{local::PrivateKeySigner, SignerSync}; +use alloy::sol; +use alloy_primitives::U160; +use eyre::{Context as _, Result}; +use taceo_oprf::types::OprfKeyId; +use walletkit_core::storage::cache_embedded_groth16_material; +use walletkit_core::{defaults::DefaultConfig, Authenticator, Environment}; +use world_id_core::primitives::{rp::RpId, FieldElement, Nullifier}; +use world_id_core::{ + requests::{ProofRequest, RequestItem, RequestVersion}, + Authenticator as CoreAuthenticator, EdDSAPrivateKey, +}; + +// --------------------------------------------------------------------------- +// Staging-registered constants +// --------------------------------------------------------------------------- + +/// RP ID registered on the staging `RpRegistry` contract. +const RP_ID: u64 = 46; + +/// ECDSA private key for the registered RP (secp256k1). +const RP_SIGNING_KEY: [u8; 32] = alloy::primitives::hex!( + "1111111111111111111111111111111111111111111111111111111111111111" +); + +/// Issuer schema ID registered on the staging `CredentialSchemaIssuerRegistry`. +const ISSUER_SCHEMA_ID: u64 = 47; + +/// `EdDSA` private key (32 bytes) for the registered issuer. +const ISSUER_EDDSA_KEY: [u8; 32] = alloy::primitives::hex!( + "1111111111111111111111111111111111111111111111111111111111111111" +); + +/// `WorldIDVerifier` proxy contract address on staging (World Chain Mainnet 480). +const WORLD_ID_VERIFIER: alloy::primitives::Address = + alloy::primitives::address!("0x703a6316c975DEabF30b637c155edD53e24657DB"); + +/// Default RPC URL for World Chain Mainnet (chain 480). +const DEFAULT_RPC_URL: &str = "https://worldchain-mainnet.g.alchemy.com/public"; + +// --------------------------------------------------------------------------- +// On-chain WorldIDVerifier binding (only the `verify` function) +// --------------------------------------------------------------------------- +sol!( + #[allow(clippy::too_many_arguments)] + #[sol(rpc)] + interface IWorldIDVerifier { + function verify( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256[5] calldata zeroKnowledgeProof + ) external view; + } +); + +/// Full end-to-end proof generation through `walletkit_core::Authenticator::generate_proof` +/// against staging infrastructure. +/// +/// This test exercises: +/// 1. Account registration (or init if already registered) via the staging gateway +/// 2. Credential issuance (signed by a pre-registered staging issuer) +/// 3. Proof generation with real staging OPRF nodes +/// 4. On-chain proof verification via the staging `WorldIDVerifier` +#[tokio::test(flavor = "multi_thread")] +async fn e2e_authenticator_generate_proof() -> Result<()> { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .try_init(); + + let rpc_url = std::env::var("WORLDCHAIN_RPC_URL") + .unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()); + + // ---------------------------------------------------------------- + // Phase 1: Account registration + // ---------------------------------------------------------------- + let seed = [7u8; 32]; + let recovery_address = alloy::primitives::Address::ZERO; + + let config = world_id_core::primitives::Config::from_environment( + &Environment::Staging, + Some(rpc_url.clone()), + None, + ) + .wrap_err("failed to build staging config")?; + let query_material = Arc::new( + world_id_core::proof::load_embedded_query_material() + .wrap_err("failed to load embedded query material")?, + ); + let nullifier_material = Arc::new( + world_id_core::proof::load_embedded_nullifier_material() + .wrap_err("failed to load embedded nullifier material")?, + ); + + let core_authenticator = CoreAuthenticator::init_or_register( + &seed, + config, + query_material, + nullifier_material, + Some(recovery_address), + ) + .await + .wrap_err("account creation/init failed")?; + + let leaf_index = core_authenticator.leaf_index(); + eprintln!("Phase 1 complete: account ready (leaf_index={leaf_index})"); + + // ---------------------------------------------------------------- + // Phase 2: Authenticator init with walletkit wrapper + // ---------------------------------------------------------------- + let store = common::create_test_credential_store(); + let paths = store.storage_paths().wrap_err("storage_paths failed")?; + cache_embedded_groth16_material(paths.clone()) + .wrap_err("cache_embedded_groth16_material failed")?; + + let authenticator = Authenticator::init_with_defaults( + &seed, + Some(rpc_url.clone()), + &Environment::Staging, + None, + paths, + store.clone(), + ) + .await + .wrap_err("failed to init walletkit Authenticator")?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_secs(); + + authenticator + .init_storage(now) + .wrap_err("init_storage failed")?; + + eprintln!("Phase 2 complete: authenticator initialized"); + + // ---------------------------------------------------------------- + // Phase 3: Credential issuance + // ---------------------------------------------------------------- + let issuer_sk = EdDSAPrivateKey::from_bytes(ISSUER_EDDSA_KEY); + let issuer_pk = issuer_sk.public(); + + let blinding_factor = authenticator + .generate_credential_blinding_factor_remote(ISSUER_SCHEMA_ID) + .await + .wrap_err("blinding factor generation failed")?; + + let mut credential = common::build_base_credential( + ISSUER_SCHEMA_ID, + leaf_index, + now, + now + 3600, // expires in 1 hour + blinding_factor.0, + ); + credential.issuer = issuer_pk; + let credential_hash = credential.hash().wrap_err("failed to hash credential")?; + credential.signature = Some(issuer_sk.sign(*credential_hash)); + + let walletkit_credential: walletkit_core::Credential = credential.clone().into(); + store + .store_credential( + &walletkit_credential, + &blinding_factor, + now + 3600, + None, + now, + ) + .wrap_err("store_credential failed")?; + + eprintln!("Phase 3 complete: credential issued and stored"); + + // ---------------------------------------------------------------- + // Phase 4: Proof generation + // ---------------------------------------------------------------- + let rp_signer = PrivateKeySigner::from_bytes(&RP_SIGNING_KEY.into()) + .expect("invalid RP ECDSA key"); + + let nonce = FieldElement::random(&mut OsRng); + let created_at = now; + let expires_at = now + 300; + let action = FieldElement::from(1u64); + + let rp_msg = world_id_core::primitives::rp::compute_rp_signature_msg( + *nonce, created_at, expires_at, + ); + let signature = rp_signer + .sign_message_sync(&rp_msg) + .wrap_err("failed to sign RP message")?; + + let rp_id = RpId::new(RP_ID); + + let proof_request_core = ProofRequest { + id: "staging_test_request".to_string(), + version: RequestVersion::V1, + created_at, + expires_at, + rp_id, + oprf_key_id: OprfKeyId::new(U160::from(RP_ID)), + session_id: None, + action: Some(action), + signature, + nonce, + requests: vec![RequestItem { + identifier: "credential identifier".to_string(), + issuer_schema_id: ISSUER_SCHEMA_ID, + signal: Some("my_signal".to_string()), + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + let proof_request: walletkit_core::requests::ProofRequest = + proof_request_core.clone().into(); + + let proof_response = authenticator + .generate_proof(&proof_request, Some(now)) + .await + .wrap_err("generate_proof failed")?; + + // TODO: ProofResponse should expose fields/methods for use by binding consumer + let response: world_id_core::requests::ProofResponse = proof_response.into_inner(); + assert!(response.error.is_none(), "proof response contains error"); + assert_eq!(response.responses.len(), 1); + + let response_item = &response.responses[0]; + let nullifier = response_item + .nullifier + .expect("uniqueness proof should have nullifier"); + assert_ne!(nullifier, Nullifier::new(FieldElement::ZERO)); // TODO: Add `Nullifier::ZERO` + + eprintln!("Phase 4 complete: proof generated (nullifier={nullifier:?})"); + + // ---------------------------------------------------------------- + // Phase 5: On-chain verification + // ---------------------------------------------------------------- + let provider = ProviderBuilder::new().connect_http(rpc_url.parse().unwrap()); + + let verifier = IWorldIDVerifier::new(WORLD_ID_VERIFIER, &provider); + + let request_item = proof_request_core + .find_request_by_issuer_schema_id(ISSUER_SCHEMA_ID) + .unwrap(); + + verifier + .verify( + nullifier.into(), + action.into(), + RP_ID, + nonce.into(), + request_item.signal_hash().into(), + response_item.expires_at_min, + ISSUER_SCHEMA_ID, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + response_item.proof.as_ethereum_representation(), + ) + .call() + .await + .wrap_err("on-chain proof verification failed")?; + + eprintln!("Phase 5 complete: on-chain verification passed"); + + Ok(()) +} diff --git a/walletkit-core/tests/solidity.rs b/walletkit-core/tests/solidity.rs index 6a4afccff..d28228f40 100644 --- a/walletkit-core/tests/solidity.rs +++ b/walletkit-core/tests/solidity.rs @@ -1,15 +1,21 @@ +#![allow(missing_docs, clippy::cast_sign_loss)] + +#[cfg(all(feature = "legacy-nullifiers", feature = "common-apps"))] +use alloy::primitives::Address; use alloy::{ node_bindings::AnvilInstance, - primitives::{address, Address, U256}, + primitives::{address, U256}, providers::{ext::AnvilApi, ProviderBuilder, WalletProvider}, signers::local::PrivateKeySigner, sol, sol_types::SolValue, }; +#[cfg(all(feature = "legacy-nullifiers", feature = "common-apps"))] use chrono::{Days, Utc}; +#[cfg(all(feature = "legacy-nullifiers", feature = "common-apps"))] +use walletkit_core::common_apps::AddressBook; use walletkit_core::{ - common_apps::AddressBook, proof::ProofContext, world_id::WorldId, CredentialType, - Environment, + proof::ProofContext, world_id::WorldId, CredentialType, Environment, }; sol!( @@ -64,6 +70,7 @@ fn setup_anvil() -> AnvilInstance { anvil } +#[cfg(all(feature = "legacy-nullifiers", feature = "common-apps"))] sol!( /// The World ID Address Book allows verifying wallet addresses using a World ID for a period of time. /// @@ -85,6 +92,7 @@ sol!( } ); +#[cfg(all(feature = "legacy-nullifiers", feature = "common-apps"))] #[tokio::test] async fn test_address_book_proof_verification_on_chain() { // set up a World Chain Sepolia fork with the `WorldIdAddressBook` contract. diff --git a/walletkit-db/Cargo.toml b/walletkit-db/Cargo.toml new file mode 100644 index 000000000..ac291a5d9 --- /dev/null +++ b/walletkit-db/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "walletkit-db" +description = "Internal SQLite wrapper crate for WalletKit storage." +publish = false + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +exclude.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +hex = "0.4" +zeroize = "1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +sqlite-wasm-rs = { version = "0.5", features = ["sqlite3mc"] } + +[build-dependencies] +cc = "1" +zip = { version = "2", default-features = false, features = ["deflate"] } +sha2 = "0.10" + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/walletkit-db/build.rs b/walletkit-db/build.rs new file mode 100644 index 000000000..954603655 --- /dev/null +++ b/walletkit-db/build.rs @@ -0,0 +1,147 @@ +//! Build script for walletkit-db. +//! +//! On non-WASM targets this downloads the sqlite3mc amalgamation from a pinned +//! upstream release (if not already cached) and compiles it into a static +//! library. +//! +//! On WASM targets compilation is skipped because `sqlite-wasm-rs` provides +//! the pre-compiled WASM binary. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; + +// Pinned sqlite3mc release. +const SQLITE3MC_VERSION: &str = "2.2.7"; +const SQLITE_VERSION: &str = "3.51.2"; +const DOWNLOAD_URL: &str = "https://github.com/utelle/SQLite3MultipleCiphers/releases/download/v2.2.7/sqlite3mc-2.2.7-sqlite-3.51.2-amalgamation.zip"; +const EXPECTED_SHA256: &str = + "8e84aadc53bc09bda9cd307745a178191e7783e1b6478d74ffbcdf6a04f98085"; + +/// Files we need from the zip archive. +const NEEDED_FILES: &[&str] = &["sqlite3mc_amalgamation.c", "sqlite3mc_amalgamation.h"]; + +fn main() { + build_sqlite3mc(); +} + +fn build_sqlite3mc() { + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + if target_arch == "wasm32" { + return; + } + + let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set")); + let source_dir = out_dir.join(format!("sqlite3mc-{SQLITE3MC_VERSION}")); + let amalgamation_c = source_dir.join("sqlite3mc_amalgamation.c"); + let amalgamation_h = source_dir.join("sqlite3mc_amalgamation.h"); + + // Download and extract if not already cached in OUT_DIR. + if !amalgamation_c.exists() || !amalgamation_h.exists() { + std::fs::create_dir_all(&source_dir).expect("failed to create source dir"); + let zip_path = out_dir.join("sqlite3mc-amalgamation.zip"); + download(&zip_path); + verify_checksum(&zip_path); + extract(&zip_path, &source_dir); + assert!( + amalgamation_c.exists(), + "sqlite3mc_amalgamation.c not found after extraction" + ); + assert!( + amalgamation_h.exists(), + "sqlite3mc_amalgamation.h not found after extraction" + ); + } + + compile(&amalgamation_c, &source_dir); +} + +/// Downloads the pinned amalgamation zip using curl. +fn download(dest: &Path) { + eprintln!("cargo:warning=Downloading sqlite3mc {SQLITE3MC_VERSION} (SQLite {SQLITE_VERSION})..."); + let status = Command::new("curl") + .args(["-fsSL", "-o"]) + .arg(dest) + .arg(DOWNLOAD_URL) + .status() + .expect("failed to run curl -- is it installed?"); + assert!(status.success(), "curl failed with status {status}"); +} + +/// Verifies the SHA-256 checksum of the downloaded zip. +fn verify_checksum(zip_path: &Path) { + let data = std::fs::read(zip_path).expect("failed to read zip for checksum"); + let hash = Sha256::digest(&data); + let actual_hash = format!("{hash:x}"); + assert_eq!( + actual_hash, EXPECTED_SHA256, + "sqlite3mc checksum mismatch!\n expected: {EXPECTED_SHA256}\n actual: {actual_hash}\n\ + The download may be corrupted or the pinned release has changed." + ); +} + +/// Extracts the needed files from the zip into `dest_dir`. +fn extract(zip_path: &Path, dest_dir: &Path) { + let file = std::fs::File::open(zip_path).expect("failed to open zip"); + let mut archive = zip::ZipArchive::new(file).expect("failed to read zip archive"); + + for name in NEEDED_FILES { + let mut entry = archive + .by_name(name) + .unwrap_or_else(|e| panic!("file {name} not found in zip: {e}")); + let dest_path = dest_dir.join(name); + let mut buf = Vec::with_capacity(usize::try_from(entry.size()).unwrap_or(0)); + entry + .read_to_end(&mut buf) + .unwrap_or_else(|e| panic!("failed to read {name} from zip: {e}")); + std::fs::write(&dest_path, &buf) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", dest_path.display())); + } +} + +/// Compiles the sqlite3mc amalgamation into a static library. +fn compile(amalgamation_c: &Path, include_dir: &Path) { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + let mut build = cc::Build::new(); + build + .file(amalgamation_c) + .include(include_dir) + // Core SQLite configuration + .define("SQLITE_CORE", None) + .define("SQLITE_THREADSAFE", "1") + .define("SQLITE_ENABLE_COLUMN_METADATA", None) + .define("SQLITE_ENABLE_FTS5", None) + .define("SQLITE_ENABLE_JSON1", None) + .define("SQLITE_ENABLE_RTREE", None) + .define("SQLITE_DEFAULT_WAL_SYNCHRONOUS", "1") + .define("SQLITE_DQS", "0") + // sqlite3mc cipher configuration -- default to ChaCha20-Poly1305 + .define("CODEC_TYPE", "CODEC_TYPE_CHACHA20") + // Disable Argon2 threading (not needed, avoids pthread dep on some targets) + .define("ARGON2_NO_THREADS", None) + // Optimizations + .define("SQLITE_DEFAULT_MEMSTATUS", "0") + .define("SQLITE_LIKE_DOESNT_MATCH_BLOBS", None) + .define("SQLITE_OMIT_DEPRECATED", None) + .define("SQLITE_OMIT_SHARED_CACHE", None); + + match target_os.as_str() { + "android" | "ios" | "macos" => { + build.define("HAVE_USLEEP", "1"); + build.define("HAVE_LOCALTIME_R", "1"); + } + "linux" => { + build.define("HAVE_USLEEP", "1"); + build.define("HAVE_LOCALTIME_R", "1"); + build.define("HAVE_POSIX_FALLOCATE", "1"); + } + _ => {} + } + + // Suppress warnings from the amalgamation (third-party code) + build.warnings(false); + build.compile("sqlite3mc"); +} diff --git a/walletkit-db/src/cipher.rs b/walletkit-db/src/cipher.rs new file mode 100644 index 000000000..016b383e8 --- /dev/null +++ b/walletkit-db/src/cipher.rs @@ -0,0 +1,126 @@ +//! `sqlite3mc` encryption configuration. +//! +//! # Encryption flow +//! +//! The credential storage uses `sqlite3mc` (`SQLite3` Multiple Ciphers) to +//! encrypt both the vault and cache databases at rest. The encryption is +//! transparent to SQL -- once a database is opened and keyed, all reads and +//! writes are automatically encrypted/decrypted by the `SQLite` pager layer. +//! +//! The flow when opening a database is: +//! +//! 1. **Open** -- `sqlite3_open_v2` creates or opens the database file. +//! At this point the file is opaque (encrypted) and no data can be read. +//! +//! 2. **Key** -- `PRAGMA key = "x''"` passes the 32-byte +//! `K_intermediate` (hex-encoded) to `sqlite3mc`. Internally, `sqlite3mc` +//! derives a page-level encryption key from this material using the +//! configured KDF (PBKDF2-SHA256 by default for ChaCha20-Poly1305). +//! After this point, every page read from disk is decrypted and every +//! page written to disk is encrypted. +//! +//! 3. **Verify** -- We immediately read from `sqlite_master` to confirm +//! the key is correct. If the key is wrong, `sqlite3mc` returns +//! `SQLITE_NOTADB` because the decrypted page header won't match the +//! expected `SQLite` magic bytes. We surface this as a clear error. +//! +//! 4. **Configure** -- WAL journal mode and `synchronous=FULL` are set for +//! crash consistency. Foreign keys are enabled. +//! +//! The default cipher is **ChaCha20-Poly1305** (authenticated encryption). +//! All crypto is built into the `sqlite3mc` amalgamation -- no OpenSSL or +//! other external crypto library is needed on any platform. + +use std::path::Path; + +use zeroize::Zeroizing; + +use super::connection::Connection; +use super::error::{DbError, DbResult}; + +/// Opens a database, applies the encryption key, and configures the connection. +/// +/// This is the standard open sequence used by both vault and cache databases: +/// open -> key -> verify -> configure (WAL + foreign keys). +/// +/// See the [module-level documentation](self) for the full encryption flow. +/// +/// # Errors +/// +/// Returns `DbError` if opening, keying, or configuring the connection fails. +pub fn open_encrypted( + path: &Path, + k_intermediate: &Zeroizing<[u8; 32]>, + read_only: bool, +) -> DbResult { + let conn = Connection::open(path, read_only)?; + apply_key(&conn, k_intermediate)?; + configure_connection(&conn)?; + Ok(conn) +} + +/// Applies the `sqlite3mc` encryption key to an open connection. +/// +/// The 32-byte `k_intermediate` is hex-encoded and passed as a raw key via +/// `PRAGMA key = "x'<64-hex-chars>'"`. `sqlite3mc` interprets the `x'...'` +/// prefix as a raw key (as opposed to a passphrase that would be run through +/// a KDF first). +/// +/// After keying, a lightweight read (`SELECT count(*) FROM sqlite_master`) +/// verifies the key is correct. If it's wrong, `sqlite3mc` fails with +/// `SQLITE_NOTADB` on the first page read. +fn apply_key(conn: &Connection, k_intermediate: &Zeroizing<[u8; 32]>) -> DbResult<()> { + // Hex-encode the key and build the PRAGMA. Both are zeroized on drop. + let key_hex = Zeroizing::new(hex::encode(k_intermediate)); + let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str())); + + // execute_batch_zeroized ensures the internal CString copy of the PRAGMA + // (which contains the hex key) is zeroized after the FFI call returns. + conn.execute_batch_zeroized(&pragma)?; + + // Touch a page to verify the key works. On failure this produces a clear + // error rather than a confusing "not a database" later during schema setup. + conn.execute_batch("SELECT count(*) FROM sqlite_master;") + .map_err(|e| { + DbError::new( + e.code.0, + format!( + "encryption key verification failed (is the key correct?): {}", + e.message + ), + ) + })?; + + // k_intermediate, key_hex, and pragma are all Zeroizing — zeroed on drop + // regardless of which exit path we took. + Ok(()) +} + +/// Configures durable WAL settings, foreign keys, and secure deletion. +/// +/// - `journal_mode = WAL` -- enables concurrent readers during writes. +/// - `synchronous = FULL` -- maximizes crash consistency (all WAL pages are +/// fsynced before the transaction is reported as committed). +/// - `foreign_keys = ON` -- enforces referential integrity constraints. +/// - `secure_delete = ON` -- overwrites deleted content with zeroes so +/// sensitive data does not linger in free pages. +fn configure_connection(conn: &Connection) -> DbResult<()> { + conn.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL; + PRAGMA secure_delete = ON;", + ) +} + +/// Runs `PRAGMA integrity_check` and returns whether the database is healthy. +/// +/// # Errors +/// +/// Returns `DbError` if the integrity check query fails. +pub fn integrity_check(conn: &Connection) -> DbResult { + let result = conn.query_row("PRAGMA integrity_check;", &[], |stmt| { + Ok(stmt.column_text(0)) + })?; + Ok(result.trim() == "ok") +} diff --git a/walletkit-db/src/connection.rs b/walletkit-db/src/connection.rs new file mode 100644 index 000000000..0b87ba48a --- /dev/null +++ b/walletkit-db/src/connection.rs @@ -0,0 +1,182 @@ +//! Safe wrapper around a `SQLite` database connection. +//! +//! This file contains **no `unsafe` code**. All FFI interaction is delegated to +//! [`ffi::RawDb`] which encapsulates the raw pointers and C type conversions. + +use std::path::Path; + +use super::error::{DbError, DbResult}; +use super::ffi::{self, RawDb}; +use super::statement::{Row, Statement, StepResult}; +use super::transaction::Transaction; +use super::value::Value; + +/// A `SQLite` database connection. +/// +/// Closed when dropped. Not `Sync` -- all access must happen from a single +/// thread (matches the WASM single-thread constraint and the native +/// `Mutex`-guarded usage in `CredentialStoreInner`). +pub struct Connection { + db: RawDb, +} + +impl Connection { + /// Opens (or creates) a database at `path`. + /// + /// # Errors + /// + /// Returns `DbError` if `SQLite` cannot open the file. + pub fn open(path: &Path, read_only: bool) -> DbResult { + let path_str = path.to_string_lossy(); + let flags = if read_only { + ffi::SQLITE_OPEN_READONLY | ffi::SQLITE_OPEN_FULLMUTEX + } else { + ffi::SQLITE_OPEN_READWRITE + | ffi::SQLITE_OPEN_CREATE + | ffi::SQLITE_OPEN_FULLMUTEX + }; + let db = RawDb::open(&path_str, flags)?; + Ok(Self { db }) + } + + /// Executes one or more SQL statements separated by semicolons. + /// + /// No result rows are returned. Suitable for DDL, PRAGMAs, and + /// multi-statement scripts. + /// + /// # Errors + /// + /// Returns `DbError` if any statement fails. + pub fn execute_batch(&self, sql: &str) -> DbResult<()> { + self.db.exec(sql) + } + + /// Like [`execute_batch`](Self::execute_batch) but zeroizes the internal + /// C string buffer after execution. Use for SQL containing sensitive + /// material (e.g. `PRAGMA key`). + /// + /// # Errors + /// + /// Returns `DbError` if the statement fails. + pub fn execute_batch_zeroized(&self, sql: &str) -> DbResult<()> { + self.db.exec_zeroized(sql) + } + + /// Prepares a single SQL statement. + /// + /// # Errors + /// + /// Returns `DbError` if the SQL is invalid. + pub fn prepare(&self, sql: &str) -> DbResult> { + let raw_stmt = self.db.prepare(sql)?; + Ok(Statement::new(raw_stmt)) + } + + /// Prepares and executes a single SQL statement with the given parameters. + /// + /// Returns the number of rows changed. + /// + /// # Errors + /// + /// Returns `DbError` if preparation or execution fails. + pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { + let mut stmt = self.prepare(sql)?; + stmt.bind_values(params)?; + stmt.step()?; + Ok(usize::try_from(self.db.changes()).unwrap_or(0)) + } + + /// Prepares and executes a statement, mapping exactly one result row. + /// + /// Returns an error if no row is returned. + /// + /// # Errors + /// + /// Returns `DbError` if preparation, execution, or the mapper fails, + /// or if the query returns no rows. + pub fn query_row( + &self, + sql: &str, + params: &[Value], + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult { + let mut stmt = self.prepare(sql)?; + stmt.bind_values(params)?; + match stmt.step()? { + StepResult::Row(row) => mapper(&row), + StepResult::Done => { + Err(DbError::new(ffi::SQLITE_DONE, "query returned no rows")) + } + } + } + + /// Like [`query_row`](Self::query_row) but returns `Ok(None)` when no row + /// is returned. + /// + /// # Errors + /// + /// Returns `DbError` if preparation, execution, or the mapper fails. + pub fn query_row_optional( + &self, + sql: &str, + params: &[Value], + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult> { + let mut stmt = self.prepare(sql)?; + stmt.bind_values(params)?; + match stmt.step()? { + StepResult::Row(row) => mapper(&row).map(Some), + StepResult::Done => Ok(None), + } + } + + /// Begins a deferred transaction. + /// + /// # Errors + /// + /// Returns `DbError` if `BEGIN DEFERRED` fails. + pub fn transaction(&self) -> DbResult> { + Transaction::begin(self, false) + } + + /// Begins an immediate transaction (acquires a RESERVED lock right away). + /// + /// # Errors + /// + /// Returns `DbError` if `BEGIN IMMEDIATE` fails. + pub fn transaction_immediate(&self) -> DbResult> { + Transaction::begin(self, true) + } + + /// Returns the rowid of the most recent successful INSERT. + #[allow(dead_code)] + #[must_use] + pub fn last_insert_rowid(&self) -> i64 { + self.db.last_insert_rowid() + } + + /// Returns the number of rows changed by the most recent statement. + #[allow(dead_code)] + #[must_use] + pub fn changes(&self) -> usize { + usize::try_from(self.db.changes()).unwrap_or(0) + } +} + +impl std::fmt::Debug for Connection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Connection").finish_non_exhaustive() + } +} + +#[cfg(test)] +impl Connection { + /// Opens an in-memory database. + /// + /// # Errors + /// + /// Returns `DbError` if the in-memory database cannot be opened. + pub fn open_in_memory() -> DbResult { + Self::open(Path::new(":memory:"), false) + } +} diff --git a/walletkit-db/src/error.rs b/walletkit-db/src/error.rs new file mode 100644 index 000000000..2fb42fbba --- /dev/null +++ b/walletkit-db/src/error.rs @@ -0,0 +1,43 @@ +//! Database error types for the safe `SQLite` wrapper. + +use std::fmt; + +/// Error code returned by `SQLite` operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DbErrorCode(pub i32); + +impl fmt::Display for DbErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Error returned by database operations. +#[derive(Debug, PartialEq, Eq)] +pub struct DbError { + /// `SQLite` result code. + pub code: DbErrorCode, + /// Human-readable error message (from `sqlite3_errmsg` when available). + pub message: String, +} + +impl DbError { + /// Creates a new database error. + pub(crate) fn new(code: i32, message: impl Into) -> Self { + Self { + code: DbErrorCode(code), + message: message.into(), + } + } +} + +impl fmt::Display for DbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "sqlite error {}: {}", self.code, self.message) + } +} + +impl std::error::Error for DbError {} + +/// Result type for database operations. +pub type DbResult = Result; diff --git a/walletkit-db/src/ffi.rs b/walletkit-db/src/ffi.rs new file mode 100644 index 000000000..03f920200 --- /dev/null +++ b/walletkit-db/src/ffi.rs @@ -0,0 +1,617 @@ +//! Raw FFI bindings to `SQLite`, resolved at compile time via `cfg`. +//! +//! This module is the **only** place in the codebase that contains `unsafe` code +//! or C types (`*mut c_void`, `CString`, etc.). It exposes two safe handle types +//! -- [`RawDb`] and [`RawStmt`] -- whose methods perform the underlying FFI calls +//! and translate results into idiomatic Rust ([`DbResult`], `String`, `Vec`). +//! +//! Why `unsafe` is required: `SQLite` is a C library. Calling any C function from +//! Rust is `unsafe` by definition because the Rust compiler cannot verify memory +//! safety across the FFI boundary. Each `unsafe` block below upholds the +//! following invariants: +//! +//! - Pointers passed to `SQLite` are either non-null (checked by the caller) or +//! explicitly documented as nullable (e.g. `sqlite3_exec` callback). +//! - Strings are null-terminated via `CString` before being handed to C. +//! - Pointer lifetimes are tracked by [`RawDb`] / [`RawStmt`] ownership: a +//! handle is valid from construction until `Drop`. +//! - `SQLITE_TRANSIENT` tells `SQLite` to copy bound data immediately, so Rust +//! can safely free the source buffer after the call returns. +//! +//! On native targets the symbols come from the `sqlite3mc` static library compiled +//! by `build.rs`. On `wasm32` targets they come from `sqlite-wasm-rs`. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_void}; + +use zeroize::Zeroize; + +use super::error::{DbError, DbResult}; + +// -- SQLite constants (plain `i32`, no C types leaked to callers) ------------- + +pub const SQLITE_OK: i32 = 0; +pub const SQLITE_ROW: i32 = 100; +pub const SQLITE_DONE: i32 = 101; + +pub const SQLITE_NULL: i32 = 5; + +pub const SQLITE_OPEN_READONLY: i32 = 0x0000_0001; +pub const SQLITE_OPEN_READWRITE: i32 = 0x0000_0002; +pub const SQLITE_OPEN_CREATE: i32 = 0x0000_0004; +pub const SQLITE_OPEN_FULLMUTEX: i32 = 0x0001_0000; + +const SQLITE_TRANSIENT: isize = -1; +const SQLITE_ERROR: i32 = 1; + +// -- Safe handle types -------------------------------------------------------- + +/// Opaque handle to an open `sqlite3` database. +/// +/// All methods perform the underlying FFI call and convert the result to safe +/// Rust types. The database is closed when the handle is dropped. +pub struct RawDb { + ptr: *mut c_void, +} + +/// Opaque handle to a prepared `sqlite3_stmt`. +/// +/// The lifetime `'db` ties the statement to the [`RawDb`] that created it, +/// ensuring at the type level that the statement cannot outlive the database. +/// The statement is finalized when the handle is dropped. +pub struct RawStmt<'db> { + ptr: *mut c_void, + /// Borrowed database handle — used only to extract error messages via + /// `sqlite3_errmsg`. + db: &'db RawDb, +} + +// Safety: RawDb is a single-owner handle to sqlite3*. On native we always open +// with FULLMUTEX and upper layers guard shared access with a Mutex, so moving a +// connection between threads is sound. +unsafe impl Send for RawDb {} + +// -- RawDb implementation ----------------------------------------------------- + +impl RawDb { + /// Opens (or creates) a database at the given `path`. + pub fn open(path: &str, flags: i32) -> DbResult { + let c_path = to_cstring(path)?; + let mut ptr: *mut c_void = std::ptr::null_mut(); + + // Safety: c_path is a valid null-terminated string. ptr is a local + // out-pointer that SQLite writes to. VFS is null (use default). + let rc = unsafe { + raw::sqlite3_open_v2( + c_path.as_ptr(), + &raw mut ptr, + flags as c_int, + std::ptr::null(), + ) + }; + + if rc != SQLITE_OK as c_int { + let msg = if ptr.is_null() { + format!("sqlite3_open_v2 returned {rc}") + } else { + let m = errmsg_from_ptr(ptr); + // Safety: ptr was allocated by sqlite3_open_v2 even on error; + // we must close it. + unsafe { + raw::sqlite3_close_v2(ptr); + } + m + }; + return Err(DbError::new(rc, msg)); + } + + Ok(Self { ptr }) + } + + /// Executes one or more semicolon-separated SQL statements. No results. + pub fn exec(&self, sql: &str) -> DbResult<()> { + let c_sql = to_cstring(sql)?; + let mut errmsg: *mut c_char = std::ptr::null_mut(); + + // Safety: self.ptr is valid for the lifetime of RawDb. c_sql is null- + // terminated. Callback is None and arg is null (no result rows needed). + let rc = unsafe { + raw::sqlite3_exec( + self.ptr, + c_sql.as_ptr(), + None, + std::ptr::null_mut(), + &raw mut errmsg, + ) + }; + + if rc == SQLITE_OK as c_int { + return Ok(()); + } + + let msg = if errmsg.is_null() { + self.errmsg() + } else { + // Safety: errmsg points to a C string allocated by SQLite. + let s = unsafe { CStr::from_ptr(errmsg) } + .to_string_lossy() + .into_owned(); + unsafe { + raw::sqlite3_free(errmsg.cast()); + } + s + }; + Err(DbError::new(rc, msg)) + } + + /// Like [`exec`](Self::exec) but zeroizes the internal `CString` buffer + /// after the FFI call. Use for SQL that contains sensitive material (e.g. + /// `PRAGMA key`). + pub fn exec_zeroized(&self, sql: &str) -> DbResult<()> { + let c_sql = to_cstring(sql)?; + let mut errmsg: *mut c_char = std::ptr::null_mut(); + + // Safety: same invariants as exec(). + let rc = unsafe { + raw::sqlite3_exec( + self.ptr, + c_sql.as_ptr(), + None, + std::ptr::null_mut(), + &raw mut errmsg, + ) + }; + + // Zeroize the CString buffer that held the sensitive SQL before freeing. + let mut bytes = c_sql.into_bytes_with_nul(); + bytes.zeroize(); + drop(bytes); + + if rc == SQLITE_OK as c_int { + return Ok(()); + } + + let msg = if errmsg.is_null() { + self.errmsg() + } else { + // Safety: errmsg points to a C string allocated by SQLite. + let s = unsafe { CStr::from_ptr(errmsg) } + .to_string_lossy() + .into_owned(); + unsafe { + raw::sqlite3_free(errmsg.cast()); + } + s + }; + Err(DbError::new(rc, msg)) + } + + /// Prepares a single SQL statement for execution. + pub fn prepare(&self, sql: &str) -> DbResult> { + let c_sql = to_cstring(sql)?; + let mut stmt_ptr: *mut c_void = std::ptr::null_mut(); + + // Safety: self.ptr is valid. c_sql is null-terminated. -1 tells SQLite + // to read until the null terminator. tail pointer is unused. + let rc = unsafe { + raw::sqlite3_prepare_v2( + self.ptr, + c_sql.as_ptr(), + -1, + &raw mut stmt_ptr, + std::ptr::null_mut(), + ) + }; + + if rc != SQLITE_OK as c_int || stmt_ptr.is_null() { + return Err(DbError::new(rc, self.errmsg())); + } + + Ok(RawStmt { + ptr: stmt_ptr, + db: self, + }) + } + + /// Returns the number of rows changed by the most recent statement. + pub fn changes(&self) -> i32 { + // Safety: self.ptr is valid. + unsafe { raw::sqlite3_changes(self.ptr) } + } + + /// Returns the rowid of the most recent successful INSERT. + pub fn last_insert_rowid(&self) -> i64 { + // Safety: self.ptr is valid. + unsafe { raw::sqlite3_last_insert_rowid(self.ptr) } + } + + /// Returns the most recent error message from `SQLite`. + pub fn errmsg(&self) -> String { + errmsg_from_ptr(self.ptr) + } +} + +impl Drop for RawDb { + fn drop(&mut self) { + if !self.ptr.is_null() { + // Safety: self.ptr was obtained from sqlite3_open_v2 and is valid. + unsafe { + raw::sqlite3_close_v2(self.ptr); + } + } + } +} + +impl std::fmt::Debug for RawDb { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawDb").finish_non_exhaustive() + } +} + +// -- RawStmt implementation --------------------------------------------------- + +impl RawStmt<'_> { + /// Executes a single step. Returns `SQLITE_ROW` or `SQLITE_DONE`. + pub fn step(&mut self) -> DbResult { + // Safety: self.ptr is a valid prepared statement. + let rc = unsafe { raw::sqlite3_step(self.ptr) }; + match rc { + SQLITE_ROW => Ok(SQLITE_ROW), + SQLITE_DONE => Ok(SQLITE_DONE), + other => Err(DbError::new(other, self.errmsg())), + } + } + + /// Resets the statement so it can be stepped again. + #[allow(dead_code)] + pub fn reset(&mut self) -> DbResult<()> { + // Safety: self.ptr is valid. + let rc = unsafe { raw::sqlite3_reset(self.ptr) }; + if rc == SQLITE_OK as c_int { + Ok(()) + } else { + Err(DbError::new(rc, self.errmsg())) + } + } + + // -- Binding -------------------------------------------------------------- + + pub fn bind_i64(&mut self, idx: i32, value: i64) -> DbResult<()> { + // Safety: self.ptr is valid; idx is a 1-based parameter index. + let rc = unsafe { raw::sqlite3_bind_int64(self.ptr, idx as c_int, value) }; + check(rc, self) + } + + pub fn bind_blob(&mut self, idx: i32, value: &[u8]) -> DbResult<()> { + // Safety: value.as_ptr() is valid for value.len() bytes. + // SQLITE_TRANSIENT tells SQLite to copy the data immediately. + let rc = unsafe { + raw::sqlite3_bind_blob( + self.ptr, + idx as c_int, + value.as_ptr().cast::(), + c_int::try_from(value.len()).unwrap_or(c_int::MAX), + SQLITE_TRANSIENT, + ) + }; + check(rc, self) + } + + pub fn bind_text(&mut self, idx: i32, value: &str) -> DbResult<()> { + // Safety: value.as_ptr() is valid for value.len() bytes. + // SQLITE_TRANSIENT tells SQLite to copy the data immediately. + let rc = unsafe { + raw::sqlite3_bind_text( + self.ptr, + idx as c_int, + value.as_ptr().cast::(), + c_int::try_from(value.len()).unwrap_or(c_int::MAX), + SQLITE_TRANSIENT, + ) + }; + check(rc, self) + } + + pub fn bind_null(&mut self, idx: i32) -> DbResult<()> { + // Safety: self.ptr is valid. + let rc = unsafe { raw::sqlite3_bind_null(self.ptr, idx as c_int) }; + check(rc, self) + } + + // -- Column reading ------------------------------------------------------- + + pub fn column_i64(&self, col: i32) -> i64 { + // Safety: self.ptr is valid; col is a 0-based column index. + unsafe { raw::sqlite3_column_int64(self.ptr, col as c_int) } + } + + pub fn column_blob(&self, col: i32) -> Vec { + // Safety: blob pointer is valid until the next step/reset/finalize. + // We copy immediately into a Vec. + unsafe { + let ptr = raw::sqlite3_column_blob(self.ptr, col as c_int); + let len = raw::sqlite3_column_bytes(self.ptr, col as c_int); + if ptr.is_null() || len <= 0 { + Vec::new() + } else { + std::slice::from_raw_parts( + ptr.cast::(), + usize::try_from(len).unwrap_or(0), + ) + .to_vec() + } + } + } + + pub fn column_text(&self, col: i32) -> String { + // Safety: text pointer is valid until the next step/reset/finalize. + // We copy immediately into a String. + unsafe { + let ptr = raw::sqlite3_column_text(self.ptr, col as c_int); + if ptr.is_null() { + String::new() + } else { + CStr::from_ptr(ptr).to_string_lossy().into_owned() + } + } + } + + pub fn column_type(&self, col: i32) -> i32 { + // Safety: self.ptr is valid. + unsafe { raw::sqlite3_column_type(self.ptr, col as c_int) } + } + + #[allow(dead_code)] + pub fn column_count(&self) -> i32 { + // Safety: self.ptr is valid. + unsafe { raw::sqlite3_column_count(self.ptr) } + } + + fn errmsg(&self) -> String { + self.db.errmsg() + } +} + +impl Drop for RawStmt<'_> { + fn drop(&mut self) { + if !self.ptr.is_null() { + // Safety: self.ptr was obtained from sqlite3_prepare_v2 and is valid. + unsafe { + raw::sqlite3_finalize(self.ptr); + } + } + } +} + +// -- Helpers (private) -------------------------------------------------------- + +fn to_cstring(s: &str) -> DbResult { + CString::new(s) + .map_err(|e| DbError::new(SQLITE_ERROR, format!("nul byte in string: {e}"))) +} + +fn errmsg_from_ptr(db: *mut c_void) -> String { + // Safety: callers must pass a valid sqlite3 handle pointer. + // In this module, it's only called with RawDb::ptr or the pointer returned + // by sqlite3_open_v2 before we close it on open failure. + unsafe { + let ptr = raw::sqlite3_errmsg(db); + if ptr.is_null() { + "unknown error".to_string() + } else { + CStr::from_ptr(ptr).to_string_lossy().into_owned() + } + } +} + +fn check(rc: c_int, stmt: &RawStmt) -> DbResult<()> { + if rc == SQLITE_OK as c_int { + Ok(()) + } else { + Err(DbError::new(rc, stmt.errmsg())) + } +} + +// -- Raw extern declarations (private, never exposed) ------------------------- +// +// These are the actual C function signatures. On native they link against our +// compiled sqlite3mc static library. On WASM they delegate to sqlite-wasm-rs. + +#[cfg(not(target_arch = "wasm32"))] +mod raw { + use std::os::raw::{c_char, c_int, c_void}; + + pub type Sqlite3Callback = Option< + unsafe extern "C" fn( + arg: *mut c_void, + n_cols: c_int, + values: *mut *mut c_char, + names: *mut *mut c_char, + ) -> c_int, + >; + + #[allow(dead_code, non_camel_case_types)] + type sqlite3 = c_void; + #[allow(dead_code, non_camel_case_types)] + type sqlite3_stmt = c_void; + + extern "C" { + pub fn sqlite3_open_v2( + filename: *const c_char, + pp_db: *mut *mut sqlite3, + flags: c_int, + z_vfs: *const c_char, + ) -> c_int; + pub fn sqlite3_close_v2(db: *mut sqlite3) -> c_int; + pub fn sqlite3_exec( + db: *mut sqlite3, + sql: *const c_char, + callback: Sqlite3Callback, + arg: *mut c_void, + errmsg: *mut *mut c_char, + ) -> c_int; + pub fn sqlite3_free(ptr: *mut c_void); + pub fn sqlite3_prepare_v2( + db: *mut sqlite3, + z_sql: *const c_char, + n_byte: c_int, + pp_stmt: *mut *mut sqlite3_stmt, + pz_tail: *mut *const c_char, + ) -> c_int; + pub fn sqlite3_step(stmt: *mut sqlite3_stmt) -> c_int; + pub fn sqlite3_reset(stmt: *mut sqlite3_stmt) -> c_int; + pub fn sqlite3_finalize(stmt: *mut sqlite3_stmt) -> c_int; + pub fn sqlite3_bind_int64( + stmt: *mut sqlite3_stmt, + index: c_int, + value: i64, + ) -> c_int; + pub fn sqlite3_bind_blob( + stmt: *mut sqlite3_stmt, + index: c_int, + value: *const c_void, + n: c_int, + destructor: isize, + ) -> c_int; + pub fn sqlite3_bind_text( + stmt: *mut sqlite3_stmt, + index: c_int, + value: *const c_char, + n: c_int, + destructor: isize, + ) -> c_int; + pub fn sqlite3_bind_null(stmt: *mut sqlite3_stmt, index: c_int) -> c_int; + pub fn sqlite3_column_int64(stmt: *mut sqlite3_stmt, i_col: c_int) -> i64; + pub fn sqlite3_column_blob( + stmt: *mut sqlite3_stmt, + i_col: c_int, + ) -> *const c_void; + pub fn sqlite3_column_bytes(stmt: *mut sqlite3_stmt, i_col: c_int) -> c_int; + pub fn sqlite3_column_text( + stmt: *mut sqlite3_stmt, + i_col: c_int, + ) -> *const c_char; + pub fn sqlite3_column_type(stmt: *mut sqlite3_stmt, i_col: c_int) -> c_int; + pub fn sqlite3_column_count(stmt: *mut sqlite3_stmt) -> c_int; + pub fn sqlite3_errmsg(db: *mut sqlite3) -> *const c_char; + pub fn sqlite3_changes(db: *mut sqlite3) -> c_int; + pub fn sqlite3_last_insert_rowid(db: *mut sqlite3) -> i64; + } +} + +#[cfg(target_arch = "wasm32")] +mod raw { + use sqlite_wasm_rs as wasm; + use std::os::raw::{c_char, c_int, c_void}; + + pub type Sqlite3Callback = wasm::sqlite3_callback; + + pub unsafe fn sqlite3_open_v2( + filename: *const c_char, + pp_db: *mut *mut c_void, + flags: c_int, + z_vfs: *const c_char, + ) -> c_int { + wasm::sqlite3_open_v2(filename.cast(), pp_db.cast(), flags, z_vfs.cast()) + } + pub unsafe fn sqlite3_close_v2(db: *mut c_void) -> c_int { + wasm::sqlite3_close_v2(db.cast()) + } + pub unsafe fn sqlite3_exec( + db: *mut c_void, + sql: *const c_char, + callback: Sqlite3Callback, + arg: *mut c_void, + errmsg: *mut *mut c_char, + ) -> c_int { + wasm::sqlite3_exec(db.cast(), sql.cast(), callback, arg, errmsg.cast()) + } + pub unsafe fn sqlite3_free(ptr: *mut c_void) { + wasm::sqlite3_free(ptr); + } + pub unsafe fn sqlite3_prepare_v2( + db: *mut c_void, + z_sql: *const c_char, + n_byte: c_int, + pp_stmt: *mut *mut c_void, + pz_tail: *mut *const c_char, + ) -> c_int { + wasm::sqlite3_prepare_v2( + db.cast(), + z_sql.cast(), + n_byte, + pp_stmt.cast(), + pz_tail.cast(), + ) + } + pub unsafe fn sqlite3_step(stmt: *mut c_void) -> c_int { + wasm::sqlite3_step(stmt.cast()) + } + pub unsafe fn sqlite3_reset(stmt: *mut c_void) -> c_int { + wasm::sqlite3_reset(stmt.cast()) + } + pub unsafe fn sqlite3_finalize(stmt: *mut c_void) -> c_int { + wasm::sqlite3_finalize(stmt.cast()) + } + pub unsafe fn sqlite3_bind_int64( + stmt: *mut c_void, + index: c_int, + value: i64, + ) -> c_int { + wasm::sqlite3_bind_int64(stmt.cast(), index, value) + } + pub unsafe fn sqlite3_bind_blob( + stmt: *mut c_void, + index: c_int, + value: *const c_void, + n: c_int, + destructor: isize, + ) -> c_int { + wasm::sqlite3_bind_blob(stmt.cast(), index, value, n, destructor) + } + pub unsafe fn sqlite3_bind_text( + stmt: *mut c_void, + index: c_int, + value: *const c_char, + n: c_int, + destructor: isize, + ) -> c_int { + wasm::sqlite3_bind_text(stmt.cast(), index, value.cast(), n, destructor) + } + pub unsafe fn sqlite3_bind_null(stmt: *mut c_void, index: c_int) -> c_int { + wasm::sqlite3_bind_null(stmt.cast(), index) + } + pub unsafe fn sqlite3_column_int64(stmt: *mut c_void, i_col: c_int) -> i64 { + wasm::sqlite3_column_int64(stmt.cast(), i_col) + } + pub unsafe fn sqlite3_column_blob( + stmt: *mut c_void, + i_col: c_int, + ) -> *const c_void { + wasm::sqlite3_column_blob(stmt.cast(), i_col) + } + pub unsafe fn sqlite3_column_bytes(stmt: *mut c_void, i_col: c_int) -> c_int { + wasm::sqlite3_column_bytes(stmt.cast(), i_col) + } + pub unsafe fn sqlite3_column_text( + stmt: *mut c_void, + i_col: c_int, + ) -> *const c_char { + wasm::sqlite3_column_text(stmt.cast(), i_col).cast() + } + pub unsafe fn sqlite3_column_type(stmt: *mut c_void, i_col: c_int) -> c_int { + wasm::sqlite3_column_type(stmt.cast(), i_col) + } + pub unsafe fn sqlite3_column_count(stmt: *mut c_void) -> c_int { + wasm::sqlite3_column_count(stmt.cast()) + } + pub unsafe fn sqlite3_errmsg(db: *mut c_void) -> *const c_char { + wasm::sqlite3_errmsg(db.cast()).cast() + } + pub unsafe fn sqlite3_changes(db: *mut c_void) -> c_int { + wasm::sqlite3_changes(db.cast()) + } + pub unsafe fn sqlite3_last_insert_rowid(db: *mut c_void) -> i64 { + wasm::sqlite3_last_insert_rowid(db.cast()) + } +} diff --git a/walletkit-db/src/lib.rs b/walletkit-db/src/lib.rs new file mode 100644 index 000000000..b34162730 --- /dev/null +++ b/walletkit-db/src/lib.rs @@ -0,0 +1,32 @@ +//! Minimal safe `SQLite` wrapper backed by `sqlite3mc`. +//! +//! This crate provides a small, safe Rust API over the `SQLite` C FFI. +//! The raw symbols are resolved at compile time: +//! +//! * **Native** (`not(wasm32)`): linked against the `sqlite3mc` static library +//! compiled from the downloaded amalgamation by `build.rs`. +//! * **WASM** (`wasm32`): delegated to `sqlite-wasm-rs` (with the `sqlite3mc` +//! feature) which ships its own WASM-compiled `sqlite3mc`. +//! +//! Consumer code (vault, cache, cipher config) uses only the safe types +//! defined here and never touches raw FFI directly. The `ffi` module is the +//! **only** file that contains `unsafe` code or C types. + +mod ffi; + +mod connection; +pub mod error; +mod statement; +mod transaction; +pub mod value; + +pub mod cipher; + +pub use connection::Connection; +pub use error::DbError; +pub use statement::{Row, Statement, StepResult}; +pub use transaction::Transaction; +pub use value::Value; + +#[cfg(test)] +mod tests; diff --git a/walletkit-db/src/statement.rs b/walletkit-db/src/statement.rs new file mode 100644 index 000000000..3d859c301 --- /dev/null +++ b/walletkit-db/src/statement.rs @@ -0,0 +1,129 @@ +//! Safe wrapper around a `SQLite` prepared statement. +//! +//! This file contains **no `unsafe` code**. All FFI interaction is delegated to +//! [`ffi::RawStmt`] which encapsulates the raw pointers and C type conversions. + +use super::error::DbResult; +use super::ffi::{self, RawStmt}; +use super::value::Value; + +/// Result of a single `sqlite3_step` call. +pub enum StepResult<'stmt, 'conn> { + /// A result row is available. + Row(Row<'stmt, 'conn>), + /// The statement has finished executing. + Done, +} + +/// A guard that represents the current row for a statement. +/// +/// Values read through this guard are valid for the current row only. +/// Calling `step`, `reset`, or dropping/finalizing the statement invalidates +/// `SQLite`'s internal row pointers. +pub struct Row<'stmt, 'conn> { + stmt: &'stmt Statement<'conn>, +} + +impl Row<'_, '_> { + /// Reads a column as `i64`. + /// + /// # Panics + /// + /// Panics if `idx` exceeds `i32::MAX`. + #[must_use] + pub fn column_i64(&self, idx: usize) -> i64 { + self.stmt + .raw + .column_i64(i32::try_from(idx).expect("column index overflow")) + } + + /// Reads a column as a blob. Returns an empty `Vec` for NULL. + /// + /// # Panics + /// + /// Panics if `idx` exceeds `i32::MAX`. + #[must_use] + pub fn column_blob(&self, idx: usize) -> Vec { + self.stmt + .raw + .column_blob(i32::try_from(idx).expect("column index overflow")) + } + + /// Reads a column as a UTF-8 string. Returns an empty string for NULL. + /// + /// # Panics + /// + /// Panics if `idx` exceeds `i32::MAX`. + #[must_use] + pub fn column_text(&self, idx: usize) -> String { + self.stmt + .raw + .column_text(i32::try_from(idx).expect("column index overflow")) + } + + /// Returns `true` if the column is SQL NULL. + /// + /// # Panics + /// + /// Panics if `idx` exceeds `i32::MAX`. + #[allow(dead_code)] + #[must_use] + pub fn is_column_null(&self, idx: usize) -> bool { + self.stmt + .raw + .column_type(i32::try_from(idx).expect("column index overflow")) + == ffi::SQLITE_NULL + } +} + +/// A prepared `SQLite` statement. +/// +/// Created via [`Connection::prepare`](super::Connection::prepare). +/// Tied to the lifetime of the connection that created it. +/// Finalized when dropped. +pub struct Statement<'conn> { + raw: RawStmt<'conn>, +} + +impl<'conn> Statement<'conn> { + /// Wraps a raw statement handle. + pub(super) const fn new(raw: RawStmt<'conn>) -> Self { + Self { raw } + } + + /// Binds a slice of [`Value`]s to the statement parameters (1-indexed). + /// + /// # Errors + /// + /// Returns `DbError` if any bind call fails. + /// + /// # Panics + /// + /// Panics if the number of values exceeds `i32::MAX`. + pub fn bind_values(&mut self, values: &[Value]) -> DbResult<()> { + for (i, val) in values.iter().enumerate() { + let idx = i32::try_from(i + 1).expect("parameter index overflow"); + match val { + Value::Integer(v) => self.raw.bind_i64(idx, *v)?, + Value::Blob(v) => self.raw.bind_blob(idx, v)?, + Value::Text(v) => self.raw.bind_text(idx, v)?, + Value::Null => self.raw.bind_null(idx)?, + } + } + Ok(()) + } + + /// Executes a single step. + /// + /// # Errors + /// + /// Returns `DbError` if the step fails. + pub fn step<'stmt>(&'stmt mut self) -> DbResult> { + let rc = self.raw.step()?; + if rc == ffi::SQLITE_ROW { + Ok(StepResult::Row(Row { stmt: self })) + } else { + Ok(StepResult::Done) + } + } +} diff --git a/walletkit-db/src/tests.rs b/walletkit-db/src/tests.rs new file mode 100644 index 000000000..a2177bf7a --- /dev/null +++ b/walletkit-db/src/tests.rs @@ -0,0 +1,154 @@ +//! Unit tests for the safe `SQLite` db wrapper. + +use super::*; +use zeroize::Zeroizing; + +#[test] +fn test_open_in_memory() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT);") + .expect("create table"); + conn.execute( + "INSERT INTO t (id, val) VALUES (?1, ?2)", + params![1_i64, "hello"], + ) + .expect("insert"); + let result = conn + .query_row("SELECT val FROM t WHERE id = ?1", params![1_i64], |stmt| { + Ok(stmt.column_text(0)) + }) + .expect("query"); + assert_eq!(result, "hello"); +} + +#[test] +fn test_query_row_optional_none() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY);") + .expect("create table"); + let result = conn + .query_row_optional("SELECT id FROM t WHERE id = 999", &[], |stmt| { + Ok(stmt.column_i64(0)) + }) + .expect("query"); + assert!(result.is_none()); +} + +#[test] +fn test_transaction_commit() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY);") + .expect("create table"); + { + let tx = conn.transaction().expect("begin tx"); + tx.execute("INSERT INTO t (id) VALUES (?1)", params![42_i64]) + .expect("insert"); + tx.commit().expect("commit"); + } + let result = conn + .query_row("SELECT id FROM t WHERE id = 42", &[], |stmt| { + Ok(stmt.column_i64(0)) + }) + .expect("query"); + assert_eq!(result, 42); +} + +#[test] +fn test_transaction_rollback_on_drop() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY);") + .expect("create table"); + { + let tx = conn.transaction().expect("begin tx"); + tx.execute("INSERT INTO t (id) VALUES (?1)", params![99_i64]) + .expect("insert"); + // Drop without commit -> rollback + } + let result = conn + .query_row_optional("SELECT id FROM t WHERE id = 99", &[], |stmt| { + Ok(stmt.column_i64(0)) + }) + .expect("query"); + assert!(result.is_none()); +} + +#[test] +fn test_blob_round_trip() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, data BLOB);") + .expect("create table"); + let data = vec![0xDE, 0xAD, 0xBE, 0xEF]; + conn.execute( + "INSERT INTO t (id, data) VALUES (?1, ?2)", + params![1_i64, data.as_slice()], + ) + .expect("insert"); + let result = conn + .query_row("SELECT data FROM t WHERE id = 1", &[], |stmt| { + Ok(stmt.column_blob(0)) + }) + .expect("query"); + assert_eq!(result, data); +} + +#[test] +fn test_null_handling() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT);") + .expect("create table"); + conn.execute( + "INSERT INTO t (id, val) VALUES (?1, ?2)", + params![1_i64, Value::Null], + ) + .expect("insert"); + let result = conn + .query_row("SELECT val FROM t WHERE id = 1", &[], |stmt| { + Ok(stmt.is_column_null(0)) + }) + .expect("query"); + assert!(result); +} + +#[test] +fn test_cipher_encrypted_round_trip() { + let dir = tempfile::tempdir().expect("create temp dir"); + let path = dir.path().join("cipher-test.sqlite"); + let key = Zeroizing::new([0xABu8; 32]); + + // Create and write + { + let conn = cipher::open_encrypted(&path, &key, false).expect("open encrypted"); + conn.execute_batch("CREATE TABLE secret (id INTEGER PRIMARY KEY, val TEXT);") + .expect("create table"); + conn.execute("INSERT INTO secret (id, val) VALUES (1, 'top-secret')", &[]) + .expect("insert"); + } + + // Re-open with correct key + { + let conn = + cipher::open_encrypted(&path, &key, false).expect("reopen encrypted"); + let val = conn + .query_row("SELECT val FROM secret WHERE id = 1", &[], |stmt| { + Ok(stmt.column_text(0)) + }) + .expect("query"); + assert_eq!(val, "top-secret"); + } + + // Wrong key should fail + { + let wrong_key = Zeroizing::new([0xCDu8; 32]); + let result = cipher::open_encrypted(&path, &wrong_key, false); + assert!(result.is_err(), "wrong key should fail"); + } + + // dir is cleaned up on drop +} + +#[test] +fn test_integrity_check() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + let ok = cipher::integrity_check(&conn).expect("check"); + assert!(ok); +} diff --git a/walletkit-db/src/transaction.rs b/walletkit-db/src/transaction.rs new file mode 100644 index 000000000..e0b79d797 --- /dev/null +++ b/walletkit-db/src/transaction.rs @@ -0,0 +1,100 @@ +//! Safe wrapper around a `SQLite` transaction. +//! +//! Automatically rolls back on drop unless explicitly committed. + +use super::connection::Connection; +use super::error::DbResult; +use super::statement::{Row, Statement}; +use super::value::Value; + +/// An open database transaction. +/// +/// Created via [`Connection::transaction`] or [`Connection::transaction_immediate`]. +/// If the `Transaction` is dropped without calling [`commit`](Self::commit), +/// the transaction is rolled back automatically. +pub struct Transaction<'conn> { + conn: &'conn Connection, + committed: bool, +} + +impl<'conn> Transaction<'conn> { + /// Begins a new transaction on `conn`. + /// + /// When `immediate` is true, the transaction acquires a RESERVED lock + /// immediately (`BEGIN IMMEDIATE`) rather than deferring it. + pub(super) fn begin(conn: &'conn Connection, immediate: bool) -> DbResult { + let sql = if immediate { + "BEGIN IMMEDIATE" + } else { + "BEGIN DEFERRED" + }; + conn.execute_batch(sql)?; + Ok(Self { + conn, + committed: false, + }) + } + + /// Commits the transaction. + /// + /// # Errors + /// + /// Returns `DbError` if the COMMIT statement fails. + pub fn commit(mut self) -> DbResult<()> { + self.conn.execute_batch("COMMIT")?; + self.committed = true; + Ok(()) + } + + // -- Delegated Connection methods ----------------------------------------- + + /// See [`Connection::execute_batch`]. + /// + /// # Errors + /// + /// Returns `DbError` if any statement fails. + #[allow(dead_code)] + pub fn execute_batch(&self, sql: &str) -> DbResult<()> { + self.conn.execute_batch(sql) + } + + /// See [`Connection::execute`]. + /// + /// # Errors + /// + /// Returns `DbError` if preparation or execution fails. + pub fn execute(&self, sql: &str, params: &[Value]) -> DbResult { + self.conn.execute(sql, params) + } + + /// See [`Connection::query_row`]. + /// + /// # Errors + /// + /// Returns `DbError` if preparation, execution, or the mapper fails. + pub fn query_row( + &self, + sql: &str, + params: &[Value], + mapper: impl FnOnce(&Row<'_, '_>) -> DbResult, + ) -> DbResult { + self.conn.query_row(sql, params, mapper) + } + + /// See [`Connection::prepare`]. + /// + /// # Errors + /// + /// Returns `DbError` if the SQL is invalid. + pub fn prepare(&self, sql: &str) -> DbResult> { + self.conn.prepare(sql) + } +} + +impl Drop for Transaction<'_> { + fn drop(&mut self) { + if !self.committed { + let _ = self.conn.execute_batch("ROLLBACK"); + } + } +} diff --git a/walletkit-db/src/value.rs b/walletkit-db/src/value.rs new file mode 100644 index 000000000..57a253a2f --- /dev/null +++ b/walletkit-db/src/value.rs @@ -0,0 +1,55 @@ +//! Parameter and column value types for the safe `SQLite` wrapper. + +/// A value that can be bound to a prepared statement parameter or read from +/// a result column. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + /// 64-bit signed integer. + Integer(i64), + /// Binary blob. + Blob(Vec), + /// UTF-8 text. + Text(String), + /// SQL NULL. + Null, +} + +impl From for Value { + fn from(v: i64) -> Self { + Self::Integer(v) + } +} + +impl From> for Value { + fn from(v: Vec) -> Self { + Self::Blob(v) + } +} + +impl From<&[u8]> for Value { + fn from(v: &[u8]) -> Self { + Self::Blob(v.to_vec()) + } +} + +impl From for Value { + fn from(v: String) -> Self { + Self::Text(v) + } +} + +impl From<&str> for Value { + fn from(v: &str) -> Self { + Self::Text(v.to_string()) + } +} + +/// Convenience macro for building parameter lists. +/// +/// Usage: `params![1_i64, blob.as_slice(), "text"]` +#[macro_export] +macro_rules! params { + ($($val:expr),* $(,)?) => { + &[$($crate::Value::from($val)),*][..] + }; +} diff --git a/walletkit/Cargo.toml b/walletkit/Cargo.toml index 998f78de7..74b8c67ab 100644 --- a/walletkit/Cargo.toml +++ b/walletkit/Cargo.toml @@ -21,13 +21,17 @@ name = "walletkit" [dependencies] uniffi = { workspace = true, features = ["build", "tokio"] } -walletkit-core = { workspace = true } +walletkit-core = { workspace = true, features = ["legacy-nullifiers", "common-apps", "storage", "issuers"] } [features] default = ["semaphore"] semaphore = ["walletkit-core/semaphore"] -v4 = ["walletkit-core/v4"] +storage = ["walletkit-core/storage"] +compress-zkeys = ["walletkit-core/compress-zkeys"] + +[lints] +workspace = true [package.metadata.docs.rs] -no-default-features = true \ No newline at end of file +no-default-features = true diff --git a/walletkit/src/lib.rs b/walletkit/src/lib.rs index ac11e2805..eb5139ed9 100644 --- a/walletkit/src/lib.rs +++ b/walletkit/src/lib.rs @@ -1,10 +1,3 @@ -#![deny( - clippy::all, - clippy::pedantic, - clippy::nursery, - missing_docs, - dead_code -)] #![doc = include_str!("../README.md")] extern crate walletkit_core;