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
JavaScript
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 },
};
}