Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Status List Credential Implementation #448

Merged
merged 29 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/credentials/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
"@sphereon/pex": "2.1.0",
"@web5/common": "1.0.0",
"@web5/crypto": "1.0.0",
"@web5/dids": "1.0.0"
"@web5/dids": "1.0.0",
"pako": "^2.1.0"
},
"devDependencies": {
"@playwright/test": "1.40.1",
Expand All @@ -88,6 +89,7 @@
"@types/eslint": "8.44.2",
"@types/mocha": "10.0.1",
"@types/node": "20.11.19",
"@types/pako": "^2.0.3",
"@types/sinon": "17.0.2",
"@typescript-eslint/eslint-plugin": "6.4.0",
"@typescript-eslint/parser": "6.4.0",
Expand All @@ -105,4 +107,4 @@
"sinon": "16.1.3",
"typescript": "5.1.6"
}
}
}
238 changes: 238 additions & 0 deletions packages/credentials/src/status-list-credential.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix the tbdocs warning yo!

Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import pako from 'pako';

Check warning on line 1 in packages/credentials/src/status-list-credential.ts

View workflow job for this annotation

GitHub Actions / tbdocs-reporter

extractor: typedoc:missing-reference

StatusList2021Entry is referenced by VerifiableCredentialCreateOptions.__type.credentialStatus but not included in the documentation.
import { getCurrentXmlSchema112Timestamp } from './utils.js';
import { VerifiableCredential, DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE, VcDataModel } from './verifiable-credential.js';
import type { ICredentialStatus} from '@sphereon/ssi-types';
import { Convert } from '@web5/common';

export const DEFAULT_STATUS_LIST_VC_CONTEXT = 'https://w3id.org/vc/status-list/2021/v1';
export const DEFAULT_STATUS_LIST_VC_TYPE = 'StatusList2021Credential';

export enum StatusPurpose {
REVOCATION = 'revocation',
SUSPENSION = 'suspension',
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* The size of the bitstring in bits.
* The bitstring is 16KB in size.
*/
const BITSTRING_SIZE = 16 * 1024 * 8; // 16KB in bits
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved

/**
* StatusListCredentialCreateOptions for creating a status list credential.
*
* @param statusListCredentialId The id used for the resolvable path to the status list credential [String].
* @param issuer The issuer URI of the credential, as a [String].
* @param statusPurpose The status purpose of the status list cred, eg: revocation, as a [StatusPurpose].
* @param issuedCredentials The credentials to be included in the status list credential, eg: revoked credentials, list of type [VerifiableCredential].
*/
export type StatusListCredentialCreateOptions = {
statusListCredentialId: string,
issuer: string,
statusPurpose: StatusPurpose,
issuedCredentials: VerifiableCredential[]
};

/**
* The StatusList2021Entry instance representing the core data model of a vc status list 2021.
*
* @see {@link https://www.w3.org/community/reports/credentials/CG-FINAL-vc-status-list-2021-20230102/ | Status List 2021 Entry}
*/
export interface StatusList2021Entry extends ICredentialStatus {
statusListIndex: string,
statusListCredential: string,
statusPurpose: string,
}

/**
* `StatusListCredential` represents a digitally verifiable status list credential according to the
* [W3C Verifiable Credentials Status List v2021](https://www.w3.org/community/reports/credentials/CG-FINAL-vc-status-list-2021-20230102/).
*
* When a status list is published, the result is a verifiable credential that encapsulates the status list.
*
*/
export class StatusListCredential {
/**
* Create a [StatusListCredential] with a specific purpose, e.g., for revocation.
*
* @param statusListCredentialId The id used for the resolvable path to the status list credential [String].
* @param issuer The issuer URI of the credential, as a [String].
* @param statusPurpose The status purpose of the status list cred, eg: revocation, as a [StatusPurpose].
* @param issuedCredentials The credentials to be included in the status list credential, eg: revoked credentials, list of type [VerifiableCredential].
* @returns A special [VerifiableCredential] instance that is a StatusListCredential.
* @throws Error If the status list credential cannot be created.
*
* Example:
* ```
StatusListCredential.create({
statusListCredentialId : 'https://statuslistcred.com/123',
issuer : issuerDid.uri,
statusPurpose : StatusPurpose.REVOCATION,
issuedCredentials : [credWithCredStatus]
})
* ```
*/
public static create(options: StatusListCredentialCreateOptions): VerifiableCredential {
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
const { statusListCredentialId, issuer, statusPurpose, issuedCredentials } = options;
const statusListIndexes: string[] = this.prepareCredentialsForStatusList(statusPurpose, issuedCredentials);
const bitString = this.bitstringGeneration(statusListIndexes);

const credentialSubject = {
id : statusListCredentialId,
type : 'StatusList2021',
statusPurpose : statusPurpose,
encodedList : bitString,
};

const vcDataModel: VcDataModel = {
'@context' : [DEFAULT_VC_CONTEXT, DEFAULT_STATUS_LIST_VC_CONTEXT],
type : [DEFAULT_VC_TYPE, DEFAULT_STATUS_LIST_VC_TYPE],
id : statusListCredentialId,
issuer : issuer,
issuanceDate : getCurrentXmlSchema112Timestamp(),
credentialSubject : credentialSubject,
};

return new VerifiableCredential(vcDataModel);
}

/**
* Validates if a given credential is part of the status list represented by a [VerifiableCredential].
*
* @param credentialToValidate The [VerifiableCredential] to be validated against the status list.
* @param statusListCredential The [VerifiableCredential] representing the status list.
* @returns A [Boolean] indicating whether the `credentialToValidate` is part of the status list.
*
* This function checks if the given `credentialToValidate`'s status list index is present in the expanded status list derived from the `statusListCredential`.
*
* Example:
* ```
* const isRevoked = StatusListCredential.validateCredentialInStatusList(credentialToCheck, statusListCred);
* ```
*/
public static validateCredentialInStatusList(
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
credentialToValidate: VerifiableCredential,
statusListCredential: VerifiableCredential
): boolean {
const statusListEntryValue = credentialToValidate.vcDataModel.credentialStatus! as StatusList2021Entry;
const credentialSubject = statusListCredential.vcDataModel.credentialSubject as any;
const statusListCredStatusPurpose = credentialSubject['statusPurpose'] as StatusPurpose;
const encodedListCompressedBitString = credentialSubject['encodedList'] as string;

if (!statusListEntryValue.statusPurpose) {
throw new Error('status purpose in the credential to validate is undefined');
}

if (!statusListCredStatusPurpose) {
throw new Error('status purpose in the status list credential is undefined');
}

if (statusListEntryValue.statusPurpose !== statusListCredStatusPurpose) {
throw new Error('status purposes do not match between the credentials');
}

if (!encodedListCompressedBitString) {
throw new Error('compressed bitstring is null or empty');
}

const expandedValues = this.bitstringExpansion(encodedListCompressedBitString);

const credentialIndex = statusListEntryValue.statusListIndex;
return expandedValues[parseInt(credentialIndex)] == 1;
}

/**
* Validates and extracts unique statusListIndex values from VerifiableCredential objects.
*
* @param statusPurpose - The status purpose
* @param credentials - An array of VerifiableCredential objects.
* @returns {string[]} An array of unique statusListIndex values.
* @throws {Error} If any validation fails.
*/
private static prepareCredentialsForStatusList(
statusPurpose: StatusPurpose,
credentials: VerifiableCredential[]
): string[] {
const duplicateSet = new Set<string>();
for (const vc of credentials) {
if (!vc.vcDataModel.credentialStatus) {
throw new Error('no credential status found in credential');
}

const statusListEntry: StatusList2021Entry = vc.vcDataModel.credentialStatus as StatusList2021Entry;

if (statusListEntry.statusPurpose !== statusPurpose) {
throw new Error('status purpose mismatch');
}

if (duplicateSet.has(statusListEntry.statusListIndex)) {
throw new Error(`duplicate entry found with index: ${statusListEntry.statusListIndex}`);
}

if(parseInt(statusListEntry.statusListIndex) < 0) {
throw new Error('status list index cannot be negative');
}

if(parseInt(statusListEntry.statusListIndex) >= BITSTRING_SIZE) {
throw new Error('status list index is larger than the bitset size');
}

duplicateSet.add(statusListEntry.statusListIndex);
}

return Array.from(duplicateSet);
}

/**
* Generates a compressed bitstring from an array of statusListIndex values.
*
* @param statusListIndexes - An array of statusListIndex values.
* @returns {string} The compressed bitstring as a base64-encoded string.
*/
private static bitstringGeneration(statusListIndexes: string[]): string {
// Initialize a Buffer with 16KB filled with zeros
const bitstring = new Uint8Array(BITSTRING_SIZE / 8);

// Set bits for revoked credentials
statusListIndexes.forEach(index => {
const statusListIndex = parseInt(index);
const byteIndex = Math.floor(statusListIndex / 8);
const bitIndex = statusListIndex % 8;

bitstring[byteIndex] = bitstring[byteIndex] | (1 << (7 - bitIndex)); // Set bit to 1
});

// Compress the bitstring with GZIP using pako
const compressed = pako.gzip(bitstring);

// Return the base64-encoded string
const base64EncodedString = Convert.uint8Array(compressed).toBase64Url();

return base64EncodedString;
}

/**
* Expands a compressed bitstring into an array of 0s and 1s.
*
* @param compressedBitstring - The compressed bitstring as a base64-encoded string.
* @returns {number[]} An array of 0s and 1s representing the bitstring.
*/
private static bitstringExpansion(compressedBitstring: string): number[] {
// Base64-decode the compressed bitstring
const compressedData = Convert.base64Url(compressedBitstring).toUint8Array();

// Decompress the data using pako
const decompressedData = pako.inflate(compressedData);

// Convert the decompressed data into an array of "0" or "1" strings
const bitstringArray: number[] = [];
decompressedData.forEach(byte => {
for (let i = 7; i >= 0; i--) {
const bit = (byte >> i) & 1;
bitstringArray.push(bit);
}
});
nitro-neal marked this conversation as resolved.
Show resolved Hide resolved

return bitstringArray;
}
}
15 changes: 13 additions & 2 deletions packages/credentials/src/verifiable-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { utils as cryptoUtils } from '@web5/crypto';
import { Jwt } from './jwt.js';
import { SsiValidator } from './validators.js';
import { getCurrentXmlSchema112Timestamp, getXmlSchema112Timestamp } from './utils.js';
import { DEFAULT_STATUS_LIST_VC_CONTEXT, StatusList2021Entry } from './status-list-credential.js';

/** The default Verifiable Credential context. */
export const DEFAULT_VC_CONTEXT = 'https://www.w3.org/2018/credentials/v1';
Expand Down Expand Up @@ -43,6 +44,8 @@ export type VerifiableCredentialCreateOptions = {
issuanceDate?: string;
/** The expiration date of the credential, as a string. */
expirationDate?: string;
/** The status of the credential, as a StatusList2021Entry. */
credentialStatus?: StatusList2021Entry;
/** The evidence of the credential, as an array of any. */
evidence?: any[];
};
Expand Down Expand Up @@ -147,7 +150,7 @@ export class VerifiableCredential {
* @returns A [VerifiableCredential] instance.
*/
public static async create(options: VerifiableCredentialCreateOptions): Promise<VerifiableCredential> {
const { type, issuer, subject, data, issuanceDate, expirationDate, evidence } = options;
const { type, issuer, subject, data, issuanceDate, expirationDate, credentialStatus, evidence } = options;

const jsonData = JSON.parse(JSON.stringify(data));

Expand All @@ -168,17 +171,25 @@ export class VerifiableCredential {
...jsonData
};

const contexts: string[] = [DEFAULT_VC_CONTEXT];

if (credentialStatus !== null) {
contexts.push(DEFAULT_STATUS_LIST_VC_CONTEXT);
}

const vcDataModel: VcDataModel = {
'@context' : [DEFAULT_VC_CONTEXT],
'@context' : contexts,
type : Array.isArray(type)
? [DEFAULT_VC_TYPE, ...type]
: (type ? [DEFAULT_VC_TYPE, type] : [DEFAULT_VC_TYPE]),
id : `urn:uuid:${cryptoUtils.randomUuid()}`,
issuer : issuer,
issuanceDate : issuanceDate || getCurrentXmlSchema112Timestamp(),
credentialSubject : credentialSubject,

// Include optional properties only if they have values
...(expirationDate && { expirationDate }),
...(credentialStatus && { credentialStatus }),
...(evidence && { evidence }),
};

Expand Down
Loading
Loading