UNPKG

lotus-sdk

Version:

Central repository for several classes of tools for integrating with, and building for, the Lotusia ecosystem

269 lines (268 loc) 11.1 kB
import { ValidationError, ErrorCode, createValidationError } from './errors.js'; const MAX_MESSAGE_SIZE = 100_000; const MAX_TIMESTAMP_SKEW = 5 * 60 * 1000; const MAX_INVALID_MESSAGES = 10; function getFieldNames() { return { sessionId: 'sessionId', requiredSigners: 'requiredSigners', coordinatorPeerId: 'coordinatorPeerId', messageHash: 'messageHash', createdAt: 'createdAt', expiresAt: 'expiresAt', signers: 'signers', metadata: 'metadata', signerPublicKey: 'signerPublicKey', accepted: 'accepted', signerIndex: 'signerIndex', reason: 'reason', publicNonces: 'publicNonces', partialSig: 'partialSig', finalSignature: 'finalSignature', timestamp: 'timestamp', }; } const SESSION_FIELDS = getFieldNames(); const JOIN_FIELDS = getFieldNames(); const JOIN_ACK_FIELDS = getFieldNames(); const NONCE_FIELDS = getFieldNames(); const SIG_FIELDS = getFieldNames(); const ABORT_FIELDS = getFieldNames(); const COMPLETE_FIELDS = getFieldNames(); const MESSAGE_FIELDS = { type: 'type', protocol: 'protocol', payload: 'payload', }; const SIGNATURE_FIELDS = { r: 'r', s: 's', }; function validateString(value, fieldName, allowEmpty = false) { if (typeof value !== 'string') { throw createValidationError(`${fieldName} must be a string`, ErrorCode.INVALID_TYPE, fieldName); } if (!allowEmpty && value.length === 0) { throw createValidationError(`${fieldName} cannot be empty`, ErrorCode.INVALID_PAYLOAD, fieldName); } } function validateNumber(value, fieldName) { if (typeof value !== 'number' || !Number.isFinite(value)) { throw createValidationError(`${fieldName} must be a valid number`, ErrorCode.INVALID_TYPE, fieldName); } } function validateArray(value, fieldName, minLength = 0) { if (!Array.isArray(value)) { throw createValidationError(`${fieldName} must be an array`, ErrorCode.INVALID_TYPE, fieldName); } if (value.length < minLength) { throw createValidationError(`${fieldName} must have at least ${minLength} elements`, ErrorCode.INVALID_LENGTH, fieldName); } } function validateTimestamp(timestamp, fieldName, maxSkew = 300_000) { validateNumber(timestamp, fieldName); const now = Date.now(); const skew = Math.abs(now - timestamp); if (skew > maxSkew) { throw createValidationError(`${fieldName} timestamp skew too large: ${skew}ms > ${maxSkew}ms`, ErrorCode.TIMESTAMP_SKEW, fieldName); } } function validateHexString(value, fieldName, expectedLength) { validateString(value, fieldName); if (!/^[0-9a-fA-F]*$/.test(value)) { throw createValidationError(`${fieldName} must be a valid hex string`, ErrorCode.INVALID_FORMAT, fieldName); } if (expectedLength !== undefined) { const buffer = Buffer.from(value, 'hex'); if (buffer.length !== expectedLength) { throw createValidationError(`${fieldName} must be ${expectedLength} bytes, got ${buffer.length}`, ErrorCode.INVALID_LENGTH, fieldName); } } } function validateSignersArray(value, fieldName) { validateArray(value, fieldName, 1); value.forEach((pk, index) => { validateHexString(pk, `${fieldName}[${index}]`); }); } function validatePublicNonces(value, fieldName) { validateObject(value, fieldName); const nonceKeys = Object.keys(value).filter(key => /^r\d+$/.test(key)); if (nonceKeys.length < 2) { throw createValidationError('MuSig2 requires at least 2 nonces (ν ≥ 2)', ErrorCode.INVALID_PAYLOAD, fieldName); } nonceKeys.forEach(key => { validateHexString(value[key], `${fieldName}.${key}`, 33); }); nonceKeys.sort((a, b) => { const numA = parseInt(a.substring(1)); const numB = parseInt(b.substring(1)); return numA - numB; }); for (let i = 0; i < nonceKeys.length; i++) { const expectedKey = `r${i + 1}`; if (nonceKeys[i] !== expectedKey) { throw createValidationError(`Nonce keys must be sequential: expected ${expectedKey}, got ${nonceKeys[i]}`, ErrorCode.INVALID_PAYLOAD, fieldName); } } } function validate32ByteHex(value, fieldName) { validateHexString(value, fieldName, 32); } function validateFinalSignature(value, fieldName) { validateObject(value, fieldName); const sigObj = value; validateField(sigObj, SIGNATURE_FIELDS.r, validate32ByteHex); validateField(sigObj, SIGNATURE_FIELDS.s, validate32ByteHex); } function validateBoolean(value, fieldName) { if (typeof value !== 'boolean') { throw createValidationError(`${fieldName} must be a boolean`, ErrorCode.INVALID_TYPE, fieldName); } } function validateObject(value, fieldName) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw createValidationError(`${fieldName} must be an object`, ErrorCode.INVALID_TYPE, fieldName); } } function validateField(obj, fieldName, validator) { if (!(fieldName in obj)) { throw createValidationError(`Missing required field: ${fieldName}`, ErrorCode.MISSING_FIELD, fieldName); } if (validator) { try { validator(obj[fieldName], fieldName); } catch (error) { if (error instanceof ValidationError) { throw createValidationError(`Invalid field ${fieldName}: ${error.message}`, ErrorCode.INVALID_PAYLOAD, fieldName); } throw error; } } } export function validateSessionAnnouncementPayload(payload) { validateObject(payload, 'sessionAnnouncement'); validateField(payload, SESSION_FIELDS.sessionId, validateString); validateField(payload, SESSION_FIELDS.requiredSigners, validateNumber); validateField(payload, SESSION_FIELDS.coordinatorPeerId, validateString); validateField(payload, SESSION_FIELDS.messageHash, validateHexString); validateField(payload, SESSION_FIELDS.createdAt, validateTimestamp); validateField(payload, SESSION_FIELDS.expiresAt, validateTimestamp); if (SESSION_FIELDS.signers in payload) { validateField(payload, SESSION_FIELDS.signers, validateSignersArray); } if (SESSION_FIELDS.metadata in payload) { validateField(payload, SESSION_FIELDS.metadata, validateObject); } } export function validateSessionJoinPayload(payload) { validateObject(payload, 'sessionJoin'); validateField(payload, JOIN_FIELDS.sessionId, validateString); validateField(payload, JOIN_FIELDS.signerPublicKey, validateHexString); validateField(payload, JOIN_FIELDS.timestamp, validateTimestamp); } export function validateSessionJoinAckPayload(payload) { validateObject(payload, 'sessionJoinAck'); validateField(payload, JOIN_ACK_FIELDS.sessionId, validateString); validateField(payload, JOIN_ACK_FIELDS.accepted, validateBoolean); validateField(payload, JOIN_ACK_FIELDS.timestamp, validateTimestamp); const payloadObj = payload; if (!payloadObj.accepted) { validateField(payload, JOIN_ACK_FIELDS.reason, validateString); } if (JOIN_ACK_FIELDS.signerIndex in payload) { validateField(payload, JOIN_ACK_FIELDS.signerIndex, validateNumber); } } export function validateNonceSharePayload(payload) { validateObject(payload, 'nonceShare'); validateField(payload, NONCE_FIELDS.sessionId, validateString); validateField(payload, NONCE_FIELDS.signerIndex, validateNumber); validateField(payload, NONCE_FIELDS.timestamp, validateTimestamp); validateField(payload, NONCE_FIELDS.publicNonces, validatePublicNonces); } export function validatePartialSigSharePayload(payload) { validateObject(payload, 'partialSigShare'); validateField(payload, SIG_FIELDS.sessionId, validateString); validateField(payload, SIG_FIELDS.signerIndex, validateNumber); validateField(payload, SIG_FIELDS.timestamp, validateTimestamp); validateField(payload, SIG_FIELDS.partialSig, validateHexString); const payloadObj = payload; const sigBuffer = Buffer.from(payloadObj.partialSig, 'hex'); if (sigBuffer.length !== 32) { throw createValidationError('Partial signature must be 32 bytes', ErrorCode.INVALID_LENGTH, SIG_FIELDS.partialSig); } } export function validateSessionAbortPayload(payload) { validateObject(payload, 'sessionAbort'); validateField(payload, ABORT_FIELDS.sessionId, validateString); validateField(payload, ABORT_FIELDS.reason, validateString); validateField(payload, ABORT_FIELDS.timestamp, validateTimestamp); } export function validateSessionCompletePayload(payload) { validateObject(payload, 'sessionComplete'); validateField(payload, COMPLETE_FIELDS.sessionId, validateString); validateField(payload, COMPLETE_FIELDS.timestamp, validateTimestamp); if (COMPLETE_FIELDS.finalSignature in payload) { validateField(payload, COMPLETE_FIELDS.finalSignature, validateFinalSignature); } } export function validateMessagePayload(messageType, payload) { switch (messageType) { case 'sessionAnnouncement': validateSessionAnnouncementPayload(payload); break; case 'sessionJoin': validateSessionJoinPayload(payload); break; case 'sessionJoinAck': validateSessionJoinAckPayload(payload); break; case 'nonceShare': validateNonceSharePayload(payload); break; case 'partialSigShare': validatePartialSigSharePayload(payload); break; case 'sessionAbort': validateSessionAbortPayload(payload); break; case 'sessionComplete': validateSessionCompletePayload(payload); break; default: throw createValidationError(`Unknown message type: ${messageType}`, ErrorCode.INVALID_MESSAGE_TYPE, 'messageType'); } } export function validateMessageSize(message, maxSize = 100_000) { const messageStr = JSON.stringify(message); const size = Buffer.byteLength(messageStr, 'utf8'); if (size > maxSize) { throw createValidationError(`Message too large: ${size} bytes > ${maxSize} bytes`, ErrorCode.SIZE_LIMIT_EXCEEDED, 'message'); } } export function validateMessageStructure(message) { validateObject(message, 'message'); const msg = message; validateField(msg, MESSAGE_FIELDS.type, validateString); validateField(msg, MESSAGE_FIELDS.protocol, validateString); validateField(msg, MESSAGE_FIELDS.payload, validateObject); validateMessageSize(msg.payload); } export function getValidationInfo() { return { supportedMessageTypes: [ 'sessionAnnouncement', 'sessionJoin', 'sessionJoinAck', 'nonceShare', 'partialSigShare', 'sessionAbort', 'sessionComplete', ], maxMessageSize: MAX_MESSAGE_SIZE, maxTimestampSkew: MAX_TIMESTAMP_SKEW, nonceRequirements: { min: 2, max: 10 }, }; }