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