From f6965a9c8a09263e5191b45ac0d401fc256b0ae3 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:12:17 -0800 Subject: [PATCH 1/7] swift and kotlin tooling --- .github/workflows/ci.yml | 42 +- .github/workflows/initiate-release.yml | 6 +- .github/workflows/release.yml | 31 +- .gitignore | 15 + kotlin/README.md | 26 ++ kotlin/build_kotlin.sh | 41 ++ kotlin/test_kotlin.sh | 113 +++++ kotlin/walletkit-android/build.gradle.kts | 80 ++++ kotlin/walletkit-android/consumer-rules.pro | 1 + kotlin/walletkit-tests/build.gradle.kts | 31 ++ .../kotlin/org/world/walletkit/SimpleTest.kt | 13 + swift/README.md | 57 +++ swift/archive_swift.sh | 95 +++++ swift/build_swift.sh | 127 ++++++ swift/local_swift.sh | 71 ++++ .../WalletKitTests/AuthenticatorTests.swift | 386 ------------------ swift/tests/WalletKitTests/SimpleTest.swift | 8 + 17 files changed, 737 insertions(+), 406 deletions(-) create mode 100644 kotlin/README.md create mode 100755 kotlin/build_kotlin.sh create mode 100755 kotlin/test_kotlin.sh create mode 100644 kotlin/walletkit-android/build.gradle.kts create mode 100644 kotlin/walletkit-android/consumer-rules.pro create mode 100644 kotlin/walletkit-tests/build.gradle.kts create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt create mode 100644 swift/README.md create mode 100755 swift/archive_swift.sh create mode 100755 swift/build_swift.sh create mode 100755 swift/local_swift.sh delete mode 100644 swift/tests/WalletKitTests/AuthenticatorTests.swift create mode 100644 swift/tests/WalletKitTests/SimpleTest.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e2203dae..24699114d 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] @@ -56,6 +61,41 @@ jobs: - name: Run Swift foreign binding tests 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: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.92.0 + + - name: Build and test Kotlin bindings + run: ./kotlin/test_kotlin.sh + + - 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: Lint Kotlin Tests + run: | + ktlint kotlin/walletkit-tests/src/test/kotlin + test: name: Tests runs-on: ubuntu-latest @@ -112,7 +152,7 @@ jobs: - uses: EmbarkStudios/cargo-deny-action@v2 with: command: check ${{ matrix.checks }} - rust-version: stable + rust-version: 1.92.0 docs: name: Check docs diff --git a/.github/workflows/initiate-release.yml b/.github/workflows/initiate-release.yml index 57ac4a5d9..70dcc3f26 100644 --- a/.github/workflows/initiate-release.yml +++ b/.github/workflows/initiate-release.yml @@ -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..a83a28a85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,11 +68,11 @@ jobs: components: rustfmt - name: Build the project (iOS) - run: ./build_swift.sh + 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 @@ -112,11 +112,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 @@ -210,7 +210,7 @@ jobs: - name: Move artifacts run: | - mkdir -p kotlin/lib/src/main/jniLibs && cd kotlin/lib/src/main/jniLibs + mkdir -p kotlin/walletkit-android/src/main/jniLibs && cd kotlin/walletkit-android/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 +219,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-android/src/main/jniLibs/arm64-v8a/libwalletkit.so --library --language kotlin --no-format --out-dir walletkit-android/src/main/java - name: Publish working-directory: kotlin - run: ./gradlew lib:publish -PversionName=${{ needs.pre-release-checks.outputs.new_version }} + run: ./gradlew walletkit-android:publish env: GITHUB_ACTOR: wld-walletkit-bot GITHUB_TOKEN: ${{ github.token }} @@ -243,16 +243,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] diff --git a/.gitignore b/.gitignore index a59439236..4f74359d5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,17 @@ 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-android/src/main/java/uniffi/ +kotlin/walletkit-tests/build/ .build/ @@ -21,4 +32,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/kotlin/README.md b/kotlin/README.md new file mode 100644 index 000000000..2373f1d6c --- /dev/null +++ b/kotlin/README.md @@ -0,0 +1,26 @@ +# 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-android`: 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_kotlin.sh b/kotlin/build_kotlin.sh new file mode 100755 index 000000000..73c19dcdd --- /dev/null +++ b/kotlin/build_kotlin.sh @@ -0,0 +1,41 @@ +#!/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-android/src/main/java" +LIBS_DIR="$KOTLIN_DIR/libs" + +# 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 + +# 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/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-android/build.gradle.kts b/kotlin/walletkit-android/build.gradle.kts new file mode 100644 index 000000000..e6f047448 --- /dev/null +++ b/kotlin/walletkit-android/build.gradle.kts @@ -0,0 +1,80 @@ +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-android" + + // Read version from Cargo.toml + val cargoToml = file("../../Cargo.toml") + val versionRegex = """version\s*=\s*"([^"]+)"""".toRegex() + val cargoContent = cargoToml.readText() + version = 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 + implementation("net.java.dev.jna:jna:5.13.0") + 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-android/consumer-rules.pro b/kotlin/walletkit-android/consumer-rules.pro new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/kotlin/walletkit-android/consumer-rules.pro @@ -0,0 +1 @@ + diff --git a/kotlin/walletkit-tests/build.gradle.kts b/kotlin/walletkit-tests/build.gradle.kts new file mode 100644 index 000000000..d3a5f433c --- /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-android/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/SimpleTest.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt new file mode 100644 index 000000000..f0efbfac9 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt @@ -0,0 +1,13 @@ +package org.world.walletkit + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SimpleTests { + @Test + fun simpleTest() { + assertEquals(1, 1) + } +} 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/swift/archive_swift.sh b/swift/archive_swift.sh new file mode 100755 index 000000000..6a1351577 --- /dev/null +++ b/swift/archive_swift.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -e + +# Creates the dynamic Package.swift file for release. +# Usage: ./archive_swift.sh --asset-url --checksum --release-version + +# Initialize variables +ASSET_URL="" +CHECKSUM="" +RELEASE_VERSION="" + +# Function to show usage +show_usage() { + echo "โŒ Error: Missing required arguments" + echo "Usage: $0 --asset-url --checksum --release-version " + echo "" + echo "Example:" + echo " $0 --asset-url 'https://github.com/user/repo/releases/download/v1.0.0/WalletKit.xcframework.zip' --checksum 'abc123def456...' --release-version '1.0.0'" + exit 1 +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --asset-url) + ASSET_URL="$2" + shift 2 + ;; + --checksum) + CHECKSUM="$2" + shift 2 + ;; + --release-version) + RELEASE_VERSION="$2" + shift 2 + ;; + -h|--help) + show_usage + ;; + *) + echo "โŒ Unknown argument: $1" + show_usage + ;; + esac +done + +# Check if all required arguments are provided +if [ -z "$ASSET_URL" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_VERSION" ]; then + echo "โŒ Error: All arguments are required" + show_usage +fi + +echo "๐Ÿ”ง Creating Package.swift with:" +echo " Asset URL: $ASSET_URL" +echo " Checksum: $CHECKSUM" +echo " Release Version: $RELEASE_VERSION" +echo "" + +cat > Package.swift << EOF +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +// Release version: $RELEASE_VERSION + +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", + url: "$ASSET_URL", + checksum: "$CHECKSUM" + ) + ] +) +EOF + +swiftlint lint --autocorrect Package.swift + +echo "" +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..90d5c32ad --- /dev/null +++ b/swift/build_swift.sh @@ -0,0 +1,127 @@ +#!/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" +FEATURES="v4" +SUPPORT_SOURCES_DIR="$BASE_PATH/support" + +# 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" + +# 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 "$FEATURES" +cargo build --package $PACKAGE_NAME --target aarch64-apple-ios --release \ + --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" \ + --features "$FEATURES" +cargo build --package $PACKAGE_NAME --target x86_64-apple-ios --release \ + --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" --target-dir "$TARGET_DIR" \ + --features "$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 + +# 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/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/SimpleTest.swift b/swift/tests/WalletKitTests/SimpleTest.swift new file mode 100644 index 000000000..fb30acd50 --- /dev/null +++ b/swift/tests/WalletKitTests/SimpleTest.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import WalletKit + +final class SimpleTest: XCTestCase { + func simpleTest() { + XCTAssertEqual(1, 1) + } +} From a90ab7f0e49d201bee964b15b98596fe0c2cd213 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:14:29 -0800 Subject: [PATCH 2/7] credential storage rs --- Cargo.lock | 170 ++++- walletkit-core/Cargo.toml | 7 + walletkit-core/src/authenticator.rs | 2 + walletkit-core/src/authenticator/storage.rs | 208 ++++++ walletkit-core/src/error.rs | 9 + walletkit-core/src/lib.rs | 3 + .../src/storage/cache/maintenance.rs | 59 ++ walletkit-core/src/storage/cache/merkle.rs | 83 +++ walletkit-core/src/storage/cache/mod.rs | 330 ++++++++++ .../src/storage/cache/nullifiers.rs | 74 +++ walletkit-core/src/storage/cache/schema.rs | 69 ++ walletkit-core/src/storage/cache/session.rs | 62 ++ walletkit-core/src/storage/cache/util.rs | 46 ++ .../src/storage/credential_storage.rs | 594 ++++++++++++++++++ walletkit-core/src/storage/envelope.rs | 70 +++ walletkit-core/src/storage/error.rs | 81 +++ walletkit-core/src/storage/keys.rs | 162 +++++ walletkit-core/src/storage/lock.rs | 286 +++++++++ walletkit-core/src/storage/mod.rs | 33 + walletkit-core/src/storage/paths.rs | 94 +++ walletkit-core/src/storage/sqlcipher.rs | 74 +++ walletkit-core/src/storage/tests_utils.rs | 161 +++++ walletkit-core/src/storage/traits.rs | 70 +++ walletkit-core/src/storage/types.rs | 180 ++++++ walletkit-core/src/storage/vault/helpers.rs | 92 +++ walletkit-core/src/storage/vault/mod.rs | 284 +++++++++ walletkit-core/src/storage/vault/schema.rs | 47 ++ walletkit-core/src/storage/vault/tests.rs | 296 +++++++++ .../tests/credential_storage_integration.rs | 256 ++++++++ walletkit-core/tests/solidity.rs | 12 +- 30 files changed, 3905 insertions(+), 9 deletions(-) create mode 100644 walletkit-core/src/authenticator/storage.rs create mode 100644 walletkit-core/src/storage/cache/maintenance.rs create mode 100644 walletkit-core/src/storage/cache/merkle.rs create mode 100644 walletkit-core/src/storage/cache/mod.rs create mode 100644 walletkit-core/src/storage/cache/nullifiers.rs create mode 100644 walletkit-core/src/storage/cache/schema.rs create mode 100644 walletkit-core/src/storage/cache/session.rs create mode 100644 walletkit-core/src/storage/cache/util.rs create mode 100644 walletkit-core/src/storage/credential_storage.rs create mode 100644 walletkit-core/src/storage/envelope.rs create mode 100644 walletkit-core/src/storage/error.rs create mode 100644 walletkit-core/src/storage/keys.rs create mode 100644 walletkit-core/src/storage/lock.rs create mode 100644 walletkit-core/src/storage/mod.rs create mode 100644 walletkit-core/src/storage/paths.rs create mode 100644 walletkit-core/src/storage/sqlcipher.rs create mode 100644 walletkit-core/src/storage/tests_utils.rs create mode 100644 walletkit-core/src/storage/traits.rs create mode 100644 walletkit-core/src/storage/types.rs create mode 100644 walletkit-core/src/storage/vault/helpers.rs create mode 100644 walletkit-core/src/storage/vault/mod.rs create mode 100644 walletkit-core/src/storage/vault/schema.rs create mode 100644 walletkit-core/src/storage/vault/tests.rs create mode 100644 walletkit-core/tests/credential_storage_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 6a56252c7..0fd39960b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.11" @@ -1714,6 +1724,30 @@ 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" @@ -1755,6 +1789,17 @@ 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" @@ -1876,7 +1921,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -2030,6 +2075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2420,7 +2466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2433,6 +2479,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -2787,6 +2845,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -2809,6 +2870,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heapless" version = "0.7.17" @@ -2859,6 +2929,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" @@ -3254,6 +3333,15 @@ dependencies = [ "serde", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-uring" version = "0.7.8" @@ -3386,6 +3474,17 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-cplusplus" version = "1.0.12" @@ -3703,6 +3802,12 @@ 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" @@ -3825,12 +3930,29 @@ 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" @@ -3988,7 +4110,7 @@ dependencies = [ "once_cell", "socket2 0.5.9", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4293,6 +4415,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.9.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -4339,7 +4475,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5407,7 +5543,7 @@ dependencies = [ "getrandom 0.3.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6021,6 +6157,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" @@ -6075,6 +6221,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6115,26 +6267,32 @@ dependencies = [ "alloy", "alloy-core", "alloy-primitives", + "bincode", + "chacha20poly1305", "chrono", "dotenvy", "hex", + "hkdf", "log", "mockito", "rand 0.8.5", "regex", "reqwest 0.12.22", "ruint", + "rusqlite", "rustls 0.23.27", "secrecy", "semaphore-rs", "serde", "serde_json", + "sha2", "strum", "subtle", "thiserror 2.0.17", "tokio", "tokio-test", "uniffi", + "uuid", "world-id-core", ] @@ -6312,7 +6470,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index 926775e49..25662446d 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -23,8 +23,12 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } alloy-primitives = { workspace = true } +bincode = "1.3" +chacha20poly1305 = "0.10" hex = "0.4" +hkdf = "0.12" log = "0.4" +rand = "0.8" reqwest = { version = "0.12", default-features = false, features = [ "json", "brotli", @@ -38,10 +42,13 @@ secrecy = "0.10" semaphore-rs = { version = "0.5" } serde = "1" serde_json = "1" +sha2 = "0.10" strum = { version = "0.27", features = ["derive"] } subtle = "2" thiserror = "2" tokio = { version = "1", features = ["sync"] } +rusqlite = { version = "0.32", features = ["bundled-sqlcipher"] } +uuid = { version = "1.10", features = ["v4"] } uniffi = { workspace = true, features = ["build", "tokio"] } world-id-core = { workspace = true, optional = true } diff --git a/walletkit-core/src/authenticator.rs b/walletkit-core/src/authenticator.rs index fcf2cffaf..843136831 100644 --- a/walletkit-core/src/authenticator.rs +++ b/walletkit-core/src/authenticator.rs @@ -11,6 +11,8 @@ use crate::{ primitives::ParseFromForeignBinding, Environment, U256Wrapper, }; +mod storage; + /// The Authenticator is the main component with which users interact with the World ID Protocol. #[derive(Debug, uniffi::Object)] pub struct Authenticator(CoreAuthenticator); diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs new file mode 100644 index 000000000..15d70297b --- /dev/null +++ b/walletkit-core/src/authenticator/storage.rs @@ -0,0 +1,208 @@ +use std::convert::TryFrom; + +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 world_id_core::{requests::ProofRequest, Credential, FieldElement}; + +use crate::error::WalletKitError; +use crate::storage::{CredentialStorage, ProofDisclosureResult, RequestId}; + +use super::Authenticator; + +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, + storage: &mut dyn CredentialStorage, + now: u64, + ) -> Result<(), WalletKitError> { + let leaf_index = u64::try_from(self.leaf_index().0).map_err(|_| { + WalletKitError::InvalidInput { + attribute: "leaf_index".to_string(), + reason: "leaf index does not fit in u64".to_string(), + } + })?; + storage.init(leaf_index, now)?; + Ok(()) + } + + /// Fetches an inclusion proof, using the storage cache when possible. + /// + /// The cached payload uses `AccountInclusionProof` serialization and is keyed by + /// (`registry_kind`, `root`, `leaf_index`). + /// + /// # Errors + /// + /// Returns an error if fetching or caching the proof fails. + #[allow(clippy::future_not_send)] + pub async fn fetch_inclusion_proof_cached( + &self, + storage: &mut dyn CredentialStorage, + registry_kind: u8, + root: [u8; 32], + now: u64, + ttl_seconds: u64, + ) -> Result< + (MerkleInclusionProof, AuthenticatorPublicKeySet), + WalletKitError, + > { + if let Some(bytes) = storage.merkle_cache_get(registry_kind, root, now)? { + if let Some(cached) = deserialize_inclusion_proof(&bytes) { + return Ok((cached.proof, cached.authenticator_pubkeys)); + } + } + + let (proof, key_set) = self.0.fetch_inclusion_proof().await?; + let payload = CachedInclusionProof { + proof: proof.clone(), + authenticator_pubkeys: key_set.clone(), + }; + let payload_bytes = serialize_inclusion_proof(&payload)?; + let proof_root = field_element_to_bytes(proof.root); + storage.merkle_cache_put( + registry_kind, + proof_root, + payload_bytes, + now, + ttl_seconds, + )?; + Ok((proof, key_set)) + } + + /// Generates a proof and enforces replay safety via storage. + /// + /// # Errors + /// + /// Returns an error if the proof generation or storage update fails. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::future_not_send)] + pub async fn generate_proof_with_disclosure( + &self, + storage: &mut dyn CredentialStorage, + proof_request: ProofRequest, + credential: Credential, + credential_sub_blinding_factor: FieldElement, + request_id: RequestId, + now: u64, + ttl_seconds: u64, + ) -> Result { + let (proof, nullifier) = self + .0 + .generate_proof(proof_request, credential, credential_sub_blinding_factor) + .await?; + let proof_bytes = serialize_proof_package(&proof, nullifier)?; + let nullifier_bytes = field_element_to_bytes(nullifier); + storage + .begin_proof_disclosure( + request_id, + nullifier_bytes, + proof_bytes, + now, + ttl_seconds, + ) + .map_err(WalletKitError::from) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedInclusionProof { + proof: MerkleInclusionProof, + authenticator_pubkeys: AuthenticatorPublicKeySet, +} + +fn serialize_inclusion_proof( + payload: &CachedInclusionProof, +) -> Result, WalletKitError> { + bincode::serialize(payload).map_err(|err| WalletKitError::SerializationError { + error: err.to_string(), + }) +} + +fn deserialize_inclusion_proof(bytes: &[u8]) -> Option { + bincode::deserialize(bytes).ok() +} + +fn field_element_to_bytes(value: FieldElement) -> [u8; 32] { + let value: ruint::aliases::U256 = value.into(); + value.to_be_bytes::<32>() +} +fn serialize_proof_package( + proof: &impl Serialize, + nullifier: FieldElement, +) -> Result, WalletKitError> { + bincode::serialize(&(proof, nullifier)).map_err(|err| { + WalletKitError::SerializationError { + error: err.to_string(), + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::tests_utils::InMemoryStorageProvider; + use crate::storage::CredentialStore; + use std::fs; + use std::path::{Path, PathBuf}; + use uuid::Uuid; + + fn temp_root() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-auth-storage-{}", Uuid::new_v4())); + path + } + + fn cleanup_storage(root: &Path) { + let paths = crate::storage::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()); + } + + #[test] + fn test_cached_inclusion_round_trip() { + let root = temp_root(); + 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 proof = MerkleInclusionProof::new(root_fe, 42, siblings); + let key_set = AuthenticatorPublicKeySet::new(None).expect("key set"); + let payload = CachedInclusionProof { + proof: proof.clone(), + authenticator_pubkeys: key_set, + }; + let payload_bytes = serialize_inclusion_proof(&payload).expect("serialize"); + let root_bytes = field_element_to_bytes(proof.root); + + store + .merkle_cache_put(1, root_bytes.to_vec(), payload_bytes, 100, 60) + .expect("cache put"); + let cached = store + .merkle_cache_get(1, root_bytes.to_vec(), 110) + .expect("cache get") + .expect("cache hit"); + let decoded = deserialize_inclusion_proof(&cached).expect("decode"); + assert_eq!(decoded.proof.leaf_index, 42); + assert_eq!(decoded.proof.root, root_fe); + assert_eq!(decoded.authenticator_pubkeys.len(), 0); + cleanup_storage(&root); + } +} diff --git a/walletkit-core/src/error.rs b/walletkit-core/src/error.rs index b94bc7ac5..3c86951b2 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -1,5 +1,6 @@ use thiserror::Error; +use crate::storage::StorageError; #[cfg(feature = "v4")] use world_id_core::AuthenticatorError; @@ -107,6 +108,14 @@ impl From for WalletKitError { } } +impl From for WalletKitError { + fn from(error: StorageError) -> Self { + Self::Generic { + error: error.to_string(), + } + } +} + #[cfg(feature = "v4")] impl From for WalletKitError { fn from(error: AuthenticatorError) -> Self { diff --git a/walletkit-core/src/lib.rs b/walletkit-core/src/lib.rs index d0eb23956..2bc4c0e2a 100644 --- a/walletkit-core/src/lib.rs +++ b/walletkit-core/src/lib.rs @@ -49,6 +49,9 @@ pub mod logger; mod u256; pub use u256::U256Wrapper; +/// Credential storage primitives for World ID v4. +pub mod storage; + #[cfg(feature = "v4")] mod authenticator; #[cfg(feature = "v4")] diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs new file mode 100644 index 000000000..8c215b763 --- /dev/null +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -0,0 +1,59 @@ +//! Cache DB maintenance helpers (integrity checks, rebuilds). + +use std::fs; +use std::path::Path; + +use rusqlite::Connection; + +use crate::storage::error::StorageResult; +use crate::storage::sqlcipher; + +use super::schema; +use super::util::{map_io_err, map_sqlcipher_err}; + +pub(super) fn open_or_rebuild( + path: &Path, + k_intermediate: [u8; 32], +) -> StorageResult { + match open_prepared(path, k_intermediate) { + Ok(conn) => { + let integrity_ok = + sqlcipher::integrity_check(&conn).map_err(map_sqlcipher_err)?; + if integrity_ok { + Ok(conn) + } else { + drop(conn); + rebuild(path, k_intermediate) + } + } + Err(err) => rebuild(path, k_intermediate).map_or_else(|_| Err(err), Ok), + } +} + +fn open_prepared(path: &Path, k_intermediate: [u8; 32]) -> StorageResult { + let conn = sqlcipher::open_connection(path).map_err(map_sqlcipher_err)?; + sqlcipher::apply_key(&conn, k_intermediate).map_err(map_sqlcipher_err)?; + sqlcipher::configure_connection(&conn).map_err(map_sqlcipher_err)?; + schema::ensure_schema(&conn)?; + Ok(conn) +} + +fn rebuild(path: &Path, k_intermediate: [u8; 32]) -> StorageResult { + delete_cache_files(path)?; + open_prepared(path, k_intermediate) +} + +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(()) +} + +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..5d49f2f0c --- /dev/null +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -0,0 +1,83 @@ +//! Merkle proof cache helpers. + +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::storage::error::StorageResult; + +use super::util::{expiry_timestamp, map_db_err, to_i64}; + +pub(super) fn get( + conn: &Connection, + registry_kind: u8, + root: [u8; 32], + leaf_index: u64, + now: u64, +) -> StorageResult>> { + let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; + let now_i64 = to_i64(now, "now")?; + let proof = conn + .query_row( + "SELECT proof_bytes + FROM merkle_proof_cache + WHERE registry_kind = ?1 + AND root = ?2 + AND leaf_index = ?3 + AND expires_at > ?4", + params![ + i64::from(registry_kind), + root.as_ref(), + leaf_index_i64, + now_i64 + ], + |row| row.get(0), + ) + .optional() + .map_err(|err| map_db_err(&err))?; + Ok(proof) +} + +pub(super) fn put( + conn: &Connection, + registry_kind: u8, + root: [u8; 32], + leaf_index: u64, + proof_bytes: &[u8], + now: u64, + ttl_seconds: u64, +) -> StorageResult<()> { + prune_expired(conn, now)?; + let expires_at = expiry_timestamp(now, ttl_seconds); + let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; + let now_i64 = to_i64(now, "now")?; + let expires_at_i64 = to_i64(expires_at, "expires_at")?; + conn.execute( + "INSERT OR REPLACE INTO merkle_proof_cache ( + registry_kind, + root, + leaf_index, + proof_bytes, + inserted_at, + expires_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + i64::from(registry_kind), + root.as_ref(), + leaf_index_i64, + proof_bytes, + now_i64, + expires_at_i64 + ], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { + let now_i64 = to_i64(now, "now")?; + conn.execute( + "DELETE FROM merkle_proof_cache WHERE expires_at <= ?1", + params![now_i64], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs new file mode 100644 index 000000000..c4c5c983d --- /dev/null +++ b/walletkit-core/src/storage/cache/mod.rs @@ -0,0 +1,330 @@ +//! Encrypted cache database for credential storage. + +use std::path::Path; + +use rusqlite::Connection; + +use crate::storage::error::StorageResult; +use crate::storage::lock::StorageLockGuard; +use crate::storage::types::ProofDisclosureResult; + +mod maintenance; +mod merkle; +mod nullifiers; +mod schema; +mod session; +mod util; + +/// Encrypted cache database wrapper. +#[derive(Debug)] +pub struct CacheDb { + conn: Connection, +} + +impl CacheDb { + /// Opens or creates the encrypted cache database at `path`. + /// + /// # Errors + /// + /// Returns an error if the database cannot be opened or rebuilt. + pub fn new( + path: &Path, + k_intermediate: [u8; 32], + _lock: &StorageLockGuard, + ) -> StorageResult { + let conn = maintenance::open_or_rebuild(path, k_intermediate)?; + Ok(Self { conn }) + } + + /// Fetches a cached Merkle proof if available. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn merkle_cache_get( + &self, + registry_kind: u8, + root: [u8; 32], + leaf_index: u64, + now: u64, + ) -> StorageResult>> { + merkle::get(&self.conn, registry_kind, root, leaf_index, now) + } + + /// Inserts a cached Merkle proof with a TTL. + /// + /// # Errors + /// + /// Returns an error if the insert fails. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::needless_pass_by_value)] + pub fn merkle_cache_put( + &mut self, + _lock: &StorageLockGuard, + registry_kind: u8, + root: [u8; 32], + leaf_index: u64, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + merkle::put( + &self.conn, + registry_kind, + root, + leaf_index, + proof_bytes.as_ref(), + now, + ttl_seconds, + ) + } + + /// Fetches a cached session key if present. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn session_key_get( + &self, + rp_id: [u8; 32], + now: u64, + ) -> StorageResult> { + session::get(&self.conn, rp_id, now) + } + + /// Stores a session key with a TTL. + /// + /// # 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], + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + session::put(&self.conn, rp_id, k_session, now, ttl_seconds) + } + + /// Enforces replay safety for proof disclosure. + /// + /// # Errors + /// + /// Returns an error if the disclosure conflicts with an existing nullifier. + pub fn begin_proof_disclosure( + &mut self, + _lock: &StorageLockGuard, + request_id: [u8; 32], + nullifier: [u8; 32], + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult { + nullifiers::begin_proof_disclosure( + &mut self.conn, + request_id, + nullifier, + proof_bytes, + now, + ttl_seconds, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::error::StorageError; + use crate::storage::lock::StorageLock; + use std::fs; + use std::path::PathBuf; + 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 = [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 = [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, 100, 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, 200).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 = [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"); + let root = [0xABu8; 32]; + db.merkle_cache_put(&guard, 1, root, 42, vec![1, 2, 3], 100, 10) + .expect("put merkle proof"); + let hit = db + .merkle_cache_get(1, root, 42, 105) + .expect("get merkle proof"); + assert!(hit.is_some()); + let miss = db + .merkle_cache_get(1, root, 42, 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 = [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, 100, 10) + .expect("put session key"); + let hit = db.session_key_get(rp_id, 105).expect("get session key"); + assert!(hit.is_some()); + let miss = db.session_key_get(rp_id, 111).expect("get session key"); + assert!(miss.is_none()); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_disclosure_replay_returns_original_bytes() { + let path = temp_cache_path(); + let key = [0x77u8; 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 request_id = [0x10u8; 32]; + let nullifier = [0x20u8; 32]; + let first = vec![1, 2, 3]; + let second = vec![9, 9, 9]; + + let fresh = db + .begin_proof_disclosure( + &guard, + request_id, + nullifier, + first.clone(), + 100, + 1000, + ) + .expect("first disclosure"); + assert_eq!(fresh, ProofDisclosureResult::Fresh(first.clone())); + + let replay = db + .begin_proof_disclosure(&guard, request_id, nullifier, second, 101, 1000) + .expect("replay disclosure"); + assert_eq!(replay, ProofDisclosureResult::Replay(first)); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_disclosure_nullifier_conflict_errors() { + let path = temp_cache_path(); + let key = [0x88u8; 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 request_id_a = [0x01u8; 32]; + let request_id_b = [0x02u8; 32]; + let nullifier = [0x03u8; 32]; + + db.begin_proof_disclosure(&guard, request_id_a, nullifier, vec![4], 100, 1000) + .expect("first disclosure"); + + let err = db + .begin_proof_disclosure(&guard, request_id_b, nullifier, vec![5], 101, 1000) + .expect_err("nullifier conflict"); + match err { + StorageError::NullifierAlreadyDisclosed => {} + other => panic!("unexpected error: {other}"), + } + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + + #[test] + fn test_disclosure_expiry_allows_new_insert() { + let path = temp_cache_path(); + let key = [0x99u8; 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 request_id_a = [0x0Au8; 32]; + let request_id_b = [0x0Bu8; 32]; + let nullifier = [0x0Cu8; 32]; + + db.begin_proof_disclosure(&guard, request_id_a, nullifier, vec![7], 100, 10) + .expect("first disclosure"); + + let fresh = db + .begin_proof_disclosure(&guard, request_id_b, nullifier, vec![8], 111, 10) + .expect("second disclosure after expiry"); + assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![8])); + 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..0575fdf93 --- /dev/null +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -0,0 +1,74 @@ +//! Used-nullifier cache helpers (Phase 4 hooks). + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; + +use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::types::ProofDisclosureResult; + +use super::util::{expiry_timestamp, map_db_err, to_i64}; + +pub(super) fn begin_proof_disclosure( + conn: &mut Connection, + request_id: [u8; 32], + nullifier: [u8; 32], + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, +) -> StorageResult { + let now_i64 = to_i64(now, "now")?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|err| map_db_err(&err))?; + tx.execute( + "DELETE FROM used_nullifiers WHERE expires_at <= ?1", + params![now_i64], + ) + .map_err(|err| map_db_err(&err))?; + + let existing_proof: Option> = tx + .query_row( + "SELECT proof_bytes + FROM used_nullifiers + WHERE request_id = ?1 + AND expires_at > ?2", + params![request_id.as_ref(), now_i64], + |row| row.get(0), + ) + .optional() + .map_err(|err| map_db_err(&err))?; + if let Some(bytes) = existing_proof { + tx.commit().map_err(|err| map_db_err(&err))?; + return Ok(ProofDisclosureResult::Replay(bytes)); + } + + let existing_request: Option> = tx + .query_row( + "SELECT request_id + FROM used_nullifiers + WHERE nullifier = ?1 + AND expires_at > ?2", + params![nullifier.as_ref(), now_i64], + |row| row.get(0), + ) + .optional() + .map_err(|err| map_db_err(&err))?; + if existing_request.is_some() { + return Err(StorageError::NullifierAlreadyDisclosed); + } + + let expires_at = expiry_timestamp(now, ttl_seconds); + let expires_at_i64 = to_i64(expires_at, "expires_at")?; + tx.execute( + "INSERT INTO used_nullifiers (request_id, nullifier, expires_at, proof_bytes) + VALUES (?1, ?2, ?3, ?4)", + params![ + request_id.as_ref(), + nullifier.as_ref(), + expires_at_i64, + proof_bytes + ], + ) + .map_err(|err| map_db_err(&err))?; + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(ProofDisclosureResult::Fresh(proof_bytes)) +} diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs new file mode 100644 index 000000000..65270983b --- /dev/null +++ b/walletkit-core/src/storage/cache/schema.rs @@ -0,0 +1,69 @@ +//! Cache database schema management. + +use rusqlite::Connection; + +use crate::storage::error::StorageResult; + +use super::util::map_db_err; + +const CACHE_SCHEMA_VERSION: i64 = 1; + +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 + ); + + CREATE TABLE IF NOT EXISTS used_nullifiers ( + request_id BLOB NOT NULL, + nullifier BLOB NOT NULL, + expires_at INTEGER NOT NULL, + proof_bytes BLOB NOT NULL, + PRIMARY KEY (request_id), + UNIQUE (nullifier) + ); + + CREATE INDEX IF NOT EXISTS idx_used_nullifiers_expiry + ON used_nullifiers (expires_at); + + CREATE TABLE IF NOT EXISTS merkle_proof_cache ( + registry_kind INTEGER NOT NULL, + root BLOB NOT NULL, + leaf_index INTEGER NOT NULL, + proof_bytes BLOB NOT NULL, + inserted_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (registry_kind, root, leaf_index) + ); + + CREATE INDEX IF NOT EXISTS idx_merkle_proof_expiry + ON merkle_proof_cache (expires_at); + + CREATE TABLE IF NOT EXISTS session_keys ( + rp_id BLOB NOT NULL, + k_session BLOB NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (rp_id) + ); + + CREATE INDEX IF NOT EXISTS idx_session_keys_expiry + ON session_keys (expires_at);", + ) + .map_err(|err| map_db_err(&err))?; + + let existing: i64 = conn + .query_row("SELECT COUNT(*) FROM cache_meta;", [], |row| row.get(0)) + .map_err(|err| map_db_err(&err))?; + if existing == 0 { + conn.execute( + "INSERT INTO cache_meta (schema_version, created_at, updated_at) + VALUES (?1, strftime('%s','now'), strftime('%s','now'))", + [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..f5f3f7e80 --- /dev/null +++ b/walletkit-core/src/storage/cache/session.rs @@ -0,0 +1,62 @@ +//! Session key cache helpers. + +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::storage::error::StorageResult; + +use super::util::{expiry_timestamp, map_db_err, parse_fixed_bytes, to_i64}; + +pub(super) fn get( + conn: &Connection, + rp_id: [u8; 32], + now: u64, +) -> StorageResult> { + let now_i64 = to_i64(now, "now")?; + let raw: Option> = conn + .query_row( + "SELECT k_session + FROM session_keys + WHERE rp_id = ?1 + AND expires_at > ?2", + params![rp_id.as_ref(), now_i64], + |row| row.get(0), + ) + .optional() + .map_err(|err| map_db_err(&err))?; + match raw { + Some(bytes) => Ok(Some(parse_fixed_bytes::<32>(&bytes, "k_session")?)), + None => Ok(None), + } +} + +pub(super) fn put( + conn: &Connection, + rp_id: [u8; 32], + k_session: [u8; 32], + now: u64, + ttl_seconds: u64, +) -> StorageResult<()> { + prune_expired(conn, now)?; + let expires_at = expiry_timestamp(now, ttl_seconds); + let expires_at_i64 = to_i64(expires_at, "expires_at")?; + conn.execute( + "INSERT OR REPLACE INTO session_keys ( + rp_id, + k_session, + expires_at + ) VALUES (?1, ?2, ?3)", + params![rp_id.as_ref(), k_session.as_ref(), expires_at_i64], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} + +fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { + let now_i64 = to_i64(now, "now")?; + conn.execute( + "DELETE FROM session_keys WHERE expires_at <= ?1", + params![now_i64], + ) + .map_err(|err| map_db_err(&err))?; + Ok(()) +} diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs new file mode 100644 index 000000000..45fecbb1a --- /dev/null +++ b/walletkit-core/src/storage/cache/util.rs @@ -0,0 +1,46 @@ +//! Shared helpers for cache database operations. + +use std::io; + +use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::sqlcipher::SqlcipherError; + +pub(super) fn map_db_err(err: &rusqlite::Error) -> StorageError { + StorageError::CacheDb(err.to_string()) +} + +pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { + match err { + SqlcipherError::Sqlite(err) => StorageError::CacheDb(err.to_string()), + SqlcipherError::CipherUnavailable => StorageError::CacheDb(err.to_string()), + } +} + +pub(super) fn map_io_err(err: &io::Error) -> StorageError { + StorageError::CacheDb(err.to_string()) +} + +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 fn expiry_timestamp(now: u64, ttl_seconds: u64) -> u64 { + now.saturating_add(ttl_seconds) +} + +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..a953f3d9c --- /dev/null +++ b/walletkit-core/src/storage/credential_storage.rs @@ -0,0 +1,594 @@ +//! Storage facade implementing the credential storage API. + +use std::sync::{Arc, Mutex}; + +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::{ + CredentialId, CredentialRecord, CredentialRecordFfi, CredentialStatus, Nullifier, + ProofDisclosureResult, ProofDisclosureResultFfi, RequestId, +}; +use super::{CacheDb, VaultDb}; + +/// Public-facing storage API used by `WalletKit` v4 flows. +pub trait CredentialStorage { + /// Initializes storage and validates the account leaf index. + /// + /// # Errors + /// + /// Returns an error if storage initialization fails or the leaf index is invalid. + fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()>; + + /// Lists active credentials, optionally filtered by issuer schema ID. + /// + /// # Errors + /// + /// Returns an error if the credential query fails. + fn list_credentials( + &self, + issuer_schema_id: Option, + now: u64, + ) -> StorageResult>; + + /// Stores a credential and optional associated data. + /// + /// # Errors + /// + /// Returns an error if the credential cannot be stored. + #[allow(clippy::too_many_arguments)] + fn store_credential( + &mut self, + issuer_schema_id: u64, + status: CredentialStatus, + subject_blinding_factor: [u8; 32], + genesis_issued_at: u64, + expires_at: Option, + credential_blob: Vec, + associated_data: Option>, + now: u64, + ) -> StorageResult; + + /// Fetches a cached Merkle proof if available. + /// + /// # Errors + /// + /// Returns an error if the cache lookup fails. + fn merkle_cache_get( + &self, + registry_kind: u8, + root: [u8; 32], + now: u64, + ) -> StorageResult>>; + + /// Inserts a cached Merkle proof with a TTL. + /// + /// # Errors + /// + /// Returns an error if the cache insert fails. + fn merkle_cache_put( + &mut self, + registry_kind: u8, + root: [u8; 32], + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()>; + + /// Enforces replay safety for proof disclosure. + /// + /// # Errors + /// + /// Returns an error if the nullifier is already disclosed or the cache + /// operation fails. + fn begin_proof_disclosure( + &mut self, + request_id: RequestId, + nullifier: Nullifier, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult; +} + +/// Concrete storage implementation backed by `SQLCipher` databases. +#[derive(uniffi::Object)] +pub struct CredentialStore { + inner: Mutex, +} + +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) + } +} + +#[uniffi::export] +impl CredentialStore { + /// Creates a new storage handle from explicit components. + /// + /// # Errors + /// + /// Returns an error if the storage lock cannot be opened. + #[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. + #[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 credentials, 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> { + let records = self.lock_inner()?.list_credentials(issuer_schema_id, now)?; + Ok(records.into_iter().map(CredentialRecordFfi::from).collect()) + } + + /// Stores a credential and optional associated data. + /// + /// # Errors + /// + /// Returns an error if the credential cannot be stored. + #[allow(clippy::too_many_arguments)] + pub fn store_credential( + &self, + issuer_schema_id: u64, + status: CredentialStatus, + subject_blinding_factor: Vec, + genesis_issued_at: u64, + expires_at: Option, + credential_blob: Vec, + associated_data: Option>, + now: u64, + ) -> StorageResult> { + let subject_blinding_factor = parse_fixed_bytes::<32>( + subject_blinding_factor, + "subject_blinding_factor", + )?; + let credential_id = self.lock_inner()?.store_credential( + issuer_schema_id, + status, + subject_blinding_factor, + genesis_issued_at, + expires_at, + credential_blob, + associated_data, + now, + )?; + Ok(credential_id.to_vec()) + } + + /// Fetches a cached Merkle proof if available. + /// + /// # Errors + /// + /// Returns an error if the cache lookup fails. + pub fn merkle_cache_get( + &self, + registry_kind: u8, + root: Vec, + now: u64, + ) -> StorageResult>> { + let root = parse_fixed_bytes::<32>(root, "root")?; + self.lock_inner()? + .merkle_cache_get(registry_kind, root, now) + } + + /// Inserts a cached Merkle proof with a TTL. + /// + /// # Errors + /// + /// Returns an error if the cache insert fails. + pub fn merkle_cache_put( + &self, + registry_kind: u8, + root: Vec, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + let root = parse_fixed_bytes::<32>(root, "root")?; + self.lock_inner()?.merkle_cache_put( + registry_kind, + root, + proof_bytes, + now, + ttl_seconds, + ) + } + + /// Enforces replay safety for proof disclosure. + /// + /// # Errors + /// + /// Returns an error if the disclosure conflicts or storage fails. + pub fn begin_proof_disclosure( + &self, + request_id: Vec, + nullifier: Vec, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult { + let request_id = parse_fixed_bytes::<32>(request_id, "request_id")?; + let nullifier = parse_fixed_bytes::<32>(nullifier, "nullifier")?; + let result = self.lock_inner()?.begin_proof_disclosure( + request_id, + nullifier, + proof_bytes, + now, + ttl_seconds, + )?; + Ok(ProofDisclosureResultFfi::from(result)) + } +} + +fn parse_fixed_bytes( + bytes: Vec, + label: &str, +) -> StorageResult<[u8; N]> { + bytes.try_into().map_err(|bytes: Vec| { + StorageError::Serialization(format!( + "{label} length mismatch: expected {N}, got {}", + bytes.len() + )) + }) +} + +impl CredentialStore { + fn lock_inner( + &self, + ) -> StorageResult> { + self.inner + .lock() + .map_err(|_| StorageError::Lock("storage mutex poisoned".to_string())) + } +} + +impl CredentialStorage for 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 vault = + VaultDb::new(&self.paths.vault_db_path(), keys.intermediate_key(), &guard)?; + let cache = + CacheDb::new(&self.paths.cache_db_path(), keys.intermediate_key(), &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 store_credential( + &mut self, + issuer_schema_id: u64, + status: CredentialStatus, + subject_blinding_factor: [u8; 32], + genesis_issued_at: u64, + expires_at: Option, + credential_blob: Vec, + associated_data: Option>, + now: u64, + ) -> StorageResult { + let guard = self.guard()?; + let state = self.state_mut()?; + state.vault.store_credential( + &guard, + issuer_schema_id, + status, + subject_blinding_factor, + genesis_issued_at, + expires_at, + credential_blob, + associated_data, + now, + ) + } + + fn merkle_cache_get( + &self, + registry_kind: u8, + root: [u8; 32], + now: u64, + ) -> StorageResult>> { + let state = self.state()?; + state + .cache + .merkle_cache_get(registry_kind, root, state.leaf_index, now) + } + + fn merkle_cache_put( + &mut self, + registry_kind: u8, + root: [u8; 32], + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + let guard = self.guard()?; + let state = self.state_mut()?; + state.cache.merkle_cache_put( + &guard, + registry_kind, + root, + state.leaf_index, + proof_bytes, + now, + ttl_seconds, + ) + } + + fn begin_proof_disclosure( + &mut self, + request_id: RequestId, + nullifier: Nullifier, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult { + let guard = self.guard()?; + let state = self.state_mut()?; + state.cache.begin_proof_disclosure( + &guard, + request_id, + nullifier, + proof_bytes, + now, + ttl_seconds, + ) + } +} + +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()) + } +} + +impl CredentialStorage for CredentialStore { + fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()> { + let mut inner = self.lock_inner()?; + inner.init(leaf_index, now) + } + + fn list_credentials( + &self, + issuer_schema_id: Option, + now: u64, + ) -> StorageResult> { + let inner = self.lock_inner()?; + inner.list_credentials(issuer_schema_id, now) + } + + #[allow(clippy::too_many_arguments)] + fn store_credential( + &mut self, + issuer_schema_id: u64, + status: CredentialStatus, + subject_blinding_factor: [u8; 32], + genesis_issued_at: u64, + expires_at: Option, + credential_blob: Vec, + associated_data: Option>, + now: u64, + ) -> StorageResult { + let mut inner = self.lock_inner()?; + inner.store_credential( + issuer_schema_id, + status, + subject_blinding_factor, + genesis_issued_at, + expires_at, + credential_blob, + associated_data, + now, + ) + } + + fn merkle_cache_get( + &self, + registry_kind: u8, + root: [u8; 32], + now: u64, + ) -> StorageResult>> { + let inner = self.lock_inner()?; + inner.merkle_cache_get(registry_kind, root, now) + } + + fn merkle_cache_put( + &mut self, + registry_kind: u8, + root: [u8; 32], + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult<()> { + let mut inner = self.lock_inner()?; + inner.merkle_cache_put(registry_kind, root, proof_bytes, now, ttl_seconds) + } + + fn begin_proof_disclosure( + &mut self, + request_id: RequestId, + nullifier: Nullifier, + proof_bytes: Vec, + now: u64, + ttl_seconds: u64, + ) -> StorageResult { + let mut inner = self.lock_inner()?; + inner.begin_proof_disclosure( + request_id, + nullifier, + proof_bytes, + now, + ttl_seconds, + ) + } +} diff --git a/walletkit-core/src/storage/envelope.rs b/walletkit-core/src/storage/envelope.rs new file mode 100644 index 000000000..d8d571758 --- /dev/null +++ b/walletkit-core/src/storage/envelope.rs @@ -0,0 +1,70 @@ +//! Account key envelope persistence helpers. + +use serde::{Deserialize, Serialize}; + +use super::error::{StorageError, StorageResult}; + +const ENVELOPE_VERSION: u32 = 1; + +#[derive(Clone, Serialize, Deserialize)] +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> { + bincode::serialize(self) + .map_err(|err| StorageError::Serialization(err.to_string())) + } + + pub(crate) fn deserialize(bytes: &[u8]) -> StorageResult { + let envelope: Self = bincode::deserialize(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..7fcb9fe45 --- /dev/null +++ b/walletkit-core/src/storage/error.rs @@ -0,0 +1,81 @@ +//! 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, 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, + + /// Unexpected `UniFFI` callback error. + #[error("unexpected uniffi callback error: {0}")] + UnexpectedUniFFICallbackError(String), +} + +impl From for StorageError { + fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::UnexpectedUniFFICallbackError(error.reason) + } +} diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs new file mode 100644 index 000000000..cc0e3fdcd --- /dev/null +++ b/walletkit-core/src/storage/keys.rs @@ -0,0 +1,162 @@ +//! Key hierarchy management for credential storage. + +use rand::{rngs::OsRng, RngCore}; + +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. +#[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 k_intermediate_bytes = keystore.open_sealed( + ACCOUNT_KEY_ENVELOPE_AD.to_vec(), + envelope.wrapped_k_intermediate, + )?; + let k_intermediate = parse_key_32(&k_intermediate_bytes, "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. Treat this as sensitive material. + #[must_use] + pub const fn intermediate_key(&self) -> [u8; 32] { + 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..f421890ab --- /dev/null +++ b/walletkit-core/src/storage/lock.rs @@ -0,0 +1,286 @@ +//! File-based storage lock for serializing writes. + +use std::fs::{self, File, OpenOptions}; +use std::path::Path; +use std::sync::Arc; + +use super::error::{StorageError, StorageResult}; + +/// A file-backed lock that serializes storage mutations across processes. +#[derive(Debug, Clone)] +pub struct StorageLock { + 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) + } + } +} + +/// Guard that holds an exclusive lock for its lifetime. +#[derive(Debug)] +pub struct StorageLockGuard { + file: Arc, +} + +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()) +} + +#[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; +} + +#[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; +} + +#[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..a80551f82 --- /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 keys; +pub mod lock; +pub mod paths; +pub(crate) mod sqlcipher; +pub mod traits; +pub mod types; +pub mod vault; + +pub use cache::CacheDb; +pub use credential_storage::{CredentialStorage, CredentialStore}; +pub use error::{StorageError, StorageResult}; +pub use keys::StorageKeys; +pub use lock::{StorageLock, StorageLockGuard}; +pub use paths::StoragePaths; +pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; +pub use types::{ + BlobKind, ContentId, CredentialId, CredentialRecord, CredentialRecordFfi, + CredentialStatus, Nullifier, ProofDisclosureKind, ProofDisclosureResult, + ProofDisclosureResultFfi, 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..65820b2c4 --- /dev/null +++ b/walletkit-core/src/storage/paths.rs @@ -0,0 +1,94 @@ +//! 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"; + +/// Paths for credential storage artifacts under `/worldid`. +#[derive(Debug, Clone, 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) + } +} + +#[uniffi::export] +impl StoragePaths { + /// Builds storage paths rooted at `root`. + #[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() + } +} diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs new file mode 100644 index 000000000..19e56b59d --- /dev/null +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -0,0 +1,74 @@ +//! Shared `SQLCipher` helpers for storage databases. + +use std::fmt; +use std::path::Path; + +use rusqlite::{Connection, OpenFlags}; + +/// `SQLCipher` helper errors. +#[derive(Debug)] +pub enum SqlcipherError { + /// `SQLite` error. + Sqlite(rusqlite::Error), + /// `SQLCipher` is unavailable in the current build. + CipherUnavailable, +} + +impl fmt::Display for SqlcipherError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sqlite(err) => write!(f, "{err}"), + Self::CipherUnavailable => write!(f, "sqlcipher not available"), + } + } +} + +impl From for SqlcipherError { + fn from(err: rusqlite::Error) -> Self { + Self::Sqlite(err) + } +} + +/// Result type for `SQLCipher` helper operations. +pub type SqlcipherResult = Result; + +/// Opens a `SQLite` connection with consistent flags. +pub(super) fn open_connection(path: &Path) -> SqlcipherResult { + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_FULL_MUTEX; + Ok(Connection::open_with_flags(path, flags)?) +} + +/// Applies `SQLCipher` keying and validates cipher availability. +pub(super) fn apply_key( + conn: &Connection, + k_intermediate: [u8; 32], +) -> SqlcipherResult<()> { + let key_hex = hex::encode(k_intermediate); + let pragma = format!("PRAGMA key = \"x'{key_hex}'\";"); + conn.execute_batch(&pragma)?; + let cipher_version: String = + conn.query_row("PRAGMA cipher_version;", [], |row| row.get(0))?; + if cipher_version.trim().is_empty() { + return Err(SqlcipherError::CipherUnavailable); + } + Ok(()) +} + +/// Configures durable WAL settings. +pub(super) fn configure_connection(conn: &Connection) -> SqlcipherResult<()> { + conn.execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = FULL;", + )?; + Ok(()) +} + +/// Runs an integrity check. +pub(super) fn integrity_check(conn: &Connection) -> SqlcipherResult { + let result: String = + conn.query_row("PRAGMA integrity_check;", [], |row| row.get(0))?; + Ok(result.trim() == "ok") +} diff --git a/walletkit-core/src/storage/tests_utils.rs b/walletkit-core/src/storage/tests_utils.rs new file mode 100644 index 000000000..865b510cf --- /dev/null +++ b/walletkit-core/src/storage/tests_utils.rs @@ -0,0 +1,161 @@ +//! Test helpers for credential storage. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + Key, XChaCha20Poly1305, XNonce, +}; +use rand::{rngs::OsRng, RngCore}; + +use std::path::Path; + +use super::{ + error::StorageError, + paths::StoragePaths, + traits::{DeviceKeystore, StorageProvider}, + AtomicBlobStore, +}; + +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) + } +} diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs new file mode 100644 index 000000000..6069b9854 --- /dev/null +++ b/walletkit-core/src/storage/traits.rs @@ -0,0 +1,70 @@ +//! Platform interfaces for credential storage. + +use std::sync::Arc; + +use super::error::StorageResult; +use super::paths::StoragePaths; + +/// Device keystore interface used to seal and open account keys. +#[uniffi::export(with_foreign)] +pub trait DeviceKeystore: Send + Sync { + /// Seals plaintext under the device-bound key, binding `associated_data`. + /// + /// # 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`. + /// + /// # 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`). +#[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. +#[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..4af5c5ec0 --- /dev/null +++ b/walletkit-core/src/storage/types.rs @@ -0,0 +1,180 @@ +//! Public types for credential storage. + +use super::error::{StorageError, StorageResult}; + +/// Status of a stored credential. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +#[repr(u8)] +pub enum CredentialStatus { + /// Credential is active and can be used. + Active = 1, + /// Credential has been revoked. + Revoked = 2, + /// Credential has expired. + Expired = 3, +} + +impl CredentialStatus { + pub(crate) const fn as_i64(self) -> i64 { + self as i64 + } +} + +impl TryFrom for CredentialStatus { + type Error = StorageError; + + fn try_from(value: i64) -> StorageResult { + match value { + 1 => Ok(Self::Active), + 2 => Ok(Self::Revoked), + 3 => Ok(Self::Expired), + _ => Err(StorageError::VaultDb(format!( + "invalid credential status {value}" + ))), + } + } +} + +/// Kind of blob stored in the vault. +#[derive(Debug, Clone, Copy, PartialEq, Eq, 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]; + +/// Credential identifier. +pub type CredentialId = [u8; 16]; + +/// Request identifier for proof disclosure. +pub type RequestId = [u8; 32]; + +/// Nullifier identifier used for replay safety. +pub type Nullifier = [u8; 32]; + +/// In-memory representation of a stored credential. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialRecord { + /// Credential identifier. + pub credential_id: CredentialId, + /// Issuer schema identifier. + pub issuer_schema_id: u64, + /// Current credential status. + pub status: CredentialStatus, + /// Subject blinding factor tied to the credential subject. + pub subject_blinding_factor: [u8; 32], + /// Genesis issuance timestamp (seconds). + pub genesis_issued_at: u64, + /// Optional expiry timestamp (seconds). + pub expires_at: Option, + /// Last updated timestamp (seconds). + pub updated_at: u64, + /// Raw credential blob bytes. + pub credential_blob: Vec, + /// Optional associated data blob bytes. + pub associated_data: Option>, +} + +/// FFI-friendly credential record. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct CredentialRecordFfi { + /// Credential identifier. + pub credential_id: Vec, + /// Issuer schema identifier. + pub issuer_schema_id: u64, + /// Current credential status. + pub status: CredentialStatus, + /// Subject blinding factor tied to the credential subject. + pub subject_blinding_factor: Vec, + /// Genesis issuance timestamp (seconds). + pub genesis_issued_at: u64, + /// Optional expiry timestamp (seconds). + pub expires_at: Option, + /// Last updated timestamp (seconds). + pub updated_at: u64, + /// Raw credential blob bytes. + pub credential_blob: Vec, + /// Optional associated data blob bytes. + pub associated_data: Option>, +} + +/// Result of proof disclosure enforcement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProofDisclosureResult { + /// Stored bytes for the first disclosure of a request. + Fresh(Vec), + /// Stored bytes replayed for an existing request. + Replay(Vec), +} + +/// FFI-friendly proof disclosure result kind. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum ProofDisclosureKind { + /// Stored bytes for the first disclosure of a request. + Fresh, + /// Stored bytes replayed for an existing request. + Replay, +} + +/// FFI-friendly proof disclosure result. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct ProofDisclosureResultFfi { + /// Result kind. + pub kind: ProofDisclosureKind, + /// Stored proof package bytes. + pub bytes: Vec, +} + +impl From for CredentialRecordFfi { + fn from(record: CredentialRecord) -> Self { + Self { + credential_id: record.credential_id.to_vec(), + issuer_schema_id: record.issuer_schema_id, + status: record.status, + subject_blinding_factor: record.subject_blinding_factor.to_vec(), + genesis_issued_at: record.genesis_issued_at, + expires_at: record.expires_at, + updated_at: record.updated_at, + credential_blob: record.credential_blob, + associated_data: record.associated_data, + } + } +} + +impl From for ProofDisclosureResultFfi { + fn from(result: ProofDisclosureResult) -> Self { + match result { + ProofDisclosureResult::Fresh(bytes) => Self { + kind: ProofDisclosureKind::Fresh, + bytes, + }, + ProofDisclosureResult::Replay(bytes) => Self { + kind: ProofDisclosureKind::Replay, + bytes, + }, + } + } +} diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs new file mode 100644 index 000000000..d4426624f --- /dev/null +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -0,0 +1,92 @@ +use rusqlite::Row; +use sha2::{Digest, Sha256}; + +use crate::storage::error::{StorageError, StorageResult}; +use crate::storage::sqlcipher::SqlcipherError; +use crate::storage::types::{BlobKind, ContentId, CredentialRecord, CredentialStatus}; + +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_bytes: Vec = row.get(0).map_err(|err| map_db_err(&err))?; + let issuer_schema_id: i64 = row.get(1).map_err(|err| map_db_err(&err))?; + let status_raw: i64 = row.get(2).map_err(|err| map_db_err(&err))?; + let subject_blinding_factor_bytes: Vec = + row.get(3).map_err(|err| map_db_err(&err))?; + let genesis_issued_at: i64 = row.get(4).map_err(|err| map_db_err(&err))?; + let expires_at: Option = row.get(5).map_err(|err| map_db_err(&err))?; + let updated_at: i64 = row.get(6).map_err(|err| map_db_err(&err))?; + let credential_blob: Vec = row.get(7).map_err(|err| map_db_err(&err))?; + let associated_data: Option> = + row.get(8).map_err(|err| map_db_err(&err))?; + + let credential_id = parse_fixed_bytes::<16>(&credential_id_bytes, "credential_id")?; + let subject_blinding_factor = parse_fixed_bytes::<32>( + &subject_blinding_factor_bytes, + "subject_blinding_factor", + )?; + let status = CredentialStatus::try_from(status_raw)?; + + Ok(CredentialRecord { + credential_id, + issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, + status, + subject_blinding_factor, + genesis_issued_at: to_u64(genesis_issued_at, "genesis_issued_at")?, + expires_at: expires_at + .map(|value| to_u64(value, "expires_at")) + .transpose()?, + updated_at: to_u64(updated_at, "updated_at")?, + credential_blob, + associated_data, + }) +} + +pub(super) fn parse_fixed_bytes( + bytes: &[u8], + label: &str, +) -> StorageResult<[u8; N]> { + if bytes.len() != N { + return Err(StorageError::VaultDb(format!( + "{label} length mismatch: expected {N}, got {}", + bytes.len() + ))); + } + let mut out = [0u8; N]; + out.copy_from_slice(bytes); + Ok(out) +} + +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: &rusqlite::Error) -> StorageError { + StorageError::VaultDb(err.to_string()) +} + +pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { + match err { + SqlcipherError::Sqlite(err) => StorageError::VaultDb(err.to_string()), + SqlcipherError::CipherUnavailable => 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..904c8ccc8 --- /dev/null +++ b/walletkit-core/src/storage/vault/mod.rs @@ -0,0 +1,284 @@ +//! Encrypted vault database for credential storage. + +mod helpers; +mod schema; +#[cfg(test)] +mod tests; + +use std::path::Path; + +use rusqlite::{params, Connection, OptionalExtension}; +use uuid::Uuid; + +use super::error::{StorageError, StorageResult}; +use super::lock::StorageLockGuard; +use super::sqlcipher; +use super::types::{BlobKind, CredentialId, CredentialRecord, CredentialStatus}; +use helpers::{ + compute_content_id, map_db_err, map_record, map_sqlcipher_err, to_i64, to_u64, +}; +use schema::{ensure_schema, VAULT_SCHEMA_VERSION}; + +/// 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: [u8; 32], + _lock: &StorageLockGuard, + ) -> StorageResult { + let conn = sqlcipher::open_connection(path).map_err(map_sqlcipher_err)?; + sqlcipher::apply_key(&conn, k_intermediate).map_err(map_sqlcipher_err)?; + sqlcipher::configure_connection(&conn).map_err(map_sqlcipher_err)?; + 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. + /// + /// # 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 existing = tx + .query_row("SELECT leaf_index FROM vault_meta LIMIT 1", [], |row| { + row.get::<_, Option>(0) + }) + .optional() + .map_err(|err| map_db_err(&err))?; + match existing { + None => { + tx.execute( + "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + VAULT_SCHEMA_VERSION, + leaf_index_i64, + now_i64, + now_i64 + ], + ) + .map_err(|err| map_db_err(&err))?; + } + Some(None) => { + tx.execute( + "UPDATE vault_meta SET leaf_index = ?1, updated_at = ?2", + params![leaf_index_i64, now_i64], + ) + .map_err(|err| map_db_err(&err))?; + } + Some(Some(stored)) => { + if stored != leaf_index_i64 { + let expected = to_u64(stored, "leaf_index")?; + return Err(StorageError::InvalidLeafIndex { + expected, + provided: leaf_index, + }); + } + tx.execute("UPDATE vault_meta SET updated_at = ?1", params![now_i64]) + .map_err(|err| map_db_err(&err))?; + } + } + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) + } + + /// Stores a credential and optional associated data. + /// + /// # 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, + status: CredentialStatus, + subject_blinding_factor: [u8; 32], + genesis_issued_at: u64, + expires_at: Option, + credential_blob: Vec, + associated_data: Option>, + now: u64, + ) -> StorageResult { + let credential_id = *Uuid::new_v4().as_bytes(); + 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 = expires_at + .map(|value| to_i64(value, "expires_at")) + .transpose()?; + + 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 + ], + ) + .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 + ], + ) + .map_err(|err| map_db_err(&err))?; + } + + tx.execute( + "INSERT INTO credential_records ( + credential_id, + issuer_schema_id, + subject_blinding_factor, + genesis_issued_at, + expires_at, + status, + updated_at, + credential_blob_cid, + associated_data_cid + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + credential_id.as_ref(), + issuer_schema_id_i64, + subject_blinding_factor.as_ref(), + genesis_issued_at_i64, + expires_at_i64, + status.as_i64(), + now_i64, + credential_blob_id.as_ref(), + associated_data_id.as_ref().map(AsRef::as_ref) + ], + ) + .map_err(|err| map_db_err(&err))?; + + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(credential_id) + } + + /// Lists active credentials, 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 mut records = Vec::new(); + let status = CredentialStatus::Active.as_i64(); + let expires = to_i64(now, "now")?; + if let Some(issuer_schema_id) = issuer_schema_id { + let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?; + let mut stmt = self + .conn + .prepare( + "SELECT + cr.credential_id, + cr.issuer_schema_id, + cr.status, + cr.subject_blinding_factor, + cr.genesis_issued_at, + cr.expires_at, + cr.updated_at, + cb.bytes, + ad.bytes + FROM credential_records cr + JOIN blob_objects cb ON cb.content_id = cr.credential_blob_cid + LEFT JOIN blob_objects ad ON ad.content_id = cr.associated_data_cid + WHERE cr.status = ?1 + AND (cr.expires_at IS NULL OR cr.expires_at > ?2) + AND cr.issuer_schema_id = ?3 + ORDER BY cr.updated_at DESC", + ) + .map_err(|err| map_db_err(&err))?; + let mut rows = stmt + .query(params![status, expires, issuer_schema_id_i64]) + .map_err(|err| map_db_err(&err))?; + while let Some(row) = rows.next().map_err(|err| map_db_err(&err))? { + records.push(map_record(row)?); + } + } else { + let mut stmt = self + .conn + .prepare( + "SELECT + cr.credential_id, + cr.issuer_schema_id, + cr.status, + cr.subject_blinding_factor, + cr.genesis_issued_at, + cr.expires_at, + cr.updated_at, + cb.bytes, + ad.bytes + FROM credential_records cr + JOIN blob_objects cb ON cb.content_id = cr.credential_blob_cid + LEFT JOIN blob_objects ad ON ad.content_id = cr.associated_data_cid + WHERE cr.status = ?1 + AND (cr.expires_at IS NULL OR cr.expires_at > ?2) + ORDER BY cr.updated_at DESC", + ) + .map_err(|err| map_db_err(&err))?; + let mut rows = stmt + .query(params![status, expires]) + .map_err(|err| map_db_err(&err))?; + while let Some(row) = rows.next().map_err(|err| map_db_err(&err))? { + records.push(map_record(row)?); + } + } + Ok(records) + } + + /// Runs an integrity check on the vault database. + /// + /// # Errors + /// + /// Returns an error if the check cannot be executed. + pub fn check_integrity(&self) -> StorageResult { + sqlcipher::integrity_check(&self.conn).map_err(map_sqlcipher_err) + } +} diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs new file mode 100644 index 000000000..61e699d0d --- /dev/null +++ b/walletkit-core/src/storage/vault/schema.rs @@ -0,0 +1,47 @@ +use rusqlite::Connection; + +use crate::storage::error::StorageResult; + +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 TABLE IF NOT EXISTS credential_records ( + credential_id BLOB NOT NULL, + issuer_schema_id INTEGER NOT NULL, + subject_blinding_factor BLOB NOT NULL, + genesis_issued_at INTEGER NOT NULL, + expires_at INTEGER, + status INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + credential_blob_cid BLOB NOT NULL, + associated_data_cid BLOB, + PRIMARY KEY (credential_id) + ); + + CREATE INDEX IF NOT EXISTS idx_cred_by_issuer_schema + ON credential_records (issuer_schema_id, status, updated_at DESC); + + CREATE INDEX IF NOT EXISTS idx_cred_by_expiry + ON credential_records (status, 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..5648a09fd --- /dev/null +++ b/walletkit-core/src/storage/vault/tests.rs @@ -0,0 +1,296 @@ +use super::helpers::{compute_content_id, map_db_err}; +use super::*; +use crate::storage::lock::StorageLock; +use std::fs; +use std::path::{Path, PathBuf}; + +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() -> [u8; 32] { + [0x11u8; 32] +} + +#[test] +fn test_vault_create_and_open() { + let path = temp_vault_path(); + let key = [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 = [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 err = VaultDb::new(&path, [0x02u8; 32], &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 mut db = VaultDb::new(&path, [0x03u8; 32], &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 mut db = VaultDb::new(&path, [0x04u8; 32], &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 mut db = VaultDb::new(&path, [0x05u8; 32], &guard).expect("create vault"); + let credential_id = db + .store_credential( + &guard, + 10, + CredentialStatus::Active, + sample_blinding_factor(), + 123, + None, + 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!(records[0].associated_data.is_none()); + 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 mut db = VaultDb::new(&path, [0x06u8; 32], &guard).expect("create vault"); + db.store_credential( + &guard, + 11, + CredentialStatus::Active, + sample_blinding_factor(), + 456, + None, + 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].associated_data.as_deref(), + Some(b"associated".as_slice()) + ); + 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 mut db = VaultDb::new(&path, [0x07u8; 32], &guard).expect("create vault"); + db.store_credential( + &guard, + 12, + CredentialStatus::Active, + sample_blinding_factor(), + 1, + None, + b"same".to_vec(), + None, + 1000, + ) + .expect("store credential"); + db.store_credential( + &guard, + 12, + CredentialStatus::Active, + sample_blinding_factor(), + 1, + None, + b"same".to_vec(), + None, + 1001, + ) + .expect("store credential"); + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM blob_objects", [], |row| row.get(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 mut db = VaultDb::new(&path, [0x08u8; 32], &guard).expect("create vault"); + db.store_credential( + &guard, + 100, + CredentialStatus::Active, + sample_blinding_factor(), + 1, + None, + b"issuer-a".to_vec(), + None, + 1000, + ) + .expect("store credential"); + db.store_credential( + &guard, + 200, + CredentialStatus::Active, + sample_blinding_factor(), + 1, + None, + 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 mut db = VaultDb::new(&path, [0x09u8; 32], &guard).expect("create vault"); + db.store_credential( + &guard, + 300, + CredentialStatus::Active, + sample_blinding_factor(), + 1, + Some(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 db = VaultDb::new(&path, [0x0Au8; 32], &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 = [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/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs new file mode 100644 index 000000000..519b1f9fd --- /dev/null +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -0,0 +1,256 @@ +use std::collections::HashMap; +use std::fs; +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, CredentialStatus, CredentialStorage, CredentialStore, + DeviceKeystore, ProofDisclosureResult, StoragePaths, StorageProvider, +}; + +struct InMemoryKeystore { + key: [u8; 32], +} + +impl InMemoryKeystore { + fn new() -> Self { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + Self { key } + } +} + +impl DeviceKeystore for InMemoryKeystore { + fn seal( + &self, + associated_data: Vec, + plaintext: Vec, + ) -> Result, walletkit_core::storage::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| { + walletkit_core::storage::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, walletkit_core::storage::StorageError> { + if ciphertext.len() < 24 { + return Err(walletkit_core::storage::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| { + walletkit_core::storage::StorageError::Crypto(err.to_string()) + }) + } +} + +struct InMemoryBlobStore { + blobs: Mutex>>, +} + +impl InMemoryBlobStore { + fn new() -> Self { + Self { + blobs: Mutex::new(HashMap::new()), + } + } +} + +impl AtomicBlobStore for InMemoryBlobStore { + fn read( + &self, + path: String, + ) -> Result>, walletkit_core::storage::StorageError> { + let guard = self.blobs.lock().map_err(|_| { + walletkit_core::storage::StorageError::BlobStore( + "mutex poisoned".to_string(), + ) + })?; + Ok(guard.get(&path).cloned()) + } + + fn write_atomic( + &self, + path: String, + bytes: Vec, + ) -> Result<(), walletkit_core::storage::StorageError> { + self.blobs + .lock() + .map_err(|_| { + walletkit_core::storage::StorageError::BlobStore( + "mutex poisoned".to_string(), + ) + })? + .insert(path, bytes); + Ok(()) + } + + fn delete( + &self, + path: String, + ) -> Result<(), walletkit_core::storage::StorageError> { + self.blobs + .lock() + .map_err(|_| { + walletkit_core::storage::StorageError::BlobStore( + "mutex poisoned".to_string(), + ) + })? + .remove(&path); + Ok(()) + } +} + +struct InMemoryStorageProvider { + keystore: Arc, + blob_store: Arc, + paths: Arc, +} + +impl InMemoryStorageProvider { + 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) + } +} + +fn temp_root() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-storage-{}", Uuid::new_v4())); + path +} + +fn cleanup_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()); +} + +#[test] +fn test_storage_flow_end_to_end() { + let root = temp_root(); + let provider = InMemoryStorageProvider::new(&root); + let mut store = CredentialStore::from_provider(&provider).expect("store"); + + store.init(42, 100).expect("init"); + + let credential_id = CredentialStorage::store_credential( + &mut store, + 7, + CredentialStatus::Active, + [0x11u8; 32], + 1_700_000_000, + Some(1_800_000_000), + vec![1, 2, 3], + Some(vec![4, 5, 6]), + 100, + ) + .expect("store credential"); + + let records = CredentialStorage::list_credentials(&store, 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.subject_blinding_factor, [0x11u8; 32]); + assert_eq!(record.credential_blob, vec![1, 2, 3]); + assert_eq!(record.associated_data.as_deref(), Some(&[4, 5, 6][..])); + + let root_bytes = [0xAAu8; 32]; + CredentialStorage::merkle_cache_put(&mut store, 1, root_bytes, vec![9, 9], 100, 10) + .expect("cache put"); + let hit = CredentialStorage::merkle_cache_get(&store, 1, root_bytes, 105) + .expect("cache get"); + assert_eq!(hit, Some(vec![9, 9])); + let miss = CredentialStorage::merkle_cache_get(&store, 1, root_bytes, 111) + .expect("cache get"); + assert!(miss.is_none()); + + let request_id = [0xABu8; 32]; + let nullifier = [0xCDu8; 32]; + let fresh = CredentialStorage::begin_proof_disclosure( + &mut store, + request_id, + nullifier, + vec![1, 2], + 200, + 50, + ) + .expect("disclose"); + assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![1, 2])); + let replay = CredentialStorage::begin_proof_disclosure( + &mut store, + request_id, + nullifier, + vec![9, 9], + 201, + 50, + ) + .expect("replay"); + assert_eq!(replay, ProofDisclosureResult::Replay(vec![1, 2])); + + cleanup_storage(&root); +} diff --git a/walletkit-core/tests/solidity.rs b/walletkit-core/tests/solidity.rs index 6a4afccff..2e54c37fb 100644 --- a/walletkit-core/tests/solidity.rs +++ b/walletkit-core/tests/solidity.rs @@ -1,15 +1,19 @@ +#[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 +68,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 +90,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. From bb529664a4f1a1cb8a47ce6a507590a9a6e2d3f2 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:15:21 -0800 Subject: [PATCH 3/7] swift and kotlin credential storage tests --- kotlin/settings.gradle.kts | 8 +- .../storage/AndroidAtomicBlobStore.kt | 57 +++ .../storage/AndroidDeviceKeystore.kt | 91 +++++ .../storage/AndroidStorageProvider.kt | 38 ++ .../world/walletkit/AtomicBlobStoreTests.kt | 25 ++ .../world/walletkit/CredentialStoreTests.kt | 79 ++++ .../world/walletkit/DeviceKeystoreTests.kt | 30 ++ .../kotlin/org/world/walletkit/SimpleTest.kt | 13 - .../kotlin/org/world/walletkit/TestHelpers.kt | 115 ++++++ swift/support/IOSAtomicBlobStore.swift | 48 +++ swift/support/IOSDeviceKeystore.swift | 131 ++++++ swift/support/IOSStorageProvider.swift | 59 +++ swift/test_swift.sh | 30 +- swift/tests/Package.swift | 12 +- .../WalletKitTests/AtomicBlobStoreTests.swift | 23 ++ .../WalletKitTests/AuthenticatorTests.swift | 386 ++++++++++++++++++ .../WalletKitTests/CredentialStoreTests.swift | 86 ++++ .../WalletKitTests/DeviceKeystoreTests.swift | 49 +++ swift/tests/WalletKitTests/SimpleTest.swift | 8 - swift/tests/WalletKitTests/TestHelpers.swift | 22 + 20 files changed, 1261 insertions(+), 49 deletions(-) create mode 100644 kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt create mode 100644 kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt create mode 100644 kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/AtomicBlobStoreTests.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt delete mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt create mode 100644 swift/support/IOSAtomicBlobStore.swift create mode 100644 swift/support/IOSDeviceKeystore.swift create mode 100644 swift/support/IOSStorageProvider.swift create mode 100644 swift/tests/WalletKitTests/AtomicBlobStoreTests.swift create mode 100644 swift/tests/WalletKitTests/AuthenticatorTests.swift create mode 100644 swift/tests/WalletKitTests/CredentialStoreTests.swift create mode 100644 swift/tests/WalletKitTests/DeviceKeystoreTests.swift delete mode 100644 swift/tests/WalletKitTests/SimpleTest.swift create mode 100644 swift/tests/WalletKitTests/TestHelpers.swift diff --git a/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts index 73668b1b0..df283f526 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,9 @@ dependencyResolutionManagement { } rootProject.name = "walletkit" -include("lib") + +// The actual library +include(":walletkit-android") + +// "Foreign Tests" (i.e. unit tests which are not bundled in the library) +include(":walletkit-tests") diff --git a/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt b/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt new file mode 100644 index 000000000..2b3a30405 --- /dev/null +++ b/kotlin/walletkit-android/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-android/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt b/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt new file mode 100644 index 000000000..98f6a8e8b --- /dev/null +++ b/kotlin/walletkit-android/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-android/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt b/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt new file mode 100644 index 000000000..1b0a8abf6 --- /dev/null +++ b/kotlin/walletkit-android/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.StoragePaths +import uniffi.walletkit_core.StorageProvider +import uniffi.walletkit_core.StorageException + +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/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..96147c3e6 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt @@ -0,0 +1,79 @@ +package org.world.walletkit + +import uniffi.walletkit_core.CredentialStatus +import uniffi.walletkit_core.CredentialStore +import uniffi.walletkit_core.ProofDisclosureKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class CredentialStoreTests { + @Test + fun storeAndCacheFlows() { + val root = tempDirectory() + val provider = InMemoryStorageProvider(root) + val store = CredentialStore.fromProviderArc(provider) + + store.`init`(leafIndex = 42UL, now = 100UL) + + val credentialId = + store.storeCredential( + issuerSchemaId = 7UL, + status = CredentialStatus.ACTIVE, + subjectBlindingFactor = ByteArray(32) { 0x11.toByte() }, + genesisIssuedAt = 1_700_000_000UL, + expiresAt = 1_800_000_000UL, + credentialBlob = byteArrayOf(1, 2, 3), + associatedData = byteArrayOf(4, 5, 6), + now = 100UL, + ) + assertEquals(16, credentialId.size) + + val records = store.listCredentials(issuerSchemaId = null, now = 101UL) + assertEquals(1, records.size) + val record = records[0] + assertEquals(7UL, record.issuerSchemaId) + assertEquals(32, record.subjectBlindingFactor.size) + + val rootHash = ByteArray(32) { 0x22.toByte() } + val proofBytes = byteArrayOf(9, 9, 9) + store.merkleCachePut( + registryKind = 1u.toUByte(), + root = rootHash, + proofBytes = proofBytes, + now = 100UL, + ttlSeconds = 60UL, + ) + val cached = + store.merkleCacheGet( + registryKind = 1u.toUByte(), + root = rootHash, + now = 110UL, + ) + assertEquals(proofBytes.toList(), cached?.toList()) + + val requestId = ByteArray(32) { 0x01.toByte() } + val nullifier = ByteArray(32) { 0x02.toByte() } + val first = + store.beginProofDisclosure( + requestId = requestId, + nullifier = nullifier, + proofBytes = byteArrayOf(7, 7), + now = 120UL, + ttlSeconds = 60UL, + ) + assertEquals(ProofDisclosureKind.FRESH, first.kind) + + val replay = + store.beginProofDisclosure( + requestId = requestId, + nullifier = nullifier, + proofBytes = byteArrayOf(8, 8), + now = 130UL, + ttlSeconds = 60UL, + ) + assertEquals(ProofDisclosureKind.REPLAY, replay.kind) + + 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..75d0aeb47 --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt @@ -0,0 +1,30 @@ +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) + } + } +} 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 deleted file mode 100644 index f0efbfac9..000000000 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.world.walletkit - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class SimpleTests { - @Test - fun simpleTest() { - assertEquals(1, 1) - } -} 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..c5c19585e --- /dev/null +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt @@ -0,0 +1,115 @@ +package org.world.walletkit + +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 +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 + +fun tempDirectory(): File { + val dir = File(System.getProperty("java.io.tmpdir"), "walletkit-tests-${UUID.randomUUID()}") + dir.mkdirs() + return dir +} + +class InMemoryDeviceKeystore : DeviceKeystore { + private val keyBytes = ByteArray(32).also { SecureRandom().nextBytes(it) } + + 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, +) : StorageProvider { + private val keystore = InMemoryDeviceKeystore() + private val blobStore = FileBlobStore(File(root, "worldid")) + private val paths = StoragePaths.fromRoot(root.absolutePath) + + override fun keystore(): DeviceKeystore = keystore + + override fun blobStore(): AtomicBlobStore = blobStore + + override fun paths(): StoragePaths = paths +} 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..40053c1a6 100755 --- a/swift/test_swift.sh +++ b/swift/test_swift.sh @@ -26,12 +26,11 @@ TESTS_PATH="$BASE_PATH/tests" 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 +# Run the build_swift.sh script +bash "$BASE_PATH/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 @@ -41,20 +40,13 @@ echo -e "${BLUE}๐Ÿ“ฆ Step 2: Copying generated Swift files to test package${NC}" # Ensure the destination directory exists 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" - echo -e "${GREEN}โœ… walletkit.swift copied to test package${NC}" +# Copy the generated Swift file + supporting Swift sources to the test package +if [ -f "$BASE_PATH/$SOURCES_PATH_NAME/walletkit.swift" ]; then + cp "$BASE_PATH/$SOURCES_PATH_NAME/walletkit.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" + rsync -a "$BASE_PATH/$SOURCES_PATH_NAME"/*.swift "$TESTS_PATH/$SOURCES_PATH_NAME"/ + echo -e "${GREEN}โœ… Swift bindings 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_PATH_NAME/walletkit.swift${NC}" exit 1 fi @@ -120,7 +112,6 @@ TEST_SUITES_FAILED=0 if [ -f test_output.log ]; then echo "โœ… Test results found in: test_output.log" - # Count test cases - ensure we get valid integers TOTAL_TESTS=$(grep -c "Test Case.*started" test_output.log 2>/dev/null || echo "0") TOTAL_TESTS=${TOTAL_TESTS%%[^0-9]*} # Remove any non-numeric characters @@ -133,7 +124,6 @@ if [ -f test_output.log ]; then FAILED_TESTS=$(grep -c "Test Case.*failed" test_output.log 2>/dev/null || echo "0") FAILED_TESTS=${FAILED_TESTS%%[^0-9]*} FAILED_TESTS=${FAILED_TESTS:-0} - # Count test suites - ensure we get valid integers TEST_SUITES_PASSED=$(grep -c "Test Suite.*passed" test_output.log 2>/dev/null || echo "0") TEST_SUITES_PASSED=${TEST_SUITES_PASSED%%[^0-9]*} @@ -142,12 +132,10 @@ if [ -f test_output.log ]; then TEST_SUITES_FAILED=$(grep -c "Test Suite.*failed" test_output.log 2>/dev/null || echo "0") TEST_SUITES_FAILED=${TEST_SUITES_FAILED%%[^0-9]*} TEST_SUITES_FAILED=${TEST_SUITES_FAILED:-0} - echo "๐Ÿ“‹ Total test cases: $TOTAL_TESTS" echo "โœ… Tests passed: $PASSED_TESTS" echo "โŒ Tests failed: $FAILED_TESTS" echo "โš ๏ธ Test errors: 0" - if [ "$TEST_SUITES_FAILED" -gt 0 ]; then echo "๐Ÿ“ฆ Test suites failed: $TEST_SUITES_FAILED" fi diff --git a/swift/tests/Package.swift b/swift/tests/Package.swift index 419c6b7ec..410aea844 100644 --- a/swift/tests/Package.swift +++ b/swift/tests/Package.swift @@ -1,12 +1,12 @@ // 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: "WalletKitForeignTestPackage", platforms: [ - .iOS(.v13), - .macOS(.v12), + .iOS(.v13) ], products: [ .library( @@ -16,17 +16,17 @@ let package = Package( targets: [ .target( name: "WalletKit", - dependencies: ["WalletKitFFI"], + dependencies: ["walletkit_coreFFI"], path: "Sources/WalletKit" ), .binaryTarget( - name: "WalletKitFFI", - path: "../../WalletKit.xcframework" + name: "walletkit_coreFFI", + 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..5e7848708 --- /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 = IOSAtomicBlobStore(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 new file mode 100644 index 000000000..3fa1f8194 --- /dev/null +++ b/swift/tests/WalletKitTests/AuthenticatorTests.swift @@ -0,0 +1,386 @@ +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 index in 0..<32 { + bytes[index] = 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..4a65fddc4 --- /dev/null +++ b/swift/tests/WalletKitTests/CredentialStoreTests.swift @@ -0,0 +1,86 @@ +import Foundation +import XCTest +@testable import WalletKit + +final class CredentialStoreTests: XCTestCase { + private let account = "test-account" + + func testStoreAndCacheFlows() throws { + let root = makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let service = uniqueKeystoreService() + defer { deleteKeychainItem(service: service, account: account) } + + let keystore = IOSDeviceKeystore(service: service, account: account) + let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) + let blobStore = IOSAtomicBlobStore(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) + + let credentialId = try store.storeCredential( + issuerSchemaId: 7, + status: .active, + subjectBlindingFactor: Data(repeating: 0x11, count: 32), + genesisIssuedAt: 1_700_000_000, + expiresAt: 1_800_000_000, + credentialBlob: Data([1, 2, 3]), + associatedData: Data([4, 5, 6]), + now: 100 + ) + + XCTAssertEqual(credentialId.count, 16) + + let records = try store.listCredentials(issuerSchemaId: nil, now: 101) + XCTAssertEqual(records.count, 1) + let record = records[0] + XCTAssertEqual(record.issuerSchemaId, 7) + XCTAssertEqual(record.credentialId, credentialId) + XCTAssertEqual(record.subjectBlindingFactor.count, 32) + + let rootHash = Data(repeating: 0x22, count: 32) + let proofBytes = Data([9, 9, 9]) + try store.merkleCachePut( + registryKind: 1, + root: rootHash, + proofBytes: proofBytes, + now: 100, + ttlSeconds: 60 + ) + let cached = try store.merkleCacheGet( + registryKind: 1, + root: rootHash, + now: 110 + ) + XCTAssertEqual(cached, proofBytes) + + let requestId = Data(repeating: 0x01, count: 32) + let nullifier = Data(repeating: 0x02, count: 32) + let first = try store.beginProofDisclosure( + requestId: requestId, + nullifier: nullifier, + proofBytes: Data([7, 7]), + now: 120, + ttlSeconds: 60 + ) + XCTAssertEqual(first.kind, .fresh) + XCTAssertEqual(first.bytes, Data([7, 7])) + + let replay = try store.beginProofDisclosure( + requestId: requestId, + nullifier: nullifier, + proofBytes: Data([8, 8]), + now: 130, + ttlSeconds: 60 + ) + XCTAssertEqual(replay.kind, .replay) + XCTAssertEqual(replay.bytes, Data([7, 7])) + } +} diff --git a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift new file mode 100644 index 000000000..c70fffa7d --- /dev/null +++ b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift @@ -0,0 +1,49 @@ +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 = IOSDeviceKeystore(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 = IOSDeviceKeystore(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 + ) + ) + } +} diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/SimpleTest.swift deleted file mode 100644 index fb30acd50..000000000 --- a/swift/tests/WalletKitTests/SimpleTest.swift +++ /dev/null @@ -1,8 +0,0 @@ -import XCTest -@testable import WalletKit - -final class SimpleTest: XCTestCase { - func simpleTest() { - XCTAssertEqual(1, 1) - } -} diff --git a/swift/tests/WalletKitTests/TestHelpers.swift b/swift/tests/WalletKitTests/TestHelpers.swift new file mode 100644 index 000000000..83aa8e817 --- /dev/null +++ b/swift/tests/WalletKitTests/TestHelpers.swift @@ -0,0 +1,22 @@ +import Foundation +import Security + +func makeTempDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "walletkit-tests-\(UUID().uuidString)", + isDirectory: true + ) +} + +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) +} From a73f73af44f84fb81ebff5e847a1b1dad71a5462 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 11:07:40 -0800 Subject: [PATCH 4/7] align with main --- .github/workflows/ci.yml | 47 +- .github/workflows/initiate-release.yml | 2 +- .github/workflows/release.yml | 34 +- .gitignore | 4 +- AGENTS.md | 5 + CONTRIBUTING.md | 4 +- Cargo.lock | 2573 ++++++++++------- Cargo.toml | 27 +- Cross.toml | 19 +- README.md | 88 +- archive_swift.sh | 95 - audits/2026-01-cure53.pdf | Bin 0 -> 130133 bytes build_swift.sh | 63 - clippy.toml | 1 - deny.toml | 7 +- kotlin/README.md | 3 +- kotlin/build.sh | 26 +- .../build_android_local.sh | 10 +- kotlin/build_kotlin.sh | 5 +- kotlin/lib/build.gradle.kts | 2 +- kotlin/settings.gradle.kts | 8 +- kotlin/walletkit-tests/build.gradle.kts | 2 +- kotlin/walletkit/.gitignore | 2 + .../build.gradle.kts | 22 +- .../consumer-rules.pro | 0 kotlin/walletkit/src/main/jniLibs/.gitignore | 2 + swift/build_swift.sh | 22 +- swift/patch_uniffi_swift_vtables.py | 118 + swift/test_swift.sh | 20 +- uniffi-bindgen/Cargo.toml | 5 +- uniffi-bindgen/src/uniffi_bindgen.rs | 2 + walletkit-core/Cargo.toml | 77 +- walletkit-core/src/authenticator.rs | 307 -- walletkit-core/src/authenticator/mod.rs | 571 ++++ walletkit-core/src/authenticator/storage.rs | 208 -- .../src/authenticator/with_storage.rs | 131 + walletkit-core/src/credential.rs | 87 + walletkit-core/src/defaults.rs | 72 +- walletkit-core/src/error.rs | 80 +- walletkit-core/src/field_element.rs | 175 ++ walletkit-core/src/http_request.rs | 171 ++ walletkit-core/src/issuers/mod.rs | 5 + walletkit-core/src/issuers/tfh_nfc.rs | 173 ++ walletkit-core/src/lib.rs | 58 +- walletkit-core/src/logger.rs | 368 ++- walletkit-core/src/merkle_tree.rs | 4 +- walletkit-core/src/proof.rs | 29 +- walletkit-core/src/request.rs | 101 - walletkit-core/src/requests.rs | 107 + .../src/storage/cache/maintenance.rs | 52 +- walletkit-core/src/storage/cache/merkle.rs | 87 +- walletkit-core/src/storage/cache/mod.rs | 227 +- .../src/storage/cache/nullifiers.rs | 120 +- walletkit-core/src/storage/cache/schema.rs | 118 +- walletkit-core/src/storage/cache/session.rs | 75 +- walletkit-core/src/storage/cache/util.rs | 212 +- .../src/storage/credential_storage.rs | 658 +++-- walletkit-core/src/storage/envelope.rs | 11 +- walletkit-core/src/storage/error.rs | 11 +- walletkit-core/src/storage/groth16_cache.rs | 154 + walletkit-core/src/storage/keys.rs | 19 +- walletkit-core/src/storage/lock.rs | 415 +-- walletkit-core/src/storage/mod.rs | 10 +- walletkit-core/src/storage/paths.rs | 125 +- walletkit-core/src/storage/sqlcipher.rs | 74 - walletkit-core/src/storage/tests_utils.rs | 25 + walletkit-core/src/storage/traits.rs | 28 +- walletkit-core/src/storage/types.rs | 145 +- walletkit-core/src/storage/vault/helpers.rs | 67 +- walletkit-core/src/storage/vault/mod.rs | 292 +- walletkit-core/src/storage/vault/schema.rs | 27 +- walletkit-core/src/storage/vault/tests.rs | 87 +- walletkit-core/src/world_id.rs | 121 - .../tests/authenticator_integration.rs | 29 +- walletkit-core/tests/common.rs | 214 ++ .../tests/credential_storage_integration.rs | 272 +- .../tests/proof_generation_integration.rs | 308 ++ walletkit-core/tests/solidity.rs | 2 + walletkit-db/Cargo.toml | 34 + walletkit-db/build.rs | 147 + walletkit-db/src/cipher.rs | 126 + walletkit-db/src/connection.rs | 182 ++ walletkit-db/src/error.rs | 43 + walletkit-db/src/ffi.rs | 617 ++++ walletkit-db/src/lib.rs | 32 + walletkit-db/src/statement.rs | 129 + walletkit-db/src/tests.rs | 154 + walletkit-db/src/transaction.rs | 100 + walletkit-db/src/value.rs | 55 + walletkit/Cargo.toml | 10 +- walletkit/src/lib.rs | 7 - 91 files changed, 7649 insertions(+), 3914 deletions(-) create mode 100644 AGENTS.md delete mode 100755 archive_swift.sh create mode 100644 audits/2026-01-cure53.pdf delete mode 100755 build_swift.sh delete mode 100644 clippy.toml rename build_android_local.sh => kotlin/build_android_local.sh (74%) create mode 100644 kotlin/walletkit/.gitignore rename kotlin/{walletkit-android => walletkit}/build.gradle.kts (66%) rename kotlin/{walletkit-android => walletkit}/consumer-rules.pro (100%) create mode 100644 kotlin/walletkit/src/main/jniLibs/.gitignore create mode 100644 swift/patch_uniffi_swift_vtables.py delete mode 100644 walletkit-core/src/authenticator.rs create mode 100644 walletkit-core/src/authenticator/mod.rs delete mode 100644 walletkit-core/src/authenticator/storage.rs create mode 100644 walletkit-core/src/authenticator/with_storage.rs create mode 100644 walletkit-core/src/credential.rs create mode 100644 walletkit-core/src/field_element.rs create mode 100644 walletkit-core/src/http_request.rs create mode 100644 walletkit-core/src/issuers/mod.rs create mode 100644 walletkit-core/src/issuers/tfh_nfc.rs delete mode 100644 walletkit-core/src/request.rs create mode 100644 walletkit-core/src/requests.rs create mode 100644 walletkit-core/src/storage/groth16_cache.rs delete mode 100644 walletkit-core/src/storage/sqlcipher.rs create mode 100644 walletkit-core/tests/common.rs create mode 100644 walletkit-core/tests/proof_generation_integration.rs create mode 100644 walletkit-db/Cargo.toml create mode 100644 walletkit-db/build.rs create mode 100644 walletkit-db/src/cipher.rs create mode 100644 walletkit-db/src/connection.rs create mode 100644 walletkit-db/src/error.rs create mode 100644 walletkit-db/src/ffi.rs create mode 100644 walletkit-db/src/lib.rs create mode 100644 walletkit-db/src/statement.rs create mode 100644 walletkit-db/src/tests.rs create mode 100644 walletkit-db/src/transaction.rs create mode 100644 walletkit-db/src/value.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24699114d..64e929a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,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 @@ -30,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 @@ -44,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 @@ -58,7 +67,9 @@ 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 @@ -70,21 +81,24 @@ jobs: kotlin-build-and-test: name: Kotlin Build & Foreign Binding Tests - runs-on: ubuntu-latest + 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@v4 + uses: actions/checkout@v6 - name: Set up Rust uses: dtolnay/rust-toolchain@master with: toolchain: 1.92.0 - - name: Build and test Kotlin bindings - run: ./kotlin/test_kotlin.sh + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 - name: Install ktlint run: | @@ -92,13 +106,16 @@ jobs: 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 @@ -111,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 @@ -125,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: | @@ -147,7 +166,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - uses: EmbarkStudios/cargo-deny-action@v2 with: @@ -162,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 70dcc3f26..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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a83a28a85..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,7 +67,9 @@ jobs: targets: aarch64-apple-ios-sim,aarch64-apple-ios,x86_64-apple-ios components: rustfmt - - name: Build the project (iOS) + # 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 @@ -75,7 +77,7 @@ jobs: 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 }} @@ -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/walletkit-android/src/main/jniLibs && cd kotlin/walletkit-android/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 ./walletkit-android/src/main/jniLibs/arm64-v8a/libwalletkit.so --library --language kotlin --no-format --out-dir walletkit-android/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 walletkit-android:publish + run: ./gradlew walletkit:publish env: GITHUB_ACTOR: wld-walletkit-bot GITHUB_TOKEN: ${{ github.token }} @@ -261,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 @@ -272,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 4f74359d5..7a07856f4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,12 +18,10 @@ swift/ios_build/ swift/local_build/ swift/tests/Sources/ swift/tests/.build/ - # Kotlin bindings and native libs kotlin/libs/ -kotlin/walletkit-android/src/main/java/uniffi/ +kotlin/walletkit/src/main/java/uniffi/ kotlin/walletkit-tests/build/ - .build/ .env 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 0fd39960b..0d1d9bb34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ 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 = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -29,21 +29,21 @@ dependencies = [ [[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", ] @@ -71,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", @@ -91,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", @@ -102,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", @@ -124,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", @@ -143,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", @@ -160,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", @@ -178,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", @@ -202,7 +202,7 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -227,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", @@ -250,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", @@ -268,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", @@ -281,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", @@ -293,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", @@ -329,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", @@ -347,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", @@ -362,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", @@ -426,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", @@ -438,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", @@ -449,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", @@ -470,7 +483,7 @@ dependencies = [ "alloy-transport-http", "futures", "pin-project", - "reqwest 0.12.22", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -483,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", @@ -495,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", @@ -506,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", @@ -522,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", @@ -538,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", @@ -548,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", @@ -564,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", @@ -614,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", @@ -630,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", @@ -642,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", @@ -655,7 +668,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower", "tracing", @@ -665,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", @@ -680,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", @@ -691,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]] @@ -717,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" @@ -800,7 +824,7 @@ checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -834,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", @@ -930,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]] @@ -968,7 +992,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -1029,7 +1053,7 @@ dependencies = [ "ark-std 0.5.0", "educe", "fnv", - "hashbrown 0.15.3", + "hashbrown 0.15.5", "rayon", ] @@ -1129,7 +1153,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -1205,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", @@ -1218,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", @@ -1231,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", @@ -1243,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", @@ -1260,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", @@ -1277,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", ] @@ -1299,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", @@ -1312,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", ] @@ -1342,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]] @@ -1379,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", @@ -1400,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", @@ -1423,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", @@ -1433,7 +1466,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -1456,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" @@ -1495,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", @@ -1517,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" @@ -1544,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]] @@ -1566,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", @@ -1596,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", @@ -1622,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" @@ -1634,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" @@ -1646,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", @@ -1670,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]] @@ -1694,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", @@ -1714,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" @@ -1750,16 +1784,16 @@ dependencies = [ [[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]] @@ -1802,15 +1836,14 @@ dependencies = [ [[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", @@ -1825,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", @@ -1835,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", @@ -1846,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" @@ -1873,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", @@ -1917,11 +1953,11 @@ 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.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -1934,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]] @@ -1955,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", ] @@ -1975,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" @@ -2006,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", ] @@ -2019,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" @@ -2052,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" @@ -2070,9 +2139,9 @@ 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", @@ -2080,74 +2149,28 @@ dependencies = [ ] [[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]] @@ -2156,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]] @@ -2186,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]] @@ -2206,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]] @@ -2243,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]] @@ -2264,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 = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73f2692d4bd3cac41dca28934a39894200c9fabf49586d77d0e5954af1d7902" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +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", ] @@ -2323,7 +2334,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2340,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" @@ -2368,7 +2379,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2430,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]] @@ -2461,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.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2479,18 +2490,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "2.3.0" @@ -2529,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" @@ -2548,11 +2558,27 @@ dependencies = [ ] [[package]] -name = "fnv" +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" @@ -2561,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", ] @@ -2645,7 +2671,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -2697,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" @@ -2770,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", @@ -2780,7 +2819,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.9.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2789,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", @@ -2808,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]] @@ -2845,38 +2885,28 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] [[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", -] - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", + "serde_core", ] [[package]] @@ -2901,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", ] @@ -2960,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", ] @@ -2987,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]] @@ -2998,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", ] @@ -3025,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", @@ -3041,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", @@ -3076,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", @@ -3118,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", @@ -3142,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", @@ -3165,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" @@ -3266,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", @@ -3277,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", @@ -3302,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" @@ -3324,13 +3319,14 @@ 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]] @@ -3342,17 +3338,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -3361,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", @@ -3398,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" @@ -3408,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", @@ -3439,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", @@ -3463,64 +3448,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "libc" -version = "0.2.172" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libm" -version = "0.2.15" +name = "libc" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] -name = "libsqlite3-sys" -version = "0.30.1" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] +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" @@ -3528,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]] @@ -3539,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", ] @@ -3554,7 +3535,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -3568,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", ] @@ -3616,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]] @@ -3653,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", @@ -3704,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]] @@ -3720,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" @@ -3745,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", @@ -3755,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", @@ -3789,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", ] @@ -3810,15 +3794,15 @@ 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", @@ -3832,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", @@ -3854,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]] @@ -3873,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", ] @@ -3905,7 +3888,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -3955,9 +3938,9 @@ dependencies = [ [[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", @@ -3966,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" @@ -3978,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]] @@ -3994,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", ] @@ -4020,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", @@ -4060,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", @@ -4070,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", @@ -4080,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", @@ -4101,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.59.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" @@ -4148,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", ] @@ -4174,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]] @@ -4183,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", @@ -4217,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", @@ -4227,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", @@ -4248,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", @@ -4259,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" @@ -4274,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", @@ -4306,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", @@ -4343,7 +4380,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.0", + "webpki-roots 1.0.6", ] [[package]] @@ -4364,7 +4401,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -4380,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" @@ -4401,7 +4448,7 @@ dependencies = [ "primitive-types", "proptest", "rand 0.8.5", - "rand 0.9.1", + "rand 0.9.2", "rlp", "ruint-macro", "serde_core", @@ -4415,25 +4462,11 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags 2.9.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[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" @@ -4462,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.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4492,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", ] @@ -4517,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", @@ -4537,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", @@ -4549,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", @@ -4567,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" @@ -4580,6 +4613,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" @@ -4588,9 +4645,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scratch" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" [[package]] name = "scroll" @@ -4609,7 +4666,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -4767,7 +4824,7 @@ dependencies = [ "proc-macro2", "quote", "semaphore-rs-depth-config", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -4812,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", @@ -4906,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]] @@ -4949,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]] @@ -4978,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", @@ -4996,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]] @@ -5050,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", @@ -5083,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" @@ -5091,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", ] @@ -5121,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", @@ -5131,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]] @@ -5158,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" @@ -5187,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]] @@ -5217,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", @@ -5228,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]] @@ -5261,7 +5344,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -5270,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", @@ -5313,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", @@ -5356,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", @@ -5376,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", @@ -5400,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", @@ -5413,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", @@ -5461,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", @@ -5475,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", @@ -5533,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.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5575,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]] @@ -5590,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]] @@ -5625,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", @@ -5665,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", @@ -5675,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", ] @@ -5690,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]] @@ -5730,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", @@ -5752,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", @@ -5771,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", @@ -5794,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", @@ -5835,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", @@ -5865,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", @@ -5876,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", @@ -5902,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]] @@ -5927,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", @@ -5957,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" @@ -6000,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" @@ -6018,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", @@ -6035,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", @@ -6068,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", @@ -6079,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", @@ -6092,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", @@ -6115,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", @@ -6134,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", @@ -6175,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]] @@ -6191,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" @@ -6205,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", ] @@ -6221,12 +6330,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -6254,7 +6357,7 @@ dependencies = [ [[package]] name = "walletkit" -version = "0.3.3" +version = "0.7.2" dependencies = [ "uniffi", "walletkit-core", @@ -6262,25 +6365,28 @@ dependencies = [ [[package]] name = "walletkit-core" -version = "0.3.3" +version = "0.7.2" dependencies = [ "alloy", "alloy-core", "alloy-primitives", - "bincode", + "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", - "rusqlite", - "rustls 0.23.27", + "rustls 0.23.37", "secrecy", "semaphore-rs", "serde", @@ -6288,12 +6394,31 @@ dependencies = [ "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]] @@ -6307,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", @@ -6361,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", @@ -6371,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", @@ -6407,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", @@ -6437,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", ] @@ -6460,17 +6616,17 @@ 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.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -6484,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]] @@ -6567,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]] @@ -6598,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" @@ -6617,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" @@ -6629,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" @@ -6641,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" @@ -6659,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" @@ -6671,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" @@ -6683,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" @@ -6695,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", ] @@ -6715,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" @@ -6816,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", @@ -6830,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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" -dependencies = [ - "zerocopy-derive 0.8.25", -] - -[[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 = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.114", ] [[package]] @@ -6897,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", @@ -6934,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/archive_swift.sh b/archive_swift.sh deleted file mode 100755 index 53e0e506f..000000000 --- a/archive_swift.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/bin/bash -set -e - -# Creates the dynamic Package.swift file for release. -# Usage: ./archive_swift.sh --asset-url --checksum --release-version - -# Initialize variables -ASSET_URL="" -CHECKSUM="" -RELEASE_VERSION="" - -# Function to show usage -show_usage() { - echo "โŒ Error: Missing required arguments" - echo "Usage: $0 --asset-url --checksum --release-version " - echo "" - echo "Example:" - echo " $0 --asset-url 'https://github.com/user/repo/releases/download/v1.0.0/WalletKit.xcframework.zip' --checksum 'abc123def456...' --release-version '1.0.0'" - exit 1 -} - -# Parse command line arguments -while [[ $# -gt 0 ]]; do - case $1 in - --asset-url) - ASSET_URL="$2" - shift 2 - ;; - --checksum) - CHECKSUM="$2" - shift 2 - ;; - --release-version) - RELEASE_VERSION="$2" - shift 2 - ;; - -h|--help) - show_usage - ;; - *) - echo "โŒ Unknown argument: $1" - show_usage - ;; - esac -done - -# Check if all required arguments are provided -if [ -z "$ASSET_URL" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_VERSION" ]; then - echo "โŒ Error: All arguments are required" - show_usage -fi - -echo "๐Ÿ”ง Creating Package.swift with:" -echo " Asset URL: $ASSET_URL" -echo " Checksum: $CHECKSUM" -echo " Release Version: $RELEASE_VERSION" -echo "" - -cat > Package.swift << EOF -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -// Release version: $RELEASE_VERSION - -import PackageDescription - -let package = Package( - name: "WalletKit", - platforms: [ - .iOS(.v13) - ], - products: [ - .library( - name: "WalletKit", - targets: ["WalletKit"]), - ], - targets: [ - .target( - name: "WalletKit", - dependencies: ["walletkit_FFI"], - path: "Sources/WalletKit" - ), - .binaryTarget( - name: "walletkit_FFI", - url: "$ASSET_URL", - checksum: "$CHECKSUM" - ) - ] -) -EOF - -swiftlint lint --autocorrect Package.swift - -echo "" -echo "โœ… Package.swift built successfully for version $RELEASE_VERSION!" \ No newline at end of file diff --git a/audits/2026-01-cure53.pdf b/audits/2026-01-cure53.pdf new file mode 100644 index 0000000000000000000000000000000000000000..39fbb3435dd22cd2ed1b9292ecde6e48e92bcef3 GIT binary patch literal 130133 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 TATC@$#097MG;f}io^1MmXOzN0 literal 0 HcmV?d00001 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 index 2373f1d6c..adf15f084 100644 --- a/kotlin/README.md +++ b/kotlin/README.md @@ -22,5 +22,6 @@ This folder contains support files for WalletKit to work in Kotlin: ## Kotlin project structure The Kotlin project has two members: -- `walletkit-android`: The main WalletKit library with UniFFI bindings for Kotlin. + +- `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 index 73c19dcdd..cfd8c3a90 100755 --- a/kotlin/build_kotlin.sh +++ b/kotlin/build_kotlin.sh @@ -6,15 +6,16 @@ set -euo pipefail PROJECT_ROOT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" KOTLIN_DIR="$PROJECT_ROOT_PATH/kotlin" -JAVA_SRC_DIR="$KOTLIN_DIR/walletkit-android/src/main/java" +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 +cargo build --package walletkit --release --features "$CARGO_FEATURES" # Determine the correct library file extension and copy it if [[ "$OSTYPE" == "darwin"* ]]; then 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 df283f526..e3860c3bb 100644 --- a/kotlin/settings.gradle.kts +++ b/kotlin/settings.gradle.kts @@ -26,9 +26,5 @@ dependencyResolutionManagement { } rootProject.name = "walletkit" - -// The actual library -include(":walletkit-android") - -// "Foreign Tests" (i.e. unit tests which are not bundled in the library) -include(":walletkit-tests") +include("walletkit") +include("walletkit-tests") diff --git a/kotlin/walletkit-tests/build.gradle.kts b/kotlin/walletkit-tests/build.gradle.kts index d3a5f433c..06c9fce69 100644 --- a/kotlin/walletkit-tests/build.gradle.kts +++ b/kotlin/walletkit-tests/build.gradle.kts @@ -19,7 +19,7 @@ dependencies { sourceSets { test { kotlin.srcDirs( - "$rootDir/walletkit-android/src/main/java/uniffi/walletkit_core" + "$rootDir/walletkit/src/main/java/uniffi/walletkit_core" ) } } 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-android/build.gradle.kts b/kotlin/walletkit/build.gradle.kts similarity index 66% rename from kotlin/walletkit-android/build.gradle.kts rename to kotlin/walletkit/build.gradle.kts index e6f047448..bf8e92db6 100644 --- a/kotlin/walletkit-android/build.gradle.kts +++ b/kotlin/walletkit/build.gradle.kts @@ -42,14 +42,18 @@ afterEvaluate { publications { create("maven") { groupId = "org.world" - artifactId = "walletkit-android" + artifactId = "walletkit" - // Read version from Cargo.toml - val cargoToml = file("../../Cargo.toml") - val versionRegex = """version\s*=\s*"([^"]+)"""".toRegex() - val cargoContent = cargoToml.readText() - version = versionRegex.find(cargoContent)?.groupValues?.get(1) - ?: throw GradleException("Could not find version in Cargo.toml") + // 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"]) @@ -71,8 +75,8 @@ afterEvaluate { } dependencies { - // UniFFI requires JNA for native calls - implementation("net.java.dev.jna:jna:5.13.0") + // 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") diff --git a/kotlin/walletkit-android/consumer-rules.pro b/kotlin/walletkit/consumer-rules.pro similarity index 100% rename from kotlin/walletkit-android/consumer-rules.pro rename to kotlin/walletkit/consumer-rules.pro 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/swift/build_swift.sh b/swift/build_swift.sh index 90d5c32ad..f661e0bfb 100755 --- a/swift/build_swift.sh +++ b/swift/build_swift.sh @@ -11,8 +11,8 @@ 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" -FEATURES="v4" SUPPORT_SOURCES_DIR="$BASE_PATH/support" +CARGO_FEATURES="compress-zkeys" # Default values OUTPUT_DIR="$BASE_PATH" # Default to BASE_PATH if not provided @@ -67,19 +67,18 @@ 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" +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 "$FEATURES" + --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 "$FEATURES" + --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 "$FEATURES" - + --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 @@ -104,6 +103,11 @@ cargo run -p uniffi-bindgen --manifest-path "$PROJECT_ROOT_PATH/Cargo.toml" \ # 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"/ 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/test_swift.sh b/swift/test_swift.sh index 40053c1a6..22b964137 100755 --- a/swift/test_swift.sh +++ b/swift/test_swift.sh @@ -26,8 +26,9 @@ TESTS_PATH="$BASE_PATH/tests" SOURCES_PATH_NAME="/Sources/WalletKit/" echo -e "${BLUE}๐Ÿ”จ Step 1: Building Swift bindings${NC}" -# Run the build_swift.sh script -bash "$BASE_PATH/build_swift.sh" +# 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 ./swift/build_swift.sh # Check if the XCFramework was created if [ ! -d "$BASE_PATH/WalletKit.xcframework" ]; then @@ -40,13 +41,12 @@ echo -e "${BLUE}๐Ÿ“ฆ Step 2: Copying generated Swift files to test package${NC}" # Ensure the destination directory exists mkdir -p "$TESTS_PATH/$SOURCES_PATH_NAME" -# Copy the generated Swift file + supporting Swift sources to the test package -if [ -f "$BASE_PATH/$SOURCES_PATH_NAME/walletkit.swift" ]; then - cp "$BASE_PATH/$SOURCES_PATH_NAME/walletkit.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" - rsync -a "$BASE_PATH/$SOURCES_PATH_NAME"/*.swift "$TESTS_PATH/$SOURCES_PATH_NAME"/ - echo -e "${GREEN}โœ… Swift bindings copied to test package${NC}" +# 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" + echo -e "${GREEN}โœ… walletkit.swift copied to test package${NC}" else - echo -e "${RED}โœ— Could not find generated Swift bindings at: $BASE_PATH/$SOURCES_PATH_NAME/walletkit.swift${NC}" + echo -e "${RED}โœ— Could not find generated Swift bindings at: $BASE_PATH/Sources/WalletKit/walletkit.swift${NC}" exit 1 fi @@ -112,6 +112,7 @@ TEST_SUITES_FAILED=0 if [ -f test_output.log ]; then echo "โœ… Test results found in: test_output.log" + # Count test cases - ensure we get valid integers TOTAL_TESTS=$(grep -c "Test Case.*started" test_output.log 2>/dev/null || echo "0") TOTAL_TESTS=${TOTAL_TESTS%%[^0-9]*} # Remove any non-numeric characters @@ -124,6 +125,7 @@ if [ -f test_output.log ]; then FAILED_TESTS=$(grep -c "Test Case.*failed" test_output.log 2>/dev/null || echo "0") FAILED_TESTS=${FAILED_TESTS%%[^0-9]*} FAILED_TESTS=${FAILED_TESTS:-0} + # Count test suites - ensure we get valid integers TEST_SUITES_PASSED=$(grep -c "Test Suite.*passed" test_output.log 2>/dev/null || echo "0") TEST_SUITES_PASSED=${TEST_SUITES_PASSED%%[^0-9]*} @@ -132,10 +134,12 @@ if [ -f test_output.log ]; then TEST_SUITES_FAILED=$(grep -c "Test Suite.*failed" test_output.log 2>/dev/null || echo "0") TEST_SUITES_FAILED=${TEST_SUITES_FAILED%%[^0-9]*} TEST_SUITES_FAILED=${TEST_SUITES_FAILED:-0} + echo "๐Ÿ“‹ Total test cases: $TOTAL_TESTS" echo "โœ… Tests passed: $PASSED_TESTS" echo "โŒ Tests failed: $FAILED_TESTS" echo "โš ๏ธ Test errors: 0" + if [ "$TEST_SUITES_FAILED" -gt 0 ]; then echo "๐Ÿ“ฆ Test suites failed: $TEST_SUITES_FAILED" fi 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 25662446d..1e139eb30 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -23,56 +23,95 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } alloy-primitives = { workspace = true } -bincode = "1.3" -chacha20poly1305 = "0.10" +backon = "1.6" +base64 = { version = "0.22", optional = true } +ctor = "0.2" hex = "0.4" -hkdf = "0.12" +hkdf = { version = "0.12", optional = true } log = "0.4" -rand = "0.8" +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 = "0.10" +sha2 = { version = "0.10", optional = true } strum = { version = "0.27", features = ["derive"] } subtle = "2" thiserror = "2" tokio = { version = "1", features = ["sync"] } -rusqlite = { version = "0.32", features = ["bundled-sqlcipher"] } -uuid = { version = "1.10", features = ["v4"] } +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 843136831..000000000 --- a/walletkit-core/src/authenticator.rs +++ /dev/null @@ -1,307 +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, -}; - -mod storage; - -/// 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/storage.rs b/walletkit-core/src/authenticator/storage.rs deleted file mode 100644 index 15d70297b..000000000 --- a/walletkit-core/src/authenticator/storage.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::convert::TryFrom; - -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 world_id_core::{requests::ProofRequest, Credential, FieldElement}; - -use crate::error::WalletKitError; -use crate::storage::{CredentialStorage, ProofDisclosureResult, RequestId}; - -use super::Authenticator; - -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, - storage: &mut dyn CredentialStorage, - now: u64, - ) -> Result<(), WalletKitError> { - let leaf_index = u64::try_from(self.leaf_index().0).map_err(|_| { - WalletKitError::InvalidInput { - attribute: "leaf_index".to_string(), - reason: "leaf index does not fit in u64".to_string(), - } - })?; - storage.init(leaf_index, now)?; - Ok(()) - } - - /// Fetches an inclusion proof, using the storage cache when possible. - /// - /// The cached payload uses `AccountInclusionProof` serialization and is keyed by - /// (`registry_kind`, `root`, `leaf_index`). - /// - /// # Errors - /// - /// Returns an error if fetching or caching the proof fails. - #[allow(clippy::future_not_send)] - pub async fn fetch_inclusion_proof_cached( - &self, - storage: &mut dyn CredentialStorage, - registry_kind: u8, - root: [u8; 32], - now: u64, - ttl_seconds: u64, - ) -> Result< - (MerkleInclusionProof, AuthenticatorPublicKeySet), - WalletKitError, - > { - if let Some(bytes) = storage.merkle_cache_get(registry_kind, root, now)? { - if let Some(cached) = deserialize_inclusion_proof(&bytes) { - return Ok((cached.proof, cached.authenticator_pubkeys)); - } - } - - let (proof, key_set) = self.0.fetch_inclusion_proof().await?; - let payload = CachedInclusionProof { - proof: proof.clone(), - authenticator_pubkeys: key_set.clone(), - }; - let payload_bytes = serialize_inclusion_proof(&payload)?; - let proof_root = field_element_to_bytes(proof.root); - storage.merkle_cache_put( - registry_kind, - proof_root, - payload_bytes, - now, - ttl_seconds, - )?; - Ok((proof, key_set)) - } - - /// Generates a proof and enforces replay safety via storage. - /// - /// # Errors - /// - /// Returns an error if the proof generation or storage update fails. - #[allow(clippy::too_many_arguments)] - #[allow(clippy::future_not_send)] - pub async fn generate_proof_with_disclosure( - &self, - storage: &mut dyn CredentialStorage, - proof_request: ProofRequest, - credential: Credential, - credential_sub_blinding_factor: FieldElement, - request_id: RequestId, - now: u64, - ttl_seconds: u64, - ) -> Result { - let (proof, nullifier) = self - .0 - .generate_proof(proof_request, credential, credential_sub_blinding_factor) - .await?; - let proof_bytes = serialize_proof_package(&proof, nullifier)?; - let nullifier_bytes = field_element_to_bytes(nullifier); - storage - .begin_proof_disclosure( - request_id, - nullifier_bytes, - proof_bytes, - now, - ttl_seconds, - ) - .map_err(WalletKitError::from) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CachedInclusionProof { - proof: MerkleInclusionProof, - authenticator_pubkeys: AuthenticatorPublicKeySet, -} - -fn serialize_inclusion_proof( - payload: &CachedInclusionProof, -) -> Result, WalletKitError> { - bincode::serialize(payload).map_err(|err| WalletKitError::SerializationError { - error: err.to_string(), - }) -} - -fn deserialize_inclusion_proof(bytes: &[u8]) -> Option { - bincode::deserialize(bytes).ok() -} - -fn field_element_to_bytes(value: FieldElement) -> [u8; 32] { - let value: ruint::aliases::U256 = value.into(); - value.to_be_bytes::<32>() -} -fn serialize_proof_package( - proof: &impl Serialize, - nullifier: FieldElement, -) -> Result, WalletKitError> { - bincode::serialize(&(proof, nullifier)).map_err(|err| { - WalletKitError::SerializationError { - error: err.to_string(), - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::storage::tests_utils::InMemoryStorageProvider; - use crate::storage::CredentialStore; - use std::fs; - use std::path::{Path, PathBuf}; - use uuid::Uuid; - - fn temp_root() -> PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!("walletkit-auth-storage-{}", Uuid::new_v4())); - path - } - - fn cleanup_storage(root: &Path) { - let paths = crate::storage::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()); - } - - #[test] - fn test_cached_inclusion_round_trip() { - let root = temp_root(); - 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 proof = MerkleInclusionProof::new(root_fe, 42, siblings); - let key_set = AuthenticatorPublicKeySet::new(None).expect("key set"); - let payload = CachedInclusionProof { - proof: proof.clone(), - authenticator_pubkeys: key_set, - }; - let payload_bytes = serialize_inclusion_proof(&payload).expect("serialize"); - let root_bytes = field_element_to_bytes(proof.root); - - store - .merkle_cache_put(1, root_bytes.to_vec(), payload_bytes, 100, 60) - .expect("cache put"); - let cached = store - .merkle_cache_get(1, root_bytes.to_vec(), 110) - .expect("cache get") - .expect("cache hit"); - let decoded = deserialize_inclusion_proof(&cached).expect("decode"); - assert_eq!(decoded.proof.leaf_index, 42); - assert_eq!(decoded.proof.root, root_fe); - assert_eq!(decoded.authenticator_pubkeys.len(), 0); - cleanup_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 3c86951b2..2141eaf69 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -1,7 +1,9 @@ use thiserror::Error; +use world_id_core::primitives::PrimitiveError; + +#[cfg(feature = "storage")] use crate::storage::StorageError; -#[cfg(feature = "v4")] use world_id_core::AuthenticatorError; /// Error outputs from `WalletKit` @@ -46,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, @@ -78,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 { @@ -100,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 { @@ -108,6 +164,7 @@ impl From for WalletKitError { } } +#[cfg(feature = "storage")] impl From for WalletKitError { fn from(error: StorageError) -> Self { Self::Generic { @@ -116,7 +173,6 @@ impl From for WalletKitError { } } -#[cfg(feature = "v4")] impl From for WalletKitError { fn from(error: AuthenticatorError) -> Self { match error { @@ -137,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 2bc4c0e2a..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,16 +70,23 @@ pub mod logger; mod u256; pub use u256::U256Wrapper; +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; -#[cfg(feature = "v4")] 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 @@ -74,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 index 8c215b763..cc7ad078e 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -3,22 +3,27 @@ use std::fs; use std::path::Path; -use rusqlite::Connection; - use crate::storage::error::StorageResult; -use crate::storage::sqlcipher; +use walletkit_db::cipher; +use walletkit_db::Connection; +use zeroize::Zeroizing; use super::schema; -use super::util::{map_io_err, map_sqlcipher_err}; +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: [u8; 32], + k_intermediate: &Zeroizing<[u8; 32]>, ) -> StorageResult { match open_prepared(path, k_intermediate) { Ok(conn) => { let integrity_ok = - sqlcipher::integrity_check(&conn).map_err(map_sqlcipher_err)?; + cipher::integrity_check(&conn).map_err(|e| map_db_err(&e))?; if integrity_ok { Ok(conn) } else { @@ -30,19 +35,39 @@ pub(super) fn open_or_rebuild( } } -fn open_prepared(path: &Path, k_intermediate: [u8; 32]) -> StorageResult { - let conn = sqlcipher::open_connection(path).map_err(map_sqlcipher_err)?; - sqlcipher::apply_key(&conn, k_intermediate).map_err(map_sqlcipher_err)?; - sqlcipher::configure_connection(&conn).map_err(map_sqlcipher_err)?; +/// 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) } -fn rebuild(path: &Path, k_intermediate: [u8; 32]) -> StorageResult { +/// 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"))?; @@ -50,6 +75,11 @@ fn delete_cache_files(path: &Path) -> StorageResult<()> { 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(()), diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index 5d49f2f0c..3fd75b679 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -1,83 +1,36 @@ //! Merkle proof cache helpers. -use rusqlite::{params, Connection, OptionalExtension}; +use crate::storage::{cache::util::CACHE_KEY_PREFIX_MERKLE, error::StorageResult}; +use walletkit_db::Connection; -use crate::storage::error::StorageResult; - -use super::util::{expiry_timestamp, map_db_err, to_i64}; +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, - registry_kind: u8, - root: [u8; 32], - leaf_index: u64, - now: u64, + valid_until: u64, ) -> StorageResult>> { - let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; - let now_i64 = to_i64(now, "now")?; - let proof = conn - .query_row( - "SELECT proof_bytes - FROM merkle_proof_cache - WHERE registry_kind = ?1 - AND root = ?2 - AND leaf_index = ?3 - AND expires_at > ?4", - params![ - i64::from(registry_kind), - root.as_ref(), - leaf_index_i64, - now_i64 - ], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; - Ok(proof) + 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, - registry_kind: u8, - root: [u8; 32], - leaf_index: u64, proof_bytes: &[u8], now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - prune_expired(conn, now)?; - let expires_at = expiry_timestamp(now, ttl_seconds); - let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; - let now_i64 = to_i64(now, "now")?; - let expires_at_i64 = to_i64(expires_at, "expires_at")?; - conn.execute( - "INSERT OR REPLACE INTO merkle_proof_cache ( - registry_kind, - root, - leaf_index, - proof_bytes, - inserted_at, - expires_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - i64::from(registry_kind), - root.as_ref(), - leaf_index_i64, - proof_bytes, - now_i64, - expires_at_i64 - ], - ) - .map_err(|err| map_db_err(&err))?; - Ok(()) -} - -fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { - let now_i64 = to_i64(now, "now")?; - conn.execute( - "DELETE FROM merkle_proof_cache WHERE expires_at <= ?1", - params![now_i64], - ) - .map_err(|err| map_db_err(&err))?; - Ok(()) + 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 index c4c5c983d..1b06a6d7e 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -2,11 +2,10 @@ use std::path::Path; -use rusqlite::Connection; - use crate::storage::error::StorageResult; use crate::storage::lock::StorageLockGuard; -use crate::storage::types::ProofDisclosureResult; +use walletkit_db::Connection; +use zeroize::Zeroizing; mod maintenance; mod merkle; @@ -16,6 +15,9 @@ 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, @@ -24,76 +26,70 @@ pub struct CacheDb { 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: [u8; 32], + 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 available. + /// 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, - registry_kind: u8, - root: [u8; 32], - leaf_index: u64, - now: u64, - ) -> StorageResult>> { - merkle::get(&self.conn, registry_kind, root, leaf_index, now) + 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::too_many_arguments)] #[allow(clippy::needless_pass_by_value)] pub fn merkle_cache_put( &mut self, _lock: &StorageLockGuard, - registry_kind: u8, - root: [u8; 32], - leaf_index: u64, proof_bytes: Vec, now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - merkle::put( - &self.conn, - registry_kind, - root, - leaf_index, - proof_bytes.as_ref(), - now, - ttl_seconds, - ) + 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], - now: u64, - ) -> StorageResult> { - session::get(&self.conn, rp_id, now) + 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. @@ -102,44 +98,50 @@ impl CacheDb { _lock: &StorageLockGuard, rp_id: [u8; 32], k_session: [u8; 32], - now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - session::put(&self.conn, rp_id, k_session, now, ttl_seconds) + session::put(&self.conn, rp_id, k_session, ttl_seconds) } - /// Enforces replay safety for proof disclosure. + /// 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 disclosure conflicts with an existing nullifier. - pub fn begin_proof_disclosure( + /// 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, - request_id: [u8; 32], nullifier: [u8; 32], - proof_bytes: Vec, now: u64, - ttl_seconds: u64, - ) -> StorageResult { - nullifiers::begin_proof_disclosure( - &mut self.conn, - request_id, - nullifier, - proof_bytes, - now, - ttl_seconds, - ) + ) -> StorageResult<()> { + nullifiers::replay_guard_set(&self.conn, nullifier, now) } } #[cfg(test)] mod tests { use super::*; - use crate::storage::error::StorageError; use crate::storage::lock::StorageLock; use std::fs; use std::path::PathBuf; + use std::time::Duration; use uuid::Uuid; fn temp_cache_path() -> PathBuf { @@ -169,13 +171,13 @@ mod tests { #[test] fn test_cache_create_and_open() { let path = temp_cache_path(); - let key = [0x11u8; 32]; + 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"); + let db = CacheDb::new(&path, &key, &guard).expect("create cache"); drop(db); - CacheDb::new(&path, key, &guard).expect("open cache"); + CacheDb::new(&path, &key, &guard).expect("open cache"); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } @@ -183,21 +185,21 @@ mod tests { #[test] fn test_cache_rebuild_on_corruption() { let path = temp_cache_path(); - let key = [0x22u8; 32]; + 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 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, 100, 1000) + 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, 200).expect("get session key"); + 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); @@ -206,21 +208,17 @@ mod tests { #[test] fn test_merkle_cache_ttl() { let path = temp_cache_path(); - let key = [0x33u8; 32]; + 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"); - let root = [0xABu8; 32]; - db.merkle_cache_put(&guard, 1, root, 42, vec![1, 2, 3], 100, 10) + 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 hit = db - .merkle_cache_get(1, root, 42, 105) - .expect("get 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(1, root, 42, 111) - .expect("get merkle proof"); + let miss = db.merkle_cache_get(111).expect("get merkle proof"); assert!(miss.is_none()); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); @@ -229,102 +227,21 @@ mod tests { #[test] fn test_session_cache_ttl() { let path = temp_cache_path(); - let key = [0x44u8; 32]; + 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 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, 100, 10) + db.session_key_put(&guard, rp_id, k_session, 1) .expect("put session key"); - let hit = db.session_key_get(rp_id, 105).expect("get session key"); + let hit = db.session_key_get(rp_id).expect("get session key"); assert!(hit.is_some()); - let miss = db.session_key_get(rp_id, 111).expect("get session key"); + 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); } - - #[test] - fn test_disclosure_replay_returns_original_bytes() { - let path = temp_cache_path(); - let key = [0x77u8; 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 request_id = [0x10u8; 32]; - let nullifier = [0x20u8; 32]; - let first = vec![1, 2, 3]; - let second = vec![9, 9, 9]; - - let fresh = db - .begin_proof_disclosure( - &guard, - request_id, - nullifier, - first.clone(), - 100, - 1000, - ) - .expect("first disclosure"); - assert_eq!(fresh, ProofDisclosureResult::Fresh(first.clone())); - - let replay = db - .begin_proof_disclosure(&guard, request_id, nullifier, second, 101, 1000) - .expect("replay disclosure"); - assert_eq!(replay, ProofDisclosureResult::Replay(first)); - cleanup_cache_files(&path); - cleanup_lock_file(&lock_path); - } - - #[test] - fn test_disclosure_nullifier_conflict_errors() { - let path = temp_cache_path(); - let key = [0x88u8; 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 request_id_a = [0x01u8; 32]; - let request_id_b = [0x02u8; 32]; - let nullifier = [0x03u8; 32]; - - db.begin_proof_disclosure(&guard, request_id_a, nullifier, vec![4], 100, 1000) - .expect("first disclosure"); - - let err = db - .begin_proof_disclosure(&guard, request_id_b, nullifier, vec![5], 101, 1000) - .expect_err("nullifier conflict"); - match err { - StorageError::NullifierAlreadyDisclosed => {} - other => panic!("unexpected error: {other}"), - } - cleanup_cache_files(&path); - cleanup_lock_file(&lock_path); - } - - #[test] - fn test_disclosure_expiry_allows_new_insert() { - let path = temp_cache_path(); - let key = [0x99u8; 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 request_id_a = [0x0Au8; 32]; - let request_id_b = [0x0Bu8; 32]; - let nullifier = [0x0Cu8; 32]; - - db.begin_proof_disclosure(&guard, request_id_a, nullifier, vec![7], 100, 10) - .expect("first disclosure"); - - let fresh = db - .begin_proof_disclosure(&guard, request_id_b, nullifier, vec![8], 111, 10) - .expect("second disclosure after expiry"); - assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![8])); - 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 index 0575fdf93..b5e6bc7fb 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -1,74 +1,74 @@ -//! Used-nullifier cache helpers (Phase 4 hooks). +//! 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 rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use crate::storage::error::StorageResult; +use walletkit_db::Connection; -use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::ProofDisclosureResult; +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, +}; -use super::util::{expiry_timestamp, map_db_err, to_i64}; +/// 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 -pub(super) fn begin_proof_disclosure( - conn: &mut Connection, - request_id: [u8; 32], +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], - proof_bytes: Vec, now: u64, - ttl_seconds: u64, -) -> StorageResult { - let now_i64 = to_i64(now, "now")?; +) -> StorageResult<()> { let tx = conn - .transaction_with_behavior(TransactionBehavior::Immediate) + .transaction_immediate() .map_err(|err| map_db_err(&err))?; - tx.execute( - "DELETE FROM used_nullifiers WHERE expires_at <= ?1", - params![now_i64], - ) - .map_err(|err| map_db_err(&err))?; + prune_expired_entries_tx(&tx, now)?; - let existing_proof: Option> = tx - .query_row( - "SELECT proof_bytes - FROM used_nullifiers - WHERE request_id = ?1 - AND expires_at > ?2", - params![request_id.as_ref(), now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; - if let Some(bytes) = existing_proof { - tx.commit().map_err(|err| map_db_err(&err))?; - return Ok(ProofDisclosureResult::Replay(bytes)); - } + let key = replay_nullifier_key(nullifier); - let existing_request: Option> = tx - .query_row( - "SELECT request_id - FROM used_nullifiers - WHERE nullifier = ?1 - AND expires_at > ?2", - params![nullifier.as_ref(), now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; - if existing_request.is_some() { - return Err(StorageError::NullifierAlreadyDisclosed); + // 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(()); } - let expires_at = expiry_timestamp(now, ttl_seconds); - let expires_at_i64 = to_i64(expires_at, "expires_at")?; - tx.execute( - "INSERT INTO used_nullifiers (request_id, nullifier, expires_at, proof_bytes) - VALUES (?1, ?2, ?3, ?4)", - params![ - request_id.as_ref(), - nullifier.as_ref(), - expires_at_i64, - proof_bytes - ], - ) - .map_err(|err| map_db_err(&err))?; + // 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(ProofDisclosureResult::Fresh(proof_bytes)) + Ok(()) } diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs index 65270983b..c12b2f517 100644 --- a/walletkit-core/src/storage/cache/schema.rs +++ b/walletkit-core/src/storage/cache/schema.rs @@ -1,69 +1,103 @@ //! Cache database schema management. -use rusqlite::Connection; - use crate::storage::error::StorageResult; +use walletkit_db::{params, Connection}; use super::util::map_db_err; -const CACHE_SCHEMA_VERSION: i64 = 1; +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))?; - CREATE TABLE IF NOT EXISTS used_nullifiers ( - request_id BLOB NOT NULL, - nullifier BLOB NOT NULL, - expires_at INTEGER NOT NULL, - proof_bytes BLOB NOT NULL, - PRIMARY KEY (request_id), - UNIQUE (nullifier) - ); + 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))?; - CREATE INDEX IF NOT EXISTS idx_used_nullifiers_expiry - ON used_nullifiers (expires_at); + 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(()) +} - CREATE TABLE IF NOT EXISTS merkle_proof_cache ( - registry_kind INTEGER NOT NULL, - root BLOB NOT NULL, - leaf_index INTEGER NOT NULL, - proof_bytes BLOB NOT NULL, +/// 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 (registry_kind, root, leaf_index) + PRIMARY KEY (key_bytes) ); - CREATE INDEX IF NOT EXISTS idx_merkle_proof_expiry - ON merkle_proof_cache (expires_at); - - CREATE TABLE IF NOT EXISTS session_keys ( - rp_id BLOB NOT NULL, - k_session BLOB NOT NULL, - expires_at INTEGER NOT NULL, - PRIMARY KEY (rp_id) - ); - - CREATE INDEX IF NOT EXISTS idx_session_keys_expiry - ON session_keys (expires_at);", + CREATE INDEX IF NOT EXISTS idx_cache_entries_expiry + ON cache_entries (expires_at);", ) .map_err(|err| map_db_err(&err))?; + Ok(()) +} - let existing: i64 = conn - .query_row("SELECT COUNT(*) FROM cache_meta;", [], |row| row.get(0)) - .map_err(|err| map_db_err(&err))?; - if existing == 0 { - conn.execute( - "INSERT INTO cache_meta (schema_version, created_at, updated_at) - VALUES (?1, strftime('%s','now'), strftime('%s','now'))", - [CACHE_SCHEMA_VERSION], - ) +/// 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 index f5f3f7e80..a9df20303 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -1,62 +1,61 @@ //! Session key cache helpers. -use rusqlite::{params, Connection, OptionalExtension}; +use std::time::{SystemTime, UNIX_EPOCH}; -use crate::storage::error::StorageResult; +use crate::storage::error::{StorageError, StorageResult}; +use walletkit_db::Connection; -use super::util::{expiry_timestamp, map_db_err, parse_fixed_bytes, to_i64}; +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], - now: u64, ) -> StorageResult> { - let now_i64 = to_i64(now, "now")?; - let raw: Option> = conn - .query_row( - "SELECT k_session - FROM session_keys - WHERE rp_id = ?1 - AND expires_at > ?2", - params![rp_id.as_ref(), now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; + 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], - now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - prune_expired(conn, now)?; - let expires_at = expiry_timestamp(now, ttl_seconds); - let expires_at_i64 = to_i64(expires_at, "expires_at")?; - conn.execute( - "INSERT OR REPLACE INTO session_keys ( - rp_id, - k_session, - expires_at - ) VALUES (?1, ?2, ?3)", - params![rp_id.as_ref(), k_session.as_ref(), expires_at_i64], - ) - .map_err(|err| map_db_err(&err))?; - Ok(()) + 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) } -fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { - let now_i64 = to_i64(now, "now")?; - conn.execute( - "DELETE FROM session_keys WHERE expires_at <= ?1", - params![now_i64], - ) - .map_err(|err| map_db_err(&err))?; - Ok(()) +/// 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 index 45fecbb1a..a5020aa8a 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -3,23 +3,23 @@ use std::io; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::sqlcipher::SqlcipherError; +use walletkit_db::{params, Connection, DbError, Transaction}; -pub(super) fn map_db_err(err: &rusqlite::Error) -> StorageError { +/// Maps a database error into a cache storage error. +pub(super) fn map_db_err(err: &DbError) -> StorageError { StorageError::CacheDb(err.to_string()) } -pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { - match err { - SqlcipherError::Sqlite(err) => StorageError::CacheDb(err.to_string()), - SqlcipherError::CipherUnavailable => 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, @@ -35,10 +35,204 @@ pub(super) fn parse_fixed_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 index a953f3d9c..54ffe8896 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -2,104 +2,30 @@ 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::{ - CredentialId, CredentialRecord, CredentialRecordFfi, CredentialStatus, Nullifier, - ProofDisclosureResult, ProofDisclosureResultFfi, RequestId, -}; +use super::types::CredentialRecord; use super::{CacheDb, VaultDb}; - -/// Public-facing storage API used by `WalletKit` v4 flows. -pub trait CredentialStorage { - /// Initializes storage and validates the account leaf index. - /// - /// # Errors - /// - /// Returns an error if storage initialization fails or the leaf index is invalid. - fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()>; - - /// Lists active credentials, optionally filtered by issuer schema ID. - /// - /// # Errors - /// - /// Returns an error if the credential query fails. - fn list_credentials( - &self, - issuer_schema_id: Option, - now: u64, - ) -> StorageResult>; - - /// Stores a credential and optional associated data. - /// - /// # Errors - /// - /// Returns an error if the credential cannot be stored. - #[allow(clippy::too_many_arguments)] - fn store_credential( - &mut self, - issuer_schema_id: u64, - status: CredentialStatus, - subject_blinding_factor: [u8; 32], - genesis_issued_at: u64, - expires_at: Option, - credential_blob: Vec, - associated_data: Option>, - now: u64, - ) -> StorageResult; - - /// Fetches a cached Merkle proof if available. - /// - /// # Errors - /// - /// Returns an error if the cache lookup fails. - fn merkle_cache_get( - &self, - registry_kind: u8, - root: [u8; 32], - now: u64, - ) -> StorageResult>>; - - /// Inserts a cached Merkle proof with a TTL. - /// - /// # Errors - /// - /// Returns an error if the cache insert fails. - fn merkle_cache_put( - &mut self, - registry_kind: u8, - root: [u8; 32], - proof_bytes: Vec, - now: u64, - ttl_seconds: u64, - ) -> StorageResult<()>; - - /// Enforces replay safety for proof disclosure. - /// - /// # Errors - /// - /// Returns an error if the nullifier is already disclosed or the cache - /// operation fails. - fn begin_proof_disclosure( - &mut self, - request_id: RequestId, - nullifier: Nullifier, - proof_bytes: Vec, - now: u64, - ttl_seconds: u64, - ) -> StorageResult; -} +use crate::{Credential, FieldElement}; /// Concrete storage implementation backed by `SQLCipher` databases. -#[derive(uniffi::Object)] +#[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, @@ -164,14 +90,14 @@ impl CredentialStoreInner { } } -#[uniffi::export] +#[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. - #[uniffi::constructor] + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] pub fn new_with_components( paths: Arc, keystore: Arc, @@ -189,7 +115,7 @@ impl CredentialStore { /// # Errors /// /// Returns an error if the storage lock cannot be opened. - #[uniffi::constructor] + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] #[allow(clippy::needless_pass_by_value)] pub fn from_provider_arc( provider: Arc, @@ -219,7 +145,7 @@ impl CredentialStore { inner.init(leaf_index, now) } - /// Lists active credentials, optionally filtered by issuer schema ID. + /// Lists active credential metadata, optionally filtered by issuer schema ID. /// /// # Errors /// @@ -228,9 +154,8 @@ impl CredentialStore { &self, issuer_schema_id: Option, now: u64, - ) -> StorageResult> { - let records = self.lock_inner()?.list_credentials(issuer_schema_id, now)?; - Ok(records.into_iter().map(CredentialRecordFfi::from).collect()) + ) -> StorageResult> { + self.lock_inner()?.list_credentials(issuer_schema_id, now) } /// Stores a credential and optional associated data. @@ -238,49 +163,30 @@ impl CredentialStore { /// # Errors /// /// Returns an error if the credential cannot be stored. - #[allow(clippy::too_many_arguments)] pub fn store_credential( &self, - issuer_schema_id: u64, - status: CredentialStatus, - subject_blinding_factor: Vec, - genesis_issued_at: u64, - expires_at: Option, - credential_blob: Vec, + credential: &Credential, + blinding_factor: &FieldElement, + expires_at: u64, associated_data: Option>, now: u64, - ) -> StorageResult> { - let subject_blinding_factor = parse_fixed_bytes::<32>( - subject_blinding_factor, - "subject_blinding_factor", - )?; - let credential_id = self.lock_inner()?.store_credential( - issuer_schema_id, - status, - subject_blinding_factor, - genesis_issued_at, + ) -> StorageResult { + self.lock_inner()?.store_credential( + credential, + blinding_factor, expires_at, - credential_blob, associated_data, now, - )?; - Ok(credential_id.to_vec()) + ) } - /// Fetches a cached Merkle proof if available. + /// 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, - registry_kind: u8, - root: Vec, - now: u64, - ) -> StorageResult>> { - let root = parse_fixed_bytes::<32>(root, "root")?; - self.lock_inner()? - .merkle_cache_get(registry_kind, root, now) + 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. @@ -290,71 +196,72 @@ impl CredentialStore { /// Returns an error if the cache insert fails. pub fn merkle_cache_put( &self, - registry_kind: u8, - root: Vec, proof_bytes: Vec, now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - let root = parse_fixed_bytes::<32>(root, "root")?; - self.lock_inner()?.merkle_cache_put( - registry_kind, - root, - proof_bytes, - now, - ttl_seconds, - ) + 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())) } - /// Enforces replay safety for proof disclosure. + /// 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 disclosure conflicts or storage fails. - pub fn begin_proof_disclosure( + /// Returns an error if the credential query fails. + pub fn get_credential( &self, - request_id: Vec, - nullifier: Vec, - proof_bytes: Vec, + issuer_schema_id: u64, now: u64, - ttl_seconds: u64, - ) -> StorageResult { - let request_id = parse_fixed_bytes::<32>(request_id, "request_id")?; - let nullifier = parse_fixed_bytes::<32>(nullifier, "nullifier")?; - let result = self.lock_inner()?.begin_proof_disclosure( - request_id, - nullifier, - proof_bytes, - now, - ttl_seconds, - )?; - Ok(ProofDisclosureResultFfi::from(result)) + ) -> StorageResult> { + self.lock_inner()?.get_credential(issuer_schema_id, now) } -} -fn parse_fixed_bytes( - bytes: Vec, - label: &str, -) -> StorageResult<[u8; N]> { - bytes.try_into().map_err(|bytes: Vec| { - StorageError::Serialization(format!( - "{label} length mismatch: expected {N}, got {}", - bytes.len() - )) - }) -} + /// 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) + } -impl CredentialStore { - fn lock_inner( + /// 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, - ) -> StorageResult> { - self.inner - .lock() - .map_err(|_| StorageError::Lock("storage mutex poisoned".to_string())) + nullifier: CoreFieldElement, + now: u64, + ) -> StorageResult<()> { + self.lock_inner()?.replay_guard_set(nullifier, now) } } -impl CredentialStorage for CredentialStoreInner { +impl CredentialStoreInner { fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()> { let guard = self.guard()?; if let Some(state) = &mut self.state { @@ -369,10 +276,9 @@ impl CredentialStorage for CredentialStoreInner { &guard, now, )?; - let vault = - VaultDb::new(&self.paths.vault_db_path(), keys.intermediate_key(), &guard)?; - let cache = - CacheDb::new(&self.paths.cache_db_path(), keys.intermediate_key(), &guard)?; + 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, @@ -393,23 +299,59 @@ impl CredentialStorage for CredentialStoreInner { 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, - issuer_schema_id: u64, - status: CredentialStatus, - subject_blinding_factor: [u8; 32], - genesis_issued_at: u64, - expires_at: Option, - credential_blob: Vec, + credential: &Credential, + blinding_factor: &FieldElement, + expires_at: u64, associated_data: Option>, now: u64, - ) -> StorageResult { + ) -> 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, - status, subject_blinding_factor, genesis_issued_at, expires_at, @@ -419,57 +361,57 @@ impl CredentialStorage for CredentialStoreInner { ) } - fn merkle_cache_get( - &self, - registry_kind: u8, - root: [u8; 32], - now: u64, - ) -> StorageResult>> { + fn merkle_cache_get(&self, valid_until: u64) -> StorageResult>> { let state = self.state()?; - state - .cache - .merkle_cache_get(registry_kind, root, state.leaf_index, now) + state.cache.merkle_cache_get(valid_until) } fn merkle_cache_put( &mut self, - registry_kind: u8, - root: [u8; 32], proof_bytes: Vec, now: u64, ttl_seconds: u64, ) -> StorageResult<()> { let guard = self.guard()?; let state = self.state_mut()?; - state.cache.merkle_cache_put( - &guard, - registry_kind, - root, - state.leaf_index, - proof_bytes, - now, - ttl_seconds, - ) + state + .cache + .merkle_cache_put(&guard, proof_bytes, now, ttl_seconds) } - fn begin_proof_disclosure( + /// 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, - request_id: RequestId, - nullifier: Nullifier, - proof_bytes: Vec, + nullifier: CoreFieldElement, now: u64, - ttl_seconds: u64, - ) -> StorageResult { + ) -> StorageResult<()> { let guard = self.guard()?; + let nullifier = nullifier.to_be_bytes(); let state = self.state_mut()?; - state.cache.begin_proof_disclosure( - &guard, - request_id, - nullifier, - proof_bytes, - now, - ttl_seconds, - ) + state.cache.replay_guard_set(&guard, nullifier, now) } } @@ -512,83 +454,229 @@ impl CredentialStore { } } -impl CredentialStorage for CredentialStore { - fn init(&mut self, leaf_index: u64, now: u64) -> StorageResult<()> { - let mut inner = self.lock_inner()?; - inner.init(leaf_index, now) +#[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); } - fn list_credentials( - &self, - issuer_schema_id: Option, - now: u64, - ) -> StorageResult> { - let inner = self.lock_inner()?; - inner.list_credentials(issuer_schema_id, now) - } - - #[allow(clippy::too_many_arguments)] - fn store_credential( - &mut self, - issuer_schema_id: u64, - status: CredentialStatus, - subject_blinding_factor: [u8; 32], - genesis_issued_at: u64, - expires_at: Option, - credential_blob: Vec, - associated_data: Option>, - now: u64, - ) -> StorageResult { - let mut inner = self.lock_inner()?; - inner.store_credential( - issuer_schema_id, - status, - subject_blinding_factor, - genesis_issued_at, - expires_at, - credential_blob, - associated_data, - now, - ) + #[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); } - fn merkle_cache_get( - &self, - registry_kind: u8, - root: [u8; 32], - now: u64, - ) -> StorageResult>> { - let inner = self.lock_inner()?; - inner.merkle_cache_get(registry_kind, root, now) + #[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); } - fn merkle_cache_put( - &mut self, - registry_kind: u8, - root: [u8; 32], - proof_bytes: Vec, - now: u64, - ttl_seconds: u64, - ) -> StorageResult<()> { - let mut inner = self.lock_inner()?; - inner.merkle_cache_put(registry_kind, root, proof_bytes, now, ttl_seconds) + #[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); } - fn begin_proof_disclosure( - &mut self, - request_id: RequestId, - nullifier: Nullifier, - proof_bytes: Vec, - now: u64, - ttl_seconds: u64, - ) -> StorageResult { - let mut inner = self.lock_inner()?; - inner.begin_proof_disclosure( - request_id, - nullifier, - proof_bytes, - now, - ttl_seconds, - ) + #[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 index d8d571758..abad8e190 100644 --- a/walletkit-core/src/storage/envelope.rs +++ b/walletkit-core/src/storage/envelope.rs @@ -1,12 +1,13 @@ //! 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)] +#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] pub(crate) struct AccountKeyEnvelope { pub(crate) version: u32, pub(crate) wrapped_k_intermediate: Vec, @@ -25,12 +26,14 @@ impl AccountKeyEnvelope { } pub(crate) fn serialize(&self) -> StorageResult> { - bincode::serialize(self) - .map_err(|err| StorageError::Serialization(err.to_string())) + 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 = bincode::deserialize(bytes) + 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)); diff --git a/walletkit-core/src/storage/error.rs b/walletkit-core/src/storage/error.rs index 7fcb9fe45..2c735be9f 100644 --- a/walletkit-core/src/storage/error.rs +++ b/walletkit-core/src/storage/error.rs @@ -6,7 +6,8 @@ use thiserror::Error; pub type StorageResult = Result; /// Errors raised by credential storage primitives. -#[derive(Debug, Error, uniffi::Error)] +#[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}")] @@ -69,11 +70,19 @@ pub enum StorageError { #[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 index cc0e3fdcd..fa5d532f2 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -1,6 +1,7 @@ //! Key hierarchy management for credential storage. use rand::{rngs::OsRng, RngCore}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use super::{ envelope::AccountKeyEnvelope, @@ -13,6 +14,7 @@ use super::{ /// 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], @@ -33,11 +35,13 @@ impl StorageKeys { ) -> StorageResult { if let Some(bytes) = blob_store.read(ACCOUNT_KEYS_FILENAME.to_string())? { let envelope = AccountKeyEnvelope::deserialize(&bytes)?; - let k_intermediate_bytes = keystore.open_sealed( + let wrapped_k_intermediate = envelope.wrapped_k_intermediate.clone(); + let k_intermediate_bytes = Zeroizing::new(keystore.open_sealed( ACCOUNT_KEY_ENVELOPE_AD.to_vec(), - envelope.wrapped_k_intermediate, - )?; - let k_intermediate = parse_key_32(&k_intermediate_bytes, "K_intermediate")?; + wrapped_k_intermediate, + )?); + let k_intermediate = + parse_key_32(k_intermediate_bytes.as_slice(), "K_intermediate")?; Ok(Self { intermediate_key: k_intermediate, }) @@ -54,10 +58,11 @@ impl StorageKeys { } } - /// Returns the intermediate key. Treat this as sensitive material. + /// 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 const fn intermediate_key(&self) -> [u8; 32] { - self.intermediate_key + pub fn intermediate_key(&self) -> Zeroizing<[u8; 32]> { + Zeroizing::new(self.intermediate_key) } } diff --git a/walletkit-core/src/storage/lock.rs b/walletkit-core/src/storage/lock.rs index f421890ab..25fdb7a90 100644 --- a/walletkit-core/src/storage/lock.rs +++ b/walletkit-core/src/storage/lock.rs @@ -1,228 +1,277 @@ -//! File-based storage lock for serializing writes. +//! 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::fs::{self, File, OpenOptions}; use std::path::Path; -use std::sync::Arc; use super::error::{StorageError, StorageResult}; -/// A file-backed lock that serializes storage mutations across processes. -#[derive(Debug, Clone)] -pub struct StorageLock { - file: Arc, -} +// 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; -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))?; + /// 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)) } - 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), - }) } +} + +// 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; - /// 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), - }) + /// A file-backed lock that serializes storage mutations across processes. + #[derive(Debug, Clone)] + pub struct StorageLock { + file: Arc, } - /// 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 { + /// 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), - })) - } else { - Ok(None) + }) + } + + /// 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) + } } } -} -/// Guard that holds an exclusive lock for its lifetime. -#[derive(Debug)] -pub struct StorageLockGuard { - file: Arc, -} + impl Drop for StorageLockGuard { + fn drop(&mut self) { + let _ = unlock(&self.file); + } + } -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()) } -} -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 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) + #[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 { - Err(err) + 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)] + 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)] + 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)] + 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; -} + #[cfg(unix)] + extern "C" { + fn flock(fd: c_int, operation: c_int) -> c_int; + } -#[cfg(windows)] -fn lock_exclusive(file: &File) -> std::io::Result<()> { - lock_file(file, 0) -} + // โ”€โ”€ Windows LockFileEx โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -#[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 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 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)] + 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)] + 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; + #[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::*; diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index a80551f82..ad5ce92ac 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -4,25 +4,25 @@ 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(crate) mod sqlcipher; pub mod traits; pub mod types; pub mod vault; pub use cache::CacheDb; -pub use credential_storage::{CredentialStorage, CredentialStore}; +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, CredentialId, CredentialRecord, CredentialRecordFfi, - CredentialStatus, Nullifier, ProofDisclosureKind, ProofDisclosureResult, - ProofDisclosureResultFfi, RequestId, + BlobKind, ContentId, CredentialRecord, Nullifier, ReplayGuardKind, + ReplayGuardResult, RequestId, }; pub use vault::VaultDb; diff --git a/walletkit-core/src/storage/paths.rs b/walletkit-core/src/storage/paths.rs index 65820b2c4..ee37944c4 100644 --- a/walletkit-core/src/storage/paths.rs +++ b/walletkit-core/src/storage/paths.rs @@ -5,9 +5,15 @@ 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, uniffi::Object)] +#[derive(Debug, Clone)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Object))] pub struct StoragePaths { root: PathBuf, worldid_dir: PathBuf, @@ -51,12 +57,42 @@ impl StoragePaths { 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) + } } -#[uniffi::export] +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export)] impl StoragePaths { /// Builds storage paths rooted at `root`. - #[uniffi::constructor] + #[cfg_attr(not(target_arch = "wasm32"), uniffi::constructor)] #[must_use] pub fn from_root(root: String) -> Self { Self::new(PathBuf::from(root)) @@ -91,4 +127,87 @@ impl StoragePaths { 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/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs deleted file mode 100644 index 19e56b59d..000000000 --- a/walletkit-core/src/storage/sqlcipher.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Shared `SQLCipher` helpers for storage databases. - -use std::fmt; -use std::path::Path; - -use rusqlite::{Connection, OpenFlags}; - -/// `SQLCipher` helper errors. -#[derive(Debug)] -pub enum SqlcipherError { - /// `SQLite` error. - Sqlite(rusqlite::Error), - /// `SQLCipher` is unavailable in the current build. - CipherUnavailable, -} - -impl fmt::Display for SqlcipherError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Sqlite(err) => write!(f, "{err}"), - Self::CipherUnavailable => write!(f, "sqlcipher not available"), - } - } -} - -impl From for SqlcipherError { - fn from(err: rusqlite::Error) -> Self { - Self::Sqlite(err) - } -} - -/// Result type for `SQLCipher` helper operations. -pub type SqlcipherResult = Result; - -/// Opens a `SQLite` connection with consistent flags. -pub(super) fn open_connection(path: &Path) -> SqlcipherResult { - let flags = OpenFlags::SQLITE_OPEN_READ_WRITE - | OpenFlags::SQLITE_OPEN_CREATE - | OpenFlags::SQLITE_OPEN_FULL_MUTEX; - Ok(Connection::open_with_flags(path, flags)?) -} - -/// Applies `SQLCipher` keying and validates cipher availability. -pub(super) fn apply_key( - conn: &Connection, - k_intermediate: [u8; 32], -) -> SqlcipherResult<()> { - let key_hex = hex::encode(k_intermediate); - let pragma = format!("PRAGMA key = \"x'{key_hex}'\";"); - conn.execute_batch(&pragma)?; - let cipher_version: String = - conn.query_row("PRAGMA cipher_version;", [], |row| row.get(0))?; - if cipher_version.trim().is_empty() { - return Err(SqlcipherError::CipherUnavailable); - } - Ok(()) -} - -/// Configures durable WAL settings. -pub(super) fn configure_connection(conn: &Connection) -> SqlcipherResult<()> { - conn.execute_batch( - "PRAGMA foreign_keys = ON; - PRAGMA journal_mode = WAL; - PRAGMA synchronous = FULL;", - )?; - Ok(()) -} - -/// Runs an integrity check. -pub(super) fn integrity_check(conn: &Connection) -> SqlcipherResult { - let result: String = - conn.query_row("PRAGMA integrity_check;", [], |row| row.get(0))?; - Ok(result.trim() == "ok") -} diff --git a/walletkit-core/src/storage/tests_utils.rs b/walletkit-core/src/storage/tests_utils.rs index 865b510cf..f6b334cc2 100644 --- a/walletkit-core/src/storage/tests_utils.rs +++ b/walletkit-core/src/storage/tests_utils.rs @@ -2,6 +2,8 @@ use std::{ collections::HashMap, + fs, + path::PathBuf, sync::{Arc, Mutex}, }; @@ -10,6 +12,7 @@ use chacha20poly1305::{ Key, XChaCha20Poly1305, XNonce, }; use rand::{rngs::OsRng, RngCore}; +use uuid::Uuid; use std::path::Path; @@ -24,6 +27,28 @@ 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]; diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs index 6069b9854..928566ed2 100644 --- a/walletkit-core/src/storage/traits.rs +++ b/walletkit-core/src/storage/traits.rs @@ -1,4 +1,18 @@ //! 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; @@ -6,9 +20,12 @@ use super::error::StorageResult; use super::paths::StoragePaths; /// Device keystore interface used to seal and open account keys. -#[uniffi::export(with_foreign)] +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] pub trait DeviceKeystore: Send + Sync { - /// Seals plaintext under the device-bound key, binding `associated_data`. + /// 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 /// @@ -21,6 +38,9 @@ pub trait DeviceKeystore: Send + Sync { /// 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. @@ -32,7 +52,7 @@ pub trait DeviceKeystore: Send + Sync { } /// Atomic blob store for small binary files (e.g., `account_keys.bin`). -#[uniffi::export(with_foreign)] +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] pub trait AtomicBlobStore: Send + Sync { /// Reads the blob at `path`, if present. /// @@ -57,7 +77,7 @@ pub trait AtomicBlobStore: Send + Sync { } /// Provider responsible for platform-specific storage components and paths. -#[uniffi::export(with_foreign)] +#[cfg_attr(not(target_arch = "wasm32"), uniffi::export(with_foreign))] pub trait StorageProvider: Send + Sync { /// Returns the device keystore implementation. fn keystore(&self) -> Arc; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 4af5c5ec0..f9bcec5d6 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -2,41 +2,12 @@ use super::error::{StorageError, StorageResult}; -/// Status of a stored credential. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] -#[repr(u8)] -pub enum CredentialStatus { - /// Credential is active and can be used. - Active = 1, - /// Credential has been revoked. - Revoked = 2, - /// Credential has expired. - Expired = 3, -} - -impl CredentialStatus { - pub(crate) const fn as_i64(self) -> i64 { - self as i64 - } -} - -impl TryFrom for CredentialStatus { - type Error = StorageError; - - fn try_from(value: i64) -> StorageResult { - match value { - 1 => Ok(Self::Active), - 2 => Ok(Self::Revoked), - 3 => Ok(Self::Expired), - _ => Err(StorageError::VaultDb(format!( - "invalid credential status {value}" - ))), - } - } -} - /// Kind of blob stored in the vault. -#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +/// +/// 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. @@ -66,115 +37,43 @@ impl TryFrom for BlobKind { /// Content identifier for stored blobs. pub type ContentId = [u8; 32]; -/// Credential identifier. -pub type CredentialId = [u8; 16]; - -/// Request identifier for proof disclosure. +/// 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 a stored credential. +/// 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: CredentialId, + pub credential_id: u64, /// Issuer schema identifier. pub issuer_schema_id: u64, - /// Current credential status. - pub status: CredentialStatus, - /// Subject blinding factor tied to the credential subject. - pub subject_blinding_factor: [u8; 32], - /// Genesis issuance timestamp (seconds). - pub genesis_issued_at: u64, - /// Optional expiry timestamp (seconds). - pub expires_at: Option, - /// Last updated timestamp (seconds). - pub updated_at: u64, - /// Raw credential blob bytes. - pub credential_blob: Vec, - /// Optional associated data blob bytes. - pub associated_data: Option>, + /// Expiry timestamp (seconds). + pub expires_at: u64, } -/// FFI-friendly credential record. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CredentialRecordFfi { - /// Credential identifier. - pub credential_id: Vec, - /// Issuer schema identifier. - pub issuer_schema_id: u64, - /// Current credential status. - pub status: CredentialStatus, - /// Subject blinding factor tied to the credential subject. - pub subject_blinding_factor: Vec, - /// Genesis issuance timestamp (seconds). - pub genesis_issued_at: u64, - /// Optional expiry timestamp (seconds). - pub expires_at: Option, - /// Last updated timestamp (seconds). - pub updated_at: u64, - /// Raw credential blob bytes. - pub credential_blob: Vec, - /// Optional associated data blob bytes. - pub associated_data: Option>, -} - -/// Result of proof disclosure enforcement. +/// FFI-friendly replay guard result kind. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ProofDisclosureResult { - /// Stored bytes for the first disclosure of a request. - Fresh(Vec), - /// Stored bytes replayed for an existing request. - Replay(Vec), -} - -/// FFI-friendly proof disclosure result kind. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] -pub enum ProofDisclosureKind { +#[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, } -/// FFI-friendly proof disclosure result. -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct ProofDisclosureResultFfi { +/// Replay guard result. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ReplayGuardResult { /// Result kind. - pub kind: ProofDisclosureKind, + pub kind: ReplayGuardKind, /// Stored proof package bytes. pub bytes: Vec, } - -impl From for CredentialRecordFfi { - fn from(record: CredentialRecord) -> Self { - Self { - credential_id: record.credential_id.to_vec(), - issuer_schema_id: record.issuer_schema_id, - status: record.status, - subject_blinding_factor: record.subject_blinding_factor.to_vec(), - genesis_issued_at: record.genesis_issued_at, - expires_at: record.expires_at, - updated_at: record.updated_at, - credential_blob: record.credential_blob, - associated_data: record.associated_data, - } - } -} - -impl From for ProofDisclosureResultFfi { - fn from(result: ProofDisclosureResult) -> Self { - match result { - ProofDisclosureResult::Fresh(bytes) => Self { - kind: ProofDisclosureKind::Fresh, - bytes, - }, - ProofDisclosureResult::Replay(bytes) => Self { - kind: ProofDisclosureKind::Replay, - bytes, - }, - } - } -} diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index d4426624f..7f45041af 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -1,9 +1,10 @@ -use rusqlite::Row; +//! Vault database helpers for content addressing and type conversion. + use sha2::{Digest, Sha256}; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::sqlcipher::SqlcipherError; -use crate::storage::types::{BlobKind, ContentId, CredentialRecord, CredentialStatus}; +use crate::storage::types::{BlobKind, ContentId, CredentialRecord}; +use walletkit_db::{DbError, Row}; const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; @@ -18,56 +19,17 @@ pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> Conte out } -pub(super) fn map_record(row: &Row<'_>) -> StorageResult { - let credential_id_bytes: Vec = row.get(0).map_err(|err| map_db_err(&err))?; - let issuer_schema_id: i64 = row.get(1).map_err(|err| map_db_err(&err))?; - let status_raw: i64 = row.get(2).map_err(|err| map_db_err(&err))?; - let subject_blinding_factor_bytes: Vec = - row.get(3).map_err(|err| map_db_err(&err))?; - let genesis_issued_at: i64 = row.get(4).map_err(|err| map_db_err(&err))?; - let expires_at: Option = row.get(5).map_err(|err| map_db_err(&err))?; - let updated_at: i64 = row.get(6).map_err(|err| map_db_err(&err))?; - let credential_blob: Vec = row.get(7).map_err(|err| map_db_err(&err))?; - let associated_data: Option> = - row.get(8).map_err(|err| map_db_err(&err))?; - - let credential_id = parse_fixed_bytes::<16>(&credential_id_bytes, "credential_id")?; - let subject_blinding_factor = parse_fixed_bytes::<32>( - &subject_blinding_factor_bytes, - "subject_blinding_factor", - )?; - let status = CredentialStatus::try_from(status_raw)?; - +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, + credential_id: to_u64(credential_id, "credential_id")?, issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, - status, - subject_blinding_factor, - genesis_issued_at: to_u64(genesis_issued_at, "genesis_issued_at")?, - expires_at: expires_at - .map(|value| to_u64(value, "expires_at")) - .transpose()?, - updated_at: to_u64(updated_at, "updated_at")?, - credential_blob, - associated_data, + expires_at: to_u64(expires_at, "expires_at")?, }) } -pub(super) fn parse_fixed_bytes( - bytes: &[u8], - label: &str, -) -> StorageResult<[u8; N]> { - if bytes.len() != N { - return Err(StorageError::VaultDb(format!( - "{label} length mismatch: expected {N}, got {}", - bytes.len() - ))); - } - let mut out = [0u8; N]; - out.copy_from_slice(bytes); - Ok(out) -} - 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}")) @@ -80,13 +42,6 @@ pub(super) fn to_u64(value: i64, label: &str) -> StorageResult { }) } -pub(super) fn map_db_err(err: &rusqlite::Error) -> StorageError { +pub(super) fn map_db_err(err: &DbError) -> StorageError { StorageError::VaultDb(err.to_string()) } - -pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { - match err { - SqlcipherError::Sqlite(err) => StorageError::VaultDb(err.to_string()), - SqlcipherError::CipherUnavailable => StorageError::VaultDb(err.to_string()), - } -} diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 904c8ccc8..645557964 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -7,17 +7,14 @@ mod tests; use std::path::Path; -use rusqlite::{params, Connection, OptionalExtension}; -use uuid::Uuid; - -use super::error::{StorageError, StorageResult}; -use super::lock::StorageLockGuard; -use super::sqlcipher; -use super::types::{BlobKind, CredentialId, CredentialRecord, CredentialStatus}; -use helpers::{ - compute_content_id, map_db_err, map_record, map_sqlcipher_err, to_i64, to_u64, -}; +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)] @@ -33,12 +30,11 @@ impl VaultDb { /// Returns an error if the database cannot be opened, keyed, or initialized. pub fn new( path: &Path, - k_intermediate: [u8; 32], + k_intermediate: &Zeroizing<[u8; 32]>, _lock: &StorageLockGuard, ) -> StorageResult { - let conn = sqlcipher::open_connection(path).map_err(map_sqlcipher_err)?; - sqlcipher::apply_key(&conn, k_intermediate).map_err(map_sqlcipher_err)?; - sqlcipher::configure_connection(&conn).map_err(map_sqlcipher_err)?; + 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()? { @@ -51,6 +47,9 @@ impl VaultDb { /// 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. @@ -63,44 +62,31 @@ impl VaultDb { 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 existing = tx - .query_row("SELECT leaf_index FROM vault_meta LIMIT 1", [], |row| { - row.get::<_, Option>(0) - }) - .optional() + 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))?; - match existing { - None => { - tx.execute( - "INSERT INTO vault_meta (schema_version, leaf_index, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4)", - params![ - VAULT_SCHEMA_VERSION, - leaf_index_i64, - now_i64, - now_i64 - ], - ) - .map_err(|err| map_db_err(&err))?; - } - Some(None) => { - tx.execute( - "UPDATE vault_meta SET leaf_index = ?1, updated_at = ?2", - params![leaf_index_i64, now_i64], - ) - .map_err(|err| map_db_err(&err))?; - } - Some(Some(stored)) => { - if stored != leaf_index_i64 { - let expected = to_u64(stored, "leaf_index")?; - return Err(StorageError::InvalidLeafIndex { - expected, - provided: leaf_index, - }); - } - tx.execute("UPDATE vault_meta SET updated_at = ?1", params![now_i64]) - .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(()) @@ -108,6 +94,9 @@ impl VaultDb { /// 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. @@ -117,15 +106,13 @@ impl VaultDb { &mut self, _lock: &StorageLockGuard, issuer_schema_id: u64, - status: CredentialStatus, - subject_blinding_factor: [u8; 32], + subject_blinding_factor: Vec, genesis_issued_at: u64, - expires_at: Option, + expires_at: u64, credential_blob: Vec, associated_data: Option>, now: u64, - ) -> StorageResult { - let credential_id = *Uuid::new_v4().as_bytes(); + ) -> StorageResult { let credential_blob_id = compute_content_id(BlobKind::CredentialBlob, &credential_blob); let associated_data_id = associated_data @@ -134,9 +121,7 @@ impl VaultDb { 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 = expires_at - .map(|value| to_i64(value, "expires_at")) - .transpose()?; + let expires_at_i64 = to_i64(expires_at, "expires_at")?; let tx = self.conn.transaction().map_err(|err| map_db_err(&err))?; tx.execute( @@ -146,7 +131,7 @@ impl VaultDb { credential_blob_id.as_ref(), BlobKind::CredentialBlob.as_i64(), now_i64, - credential_blob + credential_blob.as_slice(), ], ) .map_err(|err| map_db_err(&err))?; @@ -162,43 +147,46 @@ impl VaultDb { cid.as_ref(), BlobKind::AssociatedData.as_i64(), now_i64, - data + data.as_slice(), ], ) .map_err(|err| map_db_err(&err))?; } - tx.execute( - "INSERT INTO credential_records ( - credential_id, - issuer_schema_id, - subject_blinding_factor, - genesis_issued_at, - expires_at, - status, - updated_at, - credential_blob_cid, - associated_data_cid - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - credential_id.as_ref(), - issuer_schema_id_i64, - subject_blinding_factor.as_ref(), - genesis_issued_at_i64, - expires_at_i64, - status.as_i64(), - now_i64, - credential_blob_id.as_ref(), - associated_data_id.as_ref().map(AsRef::as_ref) - ], - ) - .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))?; - Ok(credential_id) + to_u64(credential_id, "credential_id") } - /// Lists active credentials, optionally filtered by issuer schema. + /// Lists active credential metadata, optionally filtered by issuer schema. /// /// # Errors /// @@ -208,77 +196,93 @@ impl VaultDb { issuer_schema_id: Option, now: u64, ) -> StorageResult> { - let mut records = Vec::new(); - let status = CredentialStatus::Active.as_i64(); let expires = to_i64(now, "now")?; - if let Some(issuer_schema_id) = issuer_schema_id { - let issuer_schema_id_i64 = to_i64(issuer_schema_id, "issuer_schema_id")?; - let mut stmt = self - .conn - .prepare( - "SELECT - cr.credential_id, - cr.issuer_schema_id, - cr.status, - cr.subject_blinding_factor, - cr.genesis_issued_at, - cr.expires_at, - cr.updated_at, - cb.bytes, - ad.bytes - FROM credential_records cr - JOIN blob_objects cb ON cb.content_id = cr.credential_blob_cid - LEFT JOIN blob_objects ad ON ad.content_id = cr.associated_data_cid - WHERE cr.status = ?1 - AND (cr.expires_at IS NULL OR cr.expires_at > ?2) - AND cr.issuer_schema_id = ?3 - ORDER BY cr.updated_at DESC", - ) - .map_err(|err| map_db_err(&err))?; - let mut rows = stmt - .query(params![status, expires, issuer_schema_id_i64]) + 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 Some(row) = rows.next().map_err(|err| map_db_err(&err))? { - records.push(map_record(row)?); + while let StepResult::Row(row) = + stmt.step().map_err(|err| map_db_err(&err))? + { + records.push(map_record(&row)?); } } else { - let mut stmt = self - .conn - .prepare( - "SELECT - cr.credential_id, - cr.issuer_schema_id, - cr.status, - cr.subject_blinding_factor, - cr.genesis_issued_at, - cr.expires_at, - cr.updated_at, - cb.bytes, - ad.bytes - FROM credential_records cr - JOIN blob_objects cb ON cb.content_id = cr.credential_blob_cid - LEFT JOIN blob_objects ad ON ad.content_id = cr.associated_data_cid - WHERE cr.status = ?1 - AND (cr.expires_at IS NULL OR cr.expires_at > ?2) - ORDER BY cr.updated_at DESC", - ) + 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))?; - let mut rows = stmt - .query(params![status, expires]) - .map_err(|err| map_db_err(&err))?; - while let Some(row) = rows.next().map_err(|err| map_db_err(&err))? { - records.push(map_record(row)?); + 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 { - sqlcipher::integrity_check(&self.conn).map_err(map_sqlcipher_err) + 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 index 61e699d0d..4fdde1465 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -1,6 +1,7 @@ -use rusqlite::Connection; +//! Vault database schema management. use crate::storage::error::StorageResult; +use walletkit_db::Connection; use super::helpers::map_db_err; @@ -15,24 +16,34 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { 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 BLOB NOT NULL, + 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, - status INTEGER NOT NULL, + expires_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, credential_blob_cid BLOB NOT NULL, - associated_data_cid BLOB, - PRIMARY KEY (credential_id) + associated_data_cid BLOB ); CREATE INDEX IF NOT EXISTS idx_cred_by_issuer_schema - ON credential_records (issuer_schema_id, status, updated_at DESC); + ON credential_records (issuer_schema_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_cred_by_expiry - ON credential_records (status, expires_at); + ON credential_records (expires_at); CREATE TABLE IF NOT EXISTS blob_objects ( content_id BLOB NOT NULL, diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index 5648a09fd..ee9618735 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -1,8 +1,12 @@ +//! 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(); @@ -28,20 +32,20 @@ fn cleanup_lock_file(path: &Path) { let _ = fs::remove_file(path); } -fn sample_blinding_factor() -> [u8; 32] { - [0x11u8; 32] +fn sample_blinding_factor() -> Vec { + [0x11u8; 32].to_vec() } #[test] fn test_vault_create_and_open() { let path = temp_vault_path(); - let key = [0x42u8; 32]; + 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"); + let db = VaultDb::new(&path, &key, &guard).expect("create vault"); drop(db); - VaultDb::new(&path, key, &guard).expect("open vault"); + VaultDb::new(&path, &key, &guard).expect("open vault"); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -49,12 +53,13 @@ fn test_vault_create_and_open() { #[test] fn test_vault_wrong_key_fails() { let path = temp_vault_path(); - let key = [0x01u8; 32]; + 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 err = VaultDb::new(&path, [0x02u8; 32], &guard).expect_err("wrong key"); + 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}"), @@ -69,7 +74,8 @@ fn test_leaf_index_set_once() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x03u8; 32], &guard).expect("create vault"); + 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) @@ -84,7 +90,8 @@ fn test_leaf_index_immutable() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x04u8; 32], &guard).expect("create vault"); + 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 { @@ -101,15 +108,15 @@ fn test_store_credential_without_associated_data() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x05u8; 32], &guard).expect("create vault"); + 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, - CredentialStatus::Active, sample_blinding_factor(), 123, - None, + 2000, b"credential".to_vec(), None, 1000, @@ -118,7 +125,8 @@ fn test_store_credential_without_associated_data() { let records = db.list_credentials(None, 1000).expect("list credentials"); assert_eq!(records.len(), 1); assert_eq!(records[0].credential_id, credential_id); - assert!(records[0].associated_data.is_none()); + assert_eq!(records[0].issuer_schema_id, 10); + assert_eq!(records[0].expires_at, 2000); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -129,14 +137,14 @@ fn test_store_credential_with_associated_data() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x06u8; 32], &guard).expect("create vault"); + let key = Zeroizing::new([0x06u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 11, - CredentialStatus::Active, sample_blinding_factor(), 456, - None, + 2000, b"credential-2".to_vec(), Some(b"associated".to_vec()), 1000, @@ -144,10 +152,8 @@ fn test_store_credential_with_associated_data() { .expect("store credential"); let records = db.list_credentials(None, 1000).expect("list credentials"); assert_eq!(records.len(), 1); - assert_eq!( - records[0].associated_data.as_deref(), - Some(b"associated".as_slice()) - ); + assert_eq!(records[0].issuer_schema_id, 11); + assert_eq!(records[0].expires_at, 2000); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -165,14 +171,14 @@ fn test_content_id_deduplication() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x07u8; 32], &guard).expect("create vault"); + let key = Zeroizing::new([0x07u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 12, - CredentialStatus::Active, sample_blinding_factor(), 1, - None, + 2000, b"same".to_vec(), None, 1000, @@ -181,18 +187,19 @@ fn test_content_id_deduplication() { db.store_credential( &guard, 12, - CredentialStatus::Active, sample_blinding_factor(), 1, - None, + 2000, b"same".to_vec(), None, 1001, ) .expect("store credential"); - let count: i64 = db + let count = db .conn - .query_row("SELECT COUNT(*) FROM blob_objects", [], |row| row.get(0)) + .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); @@ -206,14 +213,14 @@ fn test_list_credentials_by_issuer() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x08u8; 32], &guard).expect("create vault"); + let key = Zeroizing::new([0x08u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 100, - CredentialStatus::Active, sample_blinding_factor(), 1, - None, + 2000, b"issuer-a".to_vec(), None, 1000, @@ -222,10 +229,9 @@ fn test_list_credentials_by_issuer() { db.store_credential( &guard, 200, - CredentialStatus::Active, sample_blinding_factor(), 1, - None, + 2000, b"issuer-b".to_vec(), None, 1000, @@ -246,14 +252,14 @@ fn test_list_credentials_excludes_expired() { let lock_path = temp_lock_path(); let lock = StorageLock::open(&lock_path).expect("open lock"); let guard = lock.lock().expect("lock"); - let mut db = VaultDb::new(&path, [0x09u8; 32], &guard).expect("create vault"); + let key = Zeroizing::new([0x09u8; 32]); + let mut db = VaultDb::new(&path, &key, &guard).expect("create vault"); db.store_credential( &guard, 300, - CredentialStatus::Active, sample_blinding_factor(), 1, - Some(900), + 900, b"expired".to_vec(), None, 1000, @@ -271,7 +277,8 @@ fn test_vault_integrity_check() { 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, [0x0Au8; 32], &guard).expect("create vault"); + 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); @@ -280,13 +287,13 @@ fn test_vault_integrity_check() { #[test] fn test_vault_corruption_handling() { let path = temp_vault_path(); - let key = [0x0Bu8; 32]; + 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"); + 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"); + let err = VaultDb::new(&path, &key, &guard).expect_err("corrupt vault"); match err { StorageError::VaultDb(_) | StorageError::CorruptedVault(_) => {} _ => panic!("unexpected error: {err}"), 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 index 519b1f9fd..ac55dc229 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -1,256 +1,52 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +#![cfg(feature = "storage")] +#![allow(missing_docs)] -use chacha20poly1305::{ - aead::{Aead, KeyInit, Payload}, - Key, XChaCha20Poly1305, XNonce, -}; -use rand::{rngs::OsRng, RngCore}; -use uuid::Uuid; +mod common; -use walletkit_core::storage::{ - AtomicBlobStore, CredentialStatus, CredentialStorage, CredentialStore, - DeviceKeystore, ProofDisclosureResult, StoragePaths, StorageProvider, -}; - -struct InMemoryKeystore { - key: [u8; 32], -} - -impl InMemoryKeystore { - fn new() -> Self { - let mut key = [0u8; 32]; - OsRng.fill_bytes(&mut key); - Self { key } - } -} - -impl DeviceKeystore for InMemoryKeystore { - fn seal( - &self, - associated_data: Vec, - plaintext: Vec, - ) -> Result, walletkit_core::storage::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| { - walletkit_core::storage::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, walletkit_core::storage::StorageError> { - if ciphertext.len() < 24 { - return Err(walletkit_core::storage::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| { - walletkit_core::storage::StorageError::Crypto(err.to_string()) - }) - } -} - -struct InMemoryBlobStore { - blobs: Mutex>>, -} - -impl InMemoryBlobStore { - fn new() -> Self { - Self { - blobs: Mutex::new(HashMap::new()), - } - } -} - -impl AtomicBlobStore for InMemoryBlobStore { - fn read( - &self, - path: String, - ) -> Result>, walletkit_core::storage::StorageError> { - let guard = self.blobs.lock().map_err(|_| { - walletkit_core::storage::StorageError::BlobStore( - "mutex poisoned".to_string(), - ) - })?; - Ok(guard.get(&path).cloned()) - } - - fn write_atomic( - &self, - path: String, - bytes: Vec, - ) -> Result<(), walletkit_core::storage::StorageError> { - self.blobs - .lock() - .map_err(|_| { - walletkit_core::storage::StorageError::BlobStore( - "mutex poisoned".to_string(), - ) - })? - .insert(path, bytes); - Ok(()) - } - - fn delete( - &self, - path: String, - ) -> Result<(), walletkit_core::storage::StorageError> { - self.blobs - .lock() - .map_err(|_| { - walletkit_core::storage::StorageError::BlobStore( - "mutex poisoned".to_string(), - ) - })? - .remove(&path); - Ok(()) - } -} - -struct InMemoryStorageProvider { - keystore: Arc, - blob_store: Arc, - paths: Arc, -} - -impl InMemoryStorageProvider { - 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) - } -} - -fn temp_root() -> PathBuf { - let mut path = std::env::temp_dir(); - path.push(format!("walletkit-storage-{}", Uuid::new_v4())); - path -} - -fn cleanup_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()); -} +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 = temp_root(); - let provider = InMemoryStorageProvider::new(&root); - let mut store = CredentialStore::from_provider(&provider).expect("store"); + 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 credential_id = CredentialStorage::store_credential( - &mut store, - 7, - CredentialStatus::Active, - [0x11u8; 32], - 1_700_000_000, - Some(1_800_000_000), - vec![1, 2, 3], - Some(vec![4, 5, 6]), - 100, - ) - .expect("store credential"); - - let records = CredentialStorage::list_credentials(&store, None, 101) - .expect("list credentials"); + 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.subject_blinding_factor, [0x11u8; 32]); - assert_eq!(record.credential_blob, vec![1, 2, 3]); - assert_eq!(record.associated_data.as_deref(), Some(&[4, 5, 6][..])); + assert_eq!(record.expires_at, 1_800_000_000); - let root_bytes = [0xAAu8; 32]; - CredentialStorage::merkle_cache_put(&mut store, 1, root_bytes, vec![9, 9], 100, 10) + store + .merkle_cache_put(vec![9, 9], 100, 10) .expect("cache put"); - let hit = CredentialStorage::merkle_cache_get(&store, 1, root_bytes, 105) - .expect("cache get"); + let now = 105; + let hit = store.merkle_cache_get(now).expect("cache get"); assert_eq!(hit, Some(vec![9, 9])); - let miss = CredentialStorage::merkle_cache_get(&store, 1, root_bytes, 111) - .expect("cache get"); + let miss = store.merkle_cache_get(111).expect("cache get"); assert!(miss.is_none()); - let request_id = [0xABu8; 32]; - let nullifier = [0xCDu8; 32]; - let fresh = CredentialStorage::begin_proof_disclosure( - &mut store, - request_id, - nullifier, - vec![1, 2], - 200, - 50, - ) - .expect("disclose"); - assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![1, 2])); - let replay = CredentialStorage::begin_proof_disclosure( - &mut store, - request_id, - nullifier, - vec![9, 9], - 201, - 50, - ) - .expect("replay"); - assert_eq!(replay, ProofDisclosureResult::Replay(vec![1, 2])); - - cleanup_storage(&root); + 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 2e54c37fb..d28228f40 100644 --- a/walletkit-core/tests/solidity.rs +++ b/walletkit-core/tests/solidity.rs @@ -1,3 +1,5 @@ +#![allow(missing_docs, clippy::cast_sign_loss)] + #[cfg(all(feature = "legacy-nullifiers", feature = "common-apps"))] use alloy::primitives::Address; use alloy::{ 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; From 8909ee74ed18ebe8b44c8075c38ca835242c5fec Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 11:24:32 -0800 Subject: [PATCH 5/7] foreign tests v2 --- .../world/walletkit/CredentialStoreTests.kt | 47 +-- .../kotlin/org/world/walletkit/SimpleTest.kt | 47 +++ .../kotlin/org/world/walletkit/TestHelpers.kt | 19 +- .../storage/AndroidAtomicBlobStore.kt | 0 .../storage/AndroidDeviceKeystore.kt | 0 .../storage/AndroidStorageProvider.kt | 2 +- swift/tests/Package.swift | 8 +- .../WalletKitTests/AtomicBlobStoreTests.swift | 2 +- .../WalletKitTests/AuthenticatorTests.swift | 386 ------------------ .../WalletKitTests/CredentialStoreTests.swift | 50 +-- .../WalletKitTests/DeviceKeystoreTests.swift | 4 +- swift/tests/WalletKitTests/SimpleTest.swift | 37 ++ swift/tests/WalletKitTests/TestHelpers.swift | 18 +- .../TestIOSAtomicBlobStore.swift | 49 +++ .../TestIOSDeviceKeystore.swift | 132 ++++++ 15 files changed, 324 insertions(+), 477 deletions(-) create mode 100644 kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/SimpleTest.kt rename kotlin/{walletkit-android => walletkit}/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt (100%) rename kotlin/{walletkit-android => walletkit}/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt (100%) rename kotlin/{walletkit-android => walletkit}/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt (100%) delete mode 100644 swift/tests/WalletKitTests/AuthenticatorTests.swift create mode 100644 swift/tests/WalletKitTests/SimpleTest.swift create mode 100644 swift/tests/WalletKitTests/TestIOSAtomicBlobStore.swift create mode 100644 swift/tests/WalletKitTests/TestIOSDeviceKeystore.swift 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 index 96147c3e6..954def634 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt @@ -1,11 +1,9 @@ package org.world.walletkit -import uniffi.walletkit_core.CredentialStatus import uniffi.walletkit_core.CredentialStore -import uniffi.walletkit_core.ProofDisclosureKind import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull +import kotlin.test.assertNull class CredentialStoreTests { @Test @@ -18,61 +16,34 @@ class CredentialStoreTests { val credentialId = store.storeCredential( - issuerSchemaId = 7UL, - status = CredentialStatus.ACTIVE, - subjectBlindingFactor = ByteArray(32) { 0x11.toByte() }, - genesisIssuedAt = 1_700_000_000UL, + credential = sampleCredential(), + blindingFactor = sampleBlindingFactor(), expiresAt = 1_800_000_000UL, - credentialBlob = byteArrayOf(1, 2, 3), associatedData = byteArrayOf(4, 5, 6), now = 100UL, ) - assertEquals(16, credentialId.size) + assertEquals(1UL, credentialId) 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(32, record.subjectBlindingFactor.size) + assertEquals(1_800_000_000UL, record.expiresAt) - val rootHash = ByteArray(32) { 0x22.toByte() } val proofBytes = byteArrayOf(9, 9, 9) store.merkleCachePut( - registryKind = 1u.toUByte(), - root = rootHash, proofBytes = proofBytes, now = 100UL, ttlSeconds = 60UL, ) val cached = store.merkleCacheGet( - registryKind = 1u.toUByte(), - root = rootHash, - now = 110UL, + validUntil = 110UL, ) assertEquals(proofBytes.toList(), cached?.toList()) - - val requestId = ByteArray(32) { 0x01.toByte() } - val nullifier = ByteArray(32) { 0x02.toByte() } - val first = - store.beginProofDisclosure( - requestId = requestId, - nullifier = nullifier, - proofBytes = byteArrayOf(7, 7), - now = 120UL, - ttlSeconds = 60UL, - ) - assertEquals(ProofDisclosureKind.FRESH, first.kind) - - val replay = - store.beginProofDisclosure( - requestId = requestId, - nullifier = nullifier, - proofBytes = byteArrayOf(8, 8), - now = 130UL, - ttlSeconds = 60UL, - ) - assertEquals(ProofDisclosureKind.REPLAY, replay.kind) + val expired = store.merkleCacheGet(validUntil = 161UL) + assertNull(expired) root.deleteRecursively() } 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 index c5c19585e..915b74edf 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt @@ -1,16 +1,18 @@ package org.world.walletkit -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 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()}") @@ -18,6 +20,13 @@ fun tempDirectory(): File { return dir } +private const val SAMPLE_CREDENTIAL_JSON = + """{"id":13758530325042616850,"version":"V1","issuer_schema_id":7,"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":1800000000,"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"}""" + +fun sampleCredential(): Credential = Credential.fromBytes(SAMPLE_CREDENTIAL_JSON.encodeToByteArray()) + +fun sampleBlindingFactor(): FieldElement = FieldElement.fromU64(17UL) + class InMemoryDeviceKeystore : DeviceKeystore { private val keyBytes = ByteArray(32).also { SecureRandom().nextBytes(it) } diff --git a/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt similarity index 100% rename from kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt rename to kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidAtomicBlobStore.kt diff --git a/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt similarity index 100% rename from kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt rename to kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidDeviceKeystore.kt diff --git a/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt similarity index 100% rename from kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt rename to kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt index 1b0a8abf6..f8eaa6bb4 100644 --- a/kotlin/walletkit-android/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt +++ b/kotlin/walletkit/src/main/kotlin/org/world/walletkit/storage/AndroidStorageProvider.kt @@ -4,9 +4,9 @@ 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 -import uniffi.walletkit_core.StorageException class AndroidStorageProvider( private val rootDir: File, diff --git a/swift/tests/Package.swift b/swift/tests/Package.swift index 410aea844..c90e62479 100644 --- a/swift/tests/Package.swift +++ b/swift/tests/Package.swift @@ -1,12 +1,12 @@ // 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: "WalletKitForeignTestPackage", platforms: [ - .iOS(.v13) + .iOS(.v13), + .macOS(.v12) ], products: [ .library( @@ -16,11 +16,11 @@ let package = Package( targets: [ .target( name: "WalletKit", - dependencies: ["walletkit_coreFFI"], + dependencies: ["WalletKitFFI"], path: "Sources/WalletKit" ), .binaryTarget( - name: "walletkit_coreFFI", + name: "WalletKitFFI", path: "../WalletKit.xcframework" ), .testTarget( diff --git a/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift index 5e7848708..ff9f285df 100644 --- a/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift +++ b/swift/tests/WalletKitTests/AtomicBlobStoreTests.swift @@ -7,7 +7,7 @@ final class AtomicBlobStoreTests: XCTestCase { let root = makeTempDirectory() defer { try? FileManager.default.removeItem(at: root) } - let store = IOSAtomicBlobStore(baseURL: root) + let store = TestIOSAtomicBlobStore(baseURL: root) let path = "account_keys.bin" let payload = Data([1, 2, 3, 4]) diff --git a/swift/tests/WalletKitTests/AuthenticatorTests.swift b/swift/tests/WalletKitTests/AuthenticatorTests.swift deleted file mode 100644 index 3fa1f8194..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 index in 0..<32 { - bytes[index] = 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 index 4a65fddc4..f4f17127b 100644 --- a/swift/tests/WalletKitTests/CredentialStoreTests.swift +++ b/swift/tests/WalletKitTests/CredentialStoreTests.swift @@ -12,9 +12,9 @@ final class CredentialStoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let keystore = IOSDeviceKeystore(service: service, account: account) + let keystore = TestIOSDeviceKeystore(service: service, account: account) let worldidDir = root.appendingPathComponent("worldid", isDirectory: true) - let blobStore = IOSAtomicBlobStore(baseURL: worldidDir) + let blobStore = TestIOSAtomicBlobStore(baseURL: worldidDir) let paths = StoragePaths.fromRoot(root: root.path) let store = try CredentialStore.newWithComponents( @@ -26,61 +26,33 @@ final class CredentialStoreTests: XCTestCase { try store.`init`(leafIndex: 42, now: 100) let credentialId = try store.storeCredential( - issuerSchemaId: 7, - status: .active, - subjectBlindingFactor: Data(repeating: 0x11, count: 32), - genesisIssuedAt: 1_700_000_000, + credential: sampleCredential(), + blindingFactor: sampleBlindingFactor(), expiresAt: 1_800_000_000, - credentialBlob: Data([1, 2, 3]), associatedData: Data([4, 5, 6]), now: 100 ) - XCTAssertEqual(credentialId.count, 16) + XCTAssertEqual(credentialId, 1) - let records = try store.listCredentials(issuerSchemaId: nil, now: 101) + let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 101) XCTAssertEqual(records.count, 1) let record = records[0] - XCTAssertEqual(record.issuerSchemaId, 7) XCTAssertEqual(record.credentialId, credentialId) - XCTAssertEqual(record.subjectBlindingFactor.count, 32) + XCTAssertEqual(record.issuerSchemaId, 7) + XCTAssertEqual(record.expiresAt, 1_800_000_000) - let rootHash = Data(repeating: 0x22, count: 32) let proofBytes = Data([9, 9, 9]) try store.merkleCachePut( - registryKind: 1, - root: rootHash, proofBytes: proofBytes, now: 100, ttlSeconds: 60 ) let cached = try store.merkleCacheGet( - registryKind: 1, - root: rootHash, - now: 110 + validUntil: 110 ) XCTAssertEqual(cached, proofBytes) - - let requestId = Data(repeating: 0x01, count: 32) - let nullifier = Data(repeating: 0x02, count: 32) - let first = try store.beginProofDisclosure( - requestId: requestId, - nullifier: nullifier, - proofBytes: Data([7, 7]), - now: 120, - ttlSeconds: 60 - ) - XCTAssertEqual(first.kind, .fresh) - XCTAssertEqual(first.bytes, Data([7, 7])) - - let replay = try store.beginProofDisclosure( - requestId: requestId, - nullifier: nullifier, - proofBytes: Data([8, 8]), - now: 130, - ttlSeconds: 60 - ) - XCTAssertEqual(replay.kind, .replay) - XCTAssertEqual(replay.bytes, Data([7, 7])) + let expired = try store.merkleCacheGet(validUntil: 161) + XCTAssertNil(expired) } } diff --git a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift index c70fffa7d..b38b2ea50 100644 --- a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift +++ b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift @@ -11,7 +11,7 @@ final class DeviceKeystoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let keystore = IOSDeviceKeystore(service: service, account: account) + let keystore = TestIOSDeviceKeystore(service: service, account: account) let associatedData = Data("ad".utf8) let plaintext = Data("hello".utf8) @@ -31,7 +31,7 @@ final class DeviceKeystoreTests: XCTestCase { let service = uniqueKeystoreService() defer { deleteKeychainItem(service: service, account: account) } - let keystore = IOSDeviceKeystore(service: service, account: account) + let keystore = TestIOSDeviceKeystore(service: service, account: account) let plaintext = Data("secret".utf8) let ciphertext = try keystore.seal( diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/SimpleTest.swift new file mode 100644 index 000000000..50f5d2ffd --- /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.001) + + 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 index 83aa8e817..2d11688c5 100644 --- a/swift/tests/WalletKitTests/TestHelpers.swift +++ b/swift/tests/WalletKitTests/TestHelpers.swift @@ -1,11 +1,18 @@ import Foundation import Security +@testable import WalletKit + +private let sampleCredentialJSON = """ +{"id":13758530325042616850,"version":"V1","issuer_schema_id":7,"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":1800000000,"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"} +""" func makeTempDirectory() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent( + 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 { @@ -20,3 +27,12 @@ func deleteKeychainItem(service: String, account: String) { ] SecItemDelete(query as CFDictionary) } + +func sampleCredential() throws -> Credential { + 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 + } +} From 0ad9782b1013d0b1b0a7363bb947bb09861fec86 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 11:28:16 -0800 Subject: [PATCH 6/7] test CredentialStore initialization and filtering by issuer schema ID --- .../world/walletkit/CredentialStoreTests.kt | 66 +++++++++++++- .../kotlin/org/world/walletkit/TestHelpers.kt | 14 ++- .../WalletKitTests/CredentialStoreTests.swift | 91 ++++++++++++++++++- swift/tests/WalletKitTests/SimpleTest.swift | 2 +- swift/tests/WalletKitTests/TestHelpers.swift | 12 ++- 5 files changed, 172 insertions(+), 13 deletions(-) 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 index 954def634..5b3f82c53 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt @@ -1,11 +1,46 @@ package org.world.walletkit import uniffi.walletkit_core.CredentialStore +import uniffi.walletkit_core.StorageException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull 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 storeAndCacheFlows() { val root = tempDirectory() @@ -13,6 +48,7 @@ class CredentialStoreTests { val store = CredentialStore.fromProviderArc(provider) store.`init`(leafIndex = 42UL, now = 100UL) + assertNull(store.merkleCacheGet(validUntil = 100UL)) val credentialId = store.storeCredential( @@ -22,7 +58,6 @@ class CredentialStoreTests { associatedData = byteArrayOf(4, 5, 6), now = 100UL, ) - assertEquals(1UL, credentialId) val records = store.listCredentials(issuerSchemaId = null, now = 101UL) assertEquals(1, records.size) @@ -47,4 +82,33 @@ class CredentialStoreTests { 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() + } } 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 index 915b74edf..4a4769f19 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt @@ -20,10 +20,16 @@ fun tempDirectory(): File { return dir } -private const val SAMPLE_CREDENTIAL_JSON = - """{"id":13758530325042616850,"version":"V1","issuer_schema_id":7,"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":1800000000,"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"}""" - -fun sampleCredential(): Credential = Credential.fromBytes(SAMPLE_CREDENTIAL_JSON.encodeToByteArray()) +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) diff --git a/swift/tests/WalletKitTests/CredentialStoreTests.swift b/swift/tests/WalletKitTests/CredentialStoreTests.swift index f4f17127b..2a4d2db21 100644 --- a/swift/tests/WalletKitTests/CredentialStoreTests.swift +++ b/swift/tests/WalletKitTests/CredentialStoreTests.swift @@ -5,6 +5,58 @@ import XCTest 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 testStoreAndCacheFlows() throws { let root = makeTempDirectory() defer { try? FileManager.default.removeItem(at: root) } @@ -24,6 +76,7 @@ final class CredentialStoreTests: XCTestCase { ) try store.`init`(leafIndex: 42, now: 100) + XCTAssertNil(try store.merkleCacheGet(validUntil: 100)) let credentialId = try store.storeCredential( credential: sampleCredential(), @@ -33,8 +86,6 @@ final class CredentialStoreTests: XCTestCase { now: 100 ) - XCTAssertEqual(credentialId, 1) - let records = try store.listCredentials(issuerSchemaId: Optional.none, now: 101) XCTAssertEqual(records.count, 1) let record = records[0] @@ -55,4 +106,40 @@ final class CredentialStoreTests: XCTestCase { 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) + } } diff --git a/swift/tests/WalletKitTests/SimpleTest.swift b/swift/tests/WalletKitTests/SimpleTest.swift index 50f5d2ffd..7da1dce8a 100644 --- a/swift/tests/WalletKitTests/SimpleTest.swift +++ b/swift/tests/WalletKitTests/SimpleTest.swift @@ -24,7 +24,7 @@ final class SimpleTest: XCTestCase { WalletKit.initLogging(logger: logger, level: .info) WalletKit.emitLog(level: .info, message: "bridge test") - Thread.sleep(forTimeInterval: 0.001) + Thread.sleep(forTimeInterval: 0.05) let entries = logger.snapshot() XCTAssertFalse(entries.isEmpty, "expected at least one bridged log entry") diff --git a/swift/tests/WalletKitTests/TestHelpers.swift b/swift/tests/WalletKitTests/TestHelpers.swift index 2d11688c5..69a359fc3 100644 --- a/swift/tests/WalletKitTests/TestHelpers.swift +++ b/swift/tests/WalletKitTests/TestHelpers.swift @@ -2,10 +2,6 @@ import Foundation import Security @testable import WalletKit -private let sampleCredentialJSON = """ -{"id":13758530325042616850,"version":"V1","issuer_schema_id":7,"sub":"0x114edc9e30c245ac8e1f98375f71668a9cd4e9f1e3e9b3385a1801e9d43d731b","genesis_issued_at":1700000000,"expires_at":1800000000,"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"} -""" - func makeTempDirectory() -> URL { let url = FileManager.default.temporaryDirectory.appendingPathComponent( "walletkit-tests-\(UUID().uuidString)", @@ -28,7 +24,13 @@ func deleteKeychainItem(service: String, account: String) { SecItemDelete(query as CFDictionary) } -func sampleCredential() throws -> Credential { +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) } From c3ed2a4ac16d19a2cbe17977bb4907261a58f2f7 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 5 Mar 2026 11:36:06 -0800 Subject: [PATCH 7/7] add tests --- .../world/walletkit/CredentialStoreTests.kt | 137 +++++++++++++++ .../world/walletkit/DeviceKeystoreTests.kt | 14 ++ .../kotlin/org/world/walletkit/TestHelpers.kt | 12 +- .../WalletKitTests/CredentialStoreTests.swift | 156 ++++++++++++++++++ .../WalletKitTests/DeviceKeystoreTests.swift | 21 +++ 5 files changed, 336 insertions(+), 4 deletions(-) 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 index 5b3f82c53..a7c994cc1 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/CredentialStoreTests.kt @@ -3,9 +3,11 @@ 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 @@ -41,6 +43,31 @@ class CredentialStoreTests { 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() @@ -111,4 +138,114 @@ class CredentialStoreTests { 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 index 75d0aeb47..ac19ea5a4 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/DeviceKeystoreTests.kt @@ -27,4 +27,18 @@ class DeviceKeystoreTests { 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/TestHelpers.kt b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt index 4a4769f19..fc22656b5 100644 --- a/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt +++ b/kotlin/walletkit-tests/src/test/kotlin/org/world/walletkit/TestHelpers.kt @@ -20,6 +20,8 @@ fun tempDirectory(): File { return dir } +fun randomKeystoreKeyBytes(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } + fun sampleCredential( issuerSchemaId: ULong = 7UL, expiresAt: ULong = 1_800_000_000UL, @@ -33,8 +35,10 @@ fun sampleCredential( fun sampleBlindingFactor(): FieldElement = FieldElement.fromU64(17UL) -class InMemoryDeviceKeystore : DeviceKeystore { - private val keyBytes = ByteArray(32).also { SecureRandom().nextBytes(it) } +class InMemoryDeviceKeystore( + keyBytes: ByteArray = randomKeystoreKeyBytes(), +) : DeviceKeystore { + private val keyBytes = keyBytes.copyOf() override fun seal( associatedData: ByteArray, @@ -117,12 +121,12 @@ class FileBlobStore( class InMemoryStorageProvider( private val root: File, + private val keystoreImpl: DeviceKeystore = InMemoryDeviceKeystore(), ) : StorageProvider { - private val keystore = InMemoryDeviceKeystore() private val blobStore = FileBlobStore(File(root, "worldid")) private val paths = StoragePaths.fromRoot(root.absolutePath) - override fun keystore(): DeviceKeystore = keystore + override fun keystore(): DeviceKeystore = keystoreImpl override fun blobStore(): AtomicBlobStore = blobStore diff --git a/swift/tests/WalletKitTests/CredentialStoreTests.swift b/swift/tests/WalletKitTests/CredentialStoreTests.swift index 2a4d2db21..487fb7cb7 100644 --- a/swift/tests/WalletKitTests/CredentialStoreTests.swift +++ b/swift/tests/WalletKitTests/CredentialStoreTests.swift @@ -57,6 +57,37 @@ final class CredentialStoreTests: XCTestCase { } } + 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) } @@ -142,4 +173,129 @@ final class CredentialStoreTests: XCTestCase { 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 index b38b2ea50..ad110842b 100644 --- a/swift/tests/WalletKitTests/DeviceKeystoreTests.swift +++ b/swift/tests/WalletKitTests/DeviceKeystoreTests.swift @@ -46,4 +46,25 @@ final class DeviceKeystoreTests: XCTestCase { ) ) } + + 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) + } }