UNPKG

@4sure-tech/vc-bitstring-status-lists

Version:

TypeScript library for W3C Bitstring Status List v1.0 specification - privacy-preserving credential status management

236 lines (205 loc) 8.74 kB
/** * StatusList - High-level W3C Bitstring Status List implementation * * Provides a complete implementation of the W3C Bitstring Status List v1.0 specification. * Manages a compressed bitstring where each credential has a uniform-width status entry. * * Features: * - W3C compliant 16KB minimum bitstring size * - GZIP compression with multibase encoding (u-prefix) * - Uniform status size for all entries in a list * - Direct credential access without explicit entry creation * - Efficient ISIZE-based length calculation * * @example * ```typescript * // Create a status list for revocation (1-bit per credential) * const list = new StatusList({ statusSize: 1 }) * list.setStatus(42, 1) // Mark credential 42 as revoked * * const encoded = await list.encode() // Get compressed, encoded string * const decoded = await StatusList.decode({ encodedList: encoded, statusSize: 1 }) * console.log(decoded.getStatus(42)) // Returns 1 * ``` */ import {BitManager} from '../bit-manager/BitManager' import {base64urlToBytes, bytesToBase64url} from '../utils/base64' import {assertIsPositiveInteger, assertIsString} from '../utils/assertions' import pako from 'pako' import {MalformedValueError, StatusListLengthError} from "./errors"; /** W3C specification minimum bitstring size */ const MIN_BITSTRING_SIZE_BYTES = 16384 // 16KB (131,072 bits) const MIN_BITSTRING_SIZE_BITS = 131072 export class BitstreamStatusList { private readonly bitManager: BitManager private readonly statusSize: number /** * Creates a new StatusList instance * * @param options.buffer - Existing bitstring buffer for decoding (uncompressed) * @param options.initialSize - Initial buffer size in bytes (default: 16KB) * @param options.statusSize - Uniform bit width for all entries (default: 1) */ constructor(options: { buffer?: Uint8Array initialSize?: number statusSize?: number } = {}) { const {buffer, initialSize, statusSize = 1} = options assertIsPositiveInteger(statusSize, 'statusSize') this.statusSize = statusSize this.bitManager = new BitManager({statusSize, buffer, initialSize}) } /** * Gets the status value for a credential * * @param credentialIndex - Credential identifier * @returns Status value (0 to 2^statusSize - 1) */ getStatus(credentialIndex: number): number { return this.bitManager.getStatus(credentialIndex) } /** * Sets the status value for a credential * * @param credentialIndex - Credential identifier * @param status - Status value (0 to 2^statusSize - 1) */ setStatus(credentialIndex: number, status: number): void { this.bitManager.setStatus(credentialIndex, status) } /** * Returns the uniform status size (bit width) for all entries */ getStatusSize(): number { return this.statusSize } /** * Encodes the status list into a W3C compliant compressed string * * Process: * 1. Get current bitstring buffer * 2. Pad to minimum 16KB (W3C requirement) * 3. GZIP compress the padded buffer * 4. Base64url encode with 'u' prefix (multibase) * * @returns Promise resolving to u-prefixed compressed string */ async encode(): Promise<string> { const buffer = this.bitManager.toBuffer() const paddedBuffer = this.padToMinimumSize(buffer) const compressed = pako.gzip(paddedBuffer) return `u${bytesToBase64url(compressed)}` } /** * Decodes a W3C compliant status list string into a StatusList instance * * @param options.encodedList - u-prefixed, gzip-compressed base64url string * @param options.statusSize - Uniform bit width used during encoding (default: 1) * @returns Promise resolving to new StatusList instance * @throws {MalformedValueError} If format is invalid * @throws {StatusListLengthError} If size requirements not met */ static async decode(options: { encodedList: string statusSize?: number }): Promise<BitstreamStatusList> { const {encodedList, statusSize = 1} = options assertIsString(encodedList, 'encodedList') assertIsPositiveInteger(statusSize, 'statusSize') // Validate multibase prefix if (!encodedList.startsWith('u')) { throw new MalformedValueError('encodedList must start with lowercase "u" prefix') } // Validate base64url alphabet and no padding const base64urlPart = encodedList.slice(1) if (!/^[A-Za-z0-9_-]+$/.test(base64urlPart)) { throw new MalformedValueError('encodedList contains invalid base64url characters or padding') } let compressed: Uint8Array let buffer: Uint8Array try { // Decode multibase compressed = base64urlToBytes(base64urlPart) // Decompress buffer = pako.ungzip(compressed) } catch (error) { throw new MalformedValueError(`Failed to decode or decompress encodedList: ${error.message}`) } // Enforce W3C minimum size requirement if (buffer.length < MIN_BITSTRING_SIZE_BYTES) { throw new StatusListLengthError( `Status list must be at least ${MIN_BITSTRING_SIZE_BYTES} bytes (16KB), got ${buffer.length}` ) } // W3C spec step 9: validate minimum entries based on statusSize const totalBits = buffer.length * 8 const availableEntries = Math.floor(totalBits / statusSize) const minimumEntries = Math.floor(MIN_BITSTRING_SIZE_BITS / statusSize) if (availableEntries < minimumEntries) { throw new StatusListLengthError( `Status list must support at least ${minimumEntries} entries for statusSize ${statusSize}, got ${availableEntries}` ) } return new BitstreamStatusList({buffer, statusSize}) } /** * Gets the maximum number of entries this decoded status list can hold * * @returns Maximum number of entries based on current buffer size */ getLength(): number { const totalBits = this.bitManager.getBufferLength() * 8 return Math.floor(totalBits / this.statusSize) } /** * Efficiently calculates the maximum number of entries in an encoded status list * Uses GZIP ISIZE field to determine uncompressed size without full decompression * * @param encodedList - u-prefixed, gzip-compressed base64url string * @param statusSize - Uniform bit width used during encoding * @returns Maximum number of entries (uncompressed_bits / statusSize) * @throws {MalformedValueError} If format is invalid */ static getStatusListLength(encodedList: string, statusSize: number): number { assertIsString(encodedList, 'encodedList') assertIsPositiveInteger(statusSize, 'statusSize') // Validate multibase prefix if (!encodedList.startsWith('u')) { throw new MalformedValueError('encodedList must start with lowercase "u" prefix') } // Validate base64url alphabet and no padding const base64urlPart = encodedList.slice(1) if (!/^[A-Za-z0-9_-]+$/.test(base64urlPart)) { throw new MalformedValueError('encodedList contains invalid base64url characters or padding') } let compressed: Uint8Array try { compressed = base64urlToBytes(base64urlPart) } catch (error) { throw new MalformedValueError(`Failed to decode base64url: ${error.message}`) } if (compressed.length < 4) { throw new MalformedValueError('Invalid gzip data: too short to contain ISIZE field') } // Read ISIZE (last 4 bytes of gzip data, little-endian) const isizeOffset = compressed.byteOffset + compressed.length - 4 const view = new DataView(compressed.buffer, isizeOffset, 4) const uncompressedBytes = view.getUint32(0, true) // Convert bytes to total available entries const totalBits = uncompressedBytes * 8 return Math.floor(totalBits / statusSize) } /** * Pads buffer to W3C minimum size requirement (16KB) * * @param buffer - Source buffer to pad * @returns Padded buffer (original if already >= 16KB) */ private padToMinimumSize(buffer: Uint8Array): Uint8Array { if (buffer.length >= MIN_BITSTRING_SIZE_BYTES) { return buffer } return Uint8Array.from({length: MIN_BITSTRING_SIZE_BYTES}, (_, i) => buffer[i] || 0) } }