UNPKG

@synet/did

Version:

Secure, minimal, standards-compliant DID library for production environments. Supports did:key and did:web methods with strict validation and cryptographic security.

310 lines (309 loc) 10.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.parseDID = parseDID; exports.validateDID = validateDID; exports.createDIDURL = createDIDURL; exports.isDID = isDID; exports.extractMethod = extractMethod; exports.extractIdentifier = extractIdentifier; exports.normalizeDID = normalizeDID; exports.createId = createId; const node_crypto_1 = require("node:crypto"); const types_1 = require("./types"); /** * DID URL regex pattern * Format: did:method:identifier[/path][?query][#fragment] * Method names must start with lowercase letter and contain only lowercase letters and digits * Updated to handle colons and other characters in identifiers */ const DID_REGEX = /^did:([a-z][a-z0-9]*):([^/?#]+)(?:\/([^?#]*))?(?:\?([^#]*))?(?:#(.*))?$/; /** * Supported DID methods for production use */ const SUPPORTED_METHODS = ["key", "web"]; /** * Parse a DID URL into its components * * @param did - The DID URL to parse * @returns DID parsing result with components and validation status */ function parseDID(did) { if (!did || typeof did !== "string") { return { did, components: { method: "key", identifier: "" }, isValid: false, error: "DID must be a non-empty string", }; } const trimmed = did.trim(); // Check for empty string after trimming if (!trimmed) { return { did: trimmed, components: { method: "key", identifier: "" }, isValid: false, error: "Empty DID string is invalid", }; } const match = trimmed.match(DID_REGEX); if (!match) { return { did: trimmed, components: { method: "key", identifier: "" }, isValid: false, error: "Invalid DID format - must match scheme:method:identifier pattern", }; } const [, method, identifier, path, query, fragment] = match; // Validate that we have non-empty method and identifier if (!method || !identifier) { return { did: trimmed, components: { method: "key", identifier: "" }, isValid: false, error: "DID method and identifier cannot be empty", }; } // Parse query parameters const queryParams = {}; if (query) { const pairs = query.split("&"); for (const pair of pairs) { const [key, value] = pair.split("="); if (key) { queryParams[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ""; } } } const components = { method: method, identifier, path: path || undefined, query: Object.keys(queryParams).length > 0 ? queryParams : undefined, fragment: fragment || undefined, }; return { did: trimmed, components, isValid: true, }; } /** * Validate a DID URL * * @param did - The DID URL to validate * @returns Validation result with error messages and warnings */ function validateDID(did) { const parseResult = parseDID(did); if (!parseResult.isValid) { return { isValid: false, error: parseResult.error, }; } const { components } = parseResult; const warnings = []; // Check if method is supported if (!SUPPORTED_METHODS.includes(components.method)) { // For methods that look valid but aren't supported, warn instead of rejecting if (/^[a-z][a-z0-9]*$/.test(components.method)) { warnings.push(`Method '${components.method}' is not officially supported`); } else { return { isValid: false, error: `Invalid DID method name: '${components.method}'`, }; } } // Method-specific validation for supported methods if (SUPPORTED_METHODS.includes(components.method)) { switch (components.method) { case "key": // did:key identifiers should not contain spaces or certain special characters if (!components.identifier || components.identifier.length === 0) { return { isValid: false, error: "Empty did:key identifier", }; } // Check for invalid characters in did:key identifiers if (/[\s!@#$%^&*()+=\[\]{}|\\:";'<>?,./]/.test(components.identifier)) { return { isValid: false, error: "Invalid did:key identifier format", }; } // Check that it starts with 'z' for multibase encoding if (!components.identifier.startsWith("z")) { return { isValid: false, error: "Invalid did:key identifier format", }; } // Check minimum length for valid did:key (z + at least some encoded data) if (components.identifier.length < 10) { return { isValid: false, error: "Invalid did:key identifier format", }; } break; case "web": if (!components.identifier.includes(".")) { return { isValid: false, error: "did:web identifier must be a valid domain name", }; } break; } } return { isValid: true, warnings: warnings.length > 0 ? warnings : undefined, }; } /** * Create a DID URL from components * * @param components - DID components * @returns Complete DID URL */ function createDIDURL(components) { let did = `did:${components.method}:${components.identifier}`; if (components.path) { did += `/${components.path}`; } if (components.query) { const queryPairs = Object.entries(components.query).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`); did += `?${queryPairs.join("&")}`; } if (components.fragment) { did += `#${components.fragment}`; } return did; } /** * Check if a string is a valid DID * * @param did - String to check * @returns True if valid DID */ function isDID(did) { if (!did || typeof did !== "string") { return false; } // Use the same strict DID format check as parseDID const match = did.trim().match(DID_REGEX); if (!match) { return false; } const [, method, identifier] = match; // Validate that method and identifier are not empty if (!method || !identifier) { return false; } // Allow supported methods and valid-looking unsupported methods if (!SUPPORTED_METHODS.includes(method)) { // Only allow methods that follow the proper format if (!/^[a-z][a-z0-9]*$/.test(method)) { return false; } } // Method-specific validation only for supported methods if (SUPPORTED_METHODS.includes(method)) { switch (method) { case "key": // did:key identifiers must start with 'z' for multibase encoding if (!identifier.startsWith("z")) { return false; } // Check for invalid characters in did:key identifiers if (/[\s!@#$%^&*()+=\[\]{}|\\:";'<>?,./]/.test(identifier)) { return false; } // Check minimum length for valid did:key (z + at least some encoded data) if (identifier.length < 10) { return false; } break; case "web": if (!identifier.includes(".")) { return false; } break; } } return true; } /** * Extract the method from a DID * * @param did - DID URL * @returns DID method or null if invalid */ function extractMethod(did) { if (!did || typeof did !== "string") { return null; } const match = did.match(/^did:([a-z0-9]+):/); return match ? match[1] : null; } /** * Extract the identifier from a DID * * @param did - DID URL * @returns DID identifier or null if invalid */ function extractIdentifier(did) { if (!did || typeof did !== "string") { return null; } const match = did.match(/^did:[a-z0-9]+:([^/?#]+)/); return match ? match[1] : null; } /** * Normalize a DID by removing extra whitespace and ensuring consistent format * * @param did - DID URL to normalize * @returns Normalized DID URL */ function normalizeDID(did) { const result = parseDID(did); if (!result.isValid) { throw new types_1.DIDError(`Cannot normalize invalid DID: ${result.error}`); } return createDIDURL(result.components); } /** * Generate a CUID2-like identifier using Node.js crypto * * This is a simplified, zero-dependency implementation of CUID2 concepts: * - Uses native Node.js crypto instead of @noble/hashes * - Maintains similar structure: letter + hash of (time + entropy + counter) * - Provides collision-resistant, sortable, URL-safe IDs * * @param length - Length of the generated ID (default: 24) * @returns A CUID2-like identifier string */ function createId(length = 24) { // Start with a random letter (a-z) const firstLetter = String.fromCharCode(97 + Math.floor(Math.random() * 26)); // Create entropy components const time = Date.now().toString(36); const entropy = (0, node_crypto_1.randomBytes)(8).toString('hex'); const counter = Math.floor(Math.random() * 0xFFFFFF).toString(36); // Combine and hash const input = `${time}${entropy}${counter}`; const hash = (0, node_crypto_1.createHash)('sha3-512').update(input).digest('hex'); // Convert to base36 and take required length const hashBigInt = BigInt(`0x${hash}`); const base36Hash = hashBigInt.toString(36); // Combine first letter with hash, ensuring we get the right length return (firstLetter + base36Hash).substring(0, length); }