Merge pull request #1943 from selfxyz/release/staging-2026-04-08

Release to Staging v2.9.16 - 2026-04-08
This commit is contained in:
Justin Hernandez
2026-04-08 08:55:18 -07:00
committed by GitHub
124 changed files with 9653 additions and 1317 deletions

View File

@@ -10,6 +10,7 @@ env:
GH_CACHE_VERSION: v2 # Global cache version - bumped to invalidate caches
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
# Performance optimizations
YARN_TASK_POOL_CONCURRENCY: 2 # Limit parallel native module builds to prevent OOM on self-hosted runners
GRADLE_OPTS: -Dorg.gradle.workers.max=4 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true
CI: true
# Disable Maestro analytics in CI

5
.gitignore vendored
View File

@@ -57,7 +57,10 @@ contracts/broadcast/
!packages/rn-sdk-test-app/react-native.config.cjs
packages/native-shell-android/.gradle/
packages/native-shell-android/build/
# WebView bundles — built from webview-app, not checked in
packages/native-shell-android/src/main/assets/self-wallet/
packages/native-shell-ios/Resources/self-sdk-web/
packages/self-sdk-swift/Sources/SelfSdkSwift/Resources/self-sdk-web/
# Isolated Gradle home for format tasks
.gradle-home/

View File

@@ -1,2 +1 @@
BUNDLE_PATH: "vendor/bundle"
BUNDLE_FORCE_RUBY_PLATFORM: 1

View File

@@ -12,7 +12,7 @@ gem "fastlane", "~> 2.232.0"
group :development do
gem "dotenv"
gem "nokogiri", "~> 1.18", platform: :ruby
gem "nokogiri", "~> 1.18"
gem "bundler-audit", "~> 0.9", require: false
end

View File

@@ -189,6 +189,8 @@ GEM
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.3)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
@@ -260,6 +262,10 @@ GEM
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
@@ -318,7 +324,9 @@ GEM
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin
ruby
x86_64-darwin
DEPENDENCIES
activesupport (>= 6.1.7.5, != 7.1.0)

View File

@@ -1898,7 +1898,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNDeviceInfo (15.0.1):
- RNDeviceInfo (15.0.2):
- React-Core
- RNFBApp (21.14.0):
- Firebase/CoreOnly (= 11.11.0)
@@ -1978,7 +1978,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNLocalize (3.6.1):
- RNLocalize (3.7.0):
- DoubleConversion
- glog
- hermes-engine
@@ -2667,7 +2667,7 @@ SPEC CHECKSUMS:
RNAppleAuthentication: a89c9804592b38ed4ab11f0aee68d05ba12ad432
RNCAsyncStorage: 6a8127b6987dc9fbce778669b252b14c8355c7ce
RNCClipboard: 9f7b908de4bf4353871fb454c15fc03db4917b88
RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388
RNDeviceInfo: 4c852998208b60dc192ae3529e5867817719ad1e
RNFBApp: 4105e54d9ca4a1c10893a032268470f670181110
RNFBMessaging: 6857871d9dff8f26b0c325fc7d97ba69cb77d213
RNFBRemoteConfig: 8d3675f18c052483ce294bb97b857428467fb41e
@@ -2675,7 +2675,7 @@ SPEC CHECKSUMS:
RNGoogleSignin: 60c3f470558dbff0ae54f2f164ef82a89d3eb561
RNInAppBrowser: 6d3eb68d471b9834335c664704719b8be1bfdb20
RNKeychain: 35beaa17938f7d8e4990d8a38fad5f8a748fc47c
RNLocalize: 67cd0eece3ba20fb5dae7625d77f02e88d3d9573
RNLocalize: aa57bee9fcd545b98ce773a8e2404f9a36115b4a
RNReactNativeHapticFeedback: eb5395b503c7a8f10de5e6722ef8afd3c61bc4f5
RNScreens: b0811b109e1a0b8b579f3348018e177bee374840
RNSentry: 98ab9f6a16c9596e36565ccf1a5871323f334766

View File

@@ -0,0 +1,70 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
IOS_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
APP_DIR=$(cd -- "$IOS_DIR/.." && pwd)
REPO_DIR=$(cd -- "$APP_DIR/.." && pwd)
STAMP_DIR="$IOS_DIR/.cache"
STAMP_FILE="$STAMP_DIR/ios-deps.sha256"
mkdir -p "$STAMP_DIR"
compute_fingerprint() {
local file
local files=(
"$IOS_DIR/Podfile"
"$IOS_DIR/Podfile.lock"
"$APP_DIR/Gemfile"
"$APP_DIR/Gemfile.lock"
"$APP_DIR/package.json"
"$APP_DIR/react-native.config.cjs"
"$IOS_DIR/local-pods/DiditSDK/DiditSDK.podspec"
"$REPO_DIR/yarn.lock"
)
for file in "${files[@]}"; do
if [ -f "$file" ]; then
shasum -a 256 "$file"
fi
done
}
cd "$IOS_DIR"
if bundle check >/dev/null 2>&1; then
echo "Ruby gems already satisfied; skipping bundle install."
else
echo "Installing Ruby gems..."
bundle install
fi
current_fingerprint="$(compute_fingerprint)"
saved_fingerprint="$(cat "$STAMP_FILE" 2>/dev/null || true)"
pods_in_sync=false
if [ -d "$IOS_DIR/Pods" ] &&
[ -f "$IOS_DIR/Podfile.lock" ] &&
[ -f "$IOS_DIR/Pods/Manifest.lock" ] &&
cmp -s "$IOS_DIR/Podfile.lock" "$IOS_DIR/Pods/Manifest.lock"; then
pods_in_sync=true
fi
if [ "$pods_in_sync" = true ] && [ "$current_fingerprint" = "$saved_fingerprint" ]; then
echo "iOS pods already up to date; skipping pod install."
exit 0
fi
if [ "$pods_in_sync" = true ] && [ -z "$saved_fingerprint" ]; then
printf '%s\n' "$current_fingerprint" >"$STAMP_FILE"
echo "iOS pods already in sync; saved dependency fingerprint and skipped pod install."
exit 0
fi
echo "Installing iOS pods..."
"$SCRIPT_DIR/pod-install-with-cache-fix.sh"
compute_fingerprint >"$STAMP_FILE"
echo "Saved iOS dependency fingerprint to $STAMP_FILE."

View File

@@ -1,22 +1,23 @@
#!/bin/bash
# Pod install with hermes-engine cache fix for React Native upgrades
# This script handles CocoaPods cache mismatches that occur after React Native version upgrades
# Pod install with an on-demand Hermes cache fix.
# Most installs should reuse CocoaPods caches; only clear them after a failure
# that is likely caused by a stale Hermes artifact from a React Native upgrade.
set -e # Exit on any error
set -e
echo "🧹 Clearing CocoaPods cache to prevent hermes-engine version conflicts..."
bundle exec pod cache clean --all > /dev/null 2>&1 || true
rm -rf ~/Library/Caches/CocoaPods > /dev/null 2>&1 || true
echo "📦 Attempting pod install..."
if bundle exec pod install; then
echo "📦 Attempting pod install with existing CocoaPods caches..."
if bundle exec pod install --no-repo-update; then
echo "✅ Pods installed successfully"
else
echo "⚠️ Pod install failed, likely due to hermes-engine cache mismatch after React Native upgrade"
echo "🔧 Running targeted fix: bundle exec pod update hermes-engine..."
bundle exec pod update hermes-engine --no-repo-update
echo "🔄 Retrying pod install..."
bundle exec pod install
echo "✅ Pods installed successfully after cache fix"
exit 0
fi
echo "⚠️ pod install failed; clearing Hermes cache and retrying..."
bundle exec pod cache clean hermes-engine --all > /dev/null 2>&1 || true
echo "🔧 Running targeted fix: bundle exec pod update hermes-engine..."
bundle exec pod update hermes-engine --no-repo-update --verbose
echo "🔄 Retrying pod install..."
bundle exec pod install --no-repo-update
echo "✅ Pods installed successfully after cache fix"

View File

@@ -14,12 +14,13 @@
"bump-version:major": "npm version major && yarn sync-versions",
"bump-version:minor": "npm version minor && yarn sync-versions",
"bump-version:patch": "npm version patch && yarn sync-versions",
"clean": "yarn clean:watchman && yarn clean:build && yarn clean:ios && yarn clean:xcode && yarn clean:pod-cache && yarn clean:node && yarn clean:android-deps",
"clean": "yarn clean:watchman && yarn clean:build && yarn clean:ios && yarn clean:xcode && yarn clean:pod-cache && yarn clean:android-deps && yarn clean:node",
"clean:android-deps": "node scripts/cleanup-private-modules.cjs",
"clean:build": "rm -rf ios/build android/app/build android/build",
"clean:ios": "rm -rf ios/Pods ios/Podfile.lock Gemfile.lock",
"clean:node": "rm -rf ../node_modules app/node_modules",
"clean:ios": "rm -rf ios/Pods ios/Podfile.lock",
"clean:node": "rm -rf ../node_modules node_modules",
"clean:pod-cache": "cd ios && pod cache clean --all && cd ..",
"clean:ruby": "rm -rf vendor/bundle",
"clean:watchman": "watchman watch-del-all",
"clean:xcode": "rm -rf ~/Library/Developer/Xcode/DerivedData",
"clean:xcode-env-local": "rm -f ios/.xcode.env.local",
@@ -32,7 +33,7 @@
"postinstall": "npx patch-package --patch-dir ../patches || true",
"install-app": "yarn install-app:setup && yarn clean:xcode-env-local",
"install-app:mobile-deploy": "yarn install && yarn build:deps && yarn clean:xcode-env-local",
"install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && bundle install && scripts/pod-install-with-cache-fix.sh && cd ..",
"install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && scripts/install-ios-deps-if-needed.sh && cd ..",
"ios": "yarn build:deps && node scripts/run-ios-simulator.cjs",
"ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test",
"jest:clear": "node ./node_modules/jest/bin/jest.js --clearCache",
@@ -85,8 +86,8 @@
"react-native-webview": "13.16.0"
},
"dependencies": {
"@babel/runtime": "^7.28.6",
"@didit-protocol/sdk-react-native": "^3.2.7",
"@babel/runtime": "^7.29.2",
"@didit-protocol/sdk-react-native": "^3.2.8",
"@ethersproject/shims": "^5.8.0",
"@invertase/react-native-apple-authentication": "^2.5.1",
"@noble/hashes": "^1.5.0",
@@ -99,18 +100,18 @@
"@react-native-firebase/app": "^21.14.0",
"@react-native-firebase/messaging": "^21.14.0",
"@react-native-firebase/remote-config": "^21.14.0",
"@react-native-google-signin/google-signin": "^16.1.1",
"@react-native-google-signin/google-signin": "^16.1.2",
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.3",
"@segment/analytics-react-native": "^2.21.2",
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.6",
"@segment/analytics-react-native": "^2.22.0",
"@segment/sovran-react-native": "^1.1.3",
"@selfxyz/common": "workspace:^",
"@selfxyz/euclid": "^0.6.1",
"@selfxyz/mobile-sdk-alpha": "workspace:^",
"@sentry/react": "^9.32.0",
"@sentry/react-native": "7.0.0",
"@stablelib/cbor": "^2.0.1",
"@stablelib/cbor": "^2.0.4",
"@tamagui/animations-css": "1.126.14",
"@tamagui/animations-react-native": "1.126.14",
"@tamagui/config": "1.126.14",
@@ -123,11 +124,11 @@
"@walletconnect/react-native-compat": "^2.23.0",
"@xstate/react": "^5.0.3",
"asn1js": "^3.0.7",
"axios": "^1.13.2",
"axios": "^1.14.0",
"buffer": "^6.0.3",
"country-emoji": "^1.5.6",
"elliptic": "^6.6.1",
"ethers": "^6.11.0",
"ethers": "^6.16.0",
"expo": "~52.0.40",
"expo-application": "~6.0.2",
"hash.js": "^1.1.7",
@@ -136,40 +137,40 @@
"js-sha512": "^0.9.0",
"lottie-react": "^2.4.1",
"lottie-react-native": "7.2.2",
"node-forge": "^1.3.3",
"pkijs": "^3.3.3",
"node-forge": "^1.4.0",
"pkijs": "^3.4.0",
"poseidon-lite": "^0.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native": "0.77.0",
"react-native-app-auth": "^8.0.3",
"react-native-app-auth": "^8.1.0",
"react-native-biometrics": "^3.0.1",
"react-native-blur-effect": "^1.1.3",
"react-native-check-version": "^1.3.0",
"react-native-check-version": "^1.4.0",
"react-native-cloud-storage": "^2.2.2",
"react-native-device-info": "^15.0.1",
"react-native-device-info": "^15.0.2",
"react-native-dotenv": "^3.4.11",
"react-native-edge-to-edge": "^1.7.0",
"react-native-edge-to-edge": "^1.8.1",
"react-native-gesture-handler": "~2.22.0",
"react-native-get-random-values": "^1.11.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-inappbrowser-reborn": "^3.7.0",
"react-native-inappbrowser-reborn": "^3.7.1",
"react-native-keychain": "^10.0.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-localize": "^3.6.1",
"react-native-logs": "^5.5.0",
"react-native-localize": "^3.7.0",
"react-native-logs": "^5.6.0",
"react-native-nfc-manager": "3.17.2",
"react-native-passkey": "^3.3.2",
"react-native-passkey": "^3.3.3",
"react-native-passport-reader": "1.0.3",
"react-native-safe-area-context": "^5.6.2",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "4.15.3",
"react-native-sqlite-storage": "^6.0.1",
"react-native-svg": "15.14.0",
"react-native-svg-web": "1.0.9",
"react-native-url-polyfill": "^3.0.0",
"react-native-web": "^0.21.2",
"react-native-webview": "^13.16.0",
"react-qr-barcode-scanner": "^2.1.8",
"react-native-webview": "13.16.0",
"react-qr-barcode-scanner": "^2.1.25",
"socket.io-client": "^4.8.3",
"tamagui": "1.126.14",
"uuid": "^11.1.0",
@@ -177,13 +178,13 @@
"zustand": "^4.5.2"
},
"devDependencies": {
"@babel/core": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/plugin-syntax-flow": "^7.28.6",
"@babel/plugin-transform-classes": "^7.28.6",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-flow-strip-types": "^7.27.1",
"@babel/plugin-transform-private-methods": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@react-native-community/cli": "^16.0.3",
"@react-native/babel-preset": "0.77.0",
@@ -194,7 +195,7 @@
"@tamagui/types": "1.126.14",
"@tamagui/vite-plugin": "1.126.14",
"@testing-library/react-native": "^13.3.3",
"@tsconfig/react-native": "^3.0.6",
"@tsconfig/react-native": "^3.0.9",
"@types/dompurify": "^3.2.0",
"@types/elliptic": "^6.4.18",
"@types/jest": "^30.0.0",
@@ -202,31 +203,31 @@
"@types/node-forge": "^1.3.14",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/react-native-dotenv": "^0.2.0",
"@types/react-native-dotenv": "^0.2.2",
"@types/react-native-sqlite-storage": "^6.0.5",
"@types/react-native-web": "^0",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^4.2.2",
"babel-plugin-module-resolver": "^5.0.2",
"@vitejs/plugin-react-swc": "^4.3.0",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-transform-remove-console": "^6.9.4",
"constants-browserify": "^1.0.0",
"dompurify": "^3.3.1",
"dompurify": "^3.3.3",
"eslint": "^8.57.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-ft-flow": "^3.0.11",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^29.1.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.15.1",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sort-exports": "^0.9.1",
"hermes-eslint": "^0.33.3",
"jest": "^30.2.0",
"prettier": "^3.5.3",
"jest": "^30.3.0",
"prettier": "^3.8.1",
"prop-types": "^15.8.1",
"react-native-svg-transformer": "^1.5.2",
"react-native-svg-transformer": "^1.5.3",
"react-test-renderer": "^18.3.1",
"rollup-plugin-visualizer": "^6.0.5",
"stream-browserify": "^3.0.0",

View File

@@ -33,6 +33,7 @@ const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
const repoToken = process.env.SELFXYZ_INTERNAL_REPO_PAT;
const appToken = process.env.SELFXYZ_APP_TOKEN; // GitHub App installation token
const isDryRun = process.env.DRY_RUN === 'true';
const forceAndroidDeps = process.env.FORCE_ANDROID_DEPS === '1';
// Platform detection for Android-specific modules
function shouldSetupAndroidModule() {
@@ -240,10 +241,114 @@ function validateSetup(modulePath, validationFiles, repoName) {
log(`${repoName} validation passed`, 'success');
}
function isExistingModuleReusable(module) {
const { repoName, localPath, validationFiles } = module;
if (!fs.existsSync(localPath)) {
return false;
}
if (!fs.existsSync(path.join(localPath, '.git'))) {
log(
`Existing ${repoName} checkout is missing .git metadata; recloning`,
'warning',
);
return false;
}
try {
validateSetup(localPath, validationFiles, repoName);
} catch (error) {
log(
`Existing ${repoName} checkout is invalid: ${error.message}`,
'warning',
);
return false;
}
try {
const remoteUrl = execSync('git remote get-url origin', {
cwd: localPath,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
}).trim();
const canonicalOriginPattern = new RegExp(
`^(git@github\\.com:|https://github\\.com/|ssh://git@github\\.com/)${GITHUB_ORG}/${repoName}(\\.git)?$`,
);
if (!canonicalOriginPattern.test(remoteUrl)) {
const safeUrl = remoteUrl.replace(/\/\/[^@]+@/, '//***@');
log(
`Existing ${repoName} checkout points at unexpected origin ${safeUrl}; recloning`,
'warning',
);
return false;
}
} catch (error) {
log(
`Could not inspect origin for existing ${repoName} checkout: ${error.message}`,
'warning',
);
return false;
}
// Reject dirty checkouts — uncommitted changes compromise integrity
try {
const status = execSync('git status --porcelain', {
cwd: localPath,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
}).trim();
if (status.length > 0) {
log(
`Existing ${repoName} checkout has uncommitted changes; recloning`,
'warning',
);
return false;
}
} catch (error) {
log(
`Could not check dirty state for ${repoName}: ${error.message}`,
'warning',
);
return false;
}
// When a specific commit is pinned, verify HEAD matches
if (module.commit) {
try {
const head = execSync('git rev-parse HEAD', {
cwd: localPath,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
}).trim();
if (head !== module.commit) {
log(
`Existing ${repoName} is at ${head.slice(0, 12)} but expected ${module.commit.slice(0, 12)}; recloning`,
'warning',
);
return false;
}
} catch (error) {
log(`Could not read HEAD for ${repoName}: ${error.message}`, 'warning');
return false;
}
}
return true;
}
function setupPrivateModule(module) {
const { repoName, localPath, validationFiles, commit } = module;
log(`Starting setup of ${repoName}...`, 'info');
if (!forceAndroidDeps && isExistingModuleReusable(module)) {
log(`${repoName} already present; reusing existing checkout`, 'success');
return true;
}
// Remove existing module
removeExistingModule(localPath, repoName);
@@ -344,5 +449,6 @@ if (require.main === module) {
module.exports = {
setupAndroidPassportReader,
removeExistingModule,
isExistingModuleReusable,
PRIVATE_MODULES,
};

View File

@@ -8,6 +8,7 @@ typechain-types
#Hardhat files
cache
artifacts
edr-cache
ignition/deployed_addresses.json
ignition/parameters.json

0
contracts/contracts/registry/IdentityRegistry.sol Normal file → Executable file
View File

View File

@@ -6,6 +6,29 @@ import {InternalLeanIMT, LeanIMTData} from "@zk-kit/imt.sol/internal/InternalLea
import {IIdentityRegistryAadhaarV1} from "../interfaces/IIdentityRegistryAadhaarV1.sol";
import {ImplRoot} from "../upgradeable/ImplRoot.sol";
import {AttestationId} from "../constants/AttestationId.sol";
import {GCPJWTHelper} from "../libraries/GCPJWTHelper.sol";
import {Formatter} from "../libraries/Formatter.sol";
/**
* @title IGCPJWTVerifier
* @notice Interface for the GCP JWT verifier contract.
*/
interface IGCPJWTVerifier {
function verifyProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals
) external view returns (bool);
}
/**
* @title IPCR0Manager
* @notice Interface for the PCR0 (TEE image hash) manager contract.
*/
interface IPCR0Manager {
function isPCR0Set(bytes calldata pcr0) external view returns (bool);
}
/**
* @notice ⚠️ CRITICAL STORAGE LAYOUT WARNING ⚠️
@@ -64,6 +87,24 @@ abstract contract IdentityRegistryAadhaarStorageV1 is ImplRoot {
/// @notice Current name and year of birth OFAC root.
uint256 internal _nameAndYobOfacRoot;
/// @notice Previous name and date of birth OFAC root (rolling window).
uint256 internal _prevNameAndDobOfacRoot;
/// @notice Previous name and year of birth OFAC root (rolling window).
uint256 internal _prevNameAndYobOfacRoot;
/// @notice Address of the GCP JWT verifier contract for OFAC proof updates.
address internal _gcpJwtVerifier;
/// @notice Address of the PCR0Manager for OFAC proof updates.
address internal _pcr0Manager;
/// @notice Expected hash of the GCP root CA public key for OFAC proof verification.
uint256 internal _gcpRootCAPubkeyHash;
/// @notice Address of the TEE authorized to call updateOfacRootsWithProof.
address internal _tee;
}
/**
@@ -71,7 +112,7 @@ abstract contract IdentityRegistryAadhaarStorageV1 is ImplRoot {
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryAadhaarStorageV1 and implements IIdentityRegistryAadhaarV1.
*
* @custom:version 1.2.0
* @custom:version 1.3.1
*/
contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIdentityRegistryAadhaarV1 {
using InternalLeanIMT for LeanIMTData;
@@ -123,6 +164,16 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
event DevCommitmentUpdated(uint256 indexed oldLeaf, uint256 indexed newLeaf, uint256 imtRoot, uint256 timestamp);
/// @notice Emitted when a identity commitment is removed by dev team.
event DevCommitmentRemoved(uint256 indexed oldLeaf, uint256 imtRoot, uint256 timestamp);
/// @notice Emitted when OFAC roots are updated via proof.
event OfacRootsUpdatedWithProof(bytes32 rootsHash, uint256 timestamp);
/// @notice Emitted when the GCP JWT verifier address is updated.
event GCPJWTVerifierUpdated(address gcpJwtVerifier);
/// @notice Emitted when the PCR0Manager address is updated.
event PCR0ManagerUpdated(address pcr0Manager);
/// @notice Emitted when the GCP root CA pubkey hash is updated.
event GCPRootCAPubkeyHashUpdated(uint256 gcpRootCAPubkeyHash);
/// @notice Emitted when the TEE address is updated.
event TEEUpdated(address tee);
// ====================================================
// Errors
@@ -136,6 +187,22 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
error REGISTERED_COMMITMENT();
/// @notice Thrown when the hub address is set to the zero address.
error HUB_ADDRESS_ZERO();
/// @notice Thrown when the GCP JWT proof verification fails.
error INVALID_PROOF();
/// @notice Thrown when the GCP root CA public key hash does not match the expected value.
error INVALID_ROOT_CA();
/// @notice Thrown when the TEE image hash is not registered in the PCR0Manager.
error INVALID_IMAGE();
/// @notice Thrown when the timestamp is invalid.
error INVALID_TIMESTAMP();
/// @notice Thrown when the roots hash does not match the proof.
error InvalidRootsHash();
/// @notice Thrown when the wrong number of roots is provided.
error InvalidRootsCount();
/// @notice Thrown when the TEE address is not set.
error TEE_NOT_SET();
/// @notice Thrown when a function is accessed by an address other than the designated TEE.
error ONLY_TEE_CAN_ACCESS();
// ====================================================
// Modifiers
@@ -148,6 +215,16 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
_;
}
/**
* @notice Modifier to restrict access to functions to only the TEE.
* @dev Reverts if the TEE is not set or if the caller is not the TEE.
*/
modifier onlyTEE() {
if (address(_tee) == address(0)) revert TEE_NOT_SET();
if (msg.sender != address(_tee)) revert ONLY_TEE_CAN_ACCESS();
_;
}
// ====================================================
// Constructor
// ====================================================
@@ -184,6 +261,25 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
__ImplRoot_init();
}
/**
* @notice Initializes OFAC proof verification infrastructure.
* @dev Sets GCP JWT verifier, PCR0Manager, and root CA pubkey hash.
* @param gcpJwtVerifier_ The GCP JWT Groth16 verifier address.
* @param pcr0Manager_ The PCR0Manager address for TEE image validation.
* @param gcpRootCAPubkeyHash_ The expected Poseidon hash of the GCP root CA public key.
*/
function initializeOfacProof(
address gcpJwtVerifier_,
address pcr0Manager_,
uint256 gcpRootCAPubkeyHash_,
address teeAddress_
) external onlyProxy onlyRole(SECURITY_ROLE) reinitializer(3) {
_gcpJwtVerifier = gcpJwtVerifier_;
_pcr0Manager = pcr0Manager_;
_gcpRootCAPubkeyHash = gcpRootCAPubkeyHash_;
_tee = teeAddress_;
}
// ====================================================
// External Functions - View & Checks
// ====================================================
@@ -253,6 +349,32 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
return _nameAndYobOfacRoot;
}
/**
* @notice Retrieves the previous name and date of birth OFAC root (rolling window).
* @return The stored previous name and date of birth OFAC root.
*/
function getPrevNameAndDobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndDobOfacRoot;
}
/**
* @notice Retrieves the previous name and year of birth OFAC root (rolling window).
* @return The stored previous name and year of birth OFAC root.
*/
function getPrevNameAndYobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndYobOfacRoot;
}
/// @notice Returns the address of the GCP JWT verifier contract.
function getGcpJwtVerifier() external view onlyProxy returns (address) {
return _gcpJwtVerifier;
}
/// @notice Returns the address of the PCR0 Manager contract.
function getPcr0Manager() external view onlyProxy returns (address) {
return _pcr0Manager;
}
/// @notice Validates whether the provided OFAC roots match the stored values.
/// @param nameAndDobRoot The name and date of birth OFAC root to validate.
/// @param nameAndYobRoot The name and year of birth OFAC root to validate.
@@ -261,7 +383,11 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
uint256 nameAndDobRoot,
uint256 nameAndYobRoot
) external view virtual onlyProxy returns (bool) {
return _nameAndDobOfacRoot == nameAndDobRoot && _nameAndYobOfacRoot == nameAndYobRoot;
bool currentMatch = (_nameAndDobOfacRoot == nameAndDobRoot) && (_nameAndYobOfacRoot == nameAndYobRoot);
bool prevMatch = (_prevNameAndDobOfacRoot != 0) &&
(_prevNameAndDobOfacRoot == nameAndDobRoot) &&
(_prevNameAndYobOfacRoot == nameAndYobRoot);
return currentMatch || prevMatch;
}
/// @notice Checks if the provided UIDAI pubkey is stored in the registry and also if it's not expired.
@@ -306,6 +432,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param newNameAndDobOfacRoot The new name and date of birth OFAC root value.
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = newNameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(newNameAndDobOfacRoot);
}
@@ -314,6 +441,7 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
/// @dev Callable only via a proxy and restricted to the contract owner.
/// @param newNameAndYobOfacRoot The new name and year of birth OFAC root value.
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = newNameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(newNameAndYobOfacRoot);
}
@@ -342,6 +470,96 @@ contract IdentityRegistryAadhaarImplV1 is IdentityRegistryAadhaarStorageV1, IIde
emit UidaiPubkeyCommitmentUpdated(commitment, block.timestamp);
}
/// @notice Updates the GCP JWT verifier contract address.
/// @param verifier The new GCP JWT verifier address.
function updateGCPJWTVerifier(address verifier) external onlyProxy onlyRole(SECURITY_ROLE) {
_gcpJwtVerifier = verifier;
emit GCPJWTVerifierUpdated(verifier);
}
/// @notice Updates the PCR0Manager address.
/// @param newPCR0Manager The new PCR0Manager address.
function updatePCR0Manager(address newPCR0Manager) external onlyProxy onlyRole(SECURITY_ROLE) {
_pcr0Manager = newPCR0Manager;
emit PCR0ManagerUpdated(newPCR0Manager);
}
/// @notice Updates the GCP root CA pubkey hash.
/// @param newHash The new GCP root CA pubkey hash value.
function updateGCPRootCAPubkeyHash(uint256 newHash) external onlyProxy onlyRole(SECURITY_ROLE) {
_gcpRootCAPubkeyHash = newHash;
emit GCPRootCAPubkeyHashUpdated(newHash);
}
/// @notice Updates the TEE address.
/// @param teeAddress The new TEE address.
function updateTEE(address teeAddress) external onlyProxy onlyRole(SECURITY_ROLE) {
_tee = teeAddress;
emit TEEUpdated(teeAddress);
}
/// @notice Retrieves the TEE address.
/// @return The current TEE address.
function tee() external view onlyProxy returns (address) {
return _tee;
}
/// @notice Updates OFAC roots via proof-verified TEE attestation.
/// @dev Verifies the Groth16 proof, validates TEE attestation claims, checks
/// this registry's roots hash against the eat_nonce from the proof. Restricted to the TEE address. The proof provides
/// cryptographic verification, and onlyTEE provides access control.
/// @param pA Groth16 proof element A.
/// @param pB Groth16 proof element B.
/// @param pC Groth16 proof element C.
/// @param pubSignals Circuit public signals [rootCA, eatNonce[0-2], unused, imageHash[0-2], date[0-11]].
/// @param roots This registry's roots: [nameAndDob, nameAndYob].
function updateOfacRootsWithProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals,
uint256[] calldata roots
) external onlyProxy onlyTEE {
if (roots.length != 2) revert InvalidRootsCount();
// Verify Groth16 proof
if (!IGCPJWTVerifier(_gcpJwtVerifier).verifyProof(pA, pB, pC, pubSignals)) revert INVALID_PROOF();
// Verify root CA pubkey hash
if (pubSignals[0] != _gcpRootCAPubkeyHash) revert INVALID_ROOT_CA();
// Verify TEE image hash
bytes memory imageHash = GCPJWTHelper.unpackAndConvertImageHash(pubSignals[5], pubSignals[6], pubSignals[7]);
if (!IPCR0Manager(_pcr0Manager).isPCR0Set(imageHash)) revert INVALID_IMAGE();
// Verify timestamp (±1 hour)
uint256 currentTimestamp = Formatter.toTimeStampWithSeconds(
2000 + pubSignals[8] * 10 + pubSignals[9],
pubSignals[10] * 10 + pubSignals[11],
pubSignals[12] * 10 + pubSignals[13],
pubSignals[14] * 10 + pubSignals[15],
pubSignals[16] * 10 + pubSignals[17],
pubSignals[18] * 10 + pubSignals[19]
);
if (currentTimestamp + 1 hours < block.timestamp) revert INVALID_TIMESTAMP();
if (currentTimestamp > block.timestamp + 1 hours) revert INVALID_TIMESTAMP();
// Verify roots hash matches eat_nonce from proof
bytes32 myHash = sha256(abi.encodePacked(roots[0], roots[1]));
uint256 rootsHashFromProof = GCPJWTHelper.unpackAndDecodeHexPubkey(pubSignals[1], pubSignals[2], pubSignals[3]);
if (uint256(myHash) != rootsHashFromProof) revert InvalidRootsHash();
// Update this registry's roots: [nameAndDob, nameAndYob] (with rolling window)
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = roots[0];
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = roots[1];
emit NameAndDobOfacRootUpdated(roots[0]);
emit NameAndYobOfacRootUpdated(roots[1]);
emit OfacRootsUpdatedWithProof(myHash, block.timestamp);
}
/// @notice (DEV) Force-adds an identity commitment.
/// @dev Callable only by the owner for testing or administration.
/// @param attestationId The identifier for the attestation.

View File

@@ -5,6 +5,29 @@ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/U
import {InternalLeanIMT, LeanIMTData} from "@zk-kit/imt.sol/internal/InternalLeanIMT.sol";
import {IIdentityRegistryIdCardV1} from "../interfaces/IIdentityRegistryIdCardV1.sol";
import {ImplRoot} from "../upgradeable/ImplRoot.sol";
import {GCPJWTHelper} from "../libraries/GCPJWTHelper.sol";
import {Formatter} from "../libraries/Formatter.sol";
/**
* @title IGCPJWTVerifier
* @notice Interface for the GCP JWT verifier contract.
*/
interface IGCPJWTVerifier {
function verifyProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals
) external view returns (bool);
}
/**
* @title IPCR0Manager
* @notice Interface for the PCR0 (TEE image hash) manager contract.
*/
interface IPCR0Manager {
function isPCR0Set(bytes calldata pcr0) external view returns (bool);
}
/**
* @notice ⚠️ CRITICAL STORAGE LAYOUT WARNING ⚠️
@@ -35,8 +58,8 @@ import {ImplRoot} from "../upgradeable/ImplRoot.sol";
*/
/**
* @title IdentityRegistryStorageV1
* @dev Abstract contract for storage layout of IdentityRegistryImplV1.
* @title IdentityRegistryIdCardStorageV1
* @dev Abstract contract for storage layout of IdentityRegistryIdCardImplV1.
* Inherits from ImplRoot to provide upgradeable functionality.
*/
abstract contract IdentityRegistryIdCardStorageV1 is ImplRoot {
@@ -71,14 +94,32 @@ abstract contract IdentityRegistryIdCardStorageV1 is ImplRoot {
/// @notice Current CSCA root.
uint256 internal _cscaRoot;
/// @notice Previous name and date of birth OFAC root (rolling window).
uint256 internal _prevNameAndDobOfacRoot;
/// @notice Previous name and year of birth OFAC root (rolling window).
uint256 internal _prevNameAndYobOfacRoot;
/// @notice Address of the GCP JWT verifier contract for OFAC proof updates.
address internal _gcpJwtVerifier;
/// @notice Address of the PCR0Manager for OFAC proof updates.
address internal _pcr0Manager;
/// @notice Expected hash of the GCP root CA public key for OFAC proof verification.
uint256 internal _gcpRootCAPubkeyHash;
/// @notice Address of the TEE authorized to call updateOfacRootsWithProof.
address internal _tee;
}
/**
* @title IdentityRegistryImplV1
* @title IdentityRegistryIdCardImplV1
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryStorageV1 and implements IIdentityRegistryV1.
*
* @custom:version 1.2.0
* @custom:version 1.3.1
*/
contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdentityRegistryIdCardV1 {
using InternalLeanIMT for LeanIMTData;
@@ -131,6 +172,16 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
event DevNullifierStateChanged(bytes32 indexed attestationId, uint256 indexed nullifier, bool state);
/// @notice Emitted when the state of a DSC key commitment is changed by dev team.
event DevDscKeyCommitmentStateChanged(uint256 indexed commitment, bool state);
/// @notice Emitted when OFAC roots are updated via proof.
event OfacRootsUpdatedWithProof(bytes32 rootsHash, uint256 timestamp);
/// @notice Emitted when the GCP JWT verifier address is updated.
event GCPJWTVerifierUpdated(address gcpJwtVerifier);
/// @notice Emitted when the PCR0Manager address is updated.
event PCR0ManagerUpdated(address pcr0Manager);
/// @notice Emitted when the GCP root CA pubkey hash is updated.
event GCPRootCAPubkeyHashUpdated(uint256 gcpRootCAPubkeyHash);
/// @notice Emitted when the TEE address is updated.
event TEEUpdated(address tee);
// ====================================================
// Errors
@@ -142,6 +193,22 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
error ONLY_HUB_CAN_ACCESS();
/// @notice Thrown when attempting to register a commitment that has already been registered.
error REGISTERED_COMMITMENT();
/// @notice Thrown when the GCP JWT proof verification fails.
error INVALID_PROOF();
/// @notice Thrown when the GCP root CA public key hash does not match the expected value.
error INVALID_ROOT_CA();
/// @notice Thrown when the TEE image hash is not registered in the PCR0Manager.
error INVALID_IMAGE();
/// @notice Thrown when the timestamp is invalid.
error INVALID_TIMESTAMP();
/// @notice Thrown when the roots hash does not match the proof.
error InvalidRootsHash();
/// @notice Thrown when the wrong number of roots is provided.
error InvalidRootsCount();
/// @notice Thrown when the TEE address is not set.
error TEE_NOT_SET();
/// @notice Thrown when a function is accessed by an address other than the designated TEE.
error ONLY_TEE_CAN_ACCESS();
// ====================================================
// Modifiers
@@ -157,6 +224,16 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
_;
}
/**
* @notice Modifier to restrict access to functions to only the TEE.
* @dev Reverts if the TEE is not set or if the caller is not the TEE.
*/
modifier onlyTEE() {
if (address(_tee) == address(0)) revert TEE_NOT_SET();
if (msg.sender != address(_tee)) revert ONLY_TEE_CAN_ACCESS();
_;
}
// ====================================================
// Constructor
// ====================================================
@@ -197,6 +274,25 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
__ImplRoot_init();
}
/**
* @notice Initializes OFAC proof verification infrastructure.
* @dev Sets GCP JWT verifier, PCR0Manager, and root CA pubkey hash.
* @param gcpJwtVerifier_ The GCP JWT Groth16 verifier address.
* @param pcr0Manager_ The PCR0Manager address for TEE image validation.
* @param gcpRootCAPubkeyHash_ The expected Poseidon hash of the GCP root CA public key.
*/
function initializeOfacProof(
address gcpJwtVerifier_,
address pcr0Manager_,
uint256 gcpRootCAPubkeyHash_,
address teeAddress_
) external onlyProxy onlyRole(SECURITY_ROLE) reinitializer(3) {
_gcpJwtVerifier = gcpJwtVerifier_;
_pcr0Manager = pcr0Manager_;
_gcpRootCAPubkeyHash = gcpRootCAPubkeyHash_;
_tee = teeAddress_;
}
// ====================================================
// External Functions - View & Checks
// ====================================================
@@ -287,6 +383,32 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
return _nameAndYobOfacRoot;
}
/**
* @notice Retrieves the previous name and date of birth OFAC root (rolling window).
* @return The stored previous name and date of birth OFAC root.
*/
function getPrevNameAndDobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndDobOfacRoot;
}
/**
* @notice Retrieves the previous name and year of birth OFAC root (rolling window).
* @return The stored previous name and year of birth OFAC root.
*/
function getPrevNameAndYobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndYobOfacRoot;
}
/// @notice Returns the address of the GCP JWT verifier contract.
function getGcpJwtVerifier() external view onlyProxy returns (address) {
return _gcpJwtVerifier;
}
/// @notice Returns the address of the PCR0 Manager contract.
function getPcr0Manager() external view onlyProxy returns (address) {
return _pcr0Manager;
}
/**
* @notice Validates whether the provided OFAC roots match the stored values.
* @param nameAndDobRoot The name and date of birth OFAC root to validate.
@@ -294,7 +416,11 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @return True if all provided roots match the stored values, false otherwise.
*/
function checkOfacRoots(uint256 nameAndDobRoot, uint256 nameAndYobRoot) external view onlyProxy returns (bool) {
return _nameAndDobOfacRoot == nameAndDobRoot && _nameAndYobOfacRoot == nameAndYobRoot;
bool currentMatch = (_nameAndDobOfacRoot == nameAndDobRoot) && (_nameAndYobOfacRoot == nameAndYobRoot);
bool prevMatch = (_prevNameAndDobOfacRoot != 0) &&
(_prevNameAndDobOfacRoot == nameAndDobRoot) &&
(_prevNameAndYobOfacRoot == nameAndYobRoot);
return currentMatch || prevMatch;
}
/**
@@ -407,6 +533,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @param newNameAndDobOfacRoot The new name and date of birth OFAC root value.
*/
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = newNameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(newNameAndDobOfacRoot);
}
@@ -417,6 +544,7 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
* @param newNameAndYobOfacRoot The new name and year of birth OFAC root value.
*/
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = newNameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(newNameAndYobOfacRoot);
}
@@ -431,6 +559,96 @@ contract IdentityRegistryIdCardImplV1 is IdentityRegistryIdCardStorageV1, IIdent
emit CscaRootUpdated(newCscaRoot);
}
/// @notice Updates the GCP JWT verifier contract address.
/// @param verifier The new GCP JWT verifier address.
function updateGCPJWTVerifier(address verifier) external onlyProxy onlyRole(SECURITY_ROLE) {
_gcpJwtVerifier = verifier;
emit GCPJWTVerifierUpdated(verifier);
}
/// @notice Updates the PCR0Manager address.
/// @param newPCR0Manager The new PCR0Manager address.
function updatePCR0Manager(address newPCR0Manager) external onlyProxy onlyRole(SECURITY_ROLE) {
_pcr0Manager = newPCR0Manager;
emit PCR0ManagerUpdated(newPCR0Manager);
}
/// @notice Updates the GCP root CA pubkey hash.
/// @param newHash The new GCP root CA pubkey hash value.
function updateGCPRootCAPubkeyHash(uint256 newHash) external onlyProxy onlyRole(SECURITY_ROLE) {
_gcpRootCAPubkeyHash = newHash;
emit GCPRootCAPubkeyHashUpdated(newHash);
}
/// @notice Updates the TEE address.
/// @param teeAddress The new TEE address.
function updateTEE(address teeAddress) external onlyProxy onlyRole(SECURITY_ROLE) {
_tee = teeAddress;
emit TEEUpdated(teeAddress);
}
/// @notice Retrieves the TEE address.
/// @return The current TEE address.
function tee() external view onlyProxy returns (address) {
return _tee;
}
/// @notice Updates OFAC roots via proof-verified TEE attestation.
/// @dev Verifies the Groth16 proof, validates TEE attestation claims, checks
/// this registry's roots hash against the eat_nonce from the proof. Restricted to the TEE address. The proof provides
/// cryptographic verification, and onlyTEE provides access control.
/// @param pA Groth16 proof element A.
/// @param pB Groth16 proof element B.
/// @param pC Groth16 proof element C.
/// @param pubSignals Circuit public signals [rootCA, eatNonce[0-2], unused, imageHash[0-2], date[0-11]].
/// @param roots This registry's roots: [nameAndDob, nameAndYob].
function updateOfacRootsWithProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals,
uint256[] calldata roots
) external onlyProxy onlyTEE {
if (roots.length != 2) revert InvalidRootsCount();
// Verify Groth16 proof
if (!IGCPJWTVerifier(_gcpJwtVerifier).verifyProof(pA, pB, pC, pubSignals)) revert INVALID_PROOF();
// Verify root CA pubkey hash
if (pubSignals[0] != _gcpRootCAPubkeyHash) revert INVALID_ROOT_CA();
// Verify TEE image hash
bytes memory imageHash = GCPJWTHelper.unpackAndConvertImageHash(pubSignals[5], pubSignals[6], pubSignals[7]);
if (!IPCR0Manager(_pcr0Manager).isPCR0Set(imageHash)) revert INVALID_IMAGE();
// Verify timestamp (±1 hour)
uint256 currentTimestamp = Formatter.toTimeStampWithSeconds(
2000 + pubSignals[8] * 10 + pubSignals[9],
pubSignals[10] * 10 + pubSignals[11],
pubSignals[12] * 10 + pubSignals[13],
pubSignals[14] * 10 + pubSignals[15],
pubSignals[16] * 10 + pubSignals[17],
pubSignals[18] * 10 + pubSignals[19]
);
if (currentTimestamp + 1 hours < block.timestamp) revert INVALID_TIMESTAMP();
if (currentTimestamp > block.timestamp + 1 hours) revert INVALID_TIMESTAMP();
// Verify roots hash matches eat_nonce from proof
bytes32 myHash = sha256(abi.encodePacked(roots[0], roots[1]));
uint256 rootsHashFromProof = GCPJWTHelper.unpackAndDecodeHexPubkey(pubSignals[1], pubSignals[2], pubSignals[3]);
if (uint256(myHash) != rootsHashFromProof) revert InvalidRootsHash();
// Update this registry's roots with rolling window: [nameAndDob, nameAndYob]
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = roots[0];
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = roots[1];
emit NameAndDobOfacRootUpdated(roots[0]);
emit NameAndYobOfacRootUpdated(roots[1]);
emit OfacRootsUpdatedWithProof(myHash, block.timestamp);
}
/**
* @notice (DEV) Force-adds an identity commitment.
* @dev Callable only by the owner for testing or administration.

243
contracts/contracts/registry/IdentityRegistryImplV1.sol Normal file → Executable file
View File

@@ -8,6 +8,29 @@ import {InternalLeanIMT, LeanIMTData} from "@zk-kit/imt.sol/internal/InternalLea
import {IIdentityRegistryV1} from "../interfaces/IIdentityRegistryV1.sol";
import {IIdentityVerificationHubV1} from "../interfaces/IIdentityVerificationHubV1.sol";
import {ImplRoot} from "../upgradeable/ImplRoot.sol";
import {GCPJWTHelper} from "../libraries/GCPJWTHelper.sol";
import {Formatter} from "../libraries/Formatter.sol";
/**
* @title IGCPJWTVerifier
* @notice Interface for the GCP JWT verifier contract.
*/
interface IGCPJWTVerifier {
function verifyProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals
) external view returns (bool);
}
/**
* @title IPCR0Manager
* @notice Interface for the PCR0 (TEE image hash) manager contract.
*/
interface IPCR0Manager {
function isPCR0Set(bytes calldata pcr0) external view returns (bool);
}
/**
* @notice ⚠️ CRITICAL STORAGE LAYOUT WARNING ⚠️
* =============================================
@@ -76,6 +99,27 @@ abstract contract IdentityRegistryStorageV1 is ImplRoot {
/// @notice Current CSCA root.
uint256 internal _cscaRoot;
/// @notice Previous passport number OFAC root (rolling window).
uint256 internal _prevPassportNoOfacRoot;
/// @notice Previous name and date of birth OFAC root (rolling window).
uint256 internal _prevNameAndDobOfacRoot;
/// @notice Previous name and year of birth OFAC root (rolling window).
uint256 internal _prevNameAndYobOfacRoot;
/// @notice Address of the GCP JWT verifier contract for OFAC proof updates.
address internal _gcpJwtVerifier;
/// @notice Address of the PCR0Manager for OFAC proof updates.
address internal _pcr0Manager;
/// @notice Expected hash of the GCP root CA public key for OFAC proof verification.
uint256 internal _gcpRootCAPubkeyHash;
/// @notice Address of the TEE authorized to call updateOfacRootsWithProof.
address internal _tee;
}
/**
@@ -83,7 +127,7 @@ abstract contract IdentityRegistryStorageV1 is ImplRoot {
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryStorageV1 and implements IIdentityRegistryV1.
*
* @custom:version 1.2.0
* @custom:version 1.3.1
*/
contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV1 {
using InternalLeanIMT for LeanIMTData;
@@ -138,6 +182,16 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
event DevNullifierStateChanged(bytes32 indexed attestationId, uint256 indexed nullifier, bool state);
/// @notice Emitted when the state of a DSC key commitment is changed by dev team.
event DevDscKeyCommitmentStateChanged(uint256 indexed commitment, bool state);
/// @notice Emitted when OFAC roots are updated via proof.
event OfacRootsUpdatedWithProof(bytes32 rootsHash, uint256 timestamp);
/// @notice Emitted when the GCP JWT verifier address is updated.
event GCPJWTVerifierUpdated(address gcpJwtVerifier);
/// @notice Emitted when the PCR0Manager address is updated.
event PCR0ManagerUpdated(address pcr0Manager);
/// @notice Emitted when the GCP root CA pubkey hash is updated.
event GCPRootCAPubkeyHashUpdated(uint256 gcpRootCAPubkeyHash);
/// @notice Emitted when the TEE address is updated.
event TEEUpdated(address tee);
// ====================================================
// Errors
@@ -149,6 +203,22 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
error ONLY_HUB_CAN_ACCESS();
/// @notice Thrown when attempting to register a commitment that has already been registered.
error REGISTERED_COMMITMENT();
/// @notice Thrown when the GCP JWT proof verification fails.
error INVALID_PROOF();
/// @notice Thrown when the GCP root CA public key hash does not match the expected value.
error INVALID_ROOT_CA();
/// @notice Thrown when the TEE image hash is not registered in the PCR0Manager.
error INVALID_IMAGE();
/// @notice Thrown when the timestamp is invalid.
error INVALID_TIMESTAMP();
/// @notice Thrown when the roots hash does not match the proof.
error InvalidRootsHash();
/// @notice Thrown when the wrong number of roots is provided.
error InvalidRootsCount();
/// @notice Thrown when the TEE address is not set.
error TEE_NOT_SET();
/// @notice Thrown when a function is accessed by an address other than the designated TEE.
error ONLY_TEE_CAN_ACCESS();
// ====================================================
// Modifiers
@@ -164,6 +234,16 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
_;
}
/**
* @notice Modifier to restrict access to functions to only the TEE.
* @dev Reverts if the TEE is not set or if the caller is not the TEE.
*/
modifier onlyTEE() {
if (address(_tee) == address(0)) revert TEE_NOT_SET();
if (msg.sender != address(_tee)) revert ONLY_TEE_CAN_ACCESS();
_;
}
// ====================================================
// Constructor
// ====================================================
@@ -204,6 +284,25 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
__ImplRoot_init();
}
/**
* @notice Initializes OFAC proof verification infrastructure.
* @dev Sets GCP JWT verifier, PCR0Manager, and root CA pubkey hash.
* @param gcpJwtVerifier_ The GCP JWT Groth16 verifier address.
* @param pcr0Manager_ The PCR0Manager address for TEE image validation.
* @param gcpRootCAPubkeyHash_ The expected Poseidon hash of the GCP root CA public key.
*/
function initializeOfacProof(
address gcpJwtVerifier_,
address pcr0Manager_,
uint256 gcpRootCAPubkeyHash_,
address teeAddress_
) external onlyProxy onlyRole(SECURITY_ROLE) reinitializer(3) {
_gcpJwtVerifier = gcpJwtVerifier_;
_pcr0Manager = pcr0Manager_;
_gcpRootCAPubkeyHash = gcpRootCAPubkeyHash_;
_tee = teeAddress_;
}
// ====================================================
// External Functions - View & Checks
// ====================================================
@@ -302,6 +401,40 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
return _nameAndYobOfacRoot;
}
/**
* @notice Retrieves the previous passport number OFAC root (rolling window).
* @return The stored previous passport number OFAC root.
*/
function getPrevPassportNoOfacRoot() external view onlyProxy returns (uint256) {
return _prevPassportNoOfacRoot;
}
/**
* @notice Retrieves the previous name and date of birth OFAC root (rolling window).
* @return The stored previous name and date of birth OFAC root.
*/
function getPrevNameAndDobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndDobOfacRoot;
}
/**
* @notice Retrieves the previous name and year of birth OFAC root (rolling window).
* @return The stored previous name and year of birth OFAC root.
*/
function getPrevNameAndYobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndYobOfacRoot;
}
/// @notice Returns the address of the GCP JWT verifier contract.
function getGcpJwtVerifier() external view onlyProxy returns (address) {
return _gcpJwtVerifier;
}
/// @notice Returns the address of the PCR0 Manager contract.
function getPcr0Manager() external view onlyProxy returns (address) {
return _pcr0Manager;
}
/**
* @notice Validates whether the provided OFAC roots match the stored values.
* @param passportNoRoot The passport number OFAC root to validate.
@@ -314,10 +447,14 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
uint256 nameAndDobRoot,
uint256 nameAndYobRoot
) external view onlyProxy returns (bool) {
return
_passportNoOfacRoot == passportNoRoot &&
_nameAndDobOfacRoot == nameAndDobRoot &&
_nameAndYobOfacRoot == nameAndYobRoot;
bool currentMatch = (_passportNoOfacRoot == passportNoRoot) &&
(_nameAndDobOfacRoot == nameAndDobRoot) &&
(_nameAndYobOfacRoot == nameAndYobRoot);
bool prevMatch = (_prevPassportNoOfacRoot != 0) &&
(_prevPassportNoOfacRoot == passportNoRoot) &&
(_prevNameAndDobOfacRoot == nameAndDobRoot) &&
(_prevNameAndYobOfacRoot == nameAndYobRoot);
return currentMatch || prevMatch;
}
/**
@@ -430,6 +567,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @param newPassportNoOfacRoot The new passport number OFAC root value.
*/
function updatePassportNoOfacRoot(uint256 newPassportNoOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevPassportNoOfacRoot = _passportNoOfacRoot;
_passportNoOfacRoot = newPassportNoOfacRoot;
emit PassportNoOfacRootUpdated(newPassportNoOfacRoot);
}
@@ -440,6 +578,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @param newNameAndDobOfacRoot The new name and date of birth OFAC root value.
*/
function updateNameAndDobOfacRoot(uint256 newNameAndDobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = newNameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(newNameAndDobOfacRoot);
}
@@ -450,6 +589,7 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
* @param newNameAndYobOfacRoot The new name and year of birth OFAC root value.
*/
function updateNameAndYobOfacRoot(uint256 newNameAndYobOfacRoot) external onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = newNameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(newNameAndYobOfacRoot);
}
@@ -464,6 +604,99 @@ contract IdentityRegistryImplV1 is IdentityRegistryStorageV1, IIdentityRegistryV
emit CscaRootUpdated(newCscaRoot);
}
/// @notice Updates the GCP JWT verifier contract address.
/// @param verifier The new GCP JWT verifier address.
function updateGCPJWTVerifier(address verifier) external onlyProxy onlyRole(SECURITY_ROLE) {
_gcpJwtVerifier = verifier;
emit GCPJWTVerifierUpdated(verifier);
}
/// @notice Updates the PCR0Manager address.
/// @param newPCR0Manager The new PCR0Manager address.
function updatePCR0Manager(address newPCR0Manager) external onlyProxy onlyRole(SECURITY_ROLE) {
_pcr0Manager = newPCR0Manager;
emit PCR0ManagerUpdated(newPCR0Manager);
}
/// @notice Updates the GCP root CA pubkey hash.
/// @param newHash The new GCP root CA pubkey hash value.
function updateGCPRootCAPubkeyHash(uint256 newHash) external onlyProxy onlyRole(SECURITY_ROLE) {
_gcpRootCAPubkeyHash = newHash;
emit GCPRootCAPubkeyHashUpdated(newHash);
}
/// @notice Updates the TEE address.
/// @param teeAddress The new TEE address.
function updateTEE(address teeAddress) external onlyProxy onlyRole(SECURITY_ROLE) {
_tee = teeAddress;
emit TEEUpdated(teeAddress);
}
/// @notice Retrieves the TEE address.
/// @return The current TEE address.
function tee() external view onlyProxy returns (address) {
return _tee;
}
/// @notice Updates OFAC roots via proof-verified TEE attestation.
/// @dev Verifies the Groth16 proof, validates TEE attestation claims, checks
/// this registry's roots hash against the eat_nonce from the proof. Restricted to the TEE address. The proof provides
/// cryptographic verification, and onlyTEE provides access control.
/// @param pA Groth16 proof element A.
/// @param pB Groth16 proof element B.
/// @param pC Groth16 proof element C.
/// @param pubSignals Circuit public signals [rootCA, eatNonce[0-2], unused, imageHash[0-2], date[0-11]].
/// @param roots This registry's roots: [nameAndDob, nameAndYob, passportNo].
function updateOfacRootsWithProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals,
uint256[] calldata roots
) external onlyProxy onlyTEE {
if (roots.length != 3) revert InvalidRootsCount();
// Verify Groth16 proof
if (!IGCPJWTVerifier(_gcpJwtVerifier).verifyProof(pA, pB, pC, pubSignals)) revert INVALID_PROOF();
// Verify root CA pubkey hash
if (pubSignals[0] != _gcpRootCAPubkeyHash) revert INVALID_ROOT_CA();
// Verify TEE image hash
bytes memory imageHash = GCPJWTHelper.unpackAndConvertImageHash(pubSignals[5], pubSignals[6], pubSignals[7]);
if (!IPCR0Manager(_pcr0Manager).isPCR0Set(imageHash)) revert INVALID_IMAGE();
// Verify timestamp (±1 hour)
uint256 currentTimestamp = Formatter.toTimeStampWithSeconds(
2000 + pubSignals[8] * 10 + pubSignals[9],
pubSignals[10] * 10 + pubSignals[11],
pubSignals[12] * 10 + pubSignals[13],
pubSignals[14] * 10 + pubSignals[15],
pubSignals[16] * 10 + pubSignals[17],
pubSignals[18] * 10 + pubSignals[19]
);
if (currentTimestamp + 1 hours < block.timestamp) revert INVALID_TIMESTAMP();
if (currentTimestamp > block.timestamp + 1 hours) revert INVALID_TIMESTAMP();
// Verify roots hash matches eat_nonce from proof
bytes32 myHash = sha256(abi.encodePacked(roots[0], roots[1], roots[2]));
uint256 rootsHashFromProof = GCPJWTHelper.unpackAndDecodeHexPubkey(pubSignals[1], pubSignals[2], pubSignals[3]);
if (uint256(myHash) != rootsHashFromProof) revert InvalidRootsHash();
// Update this registry's roots with rolling window: [nameAndDob, nameAndYob, passportNo]
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = roots[0];
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = roots[1];
_prevPassportNoOfacRoot = _passportNoOfacRoot;
_passportNoOfacRoot = roots[2];
emit NameAndDobOfacRootUpdated(roots[0]);
emit NameAndYobOfacRootUpdated(roots[1]);
emit PassportNoOfacRootUpdated(roots[2]);
emit OfacRootsUpdatedWithProof(myHash, block.timestamp);
}
/**
* @notice (DEV) Force-adds an identity commitment.
* @dev Callable only by the owner for testing or administration.

View File

@@ -73,6 +73,12 @@ abstract contract IdentityRegistryKycStorageV1 is ImplRoot {
/// @notice The expected hash of the GCP root CA public key for JWT verification.
uint256 internal _gcpRootCAPubkeyHash;
/// @notice Previous name and date of birth OFAC root (rolling window).
uint256 internal _prevNameAndDobOfacRoot;
/// @notice Previous name and year of birth OFAC root (rolling window).
uint256 internal _prevNameAndYobOfacRoot;
}
/**
@@ -113,6 +119,8 @@ interface IPCR0Manager {
* @title IdentityRegistryKycImplV1
* @notice Provides functions to register and manage identity commitments using a Merkle tree structure.
* @dev Inherits from IdentityRegistryKycStorageV1 and implements IIdentityRegistryKycV1.
*
* @custom:version 1.2.1
*/
contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityRegistryKycV1 {
using InternalLeanIMT for LeanIMTData;
@@ -148,6 +156,8 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
);
/// @notice Emitted when a public key commitment is successfully registered.
event PubkeyCommitmentRegistered(uint256 indexed commitment);
/// @notice Emitted when OFAC roots are updated via proof.
event OfacRootsUpdatedWithProof(bytes32 rootsHash, uint256 timestamp);
/// @notice Emitted when a identity commitment is added by dev team.
event DevCommitmentRegistered(
@@ -187,6 +197,10 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
error INVALID_IMAGE();
/// @notice Thrown when the timestamp is invalid.
error INVALID_TIMESTAMP();
/// @notice Thrown when the roots hash does not match the proof.
error InvalidRootsHash();
/// @notice Thrown when the wrong number of roots is provided.
error InvalidRootsCount();
// ====================================================
// Modifiers
@@ -333,6 +347,32 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
return _nameAndYobOfacRoot;
}
/**
* @notice Retrieves the previous name and date of birth OFAC root (rolling window).
* @return The stored previous name and date of birth OFAC root.
*/
function getPrevNameAndDobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndDobOfacRoot;
}
/**
* @notice Retrieves the previous name and year of birth OFAC root (rolling window).
* @return The stored previous name and year of birth OFAC root.
*/
function getPrevNameAndYobOfacRoot() external view onlyProxy returns (uint256) {
return _prevNameAndYobOfacRoot;
}
/// @notice Returns the address of the GCP JWT verifier contract.
function getGcpJwtVerifier() external view onlyProxy returns (address) {
return _gcpJwtVerifier;
}
/// @notice Returns the address of the PCR0 Manager contract.
function getPcr0Manager() external view onlyProxy returns (address) {
return _PCR0Manager;
}
/**
* @notice Checks if the provided OFAC roots match the stored OFAC roots.
* @param nameAndDobRoot The name and date of birth OFAC root to verify.
@@ -340,7 +380,11 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
* @return True if both provided roots match the stored values, false otherwise.
*/
function checkOfacRoots(uint256 nameAndDobRoot, uint256 nameAndYobRoot) external view onlyProxy returns (bool) {
return _nameAndDobOfacRoot == nameAndDobRoot && _nameAndYobOfacRoot == nameAndYobRoot;
bool currentMatch = (_nameAndDobOfacRoot == nameAndDobRoot) && (_nameAndYobOfacRoot == nameAndYobRoot);
bool prevMatch = (_prevNameAndDobOfacRoot != 0) &&
(_prevNameAndDobOfacRoot == nameAndDobRoot) &&
(_prevNameAndYobOfacRoot == nameAndYobRoot);
return currentMatch || prevMatch;
}
/**
@@ -400,6 +444,7 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
* @param nameAndDobOfacRoot The new name and date of birth OFAC root value.
*/
function updateNameAndDobOfacRoot(uint256 nameAndDobOfacRoot) external virtual onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_nameAndDobOfacRoot = nameAndDobOfacRoot;
emit NameAndDobOfacRootUpdated(nameAndDobOfacRoot);
}
@@ -410,6 +455,7 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
* @param nameAndYobOfacRoot The new name and year of birth OFAC root value.
*/
function updateNameAndYobOfacRoot(uint256 nameAndYobOfacRoot) external virtual onlyProxy onlyRole(OPERATIONS_ROLE) {
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndYobOfacRoot = nameAndYobOfacRoot;
emit NameAndYobOfacRootUpdated(nameAndYobOfacRoot);
}
@@ -487,6 +533,62 @@ contract IdentityRegistryKycImplV1 is IdentityRegistryKycStorageV1, IIdentityReg
emit PubkeyCommitmentRegistered(pubkeyCommitment);
}
/// @notice Updates OFAC roots via proof-verified TEE attestation.
/// @dev Verifies the Groth16 proof, validates TEE attestation claims, checks
/// this registry's roots hash against the eat_nonce from the proof. Restricted to the TEE address. The proof provides
/// cryptographic verification, and onlyTEE provides access control.
/// @param pA Groth16 proof element A.
/// @param pB Groth16 proof element B.
/// @param pC Groth16 proof element C.
/// @param pubSignals Circuit public signals [rootCA, eatNonce[0-2], unused, imageHash[0-2], date[0-11]].
/// @param roots This registry's roots: [nameAndDob, nameAndYob].
function updateOfacRootsWithProof(
uint256[2] calldata pA,
uint256[2][2] calldata pB,
uint256[2] calldata pC,
uint256[20] calldata pubSignals,
uint256[] calldata roots
) external onlyProxy onlyTEE {
if (roots.length != 2) revert InvalidRootsCount();
// Verify Groth16 proof
if (!IGCPJWTVerifier(_gcpJwtVerifier).verifyProof(pA, pB, pC, pubSignals)) revert INVALID_PROOF();
// Verify root CA pubkey hash
if (pubSignals[0] != _gcpRootCAPubkeyHash) revert INVALID_ROOT_CA();
// Verify TEE image hash
bytes memory imageHash = GCPJWTHelper.unpackAndConvertImageHash(pubSignals[5], pubSignals[6], pubSignals[7]);
if (!IPCR0Manager(_PCR0Manager).isPCR0Set(imageHash)) revert INVALID_IMAGE();
// Verify timestamp (±1 hour)
uint256 currentTimestamp = Formatter.toTimeStampWithSeconds(
2000 + pubSignals[8] * 10 + pubSignals[9],
pubSignals[10] * 10 + pubSignals[11],
pubSignals[12] * 10 + pubSignals[13],
pubSignals[14] * 10 + pubSignals[15],
pubSignals[16] * 10 + pubSignals[17],
pubSignals[18] * 10 + pubSignals[19]
);
if (currentTimestamp + 1 hours < block.timestamp) revert INVALID_TIMESTAMP();
if (currentTimestamp > block.timestamp + 1 hours) revert INVALID_TIMESTAMP();
// Verify roots hash matches eat_nonce from proof
bytes32 myHash = sha256(abi.encodePacked(roots[0], roots[1]));
uint256 rootsHashFromProof = GCPJWTHelper.unpackAndDecodeHexPubkey(pubSignals[1], pubSignals[2], pubSignals[3]);
if (uint256(myHash) != rootsHashFromProof) revert InvalidRootsHash();
// Update this registry's roots: [nameAndDob, nameAndYob]
_prevNameAndDobOfacRoot = _nameAndDobOfacRoot;
_prevNameAndYobOfacRoot = _nameAndYobOfacRoot;
_nameAndDobOfacRoot = roots[0];
_nameAndYobOfacRoot = roots[1];
emit NameAndDobOfacRootUpdated(roots[0]);
emit NameAndYobOfacRootUpdated(roots[1]);
emit OfacRootsUpdatedWithProof(myHash, block.timestamp);
}
/// @notice (DEV) Force-adds an identity commitment.
/// @dev Callable only by the owner for testing or administration.
/// @param nullifier The nullifier associated with the identity commitment.

View File

@@ -1,6 +1,6 @@
{
"$schema": "./registry.schema.json",
"lastUpdated": "2026-02-09T11:26:31.105Z",
"lastUpdated": "2026-03-02T10:19:46.004Z",
"contracts": {
"IdentityVerificationHub": {
"source": "IdentityVerificationHubImplV2",
@@ -96,6 +96,30 @@
"proxy": "0x16ECBA51e18a4a7e61fdC417f0d47AFEeDfbed74",
"currentVersion": "2.13.0",
"currentImpl": "0x244c93516Abd58E1952452d3D8C4Ce7D454776B8"
},
"IdentityRegistry": {
"proxy": "0x1651ec77c3dC5997eC05f3EE6C2B0b904b516d1d",
"currentVersion": "1.2.0",
"currentImpl": "0x873b1289b69C452Fd8349DbAfc748183eB5314ec"
},
"IdentityRegistryIdCard": {
"proxy": "0x6B39222c3b98003010695cE0A31C9b1a61e07DdC",
"currentVersion": "1.2.0",
"currentImpl": "0xF4781c7e801D1E49aa3A95537FaEF7718f4499Cd"
},
"IdentityRegistryAadhaar": {
"proxy": "0x9cbB71468f93672DBF50f511c038eAF9fAB04732",
"currentVersion": "1.2.0",
"currentImpl": "0x74A2848D945eCffeE325dAbc9E0b72c118fAD327"
},
"IdentityRegistryKyc": {
"proxy": "0x90e907E4AaB6e9bcFB94997Af4A097e8CAadBdf3",
"currentVersion": "1.2.0",
"currentImpl": "0x6E2889Bc9baa6F53bDdf4843675155811F0AAAEd"
},
"PCR0Manager": {
"address": "0xf2810D5E9938816D42F0Ae69D33F013a23C0aED2",
"currentVersion": "1.2.0"
}
}
},
@@ -152,20 +176,6 @@
}
}
},
"2.13.0": {
"initializerVersion": 12,
"initializerFunction": "",
"changelog": "Upgrade to v2.13.0",
"gitTag": "identityverificationhub-v2.13.0",
"deployments": {
"celo-sepolia": {
"impl": "0x244c93516Abd58E1952452d3D8C4Ce7D454776B8",
"deployedAt": "2026-02-02T14:47:21.882Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": "33bca485"
}
}
},
"2.13.0": {
"initializerVersion": 12,
"initializerFunction": "",
@@ -193,6 +203,12 @@
"deployedAt": "2025-12-10T05:53:12.534Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
},
"celo-sepolia": {
"impl": "0x873b1289b69C452Fd8349DbAfc748183eB5314ec",
"deployedAt": "2025-09-16T04:37:22.000Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": ""
}
}
},
@@ -223,6 +239,12 @@
"deployedAt": "2025-12-10T05:45:56.772Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
},
"celo-sepolia": {
"impl": "0xF4781c7e801D1E49aa3A95537FaEF7718f4499Cd",
"deployedAt": "2025-09-16T04:38:58.000Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": ""
}
}
},
@@ -253,6 +275,12 @@
"deployedAt": "2025-12-10T05:47:22.844Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
},
"celo-sepolia": {
"impl": "0x74A2848D945eCffeE325dAbc9E0b72c118fAD327",
"deployedAt": "2025-09-16T04:41:01.000Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": ""
}
}
},
@@ -283,6 +311,12 @@
"deployedAt": "2025-12-10T06:17:50.863Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": "5787cff3bcbea870b50eccd7164fbd45b758568e"
},
"celo-sepolia": {
"impl": "0xf2810D5E9938816D42F0Ae69D33F013a23C0aED2",
"deployedAt": "2026-02-01T08:26:56.000Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": ""
}
}
}
@@ -299,6 +333,40 @@
"deployedAt": "2026-02-09T00:00:00.000Z",
"deployedBy": "",
"gitCommit": "03876a86284b0ed794fbff7aae142e62a3212624"
},
"celo-sepolia": {
"impl": "0x94f6DE38E10140B9E3963a770B5B769b38459a3B",
"deployedAt": "2026-02-01T08:27:21.000Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": ""
}
}
},
"1.1.0": {
"initializerVersion": 1,
"initializerFunction": "",
"changelog": "Add TEE-attested OFAC root updates via updateOfacRootsWithProof",
"gitTag": "identityregistrykyc-v1.1.0",
"deployments": {
"celo-sepolia": {
"impl": "0x530eEA7E5b286108926B05510491560c4bAE018e",
"deployedAt": "2026-03-02T05:00:49.070Z",
"deployedBy": "0xC1C860804EFdA544fe79194d1a37e60b846CEdeb",
"gitCommit": ""
}
}
},
"1.2.0": {
"initializerVersion": 1,
"initializerFunction": "",
"changelog": "Add rolling OFAC root window: store previous roots alongside current, accept either in checkOfacRoots for graceful mid-verification transitions",
"gitTag": "identityregistrykyc-v1.2.0",
"deployments": {
"celo-sepolia": {
"impl": "0x6E2889Bc9baa6F53bDdf4843675155811F0AAAEd",
"deployedAt": "2026-03-02T10:19:45.990Z",
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
"gitCommit": "611c30d21"
}
}
}

View File

@@ -4,21 +4,28 @@
"signature": "REGISTERED_COMMITMENT()",
"selector": "0x034acfcc",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 134
"line": 187
},
{
"name": "REGISTERED_COMMITMENT",
"signature": "REGISTERED_COMMITMENT()",
"selector": "0x034acfcc",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 142
"line": 195
},
{
"name": "REGISTERED_COMMITMENT",
"signature": "REGISTERED_COMMITMENT()",
"selector": "0x034acfcc",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 149
"line": 205
},
{
"name": "REGISTERED_COMMITMENT",
"signature": "REGISTERED_COMMITMENT()",
"selector": "0x034acfcc",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 189
},
{
"name": "InvalidProof",
@@ -27,19 +34,103 @@
"file": "contracts/example/Airdrop.sol",
"line": 57
},
{
"name": "InvalidProof",
"signature": "InvalidProof()",
"selector": "0x09bde339",
"file": "contracts/tests/TestAirdrop.sol",
"line": 35
},
{
"name": "InvalidPubSignalsLength",
"signature": "InvalidPubSignalsLength(uint256,uint256)",
"selector": "0x0b42b970",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 44
},
{
"name": "NoVerifierSet",
"signature": "NoVerifierSet()",
"selector": "0x0ee78d58",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 139
"line": 151
},
{
"name": "NoVerifierSet",
"signature": "NoVerifierSet()",
"selector": "0x0ee78d58",
"file": "contracts/libraries/DscProofVerifierLib.sol",
"line": 17
},
{
"name": "NoVerifierSet",
"signature": "NoVerifierSet()",
"selector": "0x0ee78d58",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 23
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 197
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 203
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 213
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 199
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 201
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 207
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 217
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 203
},
{
"name": "InvalidAttestationId",
"signature": "InvalidAttestationId()",
"selector": "0x12ec75fe",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 171
"line": 183
},
{
"name": "InvalidAttestationId",
@@ -48,6 +139,41 @@
"file": "contracts/libraries/CustomVerifier.sol",
"line": 10
},
{
"name": "InvalidAttestationId",
"signature": "InvalidAttestationId()",
"selector": "0x12ec75fe",
"file": "contracts/libraries/DscProofVerifierLib.sol",
"line": 26
},
{
"name": "InvalidAttestationId",
"signature": "InvalidAttestationId()",
"selector": "0x12ec75fe",
"file": "contracts/libraries/OfacCheckLib.sol",
"line": 22
},
{
"name": "InvalidAttestationId",
"signature": "InvalidAttestationId()",
"selector": "0x12ec75fe",
"file": "contracts/libraries/ProofVerifierLib.sol",
"line": 21
},
{
"name": "InvalidAttestationId",
"signature": "InvalidAttestationId()",
"selector": "0x12ec75fe",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 32
},
{
"name": "InvalidAttestationId",
"signature": "InvalidAttestationId()",
"selector": "0x12ec75fe",
"file": "contracts/libraries/RootCheckLib.sol",
"line": 22
},
{
"name": "RegistrationNotOpen",
"signature": "RegistrationNotOpen()",
@@ -55,40 +181,61 @@
"file": "contracts/example/Airdrop.sol",
"line": 66
},
{
"name": "RegistrationNotOpen",
"signature": "RegistrationNotOpen()",
"selector": "0x153745d3",
"file": "contracts/tests/TestAirdrop.sol",
"line": 38
},
{
"name": "InvalidDscProof",
"signature": "InvalidDscProof()",
"selector": "0x1644e049",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 151
"line": 163
},
{
"name": "InvalidDscProof",
"signature": "InvalidDscProof()",
"selector": "0x1644e049",
"file": "contracts/libraries/DscProofVerifierLib.sol",
"line": 20
},
{
"name": "InvalidYearRange",
"signature": "InvalidYearRange()",
"selector": "0x16f40c94",
"file": "contracts/libraries/Formatter.sol",
"line": 10
"line": 12
},
{
"name": "InvalidDateDigit",
"signature": "InvalidDateDigit()",
"selector": "0x17af8154",
"file": "contracts/libraries/Formatter.sol",
"line": 14
"line": 16
},
{
"name": "INVALID_OFAC_ROOT",
"signature": "INVALID_OFAC_ROOT()",
"selector": "0x1ce3d3ca",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 166
"line": 176
},
{
"name": "HUB_ADDRESS_ZERO",
"signature": "HUB_ADDRESS_ZERO()",
"selector": "0x22697ffa",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 136
"line": 189
},
{
"name": "HUB_ADDRESS_ZERO",
"signature": "HUB_ADDRESS_ZERO()",
"selector": "0x22697ffa",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 191
},
{
"name": "RegisteredNullifier",
@@ -97,12 +244,47 @@
"file": "contracts/example/Airdrop.sol",
"line": 81
},
{
"name": "RegisteredNullifier",
"signature": "RegisteredNullifier()",
"selector": "0x22cbc6a2",
"file": "contracts/tests/TestAirdrop.sol",
"line": 43
},
{
"name": "InvalidMonthRange",
"signature": "InvalidMonthRange()",
"selector": "0x25e62788",
"file": "contracts/libraries/Formatter.sol",
"line": 11
"line": 13
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 205
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 211
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 221
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 187
},
{
"name": "UserIdentifierAlreadyRegistered",
@@ -111,19 +293,61 @@
"file": "contracts/example/Airdrop.sol",
"line": 78
},
{
"name": "UserIdentifierAlreadyRegistered",
"signature": "UserIdentifierAlreadyRegistered()",
"selector": "0x29393238",
"file": "contracts/tests/TestAirdrop.sol",
"line": 42
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 199
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 205
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 215
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 201
},
{
"name": "InvalidFieldElement",
"signature": "InvalidFieldElement()",
"selector": "0x3ae4ed6b",
"file": "contracts/libraries/Formatter.sol",
"line": 13
"line": 15
},
{
"name": "InvalidPubkey",
"signature": "InvalidPubkey()",
"selector": "0x422cc3b7",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 199
"line": 211
},
{
"name": "InvalidPubkey",
"signature": "InvalidPubkey()",
"selector": "0x422cc3b7",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 35
},
{
"name": "InvalidOlderThan",
@@ -137,42 +361,63 @@
"signature": "InvalidDscCommitmentRoot()",
"selector": "0x4cb305bb",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 163
"line": 175
},
{
"name": "InvalidDscCommitmentRoot",
"signature": "InvalidDscCommitmentRoot()",
"selector": "0x4cb305bb",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 29
},
{
"name": "HUB_NOT_SET",
"signature": "HUB_NOT_SET()",
"selector": "0x4ffa9998",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 130
"line": 183
},
{
"name": "HUB_NOT_SET",
"signature": "HUB_NOT_SET()",
"selector": "0x4ffa9998",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 138
"line": 191
},
{
"name": "HUB_NOT_SET",
"signature": "HUB_NOT_SET()",
"selector": "0x4ffa9998",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 145
"line": 201
},
{
"name": "HUB_NOT_SET",
"signature": "HUB_NOT_SET()",
"selector": "0x4ffa9998",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 181
},
{
"name": "INVALID_COMMITMENT_ROOT",
"signature": "INVALID_COMMITMENT_ROOT()",
"selector": "0x52906601",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 162
"line": 172
},
{
"name": "UnauthorizedCaller",
"signature": "UnauthorizedCaller()",
"selector": "0x5c427cd9",
"file": "contracts/abstract/SelfVerificationRoot.sol",
"line": 46
"line": 49
},
{
"name": "UnauthorizedCaller",
"signature": "UnauthorizedCaller()",
"selector": "0x5c427cd9",
"file": "contracts/abstract/SelfVerificationRootUpgradeable.sol",
"line": 69
},
{
"name": "UserIdentifierAlreadyMinted",
@@ -200,7 +445,7 @@
"signature": "CrossChainIsNotSupportedYet()",
"selector": "0x61296fbb",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 179
"line": 191
},
{
"name": "AlreadyClaimed",
@@ -216,19 +461,33 @@
"file": "contracts/example/HappyBirthday.sol",
"line": 67
},
{
"name": "AlreadyClaimed",
"signature": "AlreadyClaimed()",
"selector": "0x646cf558",
"file": "contracts/tests/TestAirdrop.sol",
"line": 36
},
{
"name": "InputTooShort",
"signature": "InputTooShort()",
"selector": "0x65ec0cf1",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 183
"line": 195
},
{
"name": "InvalidRegisterProof",
"signature": "InvalidRegisterProof()",
"selector": "0x67b61dc7",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 147
"line": 159
},
{
"name": "InvalidRegisterProof",
"signature": "InvalidRegisterProof()",
"selector": "0x67b61dc7",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 26
},
{
"name": "RegistrationNotClosed",
@@ -237,12 +496,19 @@
"file": "contracts/example/Airdrop.sol",
"line": 69
},
{
"name": "RegistrationNotClosed",
"signature": "RegistrationNotClosed()",
"selector": "0x697e379b",
"file": "contracts/tests/TestAirdrop.sol",
"line": 39
},
{
"name": "INVALID_DSC_PROOF",
"signature": "INVALID_DSC_PROOF()",
"selector": "0x6a86dd76",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 154
"line": 164
},
{
"name": "ClaimNotOpen",
@@ -251,19 +517,89 @@
"file": "contracts/example/Airdrop.sol",
"line": 72
},
{
"name": "ClaimNotOpen",
"signature": "ClaimNotOpen()",
"selector": "0x6b687806",
"file": "contracts/tests/TestAirdrop.sol",
"line": 40
},
{
"name": "InvalidUidaiTimestamp",
"signature": "InvalidUidaiTimestamp(uint256,uint256)",
"selector": "0x6f26ab8d",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 203
"line": 215
},
{
"name": "InvalidUidaiTimestamp",
"signature": "InvalidUidaiTimestamp(uint256,uint256)",
"selector": "0x6f26ab8d",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 38
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 191
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 197
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 207
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 193
},
{
"name": "INVALID_OFAC",
"signature": "INVALID_OFAC()",
"selector": "0x71b125ed",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 146
"line": 156
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 195
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 201
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 211
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 197
},
{
"name": "InvalidForbiddenCountries",
@@ -284,7 +620,7 @@
"signature": "InsufficientCharcodeLen()",
"selector": "0x86d41225",
"file": "contracts/libraries/CircuitAttributeHandlerV2.sol",
"line": 16
"line": 17
},
{
"name": "InsufficientCharcodeLen",
@@ -298,42 +634,49 @@
"signature": "InvalidDayRange()",
"selector": "0x8930acef",
"file": "contracts/libraries/Formatter.sol",
"line": 12
"line": 14
},
{
"name": "LENGTH_MISMATCH",
"signature": "LENGTH_MISMATCH()",
"selector": "0x899ef10d",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 126
"line": 136
},
{
"name": "NO_VERIFIER_SET",
"signature": "NO_VERIFIER_SET()",
"selector": "0x8e727f46",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 130
"line": 140
},
{
"name": "InvalidCscaRoot",
"signature": "InvalidCscaRoot()",
"selector": "0x8f1b44c7",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 167
"line": 179
},
{
"name": "InvalidCscaRoot",
"signature": "InvalidCscaRoot()",
"selector": "0x8f1b44c7",
"file": "contracts/libraries/DscProofVerifierLib.sol",
"line": 23
},
{
"name": "INVALID_REGISTER_PROOF",
"signature": "INVALID_REGISTER_PROOF()",
"selector": "0x9003ac4d",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 150
"line": 160
},
{
"name": "UserContextDataTooShort",
"signature": "UserContextDataTooShort()",
"selector": "0x94ec3503",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 187
"line": 199
},
{
"name": "NotWithinBirthdayWindow",
@@ -347,56 +690,70 @@
"signature": "INVALID_CSCA_ROOT()",
"selector": "0xa294ad3c",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 170
"line": 180
},
{
"name": "InvalidDataFormat",
"signature": "InvalidDataFormat()",
"selector": "0xa512e2ff",
"file": "contracts/abstract/SelfVerificationRoot.sol",
"line": 42
"line": 45
},
{
"name": "InvalidDataFormat",
"signature": "InvalidDataFormat()",
"selector": "0xa512e2ff",
"file": "contracts/abstract/SelfVerificationRootUpgradeable.sol",
"line": 65
},
{
"name": "ConfigNotSet",
"signature": "ConfigNotSet()",
"selector": "0xace124bc",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 195
"line": 207
},
{
"name": "InvalidDateLength",
"signature": "InvalidDateLength()",
"selector": "0xb3375953",
"file": "contracts/libraries/Formatter.sol",
"line": 9
"line": 11
},
{
"name": "ONLY_HUB_CAN_ACCESS",
"signature": "ONLY_HUB_CAN_ACCESS()",
"selector": "0xba0318cb",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 132
"line": 185
},
{
"name": "ONLY_HUB_CAN_ACCESS",
"signature": "ONLY_HUB_CAN_ACCESS()",
"selector": "0xba0318cb",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 140
"line": 193
},
{
"name": "ONLY_HUB_CAN_ACCESS",
"signature": "ONLY_HUB_CAN_ACCESS()",
"selector": "0xba0318cb",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 147
"line": 203
},
{
"name": "ONLY_HUB_CAN_ACCESS",
"signature": "ONLY_HUB_CAN_ACCESS()",
"selector": "0xba0318cb",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 183
},
{
"name": "INVALID_FORBIDDEN_COUNTRIES",
"signature": "INVALID_FORBIDDEN_COUNTRIES()",
"selector": "0xbf21b11c",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 142
"line": 152
},
{
"name": "NotRegistered",
@@ -405,68 +762,138 @@
"file": "contracts/example/Airdrop.sol",
"line": 63
},
{
"name": "NotRegistered",
"signature": "NotRegistered(address)",
"selector": "0xbfc6c337",
"file": "contracts/tests/TestAirdrop.sol",
"line": 37
},
{
"name": "InvalidOfacRoots",
"signature": "InvalidOfacRoots()",
"selector": "0xc67a44d2",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 211
"line": 223
},
{
"name": "InvalidOfacRoots",
"signature": "InvalidOfacRoots()",
"selector": "0xc67a44d2",
"file": "contracts/libraries/OfacCheckLib.sol",
"line": 19
},
{
"name": "CurrentDateNotInValidRange",
"signature": "CurrentDateNotInValidRange()",
"selector": "0xcf46551c",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 143
"line": 155
},
{
"name": "INVALID_VC_AND_DISCLOSE_PROOF",
"signature": "INVALID_VC_AND_DISCLOSE_PROOF()",
"selector": "0xd4d37a7a",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 158
"line": 168
},
{
"name": "AttestationIdMismatch",
"signature": "AttestationIdMismatch()",
"selector": "0xd7ca437d",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 207
"line": 219
},
{
"name": "InvalidVcAndDiscloseProof",
"signature": "InvalidVcAndDiscloseProof()",
"selector": "0xda7bd3a6",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 155
"line": 167
},
{
"name": "InvalidVcAndDiscloseProof",
"signature": "InvalidVcAndDiscloseProof()",
"selector": "0xda7bd3a6",
"file": "contracts/libraries/ProofVerifierLib.sol",
"line": 18
},
{
"name": "RegistryNotSet",
"signature": "RegistryNotSet()",
"selector": "0xe048e710",
"file": "contracts/libraries/RootCheckLib.sol",
"line": 25
},
{
"name": "INVALID_REVEALED_DATA_TYPE",
"signature": "INVALID_REVEALED_DATA_TYPE()",
"selector": "0xe0f15544",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 174
"line": 184
},
{
"name": "ScopeMismatch",
"signature": "ScopeMismatch()",
"selector": "0xe7bee380",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 175
"line": 187
},
{
"name": "InvalidUserIdentifierInProof",
"signature": "InvalidUserIdentifierInProof()",
"selector": "0xebbcc178",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 191
"line": 203
},
{
"name": "InvalidPubkeyCommitment",
"signature": "InvalidPubkeyCommitment()",
"selector": "0xebc2fedc",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 227
},
{
"name": "InvalidPubkeyCommitment",
"signature": "InvalidPubkeyCommitment()",
"selector": "0xebc2fedc",
"file": "contracts/libraries/RegisterProofVerifierLib.sol",
"line": 41
},
{
"name": "CURRENT_DATE_NOT_IN_VALID_RANGE",
"signature": "CURRENT_DATE_NOT_IN_VALID_RANGE()",
"selector": "0xed8cf9ff",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 134
"line": 144
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 193
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 199
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 209
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 195
},
{
"name": "InvalidUserIdentifier",
@@ -489,25 +916,67 @@
"file": "contracts/example/SelfPassportERC721.sol",
"line": 49
},
{
"name": "InvalidUserIdentifier",
"signature": "InvalidUserIdentifier()",
"selector": "0xf0c426db",
"file": "contracts/tests/TestAirdrop.sol",
"line": 41
},
{
"name": "INVALID_OLDER_THAN",
"signature": "INVALID_OLDER_THAN()",
"selector": "0xf0e539b9",
"file": "contracts/IdentityVerificationHubImplV1.sol",
"line": 138
"line": 148
},
{
"name": "InvalidIdentityCommitmentRoot",
"signature": "InvalidIdentityCommitmentRoot()",
"selector": "0xf53393a7",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 159
"line": 171
},
{
"name": "InvalidIdentityCommitmentRoot",
"signature": "InvalidIdentityCommitmentRoot()",
"selector": "0xf53393a7",
"file": "contracts/libraries/RootCheckLib.sol",
"line": 19
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/registry/IdentityRegistryAadhaarImplV1.sol",
"line": 203
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/registry/IdentityRegistryIdCardImplV1.sol",
"line": 209
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/registry/IdentityRegistryImplV1.sol",
"line": 219
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/registry/IdentityRegistryKycImplV1.sol",
"line": 185
},
{
"name": "LengthMismatch",
"signature": "LengthMismatch()",
"selector": "0xff633a38",
"file": "contracts/IdentityVerificationHubImplV2.sol",
"line": 135
"line": 147
}
]

View File

@@ -90,8 +90,8 @@
"DeployIdCardRegistryModule#IdentityRegistryIdCardImplV1": "0xF4781c7e801D1E49aa3A95537FaEF7718f4499Cd",
"DeployIdCardRegistryModule#IdentityRegistry": "0x6B39222c3b98003010695cE0A31C9b1a61e07DdC",
"DeployAadhaarRegistryModule#PoseidonT3": "0xB80d454C2BF6c886EfA51Af830F43ac3147dCE15",
"DeployAadhaarRegistryModule#IdentityRegistryAadhaarImplV1": "0x74A2848D945eCffeE325dAbc9E0b72c118fAD327",
"DeployAadhaarRegistryModule#IdentityRegistry": "0x9cbB71468f93672DBF50f511c038eAF9fAB04732",
"DeployAadhaarRegistryModule#IdentityRegistryAadhaarImplV1": "0x2b8b23d88e29534dfd61eb64d178b0e77681f760",
"DeployAadhaarRegistryModule#IdentityRegistry": "0x52A18C19a35Ac6Ef1673B8Fa5b7BD01dBfA2D389",
"DeployHubV2#CustomVerifier": "0x3C154D1Bb35589e82B13892dE5283ADAfaDC473f",
"DeployHubV2#IdentityVerificationHubImplV2": "0xC49b7FD44Cb4bE0482AEa3335Eb2CeFb1b81B0C9",
"DeployHubV2#IdentityVerificationHub": "0x16ECBA51e18a4a7e61fdC417f0d47AFEeDfbed74",
@@ -100,10 +100,22 @@
"DeployNewHubAndUpgradee#IdentityVerificationHubImplV2": "0x48985ec4f71cBC8f387c5C77143110018560c7eD",
"DeployKycRegistryModule#PCR0Manager": "0xf2810D5E9938816D42F0Ae69D33F013a23C0aED2",
"DeployKycRegistryModule#PoseidonT3": "0x163983BAe19dE94A007C6C502b7389F6C359C818",
"DeployKycRegistryModule#Verifier_gcp_jwt": "0x13ee8CEa15a262D81a245b37889F7b4bEd015f4c",
"DeployKycRegistryModule#Verifier_gcp_jwt": "0xAF73bE5cf1E826Df56292D9FD41D37CaBfb59344",
"DeployKycRegistryModule#IdentityRegistryKycImplV1": "0x94f6DE38E10140B9E3963a770B5B769b38459a3B",
"DeployKycRegistryModule#IdentityRegistry": "0x90e907E4AaB6e9bcFB94997Af4A097e8CAadBdf3",
"UpdateAllRegistries#PCR0Manager": "0xf2810D5E9938816D42F0Ae69D33F013a23C0aED2",
"UpdateAllRegistries#PCR0Manager": "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
"DeployAllVerifiers#Verifier_vc_and_disclose_kyc": "0xAAFA189a079D04462e8ab596d9c103e081A1c810",
"UpgradeKycRegistryModule#KycRegistryProxy": "0x90e907E4AaB6e9bcFB94997Af4A097e8CAadBdf3",
"UpgradeKycRegistryModule#PoseidonT3": "0x0728806F5b99e6527847fF29F55A1ae4778D1498",
"UpgradeKycRegistryModule#IdentityRegistryKycImplV1": "0x72cB0be300e222a02C2c7aF2bb289667c8Cf2bdd",
"UpdateAllRegistries#a3": "0x90e907E4AaB6e9bcFB94997Af4A097e8CAadBdf3",
"DeployAllVerifiers#Verifier_vc_and_disclose_kyc": "0xAAFA189a079D04462e8ab596d9c103e081A1c810"
"UpgradeAadhaarRegistryModule#AadhaarRegistryProxy": "0x52A18C19a35Ac6Ef1673B8Fa5b7BD01dBfA2D389",
"UpgradeAadhaarRegistryModule#PoseidonT3": "0x424Ada67de0DBEFC1Bb6dEfeACF64E3Ae81B2411",
"UpgradeAadhaarRegistryModule#IdentityRegistryAadhaarImplV1": "0xf66fdaC87159d0362b1E26CD7739C92f4AB4f9b4",
"UpgradeIdCardRegistryModule#IdCardRegistryProxy": "0x6B39222c3b98003010695cE0A31C9b1a61e07DdC",
"UpgradeIdCardRegistryModule#PoseidonT3": "0xFd6B7b646B91298ca106c390F9f1b61D92C694F7",
"UpgradeIdCardRegistryModule#IdentityRegistryIdCardImplV1": "0x620358cb15773400F4d84217606172540f425156",
"UpgradeRegistryModule#RegistryProxy": "0x1651ec77c3dC5997eC05f3EE6C2B0b904b516d1d",
"UpgradeRegistryModule#PoseidonT3": "0x873B8F582A6EE01c4741349c99f69051B8eF1315",
"UpgradeRegistryModule#IdentityRegistryImplV1": "0x663D0C2548F14faF440FdF8118FCc811657B5A9C"
}

View File

@@ -99,6 +99,14 @@
"DeployAllVerifiers#Verifier_register_kyc": "0xbc15010D9748A5e7c0B947D0c0aCb31bD57a0626",
"DeployAllVerifiers#Verifier_vc_and_disclose_kyc": "0xdB0454156bBa5e5b9CA97be350eCc178ddE20b0f",
"UpgradeKycRegistryModule#KycRegistryProxy": "0x238f83c641020Ef9636694A846019FD2a105C4f0",
"UpgradeKycRegistryModule#PoseidonT3": "0xAD1b73963c7386bF83af187De03136a58111A637",
"UpgradeKycRegistryModule#IdentityRegistryKycImplV1": "0x71b820E2D71a9dba77dC00291873cba257ac66be"
"UpgradeKycRegistryModule#PoseidonT3": "0x7460578F2012e8a97e56F21D1D9EDd8DC5DcCB10",
"UpgradeKycRegistryModule#IdentityRegistryKycImplV1": "0xa3140DbC877A0Ac5CD8d0Eb9DB9930d62bF28c2c",
"UpgradeAadhaarRegistryModule#PoseidonT3": "0x21B81C63A66B08BfcAf53Cb2D66e0Ae8886D9ACd",
"UpgradeAadhaarRegistryModule#IdentityRegistryAadhaarImplV1": "0x06bB9e39bc89fa98cD3523e4f33d2f62d6Cb4991",
"DeployRegistryModule#IdentityRegistry": "0x37F5CB8cB1f6B00aa768D8aA99F1A9289802A968",
"DeployIdCardRegistryModule#IdentityRegistry": "0xeAD1E6Ec29c1f3D33a0662f253a3a94D189566E1",
"UpgradeRegistryModule#PoseidonT3": "0xA02587616062cD1808BAbB8840376420ED5e56eB",
"UpgradeRegistryModule#IdentityRegistryImplV1": "0x9bfBCe58f02e7c4501d51c6b1FB41b1C1ccc2e6C",
"UpgradeIdCardRegistryModule#PoseidonT3": "0x3113773f77981e198161C8e561d32Ca99AA42C6D",
"UpgradeIdCardRegistryModule#IdentityRegistryIdCardImplV1": "0xD192CD1780Dd0ceCedd626303d9F28b40949eaa3"
}

View File

@@ -1,51 +1,42 @@
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import { artifacts } from "hardhat";
import { ethers } from "ethers";
// import { artifacts } from "hardhat";
// import { ethers } from "ethers";
export default buildModule("DeployKycRegistryModule", (m) => {
// Deploy PoseidonT3
console.log("📚 Deploying PoseidonT3 library...");
const poseidonT3 = m.library("PoseidonT3");
// // Deploy PoseidonT3
// console.log("📚 Deploying PoseidonT3 library...");
// const poseidonT3 = m.library("PoseidonT3");
console.log("🏗️ Deploying IdentityRegistryKycImplV1 implementation...");
// Deploy IdentityRegistryImplV1
const identityRegistryKycImpl = m.contract("IdentityRegistryKycImplV1", [], {
libraries: { PoseidonT3: poseidonT3 },
});
// console.log("🏗️ Deploying IdentityRegistryKycImplV1 implementation...");
// // Deploy IdentityRegistryImplV1
// const identityRegistryKycImpl = m.contract("IdentityRegistryKycImplV1", [], {
// libraries: { PoseidonT3: poseidonT3 },
// });
console.log("⚙️ Preparing registry initialization data...");
// Get the interface and encode the initialize function call
const registryInterface = getRegistryInitializeData();
// console.log("⚙️ Preparing registry initialization data...");
// // Get the interface and encode the initialize function call
// const registryInterface = getRegistryInitializeData();
const registryInitData = registryInterface.encodeFunctionData("initialize", [ethers.ZeroAddress, ethers.ZeroAddress]);
console.log(" Init data:", registryInitData);
// const registryInitData = registryInterface.encodeFunctionData("initialize", [ethers.ZeroAddress, ethers.ZeroAddress]);
// console.log(" Init data:", registryInitData);
console.log("🚀 Deploying IdentityRegistry proxy...");
// Deploy the proxy contract with the implementation address and initialization data
const registry = m.contract("IdentityRegistry", [identityRegistryKycImpl, registryInitData]);
// console.log("🚀 Deploying IdentityRegistry proxy...");
// // Deploy the proxy contract with the implementation address and initialization data
// const registry = m.contract("IdentityRegistry", [identityRegistryKycImpl, registryInitData]);
// Redeploy verifier — circuit changed due to new trusted setup contributions
const gcpKycVerifier = m.contract("Verifier_gcp_jwt", []);
// PCR0Manager not deployed - using existing mainnet PCR0Manager at 0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717
// const pcr0Manager = m.contract("PCR0Manager", []);
console.log("✅ Registry deployment module setup complete!");
console.log(" 📋 Summary:");
console.log(" - PoseidonT3: Library");
console.log(" - IdentityRegistryKycImplV1: Implementation contract");
console.log(" - IdentityRegistry: Proxy contract");
console.log(" - Verifier_gcp_jwt: GCP JWT verifier contract");
return {
poseidonT3,
identityRegistryKycImpl,
registry,
gcpKycVerifier,
};
});
function getRegistryInitializeData() {
const registryArtifact = artifacts.readArtifactSync("IdentityRegistryKycImplV1");
const registryInterface = new ethers.Interface(registryArtifact.abi);
return registryInterface;
}
// function getRegistryInitializeData() {
// const registryArtifact = artifacts.readArtifactSync("IdentityRegistryKycImplV1");
// const registryInterface = new ethers.Interface(registryArtifact.abi);
// return registryInterface;
// }

View File

@@ -34,13 +34,14 @@ const registries = {
// },
"DeployKycRegistryModule#IdentityRegistry": {
shouldChange: true,
hub: "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
nameAndDobOfac: "12056959379782485690824392224737824782985009863971097094085968061978428696483",
nameAndYobOfac: "14482015433179009576094845155298164108788397224633034095648782513909282765564",
onlyTEEAddress: "0xe6b2856a51a17bd4edeb88b3f74370d64475b0fc",
gcpJWTVerifier: "0x87785cC7E9Bc70f87E6F454235214bDEc853C044",
pcr0Manager: "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
gcpRootCAPubkeyHash: "14165687497759817957828709957846495993787741657460065475757428560999622217191",
// hub: "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
// nameAndDobOfac: "12056959379782485690824392224737824782985009863971097094085968061978428696483",
// nameAndYobOfac: "14482015433179009576094845155298164108788397224633034095648782513909282765564",
// onlyTEEAddress: "0xe6b2856a51a17bd4edeb88b3f74370d64475b0fc",
// gcpJWTVerifier: "0xAF73bE5cf1E826Df56292D9FD41D37CaBfb59344",
imageDigest: "0x2a18971dae026c46b74661fbbb4a88a0da96899db78aca7b138e52ebda396f74",
pcr0Manager: "0xf2810D5E9938816D42F0Ae69D33F013a23C0aED2",
// gcpRootCAPubkeyHash: "14165687497759817957828709957846495993787741657460065475757428560999622217191",
},
};

View File

@@ -0,0 +1,36 @@
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import hre from "hardhat";
import { readFileSync } from "fs";
import path from "path";
export default buildModule("UpgradeAadhaarRegistryModule", (m) => {
const networkName = hre.network.config.chainId;
const deployedAddressesPath = path.join(__dirname, `../../deployments/chain-${networkName}/deployed_addresses.json`);
const deployedAddresses = JSON.parse(readFileSync(deployedAddressesPath, "utf8"));
const aadhaarProxyAddress = deployedAddresses["DeployAadhaarRegistryModule#IdentityRegistry"];
if (!aadhaarProxyAddress) {
throw new Error("Aadhaar Registry proxy address not found in deployed_addresses.json");
}
// Deploy PoseidonT3 library (required by Aadhaar registry)
const poseidonT3 = m.library("PoseidonT3");
// Deploy new Aadhaar implementation with PoseidonT3 linked
const newAadhaarImpl = m.contract("IdentityRegistryAadhaarImplV1", [], {
libraries: { PoseidonT3: poseidonT3 },
});
const aadhaarProxy = m.contractAt("IdentityRegistryAadhaarImplV1", aadhaarProxyAddress, {
id: "AadhaarRegistryProxy",
});
m.call(aadhaarProxy, "upgradeToAndCall", [newAadhaarImpl, "0x"], {
after: [newAadhaarImpl],
});
return {
poseidonT3,
newAadhaarImpl,
};
});

View File

@@ -0,0 +1,36 @@
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import hre from "hardhat";
import { readFileSync } from "fs";
import path from "path";
export default buildModule("UpgradeIdCardRegistryModule", (m) => {
const networkName = hre.network.config.chainId;
const deployedAddressesPath = path.join(__dirname, `../../deployments/chain-${networkName}/deployed_addresses.json`);
const deployedAddresses = JSON.parse(readFileSync(deployedAddressesPath, "utf8"));
const idCardProxyAddress = deployedAddresses["DeployIdCardRegistryModule#IdentityRegistry"];
if (!idCardProxyAddress) {
throw new Error("IdCard Registry proxy address not found in deployed_addresses.json");
}
// Deploy PoseidonT3 library (required by IdCard registry)
const poseidonT3 = m.library("PoseidonT3");
// Deploy new IdCard implementation with PoseidonT3 linked
const newIdCardImpl = m.contract("IdentityRegistryIdCardImplV1", [], {
libraries: { PoseidonT3: poseidonT3 },
});
const idCardProxy = m.contractAt("IdentityRegistryIdCardImplV1", idCardProxyAddress, {
id: "IdCardRegistryProxy",
});
m.call(idCardProxy, "upgradeToAndCall", [newIdCardImpl, "0x"], {
after: [newIdCardImpl],
});
return {
poseidonT3,
newIdCardImpl,
};
});

View File

@@ -0,0 +1,36 @@
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import hre from "hardhat";
import { readFileSync } from "fs";
import path from "path";
export default buildModule("UpgradeRegistryModule", (m) => {
const networkName = hre.network.config.chainId;
const deployedAddressesPath = path.join(__dirname, `../../deployments/chain-${networkName}/deployed_addresses.json`);
const deployedAddresses = JSON.parse(readFileSync(deployedAddressesPath, "utf8"));
const registryProxyAddress = deployedAddresses["DeployRegistryModule#IdentityRegistry"];
if (!registryProxyAddress) {
throw new Error("Passport Registry proxy address not found in deployed_addresses.json");
}
// Deploy PoseidonT3 library (required by Passport registry)
const poseidonT3 = m.library("PoseidonT3");
// Deploy new Passport implementation with PoseidonT3 linked
const newRegistryImpl = m.contract("IdentityRegistryImplV1", [], {
libraries: { PoseidonT3: poseidonT3 },
});
const registryProxy = m.contractAt("IdentityRegistryImplV1", registryProxyAddress, {
id: "RegistryProxy",
});
m.call(registryProxy, "upgradeToAndCall", [newRegistryImpl, "0x"], {
after: [newRegistryImpl],
});
return {
poseidonT3,
newRegistryImpl,
};
});

View File

@@ -16,9 +16,14 @@ The upgrade tooling provides:
```bash
# Single command to validate, deploy, and propose
npx hardhat upgrade --contract IdentityVerificationHub --network celo --changelog "Added feature X"
# Dry run first to validate without deploying
npx hardhat upgrade --contract IdentityRegistryKyc --network celo-sepolia --dry-run
```
## The `upgrade` Command
## Commands
### `upgrade`
Validates, deploys, and creates a Safe multisig proposal in one step.
@@ -27,35 +32,72 @@ npx hardhat upgrade \
--contract <ContractId> \
--network <network> \
[--changelog <message>] \
[--prepare-only]
[--prepare-only] \
[--dry-run] \
[--skip-commit]
```
**Options:**
- `--contract` - Contract to upgrade (IdentityVerificationHub, IdentityRegistry, etc.)
- `--network` - Target network (celo, sepolia, localhost)
- `--changelog` - Description of changes
- `--prepare-only` - Deploy implementation without creating Safe proposal
| Flag | Description |
| ---------------- | --------------------------------------------------------------------- |
| `--contract` | Contract to upgrade (see [Supported Contracts](#supported-contracts)) |
| `--network` | Target network: `celo`, `celo-sepolia` |
| `--changelog` | Description of changes for the version history |
| `--prepare-only` | Deploy implementation without creating Safe proposal |
| `--dry-run` | Simulate the full flow without deploying or proposing |
| `--skip-commit` | Skip automatic git commit after deployment |
**What it does:**
1. Validates `@custom:version` increment
2. Checks `reinitializer(N)` matches expected version
3. Validates storage layout compatibility
4. Clears cache and compiles fresh
5. Compares bytecode (warns if unchanged)
6. Deploys new implementation
7. ✅ Updates deployment registry
8. ✅ Creates git commit and tag
9. Creates Safe proposal (or outputs manual instructions)
1. Validates `@custom:version` increment
2. Checks `reinitializer(N)` matches expected version
3. Validates storage layout compatibility
4. Clears cache and compiles fresh
5. Compares bytecode (warns if unchanged)
6. Deploys new implementation (+ PoseidonT3 library for registry contracts)
7. Verifies on block explorer
8. Updates `deployments/registry.json`
9. Creates git commit and tag
10. Creates Safe proposal (or outputs manual instructions)
## Utility Commands
### `upgrade:prepare`
Deploys the new implementation only, without creating a Safe proposal.
```bash
# Check current deployment status
npx hardhat upgrade:status --contract IdentityVerificationHub --network celo
npx hardhat upgrade:prepare \
--contract <ContractId> \
--network <network> \
[--changelog <message>] \
[--dry-run] \
[--skip-commit]
```
# View version history
### `upgrade:propose`
Creates a Safe multisig proposal for an already-deployed implementation.
```bash
npx hardhat upgrade:propose \
--contract <ContractId> \
--network <network> \
[--dry-run]
```
### `upgrade:status`
Check current deployment status for a contract.
```bash
npx hardhat upgrade:status --contract IdentityVerificationHub --network celo
```
### `upgrade:history`
View the full version history for a contract.
```bash
npx hardhat upgrade:history --contract IdentityVerificationHub
```
@@ -68,17 +110,19 @@ npx hardhat upgrade:history --contract IdentityVerificationHub
│ 1. UPDATE CONTRACT CODE │
│ - Make your changes │
│ - Update @custom:version in NatSpec │
│ - Increment reinitializer(N) modifier
│ - Increment reinitializer(N) modifier (if new storage/init)
│ - Add new storage fields at END of struct only │
├─────────────────────────────────────────────────────────────────────┤
│ 2. RUN: npx hardhat upgrade --contract X --network Y --changelog Z
│ - Validates all safety checks
│ 2. DRY RUN: npx hardhat upgrade --contract X --network Y --dry-run
│ - Validates all safety checks without deploying
├─────────────────────────────────────────────────────────────────────┤
│ 3. DEPLOY: npx hardhat upgrade --contract X --network Y --changelog │
│ - Deploys new implementation │
│ - Updates registry.json │
│ - Creates git commit + tag │
│ - Creates Safe proposal │
├─────────────────────────────────────────────────────────────────────┤
3. MULTISIG APPROVAL │
4. MULTISIG APPROVAL │
│ - Signers review in Safe UI │
│ - Once threshold met, click Execute │
└─────────────────────────────────────────────────────────────────────┘
@@ -98,7 +142,7 @@ contract MyContract is ImplRoot {
uint256 newField; // <-- Add new fields at end only
}
// Increment reinitializer(N) for each upgrade
// Increment reinitializer(N) for each upgrade that needs initialization
function initialize(...) external reinitializer(13) {
// Initialize new fields if needed
MyStorage storage $ = _getMyStorage();
@@ -109,31 +153,66 @@ contract MyContract is ImplRoot {
}
```
Note: If an upgrade is code-only (no new storage, no initialization needed), you can bump `@custom:version` without
adding a new `reinitializer`. The script handles both cases.
## Supported Contracts
| Contract ID | Source Contract | Type | Notes |
| ------------------------- | ----------------------------- | --------------- | ---------------------------------------- |
| `IdentityVerificationHub` | IdentityVerificationHubImplV2 | UUPS Proxy | Main verification hub |
| `IdentityRegistry` | IdentityRegistryImplV1 | UUPS Proxy | Passport registry (links PoseidonT3) |
| `IdentityRegistryIdCard` | IdentityRegistryIdCardImplV1 | UUPS Proxy | EU ID Card registry (links PoseidonT3) |
| `IdentityRegistryAadhaar` | IdentityRegistryAadhaarImplV1 | UUPS Proxy | Aadhaar registry (links PoseidonT3) |
| `IdentityRegistryKyc` | IdentityRegistryKycImplV1 | UUPS Proxy | KYC registry (links PoseidonT3) |
| `PCR0Manager` | PCR0Manager | Non-upgradeable | TEE PCR0 value management (tracked only) |
| `VerifyAll` | VerifyAll | Non-upgradeable | SDK verification helper (tracked only) |
| `DummyContract` | DummyContract | UUPS Proxy | Testing contract |
The four registry contracts (Passport, IdCard, Aadhaar, KYC) all use the PoseidonT3 library for their internal Lean IMT.
The upgrade script automatically deploys and links PoseidonT3 when deploying a new implementation for these contracts.
## Supported Networks
| Network | Chain ID | Governance | Use Case |
| -------------- | -------- | ---------------------------- | ---------- |
| `celo` | 42220 | 3/5 security, 2/5 operations | Production |
| `celo-sepolia` | 11142220 | 1/1 single signer | Testnet |
## Configuration
### Deployment Registry
The registry (`deployments/registry.json`) tracks:
- Contract definitions (source contract name, type)
- Proxy addresses per network
- Current versions
- Implementation history
- Git commits and tags
- Current versions and implementation addresses
- Governance multisig configuration per network
- Full version history with deployment details and git commits
### Governance Configuration
Multisig addresses are configured in `deployments/registry.json`:
Multisig addresses are configured per network in `deployments/registry.json`:
```json
{
"networks": {
"celo": {
"governance": {
"securityMultisig": "0x...",
"operationsMultisig": "0x...",
"securityMultisig": "0x738f...",
"operationsMultisig": "0x067b...",
"securityThreshold": "3/5",
"operationsThreshold": "2/5"
}
},
"celo-sepolia": {
"governance": {
"securityMultisig": "0x82D8...",
"operationsMultisig": "0x82D8...",
"securityThreshold": "1/1",
"operationsThreshold": "1/1"
}
}
}
}
@@ -144,36 +223,30 @@ Multisig addresses are configured in `deployments/registry.json`:
Required for deployments:
```bash
PRIVATE_KEY=0x... # Deployer private key
CELO_RPC_URL=https://... # RPC endpoint
PRIVATE_KEY=0x... # Deployer private key
CELO_RPC_URL=https://... # Celo mainnet RPC endpoint
CELO_SEPOLIA_RPC_URL=https://... # Celo Sepolia RPC endpoint
```
## Supported Contracts
| Contract ID | Contract Name | Type |
| ------------------------- | ----------------------------- | ---------- |
| `IdentityVerificationHub` | IdentityVerificationHubImplV2 | UUPS Proxy |
| `IdentityRegistry` | IdentityRegistryImplV1 | UUPS Proxy |
| `IdentityRegistryIdCard` | IdentityRegistryIdCardImplV1 | UUPS Proxy |
| `IdentityRegistryAadhaar` | IdentityRegistryAadhaarImplV1 | UUPS Proxy |
## Safety Checks
| Check | What it Does | Failure Behavior |
| ---------------------- | ------------------------------------------- | -------------------- |
| Version validation | Ensures semantic version increment | Blocks upgrade |
| Reinitializer check | Verifies `reinitializer(N)` matches version | Blocks upgrade |
| Storage layout | Detects breaking storage changes | Blocks upgrade |
| Bytecode comparison | Warns if code unchanged | Prompts confirmation |
| Safe role verification | Confirms Safe has SECURITY_ROLE | Blocks upgrade |
| Constructor check | Flags `_disableInitializers()` | Prompts confirmation |
| Check | What it Does | Failure Behavior |
| ---------------------- | ---------------------------------------------------- | -------------------- |
| Version validation | Ensures semantic version increment vs registry | Blocks upgrade |
| Reinitializer check | Verifies `reinitializer(N)` matches expected version | Blocks upgrade |
| Storage layout | Detects breaking storage changes | Blocks upgrade |
| Bytecode comparison | Warns if code unchanged from current impl | Prompts confirmation |
| Safe role verification | Confirms Safe has SECURITY_ROLE on proxy | Blocks upgrade |
| Constructor check | Flags `_disableInitializers()` | Prompts confirmation |
## Troubleshooting
| Issue | Solution |
| ----------------------------- | ----------------------------------------- |
| "Version matches current" | Update `@custom:version` in contract |
| "Reinitializer mismatch" | Update `reinitializer(N)` to next version |
| "Storage layout incompatible" | Don't remove/reorder storage variables |
| "Safe not indexed" | Submit manually via Safe UI |
| "Bytecode unchanged" | Ensure you saved contract changes |
| Issue | Solution |
| -------------------------------------- | ------------------------------------------------------------------------------------------ |
| "Version matches current" | Update `@custom:version` in contract NatSpec |
| "Reinitializer mismatch" | Update `reinitializer(N)` to next version |
| "Storage layout incompatible" | Don't remove/reorder storage variables |
| "Safe not indexed" | Submit manually via Safe UI |
| "Bytecode unchanged" | Ensure you saved contract changes |
| "No proxy deployed for X on network Y" | Add the contract's proxy address to `registry.json` under `networks.<network>.deployments` |
| "Invalid contract" | Contract ID not in `CONTRACT_IDS` array in `types.ts` |

View File

@@ -202,7 +202,8 @@ task("upgrade:prepare", "Validate and deploy a new implementation contract")
} else if (
contractName === "IdentityRegistryImplV1" ||
contractName === "IdentityRegistryIdCardImplV1" ||
contractName === "IdentityRegistryAadhaarImplV1"
contractName === "IdentityRegistryAadhaarImplV1" ||
contractName === "IdentityRegistryKycImplV1"
) {
const PoseidonT3 = await hre.ethers.getContractFactory("PoseidonT3");
const poseidonT3 = await PoseidonT3.deploy();

View File

@@ -11,6 +11,7 @@ export const CONTRACT_IDS = [
"IdentityRegistry",
"IdentityRegistryIdCard",
"IdentityRegistryAadhaar",
"IdentityRegistryKyc",
"PCR0Manager",
"VerifyAll",
"DummyContract",

View File

@@ -426,7 +426,8 @@ task("upgrade", "Deploy new implementation and create Safe proposal for upgrade"
} else if (
contractName === "IdentityRegistryImplV1" ||
contractName === "IdentityRegistryIdCardImplV1" ||
contractName === "IdentityRegistryAadhaarImplV1"
contractName === "IdentityRegistryAadhaarImplV1" ||
contractName === "IdentityRegistryKycImplV1"
) {
const PoseidonT3 = await hre.ethers.getContractFactory("PoseidonT3");
const poseidonT3 = await PoseidonT3.deploy();

View File

@@ -0,0 +1,482 @@
import { ethers } from "hardhat";
import { deploySystemFixturesV2 } from "../utils/deploymentV2";
import { DeployedActorsV2 } from "../utils/types";
import { expect } from "chai";
function getCurrentDateDigitsYYMMDDHHMMSS(hoursOffset: number = 0): bigint[] {
const now = new Date();
if (hoursOffset !== 0) {
now.setUTCHours(now.getUTCHours() + hoursOffset);
}
const pad2 = (n: number) => n.toString().padStart(2, "0");
const yy = pad2(now.getUTCFullYear() % 100);
const mm = pad2(now.getUTCMonth() + 1);
const dd = pad2(now.getUTCDate());
const hh = pad2(now.getUTCHours());
const min = pad2(now.getUTCMinutes());
const ss = pad2(now.getUTCSeconds());
return `${yy}${mm}${dd}${hh}${min}${ss}`.split("").map(Number).map(BigInt);
}
/**
* Packs a uint256 value into field elements as a 64-character hex string.
* Mirrors how the GCP JWT circuit outputs values in pubSignals[1-3].
*/
function packUint256ToHexFields(value: bigint): [bigint, bigint, bigint] {
const hexStr = value.toString(16).padStart(64, "0");
const bytes = Buffer.from(hexStr, "utf8");
let p0 = 0n,
p1 = 0n,
p2 = 0n;
for (let i = 0; i < Math.min(31, bytes.length); i++) {
p0 |= BigInt(bytes[i]) << BigInt(i * 8);
}
for (let i = 31; i < Math.min(62, bytes.length); i++) {
p1 |= BigInt(bytes[i]) << BigInt((i - 31) * 8);
}
for (let i = 62; i < Math.min(93, bytes.length); i++) {
p2 |= BigInt(bytes[i]) << BigInt((i - 62) * 8);
}
return [p0, p1, p2];
}
/**
* Computes sha256(abi.encodePacked(roots...)) for a set of roots.
* This is the nonce used in the proof for each registry.
*/
function computeRootsHash(roots: bigint[]): string {
const types = roots.map(() => "uint256");
return ethers.sha256(ethers.solidityPacked(types, roots));
}
describe("OFAC Proof Update test", function () {
this.timeout(0);
let deployedActors: DeployedActorsV2;
let mockVerifier: any;
const GCP_ROOT_CA_PUBKEY_HASH = 21107503781769611051785921462832133421817512022858926231578334326320168810501n;
// Test image hash that unpacks to: d2221a0ee83901980c607ceff2edbedf3f6ce5f437eafa5d89be39e9e7487c04
const testImageHash = {
p0: 177384435506496807268973340845468654286294928521500580044819492874465981028n,
p1: 175298970718174405520284770870231222447414486446296682893283627688949855078n,
p2: 13360n,
};
const mockProof = {
a: [1n, 2n] as [bigint, bigint],
b: [
[1n, 2n],
[3n, 4n],
] as [[bigint, bigint], [bigint, bigint]],
c: [1n, 2n] as [bigint, bigint],
};
// Test OFAC roots (arbitrary non-zero values)
const passportRoots = [100n, 200n, 300n];
const aadhaarRoots = [400n, 500n];
const idCardRoots = [600n, 700n];
const kycRoots = [800n, 900n];
before(async () => {
deployedActors = await deploySystemFixturesV2();
const [deployer] = await ethers.getSigners();
// Deploy fresh MockGCPJWTVerifier
const MockVerifierFactory = await ethers.getContractFactory("MockGCPJWTVerifier");
mockVerifier = await MockVerifierFactory.deploy();
await mockVerifier.waitForDeployment();
// Register test PCR0 image hash
const pcr0Bytes = ethers.getBytes("0xd2221a0ee83901980c607ceff2edbedf3f6ce5f437eafa5d89be39e9e7487c04");
await deployedActors.pcr0Manager.addPCR0(pcr0Bytes);
// Configure OFAC proof infrastructure on non-KYC registries via reinitializer(3)
await deployedActors.registry.initializeOfacProof(
mockVerifier.target,
deployedActors.pcr0Manager.target,
GCP_ROOT_CA_PUBKEY_HASH,
deployer.address,
);
await deployedActors.registryId.initializeOfacProof(
mockVerifier.target,
deployedActors.pcr0Manager.target,
GCP_ROOT_CA_PUBKEY_HASH,
deployer.address,
);
await deployedActors.registryAadhaar.initializeOfacProof(
mockVerifier.target,
deployedActors.pcr0Manager.target,
GCP_ROOT_CA_PUBKEY_HASH,
deployer.address,
);
// Configure KYC registry (already has pcr0Manager from init)
await deployedActors.registryKyc.updateGCPJWTVerifier(mockVerifier.target);
await deployedActors.registryKyc.updateGCPRootCAPubkeyHash(GCP_ROOT_CA_PUBKEY_HASH);
await deployedActors.registryKyc.updateTEE(deployer.address);
console.log("OFAC proof test setup completed");
});
/**
* Builds pubSignals for a given set of roots (nonce = sha256 of the roots).
*/
function buildPubSignals(roots: bigint[], hoursOffset = 0): bigint[] {
const rootsHash = computeRootsHash(roots);
const [p0, p1, p2] = packUint256ToHexFields(BigInt(rootsHash));
return [
GCP_ROOT_CA_PUBKEY_HASH,
p0,
p1,
p2,
0n, // unused
testImageHash.p0,
testImageHash.p1,
testImageHash.p2,
...getCurrentDateDigitsYYMMDDHHMMSS(hoursOffset),
];
}
describe("Successful proof-based updates", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should update all 4 registries with valid proof", async () => {
// Update Passport (3 roots)
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots),
passportRoots,
),
).to.emit(deployedActors.registry, "OfacRootsUpdatedWithProof");
// Update Aadhaar (2 roots)
await expect(
deployedActors.registryAadhaar.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(aadhaarRoots),
aadhaarRoots,
),
).to.emit(deployedActors.registryAadhaar, "OfacRootsUpdatedWithProof");
// Update ID Card (2 roots)
await expect(
deployedActors.registryId.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(idCardRoots),
idCardRoots,
),
).to.emit(deployedActors.registryId, "OfacRootsUpdatedWithProof");
// Update KYC (2 roots)
await expect(
deployedActors.registryKyc.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(kycRoots),
kycRoots,
),
).to.emit(deployedActors.registryKyc, "OfacRootsUpdatedWithProof");
// Verify Passport roots
expect(await deployedActors.registry.getNameAndDobOfacRoot()).to.equal(passportRoots[0]);
expect(await deployedActors.registry.getNameAndYobOfacRoot()).to.equal(passportRoots[1]);
expect(await deployedActors.registry.getPassportNoOfacRoot()).to.equal(passportRoots[2]);
// Verify Aadhaar roots
expect(await deployedActors.registryAadhaar.getNameAndDobOfacRoot()).to.equal(aadhaarRoots[0]);
expect(await deployedActors.registryAadhaar.getNameAndYobOfacRoot()).to.equal(aadhaarRoots[1]);
// Verify ID Card roots
expect(await deployedActors.registryId.getNameAndDobOfacRoot()).to.equal(idCardRoots[0]);
expect(await deployedActors.registryId.getNameAndYobOfacRoot()).to.equal(idCardRoots[1]);
// Verify KYC roots
expect(await deployedActors.registryKyc.getNameAndDobOfacRoot()).to.equal(kycRoots[0]);
expect(await deployedActors.registryKyc.getNameAndYobOfacRoot()).to.equal(kycRoots[1]);
});
it("should reject non-TEE caller even with valid proof", async () => {
await expect(
deployedActors.registry
.connect(deployedActors.user1)
.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots),
passportRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "ONLY_TEE_CAN_ACCESS");
});
it("should emit individual root update events", async () => {
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots),
passportRoots,
),
)
.to.emit(deployedActors.registry, "NameAndDobOfacRootUpdated")
.withArgs(passportRoots[0])
.and.to.emit(deployedActors.registry, "NameAndYobOfacRootUpdated")
.withArgs(passportRoots[1])
.and.to.emit(deployedActors.registry, "PassportNoOfacRootUpdated")
.withArgs(passportRoots[2]);
});
});
describe("Error cases", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should revert with InvalidRootsCount when wrong number of roots for Passport", async () => {
const wrongRoots = [100n, 200n];
// Passport expects 3 roots, pass 2
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(wrongRoots),
wrongRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "InvalidRootsCount");
});
it("should revert with InvalidRootsCount when wrong number of roots for KYC", async () => {
const wrongRoots = [800n, 900n, 1000n];
// KYC expects 2 roots, pass 3
await expect(
deployedActors.registryKyc.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(wrongRoots),
wrongRoots,
),
).to.be.revertedWithCustomError(deployedActors.registryKyc, "InvalidRootsCount");
});
it("should revert with INVALID_PROOF when verifier rejects proof", async () => {
await mockVerifier.setShouldVerify(false);
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots),
passportRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "INVALID_PROOF");
// Reset mock verifier
await mockVerifier.setShouldVerify(true);
});
it("should revert with INVALID_ROOT_CA when rootCA hash does not match", async () => {
const rootsHash = computeRootsHash(passportRoots);
const [p0, p1, p2] = packUint256ToHexFields(BigInt(rootsHash));
// Use wrong rootCA hash in pubSignals[0]
const badPubSignals: bigint[] = [
12345n, // wrong rootCA
p0,
p1,
p2,
0n,
testImageHash.p0,
testImageHash.p1,
testImageHash.p2,
...getCurrentDateDigitsYYMMDDHHMMSS(),
];
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
badPubSignals,
passportRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "INVALID_ROOT_CA");
});
it("should revert with INVALID_IMAGE when image hash not in PCR0Manager", async () => {
const rootsHash = computeRootsHash(passportRoots);
const [p0, p1, p2] = packUint256ToHexFields(BigInt(rootsHash));
// Use unknown image hash in pubSignals[5-7]
const badPubSignals: bigint[] = [
GCP_ROOT_CA_PUBKEY_HASH,
p0,
p1,
p2,
0n,
99n, // bad image hash
99n,
99n,
...getCurrentDateDigitsYYMMDDHHMMSS(),
];
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
badPubSignals,
passportRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "INVALID_IMAGE");
});
it("should revert with INVALID_TIMESTAMP when timestamp is stale (2h past)", async () => {
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots, -2),
passportRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "INVALID_TIMESTAMP");
});
it("should revert with INVALID_TIMESTAMP when timestamp is too far in future (2h)", async () => {
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots, 2),
passportRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "INVALID_TIMESTAMP");
});
it("should revert with InvalidRootsHash when roots don't match proof nonce", async () => {
// Build pubSignals with correct roots, but pass different roots to the contract
const tamperedRoots = [999n, 888n, 777n];
await expect(
deployedActors.registry.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(passportRoots),
tamperedRoots,
),
).to.be.revertedWithCustomError(deployedActors.registry, "InvalidRootsHash");
});
it("should revert on each registry type with InvalidRootsCount", async () => {
// Aadhaar expects 2 roots, pass 1
const oneRoot = [400n];
await expect(
deployedActors.registryAadhaar.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(oneRoot),
oneRoot,
),
).to.be.revertedWithCustomError(deployedActors.registryAadhaar, "InvalidRootsCount");
// ID Card expects 2 roots, pass 3
const threeRoots = [600n, 700n, 800n];
await expect(
deployedActors.registryId.updateOfacRootsWithProof(
mockProof.a,
mockProof.b,
mockProof.c,
buildPubSignals(threeRoots),
threeRoots,
),
).to.be.revertedWithCustomError(deployedActors.registryId, "InvalidRootsCount");
});
});
describe("Config setters (non-KYC registries)", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should allow SECURITY_ROLE to update GCP JWT verifier on Passport registry", async () => {
const newVerifier = ethers.Wallet.createRandom().address;
await expect(deployedActors.registry.updateGCPJWTVerifier(newVerifier))
.to.emit(deployedActors.registry, "GCPJWTVerifierUpdated")
.withArgs(newVerifier);
});
it("should allow SECURITY_ROLE to update PCR0Manager on Passport registry", async () => {
const newManager = ethers.Wallet.createRandom().address;
await expect(deployedActors.registry.updatePCR0Manager(newManager))
.to.emit(deployedActors.registry, "PCR0ManagerUpdated")
.withArgs(newManager);
});
it("should allow SECURITY_ROLE to update GCP root CA hash on Passport registry", async () => {
const newHash = 99999n;
await expect(deployedActors.registry.updateGCPRootCAPubkeyHash(newHash))
.to.emit(deployedActors.registry, "GCPRootCAPubkeyHashUpdated")
.withArgs(newHash);
});
it("should not allow non-SECURITY_ROLE to update GCP JWT verifier", async () => {
await expect(
deployedActors.registry
.connect(deployedActors.user1)
.updateGCPJWTVerifier(ethers.Wallet.createRandom().address),
).to.be.revertedWithCustomError(deployedActors.registry, "AccessControlUnauthorizedAccount");
});
it("should not allow non-SECURITY_ROLE to update PCR0Manager", async () => {
await expect(
deployedActors.registry.connect(deployedActors.user1).updatePCR0Manager(ethers.Wallet.createRandom().address),
).to.be.revertedWithCustomError(deployedActors.registry, "AccessControlUnauthorizedAccount");
});
it("should not allow non-SECURITY_ROLE to update GCP root CA hash", async () => {
await expect(
deployedActors.registry.connect(deployedActors.user1).updateGCPRootCAPubkeyHash(99999n),
).to.be.revertedWithCustomError(deployedActors.registry, "AccessControlUnauthorizedAccount");
});
});
});

View File

@@ -0,0 +1,332 @@
import { ethers } from "hardhat";
import { expect } from "chai";
/**
* Lightweight deployment of just the 4 registry proxies — no verifiers,
* no hub, no staging artifacts required.
*/
async function deployRegistries() {
const [owner] = await ethers.getSigners();
// Deploy PoseidonT3 (required by all registries)
const poseidonT3 = await (await ethers.getContractFactory("PoseidonT3")).connect(owner).deploy();
await poseidonT3.waitForDeployment();
// Deploy PCR0Manager (required by KYC init)
const pcr0Manager = await (await ethers.getContractFactory("PCR0Manager")).connect(owner).deploy();
await pcr0Manager.waitForDeployment();
const libs = { libraries: { PoseidonT3: poseidonT3.target } };
const temporaryHubAddress = "0x0000000000000000000000000000000000000000";
// Helper: deploy impl + proxy, return proxy with impl ABI
async function deployRegistry(implName: string, initArgs: any[]) {
const ImplFactory = await ethers.getContractFactory(implName, libs);
const impl = await ImplFactory.connect(owner).deploy();
await impl.waitForDeployment();
const initData = impl.interface.encodeFunctionData("initialize", initArgs);
const ProxyFactory = await ethers.getContractFactory("IdentityRegistry");
const proxy = await ProxyFactory.connect(owner).deploy(impl.target, initData);
await proxy.waitForDeployment();
return ethers.getContractAt(implName, proxy.target);
}
const registry = await deployRegistry("IdentityRegistryImplV1", [temporaryHubAddress]);
const registryId = await deployRegistry("IdentityRegistryIdCardImplV1", [temporaryHubAddress]);
const registryAadhaar = await deployRegistry("IdentityRegistryAadhaarImplV1", [temporaryHubAddress]);
const registryKyc = await deployRegistry("IdentityRegistryKycImplV1", [temporaryHubAddress, pcr0Manager.target]);
return { registry, registryId, registryAadhaar, registryKyc, owner };
}
describe("OFAC Rolling Root Window test", function () {
this.timeout(0);
let registry: any;
let registryId: any;
let registryAadhaar: any;
let registryKyc: any;
const ROOT_A_DOB = 1000n;
const ROOT_A_YOB = 2000n;
const ROOT_A_PASSPORT = 3000n;
const ROOT_B_DOB = 4000n;
const ROOT_B_YOB = 5000n;
const ROOT_B_PASSPORT = 6000n;
const ROOT_C_DOB = 7000n;
const ROOT_C_YOB = 8000n;
const ROOT_C_PASSPORT = 9000n;
before(async () => {
const d = await deployRegistries();
registry = d.registry;
registryId = d.registryId;
registryAadhaar = d.registryAadhaar;
registryKyc = d.registryKyc;
});
// ──────────────────────────────────────────────────────────
// KYC Registry (2 roots: nameAndDob, nameAndYob)
// ──────────────────────────────────────────────────────────
describe("KYC Registry", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should accept current roots", async () => {
await registryKyc.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_A_YOB);
expect(await registryKyc.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
});
it("should accept previous roots after one update", async () => {
await registryKyc.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryKyc.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_B_YOB);
// Current
expect(await registryKyc.checkOfacRoots(ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
// Previous (rolling window)
expect(await registryKyc.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
// Mixed current+previous
expect(await registryKyc.checkOfacRoots(ROOT_A_DOB, ROOT_B_YOB)).to.be.true;
expect(await registryKyc.checkOfacRoots(ROOT_B_DOB, ROOT_A_YOB)).to.be.true;
});
it("should reject roots from 2 updates ago (window = 1)", async () => {
await registryKyc.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryKyc.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_B_YOB);
await registryKyc.updateNameAndDobOfacRoot(ROOT_C_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_C_YOB);
expect(await registryKyc.checkOfacRoots(ROOT_C_DOB, ROOT_C_YOB)).to.be.true;
expect(await registryKyc.checkOfacRoots(ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registryKyc.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.false;
});
it("should store previous root correctly via getters", async () => {
await registryKyc.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_A_YOB);
expect(await registryKyc.getPrevNameAndDobOfacRoot()).to.equal(0n);
expect(await registryKyc.getPrevNameAndYobOfacRoot()).to.equal(0n);
await registryKyc.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registryKyc.getPrevNameAndDobOfacRoot()).to.equal(ROOT_A_DOB);
expect(await registryKyc.getPrevNameAndYobOfacRoot()).to.equal(ROOT_A_YOB);
expect(await registryKyc.getNameAndDobOfacRoot()).to.equal(ROOT_B_DOB);
expect(await registryKyc.getNameAndYobOfacRoot()).to.equal(ROOT_B_YOB);
});
it("should reject random roots", async () => {
await registryKyc.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryKyc.updateNameAndYobOfacRoot(ROOT_A_YOB);
expect(await registryKyc.checkOfacRoots(99999n, 88888n)).to.be.false;
});
});
// ──────────────────────────────────────────────────────────
// Passport Registry (3 roots)
// ──────────────────────────────────────────────────────────
describe("Passport Registry", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should accept current roots", async () => {
await registry.updatePassportNoOfacRoot(ROOT_A_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_A_YOB);
expect(await registry.checkOfacRoots(ROOT_A_PASSPORT, ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
});
it("should accept previous roots after one update", async () => {
await registry.updatePassportNoOfacRoot(ROOT_A_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registry.updatePassportNoOfacRoot(ROOT_B_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registry.checkOfacRoots(ROOT_B_PASSPORT, ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registry.checkOfacRoots(ROOT_A_PASSPORT, ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
// Mixed
expect(await registry.checkOfacRoots(ROOT_A_PASSPORT, ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
});
it("should reject roots from 2 updates ago (window = 1)", async () => {
await registry.updatePassportNoOfacRoot(ROOT_A_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registry.updatePassportNoOfacRoot(ROOT_B_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_B_YOB);
await registry.updatePassportNoOfacRoot(ROOT_C_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_C_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_C_YOB);
expect(await registry.checkOfacRoots(ROOT_C_PASSPORT, ROOT_C_DOB, ROOT_C_YOB)).to.be.true;
expect(await registry.checkOfacRoots(ROOT_B_PASSPORT, ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registry.checkOfacRoots(ROOT_A_PASSPORT, ROOT_A_DOB, ROOT_A_YOB)).to.be.false;
});
it("should store previous roots correctly via getters", async () => {
await registry.updatePassportNoOfacRoot(ROOT_A_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registry.updatePassportNoOfacRoot(ROOT_B_PASSPORT);
await registry.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registry.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registry.getPrevPassportNoOfacRoot()).to.equal(ROOT_A_PASSPORT);
expect(await registry.getPrevNameAndDobOfacRoot()).to.equal(ROOT_A_DOB);
expect(await registry.getPrevNameAndYobOfacRoot()).to.equal(ROOT_A_YOB);
});
});
// ──────────────────────────────────────────────────────────
// ID Card Registry (2 roots)
// ──────────────────────────────────────────────────────────
describe("ID Card Registry", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should accept current roots", async () => {
await registryId.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_A_YOB);
expect(await registryId.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
});
it("should accept previous roots after one update", async () => {
await registryId.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryId.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registryId.checkOfacRoots(ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registryId.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
});
it("should reject roots from 2 updates ago", async () => {
await registryId.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryId.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_B_YOB);
await registryId.updateNameAndDobOfacRoot(ROOT_C_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_C_YOB);
expect(await registryId.checkOfacRoots(ROOT_C_DOB, ROOT_C_YOB)).to.be.true;
expect(await registryId.checkOfacRoots(ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registryId.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.false;
});
it("should store previous roots correctly via getters", async () => {
await registryId.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryId.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryId.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registryId.getPrevNameAndDobOfacRoot()).to.equal(ROOT_A_DOB);
expect(await registryId.getPrevNameAndYobOfacRoot()).to.equal(ROOT_A_YOB);
});
});
// ──────────────────────────────────────────────────────────
// Aadhaar Registry (2 roots)
// ──────────────────────────────────────────────────────────
describe("Aadhaar Registry", () => {
let snapshotId: string;
beforeEach(async () => {
snapshotId = await ethers.provider.send("evm_snapshot", []);
});
afterEach(async () => {
await ethers.provider.send("evm_revert", [snapshotId]);
});
it("should accept current roots", async () => {
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_A_YOB);
expect(await registryAadhaar.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
});
it("should accept previous roots after one update", async () => {
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registryAadhaar.checkOfacRoots(ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registryAadhaar.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.true;
});
it("should reject roots from 2 updates ago", async () => {
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_B_YOB);
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_C_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_C_YOB);
expect(await registryAadhaar.checkOfacRoots(ROOT_C_DOB, ROOT_C_YOB)).to.be.true;
expect(await registryAadhaar.checkOfacRoots(ROOT_B_DOB, ROOT_B_YOB)).to.be.true;
expect(await registryAadhaar.checkOfacRoots(ROOT_A_DOB, ROOT_A_YOB)).to.be.false;
});
it("should store previous roots correctly via getters", async () => {
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_A_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_A_YOB);
await registryAadhaar.updateNameAndDobOfacRoot(ROOT_B_DOB);
await registryAadhaar.updateNameAndYobOfacRoot(ROOT_B_YOB);
expect(await registryAadhaar.getPrevNameAndDobOfacRoot()).to.equal(ROOT_A_DOB);
expect(await registryAadhaar.getPrevNameAndYobOfacRoot()).to.equal(ROOT_A_YOB);
});
});
});

View File

@@ -0,0 +1,172 @@
/**
* OFAC Proof Update — Upgrade Path Test
*
* Validates that:
* 1. A KYC proxy with the current implementation can be upgraded
* 2. State (OFAC roots, IMT) survives the upgrade
* 3. updateOfacRootsWithProof works after upgrade
* 4. The new function is callable by non-owners (proof IS authorization)
*
* This test deploys a full system, sets some OFAC roots via the old setter
* functions, then upgrades the implementation and verifies everything works
* with the proof-based update path.
*/
import { ethers } from "hardhat";
import { deploySystemFixturesV2 } from "../utils/deploymentV2";
import { DeployedActorsV2 } from "../utils/types";
import { expect } from "chai";
const GCP_ROOT_CA_PUBKEY_HASH = 21107503781769611051785921462832133421817512022858926231578334326320168810501n;
function packUint256ToHexFields(value: bigint): [bigint, bigint, bigint] {
const hexStr = value.toString(16).padStart(64, "0");
const bytes = Buffer.from(hexStr, "utf8");
let p0 = 0n,
p1 = 0n,
p2 = 0n;
for (let i = 0; i < Math.min(31, bytes.length); i++) p0 |= BigInt(bytes[i]) << BigInt(i * 8);
for (let i = 31; i < Math.min(62, bytes.length); i++) p1 |= BigInt(bytes[i]) << BigInt((i - 31) * 8);
for (let i = 62; i < Math.min(93, bytes.length); i++) p2 |= BigInt(bytes[i]) << BigInt((i - 62) * 8);
return [p0, p1, p2];
}
function getCurrentDateDigitsYYMMDDHHMMSS(): bigint[] {
const now = new Date();
const pad2 = (n: number) => n.toString().padStart(2, "0");
const yy = pad2(now.getUTCFullYear() % 100);
const mm = pad2(now.getUTCMonth() + 1);
const dd = pad2(now.getUTCDate());
const hh = pad2(now.getUTCHours());
const min = pad2(now.getUTCMinutes());
const ss = pad2(now.getUTCSeconds());
return `${yy}${mm}${dd}${hh}${min}${ss}`.split("").map(Number).map(BigInt);
}
describe("OFAC Upgrade Path Test", function () {
this.timeout(0);
let actors: DeployedActorsV2;
let mockVerifier: any;
const testImageHash = {
p0: 177384435506496807268973340845468654286294928521500580044819492874465981028n,
p1: 175298970718174405520284770870231222447414486446296682893283627688949855078n,
p2: 13360n,
};
before(async () => {
actors = await deploySystemFixturesV2();
// Deploy fresh MockGCPJWTVerifier
const MockVerifierFactory = await ethers.getContractFactory("MockGCPJWTVerifier");
mockVerifier = await MockVerifierFactory.deploy();
await mockVerifier.waitForDeployment();
// Register test PCR0 image hash
const pcr0Bytes = ethers.getBytes("0xd2221a0ee83901980c607ceff2edbedf3f6ce5f437eafa5d89be39e9e7487c04");
await actors.pcr0Manager.addPCR0(pcr0Bytes);
});
it("should preserve state after upgrade and allow proof-based updates", async () => {
// --- Phase 1: Set some OFAC roots using old setter functions ---
const initialDobRoot = 12345n;
const initialYobRoot = 67890n;
await actors.registryKyc.updateNameAndDobOfacRoot(initialDobRoot);
await actors.registryKyc.updateNameAndYobOfacRoot(initialYobRoot);
// Verify old roots are set
expect(await actors.registryKyc.getNameAndDobOfacRoot()).to.equal(initialDobRoot);
expect(await actors.registryKyc.getNameAndYobOfacRoot()).to.equal(initialYobRoot);
console.log(` Pre-upgrade OFAC roots: DOB=${initialDobRoot}, YOB=${initialYobRoot}`);
// --- Phase 2: Upgrade KYC implementation ---
// Deploy new implementation (needs PoseidonT3 library linked)
const KycFactory = await ethers.getContractFactory("IdentityRegistryKycImplV1", {
libraries: {
PoseidonT3: await actors.poseidonT3.getAddress(),
},
});
const newImpl = await KycFactory.deploy();
await newImpl.waitForDeployment();
console.log(` New KYC impl deployed: ${newImpl.target}`);
// Get proxy address
const proxyAddress = await actors.registryKyc.getAddress();
// Upgrade via upgradeToAndCall (no reinitializer data for KYC)
await actors.registryKyc.upgradeToAndCall(newImpl.target, "0x");
console.log(` Proxy upgraded to new implementation`);
// --- Phase 3: Verify state survived ---
expect(await actors.registryKyc.getNameAndDobOfacRoot()).to.equal(initialDobRoot);
expect(await actors.registryKyc.getNameAndYobOfacRoot()).to.equal(initialYobRoot);
console.log(` State preserved: DOB=${initialDobRoot}, YOB=${initialYobRoot}`);
// --- Phase 4: Configure mock verifier and TEE address for proof-based updates ---
const [deployer] = await ethers.getSigners();
await actors.registryKyc.updateGCPJWTVerifier(mockVerifier.target);
await actors.registryKyc.updateGCPRootCAPubkeyHash(GCP_ROOT_CA_PUBKEY_HASH);
await actors.registryKyc.updateTEE(deployer.address);
// --- Phase 5: Call updateOfacRootsWithProof ---
const kycRoots = [800n, 900n];
// Compute roots hash (nonce for the proof)
const rootsHash = ethers.sha256(ethers.solidityPacked(["uint256", "uint256"], kycRoots));
const [p0, p1, p2] = packUint256ToHexFields(BigInt(rootsHash));
const pubSignals: bigint[] = [
GCP_ROOT_CA_PUBKEY_HASH,
p0,
p1,
p2,
0n,
testImageHash.p0,
testImageHash.p1,
testImageHash.p2,
...getCurrentDateDigitsYYMMDDHHMMSS(),
];
const mockProof = {
a: [1n, 2n] as [bigint, bigint],
b: [
[1n, 2n],
[3n, 4n],
] as [[bigint, bigint], [bigint, bigint]],
c: [1n, 2n] as [bigint, bigint],
};
// Call from TEE address (deployer)
const proofTx = await actors.registryKyc
.connect(deployer)
.updateOfacRootsWithProof(mockProof.a, mockProof.b, mockProof.c, pubSignals, kycRoots);
await proofTx.wait();
// --- Phase 6: Verify new current roots ---
expect(await actors.registryKyc.getNameAndDobOfacRoot()).to.equal(800n);
expect(await actors.registryKyc.getNameAndYobOfacRoot()).to.equal(900n);
console.log(` Proof-based update succeeded: DOB=800, YOB=900`);
// --- Phase 6b: Verify prev* slots were rotated correctly ---
expect(await actors.registryKyc.getPrevNameAndDobOfacRoot()).to.equal(initialDobRoot);
expect(await actors.registryKyc.getPrevNameAndYobOfacRoot()).to.equal(initialYobRoot);
console.log(` Rolling window: prev DOB=${initialDobRoot}, prev YOB=${initialYobRoot}`);
// --- Phase 6c: Verify checkOfacRoots accepts current and previous snapshots (not mixed) ---
expect(await actors.registryKyc.checkOfacRoots(800n, 900n)).to.equal(true);
expect(await actors.registryKyc.checkOfacRoots(initialDobRoot, initialYobRoot)).to.equal(true);
expect(await actors.registryKyc.checkOfacRoots(800n, initialYobRoot)).to.equal(
false,
"mixed pair must be rejected",
);
// --- Phase 7: Verify old setter functions still work after upgrade ---
await actors.registryKyc.updateNameAndDobOfacRoot(11111n);
expect(await actors.registryKyc.getNameAndDobOfacRoot()).to.equal(11111n);
console.log(` Old setter still works after upgrade: DOB=11111`);
console.log(`\n ✅ Upgrade path test passed — state preserved, proof updates work, old setters still work`);
});
});

View File

@@ -0,0 +1,87 @@
# PR #1924 Review Findings
**PR:** Harden WebView bridge and asset serving across native shells
**Branch:** `justin/address-wv-vulns`
**Reviewed:** 2026-04-05
**Last updated:** 2026-04-05
This document reflects the substantive, non-pedantic feedback on PR #1924 from both GitHub PR comments and manual code review.
It intentionally excludes low-signal review noise such as docstring coverage, PR description nags, bot walkthrough summaries, and duplicate comments that collapse into the same work item.
---
## Resolved
### ~~1. Bundled Android entry path breaks relative asset loading~~
**Status:** Resolved — not a real issue.
`BundledAssetPathHandler` receives only the URL path component (e.g. `/assets/app.js`), not the full navigation URL. The `/tunnel/tour/1` initial URL never reaches the asset handler. Asset requests correctly resolve to `self-wallet/assets/...` in the Android bundle. PR comment resolved.
### ~~6. iOS local asset server startup must fail closed~~
**Status:** Resolved — already addressed.
`SelfWebViewHost` (native-shell-ios) uses a custom URL scheme handler (`SelfBundledAssetSchemeHandler`), not a local asset server. `WebViewProviderImpl` (self-sdk-swift) falls back to `bundledPort = 0` on server failure, which correctly rejects trust checks downstream. PR comments resolved.
### ~~7. SwiftPM resource path is declared but not populated by build automation~~
**Status:** Resolved — already addressed.
`build-webview-bundle.sh` copies the generated bundle to the self-sdk-swift resources path. The directory exists and is populated. PR comments resolved.
### ~~2. Trust boundary is fail-open in MessageRouter APIs~~
**Status:** Resolved.
Removed the default `isTrustedSource = true` value from the KMP, Android, and iOS routers, and updated callers/tests to pass trust explicitly.
### ~~3. Android bridge trust uses `webView.url` instead of callback origin~~
**Status:** Resolved.
Both Android hosts now evaluate bridge trust from `WebViewCompat.addWebMessageListener`'s `sourceOrigin` callback parameter rather than re-reading `webView.url`.
### ~~4. iOS bridge trust is rechecked from `webView?.url` after the origin was already validated~~
**Status:** Resolved.
`SelfWebViewHost` now passes `isTrustedSource: true` after `isTrustedBridgeFrameInfo()` succeeds, removing the race-prone recheck against `webView?.url`.
### ~~5. Bridge initialization does not fail closed when `WEB_MESSAGE_LISTENER` is unavailable~~
**Status:** Resolved.
Both Android hosts now fail closed with a hard `check(...)` when `WEB_MESSAGE_LISTENER` is unavailable instead of loading a broken bridge.
### ~~8. iOS `loadHTMLString` base URL resolves relative assets against entry path~~
**Status:** Resolved.
`SelfWebViewHost` already loads verified remote HTML with the configured `baseURL`, not the full `/tunnel/tour/1` entry URL, so relative asset resolution is anchored correctly at the remote app base.
### ~~9. Android allows Didit navigation in main frame; iOS restricts to subframes only~~
**Status:** Resolved.
The Android native-shell and KMP hosts now reject Didit in `isAllowedNavigationUrl`, aligning main-frame behavior with the iOS restriction.
### ~~10. Duplicate constant in Android host~~
**Status:** Resolved.
The current native Android host uses only `BUNDLED_ASSET_HOST`; the duplicate `BUNDLED_HOST` constant is no longer present.
### ~~11. iOS `navigationDelegate` set twice~~
**Status:** Resolved.
The current `SelfWebViewHost` assigns `webView.navigationDelegate = self` only once.
---
## Validation
- `cd packages/native-shell-android && ./gradlew test` — passed
- `cd packages/kmp-sdk && ./gradlew :shared:jvmTest` — passed
- `cd packages/native-shell-ios && swift test` — blocked by environment: SwiftPM cannot import `UIKit` in this shell (`no such module 'UIKit'`)
## Current Status
All substantive findings tracked in this review doc are now resolved in source.
## Explicitly Excluded
- Docstring coverage complaints
- PR description / checklist formatting comments
- Generic CodeRabbit walkthrough summaries
- Duplicate comments that collapse into the same work item

View File

@@ -63,6 +63,48 @@
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "INVALID_TIMESTAMP",
"signature": "INVALID_TIMESTAMP()",
"selector": "0x118818d1",
"file": "contracts/contracts/registry/IdentityRegistryKycImplV1.sol"
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "InvalidRootsCount",
"signature": "InvalidRootsCount()",
"selector": "0x128781c2",
"file": "contracts/contracts/registry/IdentityRegistryKycImplV1.sol"
},
{
@@ -179,6 +221,24 @@
"selector": "0x25e62788",
"file": "contracts/contracts/libraries/Formatter.sol"
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
"selector": "0x2822d0cb",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "ONLY_TEE_CAN_ACCESS",
"signature": "ONLY_TEE_CAN_ACCESS()",
@@ -197,6 +257,30 @@
"selector": "0x29393238",
"file": "contracts/contracts/tests/TestAirdrop.sol"
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "InvalidRootsHash",
"signature": "InvalidRootsHash()",
"selector": "0x372c4a4b",
"file": "contracts/contracts/registry/IdentityRegistryKycImplV1.sol"
},
{
"name": "InvalidFieldElement",
"signature": "InvalidFieldElement()",
@@ -377,6 +461,24 @@
"selector": "0x6f26ab8d",
"file": "contracts/contracts/libraries/RegisterProofVerifierLib.sol"
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
"selector": "0x712eb087",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "INVALID_PROOF",
"signature": "INVALID_PROOF()",
@@ -389,6 +491,24 @@
"selector": "0x71b125ed",
"file": "contracts/contracts/IdentityVerificationHubImplV1.sol"
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
"selector": "0x7f91b413",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "INVALID_IMAGE",
"signature": "INVALID_IMAGE()",
@@ -623,6 +743,24 @@
"selector": "0xed8cf9ff",
"file": "contracts/contracts/IdentityVerificationHubImplV1.sol"
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
"selector": "0xee57533e",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "INVALID_ROOT_CA",
"signature": "INVALID_ROOT_CA()",
@@ -671,6 +809,24 @@
"selector": "0xf53393a7",
"file": "contracts/contracts/libraries/RootCheckLib.sol"
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/contracts/registry/IdentityRegistryAadhaarImplV1.sol"
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol"
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",
"selector": "0xfc833fc6",
"file": "contracts/contracts/registry/IdentityRegistryImplV1.sol"
},
{
"name": "TEE_NOT_SET",
"signature": "TEE_NOT_SET()",

View File

@@ -4,6 +4,7 @@
"0x0b42b970": "InvalidPubSignalsLength",
"0x0ee78d58": "NoVerifierSet",
"0x118818d1": "INVALID_TIMESTAMP",
"0x128781c2": "InvalidRootsCount",
"0x12ec75fe": "InvalidAttestationId",
"0x153745d3": "RegistrationNotOpen",
"0x1644e049": "InvalidDscProof",
@@ -15,6 +16,7 @@
"0x25e62788": "InvalidMonthRange",
"0x2822d0cb": "ONLY_TEE_CAN_ACCESS",
"0x29393238": "UserIdentifierAlreadyRegistered",
"0x372c4a4b": "InvalidRootsHash",
"0x3ae4ed6b": "InvalidFieldElement",
"0x422cc3b7": "InvalidPubkey",
"0x49aecbc2": "InvalidOlderThan",

View File

@@ -46,7 +46,7 @@
"lint:headers": "node scripts/check-duplicate-headers.cjs . && node scripts/check-license-headers.mjs . --check",
"lint:headers:fix": "node scripts/check-duplicate-headers.cjs . && node scripts/check-license-headers.mjs . --fix",
"prepare": "husky",
"reinstall-app": "yarn install && (cd app && yarn clean:watchman && yarn clean:build && yarn clean:ios && yarn clean:xcode && yarn clean:pod-cache && yarn clean:android-deps && rm -rf app/node_modules) && yarn install && yarn workspace @selfxyz/mobile-app run install-app",
"reinstall-app": "yarn install && (cd app && yarn clean:watchman && yarn clean:build && yarn clean:ios && yarn clean:xcode && yarn clean:pod-cache && yarn clean:android-deps && yarn clean:ruby && yarn clean:node) && yarn install && yarn workspace @selfxyz/mobile-app run install-app",
"sort-package-jsons": "find . -name 'package.json' -not -path './node_modules/*' -not -path './*/node_modules/*' | xargs npx sort-package-json",
"test": "yarn workspaces foreach --parallel -i --all run test",
"test:license-headers": "cd scripts/tests && node check-license-headers.test.mjs",
@@ -67,7 +67,7 @@
"punycode": "npm:punycode.js@^2.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native-passkey": "3.3.2",
"react-native-passkey": "^3.3.3",
"react-native-webview": "13.16.0"
},
"dependencies": {

View File

@@ -74,6 +74,12 @@ android {
.toInt()
versionCode = 1
versionName = "1.0.0"
buildConfigField("String", "WEBVIEW_DEV_URL", "\"${System.getenv("WEBVIEW_DEV_URL") ?: ""}\"")
}
buildFeatures {
buildConfig = true
}
compileOptions {

View File

@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.testapp.screens
import xyz.self.testapp.BuildConfig
actual fun getDevServerUrl(): String? = BuildConfig.WEBVIEW_DEV_URL.ifBlank { null }

View File

@@ -48,6 +48,8 @@ import xyz.self.sdk.api.SelfSdkError
import xyz.self.sdk.api.VerificationRequest
import xyz.self.sdk.api.VerificationResult
expect fun getDevServerUrl(): String?
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SdkLaunchScreen(navController: NavController) {
@@ -65,14 +67,17 @@ fun SdkLaunchScreen(navController: NavController) {
val environment = if (useMockDocument) SelfEnvironment.STG else SelfEnvironment.PROD
val coroutineScope = rememberCoroutineScope()
val devServerUrl = remember { getDevServerUrl() }
val sdk =
remember(environment, appName, appEndpoint) {
SelfSdk.configure(
SelfSdkConfig(
environment = environment,
debug = true,
version = if (useMockDocument) 1 else 2,
appName = appName.ifBlank { null },
appEndpoint = appEndpoint.ifBlank { null },
devServerUrl = devServerUrl,
),
)
}

View File

@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.testapp.screens
import platform.Foundation.NSBundle
actual fun getDevServerUrl(): String? {
val value = NSBundle.mainBundle.objectForInfoDictionaryKey("WEBVIEW_DEV_URL") as? String
if (value.isNullOrBlank() || value.startsWith("$(")) return null
return value
}

View File

@@ -18,6 +18,8 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>WEBVIEW_DEV_URL</key>
<string>$(WEBVIEW_DEV_URL)</string>
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
<string>A0000002471001</string>

View File

@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"android": "./scripts/run-android.sh",
"ios": "./scripts/run-ios.sh",
"android:build": "./gradlew :composeApp:assembleDebug",
"clean": "./gradlew clean",
"format": "./gradlew ktlintFormat && cd iosApp && swiftlint --fix --format",

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
IOS_DIR="$SCRIPT_DIR/iosApp"
KMP_SDK_DIR="$SCRIPT_DIR/../kmp-sdk"
# --- Build KMP framework for iOS Simulator ---
echo "🔨 Building KMP framework for iOS Simulator..."
cd "$KMP_SDK_DIR"
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
# --- Resolve Xcode project dependencies ---
cd "$IOS_DIR"
echo "📦 Resolving package dependencies..."
xcodebuild -workspace iosApp.xcworkspace -resolvePackageDependencies -quiet 2>/dev/null || true
# --- Find an available iOS Simulator ---
echo "📱 Finding iOS Simulator..."
SIMULATOR_ID=$(xcrun simctl list devices available --json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for runtime, devices in data['devices'].items():
if 'iOS' not in runtime:
continue
for d in devices:
if d['isAvailable'] and 'iPhone' in d['name']:
print(d['udid'])
sys.exit(0)
print('')
")
if [ -z "$SIMULATOR_ID" ]; then
echo "❌ No available iPhone simulator found."
echo " Open Xcode > Settings > Platforms to install an iOS Simulator runtime."
exit 1
fi
SIMULATOR_NAME=$(xcrun simctl list devices available | grep "$SIMULATOR_ID" | sed 's/(.*//' | xargs)
echo "✅ Using simulator: $SIMULATOR_NAME ($SIMULATOR_ID)"
# --- Boot simulator if not already booted ---
BOOT_STATE=$(xcrun simctl list devices | grep "$SIMULATOR_ID" | grep -o "(Booted)" || true)
if [ -z "$BOOT_STATE" ]; then
echo "🚀 Booting simulator..."
xcrun simctl boot "$SIMULATOR_ID" 2>/dev/null || true
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID"
sleep 3
fi
# --- Build the app ---
echo "🔨 Building iOS app..."
xcodebuild -workspace iosApp.xcworkspace \
-scheme iosApp \
-sdk iphonesimulator \
-destination "id=$SIMULATOR_ID" \
ONLY_ACTIVE_ARCH=YES \
ARCHS=arm64 \
WEBVIEW_DEV_URL="${WEBVIEW_DEV_URL:-}" \
build \
2>&1 | tail -5
# --- Find and install the app ---
BUILD_DIR=$(xcodebuild -workspace iosApp.xcworkspace \
-scheme iosApp \
-sdk iphonesimulator \
-showBuildSettings -json 2>/dev/null \
| jq -r '.[] | select(.target == "iosApp") | .buildSettings.BUILT_PRODUCTS_DIR')
APP_PATH="$BUILD_DIR/iosApp.app"
if [ ! -d "$APP_PATH" ]; then
echo "❌ Build output not found at $APP_PATH"
exit 1
fi
echo "📦 Installing app on simulator..."
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
# --- Launch the app ---
BUNDLE_ID=$(defaults read "$APP_PATH/Info.plist" CFBundleIdentifier 2>/dev/null || echo "xyz.self.testapp")
echo "🚀 Launching $BUNDLE_ID..."
xcrun simctl launch "$SIMULATOR_ID" "$BUNDLE_ID"
echo "✅ Done!"

View File

@@ -124,6 +124,9 @@ actual class SelfSdk private constructor(
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.debug)
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_REQUEST, serializeRequest(request))
putExtra(SelfVerificationActivity.EXTRA_CONFIG, serializeConfig(config))
if (config.devServerUrl != null) {
putExtra(SelfVerificationActivity.EXTRA_DEV_SERVER_URL, config.devServerUrl)
}
}
// Launch the verification activity

View File

@@ -11,7 +11,6 @@ import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.net.http.SslError
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
@@ -21,12 +20,17 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import xyz.self.sdk.bridge.MessageRouter
class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
private val devServerUrl: String? = null,
) {
private lateinit var webView: WebView
var pendingPermissionRequest: PermissionRequest? = null
@@ -53,13 +57,7 @@ class AndroidWebViewHost(
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val uri = request?.url ?: return true
val isAllowed =
(uri.scheme == "https" && uri.host == "self-app-alpha.vercel.app") ||
(isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173)
return !isAllowed
}
): Boolean = !isAllowedNavigationUrl(request?.url?.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl)
override fun onReceivedSslError(
view: WebView?,
@@ -79,11 +77,7 @@ class AndroidWebViewHost(
request.deny()
return
}
val isTrusted =
(origin.scheme == "https" && origin.host == "self-app-alpha.vercel.app") ||
(origin.scheme == "https" && origin.host == "verify.didit.me") ||
(isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1")
if (!isTrusted) {
if (!isTrustedPermissionOrigin(origin.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl)) {
request.deny()
return
}
@@ -154,11 +148,9 @@ class AndroidWebViewHost(
}
}
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
installBridge(webView = this)
val baseUrl = "https://self-app-alpha.vercel.app/tunnel/tour/1"
val url = if (queryParams.isNotEmpty()) "$baseUrl?$queryParams" else baseUrl
loadUrl(url)
loadUrl(initialContentUrl(queryParams, isDebugMode, remoteWebAppBaseUrl, devServerUrl))
}
return webView
}
@@ -173,15 +165,172 @@ class AndroidWebViewHost(
webView.destroy()
}
inner class BridgeJsInterface {
@JavascriptInterface
fun postMessage(json: String) {
router.onMessageReceived(json)
private fun installBridge(webView: WebView) {
check(WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
"WEB_MESSAGE_LISTENER not supported — native bridge unavailable on this device"
}
WebViewCompat.addWebMessageListener(
webView,
"SelfNativeAndroid",
buildAllowedOriginRules(isDebugMode, remoteWebAppBaseUrl, devServerUrl),
) { _, message: WebMessageCompat, sourceOrigin, isMainFrame, _ ->
if (!isMainFrame) {
return@addWebMessageListener
}
val rawJson = message.data ?: return@addWebMessageListener
router.onMessageReceived(
rawJson = rawJson,
isTrustedSource = isTrustedBridgeOrigin(sourceOrigin.toString(), isDebugMode, remoteWebAppBaseUrl, devServerUrl),
)
}
}
companion object {
const val FILE_CHOOSER_REQUEST_CODE = 1001
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
private const val BUNDLED_TOUR_PATH = "/tunnel/tour/1"
private const val DEBUG_HOST = "127.0.0.1"
private const val DEBUG_PORT = 5173
private const val DIDIT_HOST = "verify.didit.me"
internal fun initialContentUrl(
queryParams: String,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
devServerUrl: String? = null,
): String {
val baseUrl =
when {
isDebugMode && devServerUrl != null -> devServerUrl.trimEnd('/')
isDebugMode -> "http://$DEBUG_HOST:$DEBUG_PORT"
else -> {
require(remoteWebAppBaseUrl.startsWith("https://")) {
"remoteWebAppBaseUrl must use HTTPS in release builds"
}
remoteWebAppBaseUrl.trimEnd('/')
}
}
return buildString {
append(baseUrl).append(BUNDLED_TOUR_PATH)
if (queryParams.isNotEmpty()) {
append("?").append(queryParams)
}
}
}
internal fun isAllowedNavigationUrl(
rawUrl: String?,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
devServerUrl: String? = null,
): Boolean =
isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) ||
isDiditUrl(rawUrl) ||
(isDebugMode && isDebugLocalUrl(rawUrl)) ||
(isDebugMode && isDevServerUrl(rawUrl, devServerUrl))
internal fun isTrustedPermissionOrigin(
rawUrl: String?,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
devServerUrl: String? = null,
): Boolean =
isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) ||
isDiditUrl(rawUrl) ||
(isDebugMode && isDebugLocalUrl(rawUrl)) ||
(isDebugMode && isDevServerUrl(rawUrl, devServerUrl))
internal fun isTrustedBridgeOrigin(
rawUrl: String?,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
devServerUrl: String? = null,
): Boolean =
isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) ||
(isDebugMode && isDebugLocalUrl(rawUrl)) ||
(isDebugMode && isDevServerUrl(rawUrl, devServerUrl))
internal fun isRemoteOrigin(
rawUrl: String?,
remoteWebAppBaseUrl: String?,
): Boolean {
if (rawUrl == null || remoteWebAppBaseUrl == null) return false
val url = parseUri(rawUrl) ?: return false
val remote = parseUri(remoteWebAppBaseUrl) ?: return false
return url.scheme == remote.scheme &&
(url.host ?: url.authority) == (remote.host ?: remote.authority) &&
resolvedPort(url) == resolvedPort(remote)
}
private fun isDiditUrl(rawUrl: String?): Boolean {
val port = uriPort(rawUrl)
return uriScheme(rawUrl) == "https" &&
uriHost(rawUrl) == DIDIT_HOST &&
(port == null || port == 443)
}
private fun isDebugLocalUrl(rawUrl: String?): Boolean =
uriScheme(rawUrl) == "http" && uriHost(rawUrl) == DEBUG_HOST && uriPort(rawUrl) == DEBUG_PORT
private fun isDevServerUrl(
rawUrl: String?,
devServerUrl: String?,
): Boolean {
if (rawUrl == null || devServerUrl == null) return false
val url = parseUri(rawUrl) ?: return false
val dev = parseUri(devServerUrl) ?: return false
return url.scheme == dev.scheme &&
(url.host ?: url.authority) == (dev.host ?: dev.authority) &&
resolvedPort(url) == resolvedPort(dev)
}
private fun buildAllowedOriginRules(
isDebugMode: Boolean,
remoteWebAppBaseUrl: String,
devServerUrl: String? = null,
): Set<String> {
val remote = parseUri(remoteWebAppBaseUrl)
return buildSet {
if (remote != null && remote.scheme == "https") {
val host = remote.host ?: remote.authority
val port = resolvedPort(remote)
val defaultPort = if (remote.scheme == "https") 443 else 80
if (port != defaultPort) {
add("${remote.scheme}://$host:$port")
} else {
add("${remote.scheme}://$host")
}
}
if (isDebugMode) {
add("http://$DEBUG_HOST:$DEBUG_PORT")
devServerUrl?.let { parseUri(it) }?.let { dev ->
val host = dev.host ?: dev.authority
val port = resolvedPort(dev)
val defaultPort = if (dev.scheme == "https") 443 else 80
if (port != defaultPort) {
add("${dev.scheme}://$host:$port")
} else {
add("${dev.scheme}://$host")
}
}
}
}
}
private fun resolvedPort(uri: java.net.URI): Int {
val port = uri.port
if (port != -1) return port
return if (uri.scheme == "https") 443 else 80
}
private fun uriScheme(rawUrl: String?): String? = parseUri(rawUrl)?.scheme
private fun uriHost(rawUrl: String?): String? = parseUri(rawUrl)?.host ?: parseUri(rawUrl)?.authority
private fun uriPort(rawUrl: String?): Int? = parseUri(rawUrl)?.port?.takeIf { it != -1 }
private fun parseUri(rawUrl: String?): java.net.URI? = rawUrl?.let { raw -> runCatching { java.net.URI(raw) }.getOrNull() }
}
}

View File

@@ -8,8 +8,13 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import xyz.self.sdk.bridge.MessageRouter
import xyz.self.sdk.handlers.CryptoBridgeHandler
import xyz.self.sdk.handlers.LifecycleBridgeHandler
@@ -21,9 +26,11 @@ import xyz.self.sdk.providers.SdkProviderRegistry
class SelfVerificationActivity : AppCompatActivity() {
private lateinit var webViewHost: AndroidWebViewHost
private lateinit var router: MessageRouter
private var container: FrameLayout? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
initVerificationFlow()
}
@@ -63,9 +70,43 @@ class SelfVerificationActivity : AppCompatActivity() {
return
}
webViewHost = AndroidWebViewHost(this, router, isDebugMode)
val configJson = intent.getStringExtra(EXTRA_CONFIG) ?: "{}"
val remoteWebAppBaseUrl =
try {
org.json.JSONObject(configJson).optString("remoteWebAppBaseUrl", "https://self-app-alpha.vercel.app")
} catch (_: Exception) {
"https://self-app-alpha.vercel.app"
}
val devServerUrl = intent.getStringExtra(EXTRA_DEV_SERVER_URL)
webViewHost = AndroidWebViewHost(this, router, isDebugMode, remoteWebAppBaseUrl, devServerUrl)
val webView = webViewHost.createWebView(queryParams)
setContentView(webView)
val wrapper =
FrameLayout(this).apply {
addView(
webView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
}
this.container = wrapper
setContentView(wrapper)
ViewCompat.setOnApplyWindowInsetsListener(wrapper) { view, insets ->
val systemInsets =
insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
)
view.setPadding(
systemInsets.left,
systemInsets.top,
systemInsets.right,
systemInsets.bottom,
)
WindowInsetsCompat.CONSUMED
}
}
private fun registerHandlers() {
@@ -169,6 +210,7 @@ class SelfVerificationActivity : AppCompatActivity() {
}
override fun onDestroy() {
container?.let { ViewCompat.setOnApplyWindowInsetsListener(it, null) }
if (::webViewHost.isInitialized) {
webViewHost.destroy()
}
@@ -179,6 +221,7 @@ class SelfVerificationActivity : AppCompatActivity() {
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
const val EXTRA_VERIFICATION_REQUEST = "xyz.self.sdk.VERIFICATION_REQUEST"
const val EXTRA_CONFIG = "xyz.self.sdk.CONFIG"
const val EXTRA_DEV_SERVER_URL = "xyz.self.sdk.DEV_SERVER_URL"
const val RESULT_CODE_SUCCESS = RESULT_OK
const val RESULT_CODE_ERROR = RESULT_FIRST_USER

View File

@@ -0,0 +1,121 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
package xyz.self.sdk.webview
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AndroidWebViewHostSecurityTest {
private val remoteUrl = "https://self-app-alpha.vercel.app"
@Test
fun `release builds launch remote content`() {
assertEquals(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
AndroidWebViewHost.initialContentUrl(queryParams = "", isDebugMode = false, remoteWebAppBaseUrl = remoteUrl),
)
}
@Test
fun `debug builds launch localhost content`() {
assertEquals(
"http://127.0.0.1:5173/tunnel/tour/1",
AndroidWebViewHost.initialContentUrl(queryParams = "", isDebugMode = true, remoteWebAppBaseUrl = remoteUrl),
)
}
@Test
fun `navigation allows remote origin and didit`() {
assertTrue(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
assertTrue(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://verify.didit.me/session/123",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
assertTrue(
AndroidWebViewHost.isAllowedNavigationUrl(
"http://127.0.0.1:5173/tunnel/tour/1",
isDebugMode = true,
remoteWebAppBaseUrl = remoteUrl,
),
)
}
@Test
fun `navigation rejects arbitrary origins`() {
assertFalse(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://evil.com/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
assertFalse(
AndroidWebViewHost.isAllowedNavigationUrl(
"http://example.com/test",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
}
@Test
fun `release build rejects HTTP base URL`() {
assertFailsWith<IllegalArgumentException> {
AndroidWebViewHost.initialContentUrl(
queryParams = "",
isDebugMode = false,
remoteWebAppBaseUrl = "http://self-app-alpha.vercel.app",
)
}
}
@Test
fun `didit on non-443 port is rejected`() {
assertFalse(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://verify.didit.me:8443/session/123",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
}
@Test
fun `bridge trust is limited to remote origin in release`() {
assertTrue(
AndroidWebViewHost.isTrustedBridgeOrigin(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
assertFalse(
AndroidWebViewHost.isTrustedBridgeOrigin(
"https://verify.didit.me/session/123",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
assertFalse(
AndroidWebViewHost.isTrustedBridgeOrigin(
"https://evil.com/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteUrl,
),
)
}
}

View File

@@ -34,4 +34,6 @@ data class SelfSdkConfig(
val appEndpoint: String? = null,
val endpointType: String? = null,
val chainID: Int? = null,
val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
val devServerUrl: String? = null,
)

View File

@@ -27,7 +27,14 @@ class MessageRouter(
handlers[handler.domain] = handler
}
fun onMessageReceived(rawJson: String) {
fun onMessageReceived(
rawJson: String,
isTrustedSource: Boolean,
) {
if (!isTrustedSource) {
return // Drop messages from untrusted WebView origins.
}
val request =
try {
json.decodeFromString<BridgeRequest>(rawJson)

View File

@@ -44,7 +44,7 @@ class MessageRouterTest {
{"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123}
""".trimIndent()
router.onMessageReceived(request)
router.onMessageReceived(request, isTrustedSource = true)
assertEquals(1, responses.size)
assertTrue(responses[0].contains("_handleResponse"))
@@ -62,7 +62,7 @@ class MessageRouterTest {
{"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123}
""".trimIndent()
router.onMessageReceived(request)
router.onMessageReceived(request, isTrustedSource = true)
assertEquals(1, responses.size)
assertTrue(responses[0].contains("DOMAIN_NOT_FOUND"))
@@ -95,7 +95,7 @@ class MessageRouterTest {
{"type":"request","version":1,"id":"req-2","domain":"crypto","method":"sign","params":{},"timestamp":123}
""".trimIndent()
router.onMessageReceived(request)
router.onMessageReceived(request, isTrustedSource = true)
assertEquals(1, responses.size)
assertTrue(responses[0].contains("KEY_NOT_FOUND"))
@@ -117,11 +117,32 @@ class MessageRouterTest {
val responses = mutableListOf<String>()
val router = MessageRouter(sendToWebView = { responses.add(it) })
router.onMessageReceived("this is not json")
router.onMessageReceived("this is not json", isTrustedSource = true)
assertEquals(0, responses.size)
}
@Test
fun drops_messages_from_untrusted_origins_before_dispatch() =
runTest {
val responses = mutableListOf<String>()
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
val router =
MessageRouter(
sendToWebView = { responses.add(it) },
scope = testScope,
)
val handler = FakeBridgeHandler(domain = BridgeDomain.HAPTIC, response = JsonPrimitive("ok"))
router.register(handler)
val untrustedJson =
"""{"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123}"""
router.onMessageReceived(rawJson = untrustedJson, isTrustedSource = false)
assertEquals(0, responses.size)
assertEquals(0, handler.invocations.size)
}
@Test
fun pushEvent_sends_handleEvent_to_webview() {
val responses = mutableListOf<String>()
@@ -160,6 +181,7 @@ class MessageRouterTest {
repeat(3) { i ->
router.onMessageReceived(
"""{"type":"request","version":1,"id":"req-$i","domain":"haptic","method":"trigger","params":{},"timestamp":123}""",
isTrustedSource = true,
)
}
@@ -188,6 +210,7 @@ class MessageRouterTest {
router.onMessageReceived(
"""{"type":"request","version":1,"id":"req-1","domain":"haptic","method":"trigger","params":{},"timestamp":123}""",
isTrustedSource = true,
)
assertEquals(1, hapticHandler.invocations.size)
@@ -214,6 +237,7 @@ class MessageRouterTest {
router.onMessageReceived(
"""{"type":"request","version":1,"id":"req-1","domain":"nfc","method":"scan","params":{},"timestamp":123}""",
isTrustedSource = true,
)
assertEquals(0, handlerA.invocations.size)
@@ -235,6 +259,7 @@ class MessageRouterTest {
router.onMessageReceived(
"""{"type":"request","version":1,"id":"my-unique-req-id","domain":"haptic","method":"trigger","params":{},"timestamp":123}""",
isTrustedSource = true,
)
assertEquals(1, responses.size)
@@ -275,6 +300,7 @@ class MessageRouterTest {
router.onMessageReceived(
"""{"type":"request","version":1,"id":"req-1","domain":"crypto","method":"sign","params":{},"timestamp":123}""",
isTrustedSource = true,
)
assertEquals(1, responses.size)

View File

@@ -100,7 +100,13 @@ actual class SelfSdk private constructor(
val queryParams = buildQueryParams(request)
// Create WebView host and the web view
webViewHost = IosWebViewHost(router!!, config.debug)
webViewHost =
IosWebViewHost(
router!!,
config.debug,
remoteWebAppBaseUrl = config.remoteWebAppBaseUrl,
devServerUrl = config.devServerUrl,
)
webViewHost!!.createWebView(queryParams)
// Get the ViewController from the WebView provider and present it

View File

@@ -19,4 +19,10 @@ interface WebViewProvider {
fun evaluateJs(js: String)
fun getViewController(): UIViewController
fun isBridgeRequestAllowed(): Boolean
fun configureRemoteLoading(remoteWebAppBaseURL: String?) {}
fun configureDevServer(devServerUrl: String?) {}
}

View File

@@ -14,15 +14,23 @@ import xyz.self.sdk.providers.IosProviderRegistry
class IosWebViewHost(
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
private val devServerUrl: String? = null,
) {
fun createWebView(queryParams: String? = null): UIView {
val provider =
IosProviderRegistry.webView
?: throw IllegalStateException("WebView provider not configured")
provider.configureRemoteLoading(remoteWebAppBaseUrl)
provider.configureDevServer(devServerUrl)
return provider.createWebView(
onMessageReceived = { rawJson ->
router.onMessageReceived(rawJson)
router.onMessageReceived(
rawJson = rawJson,
isTrustedSource = provider.isBridgeRequestAllowed(),
)
},
isDebugMode = isDebugMode,
queryParams = queryParams,

View File

@@ -19,22 +19,23 @@
"types": ["vitest/globals", "react"],
"baseUrl": ".",
"paths": {
"@selfxyz/common": ["../../common/dist/esm/index"],
"@selfxyz/common/constants": ["../../common/dist/esm/src/constants/index"],
"@selfxyz/common/constants/countries": ["../../common/dist/esm/src/constants/countries"],
"@selfxyz/common/types": ["../../common/dist/esm/src/types/index"],
"@selfxyz/common/types/passport": ["../../common/dist/esm/src/types/passport"],
"@selfxyz/common/utils": ["../../common/dist/esm/src/utils/index"],
"@selfxyz/common/utils/attest": ["../../common/dist/esm/src/utils/attest"],
"@selfxyz/common/utils/circuits/registerInputs": ["../../common/dist/esm/src/utils/circuits/registerInputs"],
"@selfxyz/common/utils/csca": ["../../common/dist/esm/src/utils/csca"],
"@selfxyz/common/utils/hash/sha": ["../../common/dist/esm/src/utils/hash/sha"],
"@selfxyz/common/utils/passportFormat": ["../../common/dist/esm/src/utils/passports/format"],
"@selfxyz/common/utils/passports": ["../../common/dist/esm/src/utils/passports/index"],
"@selfxyz/common/utils/passports/validate": ["../../common/dist/esm/src/utils/passports/validate"],
"@selfxyz/common/utils/proving": ["../../common/dist/esm/src/utils/proving"],
"@selfxyz/common/utils/types": ["../../common/dist/esm/src/utils/types"]
"@selfxyz/common": ["../../common/index.ts"],
"@selfxyz/common/constants": ["../../common/src/constants/index.ts"],
"@selfxyz/common/constants/countries": ["../../common/src/constants/countries.ts"],
"@selfxyz/common/types": ["../../common/src/types/index.ts"],
"@selfxyz/common/types/passport": ["../../common/src/types/passport.ts"],
"@selfxyz/common/utils": ["../../common/src/utils/index.ts"],
"@selfxyz/common/utils/attest": ["../../common/src/utils/attest.ts"],
"@selfxyz/common/utils/circuits/registerInputs": ["../../common/src/utils/circuits/registerInputs.ts"],
"@selfxyz/common/utils/csca": ["../../common/src/utils/csca.ts"],
"@selfxyz/common/utils/hash/sha": ["../../common/src/utils/hash/sha.ts"],
"@selfxyz/common/utils/passportFormat": ["../../common/src/utils/passports/format.ts"],
"@selfxyz/common/utils/passports": ["../../common/src/utils/passports/index.ts"],
"@selfxyz/common/utils/passports/validate": ["../../common/src/utils/passports/validate.ts"],
"@selfxyz/common/utils/proving": ["../../common/src/utils/proving.ts"],
"@selfxyz/common/utils/types": ["../../common/src/utils/types.ts"]
}
},
"include": ["src", "tests"]
"include": ["src", "tests"],
"references": [{ "path": "../../common" }]
}

View File

@@ -53,23 +53,6 @@ android {
}
}
tasks.register("validateWebViewBundle") {
doLast {
val bundleDir = file("src/main/assets/self-wallet")
val indexFile = file("src/main/assets/self-wallet/index.html")
if (!bundleDir.exists() || !indexFile.exists()) {
throw GradleException(
"WebView bundle not found at src/main/assets/self-wallet/index.html. " +
"Run ./scripts/build-webview-bundle.sh from the repo root first.",
)
}
}
}
tasks.named("preBuild") {
dependsOn("validateWebViewBundle")
}
dependencies {
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.webkit:webkit:1.9.0")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
@font-face{font-family:Advercase;src:url(/fonts/Advercase-Regular.otf) format("opentype");font-weight:400;font-display:swap}@font-face{font-family:DIN OT;src:url(/fonts/DINOT-Medium.otf) format("opentype");font-weight:500;font-display:swap}@font-face{font-family:DIN OT;src:url(/fonts/DINOT-Bold.otf) format("opentype");font-weight:700;font-display:swap}@font-face{font-family:IBM Plex Mono;src:url(/fonts/IBMPlexMono-Regular.otf) format("opentype");font-weight:400;font-display:swap}.launch-recovery-screen{display:flex;flex:1;min-height:0}.launch-recovery-screen img[src$="/backgrounds/restore.png"],.launch-recovery-screen img[src$="restore.png"]{height:auto!important;object-fit:contain!important;object-position:top center!important}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;width:100%;overflow:hidden}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}.tour4-lottie-scale{display:flex;flex-direction:column;height:100%}.tour4-lottie-scale svg{transform:scale(1.35)!important}@keyframes spin{to{transform:rotate(360deg)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -35,10 +35,7 @@ object SelfSdk {
config.chainID?.let { putExtra(SelfVerificationActivity.EXTRA_CHAIN_ID, it) }
config.userDefinedData?.let { putExtra(SelfVerificationActivity.EXTRA_USER_DEFINED_DATA, it) }
config.selfDefinedData?.let { putExtra(SelfVerificationActivity.EXTRA_SELF_DEFINED_DATA, it) }
config.remoteWebAppBaseUrl?.let { putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_BASE_URL, it) }
config.remoteWebAppIntegritySha256?.let {
putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256, it)
}
putExtra(SelfVerificationActivity.EXTRA_REMOTE_WEB_APP_BASE_URL, config.remoteWebAppBaseUrl)
}
activity.startActivityForResult(intent, requestCode)
}

View File

@@ -19,8 +19,7 @@ data class SelfSdkConfig(
val chainID: Int? = null,
val userDefinedData: String? = null,
val selfDefinedData: String? = null,
val remoteWebAppBaseUrl: String? = null,
val remoteWebAppIntegritySha256: String? = null,
val remoteWebAppBaseUrl: String = "https://self-app-alpha.vercel.app",
)
class SelfSdkException(

View File

@@ -26,7 +26,15 @@ class MessageRouter(
handlers[handler.domain] = handler
}
fun onMessageReceived(rawJson: String) {
fun onMessageReceived(
rawJson: String,
isTrustedSource: Boolean,
) {
if (!isTrustedSource) {
android.util.Log.w("BridgeRouter", "Dropped message from untrusted WebView origin")
return
}
val request =
try {
json.decodeFromString<BridgeRequest>(rawJson)

View File

@@ -10,29 +10,26 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.net.http.SslError
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebMessageCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import xyz.self.sdk.bridge.MessageRouter
import java.net.HttpURLConnection
import java.net.URI
import java.net.URL
class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
private val remoteWebAppBaseUrl: String? = null,
private val remoteWebAppIntegritySha256: String? = null,
private val remoteWebAppBaseUrl: String = DEFAULT_REMOTE_BASE_URL,
) {
private lateinit var webView: WebView
@@ -44,42 +41,6 @@ class AndroidWebViewHost(
@SuppressLint("SetJavaScriptEnabled")
fun createWebView(queryParams: String): WebView {
isDestroyed = false
val selfWalletHandler =
WebViewAssetLoader.PathHandler { rawPath ->
try {
val normalizedPath = rawPath.removePrefix("/")
val assetPath =
if (normalizedPath.isEmpty() || !normalizedPath.contains('.')) {
"self-wallet/index.html"
} else {
"self-wallet/$normalizedPath"
}
val inputStream = context.assets.open(assetPath)
val mimeType =
when {
assetPath.endsWith(".js") -> "application/javascript"
assetPath.endsWith(".css") -> "text/css"
assetPath.endsWith(".html") -> "text/html"
assetPath.endsWith(".json") -> "application/json"
assetPath.endsWith(".woff2") -> "font/woff2"
assetPath.endsWith(".woff") -> "font/woff"
assetPath.endsWith(".otf") -> "font/otf"
assetPath.endsWith(".ttf") -> "font/ttf"
assetPath.endsWith(".png") -> "image/png"
assetPath.endsWith(".svg") -> "image/svg+xml"
else -> "application/octet-stream"
}
WebResourceResponse(mimeType, "UTF-8", inputStream)
} catch (_: Exception) {
null
}
}
val assetLoader =
WebViewAssetLoader
.Builder()
.addPathHandler("/", selfWalletHandler)
.build()
webView =
WebView(context).apply {
@@ -97,26 +58,15 @@ class AndroidWebViewHost(
webViewClient =
object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
request ?: return null
val url = request.url
if (url.host != BUNDLED_HOST) return null
return assetLoader.shouldInterceptRequest(url)
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url ?: return true
if (isBundledOrigin(url)) return false
if (isDebugMode && isDebugOrigin(url)) return false
if (isAllowedRemoteOrigin(url.toString())) return false
return true
}
): Boolean =
!isAllowedNavigationUrl(
request?.url?.toString(),
isDebugMode,
remoteWebAppBaseUrl,
)
override fun onReceivedSslError(
view: WebView?,
@@ -132,14 +82,13 @@ class AndroidWebViewHost(
override fun onPermissionRequest(request: PermissionRequest?) {
request ?: return
val originStr = request.origin?.toString() ?: ""
val originUri = Uri.parse(originStr)
val isTrusted =
isBundledOrigin(originUri) ||
(isDebugMode && isDebugOrigin(originUri)) ||
isMatchingOrigin(originUri, "https", "verify.didit.me", 443) ||
isAllowedRemoteOrigin(originStr)
if (!isTrusted) {
if (
!isTrustedPermissionOrigin(
request.origin?.toString(),
isDebugMode,
remoteWebAppBaseUrl,
)
) {
request.deny()
return
}
@@ -150,11 +99,20 @@ class AndroidWebViewHost(
return
}
val allowedResources =
request.resources.filter {
it == PermissionRequest.RESOURCE_VIDEO_CAPTURE ||
it == PermissionRequest.RESOURCE_AUDIO_CAPTURE
}
if (allowedResources.size != request.resources.size) {
request.deny()
return
}
val neededPermissions = mutableListOf<String>()
if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
if (allowedResources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
neededPermissions.add(Manifest.permission.CAMERA)
}
if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
if (allowedResources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
neededPermissions.add(Manifest.permission.RECORD_AUDIO)
}
@@ -173,7 +131,7 @@ class AndroidWebViewHost(
return
}
request.grant(request.resources)
request.grant(allowedResources.toTypedArray())
}
override fun onShowFileChooser(
@@ -200,14 +158,9 @@ class AndroidWebViewHost(
}
}
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
installBridge(webView = this)
if (isDebugMode) {
loadUrl(buildDebugUrl(queryParams))
} else {
loadUrl(buildBundledUrl(queryParams))
maybeLoadVerifiedRemoteContent(queryParams)
}
loadUrl(initialContentUrl(queryParams, isDebugMode, remoteWebAppBaseUrl))
}
return webView
}
@@ -226,123 +179,145 @@ class AndroidWebViewHost(
webView.destroy()
}
private fun buildBundledUrl(queryParams: String): String = buildEntryUrl(BUNDLED_ORIGIN, queryParams)
private fun buildDebugUrl(queryParams: String): String = buildEntryUrl(DEBUG_ORIGIN, queryParams)
private fun buildRemoteUrl(queryParams: String): String? = RemoteNavigationPolicy.buildRemoteEntryUrl(remoteWebAppBaseUrl, queryParams)
private fun buildEntryUrl(
baseUrl: String,
queryParams: String,
): String {
val separator = if (queryParams.isEmpty()) "" else "?$queryParams"
return "$baseUrl/tunnel/tour/1$separator"
}
private fun maybeLoadVerifiedRemoteContent(queryParams: String) {
val remoteUrl = buildRemoteUrl(queryParams) ?: return
val expectedSha256 = remoteWebAppIntegritySha256?.takeIf { it.isNotBlank() } ?: return
Thread {
val verifiedHtml = fetchAndVerifyRemoteEntry(remoteUrl, expectedSha256)
if (verifiedHtml == null || !::webView.isInitialized || isDestroyed) {
return@Thread
}
webView.post {
if (::webView.isInitialized && !isDestroyed) {
webView.loadDataWithBaseURL(
remoteUrl,
verifiedHtml,
"text/html",
"UTF-8",
null,
)
}
}
}.start()
}
private fun fetchAndVerifyRemoteEntry(
remoteUrl: String,
expectedSha256: String,
): String? =
try {
val connection = URL(remoteUrl).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.instanceFollowRedirects = false
connection.connectTimeout = 5_000
connection.readTimeout = 5_000
connection.connect()
if (connection.responseCode !in 200..299) {
Log.w("WebViewHost", "Remote web app integrity check failed with HTTP ${connection.responseCode}")
null
} else if (!RemoteContentIntegrity.isAcceptableContentType(connection.contentType)) {
Log.w("WebViewHost", "Remote web app integrity check failed due to unexpected content type ${connection.contentType}")
null
} else {
val body =
connection.inputStream.use { stream ->
val buffer = java.io.ByteArrayOutputStream()
val chunk = ByteArray(8192)
var totalRead = 0
var bytesRead: Int
while (stream.read(chunk).also { bytesRead = it } != -1) {
totalRead += bytesRead
if (totalRead > MAX_REMOTE_ENTRY_BYTES) {
throw IllegalStateException("Remote entry response exceeded ${MAX_REMOTE_ENTRY_BYTES} bytes")
}
buffer.write(chunk, 0, bytesRead)
}
buffer.toByteArray()
}
if (sha256Hex(body) == normalizeSha256(expectedSha256)) {
String(body, Charsets.UTF_8)
} else {
Log.w("WebViewHost", "Remote web app integrity check failed: hash mismatch")
null
}
}
} catch (error: Exception) {
Log.w("WebViewHost", "Remote web app integrity check failed", error)
null
private fun installBridge(webView: WebView) {
check(WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
"WEB_MESSAGE_LISTENER not supported — native bridge unavailable on this device"
}
private fun isAllowedRemoteOrigin(url: String): Boolean = RemoteNavigationPolicy.isAllowedRemoteOrigin(url, remoteWebAppBaseUrl)
WebViewCompat.addWebMessageListener(
webView,
"SelfNativeAndroid",
buildAllowedOriginRules(isDebugMode, remoteWebAppBaseUrl),
) { _, message: WebMessageCompat, sourceOrigin, isMainFrame, _ ->
if (!isMainFrame) {
return@addWebMessageListener
}
private fun resolvePort(uri: Uri): Int = RemoteNavigationPolicy.resolvePort(URI(uri.toString()))
private fun sha256Hex(bytes: ByteArray): String = RemoteContentIntegrity.sha256Hex(bytes)
private fun normalizeSha256(value: String): String = RemoteContentIntegrity.normalizeSha256(value)
inner class BridgeJsInterface {
@JavascriptInterface
fun postMessage(json: String) {
router.onMessageReceived(json)
val rawJson = message.data ?: return@addWebMessageListener
router.onMessageReceived(
rawJson = rawJson,
isTrustedSource =
isTrustedBridgeOrigin(
sourceOrigin.toString(),
isDebugMode,
remoteWebAppBaseUrl,
),
)
}
}
private fun isBundledOrigin(uri: Uri): Boolean = isMatchingOrigin(uri, "https", BUNDLED_HOST, 443)
private fun isDebugOrigin(uri: Uri): Boolean = isMatchingOrigin(uri, "http", "127.0.0.1", 5173)
private fun isMatchingOrigin(
uri: Uri,
scheme: String,
host: String,
port: Int,
): Boolean = uri.scheme == scheme && uri.host == host && resolvePort(uri) == port
companion object {
private const val BUNDLED_HOST = "appassets.androidplatform.net"
private const val BUNDLED_ORIGIN = "https://$BUNDLED_HOST"
private const val DEBUG_ORIGIN = "http://127.0.0.1:5173"
const val FILE_CHOOSER_REQUEST_CODE = 1001
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
private const val MAX_REMOTE_ENTRY_BYTES = 5 * 1024 * 1024
private const val DEFAULT_REMOTE_BASE_URL = "https://self-app-alpha.vercel.app"
private const val BUNDLED_TOUR_PATH = "/tunnel/tour/1"
private const val DEBUG_HOST = "127.0.0.1"
private const val DEBUG_PORT = 5173
private const val DIDIT_HOST = "verify.didit.me"
internal fun initialContentUrl(
queryParams: String,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String = DEFAULT_REMOTE_BASE_URL,
): String =
if (isDebugMode) {
buildString {
append("http://")
.append(DEBUG_HOST)
.append(":")
.append(DEBUG_PORT)
.append(BUNDLED_TOUR_PATH)
if (queryParams.isNotEmpty()) {
append("?").append(queryParams)
}
}
} else {
require(remoteWebAppBaseUrl.startsWith("https://")) {
"remoteWebAppBaseUrl must use HTTPS in release builds"
}
buildString {
append(remoteWebAppBaseUrl.trimEnd('/')).append(BUNDLED_TOUR_PATH)
if (queryParams.isNotEmpty()) {
append("?").append(queryParams)
}
}
}
internal fun isAllowedNavigationUrl(
rawUrl: String?,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
): Boolean =
isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) ||
isDiditUrl(rawUrl) ||
(isDebugMode && isDebugLocalUrl(rawUrl))
internal fun isTrustedPermissionOrigin(
rawUrl: String?,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
): Boolean =
isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) ||
isDiditUrl(rawUrl) ||
(isDebugMode && isDebugLocalUrl(rawUrl))
internal fun isTrustedBridgeOrigin(
rawUrl: String?,
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
): Boolean =
isRemoteOrigin(rawUrl, remoteWebAppBaseUrl) ||
(isDebugMode && isDebugLocalUrl(rawUrl))
private fun isDiditUrl(rawUrl: String?): Boolean {
val port = uriPort(rawUrl)
return uriScheme(rawUrl) == "https" &&
uriHost(rawUrl) == DIDIT_HOST &&
(port == null || port == 443)
}
private fun isDebugLocalUrl(rawUrl: String?): Boolean =
uriScheme(rawUrl) == "http" && uriHost(rawUrl) == DEBUG_HOST && uriPort(rawUrl) == DEBUG_PORT
private fun buildAllowedOriginRules(
isDebugMode: Boolean,
remoteWebAppBaseUrl: String? = null,
): Set<String> =
buildSet {
remoteWebAppBaseUrl
?.let(::buildOriginRule)
?.let(::add)
if (isDebugMode) {
add("http://$DEBUG_HOST:$DEBUG_PORT")
}
}
private fun buildOriginRule(rawUrl: String): String? {
val uri = parseUri(rawUrl) ?: return null
val scheme = uri.scheme ?: return null
if (scheme != "https") return null
val host = uri.host ?: return null
val port = uri.port.takeIf { it != -1 }
return buildString {
append(scheme).append("://").append(host)
if (port != null) {
append(":").append(port)
}
}
}
private fun isRemoteOrigin(
rawUrl: String?,
remoteWebAppBaseUrl: String?,
): Boolean = rawUrl?.let { RemoteNavigationPolicy.isAllowedRemoteOrigin(it, remoteWebAppBaseUrl) } ?: false
private fun uriScheme(rawUrl: String?): String? = parseUri(rawUrl)?.scheme
private fun uriHost(rawUrl: String?): String? = parseUri(rawUrl)?.host ?: parseUri(rawUrl)?.authority
private fun uriPort(rawUrl: String?): Int? = parseUri(rawUrl)?.port?.takeIf { it != -1 }
private fun parseUri(rawUrl: String?): java.net.URI? = rawUrl?.let { raw -> runCatching { java.net.URI(raw) }.getOrNull() }
}
}

View File

@@ -1,20 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import java.security.MessageDigest
// null contentType is allowed — some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate.
internal object RemoteContentIntegrity {
fun normalizeSha256(value: String): String = value.lowercase().removePrefix("sha256-").removePrefix("0x")
fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { byte ->
"%02x".format(byte)
}
fun isAcceptableContentType(rawContentType: String?): Boolean {
val normalized = rawContentType?.substringBefore(";")?.trim()?.lowercase()
return normalized == null || normalized == "text/html"
}
}

View File

@@ -5,8 +5,13 @@ package xyz.self.sdk.webview
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import xyz.self.sdk.api.SelfSdk
import xyz.self.sdk.bridge.MessageRouter
import xyz.self.sdk.handlers.CryptoHandler
@@ -16,9 +21,11 @@ import xyz.self.sdk.handlers.SecureStorageHandler
class SelfVerificationActivity : AppCompatActivity() {
private lateinit var webViewHost: AndroidWebViewHost
private lateinit var router: MessageRouter
private var container: FrameLayout? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
val isDebugMode = intent.getBooleanExtra(EXTRA_DEBUG_MODE, false)
val environment = intent.getStringExtra(EXTRA_ENVIRONMENT) ?: "prod"
@@ -36,8 +43,7 @@ class SelfVerificationActivity : AppCompatActivity() {
val chainID = if (intent.hasExtra(EXTRA_CHAIN_ID)) intent.getIntExtra(EXTRA_CHAIN_ID, 0) else null
val userDefinedData = intent.getStringExtra(EXTRA_USER_DEFINED_DATA)
val selfDefinedData = intent.getStringExtra(EXTRA_SELF_DEFINED_DATA)
val remoteWebAppBaseUrl = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_BASE_URL)
val remoteWebAppIntegritySha256 = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256)
val remoteWebAppBaseUrl = intent.getStringExtra(EXTRA_REMOTE_WEB_APP_BASE_URL) ?: "https://self-app-alpha.vercel.app"
router =
MessageRouter(
@@ -68,7 +74,6 @@ class SelfVerificationActivity : AppCompatActivity() {
router = router,
isDebugMode = isDebugMode,
remoteWebAppBaseUrl = remoteWebAppBaseUrl,
remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256,
)
val queryParams =
@@ -95,7 +100,32 @@ class SelfVerificationActivity : AppCompatActivity() {
}
val webView = webViewHost.createWebView(queryParams)
setContentView(webView)
val wrapper =
FrameLayout(this).apply {
addView(
webView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
),
)
}
container = wrapper
setContentView(wrapper)
ViewCompat.setOnApplyWindowInsetsListener(wrapper) { view, insets ->
val systemInsets =
insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
)
view.setPadding(
systemInsets.left,
systemInsets.top,
systemInsets.right,
systemInsets.bottom,
)
WindowInsetsCompat.CONSUMED
}
}
override fun onRequestPermissionsResult(
@@ -137,6 +167,7 @@ class SelfVerificationActivity : AppCompatActivity() {
}
override fun onDestroy() {
container?.let { ViewCompat.setOnApplyWindowInsetsListener(it, null) }
if (::webViewHost.isInitialized) {
webViewHost.destroy()
}
@@ -161,7 +192,6 @@ class SelfVerificationActivity : AppCompatActivity() {
const val EXTRA_USER_DEFINED_DATA = "xyz.self.sdk.USER_DEFINED_DATA"
const val EXTRA_SELF_DEFINED_DATA = "xyz.self.sdk.SELF_DEFINED_DATA"
const val EXTRA_REMOTE_WEB_APP_BASE_URL = "xyz.self.sdk.REMOTE_WEB_APP_BASE_URL"
const val EXTRA_REMOTE_WEB_APP_INTEGRITY_SHA256 = "xyz.self.sdk.REMOTE_WEB_APP_INTEGRITY_SHA256"
const val EXTRA_RESULT_DATA = "xyz.self.sdk.RESULT_DATA"
}
}

View File

@@ -66,7 +66,7 @@ class MessageRouterTest {
val sent = mutableListOf<String>()
val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this)
router.onMessageReceived(makeRequest(version = 999))
router.onMessageReceived(makeRequest(version = 999), isTrustedSource = true)
advanceUntilIdle()
assertEquals(1, sent.size)
@@ -82,7 +82,7 @@ class MessageRouterTest {
val sent = mutableListOf<String>()
val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this)
router.onMessageReceived(makeRequest(domain = BridgeDomain.NFC))
router.onMessageReceived(makeRequest(domain = BridgeDomain.NFC), isTrustedSource = true)
advanceUntilIdle()
assertEquals(1, sent.size)
@@ -98,7 +98,7 @@ class MessageRouterTest {
val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this)
router.register(StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("ok")))
router.onMessageReceived(makeRequest())
router.onMessageReceived(makeRequest(), isTrustedSource = true)
advanceUntilIdle()
assertEquals(1, sent.size)
@@ -119,7 +119,7 @@ class MessageRouterTest {
),
)
router.onMessageReceived(makeRequest())
router.onMessageReceived(makeRequest(), isTrustedSource = true)
advanceUntilIdle()
assertEquals(1, sent.size)
@@ -141,7 +141,7 @@ class MessageRouterTest {
),
)
router.onMessageReceived(makeRequest())
router.onMessageReceived(makeRequest(), isTrustedSource = true)
advanceUntilIdle()
assertEquals(1, sent.size)
@@ -157,7 +157,21 @@ class MessageRouterTest {
val sent = mutableListOf<String>()
val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this)
router.onMessageReceived("{not valid json")
router.onMessageReceived("{not valid json", isTrustedSource = true)
advanceUntilIdle()
assertTrue(sent.isEmpty())
}
@Test
fun `messages from untrusted origins are dropped before dispatch`() =
runTest {
val sent = mutableListOf<String>()
val handler = StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("ok"))
val router = MessageRouter(sendToWebView = { sent.add(it) }, scope = this)
router.register(handler)
router.onMessageReceived(makeRequest(), isTrustedSource = false)
advanceUntilIdle()
assertTrue(sent.isEmpty())
@@ -171,7 +185,7 @@ class MessageRouterTest {
router.register(StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("first")))
router.register(StubHandler(BridgeDomain.SECURE_STORAGE, result = JsonPrimitive("second")))
router.onMessageReceived(makeRequest())
router.onMessageReceived(makeRequest(), isTrustedSource = true)
advanceUntilIdle()
val resp = parseResponse(sent[0])

View File

@@ -0,0 +1,109 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AndroidWebViewHostSecurityTest {
@Test
fun `release builds launch remote content`() {
assertEquals(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
AndroidWebViewHost.initialContentUrl(queryParams = "", isDebugMode = false),
)
}
@Test
fun `debug builds launch localhost`() {
assertTrue(
AndroidWebViewHost
.initialContentUrl(queryParams = "", isDebugMode = true)
.startsWith("http://127.0.0.1:5173"),
)
}
@Test
fun `navigation allows remote didit and debug origins`() {
val remoteBase = "https://self-app-alpha.vercel.app"
assertTrue(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
assertTrue(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://verify.didit.me/session/123",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
assertFalse(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://evil.example.com/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
assertTrue(
AndroidWebViewHost.isAllowedNavigationUrl(
"http://127.0.0.1:5173/tunnel/tour/1",
isDebugMode = true,
),
)
}
@Test
fun `release build rejects HTTP base URL`() {
assertFailsWith<IllegalArgumentException> {
AndroidWebViewHost.initialContentUrl(
queryParams = "",
isDebugMode = false,
remoteWebAppBaseUrl = "http://self-app-alpha.vercel.app",
)
}
}
@Test
fun `didit on non-443 port is rejected`() {
val remoteBase = "https://self-app-alpha.vercel.app"
assertFalse(
AndroidWebViewHost.isAllowedNavigationUrl(
"https://verify.didit.me:8443/session/123",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
}
@Test
fun `bridge trust accepts remote rejects didit and arbitrary origins`() {
val remoteBase = "https://self-app-alpha.vercel.app"
assertTrue(
AndroidWebViewHost.isTrustedBridgeOrigin(
"https://self-app-alpha.vercel.app/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
assertFalse(
AndroidWebViewHost.isTrustedBridgeOrigin(
"https://verify.didit.me/session/123",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
assertFalse(
AndroidWebViewHost.isTrustedBridgeOrigin(
"https://evil.example.com/tunnel/tour/1",
isDebugMode = false,
remoteWebAppBaseUrl = remoteBase,
),
)
}
}

View File

@@ -1,121 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1
package xyz.self.sdk.webview
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RemoteContentIntegrityTest {
// -- normalizeSha256 --
@Test
fun `normalizeSha256 strips sha256- prefix`() {
assertEquals(
"abcdef1234567890",
RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"),
)
}
@Test
fun `normalizeSha256 strips 0x prefix`() {
assertEquals(
"abcdef1234567890",
RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"),
)
}
@Test
fun `normalizeSha256 strips sha256- then 0x prefix`() {
assertEquals(
"abcdef",
RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"),
)
}
@Test
fun `normalizeSha256 lowercases input`() {
assertEquals(
"abcdef",
RemoteContentIntegrity.normalizeSha256("ABCDEF"),
)
}
@Test
fun `normalizeSha256 returns raw hex unchanged`() {
assertEquals(
"abcdef1234567890",
RemoteContentIntegrity.normalizeSha256("abcdef1234567890"),
)
}
@Test
fun `normalizeSha256 does not strip interior sha256-`() {
assertEquals(
"absha256-cd",
RemoteContentIntegrity.normalizeSha256("absha256-cd"),
)
}
// -- sha256Hex --
@Test
fun `sha256Hex produces correct hash for known input`() {
// SHA-256 of empty byte array
assertEquals(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
RemoteContentIntegrity.sha256Hex(byteArrayOf()),
)
}
@Test
fun `sha256Hex produces correct hash for hello`() {
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
RemoteContentIntegrity.sha256Hex("hello".toByteArray(Charsets.UTF_8)),
)
}
// -- isAcceptableContentType --
@Test
fun `accepts null content type`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType(null))
}
@Test
fun `rejects empty content type`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType(""))
}
@Test
fun `accepts text html`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html"))
}
@Test
fun `accepts text html with charset`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType("text/html; charset=utf-8"))
}
@Test
fun `accepts Text HTML case insensitive`() {
assertTrue(RemoteContentIntegrity.isAcceptableContentType("Text/HTML"))
}
@Test
fun `rejects application javascript`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/javascript"))
}
@Test
fun `rejects application json`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType("application/json"))
}
@Test
fun `rejects text plain`() {
assertFalse(RemoteContentIntegrity.isAcceptableContentType("text/plain"))
}
}

View File

@@ -18,10 +18,8 @@ let package = Package(
.target(
name: "SelfNativeShell",
path: ".",
sources: ["Sources/SelfNativeShell"],
resources: [
.copy("Resources/self-sdk-web")
]
exclude: ["Resources"],
sources: ["Sources/SelfNativeShell"]
),
.testTarget(
name: "SelfNativeShellTests",

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Self</title>
<script type="module" crossorigin src="./assets/index-YX6AnLbA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-LqDWjDzu.css">
<script type="module" crossorigin src="/assets/index-YX6AnLbA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-LqDWjDzu.css">
</head>
<body>
<div id="root"></div>

View File

@@ -65,8 +65,7 @@ final class SelfSdkViewController: UIViewController {
let host = SelfWebViewHost(
router: router,
isDebugMode: config.isDebugMode,
remoteWebAppBaseURL: config.remoteWebAppBaseURL,
remoteWebAppIntegritySha256: config.remoteWebAppIntegritySha256
remoteWebAppBaseURL: config.remoteWebAppBaseURL
)
self.webViewHost = host

View File

@@ -20,7 +20,6 @@ public struct SelfSdkConfig {
public let userDefinedData: String?
public let selfDefinedData: String?
public let remoteWebAppBaseURL: URL?
public let remoteWebAppIntegritySha256: String?
public let secureStorageProvider: SecureStorageProvider
public init(
@@ -41,7 +40,6 @@ public struct SelfSdkConfig {
userDefinedData: String? = nil,
selfDefinedData: String? = nil,
remoteWebAppBaseURL: URL? = nil,
remoteWebAppIntegritySha256: String? = nil,
secureStorageProvider: SecureStorageProvider
) {
self.verificationId = verificationId
@@ -61,7 +59,6 @@ public struct SelfSdkConfig {
self.userDefinedData = userDefinedData
self.selfDefinedData = selfDefinedData
self.remoteWebAppBaseURL = remoteWebAppBaseURL
self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256
self.secureStorageProvider = secureStorageProvider
}

View File

@@ -14,7 +14,11 @@ final class MessageRouter {
handlers[handler.domain] = handler
}
func onMessageReceived(rawJson: String) {
func onMessageReceived(rawJson: String, isTrustedSource: Bool) {
guard isTrustedSource else {
return
}
guard let data = rawJson.data(using: .utf8) else {
return
}
@@ -47,7 +51,10 @@ final class MessageRouter {
}
let params = request.params?.mapValues { $0.value }
dispatchRequest(request, handler: handler, params: params)
}
private func dispatchRequest(_ request: BridgeRequest, handler: BridgeHandler, params: [String: Any]?) {
Task {
do {
let result = try await handler.handle(method: request.method, params: params)

View File

@@ -1,20 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
enum BundledAssetPathResolver {
static func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? {
let rawPath = requestURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let normalizedPath = rawPath.removingPercentEncoding ?? rawPath
let relativePath = normalizedPath.isEmpty || !normalizedPath.contains(".") ? "index.html" : normalizedPath
let fileURL = rootURL.appendingPathComponent(relativePath, isDirectory: false).standardized
let standardizedRootURL = rootURL.standardizedFileURL
let rootPath = standardizedRootURL.path.hasSuffix("/")
? standardizedRootURL.path
: standardizedRootURL.path + "/"
guard fileURL.path.hasPrefix(rootPath) || fileURL.path == standardizedRootURL.path else {
return nil
}
return fileURL
}
}

View File

@@ -1,21 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
// nil mimeType is allowed some CDNs omit Content-Type; the SHA-256 hash is the primary integrity gate.
enum RemoteContentIntegrity {
static func normalizeSha256(_ value: String) -> String {
var normalized = value.lowercased()
if normalized.hasPrefix("sha256-") {
normalized.removeFirst("sha256-".count)
}
if normalized.hasPrefix("0x") {
normalized.removeFirst(2)
}
return normalized
}
static func isAcceptableMimeType(_ mimeType: String?) -> Bool {
mimeType == nil || mimeType == "text/html"
}
}

View File

@@ -3,8 +3,6 @@
import Foundation
enum RemoteNavigationPolicy {
private static let bundledScheme = SelfWebViewHost.bundledScheme
private static let bundledHost = SelfWebViewHost.bundledHost
private static let allowedSubframeHosts: Set<String> = ["verify.didit.me"]
static func makeEntryURL(baseURL: URL?, queryParams: String) -> URL? {
@@ -25,11 +23,9 @@ enum RemoteNavigationPolicy {
isDebugMode: Bool
) -> Bool {
if isDebugMode {
return url.absoluteString.hasPrefix("http://localhost:5173")
}
if url.scheme == bundledScheme, url.host == bundledHost {
return true
return url.scheme == "http" &&
url.host == "localhost" &&
resolvedPort(for: url) == 5173
}
guard let remoteWebAppBaseURL,
@@ -54,7 +50,8 @@ enum RemoteNavigationPolicy {
guard url.scheme == "https", let host = url.host else {
return false
}
return allowedSubframeHosts.contains(host)
let port = resolvedPort(for: url)
return allowedSubframeHosts.contains(host) && port == 443
}
static func resolvedPort(for url: URL) -> Int {

View File

@@ -1,31 +1,25 @@
// SPDX-License-Identifier: BUSL-1.1
import CryptoKit
import Foundation
import UIKit
import WebKit
final class SelfWebViewHost: NSObject {
static let bundledScheme = "self-sdk"
static let bundledHost = "app"
fileprivate static let bundledRootFolder = "self-sdk-web"
private static let defaultRemoteBaseURL = URL(string: "https://self-app-alpha.vercel.app")!
private var webView: WKWebView?
private let router: MessageRouter
private let isDebugMode: Bool
private let remoteWebAppBaseURL: URL?
private let remoteWebAppIntegritySha256: String?
private let remoteWebAppBaseURL: URL
init(
router: MessageRouter,
isDebugMode: Bool = false,
remoteWebAppBaseURL: URL? = nil,
remoteWebAppIntegritySha256: String? = nil
remoteWebAppBaseURL: URL? = nil
) {
self.router = router
self.isDebugMode = isDebugMode
self.remoteWebAppBaseURL = remoteWebAppBaseURL
self.remoteWebAppIntegritySha256 = remoteWebAppIntegritySha256
self.remoteWebAppBaseURL = remoteWebAppBaseURL ?? Self.defaultRemoteBaseURL
super.init()
}
@@ -37,7 +31,6 @@ final class SelfWebViewHost: NSObject {
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
config.setURLSchemeHandler(SelfBundledAssetSchemeHandler(), forURLScheme: SelfWebViewHost.bundledScheme)
let webView = WKWebView(frame: .zero, configuration: config)
webView.scrollView.bounces = false
@@ -49,14 +42,12 @@ final class SelfWebViewHost: NSObject {
webView.isInspectable = isDebugMode
}
webView.navigationDelegate = self
self.webView = webView
return webView
}
func loadContent(queryParams: String) {
guard let webView = webView else { return }
if isDebugMode {
let debugBase = URL(string: "http://localhost:5173")
if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams) {
@@ -64,12 +55,10 @@ final class SelfWebViewHost: NSObject {
}
return
}
if let bundledURL = makeBundledEntryURL(queryParams: queryParams) {
webView.load(URLRequest(url: bundledURL))
guard remoteWebAppBaseURL.scheme == "https" else { return }
if let url = RemoteNavigationPolicy.makeEntryURL(baseURL: remoteWebAppBaseURL, queryParams: queryParams) {
webView.load(URLRequest(url: url))
}
loadVerifiedRemoteContent(queryParams: queryParams)
}
func evaluateJs(_ js: String) {
@@ -78,71 +67,6 @@ final class SelfWebViewHost: NSObject {
}
}
private func makeBundledEntryURL(queryParams: String) -> URL? {
RemoteNavigationPolicy.makeEntryURL(
baseURL: URL(string: "\(SelfWebViewHost.bundledScheme)://\(SelfWebViewHost.bundledHost)"),
queryParams: queryParams
)
}
private func loadVerifiedRemoteContent(queryParams: String) {
guard let baseURL = remoteWebAppBaseURL,
baseURL.scheme == "https",
baseURL.host != nil,
let expectedSha256 = remoteWebAppIntegritySha256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSha256.isEmpty,
let remoteURL = RemoteNavigationPolicy.makeEntryURL(baseURL: baseURL, queryParams: queryParams) else {
return
}
Task.detached { [weak self] in
guard let self else { return }
guard let verifiedHTML = await self.fetchAndVerifyRemoteEntry(
url: remoteURL, expectedSha256: expectedSha256
) else {
return
}
await MainActor.run {
self.webView?.loadHTMLString(verifiedHTML, baseURL: remoteURL)
}
}
}
private static let maxRemoteEntryBytes = 5 * 1024 * 1024
private func fetchAndVerifyRemoteEntry(url: URL, expectedSha256: String) async -> String? {
do {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 5
configuration.timeoutIntervalForResource = 5
let session = URLSession(configuration: configuration)
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
return nil
}
guard RemoteContentIntegrity.isAcceptableMimeType(response.mimeType) else {
return nil
}
guard data.count <= SelfWebViewHost.maxRemoteEntryBytes else {
return nil
}
let digest = SHA256.hash(data: data)
let actualHash = digest.map { String(format: "%02x", $0) }.joined()
guard actualHash == normalizeSha256(expectedSha256) else {
return nil
}
return String(data: data, encoding: .utf8)
} catch {
return nil
}
}
private func normalizeSha256(_ value: String) -> String {
RemoteContentIntegrity.normalizeSha256(value)
}
private func isAllowedNavigation(url: URL) -> Bool {
RemoteNavigationPolicy.isAllowedMainFrameNavigation(
url: url,
@@ -190,84 +114,63 @@ extension SelfWebViewHost: WKScriptMessageHandler {
didReceive message: WKScriptMessage
) {
guard message.name == "SelfNativeIOS",
message.frameInfo.isMainFrame,
isTrustedBridgeFrameInfo(message.frameInfo.securityOrigin),
let body = message.body as? String else {
return
}
router.onMessageReceived(rawJson: body)
router.onMessageReceived(rawJson: body, isTrustedSource: true)
}
}
private final class SelfBundledAssetSchemeHandler: NSObject, WKURLSchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let requestURL = urlSchemeTask.request.url,
let rootURL = Bundle.module.resourceURL?.appendingPathComponent(
SelfWebViewHost.bundledRootFolder,
isDirectory: true
),
let fileURL = resolveFileURL(for: requestURL, rootURL: rootURL) else {
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
return
private extension SelfWebViewHost {
func isTrustedBridgeOrigin(_ url: URL?) -> Bool {
guard let url else { return false }
if isDebugMode {
return url.scheme == "http" &&
url.host == "localhost" &&
resolvedPort(for: url) == 5173
}
return url.scheme == remoteWebAppBaseURL.scheme &&
url.host == remoteWebAppBaseURL.host &&
resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL)
}
}
do {
let data = try Data(contentsOf: fileURL)
let response = URLResponse(
url: requestURL,
mimeType: mimeType(for: fileURL.pathExtension),
expectedContentLength: data.count,
textEncodingName: textEncodingName(for: fileURL.pathExtension)
)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
} catch {
urlSchemeTask.didFailWithError(error)
extension SelfWebViewHost {
func initialContentURL(queryParams: String) -> URL? {
if isDebugMode {
let debugBase = URL(string: "http://localhost:5173")
return RemoteNavigationPolicy.makeEntryURL(baseURL: debugBase, queryParams: queryParams)
}
guard remoteWebAppBaseURL.scheme == "https" else { return nil }
return RemoteNavigationPolicy.makeEntryURL(baseURL: remoteWebAppBaseURL, queryParams: queryParams)
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
private func resolveFileURL(for requestURL: URL, rootURL: URL) -> URL? {
BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)
func isAllowedNavigationURL(_ url: URL?, host: String? = nil) -> Bool {
guard let url else { return false }
return isAllowedNavigation(url: url) || isAllowedSubframeNavigation(url: url)
}
private func mimeType(for pathExtension: String) -> String {
switch pathExtension.lowercased() {
case "html":
return "text/html"
case "js":
return "application/javascript"
case "css":
return "text/css"
case "json":
return "application/json"
case "svg":
return "image/svg+xml"
case "png":
return "image/png"
case "jpg", "jpeg":
return "image/jpeg"
case "woff2":
return "font/woff2"
case "woff":
return "font/woff"
case "ttf":
return "font/ttf"
case "otf":
return "font/otf"
case "wav":
return "audio/wav"
default:
return "application/octet-stream"
func isTrustedBridgeURL(_ url: URL?) -> Bool {
isTrustedBridgeOrigin(url)
}
func isTrustedBridgeFrameInfo(_ origin: WKSecurityOrigin) -> Bool {
if isDebugMode {
return origin.protocol == "http" && origin.host == "localhost" && origin.port == 5173
}
return origin.protocol == remoteWebAppBaseURL.scheme &&
origin.host == remoteWebAppBaseURL.host &&
resolvedSecurityOriginPort(origin) == resolvedPort(for: remoteWebAppBaseURL)
}
private func textEncodingName(for pathExtension: String) -> String? {
switch pathExtension.lowercased() {
case "html", "js", "css", "json", "svg":
return "utf-8"
default:
return nil
private func resolvedSecurityOriginPort(_ origin: WKSecurityOrigin) -> Int {
if origin.port != 0 { return origin.port }
switch origin.protocol {
case "https": return 443
case "http": return 80
default: return 0
}
}
}

View File

@@ -1,41 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1
import Foundation
import XCTest
@testable import SelfNativeShell
final class BundledAssetPathResolverTests: XCTestCase {
func testResolvesIndexForDirectoryStyleRequest() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/tunnel/tour/1")!
XCTAssertEqual(
BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path,
rootURL.appendingPathComponent("index.html").path
)
}
func testResolvesStaticAssetInsideBundle() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/assets/app.js")!
XCTAssertEqual(
BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL)?.path,
rootURL.appendingPathComponent("assets/app.js").path
)
}
func testRejectsPathTraversal() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/../../secret.txt")!
XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL))
}
func testRejectsPercentEncodedTraversal() {
let rootURL = URL(fileURLWithPath: "/tmp/self-sdk-web", isDirectory: true)
let requestURL = URL(string: "self-sdk://app/%2E%2E/%2E%2E/secret.txt")!
XCTAssertNil(BundledAssetPathResolver.resolveFileURL(for: requestURL, rootURL: rootURL))
}
}

View File

@@ -45,7 +45,8 @@ final class MessageRouterEscapeTests: XCTestCase {
router.onMessageReceived(
rawJson: """
{"type":"request","version":1,"id":"req-1","domain":"secureStorage","method":"get","timestamp":1000}
"""
""",
isTrustedSource: true
)
waitForExpectations(timeout: 2)

View File

@@ -38,7 +38,7 @@ final class MessageRouterTests: XCTestCase {
expectation.fulfill()
}
router.onMessageReceived(rawJson: makeRequestJSON(version: 999))
router.onMessageReceived(rawJson: makeRequestJSON(version: 999), isTrustedSource: true)
waitForExpectations(timeout: 2)
@@ -57,7 +57,7 @@ final class MessageRouterTests: XCTestCase {
expectation.fulfill()
}
router.onMessageReceived(rawJson: makeRequestJSON(domain: "secureStorage"))
router.onMessageReceived(rawJson: makeRequestJSON(domain: "secureStorage"), isTrustedSource: true)
waitForExpectations(timeout: 2)
@@ -77,7 +77,7 @@ final class MessageRouterTests: XCTestCase {
}
router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "abc"]))
router.onMessageReceived(rawJson: makeRequestJSON())
router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true)
waitForExpectations(timeout: 2)
@@ -99,7 +99,7 @@ final class MessageRouterTests: XCTestCase {
error: BridgeHandlerError.missingParam("key")
))
router.onMessageReceived(rawJson: makeRequestJSON())
router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true)
waitForExpectations(timeout: 2)
@@ -120,7 +120,7 @@ final class MessageRouterTests: XCTestCase {
error: NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "disk full"])
))
router.onMessageReceived(rawJson: makeRequestJSON())
router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true)
waitForExpectations(timeout: 2)
@@ -137,7 +137,7 @@ final class MessageRouterTests: XCTestCase {
sentCount += 1
}
router.onMessageReceived(rawJson: "{not valid json")
router.onMessageReceived(rawJson: "{not valid json", isTrustedSource: true)
// Give async code a chance to run
let expectation = expectation(description: "wait")
@@ -149,6 +149,24 @@ final class MessageRouterTests: XCTestCase {
XCTAssertEqual(sentCount, 0)
}
func testUntrustedOriginMessagesAreDroppedBeforeDispatch() {
var sentCount = 0
let router = MessageRouter { _ in
sentCount += 1
}
router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "abc"]))
router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: false)
let expectation = expectation(description: "wait")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
expectation.fulfill()
}
waitForExpectations(timeout: 2)
XCTAssertEqual(sentCount, 0)
}
// MARK: - Registration
func testRegisterReplacesExistingHandler() {
@@ -162,7 +180,7 @@ final class MessageRouterTests: XCTestCase {
router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "first"]))
router.register(handler: StubHandler(domain: .secureStorage, result: ["value": "second"]))
router.onMessageReceived(rawJson: makeRequestJSON())
router.onMessageReceived(rawJson: makeRequestJSON(), isTrustedSource: true)
waitForExpectations(timeout: 2)

View File

@@ -1,84 +0,0 @@
// SPDX-License-Identifier: BUSL-1.1
import XCTest
@testable import SelfNativeShell
final class RemoteContentIntegrityTests: XCTestCase {
// MARK: - normalizeSha256
func testStripsShaPrefixOnly() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("sha256-abcdef1234567890"),
"abcdef1234567890"
)
}
func testStrips0xPrefixOnly() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("0xabcdef1234567890"),
"abcdef1234567890"
)
}
func testStripsSha256Then0xPrefix() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("sha256-0xabcdef"),
"abcdef"
)
}
func testLowercasesInput() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("ABCDEF"),
"abcdef"
)
}
func testRawHexPassesThrough() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("abcdef1234567890"),
"abcdef1234567890"
)
}
func testDoesNotStripInteriorSha256() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("absha256-cd"),
"absha256-cd"
)
}
func testDoesNotStripInterior0x() {
XCTAssertEqual(
RemoteContentIntegrity.normalizeSha256("ab0xcd"),
"ab0xcd"
)
}
// MARK: - isAcceptableMimeType
func testAcceptsNilMimeType() {
XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType(nil))
}
func testAcceptsTextHtml() {
XCTAssertTrue(RemoteContentIntegrity.isAcceptableMimeType("text/html"))
}
func testRejectsApplicationJavascript() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/javascript"))
}
func testRejectsApplicationJson() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("application/json"))
}
func testRejectsTextPlain() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType("text/plain"))
}
func testRejectsEmptyString() {
XCTAssertFalse(RemoteContentIntegrity.isAcceptableMimeType(""))
}
}

View File

@@ -73,6 +73,16 @@ final class RemoteNavigationPolicyTests: XCTestCase {
)
}
func testSubframeRejectsDiditOnNonStandardPort() {
XCTAssertFalse(
RemoteNavigationPolicy.isAllowedSubframeNavigation(
url: URL(string: "https://verify.didit.me:8443/flow")!,
remoteWebAppBaseURL: nil,
isDebugMode: false
)
)
}
func testMakeEntryURLAppendsHostedPathAndQuery() {
XCTAssertEqual(
RemoteNavigationPolicy.makeEntryURL(

View File

@@ -0,0 +1,84 @@
// SPDX-License-Identifier: BUSL-1.1
import XCTest
@testable import SelfNativeShell
final class SelfWebViewHostTests: XCTestCase {
func testReleaseBuildUsesRemoteOrigin() throws {
let router = MessageRouter(sendToWebView: { _ in })
let host = SelfWebViewHost(router: router, isDebugMode: false)
_ = host.createWebView()
let url = try XCTUnwrap(host.initialContentURL(queryParams: ""))
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "self-app-alpha.vercel.app")
XCTAssertTrue(url.path.contains("/tunnel/tour/1"))
}
func testDebugBuildUsesLocalhost() throws {
let router = MessageRouter(sendToWebView: { _ in })
let host = SelfWebViewHost(router: router, isDebugMode: true)
_ = host.createWebView()
let url = try XCTUnwrap(host.initialContentURL(queryParams: ""))
XCTAssertEqual(url.scheme, "http")
XCTAssertEqual(url.host, "localhost")
XCTAssertTrue(url.absoluteString.hasPrefix("http://localhost:5173"))
}
func testAllowedNavigationAcceptsRemoteAlphaOrigin() {
let router = MessageRouter(sendToWebView: { _ in })
let host = SelfWebViewHost(router: router, isDebugMode: false)
_ = host.createWebView()
XCTAssertTrue(
host.isAllowedNavigationURL(
URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1")
)
)
XCTAssertTrue(
host.isAllowedNavigationURL(
URL(string: "https://verify.didit.me/session/123")
)
)
XCTAssertFalse(
host.isAllowedNavigationURL(
URL(string: "https://evil.example.com/tunnel/tour/1")
)
)
}
func testHttpBaseURLProducesNilInRelease() {
let router = MessageRouter(sendToWebView: { _ in })
let host = SelfWebViewHost(
router: router,
isDebugMode: false,
remoteWebAppBaseURL: URL(string: "http://self-app-alpha.vercel.app")
)
_ = host.createWebView()
XCTAssertNil(host.initialContentURL(queryParams: ""))
}
func testBridgeTrustAcceptsRemoteRejectsDidit() {
let router = MessageRouter(sendToWebView: { _ in })
let host = SelfWebViewHost(router: router, isDebugMode: false)
_ = host.createWebView()
XCTAssertTrue(
host.isTrustedBridgeURL(
URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1")
)
)
XCTAssertFalse(
host.isTrustedBridgeURL(
URL(string: "https://verify.didit.me/session/123")
)
)
XCTAssertFalse(
host.isTrustedBridgeURL(
URL(string: "https://evil.example.com/tunnel/tour/1")
)
)
}
}

View File

@@ -27,5 +27,10 @@ let package = Package(
],
path: "Sources/SelfSdkSwift"
),
.testTarget(
name: "SelfSdkSwiftTests",
dependencies: ["SelfSdkSwift"],
path: "Tests/SelfSdkSwiftTests"
),
]
)

View File

@@ -9,11 +9,17 @@ import WebKit
/// Swift implementation of WebViewProvider using WKWebView.
/// Handles message passing between the WebView and the KMP bridge.
public class WebViewProviderImpl: NSObject {
static let loopbackHost = "127.0.0.1"
static let diditHost = "verify.didit.me"
static let debugPort: UInt16 = 5173
private static let defaultRemoteBaseURL = URL(string: "https://self-app-alpha.vercel.app")!
private var webView: WKWebView?
private var viewController: UIViewController?
private var onMessageReceived: ((String) -> Void)?
private var isDebugMode: Bool = false
private var remoteWebAppBaseURL: URL = WebViewProviderImpl.defaultRemoteBaseURL
private var devServerUrl: String?
/// Weak proxy to avoid retain cycles with WKScriptMessageHandler
private var messageProxy: WeakScriptMessageProxy?
@@ -33,7 +39,7 @@ public class WebViewProviderImpl: NSObject {
self.webView = nil
self.viewController = nil
}
self.onMessageReceived = onMessageReceived
// Create message proxy to avoid retain cycle
@@ -64,12 +70,8 @@ public class WebViewProviderImpl: NSObject {
wv.navigationDelegate = self
self.webView = wv
var urlString = "https://self-app-alpha.vercel.app/tunnel/tour/1"
if let params = queryParams, !params.isEmpty {
urlString += "?\(params)"
}
guard let url = URL(string: urlString) else {
NSLog("SelfSDK-WebView: Failed to construct URL from: %@", urlString)
guard let url = initialContentURL(queryParams: queryParams) else {
NSLog("SelfSDK-WebView: Failed to construct bundled URL")
return wv
}
wv.load(URLRequest(url: url))
@@ -99,6 +101,21 @@ public class WebViewProviderImpl: NSObject {
self.viewController = vc
return vc
}
@objc public func isBridgeRequestAllowed() -> Bool {
isTrustedBridgeURL(webView?.url)
}
@objc(configureRemoteLoadingRemoteWebAppBaseURL:)
public func configureRemoteLoading(remoteWebAppBaseURL: String?) {
self.remoteWebAppBaseURL = remoteWebAppBaseURL.flatMap { URL(string: $0) }
?? Self.defaultRemoteBaseURL
}
@objc(configureDevServerDevServerUrl:)
public func configureDevServer(devServerUrl: String?) {
self.devServerUrl = devServerUrl
}
}
// MARK: - Host VC that embeds the WKWebView with proper Auto Layout
@@ -145,10 +162,8 @@ extension WebViewProviderImpl: WKNavigationDelegate {
decisionHandler(.cancel)
return
}
let isTrusted =
(url.scheme == "https" && host == "self-app-alpha.vercel.app") ||
(isDebugMode && url.scheme == "http" && host == "127.0.0.1")
decisionHandler(isTrusted ? .allow : .cancel)
let isAllowed = isAllowedNavigationURL(url, host: host)
decisionHandler(isAllowed ? .allow : .cancel)
}
}
@@ -159,7 +174,10 @@ extension WebViewProviderImpl: WKScriptMessageHandler {
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "SelfNativeIOS" else { return }
guard message.name == "SelfNativeIOS",
message.frameInfo.isMainFrame,
isTrustedBridgeFrameInfo(message.frameInfo.securityOrigin),
isBridgeRequestAllowed() else { return }
if let body = message.body as? String {
onMessageReceived?(body)
@@ -171,6 +189,100 @@ extension WebViewProviderImpl: WKScriptMessageHandler {
}
}
extension WebViewProviderImpl {
func initialContentURL(queryParams: String?) -> URL? {
#if DEBUG
if isDebugMode, let devUrl = devServerUrl, !devUrl.isEmpty,
let baseURL = URL(string: devUrl.hasSuffix("/") ? String(devUrl.dropLast()) : devUrl) {
var components = URLComponents()
components.scheme = baseURL.scheme
components.host = baseURL.host
components.port = baseURL.port
components.path = "/tunnel/tour/1"
if let queryParams, !queryParams.isEmpty {
components.percentEncodedQuery = queryParams
}
return components.url
}
if isDebugMode {
var components = URLComponents()
components.scheme = "http"
components.host = Self.loopbackHost
components.port = Int(Self.debugPort)
components.path = "/tunnel/tour/1"
if let queryParams, !queryParams.isEmpty {
components.percentEncodedQuery = queryParams
}
return components.url
}
#endif
guard remoteWebAppBaseURL.scheme == "https" else { return nil }
var components = URLComponents()
components.scheme = remoteWebAppBaseURL.scheme
components.host = remoteWebAppBaseURL.host
if let port = remoteWebAppBaseURL.port { components.port = port }
components.path = "/tunnel/tour/1"
if let queryParams, !queryParams.isEmpty {
components.percentEncodedQuery = queryParams
}
return components.url
}
func isAllowedNavigationURL(_ url: URL?, host: String? = nil) -> Bool {
guard let url else { return false }
let resolvedHost = host ?? url.host
return isTrustedBridgeURL(url) ||
(url.scheme == "https" && resolvedHost == Self.diditHost && resolvedPort(for: url) == 443)
}
func isTrustedBridgeURL(_ url: URL?) -> Bool {
guard let url else { return false }
#if DEBUG
if isDebugMode {
if let devUrl = devServerUrl, !devUrl.isEmpty, let devBase = URL(string: devUrl) {
return url.scheme == devBase.scheme && url.host == devBase.host && resolvedPort(for: url) == resolvedPort(for: devBase)
}
return url.scheme == "http" && url.host == Self.loopbackHost && url.port == Int(Self.debugPort)
}
#endif
return url.scheme == remoteWebAppBaseURL.scheme &&
url.host == remoteWebAppBaseURL.host &&
resolvedPort(for: url) == resolvedPort(for: remoteWebAppBaseURL)
}
func isTrustedBridgeFrameInfo(_ origin: WKSecurityOrigin) -> Bool {
#if DEBUG
if isDebugMode {
if let devUrl = devServerUrl, !devUrl.isEmpty, let devBase = URL(string: devUrl) {
let expectedPort = resolvedPort(for: devBase)
return origin.protocol == devBase.scheme && origin.host == devBase.host && resolvedSecurityOriginPort(origin) == expectedPort
}
return origin.protocol == "http" && origin.host == Self.loopbackHost && origin.port == Int(Self.debugPort)
}
#endif
let expectedPort = resolvedPort(for: remoteWebAppBaseURL)
return origin.protocol == remoteWebAppBaseURL.scheme &&
origin.host == remoteWebAppBaseURL.host &&
resolvedSecurityOriginPort(origin) == expectedPort
}
private func resolvedSecurityOriginPort(_ origin: WKSecurityOrigin) -> Int {
if origin.port != 0 { return origin.port }
switch origin.protocol {
case "https": return 443
case "http": return 80
default: return 0
}
}
private func resolvedPort(for url: URL) -> Int {
if let port = url.port { return port }
return url.scheme == "https" ? 443 : 80
}
}
// MARK: - Weak proxy to break WKScriptMessageHandler retain cycle
/// WKUserContentController retains its message handler strongly.

View File

@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import XCTest
@testable import SelfSdkSwift
final class WebViewProviderImplTests: XCTestCase {
func testReleaseBuildUsesRemoteOrigin() throws {
let provider = WebViewProviderImpl()
let url = try XCTUnwrap(provider.initialContentURL(queryParams: nil))
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "self-app-alpha.vercel.app")
XCTAssertTrue(url.path.contains("/tunnel/tour/1"))
}
func testDebugBuildUsesLocalhost() throws {
let provider = WebViewProviderImpl()
_ = provider.createWebView(onMessageReceived: { _ in }, isDebugMode: true)
let url = try XCTUnwrap(provider.initialContentURL(queryParams: nil))
XCTAssertEqual(url.scheme, "http")
XCTAssertEqual(url.host, "127.0.0.1")
XCTAssertEqual(url.port, 5173)
XCTAssertTrue(url.path.contains("/tunnel/tour/1"))
}
func testHttpBaseURLProducesNilInRelease() {
let provider = WebViewProviderImpl()
provider.configureRemoteLoading(remoteWebAppBaseURL: "http://self-app-alpha.vercel.app")
XCTAssertNil(provider.initialContentURL(queryParams: nil))
}
func testAllowedNavigationAcceptsRemoteAlphaAndDidit() {
let provider = WebViewProviderImpl()
XCTAssertTrue(
provider.isAllowedNavigationURL(
URL(string: "https://verify.didit.me/session/123")
)
)
XCTAssertTrue(
provider.isAllowedNavigationURL(
URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1")
)
)
}
func testDiditOnNonStandardPortIsRejected() {
let provider = WebViewProviderImpl()
XCTAssertFalse(
provider.isAllowedNavigationURL(
URL(string: "https://verify.didit.me:8443/session/123")
)
)
}
func testAllowedNavigationRejectsArbitraryOrigins() {
let provider = WebViewProviderImpl()
XCTAssertFalse(
provider.isAllowedNavigationURL(
URL(string: "https://evil.com/tunnel/tour/1")
)
)
XCTAssertFalse(
provider.isAllowedNavigationURL(
URL(string: "http://example.com/test")
)
)
}
func testBridgeTrustAcceptsRemoteOrigin() {
let provider = WebViewProviderImpl()
XCTAssertTrue(
provider.isTrustedBridgeURL(
URL(string: "https://self-app-alpha.vercel.app/tunnel/tour/1")
)
)
}
func testBridgeTrustRejectsDiditAndArbitrary() {
let provider = WebViewProviderImpl()
XCTAssertFalse(
provider.isTrustedBridgeURL(
URL(string: "https://verify.didit.me/session/123")
)
)
XCTAssertFalse(
provider.isTrustedBridgeURL(
URL(string: "https://evil.com/tunnel/tour/1")
)
)
}
}

View File

@@ -22,8 +22,8 @@
"@didit-protocol/sdk-web": "^0.1.8",
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^1.6.0",
"@selfxyz/euclid": "1.4.0",
"@selfxyz/euclid-core": "1.4.0",
"@selfxyz/euclid": "1.4.2",
"@selfxyz/euclid-core": "1.4.1",
"@selfxyz/mobile-sdk-alpha": "workspace:^",
"@selfxyz/webview-bridge": "workspace:^",
"buffer": "^6.0.3",

View File

@@ -47,13 +47,14 @@ import { VerificationResultScreen } from './screens/proving/VerificationResultSc
import { BackupMethodPickerScreen } from './screens/recovery/BackupMethodPickerScreen';
import { LaunchRecoveryScreen } from './screens/recovery/LaunchRecoveryScreen';
import { RecoveryFailureScreen } from './screens/recovery/RecoveryFailureScreen';
import { RecoveryPhraseScreen } from './screens/recovery/RecoveryPhraseScreen';
import { OnboardingRecoveryPhraseScreen, RecoveryPhraseScreen } from './screens/recovery/RecoveryPhraseScreen';
import { RecoverySuccessScreen } from './screens/recovery/RecoverySuccessScreen';
import { SecretPhraseInputScreen } from './screens/recovery/SecretPhraseInputScreen';
import { TourScreen as TunnelTourScreen } from './screens/tunnel/TourScreen';
import { TunnelCountryPickerScreen } from './screens/tunnel/TunnelCountryPickerScreen';
import { TunnelDiscloseScreen } from './screens/tunnel/TunnelDiscloseScreen';
import { TunnelIDTypeScreen } from './screens/tunnel/TunnelIDTypeScreen';
import { TunnelKycFailureScreen } from './screens/tunnel/TunnelKycFailureScreen';
import { TunnelKycSuccessScreen } from './screens/tunnel/TunnelKycSuccessScreen';
import { TunnelKycWrapper } from './screens/tunnel/TunnelKycWrapper';
import { TunnelProofReceiptScreen } from './screens/tunnel/TunnelProofReceiptScreen';
@@ -75,6 +76,7 @@ export const App: React.FC = () => (
<Route path="/onboarding/provider-result" element={<ProviderResultScreen />} />
<Route path="/onboarding/confirm" element={<ConfirmIdentificationScreen />} />
<Route path="/onboarding/success" element={<ScanSuccessScreen />} />
<Route path="/onboarding/recovery-phrase" element={<OnboardingRecoveryPhraseScreen />} />
<Route path="/onboarding/failure" element={<RegistrationFailureScreen />} />
<Route path="/onboarding/kyc-failure" element={<KycFailureScreen />} />
<Route path="/proving" element={<ProvingScreen />} />
@@ -110,6 +112,7 @@ export const App: React.FC = () => (
<Route path="/coming-soon" element={<ComingSoonScreen />} />
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
<Route path="/tunnel/kyc" element={<TunnelKycWrapper />} />
<Route path="/tunnel/kyc-failure" element={<TunnelKycFailureScreen />} />
<Route path="/tunnel/kyc-success" element={<TunnelKycSuccessScreen />} />
<Route path="/tunnel/registration/country" element={<TunnelCountryPickerScreen />} />
<Route path="/tunnel/registration/id-type" element={<TunnelIDTypeScreen />} />

View File

@@ -14,6 +14,7 @@ interface DevScreenLink {
interface DevScreenGroup {
title: string;
links: DevScreenLink[];
description?: string;
}
const screenGroups: DevScreenGroup[] = [
@@ -31,6 +32,7 @@ const screenGroups: DevScreenGroup[] = [
{ href: '/onboarding/country', label: 'Country Picker' },
{ href: '/onboarding/confirm', label: 'Confirm ID' },
{ href: '/onboarding/success', label: 'Scan Success' },
{ href: '/onboarding/recovery-phrase', label: 'Recovery Phrase' },
{ href: '/onboarding/failure', label: 'Registration Failure' },
{ href: '/onboarding/backup', label: 'Social Sign-On Method' },
{ href: '/onboarding/signin', label: 'Social Sign-On' },
@@ -72,15 +74,26 @@ const screenGroups: DevScreenGroup[] = [
],
},
{
title: 'Tunnel',
title: 'Tunnel — Screens',
links: [
{ href: '/tunnel/tour/1', label: 'Tour' },
{ href: '/tunnel/kyc', label: 'KYC Mock' },
{ href: '/tunnel/registration/country', label: 'Country Picker' },
{ href: '/tunnel/registration/id-type', label: 'ID Type' },
{ href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
{ href: '/tunnel/kyc-failure', label: 'KYC Failure' },
{ href: '/tunnel/recovery-required', label: 'Recovery Required' },
{ href: '/tunnel/proof/generating', label: 'Proving' },
{ href: '/tunnel/proof/result', label: 'Result' },
{ href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
],
},
{
title: 'Tunnel — Mock KYC',
description: 'Mocks diverge after /tunnel/kyc; some outcomes intentionally share the same final route.',
links: [
{ href: '/tunnel/tour/1?mock=success', label: 'Flow → KYC Success, Then Proof Failure' },
{ href: '/tunnel/tour/1?mock=kyc-failure', label: 'Flow → KYC Error (Retryable)' },
{ href: '/tunnel/tour/1?mock=registration-failure', label: 'Flow → KYC Error (Fatal → Tour Step 4)' },
{ href: '/tunnel/tour/1?mock=cancel', label: 'Flow → KYC Cancel → Tour Step 4' },
],
},
{
@@ -103,10 +116,10 @@ export const DevRouteMenu: React.FC = () => {
}
}, [isOpen]);
const currentLabel = useMemo(
() => allLinks.find(link => link.href === location.pathname)?.label ?? 'Dev Screens',
[location.pathname],
);
const currentLabel = useMemo(() => {
const fullPath = `${location.pathname}${location.search}`;
return allLinks.find(link => link.href === fullPath)?.label ?? 'Dev Screens';
}, [location.pathname, location.search]);
return (
<div
@@ -150,8 +163,20 @@ export const DevRouteMenu: React.FC = () => {
>
{group.title}
</div>
{group.description ? (
<div
style={{
color: 'rgba(255, 255, 255, 0.65)',
fontSize: 11,
lineHeight: 1.4,
padding: '0 2px 6px',
}}
>
{group.description}
</div>
) : null}
{group.links.map(link => {
const isActive = location.pathname === link.href;
const isActive = `${location.pathname}${location.search}` === link.href;
return (
<button

View File

@@ -11,7 +11,6 @@ import { ComingSoonScreen as EuclidComingSoonScreen } from '@selfxyz/euclid';
import { useSelfClient } from '../providers/SelfClientProvider';
import { getCountryName, renderFlag } from '../utils/countryFlags';
import { WEB_SAFE_AREA } from '../utils/insets';
import { shouldUseHistoryBack } from '../utils/mockOnboardingFlow';
export const ComingSoonScreen: React.FC = () => {
const navigate = useNavigate();
@@ -29,11 +28,6 @@ export const ComingSoonScreen: React.FC = () => {
const onDismiss = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('coming_soon_dismissed');
if (shouldUseHistoryBack()) {
navigate(-1);
return;
}
navigate('/');
}, [navigate, haptic, analytics]);

View File

@@ -10,7 +10,7 @@ import { ConflictDetectedScreen as EuclidConflictDetectedScreen } from '@selfxyz
import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { getPromptMockFromSearch, getPromptMockSearch, shouldUseHistoryBack } from '../../utils/mockOnboardingFlow';
import { getPromptMockFromSearch, getPromptMockSearch } from '../../utils/mockOnboardingFlow';
export const ConflictDetectedScreen: React.FC = () => {
const location = useLocation();
@@ -32,11 +32,6 @@ export const ConflictDetectedScreen: React.FC = () => {
const onClose = useCallback(() => {
haptic.trigger('selection');
if (shouldUseHistoryBack()) {
navigate(-1);
return;
}
navigate(`/onboarding/signin${getPromptMockSearch(mock === 'existing-account' ? mock : 'default')}`);
}, [mock, navigate, haptic]);

View File

@@ -15,12 +15,14 @@ import type { KycProviderResult } from '../../types/kycProvider';
import { buildKycDocument } from '../../utils/buildKycDocument';
import { waitForAttestation } from '../../utils/diditAttestation';
import { createDiditSession, launchDiditWebSdk } from '../../utils/diditProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
const CONTAINER_ID = 'didit-sdk-container';
type Phase = 'loading' | 'active' | 'waiting' | 'error';
interface ProviderLaunchState {
backPath?: string;
countryCode?: string;
documentType?: string;
nextPath?: string;
@@ -32,9 +34,10 @@ export const ProviderLaunchScreen: React.FC = () => {
const { client, analytics, haptic, lifecycle } = useSelfClient();
const { verificationId: ctxVerificationId, environment } = useVerificationRequest();
const { countryCode = '', documentType = '', nextPath } = (location.state as ProviderLaunchState) || {};
const { backPath, countryCode = '', documentType = '', nextPath } = (location.state as ProviderLaunchState) || {};
const defaultNextPath = nextPath ?? '/onboarding/provider-result';
const isTunnelFlow = defaultNextPath.startsWith('/tunnel/') || backPath?.startsWith('/tunnel/') === true;
const verificationId = ctxVerificationId ?? `didit-${Date.now()}`;
const [phase, setPhase] = useState<Phase>('loading');
@@ -222,13 +225,20 @@ export const ProviderLaunchScreen: React.FC = () => {
countryCode,
documentType,
});
if (isTunnelFlow) {
navigate(backPath ?? '/tunnel/tour/4', { replace: true });
return;
}
lifecycle.dismiss({ reason: 'back' });
if (window.history.length > 1) {
navigate(-1);
} else {
navigate('/', { state: { skipOnboardingRedirect: true } });
return;
}
}, [analytics, countryCode, documentType, haptic, lifecycle, navigate]);
navigate('/', { state: { skipOnboardingRedirect: true } });
}, [analytics, backPath, countryCode, documentType, haptic, isTunnelFlow, lifecycle, navigate]);
const handleRetry = useCallback(() => {
haptic.trigger('selection');
@@ -285,20 +295,33 @@ export const ProviderLaunchScreen: React.FC = () => {
return (
<div
style={{
minHeight: '100vh',
height: '100vh',
display: 'flex',
flexDirection: 'column',
position: 'relative',
backgroundColor: colors.white,
overflow: 'hidden',
}}
>
{phase === 'waiting' && (
<KycPendingScreen
insets={{ top: 0, bottom: 0 }}
onCheckBackLater={handleBack}
onReceiveLiveUpdates={() => {
// TODO: wire up push notifications
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
/>
>
<KycPendingScreen
insets={WEB_SAFE_AREA.insets}
onCheckBackLater={handleBack}
onReceiveLiveUpdates={() => {
// TODO: wire up push notifications
}}
/>
</div>
)}
{phase === 'loading' && (
<div
@@ -327,18 +350,18 @@ export const ProviderLaunchScreen: React.FC = () => {
</div>
)}
<style>{`
.shadow-card {
#${CONTAINER_ID} .shadow-card {
width: 100% !important;
max-width: 100% !important;
height: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
}
iframe[class*="in-iframe"] {
#${CONTAINER_ID} iframe[class*="in-iframe"] {
width: 100% !important;
height: 100% !important;
}
div[class*="size-full"] {
#${CONTAINER_ID} div[class*="size-full"] {
width: 100vw !important;
max-width: 100vw !important;
}

View File

@@ -10,7 +10,7 @@ import { PushNotificationPromptScreen as EuclidPushNotificationPromptScreen } fr
import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
import { getPromptMockFromSearch, getPromptMockSearch, shouldUseHistoryBack } from '../../utils/mockOnboardingFlow';
import { getPromptMockFromSearch, getPromptMockSearch } from '../../utils/mockOnboardingFlow';
export const PushNotificationPromptScreen: React.FC = () => {
const location = useLocation();
@@ -33,13 +33,7 @@ export const PushNotificationPromptScreen: React.FC = () => {
const onClose = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('push_notification_header_back', { mock });
if (shouldUseHistoryBack()) {
navigate(-1);
return;
}
navigate(`/onboarding/backup${getPromptMockSearch(mock)}`);
navigate(`/onboarding/recovery-phrase${getPromptMockSearch(mock)}`);
}, [mock, navigate, haptic, analytics]);
return (

View File

@@ -32,7 +32,7 @@ export const ScanSuccessScreen: React.FC = () => {
const advanceToBackupPrompt = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('registration_success_finished');
navigate(`/onboarding/backup${getPromptMockSearch()}`, {
navigate(`/onboarding/recovery-phrase${getPromptMockSearch()}`, {
state: { skipOnboardingRedirect: true },
});
}, [analytics, haptic, navigate]);

View File

@@ -24,7 +24,7 @@ export const ProofSuccessBackupScreen: React.FC = () => {
const onBackupAccount = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('proof_success_backup_pressed');
navigate('/settings/security');
navigate('/settings/recovery-phrase');
}, [navigate, haptic, analytics]);
return (

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { LaunchRecoveryScreen as EuclidLaunchRecoveryScreen, LeftArrowIcon } from '@selfxyz/euclid';
@@ -12,19 +12,26 @@ import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
export const LaunchRecoveryScreen: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const { analytics, haptic } = useSelfClient();
const backPath = (location.state as { backPath?: string } | null)?.backPath ?? '/settings/security';
const onClose = useCallback(() => {
haptic.trigger('selection');
navigate('/settings/security');
}, [navigate, haptic]);
navigate(backPath, { replace: true });
}, [backPath, navigate, haptic]);
const isTunnelFlow = backPath.startsWith('/tunnel/');
const onEnterRecoveryPhrase = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('recovery_enter_phrase_pressed');
navigate('/recovery/phrase-input');
}, [navigate, haptic, analytics]);
const target = isTunnelFlow
? `/recovery/phrase-input?returnTo=${encodeURIComponent(backPath)}`
: '/recovery/phrase-input';
navigate(target);
}, [backPath, isTunnelFlow, navigate, haptic, analytics]);
return (
<div className="launch-recovery-screen">

View File

@@ -4,55 +4,206 @@
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import type { RecoveryPhraseVariant } from '@selfxyz/euclid';
import { RecoveryPhraseScreen as EuclidRecoveryPhraseScreen } from '@selfxyz/euclid';
import {
borderRadius,
colors,
fontFamily,
fontWeight,
LeftArrowIcon,
RecoveryPhrase,
RecoveryPhraseScreen as EuclidRecoveryPhraseScreen,
type RecoveryPhraseVariant,
spacing,
TopNavigationDialogue,
} from '@selfxyz/euclid';
import { bridgeStorageAdapter } from '@selfxyz/webview-bridge/adapters';
import { useBridge } from '../../providers/BridgeProvider';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
const MNEMONIC_KEY = 'secret';
import { getPromptMockFromSearch, getPromptMockSearch } from '../../utils/mockOnboardingFlow';
import { ensureSecret, MNEMONIC_KEY } from '../../utils/secretManager';
function parseMnemonicWords(raw: string | null): string[] | undefined {
if (!raw) {
return undefined;
}
const parsed = JSON.parse(raw) as string | { phrase?: string };
const phrase = typeof parsed === 'string' ? parsed : parsed.phrase;
let phrase = raw;
try {
const parsed = JSON.parse(raw) as string | { phrase?: string };
phrase = typeof parsed === 'string' ? parsed : (parsed.phrase ?? raw);
} catch {
phrase = raw;
}
const words = phrase?.trim().split(/\s+/).filter(Boolean);
return words && words.length > 0 ? words : undefined;
}
export const RecoveryPhraseScreen: React.FC = () => {
const navigate = useNavigate();
const DEV_FAKE_MNEMONIC =
'jump car stuff tiger camp core wasp dream harlem sales mistake wish expose moose dribble noodle tornado peanut install install meat snail truck virgo';
function getDevFallbackMnemonicWords(): string[] | undefined {
if (!import.meta.env.DEV) {
return undefined;
}
return DEV_FAKE_MNEMONIC.split(' ');
}
async function resolveMnemonicWords(storage: ReturnType<typeof bridgeStorageAdapter>): Promise<string[] | undefined> {
try {
await ensureSecret(storage);
const storedWords = parseMnemonicWords(await storage.get(MNEMONIC_KEY));
return storedWords ?? getDevFallbackMnemonicWords();
} catch {
return getDevFallbackMnemonicWords();
}
}
interface RecoveryPhraseScreenBaseProps {
onBack: () => void;
onAppleBackup: () => void;
onGoogleBackup: () => void;
}
const recoveryPhrasePlaceholderWords = [
'***#****',
'****',
'****&****',
'(*****',
'***#',
'*****#********',
'********',
'**#**',
'#*******',
'******',
'***',
'*****#********',
'**#***',
'******',
'******',
'#**#',
'********',
'****',
'!******',
'*******',
'***#*********',
'*******',
'******',
'******',
];
const copy = {
navigationLabel: 'Recovery Phrase',
infoTitle: 'Back up your account',
infoDescriptionPrimary:
'Your secret recovery phrase is used to restore your account if you lose your phone or need to reinstall the Self app.',
infoDescriptionSecondary:
'Save these 24 words in a secure location, such as a password manager, and never share them with anyone.',
revealButtonLabel: 'Tap to reveal',
};
const settingsStyles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
flex: 1,
backgroundColor: colors.slate50,
},
header: {
backgroundColor: colors.slate50,
},
scrollView: {
flex: 1,
overflowY: 'auto' as const,
},
scrollContent: {
paddingLeft: spacing.mdLg,
paddingRight: spacing.mdLg,
paddingBottom: spacing.xlLg,
},
content: {
display: 'flex',
flexDirection: 'column' as const,
gap: spacing.mdLg,
paddingTop: spacing.mdLg,
},
infoBox: {
backgroundColor: colors.blue50,
borderWidth: 1,
borderStyle: 'solid' as const,
borderColor: colors.blue100,
borderRadius: borderRadius.mdd,
overflow: 'hidden' as const,
},
infoBoxContent: {
padding: spacing.mdLg,
display: 'flex',
flexDirection: 'column' as const,
gap: spacing.mdLg,
},
infoTextContainer: {
display: 'flex',
flexDirection: 'column' as const,
gap: spacing.smLg,
},
infoTitle: {
fontFamily: fontFamily.dinOT,
fontWeight: fontWeight.medium,
fontSize: 18,
color: colors.black,
lineHeight: '22px',
},
infoDescriptionContainer: {
display: 'flex',
flexDirection: 'column' as const,
gap: spacing.smPlus,
},
infoDescription: {
fontFamily: fontFamily.dinOT,
fontWeight: fontWeight.medium,
fontSize: 14,
color: colors.slate500,
lineHeight: '20px',
},
recoveryPhraseContainer: {
width: '100%',
flex: 1,
minHeight: 0,
},
};
const RecoveryPhraseScreenBase: React.FC<RecoveryPhraseScreenBaseProps> = ({
onBack,
onAppleBackup,
onGoogleBackup,
}) => {
const bridge = useBridge();
const storage = useRef(bridgeStorageAdapter(bridge)).current;
const { analytics, haptic } = useSelfClient();
const [variant, setVariant] = useState<RecoveryPhraseVariant>('hidden');
const [words, setWords] = useState<string[] | undefined>();
const onBack = useCallback(() => {
const handleBack = useCallback(() => {
haptic.trigger('selection');
navigate(-1);
}, [navigate, haptic]);
onBack();
}, [haptic, onBack]);
const onReveal = useCallback(async () => {
haptic.trigger('selection');
analytics.trackEvent('recovery_phrase_revealed');
let resolvedWords: string[] | undefined;
try {
resolvedWords = parseMnemonicWords(await storage.get(MNEMONIC_KEY));
} catch {
// Storage or parsing failed — words stay undefined, Euclid shows placeholders.
const resolvedWords = await resolveMnemonicWords(storage);
if (!resolvedWords?.length) {
haptic.trigger('error');
return;
}
setWords(resolvedWords);
setVariant('revealed');
}, [haptic, analytics, storage]);
@@ -78,11 +229,124 @@ export const RecoveryPhraseScreen: React.FC = () => {
insets={WEB_SAFE_AREA.insets}
words={words}
variant={variant}
onBack={onBack}
onBack={handleBack}
onReveal={onReveal}
onCopy={onCopy}
onAppleBackup={() => navigate('/coming-soon')}
onGoogleBackup={() => navigate('/coming-soon')}
onAppleBackup={onAppleBackup}
onGoogleBackup={onGoogleBackup}
/>
);
};
const SettingsRecoveryPhraseScreen: React.FC<{ onBack: () => void }> = ({ onBack }) => {
const bridge = useBridge();
const storage = useRef(bridgeStorageAdapter(bridge)).current;
const { analytics, haptic } = useSelfClient();
const [variant, setVariant] = useState<RecoveryPhraseVariant>('hidden');
const [words, setWords] = useState<string[] | undefined>();
const handleBack = useCallback(() => {
haptic.trigger('selection');
onBack();
}, [haptic, onBack]);
const onReveal = useCallback(async () => {
haptic.trigger('selection');
analytics.trackEvent('recovery_phrase_revealed');
const resolvedWords = await resolveMnemonicWords(storage);
if (!resolvedWords?.length) {
haptic.trigger('error');
return;
}
setWords(resolvedWords);
setVariant('revealed');
}, [analytics, haptic, storage]);
const onCopy = useCallback(async () => {
analytics.trackEvent('recovery_phrase_copied');
if (!words?.length || !navigator.clipboard) {
return;
}
try {
await navigator.clipboard.writeText(words.join(' '));
haptic.trigger('success');
setVariant('copied');
} catch {
haptic.trigger('error');
}
}, [analytics, haptic, words]);
return (
<div style={settingsStyles.container}>
<div style={settingsStyles.header}>
<TopNavigationDialogue
variant="Primary"
label={copy.navigationLabel}
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
infoIcon={({ size }) => <div style={{ width: size, height: size }} />}
onEscape={handleBack}
onPressInfo={() => {}}
/>
</div>
<div style={settingsStyles.scrollView}>
<div style={settingsStyles.scrollContent}>
<div style={settingsStyles.content}>
<div style={settingsStyles.infoBox}>
<div style={settingsStyles.infoBoxContent}>
<div style={settingsStyles.infoTextContainer}>
<span style={settingsStyles.infoTitle}>{copy.infoTitle}</span>
<div style={settingsStyles.infoDescriptionContainer}>
<span style={settingsStyles.infoDescription}>{copy.infoDescriptionPrimary}</span>
<span style={settingsStyles.infoDescription}>{copy.infoDescriptionSecondary}</span>
</div>
</div>
</div>
</div>
<div style={settingsStyles.recoveryPhraseContainer}>
<RecoveryPhrase
variant={variant}
words={words || recoveryPhrasePlaceholderWords}
onReveal={onReveal}
onCopy={onCopy}
revealButtonText={copy.revealButtonLabel}
/>
</div>
</div>
</div>
</div>
</div>
);
};
export const OnboardingRecoveryPhraseScreen: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const mock = getPromptMockFromSearch(location.search);
const notificationsPath = `/onboarding/notifications${getPromptMockSearch(mock)}`;
const successPath = `/onboarding/success${getPromptMockSearch(mock)}`;
const onBack = useCallback(() => {
navigate(successPath);
}, [navigate, successPath]);
const advanceToNotifications = useCallback(() => {
navigate(notificationsPath);
}, [navigate, notificationsPath]);
return (
<RecoveryPhraseScreenBase
onBack={onBack}
onAppleBackup={advanceToNotifications}
onGoogleBackup={advanceToNotifications}
/>
);
};
export const RecoveryPhraseScreen: React.FC = () => {
const navigate = useNavigate();
return <SettingsRecoveryPhraseScreen onBack={() => navigate(-1)} />;
};

View File

@@ -4,7 +4,7 @@
import type React from 'react';
import { useCallback } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
import { LaunchTour1Screen, LaunchTour2Screen, LaunchTour3Screen, LaunchTour4Screen } from '@selfxyz/euclid';
import { loadSelectedDocument } from '@selfxyz/mobile-sdk-alpha/browser';
@@ -14,13 +14,15 @@ import { WEB_SAFE_AREA } from '../../utils/insets';
export const TourScreen: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { step } = useParams<{ step: string }>();
const stepNum = parseInt(step ?? '1', 10);
const { client } = useSelfClient();
const mockParam = import.meta.env.DEV ? location.search : '';
const onNext = useCallback(async () => {
if (stepNum < 4) {
navigate(`/tunnel/tour/${stepNum + 1}`);
navigate(`/tunnel/tour/${stepNum + 1}${mockParam}`);
return;
}
@@ -34,12 +36,12 @@ export const TourScreen: React.FC = () => {
// Fall through to KYC when document state is unavailable.
}
navigate('/tunnel/kyc');
}, [navigate, stepNum, client]);
navigate(`/tunnel/kyc${mockParam}`);
}, [navigate, stepNum, client, mockParam]);
const onRestore = useCallback(() => {
navigate('/recovery');
}, []);
navigate('/recovery', { state: { backPath: `/tunnel/tour/${step ?? '1'}` } });
}, [navigate, step]);
switch (step) {
case '1':

View File

@@ -51,7 +51,7 @@ export const TunnelIDTypeScreen: React.FC = () => {
countryName={getCountryName(countryCode)}
idTypes={idTypes}
onIDTypeSelect={onIDTypeSelect}
onBack={() => navigate(-1)}
onBack={() => navigate('/tunnel/registration/country')}
renderFlag={renderFlag}
renderIDTypeIcon={renderIDTypeIcon}
/>

Some files were not shown because too many files have changed in this diff Show More