From e117fbe913d9b5bd16ae1f00ac95cd63941b50f6 Mon Sep 17 00:00:00 2001 From: 0xPenryn <29718876+0xPenryn@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:47:44 +0100 Subject: [PATCH 1/2] Add Identity Check simulation support --- README.md | 13 + package.json | 2 + pnpm-lock.yaml | 310 +++++++++++++++++- sidecar/src/error.rs | 9 + sidecar/src/main.rs | 1 + sidecar/src/persona.rs | 233 +++++++++++++ sidecar/src/routes.rs | 260 +++++++++++++-- .../IdentityProfile/V4PersonaEditor.tsx | 191 +++++++++++ src/components/Modal/ModalStatus.tsx | 14 +- src/components/Modal/index.tsx | 119 ++++++- src/components/SelectID/IDRow.tsx | 79 +++-- src/components/Settings.tsx | 33 +- src/hooks/useIdentity.ts | 8 +- src/lib/identity-check-preflight.ts | 195 +++++++++++ src/lib/identity-check-response.ts | 22 ++ src/lib/identity-persona.ts | 170 ++++++++++ src/pages/api/sidecar/[...path].ts | 32 +- src/stores/identityStore.ts | 43 ++- src/types/bridge-initial-data.ts | 11 + src/types/identity.ts | 16 + test/identity-check-preflight.test.ts | 258 +++++++++++++++ test/identity-check-response.test.ts | 36 ++ 22 files changed, 1943 insertions(+), 112 deletions(-) create mode 100644 sidecar/src/persona.rs create mode 100644 src/components/IdentityProfile/V4PersonaEditor.tsx create mode 100644 src/lib/identity-check-preflight.ts create mode 100644 src/lib/identity-check-response.ts create mode 100644 src/lib/identity-persona.ts create mode 100644 test/identity-check-preflight.test.ts create mode 100644 test/identity-check-response.test.ts diff --git a/README.md b/README.md index 1ba6a776..eb981d7b 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,16 @@ World ID QR URL: When valid, the simulator will auto-select an identity and open the existing verification modal flow. Invalid URLs show a toast and are ignored. + +## 🪪 Identity Check testing + +The simulator supports IDKit's +[Identity Check preview](https://docs.world.org/world-id/idkit/credentials#identity-check-preview). +Open an identity's settings to configure its simulated document type, document +number, issuing country, full name, age, and nationality. + +Identity Check requests are handled through the World ID 4.0 sidecar. Passport +and eID personas use the ICAO-9303 credential, while MNC personas use the MNC +credential. When every requested attribute matches, the simulator returns +IDKit's attested response envelope with `identity_attested: true`. A mismatch +returns `identity_attributes_not_matched`. diff --git a/package.json b/package.json index 7e2dd02a..f1563e5a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format": "next lint --fix && prettier --write src/**", "typecheck": "tsc --noEmit --emitDeclarationOnly false --declaration false", "spellcheck": "cspell **/*.{md,ts,tsx}", + "test:identity-check": "tsx --test test/identity-check-*.test.ts", "prepare": "husky install", "prebuild": "node -e \"const fs = require('fs'); const pkg = require('./package.json'); fs.writeFileSync('./public/version.json', JSON.stringify({ version: pkg.version }));\"" }, @@ -62,6 +63,7 @@ "prettier-plugin-organize-imports": "^2.3.4", "prettier-plugin-tailwindcss": "0.1.10", "tailwindcss": "^3.3.6", + "tsx": "^4.22.4", "typescript": "5.0.4", "workbox-cli": "^6.6.1", "workbox-expiration": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58fd36af..9fcf2899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,7 +116,7 @@ importers: version: 4.6.2(eslint@8.38.0) eslint-plugin-tailwindcss: specifier: ^3.13.0 - version: 3.18.2(tailwindcss@3.4.19) + version: 3.18.2(tailwindcss@3.4.19(tsx@4.22.4)) husky: specifier: ^8.0.3 version: 8.0.3 @@ -137,7 +137,10 @@ importers: version: 0.1.10(prettier@2.8.8) tailwindcss: specifier: ^3.3.6 - version: 3.4.19 + version: 3.4.19(tsx@4.22.4) + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: specifier: 5.0.4 version: 5.0.4 @@ -857,6 +860,162 @@ packages: '@emotion/memoize@0.7.4': resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -966,24 +1125,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -1457,41 +1620,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1524,6 +1695,7 @@ packages: '@worldcoin/idkit-core@1.5.0': resolution: {integrity: sha512-BNDWHi7fKwGbooyuohCJqCfPIgvpN2sp4katM/YBicQH7T9y+YpCm4zAJsy4hh9T+dALS0hPeC+rRzqgRFjJFg==} engines: {node: '>=12.4'} + deprecated: Old-versions moved to new ones peerDependencies: react-native-quick-crypto: ^0.7.13 peerDependenciesMeta: @@ -2149,6 +2321,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3921,6 +4098,11 @@ packages: peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5155,6 +5337,84 @@ snapshots: '@emotion/memoize@0.7.4': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.38.0)': dependencies: eslint: 8.38.0 @@ -6570,6 +6830,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-goat@2.1.1: {} @@ -6717,11 +7006,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19): + eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.22.4)): dependencies: fast-glob: 3.3.3 postcss: 8.4.32 - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(tsx@4.22.4) eslint-scope@5.1.1: dependencies: @@ -7771,12 +8060,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.22.4): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.22.4 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -8357,7 +8647,7 @@ snapshots: tailwind-merge@1.14.0: {} - tailwindcss@3.4.19: + tailwindcss@3.4.19(tsx@4.22.4): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -8376,7 +8666,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.22.4) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -8468,6 +8758,12 @@ snapshots: tslib: 1.14.1 typescript: 5.0.4 + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/sidecar/src/error.rs b/sidecar/src/error.rs index 6b3a19dd..53e7cbc6 100644 --- a/sidecar/src/error.rs +++ b/sidecar/src/error.rs @@ -5,9 +5,12 @@ use world_id_core::AuthenticatorError; use world_id_proof::ProofError; /// Sidecar error type that converts into HTTP responses. +#[derive(Debug)] pub enum SidecarError { /// The user's credentials do not satisfy the proof request constraints. CredentialUnavailable, + /// The selected credential matched, but requested identity attributes did not. + IdentityAttributesNotMatched, /// The requested identity index does not exist. /// /// No longer produced now that the proof endpoints auto-select the identity from the @@ -31,6 +34,7 @@ impl std::fmt::Display for SidecarError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::CredentialUnavailable => write!(f, "credential_unavailable"), + Self::IdentityAttributesNotMatched => write!(f, "identity_attributes_not_matched"), Self::IdentityNotFound => write!(f, "identity_not_found"), Self::BadRequest(msg) => write!(f, "bad request: {msg}"), Self::Authenticator(e) => write!(f, "authenticator error: {e}"), @@ -60,6 +64,11 @@ impl IntoResponse for SidecarError { Json(serde_json::json!({"error_code": "credential_unavailable"})), ) .into_response(), + SidecarError::IdentityAttributesNotMatched => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error_code": "identity_attributes_not_matched"})), + ) + .into_response(), SidecarError::IdentityNotFound => ( StatusCode::NOT_FOUND, Json(serde_json::json!({"error_code": "identity_not_found"})), diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index d7d85b22..702490e9 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -3,6 +3,7 @@ use std::sync::Arc; mod auth; mod config; mod error; +mod persona; mod routes; use config::SidecarConfig; diff --git a/sidecar/src/persona.rs b/sidecar/src/persona.rs new file mode 100644 index 00000000..fe228aab --- /dev/null +++ b/sidecar/src/persona.rs @@ -0,0 +1,233 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +pub const PASSPORT_ISSUER_SCHEMA_ID: u64 = 9303; +pub const MNC_ISSUER_SCHEMA_ID: u64 = 9310; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PersonaDocumentType { + Eid, + Passport, + Mnc, +} + +impl PersonaDocumentType { + pub const fn issuer_schema_id(self) -> u64 { + match self { + Self::Eid | Self::Passport => PASSPORT_ISSUER_SCHEMA_ID, + Self::Mnc => MNC_ISSUER_SCHEMA_ID, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct IdentityPersona { + pub document_type: PersonaDocumentType, + pub document_number: String, + pub issuing_country: String, + pub full_name: String, + pub age: u8, + pub nationality: String, +} + +impl IdentityPersona { + pub fn is_valid(&self) -> bool { + !self.document_number.trim().is_empty() + && is_iso_alpha_3(&self.issuing_country) + && !self.full_name.trim().is_empty() + && is_iso_alpha_3(&self.nationality) + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RequestedDocumentType { + Passport, + Eid, + Mnc, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum IdentityAttribute { + DocumentType(RequestedDocumentType), + DocumentNumber(String), + IssuingCountry(String), + FullName(String), + MinimumAge(u8), + Nationality(String), +} + +pub fn available_for_persona( + available: &HashSet, + persona: Option<&IdentityPersona>, +) -> HashSet { + let Some(persona) = persona else { + return available.clone(); + }; + + let persona_schema_id = persona.document_type.issuer_schema_id(); + + available + .iter() + .copied() + .filter(|schema_id| { + *schema_id != PASSPORT_ISSUER_SCHEMA_ID && *schema_id != MNC_ISSUER_SCHEMA_ID + || *schema_id == persona_schema_id + }) + .collect() +} + +pub fn includes_persona_document_schema(proved_schema_ids: I, persona: &IdentityPersona) -> bool +where + I: IntoIterator, +{ + let persona_schema_id = persona.document_type.issuer_schema_id(); + proved_schema_ids + .into_iter() + .any(|schema_id| schema_id == persona_schema_id) +} + +pub fn identity_attributes_match( + persona: &IdentityPersona, + attributes: &[IdentityAttribute], +) -> bool { + attributes.iter().all(|attribute| match attribute { + IdentityAttribute::DocumentType(requested) => match requested { + RequestedDocumentType::Passport => { + persona.document_type == PersonaDocumentType::Passport + } + RequestedDocumentType::Eid => persona.document_type == PersonaDocumentType::Eid, + RequestedDocumentType::Mnc => persona.document_type == PersonaDocumentType::Mnc, + }, + IdentityAttribute::DocumentNumber(value) => trim_eq(&persona.document_number, value), + IdentityAttribute::IssuingCountry(value) => upper_trim_eq(&persona.issuing_country, value), + IdentityAttribute::FullName(value) => trim_eq(&persona.full_name, value), + IdentityAttribute::MinimumAge(value) => persona.age >= *value, + IdentityAttribute::Nationality(value) => upper_trim_eq(&persona.nationality, value), + }) +} + +fn trim_eq(left: &str, right: &str) -> bool { + left.trim() == right.trim() +} + +fn upper_trim_eq(left: &str, right: &str) -> bool { + left.trim().eq_ignore_ascii_case(right.trim()) +} + +fn is_iso_alpha_3(value: &str) -> bool { + let value = value.trim(); + value.len() == 3 && value.bytes().all(|byte| byte.is_ascii_alphabetic()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn passport_persona() -> IdentityPersona { + IdentityPersona { + document_type: PersonaDocumentType::Passport, + document_number: "X1234567".to_string(), + issuing_country: "USA".to_string(), + full_name: "John Doe".to_string(), + age: 30, + nationality: "USA".to_string(), + } + } + + #[test] + fn passport_persona_filters_out_mnc_document_credential() { + let available = HashSet::from([1, PASSPORT_ISSUER_SCHEMA_ID, MNC_ISSUER_SCHEMA_ID]); + let persona = passport_persona(); + + let filtered = available_for_persona(&available, Some(&persona)); + + assert!(filtered.contains(&1)); + assert!(filtered.contains(&PASSPORT_ISSUER_SCHEMA_ID)); + assert!(!filtered.contains(&MNC_ISSUER_SCHEMA_ID)); + } + + #[test] + fn mnc_persona_filters_out_passport_document_credential() { + let available = HashSet::from([1, PASSPORT_ISSUER_SCHEMA_ID, MNC_ISSUER_SCHEMA_ID]); + let mut persona = passport_persona(); + persona.document_type = PersonaDocumentType::Mnc; + + let filtered = available_for_persona(&available, Some(&persona)); + + assert!(filtered.contains(&1)); + assert!(!filtered.contains(&PASSPORT_ISSUER_SCHEMA_ID)); + assert!(filtered.contains(&MNC_ISSUER_SCHEMA_ID)); + } + + #[test] + fn persona_document_schema_must_be_selected_for_attestation() { + let persona = passport_persona(); + + assert!(!includes_persona_document_schema([1, 11], &persona)); + assert!(includes_persona_document_schema( + [1, PASSPORT_ISSUER_SCHEMA_ID], + &persona + )); + } + + #[test] + fn matching_passport_attributes_pass() { + let persona = passport_persona(); + let attributes = vec![ + IdentityAttribute::DocumentType(RequestedDocumentType::Passport), + IdentityAttribute::DocumentNumber(" X1234567 ".to_string()), + IdentityAttribute::IssuingCountry("usa".to_string()), + IdentityAttribute::FullName("John Doe".to_string()), + IdentityAttribute::MinimumAge(18), + IdentityAttribute::Nationality("usa".to_string()), + ]; + + assert!(identity_attributes_match(&persona, &attributes)); + } + + #[test] + fn empty_attributes_match_when_document_proof_is_selected_elsewhere() { + let persona = passport_persona(); + + assert!(identity_attributes_match(&persona, &[])); + } + + #[test] + fn underage_persona_fails_minimum_age() { + let mut persona = passport_persona(); + persona.age = 17; + + assert!(!identity_attributes_match( + &persona, + &[IdentityAttribute::MinimumAge(18)] + )); + } + + #[test] + fn eid_persona_uses_icao_9303_credential_and_matches_eid_request() { + let mut persona = passport_persona(); + persona.document_type = PersonaDocumentType::Eid; + + assert_eq!( + persona.document_type.issuer_schema_id(), + PASSPORT_ISSUER_SCHEMA_ID + ); + assert!(identity_attributes_match( + &persona, + &[IdentityAttribute::DocumentType(RequestedDocumentType::Eid)] + )); + } + + #[test] + fn persona_validation_rejects_invalid_country_codes() { + let mut persona = passport_persona(); + assert!(persona.is_valid()); + + persona.issuing_country = "US".to_string(); + assert!(!persona.is_valid()); + } +} diff --git a/sidecar/src/routes.rs b/sidecar/src/routes.rs index 333658bb..d9279c1b 100644 --- a/sidecar/src/routes.rs +++ b/sidecar/src/routes.rs @@ -7,11 +7,15 @@ use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use tower_http::cors::CorsLayer; -use world_id_core::requests::{ProofRequest, ProofResponse, RequestItem}; +use world_id_core::requests::{ProofRequest, RequestItem}; use world_id_core::{Authenticator, Credential, CredentialInput}; use crate::auth::bearer_auth; use crate::error::SidecarError; +use crate::persona::{ + available_for_persona, identity_attributes_match, includes_persona_document_schema, + IdentityAttribute, IdentityPersona, +}; /// Shared application state. pub struct AppState { @@ -34,6 +38,10 @@ pub struct ProofRequestBody { pub identity_index: Option, /// The ProofRequest from the bridge payload (passed through from IDKit). pub proof_request: serde_json::Value, + /// Identity Check attributes requested by the bridge payload. + pub identity_attributes: Option>, + /// Simulator persona for the selected identity. Required with identity_attributes. + pub persona: Option, } /// Identity info returned by GET /identities. @@ -91,30 +99,34 @@ async fn list_identities(State(state): State>) -> Json>, Json(req): Json, -) -> Result, SidecarError> { +) -> Result, SidecarError> { generate_proof_inner(&state, req, false).await } async fn generate_session_proof( State(state): State>, Json(req): Json, -) -> Result, SidecarError> { +) -> Result, SidecarError> { generate_proof_inner(&state, req, true).await } /// Available credential schema ids for a single identity. -fn available_schema_ids(identity: &IdentityState) -> HashSet { - identity +fn available_schema_ids( + identity: &IdentityState, + persona: Option<&IdentityPersona>, +) -> HashSet { + let available = identity .credentials .iter() .map(|c| c.issuer_schema_id) - .collect() + .collect(); + available_for_persona(&available, persona) } /// Selects the first identity (in config order) that can satisfy the proof request, returning /// its index and the request items to prove. /// -/// Pure and side-effect free — it only consults `ProofRequest::credentials_to_prove`, which +/// Pure and side-effect free - it only consults `ProofRequest::credentials_to_prove`, which /// checks schema-id availability and any constraint expression. It does NOT verify /// `expires_at_min` / `genesis_issued_at_min`; those are enforced later inside /// `generate_proof`, so a match here is necessary but not a full guarantee of success. @@ -140,16 +152,21 @@ async fn generate_proof_inner( state: &AppState, req: ProofRequestBody, is_session: bool, -) -> Result, SidecarError> { - // `identity_index` is deprecated: the identity is auto-selected from the requested - // credentials. Surface a migration signal when callers still send it. - if req.identity_index.is_some() { +) -> Result, SidecarError> { + let ProofRequestBody { + identity_index, + proof_request, + identity_attributes, + persona, + } = req; + + if identity_index.is_some() { tracing::warn!( "identity_index is deprecated and ignored; identity is auto-selected from the requested credentials" ); } - let proof_request = ProofRequest::from_json(&req.proof_request.to_string()) + let proof_request = ProofRequest::from_json(&proof_request.to_string()) .map_err(|e| SidecarError::BadRequest(format!("invalid proof_request: {e}")))?; if is_session != proof_request.is_session_proof() { @@ -158,10 +175,41 @@ async fn generate_proof_inner( )); } + let is_identity_check = identity_attributes.is_some(); + let identity_attributes = identity_attributes.unwrap_or_default(); + if is_identity_check && is_session { + return Err(SidecarError::BadRequest( + "identity check is only supported for uniqueness proofs".to_string(), + )); + } + let persona = if is_identity_check { + let persona = persona + .as_ref() + .ok_or(SidecarError::CredentialUnavailable)?; + if !persona.is_valid() { + return Err(SidecarError::BadRequest( + "persona contains invalid identity details".to_string(), + )); + } + Some(persona) + } else { + None + }; + // Auto-select: pick the first configured identity whose credentials satisfy the request. - let available = state.identities.iter().map(available_schema_ids); - let (selected_index, items_to_prove) = select_identity(available, &proof_request) - .ok_or(SidecarError::CredentialUnavailable)?; + // Identity Check restricts Passport/MNC availability to the selected persona first. + let available = state + .identities + .iter() + .map(|identity| available_schema_ids(identity, persona)); + let (selected_index, items_to_prove) = + select_identity(available, &proof_request).ok_or(SidecarError::CredentialUnavailable)?; + + validate_identity_check_selection( + persona, + &identity_attributes, + items_to_prove.iter().map(|item| item.issuer_schema_id), + )?; let identity = &state.identities[selected_index]; tracing::info!(selected_index, "auto-selected identity for proof request"); @@ -210,26 +258,79 @@ async fn generate_proof_inner( .await .map_err(SidecarError::from)?; - Ok(Json(result.proof_response)) + Ok(Json(bridge_response_payload( + &result.proof_response, + is_identity_check, + )?)) +} + +fn validate_identity_check_selection( + persona: Option<&IdentityPersona>, + identity_attributes: &[IdentityAttribute], + proved_schema_ids: I, +) -> Result<(), SidecarError> +where + I: IntoIterator, +{ + let Some(persona) = persona else { + return Ok(()); + }; + + if !includes_persona_document_schema(proved_schema_ids, persona) { + return Err(SidecarError::CredentialUnavailable); + } + + if !identity_attributes_match(persona, identity_attributes) { + return Err(SidecarError::IdentityAttributesNotMatched); + } + + Ok(()) +} + +fn bridge_response_payload( + proof_response: &impl Serialize, + is_identity_check: bool, +) -> Result { + if is_identity_check { + return Ok(serde_json::json!({ + "proof_response": proof_response, + "identity_attested": true, + })); + } + + serde_json::to_value(proof_response) + .map_err(|e| SidecarError::BadRequest(format!("invalid proof response: {e}"))) } #[cfg(test)] mod tests { use super::*; + use crate::persona::{ + IdentityAttribute, IdentityPersona, PersonaDocumentType, MNC_ISSUER_SCHEMA_ID, + PASSPORT_ISSUER_SCHEMA_ID, + }; - // Well-known schema ids (see idkit `CredentialType::issuer_schema_id`). const POH: u64 = 1; - const PASSPORT: u64 = 9303; - const MNC: u64 = 9310; + const PASSPORT: u64 = PASSPORT_ISSUER_SCHEMA_ID; + const MNC: u64 = MNC_ISSUER_SCHEMA_ID; + + fn passport_persona() -> IdentityPersona { + IdentityPersona { + document_type: PersonaDocumentType::Passport, + document_number: "X1234567".to_string(), + issuing_country: "USA".to_string(), + full_name: "John Doe".to_string(), + age: 30, + nationality: "USA".to_string(), + } + } /// Builds a valid uniqueness `ProofRequest` requesting the given (identifier, schema_id) items. /// `proof_type` is omitted, which `world_id_core` treats as uniqueness. fn proof_request(items: &[(&str, u64)]) -> ProofRequest { let reqs: Vec = items .iter() - .map(|(id, schema)| { - format!(r#"{{"identifier":"{id}","issuer_schema_id":{schema}}}"#) - }) + .map(|(id, schema)| format!(r#"{{"identifier":"{id}","issuer_schema_id":{schema}}}"#)) .collect(); let json = format!( r#"{{ @@ -250,7 +351,6 @@ mod tests { ProofRequest::from_json(&json).expect("valid test proof request") } - /// Convenience: build the per-identity available-schema sets. fn sets(per_identity: &[&[u64]]) -> Vec> { per_identity .iter() @@ -258,18 +358,97 @@ mod tests { .collect() } - /// Index of the selected identity, dropping the items (most assertions only care about which - /// identity was chosen). - fn selected_index( - available: &[HashSet], - request: &ProofRequest, - ) -> Option { + fn selected_index(available: &[HashSet], request: &ProofRequest) -> Option { select_identity(available.iter().cloned(), request).map(|(index, _)| index) } + #[test] + fn identity_check_success_path_wraps_response_v2_1() { + let proof_response = serde_json::json!({ + "id": "proof-id", + "version": 2, + "responses": [], + }); + + let payload = bridge_response_payload(&proof_response, true).unwrap(); + + assert_eq!(payload["proof_response"], proof_response); + assert_eq!(payload["identity_attested"], true); + assert!(payload.get("id").is_none()); + } + + #[test] + fn regular_v4_payload_stays_flat_response_v2() { + let proof_response = serde_json::json!({ + "id": "proof-id", + "version": 2, + "responses": [], + }); + + let payload = bridge_response_payload(&proof_response, false).unwrap(); + + assert_eq!(payload, proof_response); + assert!(payload.get("proof_response").is_none()); + assert!(payload.get("identity_attested").is_none()); + } + + #[test] + fn identity_check_mismatched_attributes_returns_identity_attributes_not_matched() { + let persona = passport_persona(); + + let result = validate_identity_check_selection( + Some(&persona), + &[IdentityAttribute::Nationality("CAN".to_string())], + [PASSPORT], + ); + + assert!(matches!( + result, + Err(SidecarError::IdentityAttributesNotMatched) + )); + } + + #[test] + fn identity_check_non_document_selection_returns_credential_unavailable() { + let persona = passport_persona(); + + let result = validate_identity_check_selection(Some(&persona), &[], [POH]); + + assert!(matches!(result, Err(SidecarError::CredentialUnavailable))); + } + + #[test] + fn empty_identity_attributes_still_requires_document_selection() { + let persona = passport_persona(); + + assert!(validate_identity_check_selection(Some(&persona), &[], [PASSPORT]).is_ok()); + assert!(matches!( + validate_identity_check_selection(Some(&persona), &[], [MNC]), + Err(SidecarError::CredentialUnavailable) + )); + } + + #[test] + fn eid_persona_uses_passport_schema_for_identity_check() { + let mut persona = passport_persona(); + persona.document_type = PersonaDocumentType::Eid; + + assert!(validate_identity_check_selection( + Some(&persona), + &[IdentityAttribute::DocumentType( + crate::persona::RequestedDocumentType::Eid, + )], + [PASSPORT], + ) + .is_ok()); + assert!(matches!( + validate_identity_check_selection(Some(&persona), &[], [MNC]), + Err(SidecarError::CredentialUnavailable) + )); + } + #[test] fn selects_identity_holding_requested_credential() { - // identity 0 = PoH + Passport, identity 1 = MNC. let available = sets(&[&[POH, PASSPORT], &[MNC]]); let poh = proof_request(&[("orb", POH)]); @@ -278,14 +457,12 @@ mod tests { let passport = proof_request(&[("passport", PASSPORT)]); assert_eq!(selected_index(&available, &passport), Some(0)); - // MNC lives only on identity 1 — auto-selection must route there. let mnc = proof_request(&[("mnc", MNC)]); assert_eq!(selected_index(&available, &mnc), Some(1)); } #[test] fn returns_selected_items_to_prove() { - // The winning identity also yields the request items to prove (no recomputation needed). let available = sets(&[&[POH, PASSPORT], &[MNC]]); let mnc = proof_request(&[("mnc", MNC)]); let (index, items) = select_identity(available.iter().cloned(), &mnc).expect("matches"); @@ -297,14 +474,12 @@ mod tests { #[test] fn returns_none_when_no_identity_has_the_credential() { let available = sets(&[&[POH, PASSPORT], &[MNC]]); - // Schema 11 (selfie) is held by neither identity. let selfie = proof_request(&[("selfie", 11)]); assert_eq!(selected_index(&available, &selfie), None); } #[test] fn first_match_wins_for_determinism() { - // Two identities both hold PoH; the first in config order is chosen. let available = sets(&[&[POH], &[POH]]); let poh = proof_request(&[("orb", POH)]); assert_eq!(selected_index(&available, &poh), Some(0)); @@ -312,10 +487,23 @@ mod tests { #[test] fn credentials_split_across_identities_cannot_be_satisfied() { - // A single (no-constraint) request needing PoH AND MNC: neither identity has both, - // so no single identity satisfies it. Documents the one-proof-one-identity limit. let available = sets(&[&[POH, PASSPORT], &[MNC]]); let both = proof_request(&[("orb", POH), ("mnc", MNC)]); assert_eq!(selected_index(&available, &both), None); } + + #[test] + fn persona_filters_auto_selection_to_selected_document_type() { + let persona = passport_persona(); + let available = sets(&[&[POH, PASSPORT, MNC], &[MNC]]) + .iter() + .map(|available| available_for_persona(available, Some(&persona))) + .collect::>(); + + let passport = proof_request(&[("passport", PASSPORT)]); + assert_eq!(selected_index(&available, &passport), Some(0)); + + let mnc = proof_request(&[("mnc", MNC)]); + assert_eq!(selected_index(&available, &mnc), None); + } } diff --git a/src/components/IdentityProfile/V4PersonaEditor.tsx b/src/components/IdentityProfile/V4PersonaEditor.tsx new file mode 100644 index 00000000..14e9fa0f --- /dev/null +++ b/src/components/IdentityProfile/V4PersonaEditor.tsx @@ -0,0 +1,191 @@ +import Button from "@/components/Button"; +import { Input } from "@/components/Input"; +import { + getIdentityProfile, + normalizeV4Persona, + validateV4Persona, + V4_DOCUMENT_TYPE_LABELS, + V4_DOCUMENT_TYPES, +} from "@/lib/identity-persona"; +import type { Identity, V4DocumentType, V4IdentityPersona } from "@/types"; +import { useEffect, useId, useMemo, useState } from "react"; +import toast from "react-hot-toast"; + +type Props = { + identity: Identity | null; + onSave: (identity: Identity) => void; +}; + +function personaToForm(persona: V4IdentityPersona) { + return { + ...persona, + age: String(persona.age), + }; +} + +export function V4PersonaEditor({ identity, onSave }: Props) { + const idPrefix = useId(); + const profile = useMemo( + () => (identity ? getIdentityProfile(identity) : null), + [identity], + ); + const [form, setForm] = useState(() => + personaToForm(profile?.v4Persona ?? normalizeV4Persona(null)), + ); + + useEffect(() => { + if (!profile) return; + setForm(personaToForm(profile.v4Persona)); + }, [profile]); + + if (!identity || !profile) return null; + + const updateField = (field: keyof typeof form, value: string) => { + setForm((current) => ({ + ...current, + [field]: value, + })); + }; + + const save = () => { + const age = form.age.trim() === "" ? Number.NaN : Number(form.age); + const persona = { + ...form, + age, + }; + const validationError = validateV4Persona(persona); + if (validationError) { + toast.error(validationError); + return; + } + const normalizedPersona = normalizeV4Persona(persona); + + onSave({ + ...identity, + profile: { + ...profile, + v4Persona: normalizedPersona, + }, + }); + toast.success("Saved identity details"); + }; + + return ( +
+
+

Identity Check details

+

+ Used to simulate Passport, eID, and MNC attestations. +

+
+ +
+ + + + + + + + + + + +
+ + +
+ ); +} diff --git a/src/components/Modal/ModalStatus.tsx b/src/components/Modal/ModalStatus.tsx index bfc6b593..f9ee4ad4 100644 --- a/src/components/Modal/ModalStatus.tsx +++ b/src/components/Modal/ModalStatus.tsx @@ -1,12 +1,13 @@ import { Status } from "@/types"; import { VerificationLevel } from "@worldcoin/idkit-core"; -import { memo, useState } from "react"; +import { memo, useEffect, useState } from "react"; import Button from "../Button"; import { Icon } from "../Icon"; interface ModalStatusProps { status: Status; hasProofRequest: boolean; + forceV4: boolean; handleClick: ( malicious: boolean, verification_level: VerificationLevel, @@ -16,12 +17,17 @@ interface ModalStatusProps { export const ModalStatus = memo(function ModalStatus(props: ModalStatusProps) { const [useV4, setUseV4] = useState(props.hasProofRequest); + const useV4Flow = props.forceV4 || useV4; + + useEffect(() => { + setUseV4(props.hasProofRequest); + }, [props.hasProofRequest, props.forceV4]); return (
{props.status === Status.Waiting && (
- {props.hasProofRequest && ( + {props.hasProofRequest && !props.forceV4 && (
)} - {useV4 ? ( + {useV4Flow ? ( ) : ( <> diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index e0e870a5..44284c07 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -1,6 +1,10 @@ import { Drawer } from "@/components/Drawer"; import { Icon } from "@/components/Icon"; import useIdentity from "@/hooks/useIdentity"; +import { + getIdentityProfile, + serializeV4PersonaForSidecar, +} from "@/lib/identity-persona"; import { generateDummyMerkleProof, getFullProof, @@ -13,7 +17,7 @@ import { } from "@/services/bridge"; import type { ModalStore } from "@/stores/modalStore"; import { useModalStore } from "@/stores/modalStore"; -import { Status } from "@/types"; +import { Status, type BridgeIdentityAttribute } from "@/types"; import { VerificationLevel } from "@worldcoin/idkit-core"; @@ -66,6 +70,11 @@ export function Modal() { const showStagingContent = !isProductionRequest && metadata?.is_staging; const showEnvironmentError = !isLoading && !showStagingContent && status != Status.Error; + const requestedIdentityAttributes = bridgeInitialData?.identity_attributes; + const identityCheckAttributes = Array.isArray(requestedIdentityAttributes) + ? requestedIdentityAttributes + : null; + const isIdentityCheck = identityCheckAttributes !== null; // v3 proof flow (existing) const handleClick = useCallback( @@ -147,7 +156,7 @@ export function Modal() { setStatus(Status.Pending); - const identityIndex = parseInt(activeIdentity.id, 10); + const profile = getIdentityProfile(activeIdentity); const proofType = bridgeInitialData.proof_request.proof_type; const isSession = proofType === "create_session" || @@ -160,8 +169,11 @@ export function Modal() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - identity_index: identityIndex, proof_request: bridgeInitialData.proof_request, + ...(identityCheckAttributes !== null && { + identity_attributes: identityCheckAttributes, + persona: serializeV4PersonaForSidecar(profile.v4Persona), + }), }), }); @@ -181,13 +193,18 @@ export function Modal() { "Sidecar request error, forwarding to bridge:", errorCode, ); - await rejectRequestV4({ url, errorCode }); + const rejectResult = await rejectRequestV4({ url, errorCode }); + if (!rejectResult.success) { + setStatus(Status.Error); + return console.error(rejectResult.error); + } close(); return; } // 5xx or unstructured failure: real internal error, surface the modal. console.error("Sidecar error:", errorData); + await rejectRequestV4({ url, errorCode: "generic_error" }); setStatus(Status.Error); return; } @@ -205,9 +222,17 @@ export function Modal() { setStatus(Status.Success); } catch (error) { console.error("V4 proof generation failed:", error); + await rejectRequestV4({ url, errorCode: "generic_error" }); setStatus(Status.Error); } - }, [activeIdentity, bridgeInitialData, url, setStatus, close]); + }, [ + activeIdentity, + bridgeInitialData, + close, + identityCheckAttributes, + setStatus, + url, + ]); return (

- App will see your + {isIdentityCheck + ? "App will learn whether" + : "App will see your"}

-
- - - - - Verification level - -
+ {identityCheckAttributes !== null ? ( + <> + {(identityCheckAttributes.length > 0 + ? identityCheckAttributes + : [null] + ).map((attribute, index) => ( +
+ + + {attribute + ? formatIdentityAttribute(attribute) + : "A document-backed identity is available"} + +
+ ))} +

+ Your underlying document data is not shared. +

+ + ) : ( +
+ + + Verification level + +
+ )}
void handleClick(malicious, verification_level) } @@ -319,3 +370,37 @@ export function Modal() { ); } + +function CheckIcon() { + return ( + + + + ); +} + +function formatIdentityAttribute(attribute: BridgeIdentityAttribute): string { + switch (attribute.type) { + case "document_type": + return `Document type is ${ + attribute.value === "eid" + ? "eID" + : attribute.value === "mnc" + ? "MNC" + : "Passport" + }`; + case "document_number": + return `Document number matches ${attribute.value}`; + case "issuing_country": + return `Issuing country is ${attribute.value.toUpperCase()}`; + case "full_name": + return `Full name matches ${attribute.value}`; + case "minimum_age": + return `Age is ${attribute.value} or older`; + case "nationality": + return `Nationality is ${attribute.value.toUpperCase()}`; + } +} diff --git a/src/components/SelectID/IDRow.tsx b/src/components/SelectID/IDRow.tsx index bcbf7b19..185de17c 100644 --- a/src/components/SelectID/IDRow.tsx +++ b/src/components/SelectID/IDRow.tsx @@ -1,13 +1,25 @@ import { Icon } from "@/components/Icon"; +import { formatV4PersonaSummary } from "@/lib/identity-persona"; import { cn } from "@/lib/utils"; +import { useUiStore } from "@/stores/ui"; import type { Identity } from "@/types"; import { VerificationLevel } from "@worldcoin/idkit-core"; import Image from "next/image"; import { useRouter } from "next/router"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; export default function IDRow({ identity }: { identity: Identity }) { const router = useRouter(); + const setSettingsOpened = useUiStore((store) => store.setSettingsOpened); + + const openIdentity = useCallback(async () => { + await router.push(`/id/${identity.id}`); + }, [identity.id, router]); + + const editIdentity = useCallback(async () => { + setSettingsOpened(true); + await router.push(`/id/${identity.id}`); + }, [identity.id, router, setSettingsOpened]); // Check verification status for all levels const verifiedLevels = Object.entries(identity.verified) @@ -44,30 +56,47 @@ export default function IDRow({ identity }: { identity: Identity }) { }; return ( - + + + ); } @@ -79,7 +108,7 @@ export function identityIDToEmoji(identityID: string) { function IDEmoji({ identityID }: { identityID: string }) { const iconSource = useMemo(() => identityIDToEmoji(identityID), [identityID]); return ( -
+
setSettingsOpened(false), @@ -68,8 +69,8 @@ export const Settings = memo(function Settings(props: { commitment: string }) { open={settingsOpened} onClose={close} > -
-
+
+
+ +
+
+ +

Version {version}

- -

- Version {version} -

); diff --git a/src/hooks/useIdentity.ts b/src/hooks/useIdentity.ts index 300f5efc..9b9ae107 100644 --- a/src/hooks/useIdentity.ts +++ b/src/hooks/useIdentity.ts @@ -1,3 +1,7 @@ +import { + getIdentityProfile, + withIdentityProfile, +} from "@/lib/identity-persona"; import { encode } from "@/lib/utils"; import type { IdentityStore } from "@/stores/identityStore"; import { useIdentityStore } from "@/stores/identityStore"; @@ -61,7 +65,7 @@ const useIdentity = () => { ); // Build updated identity object const newIdentity: Identity = { - ...identity, + ...withIdentityProfile(identity), id, verified: { [VerificationLevel.Orb]: orbProof !== null, @@ -106,6 +110,7 @@ const useIdentity = () => { idNumber: idNum, }, zkIdentity: zkIdentity.toString(), + profile: getIdentityProfile({}), verified: { [VerificationLevel.Orb]: true, [VerificationLevel.Device]: true, @@ -195,6 +200,7 @@ const useIdentity = () => { resetIdentityStore, generateIdentityProofsIfNeeded, setActiveIdentityID, + replaceIdentity, generateFirstFiveIdentities, }; }; diff --git a/src/lib/identity-check-preflight.ts b/src/lib/identity-check-preflight.ts new file mode 100644 index 00000000..040930da --- /dev/null +++ b/src/lib/identity-check-preflight.ts @@ -0,0 +1,195 @@ +export const IDENTITY_ATTRIBUTES_NOT_MATCHED = + "identity_attributes_not_matched"; +export const CREDENTIAL_UNAVAILABLE = "credential_unavailable"; + +const PASSPORT_ISSUER_SCHEMA_ID = 9303; +const MNC_ISSUER_SCHEMA_ID = 9310; + +type PersonaDocumentType = "eid" | "mnc" | "passport"; + +type IdentityPersona = { + document_type: PersonaDocumentType; + document_number: string; + issuing_country: string; + full_name: string; + age: number; + nationality: string; +}; + +type PreflightErrorCode = + | typeof CREDENTIAL_UNAVAILABLE + | typeof IDENTITY_ATTRIBUTES_NOT_MATCHED; + +export type IdentityCheckPreflightResult = + | { + ok: true; + body: Record; + } + | { ok: false; status: 400; errorCode: PreflightErrorCode }; + +export function preflightSidecarProofRequestBody( + input: unknown, +): IdentityCheckPreflightResult { + const body = isRecord(input) ? input : {}; + const attributes = body.identity_attributes; + + if (!Array.isArray(attributes)) { + return { ok: true, body }; + } + + const persona = parsePersona(body.persona); + if (!persona) { + return fail(CREDENTIAL_UNAVAILABLE); + } + + if (!proofRequestIncludesPersonaDocument(body.proof_request, persona)) { + return fail(CREDENTIAL_UNAVAILABLE); + } + + if (!attributes.every((attribute) => attributeMatches(persona, attribute))) { + return fail(IDENTITY_ATTRIBUTES_NOT_MATCHED); + } + + // Keep both fields in the forwarded body. The Rust sidecar is the authority + // that evaluates the request's constraint tree and emits the attested + // BridgeResponseV2_1 wrapper. + return { ok: true, body }; +} + +function fail(errorCode: PreflightErrorCode): IdentityCheckPreflightResult { + return { ok: false, status: 400, errorCode }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parsePersona(value: unknown): IdentityPersona | null { + if (!isRecord(value)) return null; + + const { + document_type, + document_number, + issuing_country, + full_name, + age, + nationality, + } = value; + + if ( + document_type !== "passport" && + document_type !== "eid" && + document_type !== "mnc" + ) { + return null; + } + if (typeof document_number !== "string" || !document_number.trim()) { + return null; + } + if (!isIsoAlpha3(issuing_country)) return null; + if (typeof full_name !== "string" || !full_name.trim()) return null; + if (!isIsoAlpha3(nationality)) return null; + if ( + typeof age !== "number" || + !Number.isInteger(age) || + age < 0 || + age > 255 + ) { + return null; + } + + return { + document_type, + document_number, + issuing_country, + full_name, + age, + nationality, + }; +} + +function proofRequestIncludesPersonaDocument( + proofRequest: unknown, + persona: IdentityPersona, +): boolean { + if (!isRecord(proofRequest) || !Array.isArray(proofRequest.proof_requests)) { + return false; + } + + // Passport and eID are both ICAO-9303 NFC credentials. MNC uses its + // dedicated request item in the current IDKit Identity Check preset. + const expectedSchemaId = + persona.document_type === "mnc" + ? MNC_ISSUER_SCHEMA_ID + : PASSPORT_ISSUER_SCHEMA_ID; + + return proofRequest.proof_requests.some( + (item) => + isRecord(item) && + typeof item.issuer_schema_id === "number" && + item.issuer_schema_id === expectedSchemaId, + ); +} + +function attributeMatches( + persona: IdentityPersona, + attribute: unknown, +): boolean { + if (!isRecord(attribute) || typeof attribute.type !== "string") { + return false; + } + + switch (attribute.type) { + case "document_type": { + const value = stringValue(attribute); + return value === persona.document_type; + } + case "document_number": { + const value = stringValue(attribute); + return value != null && trimEq(persona.document_number, value); + } + case "issuing_country": { + const value = stringValue(attribute); + return value != null && upperTrimEq(persona.issuing_country, value); + } + case "full_name": { + const value = stringValue(attribute); + return value != null && trimEq(persona.full_name, value); + } + case "minimum_age": { + const value = numberValue(attribute); + return value != null && persona.age >= value; + } + case "nationality": { + const value = stringValue(attribute); + return value != null && upperTrimEq(persona.nationality, value); + } + default: + return false; + } +} + +function stringValue(attribute: Record): string | null { + return typeof attribute.value === "string" ? attribute.value : null; +} + +function numberValue(attribute: Record): number | null { + return typeof attribute.value === "number" && + Number.isInteger(attribute.value) && + attribute.value >= 0 && + attribute.value <= 255 + ? attribute.value + : null; +} + +function isIsoAlpha3(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z]{3}$/.test(value.trim()); +} + +function trimEq(left: string, right: string): boolean { + return left.trim() === right.trim(); +} + +function upperTrimEq(left: string, right: string): boolean { + return left.trim().toUpperCase() === right.trim().toUpperCase(); +} diff --git a/src/lib/identity-check-response.ts b/src/lib/identity-check-response.ts new file mode 100644 index 00000000..d6f62eac --- /dev/null +++ b/src/lib/identity-check-response.ts @@ -0,0 +1,22 @@ +export type IdentityCheckBridgeResponse = { + proof_response: Record; + identity_attested: true; +}; + +/** + * Identity Check uses IDKit's extended BridgeResponseV2_1 envelope. A bare + * ProofResponse is valid for other v4 requests, but must never be accepted for + * an Identity Check because it would drop the attestation result. + */ +export function isIdentityCheckBridgeResponse( + value: unknown, +): value is IdentityCheckBridgeResponse { + if (typeof value !== "object" || value === null) return false; + const response = value as Record; + return ( + response.identity_attested === true && + typeof response.proof_response === "object" && + response.proof_response !== null && + !Array.isArray(response.proof_response) + ); +} diff --git a/src/lib/identity-persona.ts b/src/lib/identity-persona.ts new file mode 100644 index 00000000..4b6a6a48 --- /dev/null +++ b/src/lib/identity-persona.ts @@ -0,0 +1,170 @@ +import type { + Identity, + IdentityProfile, + V4DocumentType, + V4IdentityPersona, +} from "@/types/identity"; + +export const V4_DOCUMENT_TYPES: readonly V4DocumentType[] = [ + "passport", + "eid", + "mnc", +]; + +export const V4_DOCUMENT_TYPE_LABELS: Record = { + eid: "eID", + passport: "Passport", + mnc: "MNC", +}; + +export const DEFAULT_V4_PERSONA: V4IdentityPersona = { + documentType: "passport", + documentNumber: "X1234567", + issuingCountry: "USA", + fullName: "John Doe", + age: 30, + nationality: "USA", +}; + +const LEGACY_DEFAULT_V4_PERSONA: V4IdentityPersona = { + ...DEFAULT_V4_PERSONA, + fullName: "Alex Example", +}; + +type PartialProfileIdentity = { + profile?: Partial | null; +}; + +const ISO_ALPHA_3 = /^[A-Z]{3}$/; + +function normalizeUpperCode(value: string): string { + return value.trim().toUpperCase(); +} + +function coerceDocumentType(value: unknown): V4DocumentType { + return value === "eid" || value === "mnc" ? value : "passport"; +} + +function coerceAge(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.min(255, Math.max(0, Math.floor(value))); + } + return DEFAULT_V4_PERSONA.age; +} + +function isSamePersona( + left: V4IdentityPersona, + right: V4IdentityPersona, +): boolean { + return ( + left.documentType === right.documentType && + left.documentNumber === right.documentNumber && + left.issuingCountry === right.issuingCountry && + left.fullName === right.fullName && + left.age === right.age && + left.nationality === right.nationality + ); +} + +export function normalizeV4Persona( + persona: Partial | null | undefined, +): V4IdentityPersona { + const normalized = { + documentType: coerceDocumentType(persona?.documentType), + documentNumber: + typeof persona?.documentNumber === "string" + ? persona.documentNumber.trim() + : DEFAULT_V4_PERSONA.documentNumber, + issuingCountry: + typeof persona?.issuingCountry === "string" + ? normalizeUpperCode(persona.issuingCountry) + : DEFAULT_V4_PERSONA.issuingCountry, + fullName: + typeof persona?.fullName === "string" + ? persona.fullName.trim() + : DEFAULT_V4_PERSONA.fullName, + age: coerceAge(persona?.age), + nationality: + typeof persona?.nationality === "string" + ? normalizeUpperCode(persona.nationality) + : DEFAULT_V4_PERSONA.nationality, + }; + + if (isSamePersona(normalized, LEGACY_DEFAULT_V4_PERSONA)) { + return DEFAULT_V4_PERSONA; + } + + return normalized; +} + +export function getIdentityProfile( + identity: PartialProfileIdentity, +): IdentityProfile { + return { + v4Persona: normalizeV4Persona(identity.profile?.v4Persona), + }; +} + +export function validateV4Persona( + persona: Partial, +): string | null { + if ( + persona.documentType == null || + !V4_DOCUMENT_TYPES.includes(persona.documentType) + ) { + return "Choose a supported document type"; + } + if (!persona.documentNumber?.trim()) { + return "Enter a document number"; + } + if (!persona.fullName?.trim()) { + return "Enter the full name shown on the document"; + } + + const issuingCountry = normalizeUpperCode(persona.issuingCountry ?? ""); + if (!ISO_ALPHA_3.test(issuingCountry)) { + return "Issuing country must be a 3-letter country code"; + } + + const nationality = normalizeUpperCode(persona.nationality ?? ""); + if (!ISO_ALPHA_3.test(nationality)) { + return "Nationality must be a 3-letter country code"; + } + + if ( + typeof persona.age !== "number" || + !Number.isInteger(persona.age) || + persona.age < 0 || + persona.age > 255 + ) { + return "Age must be a whole number from 0 to 255"; + } + + return null; +} + +export function withIdentityProfile(identity: Identity): Identity { + return { + ...identity, + profile: getIdentityProfile(identity), + }; +} + +export function serializeV4PersonaForSidecar(persona: V4IdentityPersona) { + const normalized = normalizeV4Persona(persona); + return { + document_type: normalized.documentType, + document_number: normalized.documentNumber, + issuing_country: normalized.issuingCountry, + full_name: normalized.fullName, + age: normalized.age, + nationality: normalized.nationality, + }; +} + +export function formatV4PersonaSummary(identity: Identity): string { + const persona = getIdentityProfile(identity).v4Persona; + return `${V4_DOCUMENT_TYPE_LABELS[persona.documentType]} · ${ + persona.fullName + }`; +} diff --git a/src/pages/api/sidecar/[...path].ts b/src/pages/api/sidecar/[...path].ts index 2d7708f4..ecea5c6a 100644 --- a/src/pages/api/sidecar/[...path].ts +++ b/src/pages/api/sidecar/[...path].ts @@ -1,3 +1,5 @@ +import { preflightSidecarProofRequestBody } from "@/lib/identity-check-preflight"; +import { isIdentityCheckBridgeResponse } from "@/lib/identity-check-response"; import type { NextApiRequest, NextApiResponse } from "next"; /** @@ -17,6 +19,19 @@ export default async function handler( const { path } = req.query; const targetPath = Array.isArray(path) ? path.join("/") : path; const targetUrl = `${sidecarUrl}/${targetPath}`; + const isIdentityCheck = + req.method !== "GET" && + typeof req.body === "object" && + req.body !== null && + Array.isArray((req.body as Record).identity_attributes); + const preflight = + req.method !== "GET" ? preflightSidecarProofRequestBody(req.body) : null; + + if (preflight && !preflight.ok) { + return res.status(preflight.status).json({ + error_code: preflight.errorCode, + }); + } try { const response = await fetch(targetUrl, { @@ -27,11 +42,22 @@ export default async function handler( Authorization: `Bearer ${process.env.BEARER_TOKEN}`, }), }, - body: req.method !== "GET" ? JSON.stringify(req.body) : undefined, + body: + req.method !== "GET" + ? JSON.stringify(preflight?.body ?? req.body) + : undefined, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const data = await response.json(); + const data: unknown = await response.json(); + if ( + response.ok && + isIdentityCheck && + !isIdentityCheckBridgeResponse(data) + ) { + return res.status(502).json({ + error: "Sidecar did not return an Identity Check attestation", + }); + } return res.status(response.status).json(data); } catch (error) { console.error("Sidecar proxy error:", error); diff --git a/src/stores/identityStore.ts b/src/stores/identityStore.ts index aa25d75d..e4894b32 100644 --- a/src/stores/identityStore.ts +++ b/src/stores/identityStore.ts @@ -1,3 +1,4 @@ +import { getIdentityProfile } from "@/lib/identity-persona"; import type { Identity } from "@/types/identity"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -12,22 +13,46 @@ export type IdentityStore = { }; const IDENTITY_STORE_STORAGE_KEY = "Simulator_Identity_Store_2"; +const IDENTITY_STORE_VERSION = 2; +type PersistedIdentityStore = Pick< + IdentityStore, + "activeIdentityID" | "identities" +>; + +function migrateIdentity(identity: Identity): Identity { + return { + ...identity, + profile: getIdentityProfile(identity), + }; +} + +function migrateIdentityStoreState(state: unknown): PersistedIdentityStore { + const persisted = + typeof state === "object" && state !== null + ? (state as Partial) + : {}; + + return { + activeIdentityID: persisted.activeIdentityID ?? null, + identities: (persisted.identities ?? []).map(migrateIdentity), + }; +} export const useIdentityStore = create()( - persist( + persist( (set) => ({ activeIdentityID: null, identities: [], setActiveIdentityID: (id) => set({ activeIdentityID: id }), insertIdentity: (identity) => set((state) => ({ - identities: [identity, ...state.identities], + identities: [migrateIdentity(identity), ...state.identities], })), replaceIdentity: (identity) => set((state) => ({ identities: state.identities.map((i) => { if (i.id === identity.id) { - return identity; + return migrateIdentity(identity); } return i; }), @@ -35,13 +60,21 @@ export const useIdentityStore = create()( reset: () => set(() => ({ identities: [], - activeIdentity: null, activeIdentityID: null, - lastIdentityNonce: 0, })), }), { name: IDENTITY_STORE_STORAGE_KEY, + version: IDENTITY_STORE_VERSION, + migrate: migrateIdentityStoreState, + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial; + return { + ...currentState, + activeIdentityID: persisted.activeIdentityID ?? null, + identities: (persisted.identities ?? []).map(migrateIdentity), + }; + }, }, ), ); diff --git a/src/types/bridge-initial-data.ts b/src/types/bridge-initial-data.ts index 31d1e901..655c829f 100644 --- a/src/types/bridge-initial-data.ts +++ b/src/types/bridge-initial-data.ts @@ -1,5 +1,13 @@ import type { VerificationLevel } from "@worldcoin/idkit-core"; +export type BridgeIdentityAttribute = + | { type: "document_number"; value: string } + | { type: "document_type"; value: "eid" | "mnc" | "passport" } + | { type: "full_name"; value: string } + | { type: "issuing_country"; value: string } + | { type: "minimum_age"; value: number } + | { type: "nationality"; value: string }; + export type BridgeInitialData = { app_id: `app_${string}`; verification_level: VerificationLevel; @@ -11,4 +19,7 @@ export type BridgeInitialData = { // IDKit v4: protocol-level proof request, absent in v3 payloads. // Opaque JSON passed through to the sidecar for proof generation. proof_request?: Record; + // IDKit v4 Identity Check attributes, present for identityCheck(...). + // An empty array still means Identity Check with no attribute filters. + identity_attributes?: BridgeIdentityAttribute[]; }; diff --git a/src/types/identity.ts b/src/types/identity.ts index aab1773d..b3c27237 100644 --- a/src/types/identity.ts +++ b/src/types/identity.ts @@ -6,10 +6,26 @@ interface InterfaceMeta { readonly idNumber: number; } +export type V4DocumentType = "eid" | "mnc" | "passport"; + +export interface V4IdentityPersona { + readonly documentType: V4DocumentType; + readonly documentNumber: string; + readonly issuingCountry: string; + readonly fullName: string; + readonly age: number; + readonly nationality: string; +} + +export interface IdentityProfile { + readonly v4Persona: V4IdentityPersona; +} + export interface Identity { readonly id: string; readonly meta: InterfaceMeta; readonly zkIdentity: string; + readonly profile: IdentityProfile; verified: Record; inclusionProof: Record< VerificationLevel, diff --git a/test/identity-check-preflight.test.ts b/test/identity-check-preflight.test.ts new file mode 100644 index 00000000..064e5875 --- /dev/null +++ b/test/identity-check-preflight.test.ts @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CREDENTIAL_UNAVAILABLE, + IDENTITY_ATTRIBUTES_NOT_MATCHED, + preflightSidecarProofRequestBody, +} from "../src/lib/identity-check-preflight"; + +const documentProofRequest = { + proof_requests: [ + { identifier: "passport", issuer_schema_id: 9303 }, + { identifier: "mnc", issuer_schema_id: 9310 }, + ], +}; + +const passportPersona = { + document_type: "passport", + document_number: "X1234567", + issuing_country: "USA", + full_name: "John Doe", + age: 30, + nationality: "USA", +}; + +const mncPersona = { + document_type: "mnc", + document_number: "5550100", + issuing_country: "USA", + full_name: "Jane Doe", + age: 28, + nationality: "USA", +}; + +const eidPersona = { + document_type: "eid", + document_number: "D01234567", + issuing_country: "DEU", + full_name: "Erika Example", + age: 42, + nationality: "DEU", +}; + +test("passes through non Identity Check requests unchanged", () => { + const body = { + identity_index: 0, + proof_request: { + proof_requests: [{ identifier: "orb", issuer_schema_id: 128 }], + }, + }; + + const result = preflightSidecarProofRequestBody(body); + + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected preflight success"); + assert.deepEqual(result.body, body); +}); + +test("rejects a mismatched document number before the sidecar sees the request", () => { + const result = preflightSidecarProofRequestBody({ + identity_index: 0, + proof_request: documentProofRequest, + identity_attributes: [ + { type: "document_type", value: "passport" }, + { type: "document_number", value: "WRONG" }, + ], + persona: passportPersona, + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: IDENTITY_ATTRIBUTES_NOT_MATCHED, + }); +}); + +test("accepts matching passport attributes and preserves sidecar fields", () => { + const body = { + proof_request: documentProofRequest, + identity_attributes: [ + { type: "document_type", value: "passport" }, + { type: "document_number", value: " X1234567 " }, + { type: "issuing_country", value: "usa" }, + { type: "full_name", value: "John Doe" }, + { type: "minimum_age", value: 18 }, + { type: "nationality", value: "usa" }, + ], + persona: passportPersona, + }; + const result = preflightSidecarProofRequestBody(body); + + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected preflight success"); + assert.deepEqual(result.body, body); +}); + +test("accepts matching mnc attributes when the mnc schema is requested", () => { + const body = { + proof_request: documentProofRequest, + identity_attributes: [ + { type: "document_type", value: "mnc" }, + { type: "document_number", value: "5550100" }, + ], + persona: mncPersona, + }; + const result = preflightSidecarProofRequestBody(body); + + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected preflight success"); + assert.deepEqual(result.body, body); +}); + +test("accepts an eID persona backed by the ICAO-9303 credential", () => { + const body = { + proof_request: documentProofRequest, + identity_attributes: [ + { type: "document_type", value: "eid" }, + { type: "issuing_country", value: "deu" }, + { type: "nationality", value: "DEU" }, + ], + persona: eidPersona, + }; + const result = preflightSidecarProofRequestBody(body); + + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected preflight success"); + assert.deepEqual(result.body, body); +}); + +test("rejects Identity Check requests without a persona", () => { + const result = preflightSidecarProofRequestBody({ + identity_index: 0, + proof_request: documentProofRequest, + identity_attributes: [{ type: "document_number", value: "X1234567" }], + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: CREDENTIAL_UNAVAILABLE, + }); +}); + +test("rejects Identity Check requests that do not request the selected document schema", () => { + const result = preflightSidecarProofRequestBody({ + identity_index: 0, + proof_request: { + proof_requests: [{ identifier: "orb", issuer_schema_id: 128 }], + }, + identity_attributes: [{ type: "document_number", value: "X1234567" }], + persona: passportPersona, + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: CREDENTIAL_UNAVAILABLE, + }); +}); + +test("rejects mnc personas when the mnc schema is not requested", () => { + const result = preflightSidecarProofRequestBody({ + identity_index: 0, + proof_request: { + proof_requests: [{ identifier: "passport", issuer_schema_id: 9303 }], + }, + identity_attributes: [{ type: "document_number", value: "5550100" }], + persona: mncPersona, + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: CREDENTIAL_UNAVAILABLE, + }); +}); + +test("fails closed on malformed identity attributes", () => { + const result = preflightSidecarProofRequestBody({ + identity_index: 0, + proof_request: documentProofRequest, + identity_attributes: [{ type: "minimum_age", value: "18" }], + persona: passportPersona, + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: IDENTITY_ATTRIBUTES_NOT_MATCHED, + }); +}); + +test("an empty attribute list is still an Identity Check request", () => { + const body = { + proof_request: documentProofRequest, + identity_attributes: [], + persona: passportPersona, + }; + const result = preflightSidecarProofRequestBody(body); + + assert.equal(result.ok, true); + if (!result.ok) throw new Error("expected preflight success"); + assert.deepEqual(result.body, body); +}); + +test("minimum age matches at the boundary and rejects one year above", () => { + const atBoundary = preflightSidecarProofRequestBody({ + proof_request: documentProofRequest, + identity_attributes: [{ type: "minimum_age", value: 30 }], + persona: passportPersona, + }); + assert.equal(atBoundary.ok, true); + + const aboveBoundary = preflightSidecarProofRequestBody({ + proof_request: documentProofRequest, + identity_attributes: [{ type: "minimum_age", value: 31 }], + persona: passportPersona, + }); + assert.deepEqual(aboveBoundary, { + ok: false, + status: 400, + errorCode: IDENTITY_ATTRIBUTES_NOT_MATCHED, + }); +}); + +test("fails closed on unknown attributes and out-of-range ages", () => { + for (const attribute of [ + { type: "date_of_birth", value: "1990-01-01" }, + { type: "minimum_age", value: -1 }, + { type: "minimum_age", value: 256 }, + ]) { + const result = preflightSidecarProofRequestBody({ + proof_request: documentProofRequest, + identity_attributes: [attribute], + persona: passportPersona, + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: IDENTITY_ATTRIBUTES_NOT_MATCHED, + }); + } +}); + +test("rejects malformed persona country codes", () => { + const result = preflightSidecarProofRequestBody({ + proof_request: documentProofRequest, + identity_attributes: [{ type: "nationality", value: "USA" }], + persona: { ...passportPersona, nationality: "US" }, + }); + + assert.deepEqual(result, { + ok: false, + status: 400, + errorCode: CREDENTIAL_UNAVAILABLE, + }); +}); diff --git a/test/identity-check-response.test.ts b/test/identity-check-response.test.ts new file mode 100644 index 00000000..0a557755 --- /dev/null +++ b/test/identity-check-response.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isIdentityCheckBridgeResponse } from "../src/lib/identity-check-response"; + +test("accepts IDKit's attested BridgeResponseV2_1 envelope", () => { + assert.equal( + isIdentityCheckBridgeResponse({ + proof_response: { + id: "request-id", + version: 1, + responses: [], + }, + identity_attested: true, + }), + true, + ); +}); + +test("rejects a bare proof response and false attestations", () => { + assert.equal( + isIdentityCheckBridgeResponse({ + id: "request-id", + version: 1, + responses: [], + }), + false, + ); + assert.equal( + isIdentityCheckBridgeResponse({ + proof_response: { id: "request-id" }, + identity_attested: false, + }), + false, + ); +}); From 3d5c93f2119bfb4bd4370ab8d3b1986e807fb1ea Mon Sep 17 00:00:00 2001 From: 0xPenryn <29718876+0xPenryn@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:25:50 +0100 Subject: [PATCH 2/2] Add delegated Identity Check proof support --- README.md | 6 ++ sidecar/Cargo.lock | 1 + sidecar/Cargo.toml | 1 + sidecar/config.json | 38 ++++++++++ sidecar/identities.example.json | 3 +- sidecar/src/config.rs | 59 +++++++++++---- sidecar/src/error.rs | 8 ++ sidecar/src/main.rs | 7 +- sidecar/src/routes.rs | 125 +++++++++++++++++++++++++++++++- 9 files changed, 229 insertions(+), 19 deletions(-) create mode 100644 sidecar/config.json diff --git a/README.md b/README.md index eb981d7b..b0a28166 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,9 @@ and eID personas use the ICAO-9303 credential, while MNC personas use the MNC credential. When every requested attribute matches, the simulator returns IDKit's attested response envelope with `identity_attested: true`. A mismatch returns `identity_attributes_not_matched`. + +The example sidecar config uses the public simulator's signed document proof +fixture through `identity_check_proof_url`. The local sidecar validates all +requested attributes against the selected simulator persona before requesting +the proof, and verifies that the returned credential schema matches that +persona before attesting it. diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index 049c87df..1ffd0d10 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -8017,6 +8017,7 @@ dependencies = [ "eyre", "hex", "rand 0.8.5", + "reqwest 0.12.28", "rustls", "serde", "serde_json", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 860f3487..60f2b0d9 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -24,3 +24,4 @@ eyre = "0.6" hex = "0.4" rand = "0.8" rustls = { version = "0.23", features = ["ring"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/sidecar/config.json b/sidecar/config.json new file mode 100644 index 00000000..614b0876 --- /dev/null +++ b/sidecar/config.json @@ -0,0 +1,38 @@ +{ + "protocol": { + "chain_id": 480, + "registry_address": "0x8556d07D75025f286fe757C7EeEceC40D54FA16D", + "indexer_url": "https://indexer.eu.id-infra.worldcoin.dev", + "gateway_url": "https://gateway.id-infra.worldcoin.dev", + "nullifier_oracle_urls": [ + "https://node0.eu.staging.world.oprf.taceo.network", + "https://node1.eu.staging.world.oprf.taceo.network", + "https://node2.eu.staging.world.oprf.taceo.network", + "https://node3.eu.staging.world.oprf.taceo.network", + "https://node4.eu.staging.world.oprf.taceo.network" + ], + "nullifier_oracle_threshold": 3 + }, + "identity_check_proof_url": "https://simulator.worldcoin.org/api/sidecar/proof/uniqueness", + "identities": [ + { + "seed": "0x0101010101010101010101010101010101010101010101010101010101010101", + "credentials": [ + { + "id": 1, + "version": "V1", + "issuer_schema_id": 128, + "sub": "0x0000000000000000000000000000000000000000000000000000000000000000", + "genesis_issued_at": 1, + "expires_at": 1, + "claims": [ + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + "associated_data_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "signature": null, + "issuer": "0100000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ] +} diff --git a/sidecar/identities.example.json b/sidecar/identities.example.json index 5caf802c..614b0876 100644 --- a/sidecar/identities.example.json +++ b/sidecar/identities.example.json @@ -13,6 +13,7 @@ ], "nullifier_oracle_threshold": 3 }, + "identity_check_proof_url": "https://simulator.worldcoin.org/api/sidecar/proof/uniqueness", "identities": [ { "seed": "0x0101010101010101010101010101010101010101010101010101010101010101", @@ -29,7 +30,7 @@ ], "associated_data_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", "signature": null, - "issuer": "0000000000000000000000000000000000000000000000000000000000000000" + "issuer": "0100000000000000000000000000000000000000000000000000000000000000" } ] } diff --git a/sidecar/src/config.rs b/sidecar/src/config.rs index c3a3010b..3dd4f11d 100644 --- a/sidecar/src/config.rs +++ b/sidecar/src/config.rs @@ -11,6 +11,13 @@ pub struct SidecarConfig { pub protocol: Config, /// Pre-configured identities with seeds and credentials. pub identities: Vec, + /// Optional simulator proof endpoint used for Identity Check credentials. + /// + /// The public simulator exposes metadata for its document credential, but not + /// the signed credential itself. When configured, Identity Check requests are + /// validated against the local persona and then proved by this endpoint. + #[serde(default)] + pub identity_check_proof_url: Option, } fn deserialize_protocol_config<'de, D>(deserializer: D) -> Result @@ -69,6 +76,23 @@ pub struct IdentityConfig { pub credentials: Vec, } +impl SidecarConfig { + /// Load configuration from a JSON file. + pub fn load(path: &str) -> eyre::Result { + let content = std::fs::read_to_string(path)?; + let config: Self = serde_json::from_str(&content)?; + Ok(config) + } +} + +impl IdentityConfig { + /// Decode the hex seed into bytes. + pub fn seed_bytes(&self) -> eyre::Result> { + let seed = self.seed.strip_prefix("0x").unwrap_or(&self.seed); + hex::decode(seed).map_err(|e| eyre::eyre!("invalid hex seed: {e}")) + } +} + #[cfg(test)] mod tests { use super::SidecarConfig; @@ -114,21 +138,28 @@ mod tests { assert_eq!(config.protocol.indexer_url(), "https://indexer.example.com"); assert_eq!(config.protocol.gateway_url(), "https://gateway.example.com"); } -} -impl SidecarConfig { - /// Load configuration from a JSON file. - pub fn load(path: &str) -> eyre::Result { - let content = std::fs::read_to_string(path)?; - let config: Self = serde_json::from_str(&content)?; - Ok(config) - } -} + #[test] + fn loads_identity_check_proof_url() { + let config: SidecarConfig = serde_json::from_str( + r#"{ + "protocol": { + "chain_id": 480, + "registry_address": "0x8556d07D75025f286fe757C7EeEceC40D54FA16D", + "indexer_url": "https://indexer.example.com", + "gateway_url": "https://gateway.example.com", + "nullifier_oracle_urls": ["https://node0.example.com"], + "nullifier_oracle_threshold": 1 + }, + "identity_check_proof_url": "https://simulator.example.com/proof/uniqueness", + "identities": [] + }"#, + ) + .expect("identity check proof URL should parse"); -impl IdentityConfig { - /// Decode the hex seed into bytes. - pub fn seed_bytes(&self) -> eyre::Result> { - let seed = self.seed.strip_prefix("0x").unwrap_or(&self.seed); - hex::decode(seed).map_err(|e| eyre::eyre!("invalid hex seed: {e}")) + assert_eq!( + config.identity_check_proof_url.as_deref(), + Some("https://simulator.example.com/proof/uniqueness") + ); } } diff --git a/sidecar/src/error.rs b/sidecar/src/error.rs index 53e7cbc6..6f525f58 100644 --- a/sidecar/src/error.rs +++ b/sidecar/src/error.rs @@ -20,6 +20,8 @@ pub enum SidecarError { IdentityNotFound, /// The request body or proof_request itself is malformed. BadRequest(String), + /// The configured simulator proof service could not complete the request. + Upstream(String), /// An error from the authenticator (network, proof generation, etc.). Authenticator(AuthenticatorError), } @@ -37,6 +39,7 @@ impl std::fmt::Display for SidecarError { Self::IdentityAttributesNotMatched => write!(f, "identity_attributes_not_matched"), Self::IdentityNotFound => write!(f, "identity_not_found"), Self::BadRequest(msg) => write!(f, "bad request: {msg}"), + Self::Upstream(msg) => write!(f, "upstream proof service error: {msg}"), Self::Authenticator(e) => write!(f, "authenticator error: {e}"), } } @@ -79,6 +82,11 @@ impl IntoResponse for SidecarError { Json(serde_json::json!({"error_code": "bad_request", "error": msg})), ) .into_response(), + SidecarError::Upstream(msg) => ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({"error_code": "generic_error", "error": msg})), + ) + .into_response(), SidecarError::Authenticator(e) => { let code = authenticator_error_code(&e); tracing::warn!(error_code = %code, "Authenticator error: {e}"); diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 702490e9..dc486f1b 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -39,7 +39,7 @@ async fn main() -> eyre::Result<()> { let protocol_config = sidecar_config.protocol.clone(); tracing::info!("Initializing identity {i}..."); - let authenticator = world_id_core::Authenticator::init(&seed, protocol_config.into()) + let authenticator = world_id_core::Authenticator::init(&seed, protocol_config) .await .map_err(|e| eyre::eyre!("failed to init identity {i}: {e}"))? .with_proof_materials(query_material.clone(), nullifier_material.clone()); @@ -55,7 +55,10 @@ async fn main() -> eyre::Result<()> { }); } - let state = Arc::new(AppState { identities }); + let state = Arc::new(AppState { + identities, + identity_check_proof_url: sidecar_config.identity_check_proof_url.clone(), + }); let app = routes::router(state); let port: u16 = std::env::var("PORT") diff --git a/sidecar/src/routes.rs b/sidecar/src/routes.rs index d9279c1b..cfb9e128 100644 --- a/sidecar/src/routes.rs +++ b/sidecar/src/routes.rs @@ -20,6 +20,7 @@ use crate::persona::{ /// Shared application state. pub struct AppState { pub identities: Vec, + pub identity_check_proof_url: Option, } /// State for a single pre-configured identity. @@ -155,7 +156,7 @@ async fn generate_proof_inner( ) -> Result, SidecarError> { let ProofRequestBody { identity_index, - proof_request, + proof_request: proof_request_json, identity_attributes, persona, } = req; @@ -166,7 +167,7 @@ async fn generate_proof_inner( ); } - let proof_request = ProofRequest::from_json(&proof_request.to_string()) + let proof_request = ProofRequest::from_json(&proof_request_json.to_string()) .map_err(|e| SidecarError::BadRequest(format!("invalid proof_request: {e}")))?; if is_session != proof_request.is_session_proof() { @@ -196,6 +197,26 @@ async fn generate_proof_inner( None }; + if is_identity_check { + validate_identity_check_selection( + persona, + &identity_attributes, + proof_request + .requests + .iter() + .map(|item| item.issuer_schema_id), + )?; + + if let Some(proof_url) = state.identity_check_proof_url.as_deref() { + return generate_delegated_identity_check_proof( + proof_url, + &proof_request_json, + persona.expect("Identity Check persona was validated above"), + ) + .await; + } + } + // Auto-select: pick the first configured identity whose credentials satisfy the request. // Identity Check restricts Passport/MNC availability to the selected persona first. let available = state @@ -264,6 +285,76 @@ async fn generate_proof_inner( )?)) } +async fn generate_delegated_identity_check_proof( + proof_url: &str, + proof_request: &serde_json::Value, + persona: &IdentityPersona, +) -> Result, SidecarError> { + let response = reqwest::Client::new() + .post(proof_url) + .json(&serde_json::json!({ "proof_request": proof_request })) + .send() + .await + .map_err(|error| SidecarError::Upstream(error.to_string()))?; + let status = response.status(); + let payload = response + .json::() + .await + .map_err(|error| SidecarError::Upstream(format!("invalid response: {error}")))?; + + if !status.is_success() { + return match payload + .get("error_code") + .and_then(serde_json::Value::as_str) + { + Some("credential_unavailable") => Err(SidecarError::CredentialUnavailable), + Some("identity_attributes_not_matched") => { + Err(SidecarError::IdentityAttributesNotMatched) + } + _ => Err(SidecarError::Upstream(format!( + "proof service returned {status}: {payload}" + ))), + }; + } + + let proved_schema_ids = proof_response_schema_ids(&payload)?; + if !includes_persona_document_schema(proved_schema_ids, persona) { + return Err(SidecarError::CredentialUnavailable); + } + + Ok(Json(bridge_response_payload(&payload, true)?)) +} + +fn proof_response_schema_ids(proof_response: &serde_json::Value) -> Result, SidecarError> { + if proof_response + .get("error") + .is_some_and(|error| !error.is_null()) + { + return Err(SidecarError::Upstream( + "proof service returned a protocol error".to_string(), + )); + } + + proof_response + .get("responses") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + SidecarError::Upstream("proof response did not contain responses".to_string()) + })? + .iter() + .map(|response| { + response + .get("issuer_schema_id") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + SidecarError::Upstream( + "proof response contained an invalid issuer schema".to_string(), + ) + }) + }) + .collect() +} + fn validate_identity_check_selection( persona: Option<&IdentityPersona>, identity_attributes: &[IdentityAttribute], @@ -392,6 +483,36 @@ mod tests { assert!(payload.get("identity_attested").is_none()); } + #[test] + fn delegated_proof_response_extracts_document_schema() { + let proof_response = serde_json::json!({ + "id": "proof-id", + "version": 1, + "responses": [{ + "identifier": "passport", + "issuer_schema_id": PASSPORT, + "proof": "proof", + "nullifier": "nil_test" + }], + }); + + assert_eq!( + proof_response_schema_ids(&proof_response).unwrap(), + vec![PASSPORT] + ); + } + + #[test] + fn delegated_proof_response_rejects_missing_responses() { + let error = proof_response_schema_ids(&serde_json::json!({ + "id": "proof-id", + "version": 1, + })) + .expect_err("responses are required"); + + assert!(matches!(error, SidecarError::Upstream(_))); + } + #[test] fn identity_check_mismatched_attributes_returns_identity_attributes_not_matched() { let persona = passport_persona();