Skip to content
This repository has been archived by the owner on Feb 10, 2025. It is now read-only.

Commit

Permalink
feat: Adds Passport Credential Verification (#48)
Browse files Browse the repository at this point in the history
* add verification utils

* add/implement hook

* fix verification

* fix types

* formatting

* put back prettier

* remove logs
  • Loading branch information
0xKurt authored Nov 26, 2024
1 parent c91b485 commit fe03b3f
Show file tree
Hide file tree
Showing 12 changed files with 1,678 additions and 1,565 deletions.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"devDependencies": {
"@chromatic-com/storybook": "^3.2.2",
"@eslint/js": "^9.11.1",
"@gitcoinco/passport-sdk-types": "^0.2.0",
"@spruceid/didkit-wasm": "^0.2.1",
"@storybook/addon-a11y": "^8.4.4",
"@storybook/addon-essentials": "^8.4.4",
"@storybook/addon-interactions": "^8.4.4",
Expand Down Expand Up @@ -132,7 +134,7 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3",
"@storybook/addon-actions": "^8.4.4",
"@storybook/addon-actions": "8.3.5",
"@tanstack/react-query": "^5.59.15",
"@uiw/react-markdown-preview": "^5.1.3",
"cmdk": "1.0.0",
Expand All @@ -154,6 +156,7 @@
"vaul": "^1.1.0",
"viem": "^2.21.48",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-wasm": "^3.3.0",
"zod": "^3.23.8"
}
}
}
3,005 changes: 1,480 additions & 1,525 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

18 changes: 10 additions & 8 deletions public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* - Please do NOT serve this file on production.
*/

const PACKAGE_VERSION = '2.5.1'
const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e'
const PACKAGE_VERSION = '2.6.6'
const INTEGRITY_CHECKSUM = 'ca7800994cc8bfb5eb961e037c877074'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

Expand Down Expand Up @@ -192,12 +192,14 @@ async function getResponse(event, client, requestId) {
const requestClone = request.clone()

function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())

// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention']
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)

// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
headers.delete('accept', 'msw/passthrough')

return fetch(requestClone, { headers })
}
Expand Down
1 change: 1 addition & 0 deletions src/features/checker/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./useInitialize";
export * from "./useGetApplicationsReviewPage";
export * from "./usePerformEvaluation";
export * from "./useGetPastApplications";
export * from "./useCredentialverification";
28 changes: 28 additions & 0 deletions src/features/checker/hooks/useCredentialverification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useState, useEffect } from "react";

import { isVerified } from "@/lib/passport/credentialVerification";

import { ProjectApplicationForManager } from "../services/allo";

export function useCredentialverification(
applicationMetadata: ProjectApplicationForManager | undefined,
) {
const [isTwitterVerified, setIsTwitterVerified] = useState<boolean>(false);
const [isGithubVerified, setIsGithubVerified] = useState<boolean>(false);

useEffect(() => {
async function checkVerification() {
if (applicationMetadata) {
const twitterVerified = await isVerified("twitter", applicationMetadata);
const githubVerified = await isVerified("github", applicationMetadata);

setIsTwitterVerified(twitterVerified);
setIsGithubVerified(githubVerified);
}
}

checkVerification();
}, [applicationMetadata]);

return { isTwitterVerified, isGithubVerified };
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// src/components/SubmitApplicationEvaluation/SubmitApplicationEvaluationPage.tsx
import { useEffect, useState } from "react";



import { Hex } from "viem";

import { ApplicationBadge, ApplicationStatus } from "@/components/Badges";
Expand All @@ -16,7 +18,7 @@ import { Icon, IconType } from "@/primitives/Icon";
import { ListGrid, ListGridColumn } from "@/primitives/ListGrid";
import { Markdown } from "@/primitives/Markdown/Markdown";

import { useGetPastApplications, useInitialize } from "~checker/hooks";
import { useCredentialverification, useGetPastApplications, useInitialize } from "~checker/hooks";
import { useApplicationOverviewEvaluations } from "~checker/hooks/useApplicationEvaluations";
import { PastApplication } from "~checker/services/allo";
import { EVALUATION_STATUS, EvaluationBody } from "~checker/services/checker/api";
Expand Down Expand Up @@ -63,6 +65,8 @@ export const SubmitApplicationEvaluationPage = ({
const [isModalOpen, setIsModalOpen] = useState(false);
const { application, evaluationQuestions } =
useApplicationOverviewEvaluations({ applicationId }) || {};
const { isTwitterVerified, isGithubVerified } = useCredentialverification(application);

const [toastShowed, setToastShowed] = useState(false);
const dispatch = useCheckerDispatchContext();
const { data: pastApplications } = useGetPastApplications(chainId, poolId, applicationId);
Expand Down Expand Up @@ -256,7 +260,7 @@ export const SubmitApplicationEvaluationPage = ({
? project.projectTwitter
: `https://x.com/${project.projectTwitter}`
}
isVerified={!!project.credentials["twitter"]}
isVerified={isTwitterVerified}
/>
)}
{project.projectGithub && (
Expand All @@ -268,7 +272,7 @@ export const SubmitApplicationEvaluationPage = ({
? project.projectGithub
: `https://github.com/${project.projectGithub}`
}
isVerified={!!project.credentials["github"]}
isVerified={isGithubVerified}
/>
)}
</div>
Expand Down Expand Up @@ -382,4 +386,4 @@ export const SubmitApplicationEvaluationPage = ({
</div>
</div>
);
};
};
24 changes: 13 additions & 11 deletions src/features/checker/services/allo/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,23 @@ export interface ProjectMetadata {
lastUpdated: number;
}

export interface ApplicationAnswer {
type: string;
hidden: boolean;
question: string;
questionId: number;
encryptedAnswer?: {
ciphertext: string;
encryptedSymmetricKey: string;
};
answer: string;
}

export interface ProjectApplicationMetadata {
signature: string;
application: {
round: string;
answers: {
type: string;
hidden: boolean;
question: string;
questionId: number;
encryptedAnswer?: {
ciphertext: string;
encryptedSymmetricKey: string;
};
answer: string;
}[];
answers: ApplicationAnswer[];
project: ProjectMetadata;
recipient: string;
};
Expand Down
16 changes: 2 additions & 14 deletions src/features/checker/store/actions/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Hex } from "viem";

import { CheckerApiEvaluationQuestion } from "~checker/services/checker";

import { CheckerApplication } from "../types";
import { CheckerPoolData } from "../types";

export interface SetInitialStateAction {
type: "SET_INITIAL_STATE";
Expand Down Expand Up @@ -37,17 +35,7 @@ export interface GoToSubmitFinalEvaluationAction {

export interface SetPoolDataAction {
type: "SET_POOL_DATA";
payload: {
poolId: string;
chainId: number;
applications: Record<string, CheckerApplication>;
evaluationQuestions: CheckerApiEvaluationQuestion[];
lastFetchedAt: Date;
isLoading?: boolean;
isFetching?: boolean;
isError?: boolean;
error: Error | null;
};
payload: CheckerPoolData;
}

export type CheckerAction =
Expand Down
1 change: 1 addition & 0 deletions src/features/checker/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CheckerPoolData {
evaluationQuestions: CheckerApiEvaluationQuestion[];
lastFetchedAt: Date;
isLoading?: boolean;
isFetching?: boolean;
isError?: boolean;
error: Error | null;
}
Expand Down
64 changes: 64 additions & 0 deletions src/lib/passport/PassportVerifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { DIDKitLib } from "@gitcoinco/passport-sdk-types";
import * as DIDKit from "@spruceid/didkit-wasm";

import { VerifiableCredential } from "@/features/checker/services/allo";

export class PassportVerifier {
_DIDKit: DIDKitLib | undefined;

constructor() {
this.init();
}

async init(): Promise<void> {
// webpacks `experiments.asyncWebAssembly=true` option imports the wasm asynchronously but typescript
// doesn't recognise the import as a Promise, so we wrap it in a Promise and resolve before using...
await new Promise((resolve) => resolve(DIDKit)).then(
async (didkit: { default: Promise<DIDKitLib> } | DIDKitLib | any) => {
if (didkit.default) {
await Promise.resolve(didkit.default).then((didkit) => {
this._DIDKit = didkit as typeof DIDKit;
});
} else {
this._DIDKit = didkit as typeof DIDKit;
}
},
);
}

async verifyCredential(credential: VerifiableCredential): Promise<boolean> {
// ensure DIDKit is established
if (!this._DIDKit) {
await this.init();
}

// extract expirationDate
// const { expirationDate, proof } = credential;
const { proof } = credential;

// check that the credential is still valid (not expired)
// if (new Date(expirationDate) > new Date()) {
try {
if (this._DIDKit === undefined) {
throw new Error("DIDKit is not initialized");
}

const verify = JSON.parse(
await this._DIDKit.verifyCredential(
JSON.stringify(credential),
`{"proofPurpose":"${proof.proofPurpose}"}`,
),
) as { checks: string[]; warnings: string[]; errors: string[] };

// did we get any errors when we attempted to verify?
return verify.errors.length === 0;
} catch (e) {
// if didkit throws, etc.
return false;
}
// } else {
// // past expiry :(
// return false;
// }
}
}
64 changes: 64 additions & 0 deletions src/lib/passport/credentialVerification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
ProjectApplicationMetadata,
type ProjectApplicationForManager,
type VerifiableCredential,
} from "@/features/checker/services/allo";

import { PassportVerifier } from "./PassportVerifier";

export const IAM_SERVER = "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC";

const verifier = new PassportVerifier();

export async function isVerified(
provider: "twitter" | "github",
application: ProjectApplicationForManager | undefined,
): Promise<boolean> {
const applicationMetadata = application?.metadata;
const verifiableCredential = applicationMetadata?.application.project.credentials[provider];
if (verifiableCredential === undefined) {
return false;
}

const vcHasValidProof = await verifier.verifyCredential(verifiableCredential);
const vcIssuedByValidIAMServer = verifiableCredential.issuer === IAM_SERVER;
const providerMatchesProject = vcProviderMatchesProject(
provider,
verifiableCredential,
applicationMetadata,
);
const roleAddresses = application?.canonicalProject?.roles.map((role) => role.address);
const vcIssuedToAtLeastOneProjectOwner = (roleAddresses ?? []).some((role) =>
vcIssuedToAddress(verifiableCredential, role.toLowerCase()),
);
return (
vcHasValidProof &&
vcIssuedByValidIAMServer &&
providerMatchesProject &&
vcIssuedToAtLeastOneProjectOwner
);
}

function vcIssuedToAddress(vc: VerifiableCredential, address: string) {
const vcIdSplit = vc.credentialSubject.id.split(":");
const addressFromId = vcIdSplit[vcIdSplit.length - 1];
return addressFromId.toLowerCase() === address.toLowerCase();
}

function vcProviderMatchesProject(
provider: string,
verifiableCredential: VerifiableCredential,
applicationMetadata: ProjectApplicationMetadata | undefined,
) {
let vcProviderMatchesProject = false;
if (provider === "twitter") {
vcProviderMatchesProject =
verifiableCredential.credentialSubject.provider?.split("#")[1].toLowerCase() ===
applicationMetadata?.application.project?.projectTwitter?.toLowerCase();
} else if (provider === "github") {
vcProviderMatchesProject =
verifiableCredential.credentialSubject.provider?.split("#")[1].toLowerCase() ===
applicationMetadata?.application.project?.projectGithub?.toLowerCase();
}
return vcProviderMatchesProject;
}
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import dts from "vite-plugin-dts";
import svgr from "vite-plugin-svgr";
import wasm from "vite-plugin-wasm";

import react from "@vitejs/plugin-react-swc";

Expand Down Expand Up @@ -34,7 +35,7 @@ export default defineConfig({
minify: false,
target: "esnext",
},
plugins: [react(), svgr(), dts({ rollupTypes: true }), cssInjectedByJsPlugin()],
plugins: [react(), svgr(), dts({ rollupTypes: true }), cssInjectedByJsPlugin(), wasm()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
Expand Down

0 comments on commit fe03b3f

Please sign in to comment.