Demo – Bring the Gitcoin Passport Score to the Internet Computer
Use Verifiable Credentials to share information while retaining data ownership and privacy.
—This article is a companion to a demo project that showcases how verifiable credentials can be used to bring the Gitcoin Passport score onto the IC platform. I use Gitcoin Passport as an example as it is a popular digital credential, primarily used by the Ethereum community.
TL;DR
- Read this article to learn how to build an issuer of verifiable credentials on the Internet Computer (IC) blockchain.
- Verifiable credentials are digital credentials (think digital driver’s license) that can be cryptographically and independently verified to ensure their authenticity and integrity. They allow users to selectively share personal information with applications while retaining data ownership and privacy.
- IC supports issuing and verifying VCs in its core identity layer, making them easy to integrate into IC applications.
Introduction
Verifiable Credentials on the Internet Computer
Credentials are documents that attest to an individual’s identity, qualifications, or other attributes. They can be physical documents like a driver’s license or a passport, or digital documents like a digital certificate or a digital badge. For a credential to be useful, it must establish trust through means such as the issuer’s reputation, the document’s design, or the presence of a signature or seal.
In the digital space, it becomes more difficult to verify the authenticity of credentials. Verifiable credentials (VCs) aim to solve this problem by providing digital credentials that can be independently and cryptographically verified to ensure their authenticity and integrity. They are designed to be tamper-proof, secure, and easily shareable, allowing individuals to present their credentials in a digital format that can be verified by others.
The word “independently” is important here! It means that the verifier of a credential—the app that requests the information in the credential—can verify the credential without the help of the issuer. This is made possible by the fact that the credential is cryptographically signed by the issuer and can therefore be independently verified by anyone.
The Internet Computer (IC) blockchain supports issuing and verifying VCs in its core identity layer, making them easy to integrate into IC applications. In addition to the security measures already provided by the VC standard, IC adds another layer of security by using the Internet Identity (II) service to manage the issuance and verification of credentials. A VC on the IC platform is only valid within the context of the application that requested it, drastically reducing the risk of data breaches and phishing attacks.
To learn more about VCs on the IC, check out this recent blog post: Introducing verifiable credentials to the Internet Computer
Gitcoin Passport
Passport helps you collect “stamps” that prove your humanity and reputation.
The Gitcoin Passport score is a digital credential issued by the Ethereum project Gitcoin. It allows users to collect “stamps” that prove their humanity and reputation. Each stamp proves one aspect of the user’s identity. Examples of stamps include:
- Being an NFT holder
- Being active on GitHub
- Having a Google account
The more stamps you collect, the higher your reputation. The Passport score is a digital credential, but it does not fulfill the requirements of a Verifiable Credential. Passport scores are accessible through an API provided by Gitcoin and can also be minted as “attestations” on Ethereum. Until now, the Passport score has not been available on the IC.
By bringing the Gitcoin Passport score to the Internet Computer as a Verifiable Credential, we can enhance its security, privacy, and interoperability.
Overview
The main use case for this project is to allow the user to perform an action in the demo app that requires a certain Gitcoin Passport score level. Before we can perform the action, we need to ensure that the issuer knows the user’s Gitcoin Passport score and can issue a Verifiable Credential to prove it.
There are three main components involved in what I will refer to as the VC flow:
The Issuer
The issuer is responsible for issuing the Verifiable Credential to the user. To do that, the issuer needs to know the user’s Gitcoin Passport score and link it to the user’s Internet Identity. The issuer is also responsible for validating the user’s Ethereum address and signing the VC.
The Gitcoin Passport score is fetched from the Gitcoin Passport API and stored in the issuer backend canister.
The Internet Identity
The Internet Identity (II) service acts as a broker between the issuer and the demo app. Since user identities are unique to each app, II creates an identity alias that hides the requesting identity from the issuer. II also ensures the VC request is valid and presents the user with a consent message before the VC is issued.
The Demo App
This is the application that requires the user to have a certain Gitcoin Passport score level to perform an action. To perform the action, the user must prove that they meet the required score level by presenting a Verifiable Credential to the demo app.
Before the protected action can be performed, the demo app must verify the information in the VC to ensure its accuracy and trustworthiness. The VC needs to be validated both cryptographically, checking the signature of the VC, and semantically, verifying the claims in the VC and the overall structure of the VC.
Project Structure
The project consists of four main packages: two Rust-based backend canisters and two frontend canisters built using React.
Throughout this article, I will link to relevant parts of the source code on GitHub as well as provide code snippets where relevant.
I recommend you follow along with the repository while reading this article to get a better understanding of how the different parts of the project work together.
https://github.com/kristoferlund/passport-score-issuer
1. Issuing the Verifiable Credential
1.1 Authenticating with Internet Identity
The user logs in with their II credentials to establish a session with the issuer.
To simplify this interaction, the project uses the ic-use-internet-identity package, which wraps the II API in a context, making it available to all components in the app.
import { useInternetIdentity } from "ic-use-internet-identity";
export function IcpLoginButton() {
const { identity, login, loginStatus, clear, isInitializing } =
useInternetIdentity();
if (isInitializing) return null;
const disabled = loginStatus === "logging-in" || loginStatus === "success";
const text =
loginStatus === "logging-in" ? "Authenticating..." : "Authenticate";
if (identity) return <button onClick={clear}>Logout ICP</button>;
return (
<button onClick={login} disabled={disabled}>
<img src="/ic.svg" />
{text}
</button>
);
}
issuer_frontend/src/components/IcpLoginButton.tsx
1.2 Proving control over an Ethereum address
After logging in with II, the user is asked to prove control over an Ethereum address. This is done by signing a message with their Ethereum wallet. The signed message is sent to the issuer backend canister, which validates the signature.
/// Recovers an Ethereum address from a given message and signature.
///
/// # Parameters
///
/// * `message` - The message that was signed.
/// * `signature` - The hex-encoded signature.
///
/// # Returns
///
/// The recovered Ethereum address if successful, or an error.
pub fn recover_eth_address(message: &str, signature: &EthSignature) -> Result<String, EthError> {
let message_hash = eip191_hash(message);
let signature_bytes = signature.as_bytes();
let recovery_id =
RecoveryId::try_from(signature_bytes[64] % 27).map_err(|_| EthError::InvalidRecoveryId)?;
let signature =
Signature::from_slice(&signature_bytes[..64]).map_err(|_| EthError::InvalidSignature)?;
let verifying_key = VerifyingKey::recover_from_prehash(&message_hash, &signature, recovery_id)
.map_err(|_| EthError::PublicKeyRecoveryFailure)?;
let address = derive_eth_address_from_public_key(&verifying_key)?;
Ok(address)
}
Now we have established a link between the user’s II account and their Ethereum address. This link is stored in the issuer backend canister but will never be shared with the demo app. The goal here is to allow the user to share their Ethereum-based Gitcoin Passport score with the demo app without revealing their Ethereum address.
1.3 Fetching the Gitcoin Passport score
Once the user has proven control over their Ethereum address, the issuer fetches the user’s Gitcoin Passport score from the Gitcoin Passport API. The score is stored in the issuer backend canister.
To fetch the Gitcoin Passport score, the issuer backend uses one of the coolest features of IC, HTTPS outcalls, which allow IC smart contracts to make HTTP requests to external APIs.
///
/// Get the Gitcoin Passport score for an Ethereum address from the Gitcoin Passport API.
///
pub async fn get_passport_score(address: &EthAddress) -> Result<f32, String> {
// Since the Gitcoin Passport API does not accept IPv6 connections, we use a Cloudflare Worker
// to proxy the request to the API. Source code for the Worker can be found at
// https://github.com/kristoferlund/passport-score-api-proxy
let url = format!(
"https://passport-score-proxy.kristofer-977.workers.dev/submit/{address}",
address = address.as_str()
);
let request = CanisterHttpRequestArgument {
url,
method: HttpMethod::GET,
body: None,
max_response_bytes: None,
transform: Some(TransformContext::from_name(
"transform".to_string(),
serde_json::to_vec(&Vec::<u8>::new()).unwrap(),
)),
headers: vec![],
};
match http_request(request, 30_000_000_000).await {
Ok((response,)) => {
// Convert the response body to a string
let body = String::from_utf8(response.body)
.map_err(|_| "Couldn't read Gitcoin Passport API response".to_string())?;
// Parse the response body as JSON
let v: Value = serde_json::from_str(&body)
.map_err(|_| "Invalid JSON in Gitcoin Passport API response".to_string())?;
// Access the "score" field and convert it to a f32
match v["score"].as_str() {
Some(score) => Ok(score.parse().unwrap_or(0.0)),
None => Err("Gitcoin Passport API response doesn't contain a score".to_string()),
}
}
Err((_, m)) => Err(format!("Gitcoin Passport API request failed: {}", m)),
}
}
issuer_backend/src/passport_score_api.rs
Once the Gitcoin Passport score is stored in the issuer backend canister, the issuer is ready to issue a Verifiable Credential to the user.
2. Requesting the Verifiable Credential
After the user has linked their Gitcoin Passport score to their II account, they can now request a Verifiable Credential from the issuer. This is done through the demo app.
2.1 Authenticating with Internet Identity
Once again, the user must authenticate with II to establish a session with the demo app. User identities on IC (principals) are unique to each app. This is a key distinction from how identities and signatures work on Ethereum or other blockchains.
On Ethereum, a user’s address is their identity and they can use the same address to interact with multiple apps. On IC, a user has a different identity for each app they interact with. This is a key feature of II that allows users to maintain their privacy and data ownership. This also radically reduces the risk of data breaches and phishing attacks.
2.2 Initiating the VC request
After the user has authenticated with II, they can request a Verifiable Credential from the issuer. This request is initiated by the demo app and managed by II.
The request is a JSON-RPC request that looks like this:
{
id: 1,
jsonrpc: "2.0",
method: "request_credential",
params: {
issuer: {
origin: PassportIssuerOrigin,
},
credentialSpec: {
credentialType: "GitcoinPassportScore",
arguments: {
minScore,
},
},
credentialSubject: identity?.getPrincipal().toString()
},
}
issuer
: The origin of the issuer canister, that is, the URL of the issuer canister, as defined by thePassportIssuerOrigin
constant.credentialSpec
: The specification of the credential being requested. In this case, it’s a Gitcoin Passport score credential with a minimum required score. The arguments field allows for issuers to accept additional parameters when issuing credentials.credentialSubject
: The principal of the user requesting the credential.
2.3 Getting the consent message
Before the VC can be issued, the user must consent to the issuance. This is done through a popup window that shows the consent message. The consent message is provided by the issuer and is shown to the user by II.
/// Handles the generation of a consent message for credential sharing.
///
/// This function validates the credential specification and the user's language preference.
/// It then retrieves the minimum score required for the credential and generates a consent message.
///
/// # Arguments
///
/// * `req` - An `Icrc21VcConsentMessageRequest` containing the credential specification and user preferences.
///
/// # Returns
///
/// * `Ok(Icrc21ConsentInfo)` - Contains the consent message and language if successful.
/// * `Err(Icrc21Error)` - Contains an error if validation or message creation fails.
#[update]
async fn vc_consent_message(
req: Icrc21VcConsentMessageRequest,
) -> Result<Icrc21ConsentInfo, Icrc21Error> {
// Validate the credential specification.
validate_credential_spec(&req.credential_spec).map_err(|_| Icrc21Error::GenericError {
error_code: Nat::from(400u32),
description: "Unsupported or invalid credential type".to_string(),
})?;
// Ensure the language preference is supported.
if req.preferences.language != "en-US" {
return Err(Icrc21Error::GenericError {
error_code: Nat::from(400u32),
description: "Unsupported language".to_string(),
});
}
// Retrieve the minimum score required for the credential.
let min_score =
get_credential_min_score(&req.credential_spec).map_err(|_| Icrc21Error::GenericError {
error_code: Nat::from(400u32),
description: "minScore not found in credential type".to_string(),
})?;
// Construct the consent message.
let consent_message = format!("<h1>Gitcoin Passport Score</h1><br/>Minimum Score: {min_score}<br/><br/>Sharing the credential DOES NOT mean revealing your exact Passport Score, Ethereum address or other personal information.");
Ok(Icrc21ConsentInfo {
consent_message,
language: "en".to_string(),
})
}
issuer_backend/src/service/vc_consent_message.rs
2.4 Issuing the Verifiable Credential
Once the user has consented to the issuance, II can proceed to request the VC from the issuer. The issuer validates the request and prepares the VC so that it can be fetched by II and shared with the demo app.
/// Handles the retrieval of a credential, performing necessary validations and transformations.
///
/// This function validates the provided credential specification and the alias tuple,
/// extracts the prepared context, computes the credential hash, retrieves the signature,
/// and creates a JWS (JSON Web Signature) from the credential JWT.
///
/// # Arguments
///
/// * `req` - A `GetCredentialRequest` containing the signed ID alias, credential specification, and prepared context.
///
/// # Returns
///
/// * `Ok(IssuedCredentialData)` - Contains the JWS of the credential if successful.
/// * `Err(IssueCredentialError)` - If validation or any step in the process fails.
#[query(name = "get_credential")]
fn vc_get_credential(
req: GetCredentialRequest,
) -> Result<IssuedCredentialData, IssueCredentialError> {
let alias_tuple = get_alias_tuple(&req.signed_id_alias, &caller(), time().into())?;
validate_credential_spec(&req.credential_spec)?;
let prepared_context = req
.prepared_context
.ok_or_else(|| IssueCredentialError::Internal("Missing prepared_context".to_string()))?;
let credential_jwt = String::from_utf8(prepared_context.into_vec())
.map_err(|_| IssueCredentialError::Internal("Invalid prepared_context".to_string()))?;
let credential_hash = create_credential_hash(&alias_tuple, &credential_jwt)?;
let sig = get_signature(&alias_tuple, credential_hash)?;
let vc_jws = create_jws(&alias_tuple, &credential_jwt, sig.as_slice())?;
Ok(IssuedCredentialData { vc_jws })
}
issuer_backend/src/service/vc_get_credential.rs
2.5 Validating the Verifiable Credential and performing the action
Now, the demo app has the credential that acts as proof that the user meets the required Gitcoin Passport score level. But, bvefore we can perform the protected action in the demo app, the VC needs to be validated.
The demo app must verify the information in the VC to ensure its accuracy and trustworthiness. The VC needs to be validated both cryptographically, checking the signature of the VC, and semantically, verifying the claims in the VC and the overall structure of the VC.
This is done by checking the signature of the VC and verifying the claims in the VC against the user’s Gitcoin Passport score.
In the demo app, this validation and the action is done in the same step. In a real application, the validation would be done first and then the action would be performed if the validation is successful.
/// Validate a Verifiable Presentation (VP) JWT against the credential specification and signers
/// defined in the settings.
///
/// The VP JWT is assumed to contain two credentials:
///
/// 1. An `InternetIdentityIdAlias` credential issued by the II canister, linking current user
/// principal to an id alias.
///
/// 2. A `GitcoinPassportScore` credential issued by the issuer canister, proving the user's
/// Gitcoin Passport Score.
///
/// The validation incorporates multiple factors such as credential type and argument values.
///
/// # Arguments
///
/// * `vp_jwt` - A JWT string representing the VP to be validated.
#[query]
fn do_something(vp_jwt: String) -> String {
SETTINGS.with_borrow(|settings_opt| {
let settings = settings_opt
.as_ref()
.expect("Settings should be initialized");
// Unique identifier of the caller principal
let effective_vc_subject = ic_cdk::api::caller();
// Current system time in nanoseconds, used for validation timestamp
let current_time_ns: u128 = ic_cdk::api::time() as u128;
// Define the two signers involved in the Verifiable Credential flow
let vc_flow_signers = VcFlowSigners {
ii_canister_id: settings.ii_canister_id,
ii_origin: "https://identity.ic0.app/".to_string(),
issuer_canister_id: settings.issuer_canister_id,
issuer_origin: "https://ycons-daaaa-aaaal-qja3q-cai.icp0.io/".to_string(),
};
// Define the expected credential specification for the VP. This spec should match the
// credential type and argument values in the VP.
let vc_spec = CredentialSpec {
credential_type: "GitcoinPassportScore".to_string(),
arguments: Some(HashMap::from([(
"minScore".to_string(),
ArgumentValue::Int(1),
)])),
};
// Validate the VP JWT against the specified signers and credential specifications
match validate_ii_presentation_and_claims(
&vp_jwt,
effective_vc_subject,
&vc_flow_signers,
&vc_spec,
&settings.ic_root_key_raw,
current_time_ns,
) {
Ok(_) => "✅ Success, the credential is valid.".to_string(),
Err(e) => format!("🛑 Error: {:?}", e),
}
})
}
2.6 Protected action has been performed!
That’s it! The protected action has been performed and the user has proven their Gitcoin Passport score level. The demo app can now display a message to the user that they have successfully performed the action.
Conclusion
Thanks for sticking with me through this long-ish article! I hope you found it interesting and that you learned something new about verifiable credentials and the Internet Computer.
We have explored how to build an issuer of verifiable credentials on the Internet Computer (IC) blockchain. We have used the Gitcoin Passport score as an example credential and demonstrated how to issue and request verifiable credentials in a secure and privacy-preserving way.
For more technical details on the IC VC flow, check out the Internet Computer documentation: https://internetcomputer.org/docs/current/developer-docs/identity/verifiable-credentials/overview