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 all 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
5 changes: 5 additions & 0 deletions .changeset/cyan-laws-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@web5/credentials": patch
---

Adding credential status
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.3"
"@web5/dids": "1.0.3",
"pako": "^2.1.0"
},
"devDependencies": {
"@playwright/test": "1.44.0",
Expand All @@ -87,7 +88,8 @@
"@types/chai": "4.3.6",
"@types/eslint": "8.56.10",
"@types/mocha": "10.0.6",
"@types/node": "20.12.12",
"@types/node": "20.11.19",
"@types/pako": "^2.0.3",
"@types/sinon": "17.0.2",
"@typescript-eslint/eslint-plugin": "7.10.0",
"@typescript-eslint/parser": "7.9.0",
Expand Down
1 change: 1 addition & 0 deletions packages/credentials/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './jwt.js';
export * from './presentation-exchange.js';
export * from './status-list-credential.js';
export * from './verifiable-credential.js';
export * from './verifiable-presentation.js';
export * as utils from './utils.js';
251 changes: 251 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,251 @@
import pako from 'pako';
import { getCurrentXmlSchema112Timestamp } from './utils.js';
import { VerifiableCredential, DEFAULT_VC_CONTEXT, DEFAULT_VC_TYPE, VcDataModel } from './verifiable-credential.js';
import { Convert } from '@web5/common';

/** Status list VC context */
export const DEFAULT_STATUS_LIST_VC_CONTEXT = 'https://w3id.org/vc/status-list/2021/v1';

const DEFAULT_STATUS_LIST_VC_TYPE = 'StatusList2021Credential';

/**
* The status purpose dictated by Status List 2021 spec.
* @see {@link https://www.w3.org/community/reports/credentials/CG-FINAL-vc-status-list-2021-20230102/#statuslist2021entry | Status List 2021 Entry}
*/
export enum StatusPurpose {
/** `revocation` purpose */
revocation = 'revocation',
/** `suspension` purpose */
suspension = 'suspension',
}

/**
* The size of the bitstring in bits.
* The bitstring is 16KB in size.
*/
const BITSTRING_SIZE = 16 * 1024 * 8; // 16KiB in bits

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

/**
* StatusList2021Entry Credential status lookup information included in a Verifiable Credential that supports status lookup.
* Data model dictated by the Status List 2021 spec.
*
* @see {@link https://www.w3.org/community/reports/credentials/CG-FINAL-vc-status-list-2021-20230102/#example-example-statuslist2021credential | Status List 2021 Entry}
*/
export interface StatusList2021Entry {
/** The id of the status list entry. */
id: string,
/** The type of the status list entry. */
type: string,
/** The status purpose of the status list entry. */
statusPurpose: string,
/** The index of the status entry in the status list. Poorly named by spec, should really be `entryIndex`. */
statusListIndex: string,
/** URL to the status list. */
statusListCredential: 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 credentialsToDisable The credentials to be marked as revoked/suspended (status bit set to 1) in the status list.
* @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,
credentialsToDisable : [credWithCredStatus]
})
* ```
*/
public static create(options: StatusListCredentialCreateOptions): VerifiableCredential {
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
const { statusListCredentialId, issuer, statusPurpose, credentialsToDisable } = options;
const indexesOfCredentialsToRevoke: number[] = this.validateStatusListEntryIndexesAreAllUnique(statusPurpose, credentialsToDisable);
const bitString = this.generateBitString(indexesOfCredentialsToRevoke);

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');
}

return this.getBit(encodedListCompressedBitString, parseInt(statusListEntryValue.statusListIndex));
}

/**
* Validates that the status list entry index in all the given credentials are unique,
* and returns the unique index values.
*
* @param statusPurpose - The status purpose that all given credentials must match to.
* @param credentials - An array of VerifiableCredential objects each contain a status list entry index.
* @returns {number[]} An array of unique statusListIndex values.
* @throws {Error} If any validation fails.
*/
private static validateStatusListEntryIndexesAreAllUnique(
statusPurpose: StatusPurpose,
credentials: VerifiableCredential[]
): number[] {
const uniqueIndexes = new Set<string>();
for (const vc of credentials) {
if (!vc.vcDataModel.credentialStatus) {
throw new Error('no credential status found in credential');
}

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

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

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

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

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

uniqueIndexes.add(statusList2021Entry.statusListIndex);
}

return Array.from(uniqueIndexes).map(index => parseInt(index));
}

/**
* Generates a Base64URL encoded, GZIP compressed bit string.
*
* @param indexOfBitsToTurnOn - The indexes of the bits to turn on (set to 1) in the bit string.
* @returns {string} The compressed bit string as a base64-encoded string.
*/
private static generateBitString(indexOfBitsToTurnOn: number[]): string {
// Initialize a Buffer with 16KB filled with zeros
const bitArray = new Uint8Array(BITSTRING_SIZE / 8);

// set specified bits to 1
indexOfBitsToTurnOn.forEach(index => {
const byteIndex = Math.floor(index / 8);
const bitIndex = index % 8;

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

// Compress the bit array with GZIP using pako
const compressed = pako.gzip(bitArray);

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

return base64EncodedString;
}

/**
* Retrieves the value of a specific bit from a compressed base64 URL-encoded bitstring
* by decoding and decompressing a bitstring, then extracting a bit's value by its index.
*
* @param compressedBitstring A base64 URL-encoded string representing the compressed bitstring.
* @param bitIndex The zero-based index of the bit to retrieve from the decompressed bitstream.
* @returns {boolean} True if the bit at the specified index is 1, false if it is 0.
*/
private static getBit(compressedBitstring: string, bitIndex: number): boolean {
// Base64-decode the compressed bitstring
const compressedData = Convert.base64Url(compressedBitstring).toUint8Array();

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

// Find the byte index, and bit index within the byte.
const byteIndex = Math.floor(bitIndex / 8);
const bitIndexWithinByte = bitIndex % 8;

const byte = decompressedData[byteIndex];

// Extracts the targeted bit by adjusting for bit's position from left to right.
const bitInteger = (byte >> (7 - bitIndexWithinByte)) & 1;

return (bitInteger === 1);
}
}
17 changes: 15 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 @@ -38,6 +39,8 @@ export type CredentialSchema = {
* @param issuanceDate Optional. The issuance date of the credential, as a string.
* Defaults to the current date if not specified.
* @param expirationDate Optional. The expiration date of the credential, as a string.
* @param credentialStatus Optional. The credential status lookup information to see if credential is revoked.
* @param credentialSchema Optional. The credential schema of the credential.
* @param evidence Optional. Evidence can be included by an issuer to provide the verifier with additional supporting information in a verifiable credential.
*/
export type VerifiableCredentialCreateOptions = {
Expand All @@ -53,6 +56,8 @@ export type VerifiableCredentialCreateOptions = {
issuanceDate?: string;
/** The expiration date of the credential, as a string. */
expirationDate?: string;
/** The credential status lookup information. */
credentialStatus?: StatusList2021Entry;
/** The credential schema of the credential */
credentialSchema?: CredentialSchema;
/** The evidence of the credential, as an array of any. */
Expand Down Expand Up @@ -159,7 +164,7 @@ export class VerifiableCredential {
* @returns A [VerifiableCredential] instance.
*/
public static async create(options: VerifiableCredentialCreateOptions): Promise<VerifiableCredential> {
const { type, issuer, subject, data, issuanceDate, expirationDate, credentialSchema, evidence } = options;
const { type, issuer, subject, data, issuanceDate, expirationDate, credentialStatus, credentialSchema, evidence } = options;

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

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

// create the @context value
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 }),
...(credentialSchema && { credentialSchema }),
...(evidence && { evidence }),
};
Expand Down
Loading
Loading