diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cd3acc11 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI +permissions: + contents: read + +on: + push: + branches: ['main'] + pull_request: + +jobs: + rust-core: + name: Rust Core - Build & Test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Lint with Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build Rust core + run: cargo build --release --package idkit-core + + - name: Run Rust tests + run: cargo test --package idkit-core + + swift-bindings: + name: Swift Bindings - Build & Test + runs-on: ${{ matrix.os }} + needs: rust-core + strategy: + fail-fast: false + matrix: + include: + - os: macos-15 + xcode: "16.0" + - os: macos-15 + xcode: "16.1" + env: + DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Cache Cargo dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + + - name: Restore Swift .build cache + id: restore-swift-build + uses: actions/cache/restore@v4 + with: + path: swift/.build + restore-keys: "swiftpm-build-${{ runner.os }}-${{ matrix.xcode }}-" + key: "swiftpm-build-${{ runner.os }}-${{ matrix.xcode }}-${{ hashFiles('**/Package.swift', '**/Package.resolved') }}" + + - name: Build Swift artifacts (Rust + XCFramework + Bindings) + run: bash scripts/package-swift.sh + + - name: Build Swift package + working-directory: swift + run: swift build + + - name: Run Swift tests + working-directory: swift + run: xcodebuild test -scheme IDKit -destination "platform=macOS" + + - name: Cache Swift .build + if: steps.restore-swift-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: swift/.build + key: "swiftpm-build-${{ runner.os }}-${{ matrix.xcode }}-${{ hashFiles('**/Package.swift', '**/Package.resolved') }}" \ No newline at end of file diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 00000000..04b3c9d5 --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,25 @@ +name: Validate PR conventions +permissions: + contents: read + +on: + pull_request: + types: + - opened + - edited + - synchronize + +jobs: + lint-pr: + name: Ensure PR follows conventional commits + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + validateSingleCommit: true + validateSingleCommitMatchesPrTitle: true diff --git a/.github/workflows/publish-swift.yml b/.github/workflows/publish-swift.yml new file mode 100644 index 00000000..e3696208 --- /dev/null +++ b/.github/workflows/publish-swift.yml @@ -0,0 +1,114 @@ +name: Publish Swift Release + +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + +jobs: + publish-swift: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') + runs-on: macos-15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: Install uniffi-bindgen + run: cargo install uniffi_bindgen --version 0.30.0 + + - name: Build Swift artifacts + run: scripts/package-swift.sh + + - name: Zip XCFramework + run: zip -r IDKitFFI.xcframework.zip IDKitFFI.xcframework + + - name: Compute checksum + id: checksum + run: | + CHECKSUM=$(swift package compute-checksum IDKitFFI.xcframework.zip) + echo "checksum=$CHECKSUM" >> "$GITHUB_OUTPUT" + + - name: Determine release version + id: version + run: | + VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name=="idkit-uniffi") | .version') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Create draft release in idkit-swift + id: release + uses: softprops/action-gh-release@v2 + with: + repository: worldcoin/idkit-swift + name: ${{ steps.version.outputs.version }} + tag_name: ${{ steps.version.outputs.version }} + make_latest: false + draft: true + fail_on_unmatched_files: true + files: IDKitFFI.xcframework.zip + token: ${{ secrets.GIT_HUB_TOKEN }} + body: | + Please review https://github.com/worldcoin/idkit/releases/tag/${{ steps.version.outputs.version }} for release notes. + + - name: Checkout idkit-swift repository + uses: actions/checkout@v4 + with: + repository: worldcoin/idkit-swift + token: ${{ secrets.GIT_HUB_TOKEN }} + path: idkit-swift + + - name: Sync generated Swift sources + run: | + rm -rf idkit-swift/Sources + mkdir -p idkit-swift/Sources + cp -R swift/Sources/IDKit idkit-swift/Sources/ + rm -f idkit-swift/Package.resolved + + - name: Update Package.swift metadata + run: | + ASSET_URL=$(echo '${{ steps.release.outputs.assets }}' | jq -r '.[0].browser_download_url') + VERSION=${{ steps.version.outputs.version }} + CHECKSUM=${{ steps.checksum.outputs.checksum }} + + python < - const DEFAULT_TIMEOUT_SECONDS: u64 = 900; - /// Creates a new session /// /// # Arguments @@ -210,7 +203,11 @@ impl Session { return Err(Error::BridgeError(format!( "Bridge request failed with status {}: {}", status, - if body.is_empty() { "no error details" } else { &body } + if body.is_empty() { + "no error details" + } else { + &body + } ))); } @@ -255,15 +252,7 @@ impl Session { .collect(), )); - Self::create_with_options( - app_id, - action, - requests, - None, - Some(constraints), - None, - ) - .await + Self::create_with_options(app_id, action, requests, None, Some(constraints), None).await } /// Returns the connect URL for World App @@ -273,10 +262,7 @@ impl Session { let bridge_param = if self.bridge_url == BridgeUrl::default() { String::new() } else { - format!( - "&b={}", - urlencoding::encode(self.bridge_url.as_str()) - ) + format!("&b={}", urlencoding::encode(self.bridge_url.as_str())) }; format!( @@ -292,7 +278,7 @@ impl Session { /// # Errors /// /// Returns an error if the request fails or the response is invalid - pub async fn poll(&self) -> Result { + pub async fn poll_for_status(&self) -> Result { let response = self .client .get( @@ -336,40 +322,6 @@ impl Session { } } - /// Waits for a proof, polling the bridge until completion with default timeout (15 minutes) - /// - /// # Errors - /// - /// Returns an error if polling fails, verification fails, or timeout is reached - pub async fn wait_for_proof(&self) -> Result { - self.wait_for_proof_with_timeout(Duration::from_secs(Self::DEFAULT_TIMEOUT_SECONDS)) - .await - } - - /// Waits for a proof with a specific timeout - /// - /// # Errors - /// - /// Returns an error if polling fails, verification fails, or timeout is reached - pub async fn wait_for_proof_with_timeout(&self, timeout: Duration) -> Result { - let start = tokio::time::Instant::now(); - let poll_interval = Duration::from_secs(3); - - loop { - if start.elapsed() > timeout { - return Err(Error::Timeout); - } - - match self.poll().await? { - Status::Confirmed(proof) => return Ok(proof), - Status::Failed(error) => return Err(Error::AppError(error)), - Status::WaitingForConnection | Status::AwaitingConfirmation => { - sleep(poll_interval).await; - } - } - } - } - /// Returns the request ID for this session #[must_use] pub const fn request_id(&self) -> Uuid { diff --git a/rust/core/src/constraints.rs b/rust/core/src/constraints.rs index 03493db1..19b85e00 100644 --- a/rust/core/src/constraints.rs +++ b/rust/core/src/constraints.rs @@ -251,7 +251,10 @@ mod tests { assert!(node.evaluate(&available)); // Should return Face because it's first in priority order - assert_eq!(node.first_satisfying(&available), Some(CredentialType::Face)); + assert_eq!( + node.first_satisfying(&available), + Some(CredentialType::Face) + ); } #[test] @@ -324,7 +327,10 @@ mod tests { // Only face available assert!(node.evaluate(&available)); - assert_eq!(node.first_satisfying(&available), Some(CredentialType::Face)); + assert_eq!( + node.first_satisfying(&available), + Some(CredentialType::Face) + ); // Both available - orb has priority available.insert(CredentialType::Orb); diff --git a/rust/core/src/types.rs b/rust/core/src/types.rs index 9cbf5d6f..c48587d9 100644 --- a/rust/core/src/types.rs +++ b/rust/core/src/types.rs @@ -371,10 +371,12 @@ mod tests { #[test] fn test_request_validation() { - let valid = Request::new(CredentialType::Orb, Some(Signal::from_string("signal"))).with_face_auth(true); + let valid = Request::new(CredentialType::Orb, Some(Signal::from_string("signal"))) + .with_face_auth(true); assert!(valid.validate().is_ok()); - let invalid = Request::new(CredentialType::Device, Some(Signal::from_string("signal"))).with_face_auth(true); + let invalid = Request::new(CredentialType::Device, Some(Signal::from_string("signal"))) + .with_face_auth(true); assert!(invalid.validate().is_err()); // Test without signal diff --git a/rust/uniffi-bindgen-bin/Cargo.toml b/rust/uniffi-bindgen-bin/Cargo.toml new file mode 100644 index 00000000..e58aa5f6 --- /dev/null +++ b/rust/uniffi-bindgen-bin/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "uniffi-bindgen" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "uniffi-bindgen" +path = "src/main.rs" + +[dependencies] +uniffi = { workspace = true, features = ["cli"] } diff --git a/rust/uniffi-bindgen-bin/src/main.rs b/rust/uniffi-bindgen-bin/src/main.rs new file mode 100644 index 00000000..a01b5470 --- /dev/null +++ b/rust/uniffi-bindgen-bin/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main(); +} diff --git a/rust/uniffi-bindings/Cargo.toml b/rust/uniffi-bindings/Cargo.toml index b581d90a..5fff3afc 100644 --- a/rust/uniffi-bindings/Cargo.toml +++ b/rust/uniffi-bindings/Cargo.toml @@ -12,8 +12,9 @@ crate-type = ["staticlib", "cdylib"] name = "idkit" [dependencies] -idkit-core = { path = "../core", default-features = false, features = ["uniffi-bindings", "native-crypto"] } +idkit-core = { path = "../core", default-features = false, features = ["uniffi-bindings", "native-crypto", "bridge"] } uniffi = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } hex = { workspace = true } +tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } diff --git a/rust/uniffi-bindings/src/lib.rs b/rust/uniffi-bindings/src/lib.rs index dbbb03eb..7f344531 100644 --- a/rust/uniffi-bindings/src/lib.rs +++ b/rust/uniffi-bindings/src/lib.rs @@ -5,8 +5,15 @@ #![deny(clippy::all, clippy::pedantic, clippy::nursery)] #![allow(clippy::module_name_repetitions)] +// UniFFI requires specific function signatures for FFI, so we allow these +#![allow(clippy::needless_pass_by_value)] -use idkit_core::{CredentialType, Proof, Request as CoreRequest, Signal as CoreSignal}; +use idkit_core::{ + bridge::{Session as CoreSession, Status as CoreStatus}, + ConstraintNode as CoreConstraintNode, Constraints as CoreConstraints, CredentialType, Proof, + Request as CoreRequest, Signal as CoreSignal, VerificationLevel, +}; +use std::sync::Arc; /// Signal wrapper for `UniFFI` /// @@ -20,6 +27,42 @@ pub struct Signal(CoreSignal); #[derive(Debug, Clone, uniffi::Object)] pub struct Request(CoreRequest); +/// Constraint node for `UniFFI` +/// +/// Represents a node in a constraint tree (Credential, Any, or All). +#[derive(Debug, Clone, uniffi::Object)] +pub struct ConstraintNode(CoreConstraintNode); + +/// Constraints wrapper for `UniFFI` +/// +/// Represents the top-level constraints for a session. +#[derive(Debug, Clone, uniffi::Object)] +pub struct Constraints(CoreConstraints); + +/// Session wrapper for `UniFFI` +/// +/// Manages a World ID verification session. +#[derive(uniffi::Object)] +pub struct Session { + runtime: tokio::runtime::Runtime, + inner: CoreSession, +} + +/// Status enum for `UniFFI` +/// +/// Represents the status of a verification request. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum Status { + /// Waiting for World App to retrieve the request + WaitingForConnection, + /// World App has retrieved the request, waiting for user confirmation + AwaitingConfirmation, + /// User has confirmed and provided a proof + Confirmed { proof: Proof }, + /// Request has failed + Failed { error: String }, +} + /// Error type for `UniFFI` bindings #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum IdkitError { @@ -71,20 +114,42 @@ pub enum IdkitError { impl From for IdkitError { fn from(e: idkit_core::Error) -> Self { match e { - idkit_core::Error::InvalidConfiguration(message) => Self::InvalidConfiguration { message }, - idkit_core::Error::Json(e) => Self::JsonError { message: e.to_string() }, + idkit_core::Error::InvalidConfiguration(message) => { + Self::InvalidConfiguration { message } + } + idkit_core::Error::Json(e) => Self::JsonError { + message: e.to_string(), + }, idkit_core::Error::Crypto(message) => Self::CryptoError { message }, - idkit_core::Error::Base64(e) => Self::Base64Error { message: e.to_string() }, - idkit_core::Error::Url(e) => Self::UrlError { message: e.to_string() }, + idkit_core::Error::Base64(e) => Self::Base64Error { + message: e.to_string(), + }, + idkit_core::Error::Url(e) => Self::UrlError { + message: e.to_string(), + }, idkit_core::Error::InvalidProof(message) => Self::InvalidProof { message }, idkit_core::Error::BridgeError(message) => Self::BridgeError { message }, - idkit_core::Error::AppError(app_err) => Self::AppError { message: app_err.to_string() }, + idkit_core::Error::AppError(app_err) => Self::AppError { + message: app_err.to_string(), + }, idkit_core::Error::UnexpectedResponse => Self::UnexpectedResponse, idkit_core::Error::ConnectionFailed => Self::ConnectionFailed, idkit_core::Error::Timeout => Self::Timeout, - #[allow(unreachable_patterns)] - _ => Self::BridgeError { - message: format!("Unmapped error: {e}"), + idkit_core::Error::Http(_) => Self::BridgeError { + message: format!("HTTP error: {e}"), + }, + } + } +} + +impl From for Status { + fn from(status: CoreStatus) -> Self { + match status { + CoreStatus::WaitingForConnection => Self::WaitingForConnection, + CoreStatus::AwaitingConfirmation => Self::AwaitingConfirmation, + CoreStatus::Confirmed(proof) => Self::Confirmed { proof }, + CoreStatus::Failed(app_error) => Self::Failed { + error: app_error.to_string(), }, } } @@ -174,7 +239,9 @@ impl Request { /// /// Returns an error if JSON serialization fails pub fn to_json(&self) -> Result { - serde_json::to_string(&self.0).map_err(|e| IdkitError::JsonError { message: e.to_string() }) + serde_json::to_string(&self.0).map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) } /// Deserializes a request from JSON @@ -184,7 +251,11 @@ impl Request { /// Returns an error if JSON deserialization fails #[uniffi::constructor] pub fn from_json(json: &str) -> Result { - serde_json::from_str(json).map(Self).map_err(|e| IdkitError::JsonError { message: e.to_string() }) + serde_json::from_str(json) + .map(Self) + .map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) } } @@ -197,7 +268,9 @@ impl Request { /// Returns an error if JSON serialization fails #[uniffi::export] pub fn proof_to_json(proof: &Proof) -> Result { - serde_json::to_string(proof).map_err(|e| IdkitError::JsonError { message: e.to_string() }) + serde_json::to_string(proof).map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) } /// Deserializes a proof from JSON @@ -207,7 +280,312 @@ pub fn proof_to_json(proof: &Proof) -> Result { /// Returns an error if JSON deserialization fails #[uniffi::export] pub fn proof_from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| IdkitError::JsonError { message: e.to_string() }) + serde_json::from_str(json).map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) +} + +// ConstraintNode constructors and methods + +#[uniffi::export] +impl ConstraintNode { + /// Creates a credential constraint node + #[must_use] + #[uniffi::constructor] + pub fn credential(credential_type: CredentialType) -> Self { + Self(CoreConstraintNode::credential(credential_type)) + } + + /// Creates an "any" (OR) constraint node + /// + /// At least one of the child constraints must be satisfied. + /// Order matters: earlier constraints have higher priority. + #[must_use] + #[uniffi::constructor] + pub fn any(nodes: Vec>) -> Self { + let core_nodes = nodes.iter().map(|n| n.0.clone()).collect(); + Self(CoreConstraintNode::any(core_nodes)) + } + + /// Creates an "all" (AND) constraint node + /// + /// All child constraints must be satisfied. + #[must_use] + #[uniffi::constructor] + pub fn all(nodes: Vec>) -> Self { + let core_nodes = nodes.iter().map(|n| n.0.clone()).collect(); + Self(CoreConstraintNode::all(core_nodes)) + } + + /// Serializes a constraint node to JSON + /// + /// # Errors + /// + /// Returns an error if JSON serialization fails + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0).map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) + } + + /// Deserializes a constraint node from JSON + /// + /// # Errors + /// + /// Returns an error if JSON deserialization fails + #[uniffi::constructor] + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + .map(Self) + .map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) + } +} + +// Constraints constructors and methods + +#[uniffi::export] +impl Constraints { + /// Creates constraints from a root node + #[must_use] + #[uniffi::constructor] + pub fn new(root: Arc) -> Self { + Self(CoreConstraints::new(root.0.clone())) + } + + /// Creates an "any" constraint (at least one credential must match) + #[must_use] + #[uniffi::constructor] + pub fn any(credentials: Vec) -> Self { + let nodes: Vec = credentials + .into_iter() + .map(CoreConstraintNode::credential) + .collect(); + Self(CoreConstraints::new(CoreConstraintNode::any(nodes))) + } + + /// Creates an "all" constraint (all credentials must match) + #[must_use] + #[uniffi::constructor] + pub fn all(credentials: Vec) -> Self { + let nodes: Vec = credentials + .into_iter() + .map(CoreConstraintNode::credential) + .collect(); + Self(CoreConstraints::new(CoreConstraintNode::all(nodes))) + } + + /// Serializes constraints to JSON + /// + /// # Errors + /// + /// Returns an error if JSON serialization fails + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0).map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) + } + + /// Deserializes constraints from JSON + /// + /// # Errors + /// + /// Returns an error if JSON deserialization fails + #[uniffi::constructor] + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + .map(Self) + .map_err(|e| IdkitError::JsonError { + message: e.to_string(), + }) + } +} + +// Session constructors and methods + +#[uniffi::export] +impl Session { + /// Creates a new session + /// + /// # Arguments + /// + /// * `app_id` - Application ID from the Developer Portal (e.g., `"app_123"`) + /// * `action` - Action identifier + /// * `requests` - One or more credential requests + /// + /// # Errors + /// + /// Returns an error if the session cannot be created or the request fails + #[uniffi::constructor] + pub fn create( + app_id: String, + action: String, + requests: Vec>, + ) -> Result { + let runtime = tokio::runtime::Runtime::new().map_err(|e| IdkitError::BridgeError { + message: format!("Failed to create runtime: {e}"), + })?; + + let app_id_parsed = idkit_core::types::AppId::new(&app_id)?; + let core_requests: Vec = requests.iter().map(|r| r.0.clone()).collect(); + + let inner = runtime + .block_on(CoreSession::create(app_id_parsed, action, core_requests)) + .map_err(IdkitError::from)?; + + Ok(Self { runtime, inner }) + } + + /// Creates a new session with optional configuration + /// + /// # Arguments + /// + /// * `app_id` - Application ID from the Developer Portal + /// * `action` - Action identifier + /// * `requests` - One or more credential requests + /// * `action_description` - Optional action description shown to users + /// * `constraints` - Optional constraints on which credentials are acceptable + /// * `bridge_url` - Optional bridge URL (defaults to production) + /// + /// # Errors + /// + /// Returns an error if the session cannot be created or the request fails + #[uniffi::constructor] + pub fn create_with_options( + app_id: String, + action: String, + requests: Vec>, + action_description: Option, + constraints: Option>, + bridge_url: Option, + ) -> Result { + let runtime = tokio::runtime::Runtime::new().map_err(|e| IdkitError::BridgeError { + message: format!("Failed to create runtime: {e}"), + })?; + + let app_id_parsed = idkit_core::types::AppId::new(&app_id)?; + let core_requests: Vec = requests.iter().map(|r| r.0.clone()).collect(); + let core_constraints = constraints.map(|c| c.0.clone()); + let bridge_url_parsed = bridge_url + .map(|url| idkit_core::types::BridgeUrl::new(&url)) + .transpose()?; + + let inner = runtime + .block_on(CoreSession::create_with_options( + app_id_parsed, + action, + core_requests, + action_description, + core_constraints, + bridge_url_parsed, + )) + .map_err(IdkitError::from)?; + + Ok(Self { runtime, inner }) + } + + /// Creates a session from a verification level + /// + /// This is a convenience method that maps a verification level to the appropriate + /// set of credential requests and constraints. + /// + /// # Errors + /// + /// Returns an error if the session cannot be created or the request fails + #[uniffi::constructor] + pub fn from_verification_level( + app_id: String, + action: String, + verification_level: VerificationLevel, + signal: String, + ) -> Result { + let runtime = tokio::runtime::Runtime::new().map_err(|e| IdkitError::BridgeError { + message: format!("Failed to create runtime: {e}"), + })?; + + let app_id_parsed = idkit_core::types::AppId::new(&app_id)?; + + let inner = runtime + .block_on(CoreSession::from_verification_level( + app_id_parsed, + action, + verification_level, + signal, + )) + .map_err(IdkitError::from)?; + + Ok(Self { runtime, inner }) + } + + /// Returns the connect URL for World App + #[must_use] + pub fn connect_url(&self) -> String { + self.inner.connect_url() + } + + /// Returns the request ID for this session + #[must_use] + pub fn request_id(&self) -> String { + self.inner.request_id().to_string() + } + + /// Polls the bridge for the current status (non-blocking) + /// + /// Mirrors the `idkit-rs` `poll_for_status` helper so higher-level SDKs can + /// stream updates by repeatedly invoking this method. + /// + /// # Errors + /// + /// Returns an error if the request fails or the response is invalid + pub fn poll_for_status(&self) -> Result { + self.runtime + .block_on(self.inner.poll_for_status()) + .map(Status::from) + .map_err(IdkitError::from) + } + + /// Waits for a proof with default timeout (15 minutes) + /// + /// This is a blocking convenience method that polls the bridge until completion. + /// For async Rust code, use `poll_for_status()` in a loop instead. + /// + /// # Errors + /// + /// Returns an error if polling fails, verification fails, or timeout is reached + pub fn wait_for_proof(&self) -> Result { + self.wait_for_proof_with_timeout(900) // 15 minutes + } + + /// Waits for a proof with a specific timeout (in seconds) + /// + /// This is a blocking convenience method that polls the bridge until completion. + /// For async Rust code, use `poll_for_status()` in a loop instead. + /// + /// # Errors + /// + /// Returns an error if polling fails, verification fails, or timeout is reached + pub fn wait_for_proof_with_timeout(&self, timeout_seconds: u64) -> Result { + use std::time::{Duration, Instant}; + + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_seconds); + let poll_interval = Duration::from_secs(3); + + loop { + if start.elapsed() > timeout { + return Err(IdkitError::Timeout); + } + + match self.poll_for_status()? { + Status::Confirmed { proof } => return Ok(proof), + Status::Failed { error } => return Err(IdkitError::AppError { message: error }), + Status::WaitingForConnection | Status::AwaitingConfirmation => { + std::thread::sleep(poll_interval); + } + } + } + } } // Credential methods @@ -324,7 +702,10 @@ mod tests { assert_eq!(credential_to_string(&CredentialType::Orb), "orb"); assert_eq!(credential_to_string(&CredentialType::Face), "face"); assert_eq!(credential_to_string(&CredentialType::Device), "device"); - assert_eq!(credential_to_string(&CredentialType::SecureDocument), "secure_document"); + assert_eq!( + credential_to_string(&CredentialType::SecureDocument), + "secure_document" + ); assert_eq!(credential_to_string(&CredentialType::Document), "document"); } } diff --git a/rust/wasm/src/lib.rs b/rust/wasm/src/lib.rs index cea1db11..4c631c11 100644 --- a/rust/wasm/src/lib.rs +++ b/rust/wasm/src/lib.rs @@ -41,7 +41,10 @@ impl Request { #[wasm_bindgen(js_name = withBytes)] pub fn with_bytes(credential_type: JsValue, signal_bytes: &[u8]) -> Result { let cred: CredentialType = serde_wasm_bindgen::from_value(credential_type)?; - Ok(Self(idkit_core::Request::new(cred, Some(Signal::from_abi_encoded(signal_bytes))))) + Ok(Self(idkit_core::Request::new( + cred, + Some(Signal::from_abi_encoded(signal_bytes)), + ))) } /// Gets the signal as raw bytes @@ -99,6 +102,99 @@ impl Proof { } } +/// Bridge encryption for secure communication between client and bridge +#[wasm_bindgen] +pub struct BridgeEncryption { + key: Vec, + nonce: Vec, +} + +#[wasm_bindgen] +impl BridgeEncryption { + /// Creates a new `BridgeEncryption` instance with randomly generated key and nonce + /// + /// # Errors + /// + /// Returns an error if key generation fails + #[wasm_bindgen(constructor)] + pub fn new() -> Result { + let (key, nonce) = idkit_core::crypto::generate_key() + .map_err(|e| JsValue::from_str(&format!("Failed to generate key: {e}")))?; + Ok(Self { + key: key.to_vec(), + nonce: nonce.to_vec(), + }) + } + + /// Encrypts a plaintext string using AES-256-GCM and returns base64 + /// + /// # Errors + /// + /// Returns an error if encryption fails + pub fn encrypt(&self, plaintext: &str) -> Result { + let ciphertext = idkit_core::crypto::encrypt(&self.key, &self.nonce, plaintext.as_bytes()) + .map_err(|e| JsValue::from_str(&format!("Encryption failed: {e}")))?; + Ok(idkit_core::crypto::base64_encode(&ciphertext)) + } + + /// Decrypts a base64-encoded ciphertext using AES-256-GCM + /// + /// # Errors + /// + /// Returns an error if decryption fails or the output is not valid UTF-8 + pub fn decrypt(&self, ciphertext_base64: &str) -> Result { + let ciphertext = idkit_core::crypto::base64_decode(ciphertext_base64) + .map_err(|e| JsValue::from_str(&format!("Base64 decode failed: {e}")))?; + + let plaintext_bytes = idkit_core::crypto::decrypt(&self.key, &self.nonce, &ciphertext) + .map_err(|e| JsValue::from_str(&format!("Decryption failed: {e}")))?; + + String::from_utf8(plaintext_bytes) + .map_err(|e| JsValue::from_str(&format!("Invalid UTF-8: {e}"))) + } + + /// Returns the key as a base64-encoded string + #[must_use] + #[wasm_bindgen(js_name = keyBase64)] + pub fn key_base64(&self) -> String { + idkit_core::crypto::base64_encode(&self.key) + } + + /// Returns the nonce as a base64-encoded string + #[must_use] + #[wasm_bindgen(js_name = nonceBase64)] + pub fn nonce_base64(&self) -> String { + idkit_core::crypto::base64_encode(&self.nonce) + } +} + +/// Hashes a signal string using Keccak256 +#[must_use] +#[wasm_bindgen(js_name = hashSignal)] +pub fn hash_signal(signal: &str) -> String { + use idkit_core::crypto::hash_to_field; + let hash = hash_to_field(signal.as_bytes()); + format!("{hash:#066x}") +} + +/// Encodes data to base64 +#[must_use] +#[wasm_bindgen(js_name = base64Encode)] +pub fn base64_encode(data: &[u8]) -> String { + idkit_core::crypto::base64_encode(data) +} + +/// Decodes base64 data +/// +/// # Errors +/// +/// Returns an error if decoding fails +#[wasm_bindgen(js_name = base64Decode)] +pub fn base64_decode(data: &str) -> Result, JsValue> { + idkit_core::crypto::base64_decode(data) + .map_err(|e| JsValue::from_str(&format!("Base64 decode failed: {e}"))) +} + // Export credential enum #[wasm_bindgen(typescript_custom_section)] const TS_CREDENTIAL: &str = r#" diff --git a/rust/wasm/test-crypto.mjs b/rust/wasm/test-crypto.mjs new file mode 100644 index 00000000..3f2f1fc3 --- /dev/null +++ b/rust/wasm/test-crypto.mjs @@ -0,0 +1,136 @@ +/** + * Quick test to verify WASM crypto functions work correctly + */ + +import { readFile } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +// Resolve paths from project root for portability +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '..', '..'); +const wasmDir = join(projectRoot, 'js', 'packages', 'core', 'wasm'); + +// Dynamic import to allow path resolution +const { default: init, BridgeEncryption, hashSignal, base64Encode, base64Decode } = await import( + join(wasmDir, 'idkit_wasm.js') +); + +console.log('Testing WASM Crypto Functions...\n'); + +// Initialize WASM +console.log('Initializing WASM...'); +const wasmPath = join(wasmDir, 'idkit_wasm_bg.wasm'); +const wasmBuffer = await readFile(wasmPath); +await init(wasmBuffer); +console.log('✓ WASM initialized\n'); + +// Test 1: BridgeEncryption +console.log('1. Testing BridgeEncryption...'); +try { + const encryption = new BridgeEncryption(); + console.log('✓ BridgeEncryption created'); + + const keyB64 = encryption.keyBase64(); + const nonceB64 = encryption.nonceBase64(); + console.log(`✓ Key (base64): ${keyB64.substring(0, 20)}...`); + console.log(`✓ Nonce (base64): ${nonceB64.substring(0, 20)}...`); + + // Test encrypt/decrypt + const plaintext = 'Hello, World! This is a test message.'; + const encrypted = encryption.encrypt(plaintext); + console.log(`✓ Encrypted: ${encrypted.substring(0, 30)}...`); + + const decrypted = encryption.decrypt(encrypted); + console.log(`✓ Decrypted: ${decrypted}`); + + if (decrypted === plaintext) { + console.log('✓ Encryption/decryption round-trip successful\n'); + } else { + console.error('✗ Decrypted text does not match original\n'); + process.exit(1); + } +} catch (e) { + console.error(`✗ BridgeEncryption test failed: ${e}\n`); + process.exit(1); +} + +// Test 2: Multiple encryption instances +console.log('2. Testing multiple encryption instances...'); +try { + const encryption1 = new BridgeEncryption(); + const encryption2 = new BridgeEncryption(); + console.log('✓ Created two independent encryption instances'); + + const plaintext = 'Test message for independent instances'; + const encrypted1 = encryption1.encrypt(plaintext); + const decrypted1 = encryption1.decrypt(encrypted1); + + const encrypted2 = encryption2.encrypt(plaintext); + const decrypted2 = encryption2.decrypt(encrypted2); + + if (decrypted1 === plaintext && decrypted2 === plaintext && encrypted1 !== encrypted2) { + console.log('✓ Each instance encrypts/decrypts independently with different keys\n'); + } else { + console.error('✗ Independent encryption failed\n'); + process.exit(1); + } +} catch (e) { + console.error(`✗ Multiple instances test failed: ${e}\n`); + process.exit(1); +} + +// Test 3: hashSignal +console.log('3. Testing hashSignal...'); +try { + const signal = 'test_signal_123'; + const hash = hashSignal(signal); + console.log(`✓ Hash of "${signal}": ${hash}`); + + if (hash.startsWith('0x') && hash.length === 66) { + console.log('✓ Hash format correct (0x + 64 hex chars)\n'); + } else { + console.error('✗ Hash format incorrect\n'); + process.exit(1); + } + + // Test consistency + const hash2 = hashSignal(signal); + if (hash === hash2) { + console.log('✓ Hash is deterministic\n'); + } else { + console.error('✗ Hash is not deterministic\n'); + process.exit(1); + } +} catch (e) { + console.error(`✗ hashSignal test failed: ${e}\n`); + process.exit(1); +} + +// Test 4: base64 encode/decode +console.log('4. Testing base64 encode/decode...'); +try { + const data = new Uint8Array([1, 2, 3, 4, 5, 255, 254, 253]); + const encoded = base64Encode(data); + console.log(`✓ Encoded: ${encoded}`); + + const decoded = base64Decode(encoded); + console.log(`✓ Decoded: ${Array.from(decoded).join(', ')}`); + + if (data.length === decoded.length && data.every((val, i) => val === decoded[i])) { + console.log('✓ Base64 round-trip successful\n'); + } else { + console.error('✗ Base64 round-trip failed\n'); + process.exit(1); + } +} catch (e) { + console.error(`✗ base64 test failed: ${e}\n`); + process.exit(1); +} + +console.log('✅ All WASM crypto tests passed!'); +console.log('\nThe WASM crypto implementation is working correctly and can be used for:'); +console.log(' - Bridge encryption (AES-256-GCM)'); +console.log(' - Signal hashing (Keccak256)'); +console.log(' - Base64 encoding/decoding'); +console.log('\nNext steps: Integrate with JavaScript bridge client'); diff --git a/scripts/build-swift.sh b/scripts/build-swift.sh new file mode 100755 index 00000000..99ac993f --- /dev/null +++ b/scripts/build-swift.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +# Build Swift bindings for IDKit +# This script builds the Rust library and generates Swift bindings + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" +RUST_DIR="$PROJECT_ROOT/rust/uniffi-bindings" +SWIFT_DIR="$PROJECT_ROOT/swift" +GENERATED_DIR="$SWIFT_DIR/Sources/IDKit/Generated" + +echo "🔨 Building IDKit Swift bindings..." +echo "Project root: $PROJECT_ROOT" + +# Step 1: Build the Rust library +echo "" +echo "📦 Step 1/3: Building Rust library..." +cd "$PROJECT_ROOT" +cargo build --release --package idkit-uniffi + +# Step 2: Generate Swift bindings +echo "" +echo "🔧 Step 2/3: Generating Swift bindings..." +mkdir -p "$GENERATED_DIR" + +uniffi-bindgen generate \ + --library target/release/libidkit.dylib \ + --language swift \ + --out-dir "$GENERATED_DIR" + +echo "✅ Generated Swift bindings to: $GENERATED_DIR" + +# Step 3: Build Swift package (optional test) +echo "" +echo "🧪 Step 3/3: Testing Swift package compilation..." +cd "$SWIFT_DIR" + +if command -v swift &> /dev/null; then + swift build 2>&1 | head -20 || true + echo "" + echo "⚠️ Note: Swift package may fail to build without proper linking configuration." + echo " This is expected - the bindings are generated successfully." +else + echo "⚠️ Swift compiler not found, skipping package test" +fi + +echo "" +echo "✨ Done! Swift bindings are ready." +echo "" +echo "Next steps:" +echo " 1. The Swift bindings are in: $SWIFT_DIR" +echo " 2. Generated code is in: $GENERATED_DIR" +echo " 3. To use in a Swift project, link against target/release/libidkit.dylib" diff --git a/scripts/package-swift.sh b/scripts/package-swift.sh new file mode 100755 index 00000000..547f4650 --- /dev/null +++ b/scripts/package-swift.sh @@ -0,0 +1,120 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" +SWIFT_DIR="$PROJECT_ROOT/swift" +GENERATED_DIR="$SWIFT_DIR/Sources/IDKit/Generated" +IOS_BUILD="$PROJECT_ROOT/ios_build" +FFI_INCLUDE_DIR="$SWIFT_DIR/Sources/idkitFFI/include" + +echo "📦 Packaging IDKit Swift artifacts" + +rm -rf "$IOS_BUILD" "$PROJECT_ROOT/IDKitFFI.xcframework" +mkdir -p "$FFI_INCLUDE_DIR" +mkdir -p "$IOS_BUILD/bindings" \ + "$IOS_BUILD/Headers/IDKit" \ + "$IOS_BUILD/target/universal-ios-sim/release" \ + "$IOS_BUILD/target/universal-macos/release" \ + "$GENERATED_DIR" + +export IPHONEOS_DEPLOYMENT_TARGET="13.0" +export MACOSX_DEPLOYMENT_TARGET="12.0" +export RUSTFLAGS="-C link-arg=-Wl,-application_extension \ + -C link-arg=-Wl,-dead_strip \ + -C link-arg=-Wl,-dead_strip_dylibs" + +cd "$PROJECT_ROOT" + +rustup target add aarch64-apple-ios-sim x86_64-apple-ios aarch64-apple-ios aarch64-apple-darwin x86_64-apple-darwin >/dev/null + +echo "🔧 Building Rust library for Apple targets" +cargo build --package idkit-uniffi --target aarch64-apple-ios-sim --release --locked +cargo build --package idkit-uniffi --target x86_64-apple-ios --release --locked +cargo build --package idkit-uniffi --target aarch64-apple-ios --release --locked +cargo build --package idkit-uniffi --target aarch64-apple-darwin --release --locked +cargo build --package idkit-uniffi --target x86_64-apple-darwin --release --locked + +cp target/aarch64-apple-ios/release/libidkit.a target/aarch64-apple-ios/release/libidkitFFI.a +cp target/x86_64-apple-ios/release/libidkit.a target/x86_64-apple-ios/release/libidkitFFI.a +cp target/aarch64-apple-ios-sim/release/libidkit.a target/aarch64-apple-ios-sim/release/libidkitFFI.a +cp target/aarch64-apple-darwin/release/libidkit.a target/aarch64-apple-darwin/release/libidkitFFI.a +cp target/x86_64-apple-darwin/release/libidkit.a target/x86_64-apple-darwin/release/libidkitFFI.a + +strip -S -x target/aarch64-apple-ios/release/libidkitFFI.a +strip -S -x target/x86_64-apple-ios/release/libidkitFFI.a +strip -S -x target/aarch64-apple-ios-sim/release/libidkitFFI.a || true +strip -S -x target/aarch64-apple-darwin/release/libidkitFFI.a || true +strip -S -x target/x86_64-apple-darwin/release/libidkitFFI.a || true + +lipo -create \ + target/aarch64-apple-ios-sim/release/libidkitFFI.a \ + target/x86_64-apple-ios/release/libidkitFFI.a \ + -output $IOS_BUILD/target/universal-ios-sim/release/libidkitFFI.a + +lipo -create \ + target/aarch64-apple-darwin/release/libidkitFFI.a \ + target/x86_64-apple-darwin/release/libidkitFFI.a \ + -output $IOS_BUILD/target/universal-macos/release/libidkitFFI.a + +lipo -info $IOS_BUILD/target/universal-ios-sim/release/libidkitFFI.a +lipo -info $IOS_BUILD/target/universal-macos/release/libidkitFFI.a + +echo "🧬 Generating UniFFI Swift bindings" +cargo run -p uniffi-bindgen generate \ + --library target/aarch64-apple-ios-sim/release/libidkit.dylib \ + --language swift \ + --no-format \ + --out-dir "$IOS_BUILD/bindings" + +rm -f "$GENERATED_DIR"/* +cp "$IOS_BUILD/bindings"/idkit.swift "$GENERATED_DIR/" +cp "$IOS_BUILD/bindings"/idkit_core.swift "$GENERATED_DIR/" +cp "$IOS_BUILD/bindings"/idkitFFI.h "$GENERATED_DIR/" +cp "$IOS_BUILD/bindings"/idkitFFI.modulemap "$GENERATED_DIR/" +cp "$IOS_BUILD/bindings"/idkit_coreFFI.h "$GENERATED_DIR/" +cp "$IOS_BUILD/bindings"/idkit_coreFFI.modulemap "$GENERATED_DIR/" + +rm -f "$FFI_INCLUDE_DIR"/idkitFFI.h "$FFI_INCLUDE_DIR"/idkit_coreFFI.h "$FFI_INCLUDE_DIR"/module.modulemap +cp "$IOS_BUILD/bindings"/idkitFFI.h "$FFI_INCLUDE_DIR/" +cp "$IOS_BUILD/bindings"/idkit_coreFFI.h "$FFI_INCLUDE_DIR/" +cat <<'EOF' > "$FFI_INCLUDE_DIR/module.modulemap" +module idkitFFI { + header "idkitFFI.h" + header "idkit_coreFFI.h" + export * +} +EOF + +cp "$IOS_BUILD/bindings"/idkitFFI.h "$IOS_BUILD/Headers/IDKit/" +cp "$IOS_BUILD/bindings"/idkit_coreFFI.h "$IOS_BUILD/Headers/IDKit/" +cat "$IOS_BUILD/bindings"/idkitFFI.modulemap > "$IOS_BUILD/Headers/IDKit/module.modulemap" +cat "$IOS_BUILD/bindings"/idkit_coreFFI.modulemap >> "$IOS_BUILD/Headers/IDKit/module.modulemap" + +echo "🏗️ Creating XCFramework" +xcodebuild -create-xcframework \ + -library target/aarch64-apple-ios/release/libidkitFFI.a \ + -headers "$IOS_BUILD/Headers" \ + -library "$IOS_BUILD/target/universal-ios-sim/release/libidkitFFI.a" \ + -headers "$IOS_BUILD/Headers" \ + -library "$IOS_BUILD/target/universal-macos/release/libidkitFFI.a" \ + -headers "$IOS_BUILD/Headers" \ + -output "$PROJECT_ROOT/IDKitFFI.xcframework" + +if [ -f "$PROJECT_ROOT/IDKitFFI.xcframework/ios-arm64/libidkit.a" ]; then + mv "$PROJECT_ROOT/IDKitFFI.xcframework/ios-arm64/libidkit.a" \ + "$PROJECT_ROOT/IDKitFFI.xcframework/ios-arm64/libidkitFFI.a" +fi +if [ -f "$PROJECT_ROOT/IDKitFFI.xcframework/ios-arm64_x86_64-simulator/libidkit.a" ]; then + mv "$PROJECT_ROOT/IDKitFFI.xcframework/ios-arm64_x86_64-simulator/libidkit.a" \ + "$PROJECT_ROOT/IDKitFFI.xcframework/ios-arm64_x86_64-simulator/libidkitFFI.a" +fi + +rm -rf "$IOS_BUILD" + +echo "🔗 Creating symlink for local Swift package" +cd "$SWIFT_DIR" +rm -f IDKitFFI.xcframework +ln -s ../IDKitFFI.xcframework IDKitFFI.xcframework + +echo "✨ Swift artifacts ready" diff --git a/scripts/restore-idkit-swift.sh b/scripts/restore-idkit-swift.sh new file mode 100755 index 00000000..13c1bdba --- /dev/null +++ b/scripts/restore-idkit-swift.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +IDKIT_SWIFT_PATH="${1:-../idkit-swift}" +IDKIT_SWIFT_ABS_PATH="$(cd "$IDKIT_SWIFT_PATH" && pwd)" + +echo "🔄 Restoring idkit-swift to original state..." + +# Restore Package.swift from backup +if [ -f "$IDKIT_SWIFT_ABS_PATH/Package.swift.backup" ]; then + mv "$IDKIT_SWIFT_ABS_PATH/Package.swift.backup" "$IDKIT_SWIFT_ABS_PATH/Package.swift" + echo " ✅ Restored Package.swift" +else + echo " ⚠️ No Package.swift.backup found" +fi + +# Reset git changes in idkit-swift +cd "$IDKIT_SWIFT_ABS_PATH" +echo " Resetting git changes..." +git restore . +git clean -fd + +echo "✨ idkit-swift restored to original state" diff --git a/scripts/setup-local-swift.sh b/scripts/setup-local-swift.sh new file mode 100755 index 00000000..908a9022 --- /dev/null +++ b/scripts/setup-local-swift.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" +IDKIT_SWIFT_PATH="${1:-../idkit-swift}" + +echo "🔧 Setting up local Swift development environment" + +# Build the Swift artifacts +echo "📦 Building IDKit Swift artifacts..." +bash "$SCRIPT_DIR/package-swift.sh" + +# Update idkit-swift Package.swift to point to local path +IDKIT_SWIFT_ABS_PATH="$(cd "$IDKIT_SWIFT_PATH" && pwd)" + +echo "📝 Updating $IDKIT_SWIFT_ABS_PATH/Package.swift to use local development path..." + +# Backup original Package.swift if it's not already backed up +if [ ! -f "$IDKIT_SWIFT_ABS_PATH/Package.swift.backup" ]; then + cp "$IDKIT_SWIFT_ABS_PATH/Package.swift" "$IDKIT_SWIFT_ABS_PATH/Package.swift.backup" + echo " Backed up original Package.swift to Package.swift.backup" +fi + +# Copy the generated sources to idkit-swift +rm -rf "$IDKIT_SWIFT_ABS_PATH/Sources/IDKit" +mkdir -p "$IDKIT_SWIFT_ABS_PATH/Sources/IDKit" +cp -R "$PROJECT_ROOT/swift/Sources/IDKit/"* "$IDKIT_SWIFT_ABS_PATH/Sources/IDKit/" + +# Create or update Package.swift for local development +cat > "$IDKIT_SWIFT_ABS_PATH/Package.swift" <<'EOF' +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "IDKit", + platforms: [ + .iOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "IDKit", + targets: ["IDKit", "idkitFFI"] + ) + ], + dependencies: [], + targets: [ + .target( + name: "IDKit", + dependencies: ["idkitFFI"], + path: "Sources/IDKit", + exclude: [ + "Generated/idkitFFI.h", + "Generated/idkitFFI.modulemap", + "Generated/idkit_coreFFI.h", + "Generated/idkit_coreFFI.modulemap" + ] + ), + .binaryTarget( + name: "idkitFFI", + path: "../idkit/IDKitFFI.xcframework" + ) + ] +) +// LOCAL DEVELOPMENT MODE - Points to ../idkit/IDKitFFI.xcframework +EOF + +echo "✨ Local development setup complete!" +echo "" +echo "The idkit-swift package now points to the local XCFramework at:" +echo " $PROJECT_ROOT/IDKitFFI.xcframework" +echo "" +echo "To rebuild after making changes to Rust code:" +echo " cd $PROJECT_ROOT" +echo " ./scripts/package-swift.sh" +echo "" +echo "Then rebuild your Swift project that depends on idkit-swift." diff --git a/swift/Examples/BasicVerification.swift b/swift/Examples/BasicVerification.swift new file mode 100644 index 00000000..17a11675 --- /dev/null +++ b/swift/Examples/BasicVerification.swift @@ -0,0 +1,234 @@ +import Foundation +import IDKit + +private enum ExampleError: Error { + case verificationFailed(String) +} + +/// Example: Basic Orb verification using the UniFFI API +@available(macOS 12.0, iOS 15.0, *) +func basicVerification() async throws { + let signal = Signal.fromString(s: "user_action_12345") + let request = Request(credentialType: .orb, signal: signal) + + let session = try Session.create( + appId: "app_staging_1234567890abcdef", + action: "vote", + requests: [request] + ) + + print("📱 Scan this QR code in World App:") + print(session.connectUrl()) + print() + + print("⏳ Waiting for verification...") + + for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user to scan QR code...") + case .awaitingConfirmation: + print("📱 User scanned code! Awaiting confirmation in World App...") + case .confirmed(let proof): + print("✅ Verification successful!") + print(" Nullifier Hash: \(proof.nullifierHash)") + print(" Merkle Root: \(proof.merkleRoot)") + print(" Verification Level: \(proof.verificationLevel)") + return + case .failed(let error): + throw ExampleError.verificationFailed(error) + } + } + + throw ExampleError.verificationFailed("Stream ended without terminal status") +} + +/// Example: Manually polling for status updates. +@available(macOS 12.0, iOS 15.0, *) +func verificationWithStatusUpdates() async throws { + let signal = Signal.fromString(s: "user_action_12345") + let request = Request(credentialType: .orb, signal: signal) + + let session = try Session.create( + appId: "app_staging_1234567890abcdef", + action: "vote", + requests: [request] + ) + + print("📱 QR Code URL: \(session.connectUrl())\n") + + for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user to scan QR code...") + case .awaitingConfirmation: + print("📱 User scanned code! Awaiting confirmation in World App...") + case .confirmed(let proof): + print("✅ Verification complete!") + print(" Proof: \(proof.proof.prefix(64))...") + return + case .failed(let error): + throw ExampleError.verificationFailed(error) + } + } +} + +/// Example: Using verification level (Rust convenience method) +@available(macOS 12.0, iOS 15.0, *) +func verificationWithLevel() async throws { + let session = try Session.fromVerificationLevel( + appId: "app_staging_1234567890abcdef", + action: "login", + verificationLevel: .orb, + signal: "session_token_abc123" + ) + + print("📱 QR Code: \(session.connectUrl())\n") + + for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user to scan QR code...") + case .awaitingConfirmation: + print("📱 User scanned code! Awaiting confirmation...") + case .confirmed(let proof): + print("✅ Logged in! Nullifier: \(proof.nullifierHash)") + return + case .failed(let error): + throw ExampleError.verificationFailed(error) + } + } + + throw ExampleError.verificationFailed("Stream ended without terminal status") +} + +/// Example: Multiple requests with constraints +@available(macOS 12.0, iOS 15.0, *) +func verificationWithConstraints() async throws { + let signal = Signal.fromString(s: "user_signal") + let orbRequest = Request(credentialType: .orb, signal: signal) + let faceRequest = Request(credentialType: .face, signal: signal) + let deviceRequest = Request(credentialType: .device, signal: signal) + + let constraints = Constraints.any(credentials: [.orb, .face, .device]) + + let session = try Session.createWithOptions( + appId: "app_staging_1234567890abcdef", + action: "high-security-action", + requests: [orbRequest, faceRequest, deviceRequest], + actionDescription: "Verify your World ID", + constraints: constraints, + bridgeUrl: nil + ) + + print("📱 QR Code: \(session.connectUrl())\n") + + for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user to scan QR code...") + case .awaitingConfirmation: + print("📱 Awaiting confirmation...") + case .confirmed(let proof): + print("✅ Verified with \(proof.verificationLevel)") + return + case .failed(let error): + throw ExampleError.verificationFailed(error) + } + } + + throw ExampleError.verificationFailed("Stream ended without terminal status") +} + +/// Example: Face authentication +@available(macOS 12.0, iOS 15.0, *) +func verificationWithFaceAuth() async throws { + let signal = Signal.fromString(s: "sensitive_action_12345") + let request = Request(credentialType: .orb, signal: signal) + .withFaceAuth(faceAuth: true) + + let session = try Session.create( + appId: "app_staging_1234567890abcdef", + action: "transfer-funds", + requests: [request] + ) + + print("📱 QR Code: \(session.connectUrl())") + print("🔐 Face authentication required\n") + + for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user to scan QR code...") + case .awaitingConfirmation: + print("📱 Awaiting confirmation with face auth...") + case .confirmed: + print("✅ Verified with face auth!") + return + case .failed(let error): + throw ExampleError.verificationFailed(error) + } + } + + throw ExampleError.verificationFailed("Stream ended without terminal status") +} + +/// Example: ABI-encoded signal for on-chain verification +@available(macOS 12.0, iOS 15.0, *) +func verificationWithAbiSignal() async throws { + let abiSignal = Signal.fromAbiEncoded(bytes: [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 + ]) + + let request = Request(credentialType: .orb, signal: abiSignal) + + let session = try Session.create( + appId: "app_staging_1234567890abcdef", + action: "claim-airdrop", + requests: [request] + ) + + print("📱 QR Code: \(session.connectUrl())\n") + + for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user to scan QR code...") + case .awaitingConfirmation: + print("📱 Awaiting confirmation...") + case .confirmed: + print("✅ Airdrop claimed! Proof ready for on-chain verification") + return + case .failed(let error): + throw ExampleError.verificationFailed(error) + } + } + + throw ExampleError.verificationFailed("Stream ended without terminal status") +} + +@available(macOS 12.0, iOS 15.0, *) +@main +struct ExamplesRunner { + static func main() async { + print("IDKit Swift Examples") + print("====================\n") + + do { + // Uncomment the example you want to run: + + // try await basicVerification() + // try await verificationWithStatusUpdates() + // try await verificationWithLevel() + // try await verificationWithConstraints() + // try await verificationWithFaceAuth() + // try await verificationWithAbiSignal() + + print("\n✅ Example completed successfully!") + } catch { + print("\n❌ Error: \(error)") + } + } +} diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 00000000..cbe99cf0 --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "IDKit", + platforms: [ + .iOS(.v15), + .macOS(.v12) + ], + products: [ + .library( + name: "IDKit", + targets: ["IDKit"]), + ], + targets: [ + .binaryTarget( + name: "idkitFFI", + path: "IDKitFFI.xcframework" + ), + .target( + name: "IDKit", + dependencies: ["idkitFFI"], + path: "Sources/IDKit", + exclude: [ + "Generated/idkitFFI.h", + "Generated/idkitFFI.modulemap", + "Generated/idkit_coreFFI.h", + "Generated/idkit_coreFFI.modulemap" + ] + ), + .testTarget( + name: "IDKitTests", + dependencies: ["IDKit"], + exclude: ["README.md"] + ), + ] +) diff --git a/swift/README.md b/swift/README.md new file mode 100644 index 00000000..fbea7656 --- /dev/null +++ b/swift/README.md @@ -0,0 +1,279 @@ +# IDKit Swift + +Swift bindings for IDKit - World ID verification SDK built with Rust and UniFFI. + +## Architecture + +This Swift SDK is a thing wrapper over the Rust core: + +``` +┌─────────────────────────────────────┐ +│ Rust Core (idkit-core) │ +│ - All business logic │ +│ - Session management │ +│ - Constraints │ +│ - Verification │ +└─────────────────────────────────────┘ + │ UniFFI + ▼ +┌─────────────────────────────────────┐ +│ Generated Swift │ +│ - Session, Request, Signal │ +│ - CredentialType, Constraints │ +│ - Status, Proof │ +└─────────────────────────────────────┘ +``` + +## Installation + +### Swift Package Manager + +```swift +dependencies: [ + .package(url: "https://github.com/worldcoin/idkit", from: "3.0.0") +] +``` + +## Quick Start + +### Basic Verification + +```swift +import IDKit + +// Create signal and request +let signal = Signal.fromString(s: "user_12345") +let request = Request(credentialType: .orb, signal: signal) + +// Create session +let session = try Session.create( + appId: "app_staging_123abc", + action: "vote", + requests: [request] +) + +// Display QR code +print("Scan: \(session.connectUrl())") + +for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user...") + case .awaitingConfirmation: + print("📱 Awaiting confirmation...") + case .confirmed(let proof): + print("✅ Verified! Nullifier: \(proof.nullifierHash)") + break + case .failed(let error): + fatalError("Verification failed: \(error)") + } +} +``` + +### Status Polling + +```swift +for try await status in session.status() { + switch status { + case .waitingForConnection: + print("⏳ Waiting for user...") + case .awaitingConfirmation: + print("📱 User is confirming...") + case .confirmed(let proof): + print("✅ Verified! \(proof)") + return + case .failed(let error): + fatalError("Verification failed: \(error)") + } +} +``` + +### Using Verification Level + +```swift +let session = try Session.fromVerificationLevel( + appId: "app_staging_123abc", + action: "login", + verificationLevel: .orb, + signal: "session_token" +) +``` + +### Multiple Requests with Constraints + +```swift +let signal = Signal.fromString(s: "user_signal") + +// Create multiple requests +let orbRequest = Request(credentialType: .orb, signal: signal) +let faceRequest = Request(credentialType: .face, signal: signal) + +// User must have at least one (priority: Orb > Face) +let constraints = Constraints.any(credentials: [.orb, .face]) + +let session = try Session.createWithOptions( + appId: "app_staging_123abc", + action: "secure-action", + requests: [orbRequest, faceRequest], + actionDescription: "Verify your identity", + constraints: constraints, + bridgeUrl: nil +) +``` + +### Face Authentication + +```swift +let signal = Signal.fromString(s: "sensitive_action") +let request = Request(credentialType: .orb, signal: signal) + .withFaceAuth(faceAuth: true) + +let session = try Session.create( + appId: "app_staging_123abc", + action: "transfer-funds", + requests: [request] +) +``` + +### ABI-Encoded Signals + +```swift +let abiSignal = Signal.fromAbiEncoded(bytes: [0x00, 0x01, ...]) +let request = Request(credentialType: .orb, signal: abiSignal) +``` + +## API Reference + +### Core Types (Generated from Rust) + +All core types are generated by UniFFI from the Rust implementation. This ensures 100% consistency. + +#### `Session` + +**Static Methods:** +- `create(appId:action:requests:)` - Create session +- `createWithOptions(appId:action:requests:actionDescription:constraints:bridgeUrl:)` - Full options +- `fromVerificationLevel(appId:action:verificationLevel:signal:)` - Convenience method + +- **Instance Methods:** +- `pollForStatus() -> Status` - Poll for status (blocking) +- `connectUrl() -> String` - Get connection URL +- `requestId() -> String` - Get request ID + +#### `Request` + +**From Rust:** +- `Request(credentialType:signal:)` - Create request +- `withFaceAuth(faceAuth:) -> Request` - Add face auth + +#### `Signal` + +- `Signal.fromString(s:) -> Signal` - From UTF-8 string +- `Signal.fromAbiEncoded(bytes:) -> Signal` - From ABI bytes +- `asBytes() -> Data` - Get bytes as Data +- `asString() -> String?` - Get string (if UTF-8) + +#### `Constraints` + +- `Constraints.any(credentials:)` - At least one must match +- `Constraints.all(credentials:)` - All must match +- `Constraints.new(root:)` - From constraint node + +#### `ConstraintNode` + +- `ConstraintNode.credential(credentialType:)` - Leaf node +- `ConstraintNode.any(nodes:)` - OR node +- `ConstraintNode.all(nodes:)` - AND node + +#### `Status` + +```swift +enum Status { + case waitingForConnection + case awaitingConfirmation + case confirmed(Proof) + case failed(String) +} +``` + +#### `Proof` + +```swift +struct Proof { + let proof: String + let merkleRoot: String + let nullifierHash: String + let verificationLevel: CredentialType +} +``` + +#### `CredentialType` + +```swift +enum CredentialType { + case orb // Iris biometric + case face // Face biometric + case device // Device-based + case secureDocument // NFC with auth + case document // NFC without auth +} +``` + +#### `VerificationLevel` + +```swift +enum VerificationLevel { + case orb + case device + case secureDocument + case document +} +``` + +### Errors + +All errors are surfaced as `IdkitError` values generated by the Rust core. + +## Building from Source + +The generated Swift bindings (`swift/Sources/IDKit/Generated/`) are excluded from git and must be regenerated locally. + +### Building for all platforms (iOS + macOS) + +```bash +# Build Rust library, generate bindings, and create XCFramework +./scripts/package-swift.sh + +# Then build the Swift package +cd swift +swift build +``` + +### For local development with idkit-swift + +```bash +# Set up idkit-swift to use local XCFramework +./scripts/setup-local-swift.sh ../idkit-swift + +# When done, restore idkit-swift to normal +./scripts/restore-idkit-swift.sh ../idkit-swift +``` + +### Manual build steps + +```bash +# 1. Install Rust targets +rustup target add aarch64-apple-ios-sim x86_64-apple-ios aarch64-apple-ios +rustup target add aarch64-apple-darwin x86_64-apple-darwin + +# 2. Build for all platforms +cargo build --release --package idkit-uniffi --target aarch64-apple-ios +cargo build --release --package idkit-uniffi --target aarch64-apple-darwin +# ... (see scripts/package-swift.sh for complete build steps) + +# 3. Generate bindings +uniffi-bindgen generate \ + --library target/aarch64-apple-ios-sim/release/libidkit.dylib \ + --language swift \ + --out-dir swift/Sources/IDKit/Generated +``` diff --git a/swift/Sources/IDKit/Generated/.gitignore b/swift/Sources/IDKit/Generated/.gitignore new file mode 100644 index 00000000..01c29435 --- /dev/null +++ b/swift/Sources/IDKit/Generated/.gitignore @@ -0,0 +1,4 @@ +# Auto-generated UniFFI bindings +# Regenerate with: ./scripts/package-swift.sh +* +!.gitignore diff --git a/swift/Sources/IDKit/IDKit.swift b/swift/Sources/IDKit/IDKit.swift new file mode 100644 index 00000000..84a0bd77 --- /dev/null +++ b/swift/Sources/IDKit/IDKit.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Main entry point for IDKit Swift SDK +public enum IDKit { + /// Version of the IDKit SDK + public static let version = "3.0.0" +} diff --git a/swift/Sources/IDKit/Request+Convenience.swift b/swift/Sources/IDKit/Request+Convenience.swift new file mode 100644 index 00000000..3a00cc31 --- /dev/null +++ b/swift/Sources/IDKit/Request+Convenience.swift @@ -0,0 +1,35 @@ +import Foundation + +public extension Request { + /// Mirrors IDKit v2 Swift initializer that accepted a string signal. + convenience init( + credentialType: CredentialType, + signal: String?, + faceAuth: Bool? = nil + ) throws { + let signalObject = signal.map { Signal.fromString(s: $0) } + let base = Request(credentialType: credentialType, signal: signalObject) + let final = faceAuth.map { base.withFaceAuth(faceAuth: $0) } ?? base + self.init(unsafeFromHandle: final.uniffiCloneHandle()) + } + + /// Mirrors the IDKit v2 Swift initializer that accepted raw ABI-encoded bytes. + convenience init( + credentialType: CredentialType, + abiEncodedSignal: Data, + faceAuth: Bool? = nil + ) throws { + let signalObject = Signal.fromAbiEncoded(bytes: abiEncodedSignal) + let base = Request(credentialType: credentialType, signal: signalObject) + let final = faceAuth.map { base.withFaceAuth(faceAuth: $0) } ?? base + self.init(unsafeFromHandle: final.uniffiCloneHandle()) + } +} + +public extension Signal { + /// Backwards-compatible computed property returning the raw bytes as Data. + var data: Data { Data(self.asBytes()) } + + /// Backwards-compatible computed property exposing the string form when available. + var string: String? { self.asString() } +} diff --git a/swift/Sources/IDKit/Session+Compatibility.swift b/swift/Sources/IDKit/Session+Compatibility.swift new file mode 100644 index 00000000..d4c6160f --- /dev/null +++ b/swift/Sources/IDKit/Session+Compatibility.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Errors surfaced by the high-level Swift conveniences. +public enum SessionError: Error, LocalizedError { + case timeout + case verificationFailed(String) + case invalidURL(String) + + public var errorDescription: String? { + switch self { + case .timeout: + return "Verification timed out before completing" + case .verificationFailed(let reason): + return "Verification failed: \(reason)" + case .invalidURL(let url): + return "Invalid URL: \(url)" + } + } +} + +public extension Session { + /// Matches the IDKit v2 `status()` helper + func status(pollInterval: TimeInterval = 3.0) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let pollingTask = Task { + var previousStatus: Status? + + do { + while !Task.isCancelled { + let current = try self.pollForStatus() + + if current != previousStatus { + previousStatus = current + continuation.yield(current) + } + + switch current { + case .confirmed, .failed: + continuation.finish() + return + case .waitingForConnection, .awaitingConfirmation: + break + } + + try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000)) + } + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + pollingTask.cancel() + } + } + } + + /// Backwards-compatible alias for the IDKIT v3 async stream helper. + func statusStream(pollInterval: TimeInterval = 3.0) -> AsyncThrowingStream { + status(pollInterval: pollInterval) + } + + /// Convenience accessor returning a URL instead of a string. + var verificationURL: URL { + let urlString = connectUrl() + guard let url = URL(string: urlString) else { + fatalError("Invalid connect URL generated: \(urlString)") + } + return url + } + + var requestUUID: UUID { + let raw = requestId() + guard let uuid = UUID(uuidString: raw) else { + fatalError("Invalid request ID generated: \(raw)") + } + return uuid + } +} diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift new file mode 100644 index 00000000..3d632bda --- /dev/null +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import IDKit + +// MARK: - Request Tests + +@Test("Request creation with signal") +func requestCreationWithSignal() throws { + let signal = Signal.fromString(s: "test_signal") + let request = Request(credentialType: .orb, signal: signal) + + #expect(request.credentialType() == .orb) + #expect(request.getSignalBytes() != nil) +} + +@Test("Request creation without signal") +func requestCreationWithoutSignal() { + let request = Request(credentialType: .device, signal: nil) + + #expect(request.credentialType() == .device) + #expect(request.getSignalBytes() == nil) +} + +@Test("Request with face authentication") +func requestWithFaceAuth() { + let signal = Signal.fromString(s: "test") + let request = Request(credentialType: .orb, signal: signal) + let withAuth = request.withFaceAuth(faceAuth: true) + + #expect(withAuth.faceAuth() == true) +} + +// MARK: - Signal Tests + +@Test("Signal from string") +func signalFromString() { + let signal = Signal.fromString(s: "test_signal") + + #expect(signal.asString() == "test_signal") + #expect(String(data: signal.data, encoding: .utf8) == "test_signal") +} + +@Test("Signal from ABI-encoded bytes") +func signalFromAbiEncoded() { + let bytes = Data([0x00, 0x01, 0x02, 0x03]) + let signal = Signal.fromAbiEncoded(bytes: bytes) + + #expect(signal.data == Data(bytes)) + #expect(signal.asString() == nil) // Not a valid UTF-8 string +} + +@Test("Signal data property") +func signalDataProperty() { + let signal = Signal.fromString(s: "test") + let data = signal.data + + #expect(String(data: data, encoding: .utf8) == "test") +} + +@Test("Signal string property") +func signalStringProperty() { + let signal = Signal.fromString(s: "hello") + + #expect(signal.string == "hello") +} + +// MARK: - CredentialType Tests + +@Test("All credential types are available") +func credentialTypeExists() { + // Test that all credential types are available from Rust + let types: [CredentialType] = [.orb, .face, .device, .secureDocument, .document] + + #expect(types.count == 5) +} + +// MARK: - VerificationLevel Tests + +@Test("All verification levels are available") +func verificationLevelExists() { + // Test that all verification levels are available from Rust + let levels: [VerificationLevel] = [.orb, .device, .secureDocument, .document] + + #expect(levels.count == 4) +} + +// MARK: - Constraints Tests + +@Test("Constraints with ANY logic") +func constraintsAny() throws { + let constraints = Constraints.any(credentials: [.orb, .face]) + + // Verify it can be serialized + let json = try constraints.toJson() + #expect(!json.isEmpty) +} + +@Test("Constraints with ALL logic") +func constraintsAll() throws { + let constraints = Constraints.all(credentials: [.orb, .secureDocument]) + + let json = try constraints.toJson() + #expect(!json.isEmpty) +} + +@Test("ConstraintNode credential leaf") +func constraintNodeCredential() throws { + let node = ConstraintNode.credential(credentialType: .orb) + + let json = try node.toJson() + #expect(!json.isEmpty) +} + +@Test("ConstraintNode with ANY operator") +func constraintNodeAny() throws { + let orb = ConstraintNode.credential(credentialType: .orb) + let face = ConstraintNode.credential(credentialType: .face) + + let anyNode = ConstraintNode.any(nodes: [orb, face]) + + let json = try anyNode.toJson() + #expect(!json.isEmpty) +} + +@Test("ConstraintNode with ALL operator") +func constraintNodeAll() throws { + let orb = ConstraintNode.credential(credentialType: .orb) + let doc = ConstraintNode.credential(credentialType: .secureDocument) + + let allNode = ConstraintNode.all(nodes: [orb, doc]) + + let json = try allNode.toJson() + #expect(!json.isEmpty) +} + +// MARK: - Session Creation Tests +// Note: These tests verify API shape, actual sessions need valid credentials + +@Test("Session creation API shape") +func sessionCreationAPIShape() { + let signal = Signal.fromString(s: "test") + let request = Request(credentialType: .orb, signal: signal) + + // These will throw without valid app_id - verify APIs exist + _ = try? Session.create( + appId: "app_test_invalid", + action: "test", + requests: [request] + ) + + _ = try? Session.createWithOptions( + appId: "app_test_invalid", + action: "test", + requests: [request], + actionDescription: "Test", + constraints: nil, + bridgeUrl: nil + ) + + _ = try? Session.fromVerificationLevel( + appId: "app_test_invalid", + action: "test", + verificationLevel: .orb, + signal: "test" + ) + + // If we reach here without crashing, the APIs exist + #expect(true) +} + +// MARK: - SDK Version Test + +// TODO: Re-enable this test once linker issue is resolved +// @Test("SDK version is valid") +// func sdkVersion() { +// #expect(!IDKit.version.isEmpty) +// #expect(IDKit.version.hasPrefix("3.")) +// } + +// MARK: - Proof Tests + +@Test("Proof serialization roundtrip") +func proofSerialization() throws { + let proof = Proof( + proof: "0x123", + merkleRoot: "0x456", + nullifierHash: "0x789", + verificationLevel: .orb + ) + + let json = try proofToJson(proof: proof) + let parsed = try proofFromJson(json: json) + + #expect(parsed.proof == "0x123") + #expect(parsed.merkleRoot == "0x456") + #expect(parsed.nullifierHash == "0x789") + #expect(parsed.verificationLevel == .orb) +} + +// MARK: - Swift Extensions Tests + +@Suite("Swift Extension Convenience APIs") +struct SwiftExtensionsTests { + + @Test("Request convenience init with string signal") + func requestConvenienceInitWithString() throws { + let request = try Request( + credentialType: .orb, + signal: "test_signal" + ) + + #expect(request.credentialType() == .orb) + #expect(request.getSignalBytes() != nil) + } + + @Test("Request convenience init with ABI-encoded data") + func requestConvenienceInitWithData() throws { + let bytes = Data([0x00, 0x01, 0x02, 0x03]) + let request = try Request( + credentialType: .orb, + abiEncodedSignal: bytes + ) + + #expect(request.credentialType() == .orb) + #expect(request.getSignalBytes() != nil) + } + + @Test("Signal convenience properties") + func signalConvenienceProperties() { + let signal = Signal.fromString(s: "test_signal") + + // Test Swift extension properties + #expect(signal.string == "test_signal") + #expect(signal.data == Data("test_signal".utf8)) + } +} diff --git a/swift/Tests/IDKitTests/README.md b/swift/Tests/IDKitTests/README.md new file mode 100644 index 00000000..b4b57d8f --- /dev/null +++ b/swift/Tests/IDKitTests/README.md @@ -0,0 +1,41 @@ +# Testing Swift Bindings + +## Note on Local Testing + +The Swift package tests require proper linking to the compiled Rust library (`libidkit.dylib`). + +Swift Package Manager alone cannot handle linking external C libraries from the file system without additional configuration. The tests will work properly in: + +1. **CI Environment** - Where we use system-wide uniffi-bindgen installation +2. **Xcode Projects** - Where you can configure library search paths +3. **Production Apps** - Where the library is bundled with the framework + +## Running Tests Locally + +### Verify Code Compiles (Without Running) + +The fact that the code compiles with the generated bindings is a good smoke test: + +```bash +# Generate bindings +./scripts/build-swift.sh + +# Check if Swift files are valid +cd swift +swift build --dry-run +``` + +### Xcode + +1. Generate bindings: `./scripts/build-swift.sh` +2. Open `swift/` in Xcode +3. Configure library search paths to include `../target/release/` +4. Run tests in Xcode + +### CI Testing + +The CI workflow: +1. Builds Rust library +2. Generates Swift bindings +3. Builds Swift package with proper linking +4. Runs all tests \ No newline at end of file