UNPKG

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

Version:

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

539 lines (531 loc) 19.4 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { BitManager: () => BitManager, BitstreamStatusList: () => BitstreamStatusList, checkStatus: () => checkStatus, createStatusListCredential: () => createStatusListCredential }); module.exports = __toCommonJS(index_exports); // src/utils/assertions.ts function assertIsNumber(value, name) { if (typeof value !== "number" || isNaN(value)) { throw new TypeError(`${name} must be a number, got ${typeof value}`); } } function assertIsPositiveInteger(value, name) { assertIsNumber(value, name); if (!Number.isInteger(value) || value <= 0) { throw new TypeError(`${name} must be a positive integer, got ${value}`); } } function assertIsNonNegativeInteger(value, name) { assertIsNumber(value, name); if (!Number.isInteger(value) || value < 0) { throw new TypeError(`${name} must be a non-negative integer, got ${value}`); } } function assertIsString(value, name) { if (typeof value !== "string") { throw new TypeError(`${name} must be a string, got ${typeof value}`); } } function assertIsObject(value, name) { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new TypeError(`${name} must be an object, got ${typeof value}`); } } // src/status-list/errors.ts var BitstringStatusListError = class extends Error { type; code; constructor(type, code, message) { super(message); this.name = "BitstringStatusListError"; this.type = `https://www.w3.org/ns/credentials/status-list#${type}`; this.code = code; } }; var StatusListLengthError = class extends BitstringStatusListError { constructor(message) { super("STATUS_LIST_LENGTH_ERROR", "STATUS_LIST_LENGTH_ERROR", message); } }; var StatusRangeError = class extends BitstringStatusListError { constructor(message) { super("RANGE_ERROR", "RANGE_ERROR", message); } }; var MalformedValueError = class extends BitstringStatusListError { constructor(message) { super("MALFORMED_VALUE_ERROR", "MALFORMED_VALUE_ERROR", message); } }; // src/bit-manager/BitManager.ts var LIST_BLOCK_SIZE = 16384; var BitManager = class { bits; statusSize; /** * Creates a new BitManager instance * * @param options.statusSize - Bits per credential status (default: 1, max: 31) * @param options.buffer - Existing buffer for decoding * @param options.initialSize - Initial buffer size in bytes (default: 16KB) */ constructor(options = {}) { const { statusSize = 1, buffer, initialSize = LIST_BLOCK_SIZE } = options; assertIsPositiveInteger(statusSize, "statusSize"); if (statusSize > 30) { throw new StatusRangeError(`statusSize ${statusSize} exceeds maximum supported value of 30 bits`); } this.statusSize = statusSize; this.bits = buffer ? new Uint8Array(buffer) : new Uint8Array(initialSize); } /** * Gets the status value for a credential * * @param credentialIndex - Non-negative credential identifier * @returns Status value (0 to 2^statusSize - 1) */ getStatus(credentialIndex) { assertIsNonNegativeInteger(credentialIndex, "credentialIndex"); const maxIndex = Math.floor(this.bits.length * 8 / this.statusSize); if (credentialIndex >= maxIndex) { throw new StatusRangeError(`credentialIndex ${credentialIndex} exceeds buffer bounds`); } const startBit = credentialIndex * this.statusSize; return this.readStatusBits(startBit); } /** * Sets the status value for a credential * * @param credentialIndex - Non-negative credential identifier * @param status - Status value (0 to 2^statusSize - 1) * @throws {StatusRangeError} If status exceeds maximum value for statusSize */ setStatus(credentialIndex, status) { assertIsNonNegativeInteger(credentialIndex, "credentialIndex"); assertIsNonNegativeInteger(status, "status"); const maxValue = (1 << this.statusSize) - 1; if (status > maxValue) { throw new StatusRangeError(`Status ${status} exceeds maximum value ${maxValue} for ${this.statusSize} bits`); } const startBit = credentialIndex * this.statusSize; this.writeStatusBits(startBit, status); } /** * Returns current buffer trimmed to actual data size * * @returns Copy of buffer containing only written data */ toBuffer() { let lastNonZeroByte = -1; for (let i = this.bits.length - 1; i >= 0; i--) { if (this.bits[i] !== 0) { lastNonZeroByte = i; break; } } if (lastNonZeroByte === -1) { return new Uint8Array([]); } return this.bits.slice(0, lastNonZeroByte + 1); } /** * Gets the uniform status size for all entries * * @returns Number of bits per status entry */ getStatusSize() { return this.statusSize; } /** * Gets the total buffer size in bytes * * @returns Buffer size in bytes */ getBufferLength() { return this.bits.length; } /** * Reads status bits from buffer starting at bit position * * @param startBit - Starting bit position * @returns Decoded status value */ readStatusBits(startBit) { let status = 0; for (let i = 0; i < this.statusSize; i++) { const bitIndex = startBit + i; const byteIndex = Math.floor(bitIndex / 8); const bitOffset = bitIndex % 8; if (byteIndex >= this.bits.length) { continue; } const bit = this.bits[byteIndex] >> 7 - bitOffset & 1; status |= bit << this.statusSize - i - 1; } return status; } /** * Writes status bits to buffer starting at bit position * * @param startBit - Starting bit position * @param status - Status value to write */ writeStatusBits(startBit, status) { for (let i = 0; i < this.statusSize; i++) { const bitIndex = startBit + i; const byteIndex = Math.floor(bitIndex / 8); const bitOffset = bitIndex % 8; this.ensureBufferCanHold(byteIndex + 1); const bit = status >> this.statusSize - i - 1 & 1; if (bit === 1) { this.bits[byteIndex] |= 1 << 7 - bitOffset; } else { this.bits[byteIndex] &= ~(1 << 7 - bitOffset); } } } /** * Ensures buffer can hold the specified number of bytes * Grows in bitstring-sized chunks for better memory efficiency * * @param requiredBytes - Minimum bytes needed */ ensureBufferCanHold(requiredBytes) { if (requiredBytes > this.bits.length) { const bitsNeeded = requiredBytes * 8; const entriesNeeded = Math.ceil(bitsNeeded / this.statusSize); const optimalBits = entriesNeeded * this.statusSize; const optimalBytes = Math.ceil(optimalBits / 8); const minGrowthBytes = Math.max(optimalBytes, LIST_BLOCK_SIZE); const blocksNeeded = Math.ceil(minGrowthBytes / LIST_BLOCK_SIZE); const newSize = blocksNeeded * LIST_BLOCK_SIZE; const newBuffer = new Uint8Array(newSize); newBuffer.set(this.bits); this.bits = newBuffer; } } }; // src/utils/base64.ts var u8a = __toESM(require("uint8arrays"), 1); function bytesToBase64url(b) { return u8a.toString(b, "base64url"); } function base64urlToBytes(s) { return u8a.fromString(s, "base64url"); } // src/status-list/BitstreamStatusList.ts var import_pako = __toESM(require("pako"), 1); var MIN_BITSTRING_SIZE_BYTES = 16384; var MIN_BITSTRING_SIZE_BITS = 131072; var BitstreamStatusList = class _BitstreamStatusList { bitManager; statusSize; /** * 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 = {}) { 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) { 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, status) { this.bitManager.setStatus(credentialIndex, status); } /** * Returns the uniform status size (bit width) for all entries */ getStatusSize() { 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() { const buffer = this.bitManager.toBuffer(); const paddedBuffer = this.padToMinimumSize(buffer); const compressed = import_pako.default.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) { const { encodedList, statusSize = 1 } = options; assertIsString(encodedList, "encodedList"); assertIsPositiveInteger(statusSize, "statusSize"); if (!encodedList.startsWith("u")) { throw new MalformedValueError('encodedList must start with lowercase "u" prefix'); } const base64urlPart = encodedList.slice(1); if (!/^[A-Za-z0-9_-]+$/.test(base64urlPart)) { throw new MalformedValueError("encodedList contains invalid base64url characters or padding"); } let compressed; let buffer; try { compressed = base64urlToBytes(base64urlPart); buffer = import_pako.default.ungzip(compressed); } catch (error) { throw new MalformedValueError(`Failed to decode or decompress encodedList: ${error.message}`); } 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}` ); } 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() { 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, statusSize) { assertIsString(encodedList, "encodedList"); assertIsPositiveInteger(statusSize, "statusSize"); if (!encodedList.startsWith("u")) { throw new MalformedValueError('encodedList must start with lowercase "u" prefix'); } const base64urlPart = encodedList.slice(1); if (!/^[A-Za-z0-9_-]+$/.test(base64urlPart)) { throw new MalformedValueError("encodedList contains invalid base64url characters or padding"); } let compressed; 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"); } const isizeOffset = compressed.byteOffset + compressed.length - 4; const view = new DataView(compressed.buffer, isizeOffset, 4); const uncompressedBytes = view.getUint32(0, true); 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) */ padToMinimumSize(buffer) { if (buffer.length >= MIN_BITSTRING_SIZE_BYTES) { return buffer; } return Uint8Array.from({ length: MIN_BITSTRING_SIZE_BYTES }, (_, i) => buffer[i] || 0); } }; // src/credential/create.ts async function createStatusListCredential(options) { const { id, issuer, statusSize = 1, statusList, statusPurpose, validFrom, validUntil, ttl } = options; const encodedList = await (statusList ?? new BitstreamStatusList({ statusSize })).encode(); const credentialSubject = { id: `${id}#list`, type: "BitstringStatusList", statusPurpose, encodedList, ...ttl && { ttl } }; const credential = { "@context": [ "https://www.w3.org/ns/credentials/v2", // Core VC context "https://www.w3.org/ns/credentials/status/v1" // Status list context ], id, type: ["VerifiableCredential", "BitstringStatusListCredential"], issuer, credentialSubject, ...validFrom && { validFrom: validFrom.toISOString() }, ...validUntil && { validUntil: validUntil.toISOString() } }; return credential; } // src/credential/verify.ts var MIN_BITSTRING_SIZE_BITS2 = 131072; async function checkStatus(options) { try { const { credential, getStatusListCredential } = options; assertIsObject(credential, "credential"); if (!credential.credentialStatus) { return Promise.reject(new Error("No credentialStatus found in credential")); } const entry = extractBitstringStatusEntry(credential.credentialStatus); if (!entry) { throw new Error("No BitstringStatusListEntry found in credentialStatus"); } const listCredential = await getStatusListCredential(entry.statusListCredential); validateTemporalValidity(listCredential); validateStatusPurposeAndSize(entry, listCredential); const statusSize = entry.statusSize || 1; const statusList = await BitstreamStatusList.decode({ encodedList: listCredential.credentialSubject.encodedList, statusSize }); validateMinimumBitstringLength(listCredential.credentialSubject.encodedList, statusSize); const statusIndex = parseInt(entry.statusListIndex, 10); const status = statusList.getStatus(statusIndex); const statusMessage = findStatusMessage(entry, status); const verified = determineVerificationResult(entry.statusPurpose, status); return { verified, status, statusMessage }; } catch (error) { return { verified: false, status: -1, error: error instanceof Error ? error : new Error(String(error)) }; } } function extractBitstringStatusEntry(credentialStatus) { const statusEntries = Array.isArray(credentialStatus) ? credentialStatus : [credentialStatus]; return statusEntries.find((entry) => entry.type === "BitstringStatusListEntry") || null; } function validateTemporalValidity(listCredential) { const now = /* @__PURE__ */ new Date(); if (listCredential.validFrom && new Date(listCredential.validFrom) > now) { throw new Error("Status list credential is not yet valid"); } if (listCredential.validUntil && new Date(listCredential.validUntil) < now) { throw new Error("Status list credential has expired"); } } function validateStatusPurposeAndSize(entry, listCredential) { const listStatusPurpose = listCredential.credentialSubject.statusPurpose; const purposes = Array.isArray(listStatusPurpose) ? listStatusPurpose : [listStatusPurpose]; if (!purposes.includes(entry.statusPurpose)) { throw new Error( `Status purpose '${entry.statusPurpose}' does not match any purpose in status list credential: ${purposes.join(", ")}` ); } const statusSize = entry.statusSize || 1; if (listCredential.credentialSubject.statusSize !== statusSize) { throw new Error(`StatusSize mismatch: expected ${statusSize}, got from credentialSubject ${listCredential.credentialSubject.statusSize}`); } } function validateMinimumBitstringLength(encodedList, statusSize) { const totalEntries = BitstreamStatusList.getStatusListLength(encodedList, statusSize); const minEntries = MIN_BITSTRING_SIZE_BITS2 / statusSize; if (totalEntries < minEntries) { throw new Error( `Status list length error: bitstring must support at least ${minEntries} entries for statusSize ${statusSize}` ); } } function findStatusMessage(entry, status) { if (!entry.statusMessage) { return void 0; } const statusHex = `0x${status.toString(16)}`; return entry.statusMessage.find((msg) => msg.id === statusHex); } function determineVerificationResult(statusPurpose, status) { if (statusPurpose === "revocation" || statusPurpose === "suspension") { return status === 0; } return true; } //# sourceMappingURL=index.cjs.map