@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
JavaScript
;
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