From f6965a9c8a09263e5191b45ac0d401fc256b0ae3 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:12:17 -0800 Subject: [PATCH 01/46] 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 02/46] 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 c4947a864572f1af19935692d02bbfe31bd8908f Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:47:54 -0800 Subject: [PATCH 03/46] check requestId uniqueness before proof gen --- walletkit-core/src/authenticator/storage.rs | 6 +++ walletkit-core/src/storage/cache/mod.rs | 41 ++++++++++++++++++ .../src/storage/cache/nullifiers.rs | 18 ++++++++ .../src/storage/credential_storage.rs | 43 +++++++++++++++++++ .../tests/credential_storage_integration.rs | 3 ++ 5 files changed, 111 insertions(+) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 15d70297b..9a7c84c29 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -92,6 +92,12 @@ impl Authenticator { now: u64, ttl_seconds: u64, ) -> Result { + if let Some(bytes) = storage + .proof_disclosure_get(request_id, now) + .map_err(WalletKitError::from)? + { + return Ok(ProofDisclosureResult::Replay(bytes)); + } let (proof, nullifier) = self .0 .generate_proof(proof_request, credential, credential_sub_blinding_factor) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index c4c5c983d..3f9e2e9f8 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -108,6 +108,19 @@ impl CacheDb { session::put(&self.conn, rp_id, k_session, now, ttl_seconds) } + /// Checks for a prior disclosure by request id. + /// + /// # Errors + /// + /// Returns an error if the query fails. + pub fn proof_disclosure_get( + &self, + request_id: [u8; 32], + now: u64, + ) -> StorageResult>> { + nullifiers::proof_bytes_for_request_id(&self.conn, request_id, now) + } + /// Enforces replay safety for proof disclosure. /// /// # Errors @@ -279,6 +292,34 @@ mod tests { cleanup_lock_file(&lock_path); } + #[test] + fn test_disclosure_request_id_lookup() { + let path = temp_cache_path(); + let key = [0x66u8; 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 = [0x55u8; 32]; + let nullifier = [0x44u8; 32]; + let payload = vec![4, 5, 6]; + + db.begin_proof_disclosure(&guard, request_id, nullifier, payload.clone(), 100, 10) + .expect("disclosure"); + + let hit = db + .proof_disclosure_get(request_id, 105) + .expect("lookup"); + assert_eq!(hit, Some(payload)); + + let miss = db + .proof_disclosure_get(request_id, 111) + .expect("lookup"); + assert!(miss.is_none()); + cleanup_cache_files(&path); + cleanup_lock_file(&lock_path); + } + #[test] fn test_disclosure_nullifier_conflict_errors() { let path = temp_cache_path(); diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index 0575fdf93..9043e5278 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -7,6 +7,24 @@ use crate::storage::types::ProofDisclosureResult; use super::util::{expiry_timestamp, map_db_err, to_i64}; +pub(super) fn proof_bytes_for_request_id( + conn: &Connection, + request_id: [u8; 32], + now: u64, +) -> StorageResult>> { + let now_i64 = to_i64(now, "now")?; + conn.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)) +} + pub(super) fn begin_proof_disclosure( conn: &mut Connection, request_id: [u8; 32], diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index a953f3d9c..190630039 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -78,6 +78,17 @@ pub trait CredentialStorage { ttl_seconds: u64, ) -> StorageResult<()>; + /// Checks for a prior disclosure by request id. + /// + /// # Errors + /// + /// Returns an error if the cache lookup fails. + fn proof_disclosure_get( + &self, + request_id: RequestId, + now: u64, + ) -> StorageResult>>; + /// Enforces replay safety for proof disclosure. /// /// # Errors @@ -306,6 +317,20 @@ impl CredentialStore { ) } + /// Checks for a prior disclosure by request id. + /// + /// # Errors + /// + /// Returns an error if the cache lookup fails. + pub fn proof_disclosure_get( + &self, + request_id: Vec, + now: u64, + ) -> StorageResult>> { + let request_id = parse_fixed_bytes::<32>(request_id, "request_id")?; + self.lock_inner()?.proof_disclosure_get(request_id, now) + } + /// Enforces replay safety for proof disclosure. /// /// # Errors @@ -452,6 +477,15 @@ impl CredentialStorage for CredentialStoreInner { ) } + fn proof_disclosure_get( + &self, + request_id: RequestId, + now: u64, + ) -> StorageResult>> { + let state = self.state()?; + state.cache.proof_disclosure_get(request_id, now) + } + fn begin_proof_disclosure( &mut self, request_id: RequestId, @@ -574,6 +608,15 @@ impl CredentialStorage for CredentialStore { inner.merkle_cache_put(registry_kind, root, proof_bytes, now, ttl_seconds) } + fn proof_disclosure_get( + &self, + request_id: RequestId, + now: u64, + ) -> StorageResult>> { + let inner = self.lock_inner()?; + inner.proof_disclosure_get(request_id, now) + } + fn begin_proof_disclosure( &mut self, request_id: RequestId, diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index 519b1f9fd..ea23eafcf 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -241,6 +241,9 @@ fn test_storage_flow_end_to_end() { ) .expect("disclose"); assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![1, 2])); + let cached = CredentialStorage::proof_disclosure_get(&store, request_id, 210) + .expect("disclosure lookup"); + assert_eq!(cached, Some(vec![1, 2])); let replay = CredentialStorage::begin_proof_disclosure( &mut store, request_id, From 775617c2ddcbcbd8f3a69745720020f085cac1dc Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:50:59 -0800 Subject: [PATCH 04/46] proof disclosure -> replay guard --- walletkit-core/src/authenticator/storage.rs | 10 ++--- walletkit-core/src/storage/cache/mod.rs | 44 +++++++++---------- .../src/storage/cache/nullifiers.rs | 12 ++--- .../src/storage/credential_storage.rs | 44 +++++++++---------- walletkit-core/src/storage/mod.rs | 4 +- walletkit-core/src/storage/types.rs | 26 +++++------ .../tests/credential_storage_integration.rs | 12 ++--- 7 files changed, 76 insertions(+), 76 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 9a7c84c29..550af284a 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -7,7 +7,7 @@ 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 crate::storage::{CredentialStorage, ReplayGuardResult, RequestId}; use super::Authenticator; @@ -91,12 +91,12 @@ impl Authenticator { request_id: RequestId, now: u64, ttl_seconds: u64, - ) -> Result { + ) -> Result { if let Some(bytes) = storage - .proof_disclosure_get(request_id, now) + .replay_guard_get(request_id, now) .map_err(WalletKitError::from)? { - return Ok(ProofDisclosureResult::Replay(bytes)); + return Ok(ReplayGuardResult::Replay(bytes)); } let (proof, nullifier) = self .0 @@ -105,7 +105,7 @@ impl Authenticator { let proof_bytes = serialize_proof_package(&proof, nullifier)?; let nullifier_bytes = field_element_to_bytes(nullifier); storage - .begin_proof_disclosure( + .begin_replay_guard( request_id, nullifier_bytes, proof_bytes, diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 3f9e2e9f8..65ee917bb 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -6,7 +6,7 @@ use rusqlite::Connection; use crate::storage::error::StorageResult; use crate::storage::lock::StorageLockGuard; -use crate::storage::types::ProofDisclosureResult; +use crate::storage::types::ReplayGuardResult; mod maintenance; mod merkle; @@ -113,12 +113,12 @@ impl CacheDb { /// # Errors /// /// Returns an error if the query fails. - pub fn proof_disclosure_get( + pub fn replay_guard_get( &self, request_id: [u8; 32], now: u64, ) -> StorageResult>> { - nullifiers::proof_bytes_for_request_id(&self.conn, request_id, now) + nullifiers::replay_guard_bytes_for_request_id(&self.conn, request_id, now) } /// Enforces replay safety for proof disclosure. @@ -126,7 +126,7 @@ impl CacheDb { /// # Errors /// /// Returns an error if the disclosure conflicts with an existing nullifier. - pub fn begin_proof_disclosure( + pub fn begin_replay_guard( &mut self, _lock: &StorageLockGuard, request_id: [u8; 32], @@ -134,8 +134,8 @@ impl CacheDb { proof_bytes: Vec, now: u64, ttl_seconds: u64, - ) -> StorageResult { - nullifiers::begin_proof_disclosure( + ) -> StorageResult { + nullifiers::begin_replay_guard( &mut self.conn, request_id, nullifier, @@ -260,7 +260,7 @@ mod tests { } #[test] - fn test_disclosure_replay_returns_original_bytes() { + fn test_replay_guard_replay_returns_original_bytes() { let path = temp_cache_path(); let key = [0x77u8; 32]; let lock_path = temp_lock_path(); @@ -273,7 +273,7 @@ mod tests { let second = vec![9, 9, 9]; let fresh = db - .begin_proof_disclosure( + .begin_replay_guard( &guard, request_id, nullifier, @@ -282,18 +282,18 @@ mod tests { 1000, ) .expect("first disclosure"); - assert_eq!(fresh, ProofDisclosureResult::Fresh(first.clone())); + assert_eq!(fresh, ReplayGuardResult::Fresh(first.clone())); let replay = db - .begin_proof_disclosure(&guard, request_id, nullifier, second, 101, 1000) + .begin_replay_guard(&guard, request_id, nullifier, second, 101, 1000) .expect("replay disclosure"); - assert_eq!(replay, ProofDisclosureResult::Replay(first)); + assert_eq!(replay, ReplayGuardResult::Replay(first)); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } #[test] - fn test_disclosure_request_id_lookup() { + fn test_replay_guard_request_id_lookup() { let path = temp_cache_path(); let key = [0x66u8; 32]; let lock_path = temp_lock_path(); @@ -304,16 +304,16 @@ mod tests { let nullifier = [0x44u8; 32]; let payload = vec![4, 5, 6]; - db.begin_proof_disclosure(&guard, request_id, nullifier, payload.clone(), 100, 10) + db.begin_replay_guard(&guard, request_id, nullifier, payload.clone(), 100, 10) .expect("disclosure"); let hit = db - .proof_disclosure_get(request_id, 105) + .replay_guard_get(request_id, 105) .expect("lookup"); assert_eq!(hit, Some(payload)); let miss = db - .proof_disclosure_get(request_id, 111) + .replay_guard_get(request_id, 111) .expect("lookup"); assert!(miss.is_none()); cleanup_cache_files(&path); @@ -321,7 +321,7 @@ mod tests { } #[test] - fn test_disclosure_nullifier_conflict_errors() { + fn test_replay_guard_nullifier_conflict_errors() { let path = temp_cache_path(); let key = [0x88u8; 32]; let lock_path = temp_lock_path(); @@ -332,11 +332,11 @@ mod tests { let request_id_b = [0x02u8; 32]; let nullifier = [0x03u8; 32]; - db.begin_proof_disclosure(&guard, request_id_a, nullifier, vec![4], 100, 1000) + db.begin_replay_guard(&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) + .begin_replay_guard(&guard, request_id_b, nullifier, vec![5], 101, 1000) .expect_err("nullifier conflict"); match err { StorageError::NullifierAlreadyDisclosed => {} @@ -347,7 +347,7 @@ mod tests { } #[test] - fn test_disclosure_expiry_allows_new_insert() { + fn test_replay_guard_expiry_allows_new_insert() { let path = temp_cache_path(); let key = [0x99u8; 32]; let lock_path = temp_lock_path(); @@ -358,13 +358,13 @@ mod tests { let request_id_b = [0x0Bu8; 32]; let nullifier = [0x0Cu8; 32]; - db.begin_proof_disclosure(&guard, request_id_a, nullifier, vec![7], 100, 10) + db.begin_replay_guard(&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) + .begin_replay_guard(&guard, request_id_b, nullifier, vec![8], 111, 10) .expect("second disclosure after expiry"); - assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![8])); + assert_eq!(fresh, ReplayGuardResult::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 9043e5278..fd3882361 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -3,11 +3,11 @@ use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::ProofDisclosureResult; +use crate::storage::types::ReplayGuardResult; use super::util::{expiry_timestamp, map_db_err, to_i64}; -pub(super) fn proof_bytes_for_request_id( +pub(super) fn replay_guard_bytes_for_request_id( conn: &Connection, request_id: [u8; 32], now: u64, @@ -25,14 +25,14 @@ pub(super) fn proof_bytes_for_request_id( .map_err(|err| map_db_err(&err)) } -pub(super) fn begin_proof_disclosure( +pub(super) fn begin_replay_guard( conn: &mut Connection, request_id: [u8; 32], nullifier: [u8; 32], proof_bytes: Vec, now: u64, ttl_seconds: u64, -) -> StorageResult { +) -> StorageResult { let now_i64 = to_i64(now, "now")?; let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) @@ -56,7 +56,7 @@ pub(super) fn begin_proof_disclosure( .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)); + return Ok(ReplayGuardResult::Replay(bytes)); } let existing_request: Option> = tx @@ -88,5 +88,5 @@ pub(super) fn begin_proof_disclosure( ) .map_err(|err| map_db_err(&err))?; tx.commit().map_err(|err| map_db_err(&err))?; - Ok(ProofDisclosureResult::Fresh(proof_bytes)) + Ok(ReplayGuardResult::Fresh(proof_bytes)) } diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 190630039..e5921a8fd 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -10,7 +10,7 @@ use super::traits::StorageProvider; use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ CredentialId, CredentialRecord, CredentialRecordFfi, CredentialStatus, Nullifier, - ProofDisclosureResult, ProofDisclosureResultFfi, RequestId, + ReplayGuardResult, ReplayGuardResultFfi, RequestId, }; use super::{CacheDb, VaultDb}; @@ -78,12 +78,12 @@ pub trait CredentialStorage { ttl_seconds: u64, ) -> StorageResult<()>; - /// Checks for a prior disclosure by request id. + /// Checks for a prior replay guard entry by request id. /// /// # Errors /// /// Returns an error if the cache lookup fails. - fn proof_disclosure_get( + fn replay_guard_get( &self, request_id: RequestId, now: u64, @@ -95,14 +95,14 @@ pub trait CredentialStorage { /// /// Returns an error if the nullifier is already disclosed or the cache /// operation fails. - fn begin_proof_disclosure( + fn begin_replay_guard( &mut self, request_id: RequestId, nullifier: Nullifier, proof_bytes: Vec, now: u64, ttl_seconds: u64, - ) -> StorageResult; + ) -> StorageResult; } /// Concrete storage implementation backed by `SQLCipher` databases. @@ -317,18 +317,18 @@ impl CredentialStore { ) } - /// Checks for a prior disclosure by request id. + /// Checks for a prior replay guard entry by request id. /// /// # Errors /// /// Returns an error if the cache lookup fails. - pub fn proof_disclosure_get( + pub fn replay_guard_get( &self, request_id: Vec, now: u64, ) -> StorageResult>> { let request_id = parse_fixed_bytes::<32>(request_id, "request_id")?; - self.lock_inner()?.proof_disclosure_get(request_id, now) + self.lock_inner()?.replay_guard_get(request_id, now) } /// Enforces replay safety for proof disclosure. @@ -336,24 +336,24 @@ impl CredentialStore { /// # Errors /// /// Returns an error if the disclosure conflicts or storage fails. - pub fn begin_proof_disclosure( + pub fn begin_replay_guard( &self, request_id: Vec, nullifier: Vec, proof_bytes: Vec, now: u64, ttl_seconds: u64, - ) -> StorageResult { + ) -> 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( + let result = self.lock_inner()?.begin_replay_guard( request_id, nullifier, proof_bytes, now, ttl_seconds, )?; - Ok(ProofDisclosureResultFfi::from(result)) + Ok(ReplayGuardResultFfi::from(result)) } } @@ -477,26 +477,26 @@ impl CredentialStorage for CredentialStoreInner { ) } - fn proof_disclosure_get( + fn replay_guard_get( &self, request_id: RequestId, now: u64, ) -> StorageResult>> { let state = self.state()?; - state.cache.proof_disclosure_get(request_id, now) + state.cache.replay_guard_get(request_id, now) } - fn begin_proof_disclosure( + fn begin_replay_guard( &mut self, request_id: RequestId, nullifier: Nullifier, proof_bytes: Vec, now: u64, ttl_seconds: u64, - ) -> StorageResult { + ) -> StorageResult { let guard = self.guard()?; let state = self.state_mut()?; - state.cache.begin_proof_disclosure( + state.cache.begin_replay_guard( &guard, request_id, nullifier, @@ -608,25 +608,25 @@ impl CredentialStorage for CredentialStore { inner.merkle_cache_put(registry_kind, root, proof_bytes, now, ttl_seconds) } - fn proof_disclosure_get( + fn replay_guard_get( &self, request_id: RequestId, now: u64, ) -> StorageResult>> { let inner = self.lock_inner()?; - inner.proof_disclosure_get(request_id, now) + inner.replay_guard_get(request_id, now) } - fn begin_proof_disclosure( + fn begin_replay_guard( &mut self, request_id: RequestId, nullifier: Nullifier, proof_bytes: Vec, now: u64, ttl_seconds: u64, - ) -> StorageResult { + ) -> StorageResult { let mut inner = self.lock_inner()?; - inner.begin_proof_disclosure( + inner.begin_replay_guard( request_id, nullifier, proof_bytes, diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index a80551f82..492e483a6 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -21,8 +21,8 @@ pub use paths::StoragePaths; pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; pub use types::{ BlobKind, ContentId, CredentialId, CredentialRecord, CredentialRecordFfi, - CredentialStatus, Nullifier, ProofDisclosureKind, ProofDisclosureResult, - ProofDisclosureResultFfi, RequestId, + CredentialStatus, Nullifier, ReplayGuardKind, ReplayGuardResult, + ReplayGuardResultFfi, RequestId, }; pub use vault::VaultDb; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 4af5c5ec0..4f532da23 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -121,29 +121,29 @@ pub struct CredentialRecordFfi { pub associated_data: Option>, } -/// Result of proof disclosure enforcement. +/// Result of replay guard enforcement. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ProofDisclosureResult { +pub enum ReplayGuardResult { /// 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. +/// FFI-friendly replay guard result kind. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] -pub enum ProofDisclosureKind { +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. +/// FFI-friendly replay guard result. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct ProofDisclosureResultFfi { +pub struct ReplayGuardResultFfi { /// Result kind. - pub kind: ProofDisclosureKind, + pub kind: ReplayGuardKind, /// Stored proof package bytes. pub bytes: Vec, } @@ -164,15 +164,15 @@ impl From for CredentialRecordFfi { } } -impl From for ProofDisclosureResultFfi { - fn from(result: ProofDisclosureResult) -> Self { +impl From for ReplayGuardResultFfi { + fn from(result: ReplayGuardResult) -> Self { match result { - ProofDisclosureResult::Fresh(bytes) => Self { - kind: ProofDisclosureKind::Fresh, + ReplayGuardResult::Fresh(bytes) => Self { + kind: ReplayGuardKind::Fresh, bytes, }, - ProofDisclosureResult::Replay(bytes) => Self { - kind: ProofDisclosureKind::Replay, + ReplayGuardResult::Replay(bytes) => Self { + kind: ReplayGuardKind::Replay, bytes, }, } diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index ea23eafcf..a06e7b2f8 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use walletkit_core::storage::{ AtomicBlobStore, CredentialStatus, CredentialStorage, CredentialStore, - DeviceKeystore, ProofDisclosureResult, StoragePaths, StorageProvider, + DeviceKeystore, ReplayGuardResult, StoragePaths, StorageProvider, }; struct InMemoryKeystore { @@ -231,7 +231,7 @@ fn test_storage_flow_end_to_end() { let request_id = [0xABu8; 32]; let nullifier = [0xCDu8; 32]; - let fresh = CredentialStorage::begin_proof_disclosure( + let fresh = CredentialStorage::begin_replay_guard( &mut store, request_id, nullifier, @@ -240,11 +240,11 @@ fn test_storage_flow_end_to_end() { 50, ) .expect("disclose"); - assert_eq!(fresh, ProofDisclosureResult::Fresh(vec![1, 2])); - let cached = CredentialStorage::proof_disclosure_get(&store, request_id, 210) + assert_eq!(fresh, ReplayGuardResult::Fresh(vec![1, 2])); + let cached = CredentialStorage::replay_guard_get(&store, request_id, 210) .expect("disclosure lookup"); assert_eq!(cached, Some(vec![1, 2])); - let replay = CredentialStorage::begin_proof_disclosure( + let replay = CredentialStorage::begin_replay_guard( &mut store, request_id, nullifier, @@ -253,7 +253,7 @@ fn test_storage_flow_end_to_end() { 50, ) .expect("replay"); - assert_eq!(replay, ProofDisclosureResult::Replay(vec![1, 2])); + assert_eq!(replay, ReplayGuardResult::Replay(vec![1, 2])); cleanup_storage(&root); } From 62599eea6e9bec19a5ec3cb23e70bf17bc33911d Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Wed, 21 Jan 2026 15:51:32 -0800 Subject: [PATCH 05/46] proof disclosure -> replay guard --- walletkit-core/src/storage/cache/mod.rs | 2 +- walletkit-core/src/storage/credential_storage.rs | 4 ++-- walletkit-core/src/storage/types.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 65ee917bb..4b135d525 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -121,7 +121,7 @@ impl CacheDb { nullifiers::replay_guard_bytes_for_request_id(&self.conn, request_id, now) } - /// Enforces replay safety for proof disclosure. + /// Enforces replay safety for replay guard. /// /// # Errors /// diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index e5921a8fd..740cc1cfd 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -89,7 +89,7 @@ pub trait CredentialStorage { now: u64, ) -> StorageResult>>; - /// Enforces replay safety for proof disclosure. + /// Enforces replay safety for replay guard. /// /// # Errors /// @@ -331,7 +331,7 @@ impl CredentialStore { self.lock_inner()?.replay_guard_get(request_id, now) } - /// Enforces replay safety for proof disclosure. + /// Enforces replay safety for replay guard. /// /// # Errors /// diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 4f532da23..6fe79cc0e 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -69,7 +69,7 @@ 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. From 5eee72e1f77802bbf2f4a3b866b49d399247a2e8 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 15:48:10 -0800 Subject: [PATCH 06/46] add merkle valid_before --- walletkit-core/src/authenticator/storage.rs | 9 +++++++-- walletkit-core/src/storage/cache/merkle.rs | 6 +++--- walletkit-core/src/storage/cache/mod.rs | 9 +++++---- .../src/storage/credential_storage.rs | 18 +++++++++--------- .../tests/credential_storage_integration.rs | 3 ++- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 550af284a..3e6d4b57c 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -11,6 +11,9 @@ use crate::storage::{CredentialStorage, ReplayGuardResult, RequestId}; use super::Authenticator; +/// Buffer cached proofs to remain valid during on-chain verification. +const MERKLE_PROOF_VALIDITY_BUFFER_SECS: u64 = 120; + impl Authenticator { /// Initializes storage using the authenticator's leaf index. /// @@ -52,7 +55,8 @@ impl Authenticator { (MerkleInclusionProof, AuthenticatorPublicKeySet), WalletKitError, > { - if let Some(bytes) = storage.merkle_cache_get(registry_kind, root, now)? { + let valid_before = now.saturating_add(MERKLE_PROOF_VALIDITY_BUFFER_SECS); + if let Some(bytes) = storage.merkle_cache_get(registry_kind, root, valid_before)? { if let Some(cached) = deserialize_inclusion_proof(&bytes) { return Ok((cached.proof, cached.authenticator_pubkeys)); } @@ -201,8 +205,9 @@ mod tests { store .merkle_cache_put(1, root_bytes.to_vec(), payload_bytes, 100, 60) .expect("cache put"); + let valid_before = 110; let cached = store - .merkle_cache_get(1, root_bytes.to_vec(), 110) + .merkle_cache_get(1, root_bytes.to_vec(), valid_before) .expect("cache get") .expect("cache hit"); let decoded = deserialize_inclusion_proof(&cached).expect("decode"); diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index 5d49f2f0c..f3f2b91ae 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -11,10 +11,10 @@ pub(super) fn get( registry_kind: u8, root: [u8; 32], leaf_index: u64, - now: u64, + valid_before: u64, ) -> StorageResult>> { let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; - let now_i64 = to_i64(now, "now")?; + let valid_before_i64 = to_i64(valid_before, "valid_before")?; let proof = conn .query_row( "SELECT proof_bytes @@ -27,7 +27,7 @@ pub(super) fn get( i64::from(registry_kind), root.as_ref(), leaf_index_i64, - now_i64 + valid_before_i64 ], |row| row.get(0), ) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 4b135d525..4d69bb035 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -36,7 +36,7 @@ impl CacheDb { Ok(Self { conn }) } - /// Fetches a cached Merkle proof if available. + /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. /// /// # Errors /// @@ -46,9 +46,9 @@ impl CacheDb { registry_kind: u8, root: [u8; 32], leaf_index: u64, - now: u64, + valid_before: u64, ) -> StorageResult>> { - merkle::get(&self.conn, registry_kind, root, leaf_index, now) + merkle::get(&self.conn, registry_kind, root, leaf_index, valid_before) } /// Inserts a cached Merkle proof with a TTL. @@ -227,8 +227,9 @@ mod tests { let root = [0xABu8; 32]; db.merkle_cache_put(&guard, 1, root, 42, vec![1, 2, 3], 100, 10) .expect("put merkle proof"); + let valid_before = 105; let hit = db - .merkle_cache_get(1, root, 42, 105) + .merkle_cache_get(1, root, 42, valid_before) .expect("get merkle proof"); assert!(hit.is_some()); let miss = db diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 740cc1cfd..7acb65dc2 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -52,7 +52,7 @@ pub trait CredentialStorage { now: u64, ) -> StorageResult; - /// Fetches a cached Merkle proof if available. + /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. /// /// # Errors /// @@ -61,7 +61,7 @@ pub trait CredentialStorage { &self, registry_kind: u8, root: [u8; 32], - now: u64, + valid_before: u64, ) -> StorageResult>>; /// Inserts a cached Merkle proof with a TTL. @@ -278,7 +278,7 @@ impl CredentialStore { 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 /// @@ -287,11 +287,11 @@ impl CredentialStore { &self, registry_kind: u8, root: Vec, - now: u64, + valid_before: u64, ) -> StorageResult>> { let root = parse_fixed_bytes::<32>(root, "root")?; self.lock_inner()? - .merkle_cache_get(registry_kind, root, now) + .merkle_cache_get(registry_kind, root, valid_before) } /// Inserts a cached Merkle proof with a TTL. @@ -448,12 +448,12 @@ impl CredentialStorage for CredentialStoreInner { &self, registry_kind: u8, root: [u8; 32], - now: u64, + valid_before: u64, ) -> StorageResult>> { let state = self.state()?; state .cache - .merkle_cache_get(registry_kind, root, state.leaf_index, now) + .merkle_cache_get(registry_kind, root, state.leaf_index, valid_before) } fn merkle_cache_put( @@ -590,10 +590,10 @@ impl CredentialStorage for CredentialStore { &self, registry_kind: u8, root: [u8; 32], - now: u64, + valid_before: u64, ) -> StorageResult>> { let inner = self.lock_inner()?; - inner.merkle_cache_get(registry_kind, root, now) + inner.merkle_cache_get(registry_kind, root, valid_before) } fn merkle_cache_put( diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index a06e7b2f8..8169b74c9 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -222,7 +222,8 @@ fn test_storage_flow_end_to_end() { 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) + let valid_before = 105; + let hit = CredentialStorage::merkle_cache_get(&store, 1, root_bytes, valid_before) .expect("cache get"); assert_eq!(hit, Some(vec![9, 9])); let miss = CredentialStorage::merkle_cache_get(&store, 1, root_bytes, 111) From 98168736e02509cffd33e9686dd113e84d0f235b Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 15:50:28 -0800 Subject: [PATCH 07/46] use db time for inserted_at --- walletkit-core/src/storage/cache/merkle.rs | 4 +--- walletkit-core/src/storage/cache/mod.rs | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index f3f2b91ae..a0ed3a372 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -48,7 +48,6 @@ pub(super) fn put( 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 ( @@ -58,13 +57,12 @@ pub(super) fn put( proof_bytes, inserted_at, expires_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) VALUES (?1, ?2, ?3, ?4, strftime('%s','now'), ?5)", params![ i64::from(registry_kind), root.as_ref(), leaf_index_i64, proof_bytes, - now_i64, expires_at_i64 ], ) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 4d69bb035..e15ba7270 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -52,6 +52,7 @@ impl CacheDb { } /// Inserts a cached Merkle proof with a TTL. + /// Uses the database current time for `inserted_at`. /// /// # Errors /// From 5b96260982faea1aeba1e6a118bbc4c6f4fa8973 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 15:51:55 -0800 Subject: [PATCH 08/46] remove now param for prune_expired --- walletkit-core/src/storage/cache/merkle.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index a0ed3a372..dfa247273 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -45,7 +45,7 @@ pub(super) fn put( now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - prune_expired(conn, now)?; + prune_expired(conn)?; let expires_at = expiry_timestamp(now, ttl_seconds); let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; @@ -70,11 +70,11 @@ pub(super) fn put( Ok(()) } -fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { - let now_i64 = to_i64(now, "now")?; +fn prune_expired(conn: &Connection) -> StorageResult<()> { conn.execute( - "DELETE FROM merkle_proof_cache WHERE expires_at <= ?1", - params![now_i64], + "DELETE FROM merkle_proof_cache + WHERE expires_at <= CAST(strftime('%s','now') AS INTEGER)", + [], ) .map_err(|err| map_db_err(&err))?; Ok(()) From 551286d7ad12a48e61a59907c1dc9a6492d10655 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 15:56:53 -0800 Subject: [PATCH 09/46] vault_meta upsert --- walletkit-core/src/storage/vault/mod.rs | 65 +++++++++------------- walletkit-core/src/storage/vault/schema.rs | 3 + 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 904c8ccc8..6c466d841 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -7,7 +7,7 @@ mod tests; use std::path::Path; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, Connection}; use uuid::Uuid; use super::error::{StorageError, StorageResult}; @@ -63,44 +63,33 @@ 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, + updated_at = CASE + WHEN vault_meta.leaf_index IS NULL + OR vault_meta.leaf_index = excluded.leaf_index + THEN excluded.updated_at + ELSE vault_meta.updated_at + END + RETURNING leaf_index", + params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], + |row| row.get::<_, 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(()) diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 61e699d0d..29632884f 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -15,6 +15,9 @@ 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 TABLE IF NOT EXISTS credential_records ( credential_id BLOB NOT NULL, issuer_schema_id INTEGER NOT NULL, From 8d3df314b676c80ef2b085d62674c40b940e428d Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:02:43 -0800 Subject: [PATCH 10/46] vault_meta update trigger --- walletkit-core/src/storage/vault/mod.rs | 6 ------ walletkit-core/src/storage/vault/schema.rs | 9 +++++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 6c466d841..c4e5bcfb6 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -72,12 +72,6 @@ impl VaultDb { WHEN vault_meta.leaf_index IS NULL THEN excluded.leaf_index ELSE vault_meta.leaf_index - END, - updated_at = CASE - WHEN vault_meta.leaf_index IS NULL - OR vault_meta.leaf_index = excluded.leaf_index - THEN excluded.updated_at - ELSE vault_meta.updated_at END RETURNING leaf_index", params![VAULT_SCHEMA_VERSION, leaf_index_i64, now_i64], diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 29632884f..5d0eea469 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -18,6 +18,15 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { 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, issuer_schema_id INTEGER NOT NULL, From fff71fb61844386a8b7e777e2ac7a903c300452f Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:04:42 -0800 Subject: [PATCH 11/46] dynamically build list_credentials --- walletkit-core/src/storage/vault/mod.rs | 95 +++++++++---------------- 1 file changed, 35 insertions(+), 60 deletions(-) diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index c4e5bcfb6..4d1c24442 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -7,7 +7,7 @@ mod tests; use std::path::Path; -use rusqlite::{params, Connection}; +use rusqlite::{params, params_from_iter, Connection}; use uuid::Uuid; use super::error::{StorageError, StorageResult}; @@ -186,7 +186,7 @@ impl VaultDb { /// # Errors /// /// Returns an error if the query fails. - pub fn list_credentials( + pub fn d( &self, issuer_schema_id: Option, now: u64, @@ -194,64 +194,39 @@ impl VaultDb { 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)?); - } + let issuer_schema_id_i64 = issuer_schema_id + .map(|value| to_i64(value, "issuer_schema_id")) + .transpose()?; + let mut sql = String::from( + "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)", + ); + let mut params: Vec<&dyn rusqlite::ToSql> = vec![&status, &expires]; + if let Some(ref issuer_schema_id_i64) = issuer_schema_id_i64 { + sql.push_str(" AND cr.issuer_schema_id = ?3"); + params.push(issuer_schema_id_i64); + } + sql.push_str(" ORDER BY cr.updated_at DESC"); + + let mut stmt = self.conn.prepare(&sql).map_err(|err| map_db_err(&err))?; + let mut rows = stmt + .query(params_from_iter(params)) + .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) } From f0ddba62c4abf20f8542b11212082ad9e2ae60cd Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:05:26 -0800 Subject: [PATCH 12/46] fix list_credentials --- walletkit-core/src/storage/vault/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 4d1c24442..0e54322c9 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -186,7 +186,7 @@ impl VaultDb { /// # Errors /// /// Returns an error if the query fails. - pub fn d( + pub fn list_credentials( &self, issuer_schema_id: Option, now: u64, From a5dce80a864bd92ba9900bd7bd3a88b8bb1b7307 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:10:14 -0800 Subject: [PATCH 13/46] remove CredentialStatus --- .../src/storage/credential_storage.rs | 9 +---- walletkit-core/src/storage/mod.rs | 3 +- walletkit-core/src/storage/types.rs | 38 ------------------- walletkit-core/src/storage/vault/helpers.rs | 18 ++++----- walletkit-core/src/storage/vault/mod.rs | 16 +++----- walletkit-core/src/storage/vault/schema.rs | 5 +-- walletkit-core/src/storage/vault/tests.rs | 7 ---- .../tests/credential_storage_integration.rs | 5 +-- 8 files changed, 18 insertions(+), 83 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 7acb65dc2..8e119272c 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -9,7 +9,7 @@ use super::paths::StoragePaths; use super::traits::StorageProvider; use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ - CredentialId, CredentialRecord, CredentialRecordFfi, CredentialStatus, Nullifier, + CredentialId, CredentialRecord, CredentialRecordFfi, Nullifier, ReplayGuardResult, ReplayGuardResultFfi, RequestId, }; use super::{CacheDb, VaultDb}; @@ -43,7 +43,6 @@ pub trait CredentialStorage { fn store_credential( &mut self, issuer_schema_id: u64, - status: CredentialStatus, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, expires_at: Option, @@ -253,7 +252,6 @@ impl CredentialStore { pub fn store_credential( &self, issuer_schema_id: u64, - status: CredentialStatus, subject_blinding_factor: Vec, genesis_issued_at: u64, expires_at: Option, @@ -267,7 +265,6 @@ impl CredentialStore { )?; let credential_id = self.lock_inner()?.store_credential( issuer_schema_id, - status, subject_blinding_factor, genesis_issued_at, expires_at, @@ -421,7 +418,6 @@ impl CredentialStorage for CredentialStoreInner { fn store_credential( &mut self, issuer_schema_id: u64, - status: CredentialStatus, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, expires_at: Option, @@ -434,7 +430,6 @@ impl CredentialStorage for CredentialStoreInner { state.vault.store_credential( &guard, issuer_schema_id, - status, subject_blinding_factor, genesis_issued_at, expires_at, @@ -565,7 +560,6 @@ impl CredentialStorage for CredentialStore { fn store_credential( &mut self, issuer_schema_id: u64, - status: CredentialStatus, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, expires_at: Option, @@ -576,7 +570,6 @@ impl CredentialStorage for CredentialStore { let mut inner = self.lock_inner()?; inner.store_credential( issuer_schema_id, - status, subject_blinding_factor, genesis_issued_at, expires_at, diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index 492e483a6..a9aa15bfd 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -21,8 +21,7 @@ pub use paths::StoragePaths; pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; pub use types::{ BlobKind, ContentId, CredentialId, CredentialRecord, CredentialRecordFfi, - CredentialStatus, Nullifier, ReplayGuardKind, ReplayGuardResult, - ReplayGuardResultFfi, RequestId, + Nullifier, ReplayGuardKind, ReplayGuardResult, ReplayGuardResultFfi, RequestId, }; pub use vault::VaultDb; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 6fe79cc0e..584a9e3af 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -2,39 +2,6 @@ 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)] @@ -82,8 +49,6 @@ pub struct CredentialRecord { 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). @@ -105,8 +70,6 @@ pub struct CredentialRecordFfi { 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). @@ -153,7 +116,6 @@ impl From for CredentialRecordFfi { 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, diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index d4426624f..f497a1915 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -3,7 +3,7 @@ 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}; const CONTENT_ID_PREFIX: &[u8] = b"worldid:blob"; @@ -21,27 +21,23 @@ pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> Conte 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))?; + row.get(2).map_err(|err| map_db_err(&err))?; + let genesis_issued_at: i64 = row.get(3).map_err(|err| map_db_err(&err))?; + let expires_at: Option = row.get(4).map_err(|err| map_db_err(&err))?; + let updated_at: i64 = row.get(5).map_err(|err| map_db_err(&err))?; + let credential_blob: Vec = row.get(6).map_err(|err| map_db_err(&err))?; let associated_data: Option> = - row.get(8).map_err(|err| map_db_err(&err))?; + row.get(7).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 diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 0e54322c9..f6a49ffd3 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use super::error::{StorageError, StorageResult}; use super::lock::StorageLockGuard; use super::sqlcipher; -use super::types::{BlobKind, CredentialId, CredentialRecord, CredentialStatus}; +use super::types::{BlobKind, CredentialId, CredentialRecord}; use helpers::{ compute_content_id, map_db_err, map_record, map_sqlcipher_err, to_i64, to_u64, }; @@ -100,7 +100,6 @@ impl VaultDb { &mut self, _lock: &StorageLockGuard, issuer_schema_id: u64, - status: CredentialStatus, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, expires_at: Option, @@ -158,18 +157,16 @@ impl VaultDb { 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)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", 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) @@ -192,7 +189,6 @@ impl VaultDb { now: u64, ) -> StorageResult> { let mut records = Vec::new(); - let status = CredentialStatus::Active.as_i64(); let expires = to_i64(now, "now")?; let issuer_schema_id_i64 = issuer_schema_id .map(|value| to_i64(value, "issuer_schema_id")) @@ -201,7 +197,6 @@ impl VaultDb { "SELECT cr.credential_id, cr.issuer_schema_id, - cr.status, cr.subject_blinding_factor, cr.genesis_issued_at, cr.expires_at, @@ -211,12 +206,11 @@ impl VaultDb { 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)", + WHERE (cr.expires_at IS NULL OR cr.expires_at > ?1)", ); - let mut params: Vec<&dyn rusqlite::ToSql> = vec![&status, &expires]; + let mut params: Vec<&dyn rusqlite::ToSql> = vec![&expires]; if let Some(ref issuer_schema_id_i64) = issuer_schema_id_i64 { - sql.push_str(" AND cr.issuer_schema_id = ?3"); + sql.push_str(" AND cr.issuer_schema_id = ?2"); params.push(issuer_schema_id_i64); } sql.push_str(" ORDER BY cr.updated_at DESC"); diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index 5d0eea469..dc3929dc4 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -33,7 +33,6 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { 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, @@ -41,10 +40,10 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { ); 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..16fb1ac70 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -106,7 +106,6 @@ fn test_store_credential_without_associated_data() { .store_credential( &guard, 10, - CredentialStatus::Active, sample_blinding_factor(), 123, None, @@ -133,7 +132,6 @@ fn test_store_credential_with_associated_data() { db.store_credential( &guard, 11, - CredentialStatus::Active, sample_blinding_factor(), 456, None, @@ -169,7 +167,6 @@ fn test_content_id_deduplication() { db.store_credential( &guard, 12, - CredentialStatus::Active, sample_blinding_factor(), 1, None, @@ -181,7 +178,6 @@ fn test_content_id_deduplication() { db.store_credential( &guard, 12, - CredentialStatus::Active, sample_blinding_factor(), 1, None, @@ -210,7 +206,6 @@ fn test_list_credentials_by_issuer() { db.store_credential( &guard, 100, - CredentialStatus::Active, sample_blinding_factor(), 1, None, @@ -222,7 +217,6 @@ fn test_list_credentials_by_issuer() { db.store_credential( &guard, 200, - CredentialStatus::Active, sample_blinding_factor(), 1, None, @@ -250,7 +244,6 @@ fn test_list_credentials_excludes_expired() { db.store_credential( &guard, 300, - CredentialStatus::Active, sample_blinding_factor(), 1, Some(900), diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index 8169b74c9..92f6376f8 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -11,8 +11,8 @@ use rand::{rngs::OsRng, RngCore}; use uuid::Uuid; use walletkit_core::storage::{ - AtomicBlobStore, CredentialStatus, CredentialStorage, CredentialStore, - DeviceKeystore, ReplayGuardResult, StoragePaths, StorageProvider, + AtomicBlobStore, CredentialStorage, CredentialStore, DeviceKeystore, + ReplayGuardResult, StoragePaths, StorageProvider, }; struct InMemoryKeystore { @@ -199,7 +199,6 @@ fn test_storage_flow_end_to_end() { let credential_id = CredentialStorage::store_credential( &mut store, 7, - CredentialStatus::Active, [0x11u8; 32], 1_700_000_000, Some(1_800_000_000), From b00d8f4e8496a21cda07fc893b903bec43458850 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:19:54 -0800 Subject: [PATCH 14/46] add storage feature flag --- walletkit-core/Cargo.toml | 17 +++++++++-------- walletkit-core/src/error.rs | 2 ++ walletkit-core/src/lib.rs | 1 + .../tests/credential_storage_integration.rs | 2 ++ 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index 25662446d..8975a8368 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -23,12 +23,11 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } alloy-primitives = { workspace = true } -bincode = "1.3" -chacha20poly1305 = "0.10" +bincode = { version = "1.3", optional = true } 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", @@ -42,18 +41,19 @@ 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"] } +rusqlite = { version = "0.32", features = ["bundled-sqlcipher"], optional = true } +uuid = { version = "1.10", features = ["v4"], optional = true } uniffi = { workspace = true, features = ["build", "tokio"] } world-id-core = { workspace = true, optional = true } [dev-dependencies] 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" mockito = "1.6" @@ -68,7 +68,8 @@ default = ["common-apps", "semaphore", "v4"] common-apps = [] http-tests = [] semaphore = ["semaphore-rs/depth_30"] -v4 = ["world-id-core"] +storage = ["dep:bincode", "dep:hkdf", "dep:rand", "dep:rusqlite", "dep:sha2", "dep:uuid"] +v4 = ["world-id-core", "storage"] # 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. diff --git a/walletkit-core/src/error.rs b/walletkit-core/src/error.rs index 3c86951b2..91e2c22d6 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -1,5 +1,6 @@ use thiserror::Error; +#[cfg(feature = "storage")] use crate::storage::StorageError; #[cfg(feature = "v4")] use world_id_core::AuthenticatorError; @@ -108,6 +109,7 @@ impl From for WalletKitError { } } +#[cfg(feature = "storage")] impl From for WalletKitError { fn from(error: StorageError) -> Self { Self::Generic { diff --git a/walletkit-core/src/lib.rs b/walletkit-core/src/lib.rs index 2bc4c0e2a..e145bfbf5 100644 --- a/walletkit-core/src/lib.rs +++ b/walletkit-core/src/lib.rs @@ -50,6 +50,7 @@ mod u256; pub use u256::U256Wrapper; /// Credential storage primitives for World ID v4. +#[cfg(feature = "storage")] pub mod storage; #[cfg(feature = "v4")] diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index 92f6376f8..fc25cfdf9 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "storage")] + use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; From 7e04ff4517011126180360ccf8f01c0a4029c437 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:32:52 -0800 Subject: [PATCH 15/46] improve docstrings --- walletkit-core/src/storage/cache/mod.rs | 20 +++++++++++++++++++ .../src/storage/cache/nullifiers.rs | 5 ++++- .../src/storage/credential_storage.rs | 6 ++++++ walletkit-core/src/storage/sqlcipher.rs | 5 +++++ walletkit-core/src/storage/types.rs | 9 +++++++++ walletkit-core/src/storage/vault/mod.rs | 6 ++++++ 6 files changed, 50 insertions(+), 1 deletion(-) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index e15ba7270..7d8a06bed 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -16,6 +16,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,6 +27,9 @@ 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. @@ -38,6 +44,9 @@ impl CacheDb { /// 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. @@ -54,6 +63,8 @@ impl CacheDb { /// 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. @@ -82,6 +93,8 @@ impl CacheDb { /// Fetches a cached session key if present. /// + /// Session keys are optional performance hints and may be missing or expired. + /// /// # Errors /// /// Returns an error if the query fails. @@ -95,6 +108,8 @@ impl CacheDb { /// 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. @@ -111,6 +126,8 @@ impl CacheDb { /// Checks for a prior disclosure by request id. /// + /// Returns the original proof bytes to make disclosure idempotent. + /// /// # Errors /// /// Returns an error if the query fails. @@ -124,6 +141,9 @@ impl CacheDb { /// Enforces replay safety for replay guard. /// + /// Ensures a nullifier is disclosed at most once and that repeated requests + /// return the previously stored proof bytes. + /// /// # Errors /// /// Returns an error if the disclosure conflicts with an existing nullifier. diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index fd3882361..460f7ffac 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -1,4 +1,7 @@ -//! 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}; diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 8e119272c..5352070a9 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -18,6 +18,9 @@ use super::{CacheDb, VaultDb}; pub trait CredentialStorage { /// Initializes storage and validates the account leaf index. /// + /// Loads or creates the account key envelope, opens the vault/cache, and + /// ensures the stored leaf index matches the provided value. + /// /// # Errors /// /// Returns an error if storage initialization fails or the leaf index is invalid. @@ -90,6 +93,9 @@ pub trait CredentialStorage { /// Enforces replay safety for replay guard. /// + /// Returns the stored proof bytes on replay and rejects nullifier reuse + /// across different request ids. + /// /// # Errors /// /// Returns an error if the nullifier is already disclosed or the cache diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs index 19e56b59d..a78cf9d82 100644 --- a/walletkit-core/src/storage/sqlcipher.rs +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -57,6 +57,9 @@ pub(super) fn apply_key( } /// Configures durable WAL settings. +/// +/// WAL improves read/write concurrency while `synchronous = FULL` prioritizes +/// durability for credential data. pub(super) fn configure_connection(conn: &Connection) -> SqlcipherResult<()> { conn.execute_batch( "PRAGMA foreign_keys = ON; @@ -67,6 +70,8 @@ pub(super) fn configure_connection(conn: &Connection) -> SqlcipherResult<()> { } /// Runs an integrity check. +/// +/// Uses `PRAGMA integrity_check` to detect corruption on open. pub(super) fn integrity_check(conn: &Connection) -> SqlcipherResult { let result: String = conn.query_row("PRAGMA integrity_check;", [], |row| row.get(0))?; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 584a9e3af..351374a83 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -3,6 +3,9 @@ use super::error::{StorageError, StorageResult}; /// Kind of blob stored in the vault. +/// +/// Blob records (stored in the `blob_objects` table) carry a kind tag that +/// distinguishes credential payloads from associated data. #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] #[repr(u8)] pub enum BlobKind { @@ -43,6 +46,9 @@ pub type RequestId = [u8; 32]; pub type Nullifier = [u8; 32]; /// In-memory representation of a stored credential. +/// +/// This struct joins vault metadata with the blob bytes so callers can consume +/// a single, self-contained record without additional lookups. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CredentialRecord { /// Credential identifier. @@ -85,6 +91,9 @@ pub struct CredentialRecordFfi { } /// Result of replay guard enforcement. +/// +/// The replay guard is idempotent: repeated calls with the same request return +/// the original proof bytes rather than generating a new disclosure. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReplayGuardResult { /// Stored bytes for the first disclosure of a request. diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index f6a49ffd3..f2158c622 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -51,6 +51,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. @@ -91,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. From e470c61096d31b34b57b311d96ffb310759284bc Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:38:20 -0800 Subject: [PATCH 16/46] credential_id -> int --- .../src/storage/credential_storage.rs | 4 +- walletkit-core/src/storage/types.rs | 8 +-- walletkit-core/src/storage/vault/helpers.rs | 5 +- walletkit-core/src/storage/vault/mod.rs | 51 +++++++++---------- walletkit-core/src/storage/vault/schema.rs | 5 +- walletkit-core/src/storage/vault/tests.rs | 1 + 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 5352070a9..0a9fc7567 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -264,7 +264,7 @@ impl CredentialStore { credential_blob: Vec, associated_data: Option>, now: u64, - ) -> StorageResult> { + ) -> StorageResult { let subject_blinding_factor = parse_fixed_bytes::<32>( subject_blinding_factor, "subject_blinding_factor", @@ -278,7 +278,7 @@ impl CredentialStore { associated_data, now, )?; - Ok(credential_id.to_vec()) + Ok(credential_id) } /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 351374a83..19ae3e74a 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -37,7 +37,9 @@ impl TryFrom for BlobKind { pub type ContentId = [u8; 32]; /// Credential identifier. -pub type CredentialId = [u8; 16]; +/// +/// Stored as a numeric value to align with protocol-level identifiers. +pub type CredentialId = u64; /// Request identifier for replay guard. pub type RequestId = [u8; 32]; @@ -73,7 +75,7 @@ pub struct CredentialRecord { #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct CredentialRecordFfi { /// Credential identifier. - pub credential_id: Vec, + pub credential_id: u64, /// Issuer schema identifier. pub issuer_schema_id: u64, /// Subject blinding factor tied to the credential subject. @@ -123,7 +125,7 @@ pub struct ReplayGuardResultFfi { impl From for CredentialRecordFfi { fn from(record: CredentialRecord) -> Self { Self { - credential_id: record.credential_id.to_vec(), + credential_id: record.credential_id, issuer_schema_id: record.issuer_schema_id, subject_blinding_factor: record.subject_blinding_factor.to_vec(), genesis_issued_at: record.genesis_issued_at, diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index f497a1915..d10531b43 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -19,7 +19,7 @@ pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> Conte } pub(super) fn map_record(row: &Row<'_>) -> StorageResult { - let credential_id_bytes: Vec = row.get(0).map_err(|err| map_db_err(&err))?; + let credential_id: i64 = 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 subject_blinding_factor_bytes: Vec = row.get(2).map_err(|err| map_db_err(&err))?; @@ -30,13 +30,12 @@ pub(super) fn map_record(row: &Row<'_>) -> StorageResult { let associated_data: Option> = row.get(7).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", )?; Ok(CredentialRecord { - credential_id, + credential_id: to_u64(credential_id, "credential_id")?, issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, subject_blinding_factor, genesis_issued_at: to_u64(genesis_issued_at, "genesis_issued_at")?, diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index f2158c622..6ac732451 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -8,7 +8,6 @@ mod tests; use std::path::Path; use rusqlite::{params, params_from_iter, Connection}; -use uuid::Uuid; use super::error::{StorageError, StorageResult}; use super::lock::StorageLockGuard; @@ -113,7 +112,6 @@ impl VaultDb { 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 @@ -156,32 +154,33 @@ impl VaultDb { .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, - updated_at, - credential_blob_cid, - associated_data_cid - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - credential_id.as_ref(), - issuer_schema_id_i64, - subject_blinding_factor.as_ref(), - genesis_issued_at_i64, - expires_at_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 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.as_ref(), + genesis_issued_at_i64, + expires_at_i64, + now_i64, + credential_blob_id.as_ref(), + associated_data_id.as_ref().map(AsRef::as_ref) + ], + |row| row.get::<_, i64>(0), + ) + .map_err(|err| map_db_err(&err))?; tx.commit().map_err(|err| map_db_err(&err))?; - Ok(credential_id) + Ok(to_u64(credential_id, "credential_id")?) } /// Lists active credentials, optionally filtered by issuer schema. diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index dc3929dc4..c8c44c5cb 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -28,15 +28,14 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { 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, 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 diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index 16fb1ac70..d4a30d21d 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::storage::lock::StorageLock; use std::fs; use std::path::{Path, PathBuf}; +use uuid::Uuid; fn temp_vault_path() -> PathBuf { let mut path = std::env::temp_dir(); From d5305fe25ba2a035b9997a38eaf549d7bf376d2d Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:46:16 -0800 Subject: [PATCH 17/46] update subject_blinding_factor docstring --- walletkit-core/src/storage/types.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 19ae3e74a..f7848c331 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -58,6 +58,9 @@ pub struct CredentialRecord { /// Issuer schema identifier. pub issuer_schema_id: u64, /// Subject blinding factor tied to the credential subject. + /// + /// Stored for indexing and query efficiency; the authoritative value lives + /// in the credential payload itself. pub subject_blinding_factor: [u8; 32], /// Genesis issuance timestamp (seconds). pub genesis_issued_at: u64, From 0fa6f0c1886251a3eca0dbdafd9c70169bf583bd Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:48:39 -0800 Subject: [PATCH 18/46] make list_credentials metadata only --- .../src/storage/credential_storage.rs | 4 +-- walletkit-core/src/storage/types.rs | 34 ++----------------- walletkit-core/src/storage/vault/helpers.rs | 34 +------------------ walletkit-core/src/storage/vault/mod.rs | 11 ++---- walletkit-core/src/storage/vault/tests.rs | 9 +++-- .../tests/credential_storage_integration.rs | 4 +-- 6 files changed, 13 insertions(+), 83 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 0a9fc7567..40594d097 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -26,7 +26,7 @@ pub trait CredentialStorage { /// 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. + /// Lists active credential metadata, optionally filtered by issuer schema ID. /// /// # Errors /// @@ -235,7 +235,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 /// diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index f7848c331..a8bac5a15 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -47,31 +47,18 @@ 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 struct joins vault metadata with the blob bytes so callers can consume -/// a single, self-contained record without additional lookups. +/// This is intentionally small and excludes blobs; full credential payloads can +/// be fetched separately to avoid heavy list queries. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CredentialRecord { /// Credential identifier. pub credential_id: CredentialId, /// Issuer schema identifier. pub issuer_schema_id: u64, - /// Subject blinding factor tied to the credential subject. - /// - /// Stored for indexing and query efficiency; the authoritative value lives - /// in the credential payload itself. - 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. @@ -81,18 +68,8 @@ pub struct CredentialRecordFfi { pub credential_id: u64, /// Issuer schema identifier. pub issuer_schema_id: u64, - /// 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 replay guard enforcement. @@ -130,12 +107,7 @@ impl From for CredentialRecordFfi { Self { credential_id: record.credential_id, issuer_schema_id: record.issuer_schema_id, - 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, } } } diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index d10531b43..fead75818 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -21,48 +21,16 @@ pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> Conte pub(super) fn map_record(row: &Row<'_>) -> StorageResult { let credential_id: i64 = 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 subject_blinding_factor_bytes: Vec = - row.get(2).map_err(|err| map_db_err(&err))?; - let genesis_issued_at: i64 = row.get(3).map_err(|err| map_db_err(&err))?; - let expires_at: Option = row.get(4).map_err(|err| map_db_err(&err))?; - let updated_at: i64 = row.get(5).map_err(|err| map_db_err(&err))?; - let credential_blob: Vec = row.get(6).map_err(|err| map_db_err(&err))?; - let associated_data: Option> = - row.get(7).map_err(|err| map_db_err(&err))?; - - let subject_blinding_factor = parse_fixed_bytes::<32>( - &subject_blinding_factor_bytes, - "subject_blinding_factor", - )?; + let expires_at: Option = row.get(2).map_err(|err| map_db_err(&err))?; Ok(CredentialRecord { credential_id: to_u64(credential_id, "credential_id")?, issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, - 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}")) diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 6ac732451..09a9ff2f4 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -183,7 +183,7 @@ impl VaultDb { Ok(to_u64(credential_id, "credential_id")?) } - /// Lists active credentials, optionally filtered by issuer schema. + /// Lists active credential metadata, optionally filtered by issuer schema. /// /// # Errors /// @@ -202,15 +202,8 @@ impl VaultDb { "SELECT cr.credential_id, cr.issuer_schema_id, - cr.subject_blinding_factor, - cr.genesis_issued_at, - cr.expires_at, - cr.updated_at, - cb.bytes, - ad.bytes + cr.expires_at 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.expires_at IS NULL OR cr.expires_at > ?1)", ); let mut params: Vec<&dyn rusqlite::ToSql> = vec![&expires]; diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index d4a30d21d..c3b7136cd 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -118,7 +118,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!(records[0].expires_at.is_none()); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -143,10 +144,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!(records[0].expires_at.is_none()); 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 index fc25cfdf9..6bf8e4d8e 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -216,9 +216,7 @@ fn test_storage_flow_end_to_end() { 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, Some(1_800_000_000)); let root_bytes = [0xAAu8; 32]; CredentialStorage::merkle_cache_put(&mut store, 1, root_bytes, vec![9, 9], 100, 10) From 4270685592ec11008828f4fa67327103614237de Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 16:52:27 -0800 Subject: [PATCH 19/46] remove credentialId --- walletkit-core/src/storage/credential_storage.rs | 8 ++++---- walletkit-core/src/storage/mod.rs | 4 ++-- walletkit-core/src/storage/types.rs | 7 +------ walletkit-core/src/storage/vault/mod.rs | 4 ++-- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 40594d097..aa392451f 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -9,7 +9,7 @@ use super::paths::StoragePaths; use super::traits::StorageProvider; use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ - CredentialId, CredentialRecord, CredentialRecordFfi, Nullifier, + CredentialRecord, CredentialRecordFfi, Nullifier, ReplayGuardResult, ReplayGuardResultFfi, RequestId, }; use super::{CacheDb, VaultDb}; @@ -52,7 +52,7 @@ pub trait CredentialStorage { credential_blob: Vec, associated_data: Option>, now: u64, - ) -> StorageResult; + ) -> StorageResult; /// Fetches a cached Merkle proof if it remains valid beyond `valid_before`. /// @@ -430,7 +430,7 @@ impl CredentialStorage for CredentialStoreInner { credential_blob: Vec, associated_data: Option>, now: u64, - ) -> StorageResult { + ) -> StorageResult { let guard = self.guard()?; let state = self.state_mut()?; state.vault.store_credential( @@ -572,7 +572,7 @@ impl CredentialStorage for CredentialStore { credential_blob: Vec, associated_data: Option>, now: u64, - ) -> StorageResult { + ) -> StorageResult { let mut inner = self.lock_inner()?; inner.store_credential( issuer_schema_id, diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index a9aa15bfd..67c83e4f3 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -20,8 +20,8 @@ pub use lock::{StorageLock, StorageLockGuard}; pub use paths::StoragePaths; pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; pub use types::{ - BlobKind, ContentId, CredentialId, CredentialRecord, CredentialRecordFfi, - Nullifier, ReplayGuardKind, ReplayGuardResult, ReplayGuardResultFfi, RequestId, + BlobKind, ContentId, CredentialRecord, CredentialRecordFfi, Nullifier, + ReplayGuardKind, ReplayGuardResult, ReplayGuardResultFfi, RequestId, }; pub use vault::VaultDb; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index a8bac5a15..bbf938f6e 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -36,11 +36,6 @@ impl TryFrom for BlobKind { /// Content identifier for stored blobs. pub type ContentId = [u8; 32]; -/// Credential identifier. -/// -/// Stored as a numeric value to align with protocol-level identifiers. -pub type CredentialId = u64; - /// Request identifier for replay guard. pub type RequestId = [u8; 32]; @@ -54,7 +49,7 @@ pub type Nullifier = [u8; 32]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CredentialRecord { /// Credential identifier. - pub credential_id: CredentialId, + pub credential_id: u64, /// Issuer schema identifier. pub issuer_schema_id: u64, /// Optional expiry timestamp (seconds). diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 09a9ff2f4..150ec0bcc 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -12,7 +12,7 @@ use rusqlite::{params, params_from_iter, Connection}; use super::error::{StorageError, StorageResult}; use super::lock::StorageLockGuard; use super::sqlcipher; -use super::types::{BlobKind, CredentialId, CredentialRecord}; +use super::types::{BlobKind, CredentialRecord}; use helpers::{ compute_content_id, map_db_err, map_record, map_sqlcipher_err, to_i64, to_u64, }; @@ -111,7 +111,7 @@ impl VaultDb { credential_blob: Vec, associated_data: Option>, now: u64, - ) -> StorageResult { + ) -> StorageResult { let credential_blob_id = compute_content_id(BlobKind::CredentialBlob, &credential_blob); let associated_data_id = associated_data From 32458ac2bab24565f53f40b9ff4a11507d5cb233 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:03:05 -0800 Subject: [PATCH 20/46] remove CredentialRecordFfi --- .../src/storage/credential_storage.rs | 9 ++++---- walletkit-core/src/storage/mod.rs | 4 ++-- walletkit-core/src/storage/types.rs | 23 +------------------ 3 files changed, 7 insertions(+), 29 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index aa392451f..36221da9f 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -9,8 +9,8 @@ use super::paths::StoragePaths; use super::traits::StorageProvider; use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ - CredentialRecord, CredentialRecordFfi, Nullifier, - ReplayGuardResult, ReplayGuardResultFfi, RequestId, + CredentialRecord, Nullifier, ReplayGuardResult, ReplayGuardResultFfi, + RequestId, }; use super::{CacheDb, VaultDb}; @@ -244,9 +244,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. diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index 67c83e4f3..61556f8b4 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -20,8 +20,8 @@ pub use lock::{StorageLock, StorageLockGuard}; pub use paths::StoragePaths; pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; pub use types::{ - BlobKind, ContentId, CredentialRecord, CredentialRecordFfi, Nullifier, - ReplayGuardKind, ReplayGuardResult, ReplayGuardResultFfi, RequestId, + BlobKind, ContentId, CredentialRecord, Nullifier, ReplayGuardKind, + ReplayGuardResult, ReplayGuardResultFfi, RequestId, }; pub use vault::VaultDb; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index bbf938f6e..37f5bd373 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -46,19 +46,8 @@ pub type Nullifier = [u8; 32]; /// /// This is intentionally small and excludes blobs; full credential payloads can /// be fetched separately to avoid heavy list queries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CredentialRecord { - /// Credential identifier. - pub credential_id: u64, - /// Issuer schema identifier. - pub issuer_schema_id: u64, - /// Optional expiry timestamp (seconds). - pub expires_at: Option, -} - -/// FFI-friendly credential record. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct CredentialRecordFfi { +pub struct CredentialRecord { /// Credential identifier. pub credential_id: u64, /// Issuer schema identifier. @@ -97,16 +86,6 @@ pub struct ReplayGuardResultFfi { pub bytes: Vec, } -impl From for CredentialRecordFfi { - fn from(record: CredentialRecord) -> Self { - Self { - credential_id: record.credential_id, - issuer_schema_id: record.issuer_schema_id, - expires_at: record.expires_at, - } - } -} - impl From for ReplayGuardResultFfi { fn from(result: ReplayGuardResult) -> Self { match result { From 97a09915b299078cffecbbd97a07cb4e0d0fda73 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:08:23 -0800 Subject: [PATCH 21/46] refactor ReplayGuardResultFfi --- walletkit-core/src/authenticator/storage.rs | 9 ++++-- walletkit-core/src/storage/cache/mod.rs | 25 +++++++++++++-- .../src/storage/cache/nullifiers.rs | 12 +++++-- .../src/storage/credential_storage.rs | 7 ++--- walletkit-core/src/storage/mod.rs | 2 +- walletkit-core/src/storage/types.rs | 31 ++----------------- .../tests/credential_storage_integration.rs | 18 +++++++++-- 7 files changed, 59 insertions(+), 45 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 3e6d4b57c..173c3141b 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -7,7 +7,9 @@ use world_id_core::primitives::TREE_DEPTH; use world_id_core::{requests::ProofRequest, Credential, FieldElement}; use crate::error::WalletKitError; -use crate::storage::{CredentialStorage, ReplayGuardResult, RequestId}; +use crate::storage::{ + CredentialStorage, ReplayGuardKind, ReplayGuardResult, RequestId, +}; use super::Authenticator; @@ -100,7 +102,10 @@ impl Authenticator { .replay_guard_get(request_id, now) .map_err(WalletKitError::from)? { - return Ok(ReplayGuardResult::Replay(bytes)); + return Ok(ReplayGuardResult { + kind: ReplayGuardKind::Replay, + bytes, + }); } let (proof, nullifier) = self .0 diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 7d8a06bed..4a17adb6d 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -172,6 +172,7 @@ mod tests { use super::*; use crate::storage::error::StorageError; use crate::storage::lock::StorageLock; + use crate::storage::types::ReplayGuardKind; use std::fs; use std::path::PathBuf; use uuid::Uuid; @@ -304,12 +305,24 @@ mod tests { 1000, ) .expect("first disclosure"); - assert_eq!(fresh, ReplayGuardResult::Fresh(first.clone())); + assert_eq!( + fresh, + ReplayGuardResult { + kind: ReplayGuardKind::Fresh, + bytes: first.clone(), + } + ); let replay = db .begin_replay_guard(&guard, request_id, nullifier, second, 101, 1000) .expect("replay disclosure"); - assert_eq!(replay, ReplayGuardResult::Replay(first)); + assert_eq!( + replay, + ReplayGuardResult { + kind: ReplayGuardKind::Replay, + bytes: first, + } + ); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); } @@ -386,7 +399,13 @@ mod tests { let fresh = db .begin_replay_guard(&guard, request_id_b, nullifier, vec![8], 111, 10) .expect("second disclosure after expiry"); - assert_eq!(fresh, ReplayGuardResult::Fresh(vec![8])); + assert_eq!( + fresh, + ReplayGuardResult { + kind: ReplayGuardKind::Fresh, + bytes: 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 460f7ffac..dd14adade 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -6,7 +6,7 @@ use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use crate::storage::error::{StorageError, StorageResult}; -use crate::storage::types::ReplayGuardResult; +use crate::storage::types::{ReplayGuardKind, ReplayGuardResult}; use super::util::{expiry_timestamp, map_db_err, to_i64}; @@ -59,7 +59,10 @@ pub(super) fn begin_replay_guard( .map_err(|err| map_db_err(&err))?; if let Some(bytes) = existing_proof { tx.commit().map_err(|err| map_db_err(&err))?; - return Ok(ReplayGuardResult::Replay(bytes)); + return Ok(ReplayGuardResult { + kind: ReplayGuardKind::Replay, + bytes, + }); } let existing_request: Option> = tx @@ -91,5 +94,8 @@ pub(super) fn begin_replay_guard( ) .map_err(|err| map_db_err(&err))?; tx.commit().map_err(|err| map_db_err(&err))?; - Ok(ReplayGuardResult::Fresh(proof_bytes)) + Ok(ReplayGuardResult { + kind: ReplayGuardKind::Fresh, + bytes: proof_bytes, + }) } diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 36221da9f..4d6f42803 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -9,8 +9,7 @@ use super::paths::StoragePaths; use super::traits::StorageProvider; use super::traits::{AtomicBlobStore, DeviceKeystore}; use super::types::{ - CredentialRecord, Nullifier, ReplayGuardResult, ReplayGuardResultFfi, - RequestId, + CredentialRecord, Nullifier, ReplayGuardResult, RequestId, }; use super::{CacheDb, VaultDb}; @@ -345,7 +344,7 @@ impl CredentialStore { proof_bytes: Vec, now: u64, ttl_seconds: u64, - ) -> StorageResult { + ) -> 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_replay_guard( @@ -355,7 +354,7 @@ impl CredentialStore { now, ttl_seconds, )?; - Ok(ReplayGuardResultFfi::from(result)) + Ok(result) } } diff --git a/walletkit-core/src/storage/mod.rs b/walletkit-core/src/storage/mod.rs index 61556f8b4..0214089ca 100644 --- a/walletkit-core/src/storage/mod.rs +++ b/walletkit-core/src/storage/mod.rs @@ -21,7 +21,7 @@ pub use paths::StoragePaths; pub use traits::{AtomicBlobStore, DeviceKeystore, StorageProvider}; pub use types::{ BlobKind, ContentId, CredentialRecord, Nullifier, ReplayGuardKind, - ReplayGuardResult, ReplayGuardResultFfi, RequestId, + ReplayGuardResult, RequestId, }; pub use vault::VaultDb; diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 37f5bd373..2bb146bc1 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -56,18 +56,6 @@ pub struct CredentialRecord { pub expires_at: Option, } -/// Result of replay guard enforcement. -/// -/// The replay guard is idempotent: repeated calls with the same request return -/// the original proof bytes rather than generating a new disclosure. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReplayGuardResult { - /// Stored bytes for the first disclosure of a request. - Fresh(Vec), - /// Stored bytes replayed for an existing request. - Replay(Vec), -} - /// FFI-friendly replay guard result kind. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum ReplayGuardKind { @@ -77,26 +65,11 @@ pub enum ReplayGuardKind { Replay, } -/// FFI-friendly replay guard result. +/// Replay guard result. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct ReplayGuardResultFfi { +pub struct ReplayGuardResult { /// Result kind. pub kind: ReplayGuardKind, /// Stored proof package bytes. pub bytes: Vec, } - -impl From for ReplayGuardResultFfi { - fn from(result: ReplayGuardResult) -> Self { - match result { - ReplayGuardResult::Fresh(bytes) => Self { - kind: ReplayGuardKind::Fresh, - bytes, - }, - ReplayGuardResult::Replay(bytes) => Self { - kind: ReplayGuardKind::Replay, - bytes, - }, - } - } -} diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index 6bf8e4d8e..471d38aaf 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use walletkit_core::storage::{ AtomicBlobStore, CredentialStorage, CredentialStore, DeviceKeystore, - ReplayGuardResult, StoragePaths, StorageProvider, + ReplayGuardKind, ReplayGuardResult, StoragePaths, StorageProvider, }; struct InMemoryKeystore { @@ -240,7 +240,13 @@ fn test_storage_flow_end_to_end() { 50, ) .expect("disclose"); - assert_eq!(fresh, ReplayGuardResult::Fresh(vec![1, 2])); + assert_eq!( + fresh, + ReplayGuardResult { + kind: ReplayGuardKind::Fresh, + bytes: vec![1, 2], + } + ); let cached = CredentialStorage::replay_guard_get(&store, request_id, 210) .expect("disclosure lookup"); assert_eq!(cached, Some(vec![1, 2])); @@ -253,7 +259,13 @@ fn test_storage_flow_end_to_end() { 50, ) .expect("replay"); - assert_eq!(replay, ReplayGuardResult::Replay(vec![1, 2])); + assert_eq!( + replay, + ReplayGuardResult { + kind: ReplayGuardKind::Replay, + bytes: vec![1, 2], + } + ); cleanup_storage(&root); } From 835885b5c71f15e81d761c2a4b8f4d3bab52ab04 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:10:07 -0800 Subject: [PATCH 22/46] clarity docstring --- walletkit-core/src/storage/traits.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs index 6069b9854..f83e9f0d3 100644 --- a/walletkit-core/src/storage/traits.rs +++ b/walletkit-core/src/storage/traits.rs @@ -8,7 +8,10 @@ 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`. + /// 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 +24,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. From 0ef1325623acccb1baff8e813bb4c8bc7e351f60 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:12:13 -0800 Subject: [PATCH 23/46] document Key structure --- walletkit-core/src/storage/traits.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/walletkit-core/src/storage/traits.rs b/walletkit-core/src/storage/traits.rs index f83e9f0d3..0a82e5551 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; From 8676e33a397815bdd2b57ae52a6efcd304fe2da5 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:24:02 -0800 Subject: [PATCH 24/46] add db read_only flag --- .../src/storage/cache/maintenance.rs | 3 ++- walletkit-core/src/storage/sqlcipher.rs | 18 ++++++++++++++---- walletkit-core/src/storage/vault/mod.rs | 5 +++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 8c215b763..e44a3e9e8 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -31,7 +31,8 @@ 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)?; + let conn = + sqlcipher::open_connection(path, false).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)?; diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs index a78cf9d82..d55f078fe 100644 --- a/walletkit-core/src/storage/sqlcipher.rs +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -33,10 +33,20 @@ impl From for SqlcipherError { 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; +/// +/// Pass `read_only = true` for read-only access; `false` enables read/write +/// access and creates the database if needed. +pub(super) fn open_connection( + path: &Path, + read_only: bool, +) -> SqlcipherResult { + let flags = if read_only { + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX + } else { + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_FULL_MUTEX + }; Ok(Connection::open_with_flags(path, flags)?) } diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 150ec0bcc..59b67439c 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -35,7 +35,8 @@ impl VaultDb { k_intermediate: [u8; 32], _lock: &StorageLockGuard, ) -> StorageResult { - let conn = sqlcipher::open_connection(path).map_err(map_sqlcipher_err)?; + let conn = + sqlcipher::open_connection(path, false).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)?; @@ -180,7 +181,7 @@ impl VaultDb { .map_err(|err| map_db_err(&err))?; tx.commit().map_err(|err| map_db_err(&err))?; - Ok(to_u64(credential_id, "credential_id")?) + to_u64(credential_id, "credential_id") } /// Lists active credential metadata, optionally filtered by issuer schema. From 3100da0c87a031fe9a3d70d8f7e8ef8f19cd768c Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:26:55 -0800 Subject: [PATCH 25/46] add configure_connection doc --- walletkit-core/src/storage/sqlcipher.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs index d55f078fe..0893d8566 100644 --- a/walletkit-core/src/storage/sqlcipher.rs +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -68,8 +68,9 @@ pub(super) fn apply_key( /// Configures durable WAL settings. /// -/// WAL improves read/write concurrency while `synchronous = FULL` prioritizes -/// durability for credential data. +/// Rationale: +/// - `journal_mode = WAL` enables concurrent readers during writes +/// - `synchronous = FULL` maximizes crash consistency pub(super) fn configure_connection(conn: &Connection) -> SqlcipherResult<()> { conn.execute_batch( "PRAGMA foreign_keys = ON; From 33929e3767ca4e3655f8ca8826a1281842b19a41 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:30:16 -0800 Subject: [PATCH 26/46] update session_key doc --- walletkit-core/src/storage/cache/mod.rs | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 4a17adb6d..4d3912250 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -93,7 +93,10 @@ impl CacheDb { /// Fetches a cached session key if present. /// - /// Session keys are optional performance hints and may be missing or expired. + /// 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 /// @@ -296,14 +299,7 @@ mod tests { let second = vec![9, 9, 9]; let fresh = db - .begin_replay_guard( - &guard, - request_id, - nullifier, - first.clone(), - 100, - 1000, - ) + .begin_replay_guard(&guard, request_id, nullifier, first.clone(), 100, 1000) .expect("first disclosure"); assert_eq!( fresh, @@ -342,14 +338,10 @@ mod tests { db.begin_replay_guard(&guard, request_id, nullifier, payload.clone(), 100, 10) .expect("disclosure"); - let hit = db - .replay_guard_get(request_id, 105) - .expect("lookup"); + let hit = db.replay_guard_get(request_id, 105).expect("lookup"); assert_eq!(hit, Some(payload)); - let miss = db - .replay_guard_get(request_id, 111) - .expect("lookup"); + let miss = db.replay_guard_get(request_id, 111).expect("lookup"); assert!(miss.is_none()); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); From 96d08b838e70377e21650098a85e954b8639ab3b Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:32:12 -0800 Subject: [PATCH 27/46] remove now params --- walletkit-core/src/storage/cache/mod.rs | 23 +++++++++------------ walletkit-core/src/storage/cache/session.rs | 17 ++++++++++++--- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/walletkit-core/src/storage/cache/mod.rs b/walletkit-core/src/storage/cache/mod.rs index 4d3912250..e14054820 100644 --- a/walletkit-core/src/storage/cache/mod.rs +++ b/walletkit-core/src/storage/cache/mod.rs @@ -101,12 +101,8 @@ impl CacheDb { /// # 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. @@ -121,10 +117,9 @@ 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) } /// Checks for a prior disclosure by request id. @@ -178,6 +173,7 @@ mod tests { use crate::storage::types::ReplayGuardKind; use std::fs; use std::path::PathBuf; + use std::time::Duration; use uuid::Uuid; fn temp_cache_path() -> PathBuf { @@ -228,14 +224,14 @@ mod tests { 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 value = db.session_key_get(rp_id).expect("get session key"); assert!(value.is_none()); cleanup_cache_files(&path); cleanup_lock_file(&lock_path); @@ -275,11 +271,12 @@ mod tests { 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); diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs index f5f3f7e80..b4aa1ff08 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -1,16 +1,18 @@ //! Session key cache helpers. +use std::time::{SystemTime, UNIX_EPOCH}; + use rusqlite::{params, Connection, OptionalExtension}; -use crate::storage::error::StorageResult; +use crate::storage::error::{StorageError, 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 = current_unix_timestamp()?; let now_i64 = to_i64(now, "now")?; let raw: Option> = conn .query_row( @@ -33,9 +35,9 @@ pub(super) fn put( conn: &Connection, rp_id: [u8; 32], k_session: [u8; 32], - now: u64, ttl_seconds: u64, ) -> StorageResult<()> { + let now = current_unix_timestamp()?; prune_expired(conn, now)?; let expires_at = expiry_timestamp(now, ttl_seconds); let expires_at_i64 = to_i64(expires_at, "expires_at")?; @@ -60,3 +62,12 @@ fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { .map_err(|err| map_db_err(&err))?; Ok(()) } + +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()) +} From 79d46eafb80e60956ac4096300fcd46e2d7695e9 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:45:27 -0800 Subject: [PATCH 28/46] switch to kv cache table --- walletkit-core/src/storage/cache/merkle.rs | 52 ++++------ .../src/storage/cache/nullifiers.rs | 53 ++++++---- walletkit-core/src/storage/cache/schema.rs | 98 +++++++++++-------- walletkit-core/src/storage/cache/session.rs | 28 +++--- walletkit-core/src/storage/cache/util.rs | 36 +++++++ 5 files changed, 164 insertions(+), 103 deletions(-) diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index dfa247273..5c10407ff 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -4,7 +4,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::storage::error::StorageResult; -use super::util::{expiry_timestamp, map_db_err, to_i64}; +use super::util::{expiry_timestamp, map_db_err, merkle_cache_key, to_i64}; pub(super) fn get( conn: &Connection, @@ -13,22 +13,15 @@ pub(super) fn get( leaf_index: u64, valid_before: u64, ) -> StorageResult>> { - let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; let valid_before_i64 = to_i64(valid_before, "valid_before")?; + let key = merkle_cache_key(registry_kind, root, leaf_index); 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, - valid_before_i64 - ], + "SELECT value_bytes + FROM cache_entries + WHERE key_bytes = ?1 + AND expires_at > ?2", + params![key, valid_before_i64], |row| row.get(0), ) .optional() @@ -45,36 +38,29 @@ pub(super) fn put( now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - prune_expired(conn)?; + prune_expired(conn, now)?; let expires_at = expiry_timestamp(now, ttl_seconds); - let leaf_index_i64 = to_i64(leaf_index, "leaf_index")?; + let key = merkle_cache_key(registry_kind, root, leaf_index); + let inserted_at_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, + "INSERT OR REPLACE INTO cache_entries ( + key_bytes, + value_bytes, inserted_at, expires_at - ) VALUES (?1, ?2, ?3, ?4, strftime('%s','now'), ?5)", - params![ - i64::from(registry_kind), - root.as_ref(), - leaf_index_i64, - proof_bytes, - expires_at_i64 - ], + ) VALUES (?1, ?2, ?3, ?4)", + params![key, proof_bytes, inserted_at_i64, expires_at_i64], ) .map_err(|err| map_db_err(&err))?; Ok(()) } -fn prune_expired(conn: &Connection) -> StorageResult<()> { +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 <= CAST(strftime('%s','now') AS INTEGER)", - [], + "DELETE FROM cache_entries WHERE expires_at <= ?1", + params![now_i64], ) .map_err(|err| map_db_err(&err))?; Ok(()) diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index dd14adade..4fddd65d7 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -8,7 +8,9 @@ use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{ReplayGuardKind, ReplayGuardResult}; -use super::util::{expiry_timestamp, map_db_err, to_i64}; +use super::util::{ + expiry_timestamp, map_db_err, replay_nullifier_key, replay_request_key, to_i64, +}; pub(super) fn replay_guard_bytes_for_request_id( conn: &Connection, @@ -16,12 +18,13 @@ pub(super) fn replay_guard_bytes_for_request_id( now: u64, ) -> StorageResult>> { let now_i64 = to_i64(now, "now")?; + let key = replay_request_key(request_id); conn.query_row( - "SELECT proof_bytes - FROM used_nullifiers - WHERE request_id = ?1 + "SELECT value_bytes + FROM cache_entries + WHERE key_bytes = ?1 AND expires_at > ?2", - params![request_id.as_ref(), now_i64], + params![key, now_i64], |row| row.get(0), ) .optional() @@ -41,18 +44,19 @@ pub(super) fn begin_replay_guard( .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(|err| map_db_err(&err))?; tx.execute( - "DELETE FROM used_nullifiers WHERE expires_at <= ?1", + "DELETE FROM cache_entries WHERE expires_at <= ?1", params![now_i64], ) .map_err(|err| map_db_err(&err))?; + let request_key = replay_request_key(request_id); let existing_proof: Option> = tx .query_row( - "SELECT proof_bytes - FROM used_nullifiers - WHERE request_id = ?1 + "SELECT value_bytes + FROM cache_entries + WHERE key_bytes = ?1 AND expires_at > ?2", - params![request_id.as_ref(), now_i64], + params![request_key.as_slice(), now_i64], |row| row.get(0), ) .optional() @@ -65,13 +69,14 @@ pub(super) fn begin_replay_guard( }); } + let nullifier_key = replay_nullifier_key(nullifier); let existing_request: Option> = tx .query_row( - "SELECT request_id - FROM used_nullifiers - WHERE nullifier = ?1 + "SELECT value_bytes + FROM cache_entries + WHERE key_bytes = ?1 AND expires_at > ?2", - params![nullifier.as_ref(), now_i64], + params![nullifier_key.as_slice(), now_i64], |row| row.get(0), ) .optional() @@ -82,14 +87,26 @@ pub(super) fn begin_replay_guard( let expires_at = expiry_timestamp(now, ttl_seconds); let expires_at_i64 = to_i64(expires_at, "expires_at")?; + let inserted_at_i64 = to_i64(now, "now")?; tx.execute( - "INSERT INTO used_nullifiers (request_id, nullifier, expires_at, proof_bytes) + "INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at) VALUES (?1, ?2, ?3, ?4)", params![ + request_key.as_slice(), + proof_bytes, + inserted_at_i64, + expires_at_i64 + ], + ) + .map_err(|err| map_db_err(&err))?; + tx.execute( + "INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + nullifier_key.as_slice(), request_id.as_ref(), - nullifier.as_ref(), - expires_at_i64, - proof_bytes + inserted_at_i64, + expires_at_i64 ], ) .map_err(|err| map_db_err(&err))?; diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs index 65270983b..176f4918f 100644 --- a/walletkit-core/src/storage/cache/schema.rs +++ b/walletkit-core/src/storage/cache/schema.rs @@ -1,12 +1,12 @@ //! Cache database schema management. -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension}; use crate::storage::error::StorageResult; use super::util::map_db_err; -const CACHE_SCHEMA_VERSION: i64 = 1; +const CACHE_SCHEMA_VERSION: i64 = 2; pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { conn.execute_batch( @@ -14,56 +14,72 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { 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: Option = conn + .query_row( + "SELECT schema_version FROM cache_meta LIMIT 1;", + [], + |row| row.get(0), + ) + .optional() + .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, +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) - ); - - 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) + PRIMARY KEY (key_bytes) ); - 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], - ) +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(()) +} +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'))", + [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 b4aa1ff08..47a6976b3 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -6,7 +6,9 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::storage::error::{StorageError, StorageResult}; -use super::util::{expiry_timestamp, map_db_err, parse_fixed_bytes, to_i64}; +use super::util::{ + expiry_timestamp, map_db_err, parse_fixed_bytes, session_cache_key, to_i64, +}; pub(super) fn get( conn: &Connection, @@ -14,13 +16,14 @@ pub(super) fn get( ) -> StorageResult> { let now = current_unix_timestamp()?; let now_i64 = to_i64(now, "now")?; + let key = session_cache_key(rp_id); let raw: Option> = conn .query_row( - "SELECT k_session - FROM session_keys - WHERE rp_id = ?1 + "SELECT value_bytes + FROM cache_entries + WHERE key_bytes = ?1 AND expires_at > ?2", - params![rp_id.as_ref(), now_i64], + params![key, now_i64], |row| row.get(0), ) .optional() @@ -40,14 +43,17 @@ pub(super) fn put( let now = current_unix_timestamp()?; prune_expired(conn, now)?; let expires_at = expiry_timestamp(now, ttl_seconds); + let key = session_cache_key(rp_id); + let inserted_at_i64 = to_i64(now, "now")?; let expires_at_i64 = to_i64(expires_at, "expires_at")?; conn.execute( - "INSERT OR REPLACE INTO session_keys ( - rp_id, - k_session, + "INSERT OR REPLACE INTO cache_entries ( + key_bytes, + value_bytes, + inserted_at, expires_at - ) VALUES (?1, ?2, ?3)", - params![rp_id.as_ref(), k_session.as_ref(), expires_at_i64], + ) VALUES (?1, ?2, ?3, ?4)", + params![key, k_session.as_ref(), inserted_at_i64, expires_at_i64], ) .map_err(|err| map_db_err(&err))?; Ok(()) @@ -56,7 +62,7 @@ pub(super) fn put( 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", + "DELETE FROM cache_entries WHERE expires_at <= ?1", params![now_i64], ) .map_err(|err| map_db_err(&err))?; diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs index 45fecbb1a..917cbe241 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -35,6 +35,42 @@ 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_REQUEST: u8 = 0x03; +pub(super) const CACHE_KEY_PREFIX_REPLAY_NULLIFIER: u8 = 0x04; + +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 +} + +pub(super) fn merkle_cache_key( + registry_kind: u8, + root: [u8; 32], + leaf_index: u64, +) -> Vec { + let mut payload = Vec::with_capacity(1 + 32 + 8); + payload.push(registry_kind); + payload.extend_from_slice(root.as_ref()); + payload.extend_from_slice(&leaf_index.to_be_bytes()); + cache_key_with_prefix(CACHE_KEY_PREFIX_MERKLE, &payload) +} + +pub(super) fn session_cache_key(rp_id: [u8; 32]) -> Vec { + cache_key_with_prefix(CACHE_KEY_PREFIX_SESSION, rp_id.as_ref()) +} + +pub(super) fn replay_request_key(request_id: [u8; 32]) -> Vec { + cache_key_with_prefix(CACHE_KEY_PREFIX_REPLAY_REQUEST, request_id.as_ref()) +} + +pub(super) fn replay_nullifier_key(nullifier: [u8; 32]) -> Vec { + cache_key_with_prefix(CACHE_KEY_PREFIX_REPLAY_NULLIFIER, nullifier.as_ref()) +} + pub(super) const fn expiry_timestamp(now: u64, ttl_seconds: u64) -> u64 { now.saturating_add(ttl_seconds) } From b4bd3a3e88dc01184fde4096fc97a070688dae35 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:49:38 -0800 Subject: [PATCH 29/46] bincode->ciborium --- Cargo.lock | 2 +- walletkit-core/Cargo.toml | 4 ++-- walletkit-core/src/authenticator/storage.rs | 18 ++++++++++++------ walletkit-core/src/storage/envelope.rs | 8 +++++--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0fd39960b..59c4a4600 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6267,9 +6267,9 @@ dependencies = [ "alloy", "alloy-core", "alloy-primitives", - "bincode", "chacha20poly1305", "chrono", + "ciborium", "dotenvy", "hex", "hkdf", diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index 8975a8368..e984b8e13 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -23,7 +23,6 @@ name = "walletkit_core" [dependencies] alloy-core = { workspace = true } alloy-primitives = { workspace = true } -bincode = { version = "1.3", optional = true } hex = "0.4" hkdf = { version = "0.12", optional = true } log = "0.4" @@ -50,6 +49,7 @@ rusqlite = { version = "0.32", features = ["bundled-sqlcipher"], optional = true uuid = { version = "1.10", features = ["v4"], optional = true } uniffi = { workspace = true, features = ["build", "tokio"] } world-id-core = { workspace = true, optional = true } +ciborium = { version = "0.2.2", optional = true } [dev-dependencies] alloy = { version = "1", default-features = false, features = ["getrandom", "json", "contract", "node-bindings", "signer-local"] } @@ -68,7 +68,7 @@ default = ["common-apps", "semaphore", "v4"] common-apps = [] http-tests = [] semaphore = ["semaphore-rs/depth_30"] -storage = ["dep:bincode", "dep:hkdf", "dep:rand", "dep:rusqlite", "dep:sha2", "dep:uuid"] +storage = ["dep:ciborium", "dep:hkdf", "dep:rand", "dep:rusqlite", "dep:sha2", "dep:uuid"] v4 = ["world-id-core", "storage"] # Before conventions were introduced for external nullifiers with `app_id` & `action`, raw field elements were used. diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 173c3141b..f9cd0a84e 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -134,13 +134,17 @@ struct CachedInclusionProof { fn serialize_inclusion_proof( payload: &CachedInclusionProof, ) -> Result, WalletKitError> { - bincode::serialize(payload).map_err(|err| WalletKitError::SerializationError { - error: err.to_string(), - }) + let mut bytes = Vec::new(); + ciborium::ser::into_writer(payload, &mut bytes).map_err(|err| { + WalletKitError::SerializationError { + error: err.to_string(), + } + })?; + Ok(bytes) } fn deserialize_inclusion_proof(bytes: &[u8]) -> Option { - bincode::deserialize(bytes).ok() + ciborium::de::from_reader(bytes).ok() } fn field_element_to_bytes(value: FieldElement) -> [u8; 32] { @@ -151,11 +155,13 @@ fn serialize_proof_package( proof: &impl Serialize, nullifier: FieldElement, ) -> Result, WalletKitError> { - bincode::serialize(&(proof, nullifier)).map_err(|err| { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&(proof, nullifier), &mut bytes).map_err(|err| { WalletKitError::SerializationError { error: err.to_string(), } - }) + })?; + Ok(bytes) } #[cfg(test)] diff --git a/walletkit-core/src/storage/envelope.rs b/walletkit-core/src/storage/envelope.rs index d8d571758..f689d7f03 100644 --- a/walletkit-core/src/storage/envelope.rs +++ b/walletkit-core/src/storage/envelope.rs @@ -25,12 +25,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)); From 204f2238b5f92f6d6c3560aaa2fc32dde8d05279 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:52:58 -0800 Subject: [PATCH 30/46] non optional expires_at --- .../src/storage/credential_storage.rs | 8 ++++---- walletkit-core/src/storage/types.rs | 4 ++-- walletkit-core/src/storage/vault/helpers.rs | 6 ++---- walletkit-core/src/storage/vault/mod.rs | 8 +++----- walletkit-core/src/storage/vault/schema.rs | 2 +- walletkit-core/src/storage/vault/tests.rs | 18 +++++++++--------- .../tests/credential_storage_integration.rs | 4 ++-- 7 files changed, 23 insertions(+), 27 deletions(-) diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 4d6f42803..16cd542be 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -47,7 +47,7 @@ pub trait CredentialStorage { issuer_schema_id: u64, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, - expires_at: Option, + expires_at: u64, credential_blob: Vec, associated_data: Option>, now: u64, @@ -258,7 +258,7 @@ impl CredentialStore { issuer_schema_id: u64, subject_blinding_factor: Vec, genesis_issued_at: u64, - expires_at: Option, + expires_at: u64, credential_blob: Vec, associated_data: Option>, now: u64, @@ -424,7 +424,7 @@ impl CredentialStorage for CredentialStoreInner { issuer_schema_id: u64, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, - expires_at: Option, + expires_at: u64, credential_blob: Vec, associated_data: Option>, now: u64, @@ -566,7 +566,7 @@ impl CredentialStorage for CredentialStore { issuer_schema_id: u64, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, - expires_at: Option, + expires_at: u64, credential_blob: Vec, associated_data: Option>, now: u64, diff --git a/walletkit-core/src/storage/types.rs b/walletkit-core/src/storage/types.rs index 2bb146bc1..40833812a 100644 --- a/walletkit-core/src/storage/types.rs +++ b/walletkit-core/src/storage/types.rs @@ -52,8 +52,8 @@ pub struct CredentialRecord { pub credential_id: u64, /// Issuer schema identifier. pub issuer_schema_id: u64, - /// Optional expiry timestamp (seconds). - pub expires_at: Option, + /// Expiry timestamp (seconds). + pub expires_at: u64, } /// FFI-friendly replay guard result kind. diff --git a/walletkit-core/src/storage/vault/helpers.rs b/walletkit-core/src/storage/vault/helpers.rs index fead75818..5f7cee6ec 100644 --- a/walletkit-core/src/storage/vault/helpers.rs +++ b/walletkit-core/src/storage/vault/helpers.rs @@ -21,13 +21,11 @@ pub(super) fn compute_content_id(blob_kind: BlobKind, plaintext: &[u8]) -> Conte pub(super) fn map_record(row: &Row<'_>) -> StorageResult { let credential_id: i64 = 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 expires_at: Option = row.get(2).map_err(|err| map_db_err(&err))?; + let expires_at: i64 = row.get(2).map_err(|err| map_db_err(&err))?; Ok(CredentialRecord { credential_id: to_u64(credential_id, "credential_id")?, issuer_schema_id: to_u64(issuer_schema_id, "issuer_schema_id")?, - expires_at: expires_at - .map(|value| to_u64(value, "expires_at")) - .transpose()?, + expires_at: to_u64(expires_at, "expires_at")?, }) } diff --git a/walletkit-core/src/storage/vault/mod.rs b/walletkit-core/src/storage/vault/mod.rs index 59b67439c..ab07ccec7 100644 --- a/walletkit-core/src/storage/vault/mod.rs +++ b/walletkit-core/src/storage/vault/mod.rs @@ -108,7 +108,7 @@ impl VaultDb { issuer_schema_id: u64, subject_blinding_factor: [u8; 32], genesis_issued_at: u64, - expires_at: Option, + expires_at: u64, credential_blob: Vec, associated_data: Option>, now: u64, @@ -121,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( @@ -205,7 +203,7 @@ impl VaultDb { cr.issuer_schema_id, cr.expires_at FROM credential_records cr - WHERE (cr.expires_at IS NULL OR cr.expires_at > ?1)", + WHERE cr.expires_at > ?1", ); let mut params: Vec<&dyn rusqlite::ToSql> = vec![&expires]; if let Some(ref issuer_schema_id_i64) = issuer_schema_id_i64 { diff --git a/walletkit-core/src/storage/vault/schema.rs b/walletkit-core/src/storage/vault/schema.rs index c8c44c5cb..ff368f0ac 100644 --- a/walletkit-core/src/storage/vault/schema.rs +++ b/walletkit-core/src/storage/vault/schema.rs @@ -32,7 +32,7 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { issuer_schema_id INTEGER NOT NULL, subject_blinding_factor BLOB NOT NULL, genesis_issued_at INTEGER NOT NULL, - expires_at INTEGER, + expires_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, credential_blob_cid BLOB NOT NULL, associated_data_cid BLOB diff --git a/walletkit-core/src/storage/vault/tests.rs b/walletkit-core/src/storage/vault/tests.rs index c3b7136cd..7268c35d3 100644 --- a/walletkit-core/src/storage/vault/tests.rs +++ b/walletkit-core/src/storage/vault/tests.rs @@ -109,7 +109,7 @@ fn test_store_credential_without_associated_data() { 10, sample_blinding_factor(), 123, - None, + 2000, b"credential".to_vec(), None, 1000, @@ -119,7 +119,7 @@ fn test_store_credential_without_associated_data() { assert_eq!(records.len(), 1); assert_eq!(records[0].credential_id, credential_id); assert_eq!(records[0].issuer_schema_id, 10); - assert!(records[0].expires_at.is_none()); + assert_eq!(records[0].expires_at, 2000); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -136,7 +136,7 @@ fn test_store_credential_with_associated_data() { 11, sample_blinding_factor(), 456, - None, + 2000, b"credential-2".to_vec(), Some(b"associated".to_vec()), 1000, @@ -145,7 +145,7 @@ fn test_store_credential_with_associated_data() { let records = db.list_credentials(None, 1000).expect("list credentials"); assert_eq!(records.len(), 1); assert_eq!(records[0].issuer_schema_id, 11); - assert!(records[0].expires_at.is_none()); + assert_eq!(records[0].expires_at, 2000); cleanup_vault_files(&path); cleanup_lock_file(&lock_path); } @@ -169,7 +169,7 @@ fn test_content_id_deduplication() { 12, sample_blinding_factor(), 1, - None, + 2000, b"same".to_vec(), None, 1000, @@ -180,7 +180,7 @@ fn test_content_id_deduplication() { 12, sample_blinding_factor(), 1, - None, + 2000, b"same".to_vec(), None, 1001, @@ -208,7 +208,7 @@ fn test_list_credentials_by_issuer() { 100, sample_blinding_factor(), 1, - None, + 2000, b"issuer-a".to_vec(), None, 1000, @@ -219,7 +219,7 @@ fn test_list_credentials_by_issuer() { 200, sample_blinding_factor(), 1, - None, + 2000, b"issuer-b".to_vec(), None, 1000, @@ -246,7 +246,7 @@ fn test_list_credentials_excludes_expired() { 300, sample_blinding_factor(), 1, - Some(900), + 900, b"expired".to_vec(), None, 1000, diff --git a/walletkit-core/tests/credential_storage_integration.rs b/walletkit-core/tests/credential_storage_integration.rs index 471d38aaf..bec879b3a 100644 --- a/walletkit-core/tests/credential_storage_integration.rs +++ b/walletkit-core/tests/credential_storage_integration.rs @@ -203,7 +203,7 @@ fn test_storage_flow_end_to_end() { 7, [0x11u8; 32], 1_700_000_000, - Some(1_800_000_000), + 1_800_000_000, vec![1, 2, 3], Some(vec![4, 5, 6]), 100, @@ -216,7 +216,7 @@ fn test_storage_flow_end_to_end() { let record = &records[0]; assert_eq!(record.credential_id, credential_id); assert_eq!(record.issuer_schema_id, 7); - assert_eq!(record.expires_at, Some(1_800_000_000)); + 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) From ac956f59ae28b0a8423c226568237d7e8e5c07f0 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Thu, 22 Jan 2026 17:55:01 -0800 Subject: [PATCH 31/46] fmt --- walletkit-core/src/authenticator/storage.rs | 4 +++- .../src/storage/cache/maintenance.rs | 3 +-- .../src/storage/credential_storage.rs | 21 +++++++------------ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index f9cd0a84e..8c267c6bd 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -58,7 +58,9 @@ impl Authenticator { WalletKitError, > { let valid_before = now.saturating_add(MERKLE_PROOF_VALIDITY_BUFFER_SECS); - if let Some(bytes) = storage.merkle_cache_get(registry_kind, root, valid_before)? { + if let Some(bytes) = + storage.merkle_cache_get(registry_kind, root, valid_before)? + { if let Some(cached) = deserialize_inclusion_proof(&bytes) { return Ok((cached.proof, cached.authenticator_pubkeys)); } diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index e44a3e9e8..c32ca27f9 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -31,8 +31,7 @@ pub(super) fn open_or_rebuild( } fn open_prepared(path: &Path, k_intermediate: [u8; 32]) -> StorageResult { - let conn = - sqlcipher::open_connection(path, false).map_err(map_sqlcipher_err)?; + let conn = sqlcipher::open_connection(path, false).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)?; diff --git a/walletkit-core/src/storage/credential_storage.rs b/walletkit-core/src/storage/credential_storage.rs index 16cd542be..46d08bc17 100644 --- a/walletkit-core/src/storage/credential_storage.rs +++ b/walletkit-core/src/storage/credential_storage.rs @@ -8,9 +8,7 @@ use super::lock::{StorageLock, StorageLockGuard}; use super::paths::StoragePaths; use super::traits::StorageProvider; use super::traits::{AtomicBlobStore, DeviceKeystore}; -use super::types::{ - CredentialRecord, Nullifier, ReplayGuardResult, RequestId, -}; +use super::types::{CredentialRecord, Nullifier, ReplayGuardResult, RequestId}; use super::{CacheDb, VaultDb}; /// Public-facing storage API used by `WalletKit` v4 flows. @@ -450,9 +448,12 @@ impl CredentialStorage for CredentialStoreInner { valid_before: u64, ) -> StorageResult>> { let state = self.state()?; - state - .cache - .merkle_cache_get(registry_kind, root, state.leaf_index, valid_before) + state.cache.merkle_cache_get( + registry_kind, + root, + state.leaf_index, + valid_before, + ) } fn merkle_cache_put( @@ -623,12 +624,6 @@ impl CredentialStorage for CredentialStore { ttl_seconds: u64, ) -> StorageResult { let mut inner = self.lock_inner()?; - inner.begin_replay_guard( - request_id, - nullifier, - proof_bytes, - now, - ttl_seconds, - ) + inner.begin_replay_guard(request_id, nullifier, proof_bytes, now, ttl_seconds) } } From a7a5538b0724c84e267a39084f48ccc4b389df5e Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 12:43:18 -0800 Subject: [PATCH 32/46] refactor authenticator for uniffi export --- .../mod.rs} | 1 + walletkit-core/src/authenticator/storage.rs | 149 +++++++++++++----- walletkit-core/src/authenticator/utils.rs | 41 +++++ 3 files changed, 151 insertions(+), 40 deletions(-) rename walletkit-core/src/{authenticator.rs => authenticator/mod.rs} (99%) create mode 100644 walletkit-core/src/authenticator/utils.rs diff --git a/walletkit-core/src/authenticator.rs b/walletkit-core/src/authenticator/mod.rs similarity index 99% rename from walletkit-core/src/authenticator.rs rename to walletkit-core/src/authenticator/mod.rs index 843136831..c369582cd 100644 --- a/walletkit-core/src/authenticator.rs +++ b/walletkit-core/src/authenticator/mod.rs @@ -12,6 +12,7 @@ use crate::{ }; mod storage; +mod utils; /// The Authenticator is the main component with which users interact with the World ID Protocol. #[derive(Debug, uniffi::Object)] diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 8c267c6bd..2a8de3401 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -1,21 +1,24 @@ -use std::convert::TryFrom; +use std::sync::Arc; 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 world_id_core::{requests::ProofRequest, Credential, FieldElement, OnchainKeyRepresentable}; use crate::error::WalletKitError; -use crate::storage::{ - CredentialStorage, ReplayGuardKind, ReplayGuardResult, RequestId, -}; +use crate::storage::{CredentialStore, ReplayGuardKind, ReplayGuardResult}; +use crate::U256Wrapper; use super::Authenticator; +use super::utils::{ + field_element_to_bytes, leaf_index_to_u64, parse_fixed_bytes, u256_to_hex, +}; /// Buffer cached proofs to remain valid during on-chain verification. const MERKLE_PROOF_VALIDITY_BUFFER_SECS: u64 = 120; +#[uniffi::export(async_runtime = "tokio")] impl Authenticator { /// Initializes storage using the authenticator's leaf index. /// @@ -24,45 +27,41 @@ impl Authenticator { /// Returns an error if the leaf index is invalid or storage initialization fails. pub fn init_storage( &self, - storage: &mut dyn CredentialStorage, + storage: Arc, 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(), - } - })?; + let leaf_index = leaf_index_to_u64(&self.leaf_index())?; 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 + /// The cached payload uses `CachedInclusionProof` CBOR encoding and is keyed by /// (`registry_kind`, `root`, `leaf_index`). /// + /// Returns the decoded proof with hex-encoded field elements and compressed + /// authenticator public keys. + /// /// # 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, + storage: Arc, registry_kind: u8, - root: [u8; 32], + root: Vec, now: u64, ttl_seconds: u64, - ) -> Result< - (MerkleInclusionProof, AuthenticatorPublicKeySet), - WalletKitError, - > { + ) -> Result { + let root = parse_fixed_bytes::<32>(root, "root")?; let valid_before = now.saturating_add(MERKLE_PROOF_VALIDITY_BUFFER_SECS); if let Some(bytes) = - storage.merkle_cache_get(registry_kind, root, valid_before)? + storage.merkle_cache_get(registry_kind, root.to_vec(), valid_before)? { if let Some(cached) = deserialize_inclusion_proof(&bytes) { - return Ok((cached.proof, cached.authenticator_pubkeys)); + return inclusion_proof_payload_from_cached(&cached); } } @@ -75,33 +74,58 @@ impl Authenticator { let proof_root = field_element_to_bytes(proof.root); storage.merkle_cache_put( registry_kind, - proof_root, - payload_bytes, + proof_root.to_vec(), + payload_bytes.clone(), now, ttl_seconds, )?; - Ok((proof, key_set)) + inclusion_proof_payload_from_cached(&payload) } /// Generates a proof and enforces replay safety via storage. /// + /// The proof request and credential are supplied as JSON strings. + /// /// # 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( + pub async fn generate_proof_with_replay_guard( &self, - storage: &mut dyn CredentialStorage, - proof_request: ProofRequest, - credential: Credential, - credential_sub_blinding_factor: FieldElement, - request_id: RequestId, + storage: Arc, + proof_request_json: &str, + credential_json: &str, + credential_sub_blinding_factor: &U256Wrapper, + request_id: Vec, now: u64, ttl_seconds: u64, ) -> Result { + let proof_request = + ProofRequest::from_json(proof_request_json).map_err(|err| { + WalletKitError::InvalidInput { + attribute: "proof_request".to_string(), + reason: err.to_string(), + } + })?; + let credential: Credential = + serde_json::from_str(credential_json).map_err(|err| { + WalletKitError::InvalidInput { + attribute: "credential".to_string(), + reason: err.to_string(), + } + })?; + let request_id = parse_fixed_bytes::<32>(request_id, "request_id")?; + let credential_sub_blinding_factor = FieldElement::try_from( + credential_sub_blinding_factor.0, + ) + .map_err(|err| WalletKitError::InvalidInput { + attribute: "credential_sub_blinding_factor".to_string(), + reason: err.to_string(), + })?; + if let Some(bytes) = storage - .replay_guard_get(request_id, now) + .replay_guard_get(request_id.to_vec(), now) .map_err(WalletKitError::from)? { return Ok(ReplayGuardResult { @@ -109,16 +133,23 @@ impl Authenticator { bytes, }); } - let (proof, nullifier) = self - .0 - .generate_proof(proof_request, credential, credential_sub_blinding_factor) - .await?; + let (proof, nullifier) = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + 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_replay_guard( - request_id, - nullifier_bytes, + request_id.to_vec(), + nullifier_bytes.to_vec(), proof_bytes, now, ttl_seconds, @@ -133,6 +164,18 @@ struct CachedInclusionProof { authenticator_pubkeys: AuthenticatorPublicKeySet, } +#[derive(Debug, Clone, uniffi::Record)] +pub struct InclusionProofPayload { + /// Merkle root as hex string. + pub root: String, + /// Leaf index for the account. + pub leaf_index: u64, + /// Sibling path as hex strings. + pub siblings: Vec, + /// Compressed authenticator public keys as hex strings. + pub authenticator_pubkeys: Vec, +} + fn serialize_inclusion_proof( payload: &CachedInclusionProof, ) -> Result, WalletKitError> { @@ -149,9 +192,35 @@ fn deserialize_inclusion_proof(bytes: &[u8]) -> Option { ciborium::de::from_reader(bytes).ok() } -fn field_element_to_bytes(value: FieldElement) -> [u8; 32] { - let value: ruint::aliases::U256 = value.into(); - value.to_be_bytes::<32>() +fn inclusion_proof_payload_from_cached( + payload: &CachedInclusionProof, +) -> Result { + let authenticator_pubkeys = payload + .authenticator_pubkeys + .iter() + .map(|pk| { + let encoded = pk.to_ethereum_representation().map_err(|err| { + WalletKitError::Generic { + error: format!( + "failed to encode authenticator pubkey: {err}" + ), + } + })?; + Ok(u256_to_hex(encoded)) + }) + .collect::, WalletKitError>>()?; + + Ok(InclusionProofPayload { + root: payload.proof.root.to_string(), + leaf_index: payload.proof.leaf_index, + siblings: payload + .proof + .siblings + .iter() + .map(|s| s.to_string()) + .collect(), + authenticator_pubkeys, + }) } fn serialize_proof_package( proof: &impl Serialize, diff --git a/walletkit-core/src/authenticator/utils.rs b/walletkit-core/src/authenticator/utils.rs new file mode 100644 index 000000000..f3c69f915 --- /dev/null +++ b/walletkit-core/src/authenticator/utils.rs @@ -0,0 +1,41 @@ +use std::convert::TryFrom; + +use crate::error::WalletKitError; +use crate::U256Wrapper; +use ruint::aliases::U256; +use world_id_core::FieldElement; + +/// Converts a leaf index from `U256Wrapper` to `u64`. +/// +/// # Errors +/// +/// Returns an error if the leaf index does not fit in a `u64`. +pub(super) fn leaf_index_to_u64( + leaf_index: &U256Wrapper, +) -> Result { + u64::try_from(leaf_index.0).map_err(|_| WalletKitError::InvalidInput { + attribute: "leaf_index".to_string(), + reason: "leaf index does not fit in u64".to_string(), + }) +} + +pub(super) fn parse_fixed_bytes( + bytes: Vec, + label: &str, +) -> Result<[u8; N], WalletKitError> { + bytes + .try_into() + .map_err(|bytes: Vec| WalletKitError::InvalidInput { + attribute: label.to_string(), + reason: format!("length mismatch: expected {N}, got {}", bytes.len()), + }) +} + +pub(super) fn field_element_to_bytes(value: FieldElement) -> [u8; 32] { + let value: U256 = value.into(); + value.to_be_bytes::<32>() +} + +pub(super) fn u256_to_hex(value: U256) -> String { + format!("{:#066x}", value) +} From 719417f58434b84699d0c19534c362652179dd65 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 13:06:01 -0800 Subject: [PATCH 33/46] use serialize_as_bytes --- walletkit-core/src/authenticator/storage.rs | 7 ++++--- walletkit-core/src/authenticator/utils.rs | 20 +++++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 2a8de3401..42ede2e0a 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -71,7 +71,7 @@ impl Authenticator { authenticator_pubkeys: key_set.clone(), }; let payload_bytes = serialize_inclusion_proof(&payload)?; - let proof_root = field_element_to_bytes(proof.root); + let proof_root = field_element_to_bytes(proof.root)?; storage.merkle_cache_put( registry_kind, proof_root.to_vec(), @@ -145,7 +145,7 @@ impl Authenticator { }) })?; let proof_bytes = serialize_proof_package(&proof, nullifier)?; - let nullifier_bytes = field_element_to_bytes(nullifier); + let nullifier_bytes = field_element_to_bytes(nullifier)?; storage .begin_replay_guard( request_id.to_vec(), @@ -282,7 +282,8 @@ mod tests { authenticator_pubkeys: key_set, }; let payload_bytes = serialize_inclusion_proof(&payload).expect("serialize"); - let root_bytes = field_element_to_bytes(proof.root); + let root_bytes = + field_element_to_bytes(proof.root).expect("field element bytes"); store .merkle_cache_put(1, root_bytes.to_vec(), payload_bytes, 100, 60) diff --git a/walletkit-core/src/authenticator/utils.rs b/walletkit-core/src/authenticator/utils.rs index f3c69f915..d28a69cbf 100644 --- a/walletkit-core/src/authenticator/utils.rs +++ b/walletkit-core/src/authenticator/utils.rs @@ -31,9 +31,23 @@ pub(super) fn parse_fixed_bytes( }) } -pub(super) fn field_element_to_bytes(value: FieldElement) -> [u8; 32] { - let value: U256 = value.into(); - value.to_be_bytes::<32>() +pub(super) fn field_element_to_bytes( + value: FieldElement, +) -> Result<[u8; 32], WalletKitError> { + let mut bytes = Vec::new(); + value.serialize_as_bytes(&mut bytes).map_err(|err| { + WalletKitError::SerializationError { + error: err.to_string(), + } + })?; + bytes.try_into().map_err(|bytes: Vec| { + WalletKitError::SerializationError { + error: format!( + "field element length mismatch: expected 32, got {}", + bytes.len() + ), + } + }) } pub(super) fn u256_to_hex(value: U256) -> String { From b1e8854823dfd3acded0780ff2970b1b5fcaffbb Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 13:25:02 -0800 Subject: [PATCH 34/46] CachedInclusionProof::serialize --- walletkit-core/src/authenticator/storage.rs | 38 ++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 42ede2e0a..82508f8d9 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -4,16 +4,18 @@ 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, OnchainKeyRepresentable}; +use world_id_core::{ + requests::ProofRequest, Credential, FieldElement, OnchainKeyRepresentable, +}; use crate::error::WalletKitError; use crate::storage::{CredentialStore, ReplayGuardKind, ReplayGuardResult}; use crate::U256Wrapper; -use super::Authenticator; use super::utils::{ field_element_to_bytes, leaf_index_to_u64, parse_fixed_bytes, u256_to_hex, }; +use super::Authenticator; /// Buffer cached proofs to remain valid during on-chain verification. const MERKLE_PROOF_VALIDITY_BUFFER_SECS: u64 = 120; @@ -70,7 +72,7 @@ impl Authenticator { proof: proof.clone(), authenticator_pubkeys: key_set.clone(), }; - let payload_bytes = serialize_inclusion_proof(&payload)?; + let payload_bytes = payload.serialize()?; let proof_root = field_element_to_bytes(proof.root)?; storage.merkle_cache_put( registry_kind, @@ -164,6 +166,18 @@ struct CachedInclusionProof { authenticator_pubkeys: 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) + } +} + #[derive(Debug, Clone, uniffi::Record)] pub struct InclusionProofPayload { /// Merkle root as hex string. @@ -176,18 +190,6 @@ pub struct InclusionProofPayload { pub authenticator_pubkeys: Vec, } -fn serialize_inclusion_proof( - payload: &CachedInclusionProof, -) -> Result, WalletKitError> { - let mut bytes = Vec::new(); - ciborium::ser::into_writer(payload, &mut bytes).map_err(|err| { - WalletKitError::SerializationError { - error: err.to_string(), - } - })?; - Ok(bytes) -} - fn deserialize_inclusion_proof(bytes: &[u8]) -> Option { ciborium::de::from_reader(bytes).ok() } @@ -201,9 +203,7 @@ fn inclusion_proof_payload_from_cached( .map(|pk| { let encoded = pk.to_ethereum_representation().map_err(|err| { WalletKitError::Generic { - error: format!( - "failed to encode authenticator pubkey: {err}" - ), + error: format!("failed to encode authenticator pubkey: {err}"), } })?; Ok(u256_to_hex(encoded)) @@ -281,7 +281,7 @@ mod tests { proof: proof.clone(), authenticator_pubkeys: key_set, }; - let payload_bytes = serialize_inclusion_proof(&payload).expect("serialize"); + let payload_bytes = payload.serialize().expect("serialize"); let root_bytes = field_element_to_bytes(proof.root).expect("field element bytes"); From d954b065bdb3afb26c000859156c978b086a1463 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 13:25:42 -0800 Subject: [PATCH 35/46] CachedInclusionProof::deserialize --- walletkit-core/src/authenticator/storage.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 82508f8d9..6dcbfb738 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -62,7 +62,7 @@ impl Authenticator { if let Some(bytes) = storage.merkle_cache_get(registry_kind, root.to_vec(), valid_before)? { - if let Some(cached) = deserialize_inclusion_proof(&bytes) { + if let Some(cached) = CachedInclusionProof::deserialize(&bytes) { return inclusion_proof_payload_from_cached(&cached); } } @@ -176,6 +176,10 @@ impl CachedInclusionProof { })?; Ok(bytes) } + + fn deserialize(bytes: &[u8]) -> Option { + ciborium::de::from_reader(bytes).ok() + } } #[derive(Debug, Clone, uniffi::Record)] @@ -190,10 +194,6 @@ pub struct InclusionProofPayload { pub authenticator_pubkeys: Vec, } -fn deserialize_inclusion_proof(bytes: &[u8]) -> Option { - ciborium::de::from_reader(bytes).ok() -} - fn inclusion_proof_payload_from_cached( payload: &CachedInclusionProof, ) -> Result { @@ -293,7 +293,7 @@ mod tests { .merkle_cache_get(1, root_bytes.to_vec(), valid_before) .expect("cache get") .expect("cache hit"); - let decoded = deserialize_inclusion_proof(&cached).expect("decode"); + let decoded = CachedInclusionProof::deserialize(&cached).expect("decode"); assert_eq!(decoded.proof.leaf_index, 42); assert_eq!(decoded.proof.root, root_fe); assert_eq!(decoded.authenticator_pubkeys.len(), 0); From cfa18f17d4845cd8694f1b4bd2c28321fe851515 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 13:33:46 -0800 Subject: [PATCH 36/46] impl From for WalletKitError, remove field_element_to_bytes --- walletkit-core/src/authenticator/storage.rs | 27 +++++++++++---- walletkit-core/src/authenticator/utils.rs | 20 ----------- walletkit-core/src/defaults.rs | 15 ++------ walletkit-core/src/error.rs | 38 +++++++++++++++------ 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 6dcbfb738..1ede0b1e6 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -12,9 +12,7 @@ use crate::error::WalletKitError; use crate::storage::{CredentialStore, ReplayGuardKind, ReplayGuardResult}; use crate::U256Wrapper; -use super::utils::{ - field_element_to_bytes, leaf_index_to_u64, parse_fixed_bytes, u256_to_hex, -}; +use super::utils::{leaf_index_to_u64, parse_fixed_bytes, u256_to_hex}; use super::Authenticator; /// Buffer cached proofs to remain valid during on-chain verification. @@ -73,7 +71,11 @@ impl Authenticator { authenticator_pubkeys: key_set.clone(), }; let payload_bytes = payload.serialize()?; - let proof_root = field_element_to_bytes(proof.root)?; + let proof_root = { + let mut bytes = Vec::new(); + proof.root.serialize_as_bytes(&mut bytes)?; + parse_fixed_bytes::<32>(bytes, "field_element")? + }; storage.merkle_cache_put( registry_kind, proof_root.to_vec(), @@ -147,7 +149,11 @@ impl Authenticator { }) })?; let proof_bytes = serialize_proof_package(&proof, nullifier)?; - let nullifier_bytes = field_element_to_bytes(nullifier)?; + let nullifier_bytes = { + let mut bytes = Vec::new(); + nullifier.serialize_as_bytes(&mut bytes)?; + parse_fixed_bytes::<32>(bytes, "field_element")? + }; storage .begin_replay_guard( request_id.to_vec(), @@ -282,8 +288,15 @@ mod tests { authenticator_pubkeys: key_set, }; let payload_bytes = payload.serialize().expect("serialize"); - let root_bytes = - field_element_to_bytes(proof.root).expect("field element bytes"); + let root_bytes: [u8; 32] = { + let mut bytes = Vec::new(); + proof + .root + .serialize_as_bytes(&mut bytes) + .expect("serialize field element"); + parse_fixed_bytes::<32>(bytes, "field_element") + .expect("field element bytes") + }; store .merkle_cache_put(1, root_bytes.to_vec(), payload_bytes, 100, 60) diff --git a/walletkit-core/src/authenticator/utils.rs b/walletkit-core/src/authenticator/utils.rs index d28a69cbf..f0f09febc 100644 --- a/walletkit-core/src/authenticator/utils.rs +++ b/walletkit-core/src/authenticator/utils.rs @@ -3,7 +3,6 @@ use std::convert::TryFrom; use crate::error::WalletKitError; use crate::U256Wrapper; use ruint::aliases::U256; -use world_id_core::FieldElement; /// Converts a leaf index from `U256Wrapper` to `u64`. /// @@ -31,25 +30,6 @@ pub(super) fn parse_fixed_bytes( }) } -pub(super) fn field_element_to_bytes( - value: FieldElement, -) -> Result<[u8; 32], WalletKitError> { - let mut bytes = Vec::new(); - value.serialize_as_bytes(&mut bytes).map_err(|err| { - WalletKitError::SerializationError { - error: err.to_string(), - } - })?; - bytes.try_into().map_err(|bytes: Vec| { - WalletKitError::SerializationError { - error: format!( - "field element length mismatch: expected 32, got {}", - bytes.len() - ), - } - }) -} - pub(super) fn u256_to_hex(value: U256) -> String { format!("{:#066x}", value) } diff --git a/walletkit-core/src/defaults.rs b/walletkit-core/src/defaults.rs index fefca5300..e6538d28f 100644 --- a/walletkit-core/src/defaults.rs +++ b/walletkit-core/src/defaults.rs @@ -1,5 +1,5 @@ use alloy_primitives::{address, Address}; -use world_id_core::primitives::{Config, PrimitiveError}; +use world_id_core::primitives::Config; use crate::{error::WalletKitError, Environment}; @@ -15,15 +15,6 @@ pub trait DefaultConfig { 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, @@ -40,7 +31,7 @@ impl DefaultConfig for Config { vec![], 2, ) - .map_err(map_config_error), + .map_err(WalletKitError::from), Environment::Production => Self::new( rpc_url, @@ -51,7 +42,7 @@ impl DefaultConfig for Config { vec![], 2, ) - .map_err(map_config_error), + .map_err(WalletKitError::from), } } } diff --git a/walletkit-core/src/error.rs b/walletkit-core/src/error.rs index 91e2c22d6..0acd94064 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -1,4 +1,5 @@ use thiserror::Error; +use world_id_core::primitives::PrimitiveError; #[cfg(feature = "storage")] use crate::storage::StorageError; @@ -101,6 +102,31 @@ 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 { @@ -139,17 +165,7 @@ 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), _ => Self::AuthenticatorError { error: error.to_string(), From 9cdda482bd54828670f8575854a5ffaf9cb7dab9 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 13:37:18 -0800 Subject: [PATCH 37/46] clippy --- walletkit-core/src/authenticator/storage.rs | 8 +++++--- walletkit-core/src/authenticator/utils.rs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 1ede0b1e6..0b910dca3 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -25,6 +25,7 @@ impl Authenticator { /// # Errors /// /// Returns an error if the leaf index is invalid or storage initialization fails. + #[allow(clippy::needless_pass_by_value)] pub fn init_storage( &self, storage: Arc, @@ -68,7 +69,7 @@ impl Authenticator { let (proof, key_set) = self.0.fetch_inclusion_proof().await?; let payload = CachedInclusionProof { proof: proof.clone(), - authenticator_pubkeys: key_set.clone(), + authenticator_pubkeys: key_set, }; let payload_bytes = payload.serialize()?; let proof_root = { @@ -79,7 +80,7 @@ impl Authenticator { storage.merkle_cache_put( registry_kind, proof_root.to_vec(), - payload_bytes.clone(), + payload_bytes, now, ttl_seconds, )?; @@ -95,6 +96,7 @@ impl Authenticator { /// Returns an error if the proof generation or storage update fails. #[allow(clippy::too_many_arguments)] #[allow(clippy::future_not_send)] + #[allow(clippy::unused_async)] pub async fn generate_proof_with_replay_guard( &self, storage: Arc, @@ -223,7 +225,7 @@ fn inclusion_proof_payload_from_cached( .proof .siblings .iter() - .map(|s| s.to_string()) + .map(std::string::ToString::to_string) .collect(), authenticator_pubkeys, }) diff --git a/walletkit-core/src/authenticator/utils.rs b/walletkit-core/src/authenticator/utils.rs index f0f09febc..7ac54113b 100644 --- a/walletkit-core/src/authenticator/utils.rs +++ b/walletkit-core/src/authenticator/utils.rs @@ -31,5 +31,5 @@ pub(super) fn parse_fixed_bytes( } pub(super) fn u256_to_hex(value: U256) -> String { - format!("{:#066x}", value) + format!("{value:#066x}") } From 3fe300a97a9c958a69835fd1f8b5abd58b991f44 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 13:46:12 -0800 Subject: [PATCH 38/46] add docstrings --- .../src/storage/cache/maintenance.rs | 25 +++++++++++++++++++ walletkit-core/src/storage/cache/merkle.rs | 15 +++++++++++ .../src/storage/cache/nullifiers.rs | 10 ++++++++ walletkit-core/src/storage/cache/schema.rs | 20 +++++++++++++++ walletkit-core/src/storage/cache/session.rs | 20 +++++++++++++++ walletkit-core/src/storage/cache/util.rs | 19 ++++++++++++++ 6 files changed, 109 insertions(+) diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index c32ca27f9..3395d9f43 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -11,6 +11,11 @@ use crate::storage::sqlcipher; use super::schema; use super::util::{map_io_err, map_sqlcipher_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], @@ -30,6 +35,11 @@ pub(super) fn open_or_rebuild( } } +/// Opens the cache DB, applies SQLCipher settings, and ensures schema. +/// +/// # Errors +/// +/// Returns an error if the DB cannot be opened or configured. fn open_prepared(path: &Path, k_intermediate: [u8; 32]) -> StorageResult { let conn = sqlcipher::open_connection(path, false).map_err(map_sqlcipher_err)?; sqlcipher::apply_key(&conn, k_intermediate).map_err(map_sqlcipher_err)?; @@ -38,11 +48,21 @@ fn open_prepared(path: &Path, k_intermediate: [u8; 32]) -> StorageResult 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 +70,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 5c10407ff..a52fa6269 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -6,6 +6,11 @@ use crate::storage::error::StorageResult; use super::util::{expiry_timestamp, map_db_err, merkle_cache_key, to_i64}; +/// 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, @@ -29,6 +34,11 @@ pub(super) fn get( Ok(proof) } +/// 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, @@ -56,6 +66,11 @@ pub(super) fn put( Ok(()) } +/// Removes expired cache entries before inserting new ones. +/// +/// # Errors +/// +/// Returns an error if the deletion fails. fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { let now_i64 = to_i64(now, "now")?; conn.execute( diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index 4fddd65d7..8c1fa0c58 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -12,6 +12,11 @@ use super::util::{ expiry_timestamp, map_db_err, replay_nullifier_key, replay_request_key, to_i64, }; +/// Fetches stored proof bytes for a request id if still valid. +/// +/// # Errors +/// +/// Returns an error if the query or conversion fails. pub(super) fn replay_guard_bytes_for_request_id( conn: &Connection, request_id: [u8; 32], @@ -31,6 +36,11 @@ pub(super) fn replay_guard_bytes_for_request_id( .map_err(|err| map_db_err(&err)) } +/// Enforces replay-safety for disclosures within a single transaction. +/// +/// # Errors +/// +/// Returns an error if the nullifier was already disclosed or on DB failures. pub(super) fn begin_replay_guard( conn: &mut Connection, request_id: [u8; 32], diff --git a/walletkit-core/src/storage/cache/schema.rs b/walletkit-core/src/storage/cache/schema.rs index 176f4918f..9f86f82ea 100644 --- a/walletkit-core/src/storage/cache/schema.rs +++ b/walletkit-core/src/storage/cache/schema.rs @@ -8,6 +8,11 @@ use super::util::map_db_err; const CACHE_SCHEMA_VERSION: i64 = 2; +/// Ensures the cache schema is present and at the expected version. +/// +/// # Errors +/// +/// Returns an error if schema creation or migration fails. pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS cache_meta ( @@ -42,6 +47,11 @@ pub(super) fn ensure_schema(conn: &Connection) -> StorageResult<()> { Ok(()) } +/// Ensures the `cache_entries` table and indexes exist. +/// +/// # Errors +/// +/// Returns an error if schema creation fails. fn ensure_entries_schema(conn: &Connection) -> StorageResult<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS cache_entries ( @@ -59,6 +69,11 @@ fn ensure_entries_schema(conn: &Connection) -> StorageResult<()> { Ok(()) } +/// Drops legacy cache tables and recreates the current schema. +/// +/// # Errors +/// +/// Returns an error if the reset or re-init fails. fn reset_schema(conn: &Connection) -> StorageResult<()> { conn.execute_batch( "DROP TABLE IF EXISTS used_nullifiers; @@ -74,6 +89,11 @@ fn reset_schema(conn: &Connection) -> StorageResult<()> { 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) diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs index 47a6976b3..c755e4378 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -10,6 +10,11 @@ use super::util::{ expiry_timestamp, map_db_err, parse_fixed_bytes, session_cache_key, to_i64, }; +/// 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], @@ -34,6 +39,11 @@ pub(super) fn get( } } +/// 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], @@ -59,6 +69,11 @@ pub(super) fn put( Ok(()) } +/// Removes expired cache entries before inserting new ones. +/// +/// # Errors +/// +/// Returns an error if the deletion fails. fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { let now_i64 = to_i64(now, "now")?; conn.execute( @@ -69,6 +84,11 @@ fn prune_expired(conn: &Connection, now: u64) -> StorageResult<()> { 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) diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs index 917cbe241..9fefab94e 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -5,10 +5,12 @@ use std::io; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::sqlcipher::SqlcipherError; +/// Maps a rusqlite error into a cache storage error. pub(super) fn map_db_err(err: &rusqlite::Error) -> StorageError { StorageError::CacheDb(err.to_string()) } +/// Maps a SQLCipher error into a cache storage error. pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { match err { SqlcipherError::Sqlite(err) => StorageError::CacheDb(err.to_string()), @@ -16,10 +18,16 @@ pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { } } +/// 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, @@ -40,6 +48,7 @@ pub(super) const CACHE_KEY_PREFIX_SESSION: u8 = 0x02; pub(super) const CACHE_KEY_PREFIX_REPLAY_REQUEST: u8 = 0x03; pub(super) const CACHE_KEY_PREFIX_REPLAY_NULLIFIER: u8 = 0x04; +/// 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); @@ -47,6 +56,7 @@ fn cache_key_with_prefix(prefix: u8, payload: &[u8]) -> Vec { key } +/// Builds the cache key for a Merkle proof entry. pub(super) fn merkle_cache_key( registry_kind: u8, root: [u8; 32], @@ -59,22 +69,31 @@ pub(super) fn merkle_cache_key( cache_key_with_prefix(CACHE_KEY_PREFIX_MERKLE, &payload) } +/// 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 request entry. pub(super) fn replay_request_key(request_id: [u8; 32]) -> Vec { cache_key_with_prefix(CACHE_KEY_PREFIX_REPLAY_REQUEST, request_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}")) From 28077e884dcf71a62d98e76f91a3880535664ce6 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 14:03:55 -0800 Subject: [PATCH 39/46] cache utils --- .../src/storage/cache/maintenance.rs | 2 +- walletkit-core/src/storage/cache/merkle.rs | 38 ++------ .../src/storage/cache/nullifiers.rs | 37 ++------ walletkit-core/src/storage/cache/session.rs | 36 +------- walletkit-core/src/storage/cache/util.rs | 91 ++++++++++++++++++- 5 files changed, 108 insertions(+), 96 deletions(-) diff --git a/walletkit-core/src/storage/cache/maintenance.rs b/walletkit-core/src/storage/cache/maintenance.rs index 3395d9f43..97834b2d9 100644 --- a/walletkit-core/src/storage/cache/maintenance.rs +++ b/walletkit-core/src/storage/cache/maintenance.rs @@ -35,7 +35,7 @@ pub(super) fn open_or_rebuild( } } -/// Opens the cache DB, applies SQLCipher settings, and ensures schema. +/// Opens the cache DB, applies `SQLCipher` settings, and ensures schema. /// /// # Errors /// diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index a52fa6269..9cff2a253 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -4,7 +4,10 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::storage::error::StorageResult; -use super::util::{expiry_timestamp, map_db_err, merkle_cache_key, to_i64}; +use super::util::{ + cache_entry_times, map_db_err, merkle_cache_key, prune_expired_entries, to_i64, + upsert_cache_entry, +}; /// Fetches a cached Merkle proof if it is still valid. /// @@ -48,35 +51,8 @@ pub(super) fn put( now: u64, ttl_seconds: u64, ) -> StorageResult<()> { - prune_expired(conn, now)?; - let expires_at = expiry_timestamp(now, ttl_seconds); + prune_expired_entries(conn, now)?; let key = merkle_cache_key(registry_kind, root, leaf_index); - let inserted_at_i64 = to_i64(now, "now")?; - let expires_at_i64 = to_i64(expires_at, "expires_at")?; - conn.execute( - "INSERT OR REPLACE INTO cache_entries ( - key_bytes, - value_bytes, - inserted_at, - expires_at - ) VALUES (?1, ?2, ?3, ?4)", - params![key, proof_bytes, inserted_at_i64, expires_at_i64], - ) - .map_err(|err| map_db_err(&err))?; - Ok(()) -} - -/// Removes expired cache entries before inserting new ones. -/// -/// # Errors -/// -/// Returns an error if the deletion fails. -fn prune_expired(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(()) + let times = cache_entry_times(now, ttl_seconds)?; + upsert_cache_entry(conn, key.as_slice(), proof_bytes, times) } diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index 8c1fa0c58..ca24cc384 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -9,7 +9,8 @@ use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{ReplayGuardKind, ReplayGuardResult}; use super::util::{ - expiry_timestamp, map_db_err, replay_nullifier_key, replay_request_key, to_i64, + cache_entry_times, insert_cache_entry, map_db_err, prune_expired_entries, + replay_nullifier_key, replay_request_key, to_i64, }; /// Fetches stored proof bytes for a request id if still valid. @@ -53,11 +54,7 @@ pub(super) fn begin_replay_guard( let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(|err| map_db_err(&err))?; - tx.execute( - "DELETE FROM cache_entries WHERE expires_at <= ?1", - params![now_i64], - ) - .map_err(|err| map_db_err(&err))?; + prune_expired_entries(&tx, now)?; let request_key = replay_request_key(request_id); let existing_proof: Option> = tx @@ -95,31 +92,9 @@ pub(super) fn begin_replay_guard( return Err(StorageError::NullifierAlreadyDisclosed); } - let expires_at = expiry_timestamp(now, ttl_seconds); - let expires_at_i64 = to_i64(expires_at, "expires_at")?; - let inserted_at_i64 = to_i64(now, "now")?; - tx.execute( - "INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at) - VALUES (?1, ?2, ?3, ?4)", - params![ - request_key.as_slice(), - proof_bytes, - inserted_at_i64, - expires_at_i64 - ], - ) - .map_err(|err| map_db_err(&err))?; - tx.execute( - "INSERT INTO cache_entries (key_bytes, value_bytes, inserted_at, expires_at) - VALUES (?1, ?2, ?3, ?4)", - params![ - nullifier_key.as_slice(), - request_id.as_ref(), - inserted_at_i64, - expires_at_i64 - ], - ) - .map_err(|err| map_db_err(&err))?; + let times = cache_entry_times(now, ttl_seconds)?; + insert_cache_entry(&tx, request_key.as_slice(), proof_bytes.as_ref(), times)?; + insert_cache_entry(&tx, nullifier_key.as_slice(), request_id.as_ref(), times)?; tx.commit().map_err(|err| map_db_err(&err))?; Ok(ReplayGuardResult { kind: ReplayGuardKind::Fresh, diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs index c755e4378..7cfadfcba 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -7,7 +7,8 @@ use rusqlite::{params, Connection, OptionalExtension}; use crate::storage::error::{StorageError, StorageResult}; use super::util::{ - expiry_timestamp, map_db_err, parse_fixed_bytes, session_cache_key, to_i64, + cache_entry_times, map_db_err, parse_fixed_bytes, prune_expired_entries, + session_cache_key, to_i64, upsert_cache_entry, }; /// Fetches a cached session key if it is still valid. @@ -51,37 +52,10 @@ pub(super) fn put( ttl_seconds: u64, ) -> StorageResult<()> { let now = current_unix_timestamp()?; - prune_expired(conn, now)?; - let expires_at = expiry_timestamp(now, ttl_seconds); let key = session_cache_key(rp_id); - let inserted_at_i64 = to_i64(now, "now")?; - let expires_at_i64 = to_i64(expires_at, "expires_at")?; - conn.execute( - "INSERT OR REPLACE INTO cache_entries ( - key_bytes, - value_bytes, - inserted_at, - expires_at - ) VALUES (?1, ?2, ?3, ?4)", - params![key, k_session.as_ref(), inserted_at_i64, expires_at_i64], - ) - .map_err(|err| map_db_err(&err))?; - Ok(()) -} - -/// Removes expired cache entries before inserting new ones. -/// -/// # Errors -/// -/// Returns an error if the deletion fails. -fn prune_expired(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(()) + prune_expired_entries(conn, now)?; + let times = cache_entry_times(now, ttl_seconds)?; + upsert_cache_entry(conn, key.as_slice(), k_session.as_ref(), times) } /// Returns the current unix timestamp in seconds. diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs index 9fefab94e..739543c95 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -1,5 +1,6 @@ //! Shared helpers for cache database operations. +use rusqlite::{params, Connection}; use std::io; use crate::storage::error::{StorageError, StorageResult}; @@ -10,7 +11,7 @@ pub(super) fn map_db_err(err: &rusqlite::Error) -> StorageError { StorageError::CacheDb(err.to_string()) } -/// Maps a SQLCipher error into a cache storage error. +/// Maps a `SQLCipher` error into a cache storage error. pub(super) fn map_sqlcipher_err(err: SqlcipherError) -> StorageError { match err { SqlcipherError::Sqlite(err) => StorageError::CacheDb(err.to_string()), @@ -48,6 +49,92 @@ pub(super) const CACHE_KEY_PREFIX_SESSION: u8 = 0x02; pub(super) const CACHE_KEY_PREFIX_REPLAY_REQUEST: u8 = 0x03; pub(super) const CACHE_KEY_PREFIX_REPLAY_NULLIFIER: u8 = 0x04; +/// Timestamps for cache entry insertion and expiry. +#[derive(Clone, Copy, Debug)] +pub(super) struct CacheEntryTimes { + inserted_at: i64, + 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(()) +} + +/// 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. +/// +/// # Errors +/// +/// Returns an error if the insert fails. +pub(super) fn insert_cache_entry( + conn: &Connection, + key: &[u8], + value: &[u8], + times: CacheEntryTimes, +) -> StorageResult<()> { + conn.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(()) +} + /// 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()); @@ -89,7 +176,7 @@ 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. +/// Converts a `u64` into `i64` for `SQLite` parameter bindings. /// /// # Errors /// From 095aa148a699397a04b3f33267ba629e2b553f2c Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 14:08:41 -0800 Subject: [PATCH 40/46] get_cache_entry --- walletkit-core/src/storage/cache/merkle.rs | 18 ++----- .../src/storage/cache/nullifiers.rs | 47 ++++--------------- walletkit-core/src/storage/cache/session.rs | 19 ++------ walletkit-core/src/storage/cache/util.rs | 35 +++++++++++++- 4 files changed, 49 insertions(+), 70 deletions(-) diff --git a/walletkit-core/src/storage/cache/merkle.rs b/walletkit-core/src/storage/cache/merkle.rs index 9cff2a253..b30fdea3d 100644 --- a/walletkit-core/src/storage/cache/merkle.rs +++ b/walletkit-core/src/storage/cache/merkle.rs @@ -1,11 +1,11 @@ //! Merkle proof cache helpers. -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::Connection; use crate::storage::error::StorageResult; use super::util::{ - cache_entry_times, map_db_err, merkle_cache_key, prune_expired_entries, to_i64, + cache_entry_times, get_cache_entry, merkle_cache_key, prune_expired_entries, upsert_cache_entry, }; @@ -21,20 +21,8 @@ pub(super) fn get( leaf_index: u64, valid_before: u64, ) -> StorageResult>> { - let valid_before_i64 = to_i64(valid_before, "valid_before")?; let key = merkle_cache_key(registry_kind, root, leaf_index); - let proof = conn - .query_row( - "SELECT value_bytes - FROM cache_entries - WHERE key_bytes = ?1 - AND expires_at > ?2", - params![key, valid_before_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; - Ok(proof) + get_cache_entry(conn, key.as_slice(), valid_before) } /// Inserts or replaces a cached Merkle proof with a TTL. diff --git a/walletkit-core/src/storage/cache/nullifiers.rs b/walletkit-core/src/storage/cache/nullifiers.rs index ca24cc384..760d9b490 100644 --- a/walletkit-core/src/storage/cache/nullifiers.rs +++ b/walletkit-core/src/storage/cache/nullifiers.rs @@ -3,14 +3,14 @@ //! 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 rusqlite::{Connection, TransactionBehavior}; use crate::storage::error::{StorageError, StorageResult}; use crate::storage::types::{ReplayGuardKind, ReplayGuardResult}; use super::util::{ - cache_entry_times, insert_cache_entry, map_db_err, prune_expired_entries, - replay_nullifier_key, replay_request_key, to_i64, + cache_entry_times, commit_transaction, get_cache_entry, insert_cache_entry, + map_db_err, prune_expired_entries, replay_nullifier_key, replay_request_key, }; /// Fetches stored proof bytes for a request id if still valid. @@ -23,18 +23,8 @@ pub(super) fn replay_guard_bytes_for_request_id( request_id: [u8; 32], now: u64, ) -> StorageResult>> { - let now_i64 = to_i64(now, "now")?; let key = replay_request_key(request_id); - conn.query_row( - "SELECT value_bytes - FROM cache_entries - WHERE key_bytes = ?1 - AND expires_at > ?2", - params![key, now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err)) + get_cache_entry(conn, key.as_slice(), now) } /// Enforces replay-safety for disclosures within a single transaction. @@ -50,26 +40,15 @@ pub(super) fn begin_replay_guard( 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))?; prune_expired_entries(&tx, now)?; let request_key = replay_request_key(request_id); - let existing_proof: Option> = tx - .query_row( - "SELECT value_bytes - FROM cache_entries - WHERE key_bytes = ?1 - AND expires_at > ?2", - params![request_key.as_slice(), now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; + let existing_proof = get_cache_entry(&tx, request_key.as_slice(), now)?; if let Some(bytes) = existing_proof { - tx.commit().map_err(|err| map_db_err(&err))?; + commit_transaction(tx)?; return Ok(ReplayGuardResult { kind: ReplayGuardKind::Replay, bytes, @@ -77,17 +56,7 @@ pub(super) fn begin_replay_guard( } let nullifier_key = replay_nullifier_key(nullifier); - let existing_request: Option> = tx - .query_row( - "SELECT value_bytes - FROM cache_entries - WHERE key_bytes = ?1 - AND expires_at > ?2", - params![nullifier_key.as_slice(), now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; + let existing_request = get_cache_entry(&tx, nullifier_key.as_slice(), now)?; if existing_request.is_some() { return Err(StorageError::NullifierAlreadyDisclosed); } @@ -95,7 +64,7 @@ pub(super) fn begin_replay_guard( let times = cache_entry_times(now, ttl_seconds)?; insert_cache_entry(&tx, request_key.as_slice(), proof_bytes.as_ref(), times)?; insert_cache_entry(&tx, nullifier_key.as_slice(), request_id.as_ref(), times)?; - tx.commit().map_err(|err| map_db_err(&err))?; + commit_transaction(tx)?; Ok(ReplayGuardResult { kind: ReplayGuardKind::Fresh, bytes: proof_bytes, diff --git a/walletkit-core/src/storage/cache/session.rs b/walletkit-core/src/storage/cache/session.rs index 7cfadfcba..b3daec68f 100644 --- a/walletkit-core/src/storage/cache/session.rs +++ b/walletkit-core/src/storage/cache/session.rs @@ -2,13 +2,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::Connection; use crate::storage::error::{StorageError, StorageResult}; use super::util::{ - cache_entry_times, map_db_err, parse_fixed_bytes, prune_expired_entries, - session_cache_key, to_i64, upsert_cache_entry, + 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. @@ -21,19 +21,8 @@ pub(super) fn get( rp_id: [u8; 32], ) -> StorageResult> { let now = current_unix_timestamp()?; - let now_i64 = to_i64(now, "now")?; let key = session_cache_key(rp_id); - let raw: Option> = conn - .query_row( - "SELECT value_bytes - FROM cache_entries - WHERE key_bytes = ?1 - AND expires_at > ?2", - params![key, now_i64], - |row| row.get(0), - ) - .optional() - .map_err(|err| map_db_err(&err))?; + let raw = get_cache_entry(conn, key.as_slice(), now)?; match raw { Some(bytes) => Ok(Some(parse_fixed_bytes::<32>(&bytes, "k_session")?)), None => Ok(None), diff --git a/walletkit-core/src/storage/cache/util.rs b/walletkit-core/src/storage/cache/util.rs index 739543c95..db2e4727a 100644 --- a/walletkit-core/src/storage/cache/util.rs +++ b/walletkit-core/src/storage/cache/util.rs @@ -1,6 +1,6 @@ //! Shared helpers for cache database operations. -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; use std::io; use crate::storage::error::{StorageError, StorageResult}; @@ -135,6 +135,39 @@ pub(super) fn insert_cache_entry( Ok(()) } +/// Fetches a cache entry if it is still valid. +/// +/// # Errors +/// +/// Returns an error if the query or conversion fails. +pub(super) fn get_cache_entry( + conn: &Connection, + key: &[u8], + valid_before: u64, +) -> StorageResult>> { + let valid_before_i64 = to_i64(valid_before, "valid_before")?; + conn.query_row( + "SELECT value_bytes + FROM cache_entries + WHERE key_bytes = ?1 + AND expires_at > ?2", + params![key, valid_before_i64], + |row| row.get(0), + ) + .optional() + .map_err(|err| map_db_err(&err)) +} + +/// Commits a `Transaction` with mapped cache errors. +/// +/// # Errors +/// +/// Returns an error if the commit fails. +pub(super) fn commit_transaction(tx: Transaction) -> StorageResult<()> { + tx.commit().map_err(|err| map_db_err(&err))?; + Ok(()) +} + /// 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()); From 3c5c5685251189319d5142801ab333c58332aba4 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 14:19:32 -0800 Subject: [PATCH 41/46] zeroize intermediate_key, StorageKeys, k_intermediate_bytes, wrapped_k_intermediate --- Cargo.lock | 1 + walletkit-core/Cargo.toml | 22 +++++++++++++++++++--- walletkit-core/src/storage/envelope.rs | 3 ++- walletkit-core/src/storage/keys.rs | 9 ++++++--- walletkit-core/src/storage/sqlcipher.rs | 8 +++++--- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59c4a4600..1c51899a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6294,6 +6294,7 @@ dependencies = [ "uniffi", "uuid", "world-id-core", + "zeroize", ] [[package]] diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index e984b8e13..e678cd97c 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -45,14 +45,23 @@ strum = { version = "0.27", features = ["derive"] } subtle = "2" thiserror = "2" tokio = { version = "1", features = ["sync"] } -rusqlite = { version = "0.32", features = ["bundled-sqlcipher"], optional = true } +zeroize = "1" +rusqlite = { version = "0.32", features = [ + "bundled-sqlcipher", +], optional = true } uuid = { version = "1.10", features = ["v4"], optional = true } uniffi = { workspace = true, features = ["build", "tokio"] } world-id-core = { workspace = true, optional = true } ciborium = { version = "0.2.2", 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" @@ -68,7 +77,14 @@ default = ["common-apps", "semaphore", "v4"] common-apps = [] http-tests = [] semaphore = ["semaphore-rs/depth_30"] -storage = ["dep:ciborium", "dep:hkdf", "dep:rand", "dep:rusqlite", "dep:sha2", "dep:uuid"] +storage = [ + "dep:ciborium", + "dep:hkdf", + "dep:rand", + "dep:rusqlite", + "dep:sha2", + "dep:uuid", +] v4 = ["world-id-core", "storage"] # Before conventions were introduced for external nullifiers with `app_id` & `action`, raw field elements were used. diff --git a/walletkit-core/src/storage/envelope.rs b/walletkit-core/src/storage/envelope.rs index f689d7f03..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, diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index cc0e3fdcd..133096ce7 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,12 @@ 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 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")?; + )?); + let k_intermediate = + parse_key_32(k_intermediate_bytes.as_slice(), "K_intermediate")?; Ok(Self { intermediate_key: k_intermediate, }) diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs index 0893d8566..d98bc4dbe 100644 --- a/walletkit-core/src/storage/sqlcipher.rs +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -4,6 +4,7 @@ use std::fmt; use std::path::Path; use rusqlite::{Connection, OpenFlags}; +use zeroize::{Zeroize, Zeroizing}; /// `SQLCipher` helper errors. #[derive(Debug)] @@ -53,16 +54,17 @@ pub(super) fn open_connection( /// Applies `SQLCipher` keying and validates cipher availability. pub(super) fn apply_key( conn: &Connection, - k_intermediate: [u8; 32], + mut k_intermediate: [u8; 32], ) -> SqlcipherResult<()> { - let key_hex = hex::encode(k_intermediate); - let pragma = format!("PRAGMA key = \"x'{key_hex}'\";"); + let key_hex = Zeroizing::new(hex::encode(k_intermediate)); + let pragma = Zeroizing::new(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); } + k_intermediate.zeroize(); Ok(()) } From 0d631b8cd8769f8afa9120b39c1354d88435f041 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Mon, 26 Jan 2026 14:22:36 -0800 Subject: [PATCH 42/46] validate fetch proof --- walletkit-core/src/authenticator/storage.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/walletkit-core/src/authenticator/storage.rs b/walletkit-core/src/authenticator/storage.rs index 0b910dca3..962126470 100644 --- a/walletkit-core/src/authenticator/storage.rs +++ b/walletkit-core/src/authenticator/storage.rs @@ -77,9 +77,15 @@ impl Authenticator { proof.root.serialize_as_bytes(&mut bytes)?; parse_fixed_bytes::<32>(bytes, "field_element")? }; + if proof_root != root { + return Err(WalletKitError::InvalidInput { + attribute: "root".to_string(), + reason: "fetched proof root does not match requested root".to_string(), + }); + } storage.merkle_cache_put( registry_kind, - proof_root.to_vec(), + root.to_vec(), payload_bytes, now, ttl_seconds, From 5f26fef8c6339cf367979e7408f7264011d8b05e Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Tue, 27 Jan 2026 14:41:15 -0800 Subject: [PATCH 43/46] add v4 PrimitiveError gate --- walletkit-core/src/error.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/walletkit-core/src/error.rs b/walletkit-core/src/error.rs index 0acd94064..91ff873b2 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -1,4 +1,5 @@ use thiserror::Error; +#[cfg(feature = "v4")] use world_id_core::primitives::PrimitiveError; #[cfg(feature = "storage")] @@ -102,6 +103,7 @@ impl From for WalletKitError { } } +#[cfg(feature = "v4")] impl From for WalletKitError { fn from(error: PrimitiveError) -> Self { match error { From 6d03cf5eff8202af1e5842e55171d3ab738a66de Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Tue, 27 Jan 2026 14:51:55 -0800 Subject: [PATCH 44/46] fix type error --- walletkit-core/src/storage/keys.rs | 3 ++- walletkit-core/src/storage/sqlcipher.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/walletkit-core/src/storage/keys.rs b/walletkit-core/src/storage/keys.rs index 133096ce7..b99433753 100644 --- a/walletkit-core/src/storage/keys.rs +++ b/walletkit-core/src/storage/keys.rs @@ -35,9 +35,10 @@ impl StorageKeys { ) -> StorageResult { if let Some(bytes) = blob_store.read(ACCOUNT_KEYS_FILENAME.to_string())? { let envelope = AccountKeyEnvelope::deserialize(&bytes)?; + let wrapped_k_intermediate = envelope.wrapped_k_intermediate.clone(); let k_intermediate_bytes = Zeroizing::new(keystore.open_sealed( ACCOUNT_KEY_ENVELOPE_AD.to_vec(), - envelope.wrapped_k_intermediate, + wrapped_k_intermediate, )?); let k_intermediate = parse_key_32(k_intermediate_bytes.as_slice(), "K_intermediate")?; diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs index d98bc4dbe..7043b69df 100644 --- a/walletkit-core/src/storage/sqlcipher.rs +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -57,7 +57,10 @@ pub(super) fn apply_key( mut k_intermediate: [u8; 32], ) -> SqlcipherResult<()> { let key_hex = Zeroizing::new(hex::encode(k_intermediate)); - let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{key_hex}'\";")); + let pragma = Zeroizing::new(format!( + "PRAGMA key = \"x'{}'\";", + key_hex.as_str() + )); conn.execute_batch(&pragma)?; let cipher_version: String = conn.query_row("PRAGMA cipher_version;", [], |row| row.get(0))?; From c3c0a0125068287309556c69b5114dc186c9a981 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Tue, 27 Jan 2026 14:53:25 -0800 Subject: [PATCH 45/46] fmt --- walletkit-core/src/storage/sqlcipher.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/walletkit-core/src/storage/sqlcipher.rs b/walletkit-core/src/storage/sqlcipher.rs index 7043b69df..2cf006e38 100644 --- a/walletkit-core/src/storage/sqlcipher.rs +++ b/walletkit-core/src/storage/sqlcipher.rs @@ -57,10 +57,7 @@ pub(super) fn apply_key( mut k_intermediate: [u8; 32], ) -> SqlcipherResult<()> { let key_hex = Zeroizing::new(hex::encode(k_intermediate)); - let pragma = Zeroizing::new(format!( - "PRAGMA key = \"x'{}'\";", - key_hex.as_str() - )); + let pragma = Zeroizing::new(format!("PRAGMA key = \"x'{}'\";", key_hex.as_str())); conn.execute_batch(&pragma)?; let cipher_version: String = conn.query_row("PRAGMA cipher_version;", [], |row| row.get(0))?; From 607d155034bd2fc957b4414136fd77256c01de63 Mon Sep 17 00:00:00 2001 From: Luke Mann Date: Tue, 27 Jan 2026 14:53:29 -0800 Subject: [PATCH 46/46] fmt --- walletkit-core/src/error.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/walletkit-core/src/error.rs b/walletkit-core/src/error.rs index 91ff873b2..e41999fce 100644 --- a/walletkit-core/src/error.rs +++ b/walletkit-core/src/error.rs @@ -110,9 +110,7 @@ impl From for WalletKitError { PrimitiveError::InvalidInput { attribute, reason } => { Self::InvalidInput { attribute, reason } } - PrimitiveError::Serialization(error) => { - Self::SerializationError { error } - } + PrimitiveError::Serialization(error) => Self::SerializationError { error }, PrimitiveError::Deserialization(reason) => Self::InvalidInput { attribute: "deserialization".to_string(), reason,