diff --git a/src/errors.ts b/src/errors.ts index 7332b5f..de46153 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -309,8 +309,12 @@ export class UnauthorizedError extends StellarSplitError { /** Thrown when validation fails for input data (e.g., template deserialization). */ export class ValidationError extends StellarSplitError { - constructor(message: string, context?: Record) { - super(message, "VALIDATION_ERROR", context, message); + constructor( + message: string, + context?: Record, + code: string = "VALIDATION_ERROR" + ) { + super(message, code, context, message); this.name = "ValidationError"; Object.setPrototypeOf(this, new.target.prototype); } @@ -2356,3 +2360,60 @@ export function isBatchTooLargeError(err: unknown): err is BatchTooLargeError { return err instanceof BatchTooLargeError; } +// --------------------------------------------------------------------------- +// Validation errors (issues #1061, #1062, #1063, #1064) +// --------------------------------------------------------------------------- + +/** Thrown when deadline validation fails. */ +export class DeadlineValidationError extends ValidationError { + constructor(message: string, context?: Record) { + super(message, context, "DEADLINE_VALIDATION_ERROR"); + this.name = "DeadlineValidationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isDeadlineValidationError(err: unknown): err is DeadlineValidationError { + return err instanceof DeadlineValidationError; +} + +/** Thrown when token or payment amount validation fails. */ +export class AmountValidationError extends ValidationError { + constructor(message: string, context?: Record) { + super(message, context, "AMOUNT_VALIDATION_ERROR"); + this.name = "AmountValidationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isAmountValidationError(err: unknown): err is AmountValidationError { + return err instanceof AmountValidationError; +} + +/** Thrown when Stellar address format or checksum validation fails. */ +export class AddressValidationError extends ValidationError { + constructor(message: string, context?: Record) { + super(message, context, "ADDRESS_VALIDATION_ERROR"); + this.name = "AddressValidationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isAddressValidationError(err: unknown): err is AddressValidationError { + return err instanceof AddressValidationError; +} + +/** Thrown when cryptographic or webhook signature validation fails. */ +export class SignatureValidationError extends ValidationError { + constructor(message: string, context?: Record) { + super(message, context, "SIGNATURE_VALIDATION_ERROR"); + this.name = "SignatureValidationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isSignatureValidationError(err: unknown): err is SignatureValidationError { + return err instanceof SignatureValidationError; +} + + diff --git a/src/index.ts b/src/index.ts index bf64e06..c116375 100644 --- a/src/index.ts +++ b/src/index.ts @@ -237,6 +237,15 @@ export { // Batch operations errors (issue #855) BatchTooLargeError, isBatchTooLargeError, + // Validation errors (issues #1061, #1062, #1063, #1064) + DeadlineValidationError, + isDeadlineValidationError, + AmountValidationError, + isAmountValidationError, + AddressValidationError, + isAddressValidationError, + SignatureValidationError, + isSignatureValidationError, } from "./errors.js"; // Invoice metadata JSON Schema validator (issue #533) @@ -244,6 +253,81 @@ export { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator. export type { MetadataValidationResult } from "./validators/invoiceMetadataValidator.js"; export { validateMetadataKeys, MAX_METADATA_KEY_LENGTH } from "./validators/invoiceMetadataValidator.js"; +// Deadline validator (issue #1064) +export { + validateDeadline, + validateDeadlineOrThrow, + isFutureDeadline, + parseDeadline, + DeadlineValidator, + MAX_U64 as MAX_DEADLINE_U64, + MS_TIMESTAMP_THRESHOLD as DEADLINE_MS_TIMESTAMP_THRESHOLD, +} from "./validators/deadlineValidator.js"; +export type { + DeadlineValidationResult, + DeadlineValidationOptions, +} from "./validators/deadlineValidator.js"; + +// Amount validator (issue #1063) +export { + validateAmount, + validateAmountOrThrow, + isValidAmount, + parseToStroops, + AmountValidator, + STELLAR_MAX_INT64, + STELLAR_MIN_AMOUNT, + STELLAR_MAX_DECIMALS, + STROOPS_PER_UNIT as AMOUNT_STROOPS_PER_UNIT, +} from "./validators/amountValidator.js"; +export type { + AmountValidationResult, + AmountValidationOptions, + AmountUnit, +} from "./validators/amountValidator.js"; + +// Address validator (issue #1062) +export { + validateAddress, + validateAddressOrThrow, + isStellarAddress, + detectAddressType, + verifyAddressChecksum, + AddressValidator, + STELLAR_ADDRESS_LENGTHS, +} from "./validators/addressValidator.js"; +export type { + StellarAddressType, + AddressValidationResult, + AddressValidationOptions, +} from "./validators/addressValidator.js"; + +// Signature validator (issue #1061) +export { + validateEd25519Signature, + validateEd25519SignatureOrThrow, + validateMultiSig, + validateMultiSigOrThrow, + validateWebhookSignature as validateWebhookSignaturePayload, + isValidSignatureFormat, + normalizeSignature, + normalizePublicKey, + normalizeMessage, + SignatureValidator, + ED25519_SIGNATURE_BYTE_LENGTH, + ED25519_PUBLIC_KEY_BYTE_LENGTH, +} from "./validators/signatureValidator.js"; +export type { + SignatureValidationResult, + Ed25519ValidationParams, + MultiSigEntry, + SignerWeightConfig, + MultiSigValidationParams, + MultiSigValidationResult, + WebhookValidationParams, +} from "./validators/signatureValidator.js"; + + // --------------------------------------------------------------------------- // Lifecycle management (graceful shutdown) // --------------------------------------------------------------------------- diff --git a/src/validators/addressValidator.ts b/src/validators/addressValidator.ts new file mode 100644 index 0000000..75372a3 --- /dev/null +++ b/src/validators/addressValidator.ts @@ -0,0 +1,300 @@ +/** + * Stellar address validator for accounts, contracts, and muxed addresses. + * + * Stellar addresses use StrKey encoding, which is RFC 4648 Base32 without padding + * composed of: + * 1. A 1-byte version prefix ('G' for public keys, 'C' for contract IDs, 'M' for muxed accounts). + * 2. An encoded payload (32 bytes for account/contract, 40 bytes for muxed accounts). + * 3. A 2-byte CRC16-XModem checksum of the prefix and payload. + * + * This module enforces strict validation rules: + * 1. Format validation: Enforces exact lengths (56 chars for G/C, 69 for M), + * valid Base32 characters (`[A-Z2-7]`), uppercase standard, and correct prefix. + * 2. Checksum validation: Decodes and verifies the CRC16-XModem checksum against the payload. + * 3. Secret key protection: Explicitly rejects secret seeds ('S...') to prevent + * accidental disclosure or misuse of private keys as public addresses. + * 4. Address type constraints: Allows integrators to restrict accepted addresses + * to specific types (e.g., only account addresses, or account + contract). + * + * @example + * ```ts + * import { validateAddress, validateAddressOrThrow } from "@stellar-split/sdk"; + * + * // Validate standard ed25519 public key + * const result = validateAddress("GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"); + * console.log(result.valid); // true + * console.log(result.type); // "account" + * + * // Throws AddressValidationError for corrupted checksum + * validateAddressOrThrow("GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGA"); + * ``` + */ + +import { StrKey } from "@stellar/stellar-sdk"; +import { AddressValidationError } from "../errors.js"; + +/** Valid Stellar address types supported by the validator. */ +export type StellarAddressType = "account" | "contract" | "muxed"; + +/** RFC 4648 Base32 alphabet characters used by Stellar StrKeys. */ +const BASE32_ALPHABET = /^[A-Z2-7]+$/; + +/** Expected lengths for each address type. */ +export const STELLAR_ADDRESS_LENGTHS = { + account: 56, + contract: 56, + muxed: 69, + secretKey: 56, +} as const; + +/** Result object returned by {@link validateAddress}. */ +export interface AddressValidationResult { + /** True when the address passes all format, checksum, and type constraints. */ + valid: boolean; + /** Detected address type ("account", "contract", or "muxed") if valid or recognizable. */ + type?: StellarAddressType; + /** List of human-readable validation error messages. */ + errors: string[]; + /** Normalized address (trimmed and uppercase) if valid. */ + normalizedAddress?: string; +} + +/** Options for configuring address validation behavior. */ +export interface AddressValidationOptions { + /** + * Allowed address types. + * Defaults to `["account", "contract"]`. + */ + allowedTypes?: StellarAddressType[]; + /** + * Shortcut to enable or disable muxed addresses ('M...'). + * If true, adds "muxed" to allowedTypes; if false, excludes "muxed". + */ + allowMuxed?: boolean; + /** + * Whether to explicitly reject secret keys ('S...') with a security warning. + * Defaults to true. + */ + rejectSecretKeys?: boolean; +} + +/** + * Detect the address type from prefix and length. + * + * @param address - Address string to inspect. + * @returns The detected {@link StellarAddressType}, or `null` if unrecognized. + */ +export function detectAddressType(address: string): StellarAddressType | null { + if (typeof address !== "string") return null; + const trimmed = address.trim(); + + if (trimmed.startsWith("G") && trimmed.length === STELLAR_ADDRESS_LENGTHS.account) { + return "account"; + } + if (trimmed.startsWith("C") && trimmed.length === STELLAR_ADDRESS_LENGTHS.contract) { + return "contract"; + } + if (trimmed.startsWith("M") && trimmed.length === STELLAR_ADDRESS_LENGTHS.muxed) { + return "muxed"; + } + return null; +} + +/** + * Verify whether an address string passes Stellar CRC16 checksum verification. + * + * @param address - The address string to verify. + * @returns `true` if the checksum is valid for its detected type. + */ +export function verifyAddressChecksum(address: string): boolean { + if (typeof address !== "string") return false; + const trimmed = address.trim(); + + if (trimmed.startsWith("G")) { + return StrKey.isValidEd25519PublicKey(trimmed); + } + if (trimmed.startsWith("C")) { + return StrKey.isValidContract(trimmed); + } + if (trimmed.startsWith("M")) { + return StrKey.isValidMed25519PublicKey(trimmed); + } + return false; +} + +/** + * Validate a Stellar address for correct format, character set, prefix, and CRC16 checksum. + * + * Validation checks: + * 1. Type & Non-empty: Must be a non-empty string. + * 2. Secret Key Check: Explicitly rejects private keys ('S...') for security. + * 3. Base32 Characters: Must contain only RFC 4648 Base32 uppercase characters (`[A-Z2-7]`). + * 4. Prefix & Length: + * - 'G' addresses must be exactly 56 characters. + * - 'C' addresses must be exactly 56 characters. + * - 'M' addresses must be exactly 69 characters. + * 5. Type Restrictions: Verifies detected type is in `allowedTypes`. + * 6. CRC16 Checksum: Decodes the StrKey and verifies integrity against corruption or typos. + * + * @param address - The candidate address to validate. + * @param options - Optional validation configuration. + * @returns A {@link AddressValidationResult} object. + */ +export function validateAddress( + address: unknown, + options?: AddressValidationOptions +): AddressValidationResult { + const errors: string[] = []; + + // 1. Type check + if (address === null || address === undefined) { + return { + valid: false, + errors: ["Address is required (received null or undefined)."], + }; + } + + if (typeof address !== "string") { + return { + valid: false, + errors: [`Address must be a string (received ${typeof address}).`], + }; + } + + const trimmed = address.trim(); + if (trimmed.length === 0) { + return { + valid: false, + errors: ["Address string cannot be empty."], + }; + } + + // 2. Secret key security check + const rejectSecretKeys = options?.rejectSecretKeys ?? true; + if (rejectSecretKeys && (trimmed.startsWith("S") || StrKey.isValidEd25519SecretSeed(trimmed))) { + return { + valid: false, + errors: [ + "Security error: Secret key ('S...') detected. Private keys must never be used as public addresses.", + ], + }; + } + + // 3. Allowed types configuration + let allowedTypes: StellarAddressType[] = options?.allowedTypes ?? ["account", "contract"]; + if (options?.allowMuxed === true && !allowedTypes.includes("muxed")) { + allowedTypes = [...allowedTypes, "muxed"]; + } else if (options?.allowMuxed === false && allowedTypes.includes("muxed")) { + allowedTypes = allowedTypes.filter((t) => t !== "muxed"); + } + + // 4. Prefix check + const firstChar = trimmed[0]; + if (firstChar !== "G" && firstChar !== "C" && firstChar !== "M") { + errors.push( + `Invalid address prefix: "${firstChar}". Expected "G" (account), "C" (contract), or "M" (muxed).` + ); + return { valid: false, errors }; + } + + const detectedType = detectAddressType(trimmed); + + // 5. Length check + if (!detectedType) { + const expectedLen = + firstChar === "M" + ? STELLAR_ADDRESS_LENGTHS.muxed + : STELLAR_ADDRESS_LENGTHS.account; + errors.push( + `Invalid address length for "${firstChar}" address: got ${trimmed.length} characters, expected ${expectedLen}.` + ); + return { valid: false, errors }; + } + + // 6. Character set check (RFC 4648 Base32) + if (!BASE32_ALPHABET.test(trimmed)) { + const invalidChars = Array.from(new Set(trimmed.replace(/[A-Z2-7]/g, ""))).join(", "); + errors.push( + `Address contains invalid Base32 characters: ${invalidChars}. Stellar addresses may only contain uppercase letters A-Z and digits 2-7.` + ); + return { valid: false, errors, type: detectedType }; + } + + // 7. Allowed types restriction check + if (!allowedTypes.includes(detectedType)) { + errors.push( + `Address type "${detectedType}" is not allowed. Allowed types: ${allowedTypes.join(", ")}.` + ); + return { valid: false, errors, type: detectedType }; + } + + // 8. CRC16 Checksum validation + const checksumValid = verifyAddressChecksum(trimmed); + if (!checksumValid) { + errors.push( + `Address checksum validation failed. The address format is syntactically valid but the CRC16 checksum does not match (potential typo or corruption).` + ); + return { valid: false, errors, type: detectedType }; + } + + return { + valid: true, + type: detectedType, + errors: [], + normalizedAddress: trimmed, + }; +} + +/** + * Validate a Stellar address and throw {@link AddressValidationError} if invalid. + * + * @param address - The candidate address to validate. + * @param options - Optional validation configuration. + * @returns The normalized address string. + * @throws {AddressValidationError} If validation fails. + */ +export function validateAddressOrThrow( + address: unknown, + options?: AddressValidationOptions +): string { + const result = validateAddress(address, options); + if (!result.valid || !result.normalizedAddress) { + throw new AddressValidationError(result.errors.join("; "), { + errors: result.errors, + address, + }); + } + return result.normalizedAddress; +} + +/** + * Convenience helper returning `true` if an address is valid. + * + * @param address - Candidate address. + * @param options - Optional validation configuration. + * @returns `true` if valid, `false` otherwise. + */ +export function isStellarAddress(address: unknown, options?: AddressValidationOptions): boolean { + return validateAddress(address, options).valid; +} + +/** + * Reusable validator instance for validating addresses with pre-configured rules. + */ +export class AddressValidator { + constructor(private readonly defaultOptions?: AddressValidationOptions) {} + + /** + * Validate an address using configured defaults combined with per-call overrides. + */ + validate(address: unknown, options?: AddressValidationOptions): AddressValidationResult { + return validateAddress(address, { ...this.defaultOptions, ...options }); + } + + /** + * Validate an address or throw {@link AddressValidationError}. + */ + validateOrThrow(address: unknown, options?: AddressValidationOptions): string { + return validateAddressOrThrow(address, { ...this.defaultOptions, ...options }); + } +} diff --git a/src/validators/amountValidator.ts b/src/validators/amountValidator.ts new file mode 100644 index 0000000..803f7bd --- /dev/null +++ b/src/validators/amountValidator.ts @@ -0,0 +1,364 @@ +/** + * Amount validator for StellarSplit payments, splits, and token transfers. + * + * Stellar operates in stroops (1 unit = 10,000,000 stroops = 10^7), giving + * token amounts a maximum precision of 7 decimal places. The maximum allowable + * asset amount on Stellar is bounded by the signed 64-bit integer limit: + * `INT64_MAX = 9,223,372,036,854,775,807` stroops (~922.3 billion units). + * + * This module enforces strict validation rules: + * 1. Range validation: Enforces minimum bounds (default: 1 stroop, or 0 if allowZero), + * maximum bounds (default: INT64_MAX), and prevents negative values. + * 2. Precision validation: Enforces that decimal amounts do not exceed Stellar's + * 7 decimal places (configurable via maxDecimals), and rejects scientific notation. + * 3. Boundary handling: Validates edge cases at exact thresholds (0, 1 stroop, + * INT64_MAX, INT64_MAX + 1, negative amounts). + * 4. Clear documentation & errors: Provides actionable error messages and limits. + * + * @example + * ```ts + * import { validateAmount, validateAmountOrThrow } from "@stellar-split/sdk"; + * + * // Validate bigint stroops + * const result = validateAmount(10_000_000n); + * if (result.valid) { + * console.log("Valid stroops:", result.stroops); + * } + * + * // Validate decimal string with precision checking + * const stroops = validateAmountOrThrow("100.50"); // 1_005_000_000n + * + * // Throws AmountValidationError: precision exceeds 7 decimals + * validateAmountOrThrow("1.12345678"); + * ``` + */ + +import { AmountValidationError } from "../errors.js"; + +/** + * Maximum 64-bit signed integer supported by Stellar protocol amounts. + * Equals `2^63 - 1` = `9,223,372,036,854,775,807n` stroops (~922,337,203,685 units). + */ +export const STELLAR_MAX_INT64 = 9_223_372_036_854_775_807n; + +/** + * Minimum valid positive amount on Stellar (1 stroop = 0.0000001 units). + */ +export const STELLAR_MIN_AMOUNT = 1n; + +/** + * Maximum decimal places supported by the Stellar protocol. + */ +export const STELLAR_MAX_DECIMALS = 7; + +/** + * Number of stroops in one whole token unit (10^7). + */ +export const STROOPS_PER_UNIT = 10_000_000n; + +/** Result object returned by {@link validateAmount}. */ +export interface AmountValidationResult { + /** True when the amount passes all range, precision, and boundary constraints. */ + valid: boolean; + /** List of human-readable validation error messages. */ + errors: string[]; + /** The normalized amount in stroops (`bigint`), if valid. */ + stroops?: bigint; +} + +/** Unit format of the input amount. */ +export type AmountUnit = "stroops" | "decimal" | "auto"; + +/** Options for configuring amount validation behavior. */ +export interface AmountValidationOptions { + /** + * Minimum allowable amount in stroops. + * Defaults to 1n (or 0n if `allowZero` is true). + */ + min?: bigint | number | string; + /** + * Maximum allowable amount in stroops. + * Defaults to {@link STELLAR_MAX_INT64}. + */ + max?: bigint | number | string; + /** + * Whether zero (0n) is considered a valid amount. + * Defaults to false. + */ + allowZero?: boolean; + /** + * Whether negative amounts are considered valid. + * Defaults to false. + */ + allowNegative?: boolean; + /** + * Maximum number of decimal places allowed for decimal representations. + * Defaults to 7 (Stellar's standard precision). + */ + maxDecimals?: number; + /** + * How to interpret input numbers or strings. + * - "stroops": Input is interpreted as raw integer stroops. + * - "decimal": Input is interpreted as whole units with decimal fractions (e.g. "1.5" = 15,000,000 stroops). + * - "auto": bigint is stroops; string with '.' is decimal; string without '.' is stroops. + * Defaults to "auto". + */ + unit?: AmountUnit; +} + +/** + * Parse an unknown input into raw stroops (`bigint`). + * + * @param amount - The raw amount input (bigint, number, or string). + * @param unit - Unit mode ("auto", "decimal", or "stroops"). Defaults to "auto". + * @param maxDecimals - Maximum allowed decimal places. Defaults to 7. + * @returns Parsed stroops as `bigint`. + * @throws {AmountValidationError} If parsing fails or precision is exceeded. + */ +export function parseToStroops( + amount: unknown, + unit: AmountUnit = "auto", + maxDecimals: number = STELLAR_MAX_DECIMALS +): bigint { + if (amount === null || amount === undefined) { + throw new AmountValidationError("Amount is required (received null or undefined).", { + amount, + }); + } + + if (typeof amount === "bigint") { + if (unit === "decimal") { + return amount * STROOPS_PER_UNIT; + } + return amount; + } + + if (typeof amount === "number") { + if (!Number.isFinite(amount)) { + throw new AmountValidationError( + `Amount must be a finite number (received ${String(amount)}).`, + { amount } + ); + } + if (unit === "stroops") { + if (!Number.isInteger(amount)) { + throw new AmountValidationError( + `Stroop amounts must be integers (received fractional number ${amount}).`, + { amount } + ); + } + return BigInt(Math.trunc(amount)); + } + // Decimal mode or auto with fractional number + return parseDecimalStringToStroops(amount.toString(), maxDecimals); + } + + if (typeof amount === "string") { + const trimmed = amount.trim(); + if (trimmed.length === 0) { + throw new AmountValidationError("Amount string cannot be empty.", { amount }); + } + + // Disallow scientific notation to prevent floating-point ambiguity + if (/[eE]/.test(trimmed)) { + throw new AmountValidationError( + `Scientific notation is not supported for amounts ("${amount}"). Use standard decimal notation.`, + { amount } + ); + } + + if (unit === "stroops" || (unit === "auto" && !trimmed.includes("."))) { + if (!/^[+-]?\d+$/.test(trimmed)) { + throw new AmountValidationError( + `Invalid stroop amount format: "${amount}". Expected an integer string.`, + { amount } + ); + } + return BigInt(trimmed); + } + + // Decimal mode or auto with '.' + return parseDecimalStringToStroops(trimmed, maxDecimals); + } + + throw new AmountValidationError( + `Unsupported amount type: expected bigint, number, or string (received ${typeof amount}).`, + { amount } + ); +} + +/** + * Parse a decimal string into stroops with precision checking. + */ +function parseDecimalStringToStroops(str: string, maxDecimals: number): bigint { + const match = /^([+-])?(\d*)(\.(\d*))?$/.exec(str); + if (!match) { + throw new AmountValidationError( + `Invalid decimal amount format: "${str}". Expected a numeric string (e.g. "10.50").`, + { str } + ); + } + + const sign = match[1] === "-" ? -1n : 1n; + const wholePart = match[2] || "0"; + const fracPart = match[4] || ""; + + if (!match[2] && !match[4]) { + throw new AmountValidationError(`Invalid decimal amount: "${str}". No digits found.`, { str }); + } + + if (fracPart.length > maxDecimals) { + throw new AmountValidationError( + `Amount precision (${fracPart.length} decimals) exceeds maximum allowed ${maxDecimals} decimal places in "${str}".`, + { str, decimals: fracPart.length, maxDecimals } + ); + } + + const whole = BigInt(wholePart) * STROOPS_PER_UNIT; + const fracPadded = fracPart.padEnd(7, "0").slice(0, 7); + const frac = BigInt(fracPadded); + + return sign * (whole + frac); +} + +/** + * Validate an amount for correct range, precision, and bounds. + * + * Validation checks: + * 1. Format & Parsing: Parses amount into bigint stroops, rejecting NaN, Infinity, + * unsupported types, or malformed strings. + * 2. Precision: Verifies decimal fraction digits do not exceed `maxDecimals` (default 7). + * 3. Non-negative: Rejects negative amounts unless `allowNegative` is true. + * 4. Zero check: Rejects 0 amounts unless `allowZero` is true. + * 5. Minimum bound: Verifies amount >= `min` (default 1 stroop or 0 if allowZero). + * 6. Maximum bound: Verifies amount <= `max` (default Stellar INT64_MAX). + * + * @param amount - The amount to validate (bigint, number, or string). + * @param options - Optional validation configuration. + * @returns A {@link AmountValidationResult} containing validity status, normalized stroops, and errors. + */ +export function validateAmount( + amount: unknown, + options?: AmountValidationOptions +): AmountValidationResult { + const errors: string[] = []; + const maxDecimals = options?.maxDecimals ?? STELLAR_MAX_DECIMALS; + const unit = options?.unit ?? "auto"; + + let stroops: bigint; + try { + stroops = parseToStroops(amount, unit, maxDecimals); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, errors: [message] }; + } + + const allowZero = options?.allowZero ?? false; + const allowNegative = options?.allowNegative ?? false; + + // 1. Negative amount check + if (stroops < 0n && !allowNegative) { + errors.push(`Amount cannot be negative (received ${stroops} stroops).`); + } + + // 2. Zero amount check + if (stroops === 0n && !allowZero) { + errors.push("Amount must be greater than zero."); + } + + // 3. Minimum bound check + let minBound: bigint = allowNegative + ? -STELLAR_MAX_INT64 + : allowZero + ? 0n + : STELLAR_MIN_AMOUNT; + if (options?.min !== undefined) { + try { + minBound = parseToStroops(options.min, unit, maxDecimals); + } catch { + minBound = typeof options.min === "bigint" ? options.min : BigInt(options.min); + } + } + + if (stroops < minBound) { + errors.push( + `Amount (${stroops} stroops) is below the minimum allowed limit of ${minBound} stroops.` + ); + } + + // 4. Maximum bound check + let maxBound: bigint = STELLAR_MAX_INT64; + if (options?.max !== undefined) { + try { + maxBound = parseToStroops(options.max, unit, maxDecimals); + } catch { + maxBound = typeof options.max === "bigint" ? options.max : BigInt(options.max); + } + } + + if (stroops > maxBound) { + errors.push( + `Amount (${stroops} stroops) exceeds the maximum allowed limit of ${maxBound} stroops.` + ); + } + + return { + valid: errors.length === 0, + errors, + stroops: errors.length === 0 ? stroops : undefined, + }; +} + +/** + * Validate an amount and throw {@link AmountValidationError} if invalid. + * + * @param amount - The amount to validate. + * @param options - Optional validation configuration. + * @returns The validated amount in stroops as a `bigint`. + * @throws {AmountValidationError} If validation fails. + */ +export function validateAmountOrThrow( + amount: unknown, + options?: AmountValidationOptions +): bigint { + const result = validateAmount(amount, options); + if (!result.valid || result.stroops === undefined) { + throw new AmountValidationError(result.errors.join("; "), { + errors: result.errors, + amount, + }); + } + return result.stroops; +} + +/** + * Convenience helper returning `true` if an amount is valid. + * + * @param amount - The amount to check. + * @param options - Optional validation configuration. + * @returns `true` if valid, `false` otherwise. + */ +export function isValidAmount(amount: unknown, options?: AmountValidationOptions): boolean { + return validateAmount(amount, options).valid; +} + +/** + * Reusable validator instance for validating amounts with pre-configured rules. + */ +export class AmountValidator { + constructor(private readonly defaultOptions?: AmountValidationOptions) {} + + /** + * Validate an amount using configured defaults combined with per-call overrides. + */ + validate(amount: unknown, options?: AmountValidationOptions): AmountValidationResult { + return validateAmount(amount, { ...this.defaultOptions, ...options }); + } + + /** + * Validate an amount or throw {@link AmountValidationError}. + */ + validateOrThrow(amount: unknown, options?: AmountValidationOptions): bigint { + return validateAmountOrThrow(amount, { ...this.defaultOptions, ...options }); + } +} diff --git a/src/validators/deadlineValidator.ts b/src/validators/deadlineValidator.ts new file mode 100644 index 0000000..94ad784 --- /dev/null +++ b/src/validators/deadlineValidator.ts @@ -0,0 +1,332 @@ +/** + * Deadline validator for StellarSplit invoices and time-locked operations. + * + * Invoices and contract operations require deadlines expressed as Unix + * timestamps in seconds (Soroban `u64`). Submitting past, malformed, or + * unrealistic deadlines can cause transaction panics, unfulfillable contracts, + * or immediate invoice expiration. + * + * This module enforces strict validation rules: + * 1. Format check: Ensures input is a valid timestamp, Date, or date string + * within valid 64-bit unsigned integer bounds (`1 <= deadline <= 2^64 - 1`). + * 2. Millisecond normalization: Automatically detects millisecond timestamps + * (e.g., JS `Date.now()`) and converts them to seconds. + * 3. Future requirement: Enforces that the deadline is strictly in the future + * relative to a reference time (wall-clock or caller-injected). + * 4. Lead time constraint: Validates optional minimum lead time into the future + * (e.g., minimum 1 hour lead time). + * 5. Upper bound constraint: Validates optional maximum allowable duration into + * the future to prevent absurdly distant deadlines. + * + * @example + * ```ts + * import { validateDeadline, validateDeadlineOrThrow } from "@stellar-split/sdk"; + * + * // Validating a Date + * const result = validateDeadline(new Date(Date.now() + 86400 * 1000)); + * if (result.valid) { + * console.log("Deadline in seconds:", result.deadlineSeconds); + * } + * + * // Throwing on invalid or past deadline + * const deadlineSec = validateDeadlineOrThrow(Date.now() + 3600 * 1000, { + * minLeadSeconds: 1800, // at least 30 minutes in future + * }); + * ``` + */ + +import { DeadlineValidationError } from "../errors.js"; + +/** Maximum value of an unsigned 64-bit integer (`u64` max). */ +export const MAX_U64 = 18_446_744_073_709_551_615n; + +/** + * Threshold above which a timestamp is assumed to be in milliseconds rather + * than seconds (10^11 seconds corresponds to November 5138). + */ +export const MS_TIMESTAMP_THRESHOLD = 100_000_000_000n; + +/** Result object returned by {@link validateDeadline}. */ +export interface DeadlineValidationResult { + /** True when the deadline passes all format and future constraints. */ + valid: boolean; + /** List of human-readable validation error messages. */ + errors: string[]; + /** The normalized deadline as a Unix timestamp in seconds (`bigint`), if valid. */ + deadlineSeconds?: bigint; +} + +/** Options for configuring deadline validation behavior. */ +export interface DeadlineValidationOptions { + /** + * Reference time to measure against. + * Defaults to the current wall-clock time (`Date.now()`). + */ + referenceTime?: bigint | number | Date; + /** + * Minimum required lead time in seconds into the future. + * If set, `deadline >= referenceTime + minLeadSeconds` is required. + * Defaults to 0 (meaning `deadline > referenceTime`). + */ + minLeadSeconds?: number; + /** + * Maximum allowable future duration in seconds from referenceTime. + * If set, `deadline <= referenceTime + maxFutureSeconds` is required. + */ + maxFutureSeconds?: number; + /** + * Whether to automatically detect and convert millisecond timestamps to seconds. + * Defaults to true. + */ + autoConvertMs?: boolean; +} + +/** + * Convert a reference time input into a normalized Unix timestamp in seconds (`bigint`). + */ +function toReferenceSeconds(ref?: bigint | number | Date, autoConvertMs: boolean = true): bigint { + if (ref === undefined || ref === null) { + return BigInt(Math.floor(Date.now() / 1000)); + } + if (ref instanceof Date) { + return BigInt(Math.floor(ref.getTime() / 1000)); + } + if (typeof ref === "bigint") { + if (autoConvertMs && ref >= MS_TIMESTAMP_THRESHOLD) { + return ref / 1000n; + } + return ref; + } + if (typeof ref === "number") { + if (!Number.isFinite(ref)) { + return BigInt(Math.floor(Date.now() / 1000)); + } + if (autoConvertMs && ref >= Number(MS_TIMESTAMP_THRESHOLD)) { + return BigInt(Math.floor(ref / 1000)); + } + return BigInt(Math.floor(ref)); + } + return BigInt(Math.floor(Date.now() / 1000)); +} + +/** + * Parse an unknown input into a Unix timestamp in seconds as a `bigint`. + * + * Supports `bigint`, `number`, `Date`, and string representations (ISO-8601 or numeric). + * + * @param input - The raw deadline input. + * @param autoConvertMs - Whether to convert timestamps >= 10^11 from ms to seconds. Defaults to true. + * @returns Normalized seconds timestamp as `bigint`. + * @throws {DeadlineValidationError} If input cannot be parsed into a valid timestamp. + */ +export function parseDeadline(input: unknown, autoConvertMs: boolean = true): bigint { + if (input === null || input === undefined) { + throw new DeadlineValidationError("Deadline value is required (received null or undefined).", { + input, + }); + } + + if (typeof input === "bigint") { + if (autoConvertMs && input >= MS_TIMESTAMP_THRESHOLD) { + return input / 1000n; + } + return input; + } + + if (typeof input === "number") { + if (!Number.isFinite(input)) { + throw new DeadlineValidationError( + `Deadline must be a finite number (received ${String(input)}).`, + { input } + ); + } + if (autoConvertMs && input >= Number(MS_TIMESTAMP_THRESHOLD)) { + return BigInt(Math.floor(input / 1000)); + } + return BigInt(Math.floor(input)); + } + + if (input instanceof Date) { + const ms = input.getTime(); + if (!Number.isFinite(ms)) { + throw new DeadlineValidationError("Deadline Date is invalid (NaN timestamp).", { + input: input.toString(), + }); + } + return BigInt(Math.floor(ms / 1000)); + } + + if (typeof input === "string") { + const trimmed = input.trim(); + if (trimmed.length === 0) { + throw new DeadlineValidationError("Deadline string cannot be empty.", { input }); + } + + // Check if it is a pure integer string + if (/^[+-]?\d+$/.test(trimmed)) { + try { + const val = BigInt(trimmed); + if (autoConvertMs && val >= MS_TIMESTAMP_THRESHOLD) { + return val / 1000n; + } + return val; + } catch { + // Fall through to Date.parse + } + } + + // Try parsing as ISO / date string + const parsedMs = Date.parse(trimmed); + if (Number.isFinite(parsedMs)) { + return BigInt(Math.floor(parsedMs / 1000)); + } + + throw new DeadlineValidationError( + `Invalid deadline string format: "${input}". Must be a Unix timestamp or ISO date string.`, + { input } + ); + } + + throw new DeadlineValidationError( + `Unsupported deadline type: expected bigint, number, Date, or date string (received ${typeof input}).`, + { input } + ); +} + +/** + * Validate a deadline against format, range, and future constraints. + * + * Validation checks: + * 1. Format: Must be parsable as a Unix timestamp or Date. + * 2. Range: Must be positive (> 0) and fit within unsigned 64-bit integer (`u64`). + * 3. Future requirement: Must be strictly in the future relative to `referenceTime`. + * 4. Lead time: If `minLeadSeconds` is specified, `deadline >= referenceTime + minLeadSeconds`. + * 5. Max duration: If `maxFutureSeconds` is specified, `deadline <= referenceTime + maxFutureSeconds`. + * + * @param deadline - The deadline to validate (bigint, number, Date, or string). + * @param options - Optional validation options. + * @returns A {@link DeadlineValidationResult} containing validity status, normalized seconds, and errors. + */ +export function validateDeadline( + deadline: unknown, + options?: DeadlineValidationOptions +): DeadlineValidationResult { + const errors: string[] = []; + const autoConvertMs = options?.autoConvertMs ?? true; + + let parsed: bigint; + try { + parsed = parseDeadline(deadline, autoConvertMs); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { valid: false, errors: [message] }; + } + + // 1. Positive value check + if (parsed <= 0n) { + errors.push(`Deadline must be a positive timestamp (received ${parsed}).`); + } + + // 2. 64-bit unsigned bounds check + if (parsed > MAX_U64) { + errors.push( + `Deadline (${parsed}) exceeds maximum 64-bit unsigned integer limit (${MAX_U64}).` + ); + } + + // 3. Future constraint check + const refSec = toReferenceSeconds(options?.referenceTime, autoConvertMs); + + if (parsed <= refSec) { + errors.push( + `Deadline (${parsed}) must be in the future (reference time: ${refSec}).` + ); + } else { + // 4. Minimum lead time check + const minLead = options?.minLeadSeconds ?? 0; + if (minLead > 0) { + const minRequired = refSec + BigInt(minLead); + if (parsed < minRequired) { + errors.push( + `Deadline (${parsed}) must be at least ${minLead} seconds in the future (minimum required: ${minRequired}).` + ); + } + } + + // 5. Maximum future limit check + const maxFuture = options?.maxFutureSeconds; + if (maxFuture !== undefined && maxFuture > 0) { + const maxAllowed = refSec + BigInt(maxFuture); + if (parsed > maxAllowed) { + errors.push( + `Deadline (${parsed}) exceeds maximum allowed future limit of ${maxFuture} seconds (maximum allowed: ${maxAllowed}).` + ); + } + } + } + + return { + valid: errors.length === 0, + errors, + deadlineSeconds: errors.length === 0 ? parsed : undefined, + }; +} + +/** + * Validate a deadline and throw {@link DeadlineValidationError} if invalid. + * + * @param deadline - The deadline to validate. + * @param options - Optional validation options. + * @returns The normalized deadline in seconds as a `bigint`. + * @throws {DeadlineValidationError} If validation fails. + */ +export function validateDeadlineOrThrow( + deadline: unknown, + options?: DeadlineValidationOptions +): bigint { + const result = validateDeadline(deadline, options); + if (!result.valid || result.deadlineSeconds === undefined) { + throw new DeadlineValidationError(result.errors.join("; "), { + errors: result.errors, + deadline, + }); + } + return result.deadlineSeconds; +} + +/** + * Convenience helper to check if a deadline is in the future. + * + * @param deadline - The deadline to check. + * @param minLeadSeconds - Optional minimum lead time in seconds. Defaults to 0. + * @param referenceTime - Optional reference time. Defaults to now. + * @returns `true` if valid and in the future; `false` otherwise. + */ +export function isFutureDeadline( + deadline: unknown, + minLeadSeconds: number = 0, + referenceTime?: bigint | number | Date +): boolean { + return validateDeadline(deadline, { minLeadSeconds, referenceTime }).valid; +} + +/** + * Reusable validator instance for validating deadlines with pre-configured rules. + */ +export class DeadlineValidator { + constructor(private readonly defaultOptions?: DeadlineValidationOptions) {} + + /** + * Validate a deadline using configured defaults combined with per-call overrides. + */ + validate(deadline: unknown, options?: DeadlineValidationOptions): DeadlineValidationResult { + return validateDeadline(deadline, { ...this.defaultOptions, ...options }); + } + + /** + * Validate a deadline or throw {@link DeadlineValidationError}. + */ + validateOrThrow(deadline: unknown, options?: DeadlineValidationOptions): bigint { + return validateDeadlineOrThrow(deadline, { ...this.defaultOptions, ...options }); + } +} diff --git a/src/validators/index.ts b/src/validators/index.ts new file mode 100644 index 0000000..4c566f7 --- /dev/null +++ b/src/validators/index.ts @@ -0,0 +1,20 @@ +/** + * Validator suite for StellarSplit SDK. + * + * Provides dedicated validators for: + * - Deadlines (Issue #1064) + * - Amounts and precision (Issue #1063) + * - Stellar addresses and checksums (Issue #1062) + * - Signatures and multi-sig thresholds (Issue #1061) + * - Split ratios and totals + * - Invoice metadata + * - Recipient deduplication + */ + +export * from "./deadlineValidator.js"; +export * from "./amountValidator.js"; +export * from "./addressValidator.js"; +export * from "./signatureValidator.js"; +export * from "./splitRatioValidator.js"; +export * from "./invoiceMetadataValidator.js"; +export * from "./recipientDeduplicator.js"; diff --git a/src/validators/signatureValidator.ts b/src/validators/signatureValidator.ts new file mode 100644 index 0000000..15c65da --- /dev/null +++ b/src/validators/signatureValidator.ts @@ -0,0 +1,617 @@ +/** + * Signature validator for StellarSplit transactions, multi-signature sets, and webhooks. + * + * Provides comprehensive integrity and authenticity verification across: + * 1. Ed25519 signatures: Validates 64-byte cryptographic signatures over transaction + * hashes or arbitrary messages against Stellar ed25519 public keys ('G...'). + * 2. Multi-signature threshold verification: Verifies M-of-N signature sets, + * accumulating weights across authorized signers and comparing against an + * execution threshold. + * 3. Webhook signatures: Verifies HMAC signatures over webhook payloads with + * timing-safe comparison and replay protection timestamps. + * + * @example + * ```ts + * import { + * validateEd25519Signature, + * validateEd25519SignatureOrThrow, + * validateMultiSig, + * } from "@stellar-split/sdk"; + * + * // Validate Ed25519 signature + * const result = validateEd25519Signature({ + * publicKey: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + * message: Buffer.from("hello world"), + * signature: "...", // 64-byte Buffer, Uint8Array, or 128-hex string + * }); + * + * if (result.valid) { + * console.log("Signature is authentic!"); + * } + * ``` + */ + +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { createHmac, timingSafeEqual } from "crypto"; +import { SignatureValidationError } from "../errors.js"; + +/** Standard Ed25519 signature length in bytes. */ +export const ED25519_SIGNATURE_BYTE_LENGTH = 64; + +/** Standard Ed25519 public key raw byte length. */ +export const ED25519_PUBLIC_KEY_BYTE_LENGTH = 32; + +/** Hex character regex. */ +const HEX_REGEX = /^[0-9a-fA-F]+$/; + +/** Base64 character regex. */ +const BASE64_REGEX = /^[A-Za-z0-9+/=]+$/; + +/** Result of a single signature validation. */ +export interface SignatureValidationResult { + /** True when the signature is well-formed and cryptographically authentic. */ + valid: boolean; + /** Primary error message if invalid. */ + error?: string; + /** Complete list of validation error messages. */ + errors: string[]; +} + +/** Parameters for Ed25519 signature validation. */ +export interface Ed25519ValidationParams { + /** + * The signer's public key. + * Can be a 56-character Stellar 'G...' address or raw 32-byte Buffer/Uint8Array. + */ + publicKey: string | Buffer | Uint8Array; + /** + * The signed message or transaction hash. + * Can be a Buffer, Uint8Array, or string. + */ + message: string | Buffer | Uint8Array; + /** + * The 64-byte Ed25519 signature. + * Can be a Buffer, Uint8Array, or hex/base64 encoded string. + */ + signature: string | Buffer | Uint8Array; +} + +/** Single signature entry in a multi-signature verification request. */ +export interface MultiSigEntry { + /** Stellar 'G...' address or 32-byte public key of the signer. */ + signer: string | Buffer | Uint8Array; + /** 64-byte signature produced by this signer. */ + signature: string | Buffer | Uint8Array; +} + +/** Authorized signer configuration with voting weight. */ +export interface SignerWeightConfig { + /** Stellar 'G...' address of the authorized signer. */ + address: string; + /** Voting weight of this signer (positive integer). */ + weight: number; +} + +/** Parameters for multi-signature threshold validation. */ +export interface MultiSigValidationParams { + /** The signed message or transaction hash. */ + message: string | Buffer | Uint8Array; + /** Array of signatures collected for this operation. */ + signatures: MultiSigEntry[]; + /** List of authorized signers and their assigned weights. */ + signers: SignerWeightConfig[]; + /** Required weight threshold for the operation to be approved. */ + threshold: number; +} + +/** Result of multi-signature threshold validation. */ +export interface MultiSigValidationResult { + /** True when the accumulated weight of valid signatures meets or exceeds threshold. */ + valid: boolean; + /** Total weight accumulated from verified unique signers. */ + totalWeight: number; + /** Required threshold weight. */ + threshold: number; + /** Addresses of signers whose signatures were verified. */ + verifiedSigners: string[]; + /** Complete list of errors encountered. */ + errors: string[]; +} + +/** Parameters for webhook signature verification. */ +export interface WebhookValidationParams { + /** Hex-encoded HMAC signature from the webhook header. */ + signature: string; + /** Raw webhook request body (string or Buffer). */ + payload: string | Buffer; + /** Shared secret key. */ + secret: string; + /** HMAC hash algorithm. Defaults to 'sha256'. */ + algorithm?: "sha256" | "sha1" | "sha512"; + /** Optional timestamp from the webhook header for replay protection. */ + timestamp?: number | string; + /** Maximum allowable clock skew in seconds. Defaults to 300 (5 minutes). */ + toleranceSeconds?: number; + /** Optional signature prefix to strip before comparison (e.g., 'sha256='). */ + signaturePrefix?: string; +} + +// --------------------------------------------------------------------------- +// Normalization and Format Utilities +// --------------------------------------------------------------------------- + +/** + * Check whether a signature input has a valid length and format. + * + * @param signature - Signature as Buffer, Uint8Array, or hex/base64 string. + * @param expectedLength - Expected byte length (defaults to 64 for Ed25519). + * @returns `true` if valid, `false` otherwise. + */ +export function isValidSignatureFormat( + signature: unknown, + expectedLength: number = ED25519_SIGNATURE_BYTE_LENGTH +): boolean { + return normalizeSignature(signature, expectedLength) !== null; +} + +/** + * Normalize a signature input into a raw Buffer of exact expected length. + * + * @param signature - Signature input. + * @param expectedLength - Expected byte length (defaults to 64). + * @returns The normalized Buffer, or `null` if invalid. + */ +export function normalizeSignature( + signature: unknown, + expectedLength: number = ED25519_SIGNATURE_BYTE_LENGTH +): Buffer | null { + if (!signature) return null; + + if (Buffer.isBuffer(signature)) { + return signature.length === expectedLength ? signature : null; + } + + if (signature instanceof Uint8Array) { + return signature.length === expectedLength ? Buffer.from(signature) : null; + } + + if (typeof signature === "string") { + const trimmed = signature.trim(); + + // Hex string (e.g. 128 hex chars for 64 bytes) + if (trimmed.length === expectedLength * 2 && HEX_REGEX.test(trimmed)) { + return Buffer.from(trimmed, "hex"); + } + + // Base64 string + try { + if (BASE64_REGEX.test(trimmed)) { + const buf = Buffer.from(trimmed, "base64"); + if (buf.length === expectedLength) { + return buf; + } + } + } catch { + // not valid base64 + } + } + + return null; +} + +/** + * Normalize a public key into a Stellar 'G...' address string. + * + * @param publicKey - Public key as 'G...' string or raw 32-byte Buffer/Uint8Array. + * @returns The normalized 'G...' address string, or `null` if invalid. + */ +export function normalizePublicKey(publicKey: unknown): string | null { + if (!publicKey) return null; + + if (typeof publicKey === "string") { + const trimmed = publicKey.trim(); + if (StrKey.isValidEd25519PublicKey(trimmed)) { + return trimmed; + } + return null; + } + + if (Buffer.isBuffer(publicKey) || publicKey instanceof Uint8Array) { + if (publicKey.length === ED25519_PUBLIC_KEY_BYTE_LENGTH) { + try { + return StrKey.encodeEd25519PublicKey(Buffer.from(publicKey)); + } catch { + return null; + } + } + } + + return null; +} + +/** + * Normalize a message input into a Buffer. + * + * @param message - String or Buffer. + * @returns Buffer representation of the message. + */ +export function normalizeMessage(message: unknown): Buffer | null { + if (message === null || message === undefined) return null; + + if (Buffer.isBuffer(message)) { + return message; + } + + if (message instanceof Uint8Array) { + return Buffer.from(message); + } + + if (typeof message === "string") { + return Buffer.from(message, "utf8"); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Validation Implementations +// --------------------------------------------------------------------------- + +/** + * Validate an Ed25519 cryptographic signature. + * + * Verification checks: + * 1. Signature format: Exactly 64 bytes in length (Buffer, Uint8Array, hex, or base64). + * 2. Public key format: Valid Stellar 'G...' address or 32-byte ed25519 public key. + * 3. Message format: Non-empty Buffer, Uint8Array, or string. + * 4. Authenticity: Cryptographic verification using ed25519 verification. + * + * @param params - Signature, message, and public key parameters. + * @returns A {@link SignatureValidationResult} with validity and diagnostic errors. + */ +export function validateEd25519Signature( + params: Ed25519ValidationParams +): SignatureValidationResult { + const errors: string[] = []; + + if (!params) { + return { + valid: false, + error: "Validation parameters are required.", + errors: ["Validation parameters are required."], + }; + } + + // 1. Signature format check + const sigBuf = normalizeSignature(params.signature, ED25519_SIGNATURE_BYTE_LENGTH); + if (!sigBuf) { + errors.push( + `Invalid signature format: expected ${ED25519_SIGNATURE_BYTE_LENGTH} bytes (or ${ED25519_SIGNATURE_BYTE_LENGTH * 2} hex characters).` + ); + } + + // 2. Public key format check + const pubKeyStr = normalizePublicKey(params.publicKey); + if (!pubKeyStr) { + errors.push( + "Invalid public key: expected a valid Stellar ed25519 public key ('G...') or 32-byte buffer." + ); + } + + // 3. Message check + const msgBuf = normalizeMessage(params.message); + if (!msgBuf) { + errors.push("Invalid message: message cannot be null or undefined."); + } + + if (errors.length > 0 || !sigBuf || !pubKeyStr || !msgBuf) { + return { valid: false, error: errors[0], errors }; + } + + // 4. Authenticity verification + try { + const keypair = Keypair.fromPublicKey(pubKeyStr); + const verified = keypair.verify(msgBuf, sigBuf); + + if (!verified) { + errors.push( + `Cryptographic signature verification failed: signature does not match public key ${pubKeyStr}.` + ); + return { valid: false, error: errors[0], errors }; + } + + return { valid: true, errors: [] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + errors.push(`Signature verification error: ${message}`); + return { valid: false, error: errors[0], errors }; + } +} + +/** + * Validate an Ed25519 signature and throw {@link SignatureValidationError} if invalid. + * + * @param params - Signature, message, and public key parameters. + * @throws {SignatureValidationError} If validation fails. + */ +export function validateEd25519SignatureOrThrow(params: Ed25519ValidationParams): void { + const result = validateEd25519Signature(params); + if (!result.valid) { + throw new SignatureValidationError(result.errors.join("; "), { + errors: result.errors, + publicKey: typeof params.publicKey === "string" ? params.publicKey : undefined, + }); + } +} + +/** + * Validate a multi-signature threshold against authorized signers. + * + * Verification checks: + * 1. Parameter integrity: Validates threshold, message, and signer arrays. + * 2. Authenticity: Verifies each signature cryptographically. + * 3. Authorization: Ensures each signer is part of the authorized `signers` config. + * 4. Deduplication: Ensures each unique signer contributes weight only once. + * 5. Threshold: Validates that accumulated weight >= required threshold. + * + * @param params - Multi-signature parameters. + * @returns A {@link MultiSigValidationResult} with accumulated weight and verified signers. + */ +export function validateMultiSig(params: MultiSigValidationParams): MultiSigValidationResult { + const errors: string[] = []; + + if (!params) { + return { + valid: false, + totalWeight: 0, + threshold: 0, + verifiedSigners: [], + errors: ["Multi-signature parameters are required."], + }; + } + + const { message, signatures, signers, threshold } = params; + + if (threshold <= 0) { + errors.push(`Threshold must be greater than zero (received ${threshold}).`); + } + + if (!signers || signers.length === 0) { + errors.push("Authorized signers list cannot be empty."); + } + + if (!signatures || signatures.length === 0) { + errors.push("Signatures list cannot be empty."); + } + + if (errors.length > 0) { + return { + valid: false, + totalWeight: 0, + threshold, + verifiedSigners: [], + errors, + }; + } + + // Build a lookup map of authorized signers: address -> weight + const signerWeightMap = new Map(); + for (const s of signers) { + const norm = normalizePublicKey(s.address); + if (norm) { + signerWeightMap.set(norm, s.weight); + } + } + + const verifiedSigners: string[] = []; + const seenSigners = new Set(); + let totalWeight = 0; + + for (let i = 0; i < signatures.length; i++) { + const entry = signatures[i]; + if (!entry) continue; + const normSigner = normalizePublicKey(entry.signer); + + if (!normSigner) { + errors.push(`Signature at index ${i} has an invalid signer address.`); + continue; + } + + if (seenSigners.has(normSigner)) { + // Duplicate signer in signature list - do not count twice + continue; + } + + // Verify cryptographic signature + const sigResult = validateEd25519Signature({ + publicKey: normSigner, + message, + signature: entry.signature, + }); + + if (!sigResult.valid) { + errors.push(`Signature from ${normSigner} failed verification: ${sigResult.error}`); + continue; + } + + // Check if signer is authorized + const weight = signerWeightMap.get(normSigner); + if (weight === undefined || weight <= 0) { + errors.push(`Signer ${normSigner} is not in the authorized signers list.`); + continue; + } + + seenSigners.add(normSigner); + verifiedSigners.push(normSigner); + totalWeight += weight; + } + + const meetsThreshold = totalWeight >= threshold; + if (!meetsThreshold) { + errors.push( + `Insufficient signature weight: accumulated weight ${totalWeight} is below required threshold ${threshold}.` + ); + } + + return { + valid: meetsThreshold, + totalWeight, + threshold, + verifiedSigners, + errors, + }; +} + +/** + * Validate a multi-signature threshold and throw {@link SignatureValidationError} if insufficient. + * + * @param params - Multi-signature parameters. + * @throws {SignatureValidationError} If threshold is not met. + */ +export function validateMultiSigOrThrow(params: MultiSigValidationParams): void { + const result = validateMultiSig(params); + if (!result.valid) { + throw new SignatureValidationError(result.errors.join("; "), { + errors: result.errors, + totalWeight: result.totalWeight, + threshold: result.threshold, + }); + } +} + +/** + * Validate an HMAC webhook signature with timing-safe comparison and replay protection. + * + * @param params - Webhook signature parameters. + * @returns A {@link SignatureValidationResult}. + */ +export function validateWebhookSignature( + params: WebhookValidationParams +): SignatureValidationResult { + const errors: string[] = []; + + if (!params || !params.secret) { + return { + valid: false, + error: "Webhook secret is required.", + errors: ["Webhook secret is required."], + }; + } + + if (!params.signature) { + return { + valid: false, + error: "Webhook signature is required.", + errors: ["Webhook signature is required."], + }; + } + + let sig = params.signature.trim(); + if (params.signaturePrefix && sig.startsWith(params.signaturePrefix)) { + sig = sig.slice(params.signaturePrefix.length); + } + + // Validate hex format + if (!HEX_REGEX.test(sig) || sig.length % 2 !== 0) { + errors.push("Invalid webhook signature format: expected even-length hex string."); + return { valid: false, error: errors[0], errors }; + } + + // Timestamp replay check if provided + if (params.timestamp !== undefined) { + const tolerance = params.toleranceSeconds ?? 300; + const now = Math.floor(Date.now() / 1000); + let ts = + typeof params.timestamp === "number" + ? Math.floor(params.timestamp) + : parseInt(params.timestamp, 10); + + // If timestamp in ms, convert to seconds + if (ts > 100_000_000_000) { + ts = Math.floor(ts / 1000); + } + + if (isNaN(ts)) { + errors.push("Invalid timestamp format in webhook payload."); + return { valid: false, error: errors[0], errors }; + } + + if (Math.abs(now - ts) > tolerance) { + errors.push( + `Webhook signature expired: timestamp ${ts} differs from current time ${now} by more than ${tolerance} seconds.` + ); + return { valid: false, error: errors[0], errors }; + } + } + + // Compute expected HMAC + const algorithm = params.algorithm ?? "sha256"; + const body = + params.timestamp !== undefined + ? `${params.timestamp}.${typeof params.payload === "string" ? params.payload : params.payload.toString("utf8")}` + : params.payload; + + let expectedBuf: Buffer; + try { + expectedBuf = createHmac(algorithm, params.secret).update(body).digest(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`Failed to compute HMAC: ${msg}`); + return { valid: false, error: errors[0], errors }; + } + + const providedBuf = Buffer.from(sig, "hex"); + + if (expectedBuf.length !== providedBuf.length) { + errors.push("Webhook signature length does not match expected algorithm digest length."); + return { valid: false, error: errors[0], errors }; + } + + const matches = timingSafeEqual(expectedBuf, providedBuf); + if (!matches) { + errors.push("Webhook signature verification failed: signature does not match payload digest."); + return { valid: false, error: errors[0], errors }; + } + + return { valid: true, errors: [] }; +} + +/** + * Reusable validator class for cryptographic and webhook signatures. + */ +export class SignatureValidator { + /** + * Validate an Ed25519 signature. + */ + validateEd25519(params: Ed25519ValidationParams): SignatureValidationResult { + return validateEd25519Signature(params); + } + + /** + * Validate an Ed25519 signature or throw {@link SignatureValidationError}. + */ + validateEd25519OrThrow(params: Ed25519ValidationParams): void { + return validateEd25519SignatureOrThrow(params); + } + + /** + * Validate multi-signature threshold. + */ + validateMultiSig(params: MultiSigValidationParams): MultiSigValidationResult { + return validateMultiSig(params); + } + + /** + * Validate multi-signature threshold or throw {@link SignatureValidationError}. + */ + validateMultiSigOrThrow(params: MultiSigValidationParams): void { + return validateMultiSigOrThrow(params); + } + + /** + * Validate a webhook signature. + */ + validateWebhook(params: WebhookValidationParams): SignatureValidationResult { + return validateWebhookSignature(params); + } +} diff --git a/test/addressValidator.test.ts b/test/addressValidator.test.ts new file mode 100644 index 0000000..4a6a15b --- /dev/null +++ b/test/addressValidator.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "vitest"; +import { + validateAddress, + validateAddressOrThrow, + isStellarAddress, + detectAddressType, + verifyAddressChecksum, + AddressValidator, + STELLAR_ADDRESS_LENGTHS, +} from "../src/validators/addressValidator.js"; +import { + AddressValidationError, + ValidationError, + isAddressValidationError, +} from "../src/errors.js"; + +const VALID_ACCOUNT = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; +const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const VALID_MUXED = "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAGZFQ"; +const VALID_SECRET = "SCQA5IYGYKD4HVEFXJCU3ROL6XS5NHSEHQEJ3GWINDUBC7KD6E5KL5UU"; + +describe("AddressValidator (Issue #1062)", () => { + describe("Format validation", () => { + it("accepts a valid G... Stellar account address", () => { + const result = validateAddress(VALID_ACCOUNT); + expect(result.valid).toBe(true); + expect(result.type).toBe("account"); + expect(result.normalizedAddress).toBe(VALID_ACCOUNT); + expect(result.errors).toHaveLength(0); + }); + + it("accepts a valid C... Stellar contract address", () => { + const result = validateAddress(VALID_CONTRACT); + expect(result.valid).toBe(true); + expect(result.type).toBe("contract"); + expect(result.normalizedAddress).toBe(VALID_CONTRACT); + }); + + it("accepts a valid M... muxed address when allowed", () => { + const result = validateAddress(VALID_MUXED, { allowMuxed: true }); + expect(result.valid).toBe(true); + expect(result.type).toBe("muxed"); + expect(result.normalizedAddress).toBe(VALID_MUXED); + }); + + it("rejects M... muxed addresses by default", () => { + const result = validateAddress(VALID_MUXED); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('Address type "muxed" is not allowed'); + }); + + it("rejects invalid prefixes", () => { + const badPrefix = "BA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + const result = validateAddress(badPrefix); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain('Invalid address prefix: "B"'); + }); + + it("rejects invalid lengths for account address", () => { + // 55 characters + const tooShort = VALID_ACCOUNT.slice(0, 55); + const shortResult = validateAddress(tooShort); + expect(shortResult.valid).toBe(false); + expect(shortResult.errors[0]).toContain("Invalid address length"); + + // 57 characters + const tooLong = VALID_ACCOUNT + "A"; + const longResult = validateAddress(tooLong); + expect(longResult.valid).toBe(false); + expect(longResult.errors[0]).toContain("Invalid address length"); + }); + + it("rejects invalid Base32 characters (0, 1, 8, 9, lowercase, symbols)", () => { + // Replacing char with '8' + const withEight = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSG8"; + const resultEight = validateAddress(withEight); + expect(resultEight.valid).toBe(false); + expect(resultEight.errors[0]).toContain("Address contains invalid Base32 characters"); + + // Lowercase in body + const withLowerBody = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGz"; + const resultLower = validateAddress(withLowerBody); + expect(resultLower.valid).toBe(false); + expect(resultLower.errors[0]).toContain("Address contains invalid Base32 characters"); + + // Lowercase prefix + const resultLowerPrefix = validateAddress(VALID_ACCOUNT.toLowerCase()); + expect(resultLowerPrefix.valid).toBe(false); + expect(resultLowerPrefix.errors[0]).toContain('Invalid address prefix: "g"'); + }); + + it("rejects null, undefined, empty, and non-string inputs", () => { + expect(validateAddress(null).valid).toBe(false); + expect(validateAddress(undefined).valid).toBe(false); + expect(validateAddress("").valid).toBe(false); + expect(validateAddress(" ").valid).toBe(false); + expect(validateAddress(12345).valid).toBe(false); + expect(validateAddress({}).valid).toBe(false); + }); + }); + + describe("Checksum validation", () => { + it("passes for authentic checksums", () => { + expect(verifyAddressChecksum(VALID_ACCOUNT)).toBe(true); + expect(verifyAddressChecksum(VALID_CONTRACT)).toBe(true); + expect(verifyAddressChecksum(VALID_MUXED)).toBe(true); + }); + + it("fails when checksum is corrupted (single character typo)", () => { + // Flip the last character (which is part of the CRC16 checksum) + const lastChar = VALID_ACCOUNT[VALID_ACCOUNT.length - 1]; + const replacementChar = lastChar === "A" ? "B" : "A"; + const corruptedAddress = VALID_ACCOUNT.slice(0, -1) + replacementChar; + + expect(corruptedAddress.length).toBe(56); + const result = validateAddress(corruptedAddress); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Address checksum validation failed"); + expect(result.errors[0]).toContain("CRC16 checksum does not match"); + }); + }); + + describe("Security and secret key protection", () => { + it("explicitly rejects secret keys (S...) with security error", () => { + const result = validateAddress(VALID_SECRET); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Security error: Secret key ('S...') detected"); + }); + + it("throws AddressValidationError if secret key is passed to validateAddressOrThrow", () => { + expect(() => validateAddressOrThrow(VALID_SECRET)).toThrow(AddressValidationError); + }); + }); + + describe("Allowed types restrictions", () => { + it("restricts to account addresses only when specified", () => { + const options = { allowedTypes: ["account" as const] }; + + expect(validateAddress(VALID_ACCOUNT, options).valid).toBe(true); + + const contractResult = validateAddress(VALID_CONTRACT, options); + expect(contractResult.valid).toBe(false); + expect(contractResult.errors[0]).toContain('Address type "contract" is not allowed'); + }); + + it("restricts to contract addresses only when specified", () => { + const options = { allowedTypes: ["contract" as const] }; + + expect(validateAddress(VALID_CONTRACT, options).valid).toBe(true); + + const accountResult = validateAddress(VALID_ACCOUNT, options); + expect(accountResult.valid).toBe(false); + expect(accountResult.errors[0]).toContain('Address type "account" is not allowed'); + }); + }); + + describe("validateAddressOrThrow", () => { + it("returns normalized address on valid input", () => { + const addr = validateAddressOrThrow(` ${VALID_ACCOUNT} `); + expect(addr).toBe(VALID_ACCOUNT); + }); + + it("throws AddressValidationError on invalid input", () => { + expect(() => validateAddressOrThrow("invalid")).toThrow(AddressValidationError); + + try { + validateAddressOrThrow("GABC"); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(AddressValidationError); + expect(err).toBeInstanceOf(ValidationError); + expect(isAddressValidationError(err)).toBe(true); + expect((err as AddressValidationError).code).toBe("ADDRESS_VALIDATION_ERROR"); + } + }); + }); + + describe("Utility helpers", () => { + it("isStellarAddress checks validity", () => { + expect(isStellarAddress(VALID_ACCOUNT)).toBe(true); + expect(isStellarAddress(VALID_CONTRACT)).toBe(true); + expect(isStellarAddress("invalid")).toBe(false); + }); + + it("detectAddressType detects types accurately", () => { + expect(detectAddressType(VALID_ACCOUNT)).toBe("account"); + expect(detectAddressType(VALID_CONTRACT)).toBe("contract"); + expect(detectAddressType(VALID_MUXED)).toBe("muxed"); + expect(detectAddressType("not-an-address")).toBeNull(); + }); + }); + + describe("AddressValidator class", () => { + it("reuses default options across validation calls", () => { + const validator = new AddressValidator({ + allowMuxed: true, + }); + + expect(validator.validate(VALID_MUXED).valid).toBe(true); + expect(validator.validate(VALID_ACCOUNT).valid).toBe(true); + + const strictValidator = new AddressValidator({ + allowedTypes: ["contract"], + }); + expect(strictValidator.validate(VALID_ACCOUNT).valid).toBe(false); + expect(strictValidator.validate(VALID_CONTRACT).valid).toBe(true); + }); + }); +}); diff --git a/test/amountValidator.test.ts b/test/amountValidator.test.ts new file mode 100644 index 0000000..26ea31a --- /dev/null +++ b/test/amountValidator.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { + validateAmount, + validateAmountOrThrow, + isValidAmount, + parseToStroops, + AmountValidator, + STELLAR_MAX_INT64, + STELLAR_MIN_AMOUNT, + STELLAR_MAX_DECIMALS, + STROOPS_PER_UNIT, +} from "../src/validators/amountValidator.js"; +import { + AmountValidationError, + ValidationError, + isAmountValidationError, +} from "../src/errors.js"; + +describe("AmountValidator (Issue #1063)", () => { + describe("Range validation", () => { + it("accepts valid positive bigint amounts", () => { + const result = validateAmount(10_000_000n); + expect(result.valid).toBe(true); + expect(result.stroops).toBe(10_000_000n); + expect(result.errors).toHaveLength(0); + }); + + it("accepts valid decimal strings", () => { + const result = validateAmount("1.50"); + expect(result.valid).toBe(true); + expect(result.stroops).toBe(15_000_000n); + }); + + it("rejects zero by default", () => { + const result = validateAmount(0n); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("must be greater than zero"); + }); + + it("accepts zero when allowZero is true", () => { + const result = validateAmount(0n, { allowZero: true }); + expect(result.valid).toBe(true); + expect(result.stroops).toBe(0n); + }); + + it("rejects negative amounts by default", () => { + const result = validateAmount(-10n); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("cannot be negative"); + }); + + it("accepts negative amounts when allowNegative is true", () => { + const result = validateAmount(-10n, { allowNegative: true }); + expect(result.valid).toBe(true); + expect(result.stroops).toBe(-10n); + }); + + it("enforces custom minimum bounds", () => { + const min = 500n; + + expect(validateAmount(500n, { min }).valid).toBe(true); + expect(validateAmount(501n, { min }).valid).toBe(true); + + const belowResult = validateAmount(499n, { min }); + expect(belowResult.valid).toBe(false); + expect(belowResult.errors[0]).toContain("is below the minimum allowed limit of 500 stroops"); + }); + + it("enforces custom maximum bounds", () => { + const max = 1_000_000n; + + expect(validateAmount(1_000_000n, { max }).valid).toBe(true); + expect(validateAmount(999_999n, { max }).valid).toBe(true); + + const aboveResult = validateAmount(1_000_001n, { max }); + expect(aboveResult.valid).toBe(false); + expect(aboveResult.errors[0]).toContain("exceeds the maximum allowed limit of 1000000 stroops"); + }); + + it("enforces Stellar protocol INT64_MAX bound", () => { + // Exactly INT64_MAX -> valid + expect(validateAmount(STELLAR_MAX_INT64).valid).toBe(true); + + // Above INT64_MAX -> invalid + const aboveResult = validateAmount(STELLAR_MAX_INT64 + 1n); + expect(aboveResult.valid).toBe(false); + expect(aboveResult.errors[0]).toContain("exceeds the maximum allowed limit"); + }); + }); + + describe("Precision validation", () => { + it("accepts amounts with up to 7 decimal places (Stellar standard)", () => { + expect(validateAmount("1.1").valid).toBe(true); + expect(validateAmount("1.123456").valid).toBe(true); + expect(validateAmount("1.1234567").valid).toBe(true); + expect(validateAmount("0.0000001").valid).toBe(true); + expect(validateAmount("0.0000001").stroops).toBe(1n); + }); + + it("rejects amounts exceeding 7 decimal places", () => { + const result = validateAmount("1.12345678"); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Amount precision (8 decimals) exceeds maximum allowed 7 decimal places"); + }); + + it("enforces custom maxDecimals limit", () => { + const options = { maxDecimals: 2 }; + + expect(validateAmount("10.5", options).valid).toBe(true); + expect(validateAmount("10.55", options).valid).toBe(true); + + const exceedResult = validateAmount("10.555", options); + expect(exceedResult.valid).toBe(false); + expect(exceedResult.errors[0]).toContain("Amount precision (3 decimals) exceeds maximum allowed 2 decimal places"); + }); + + it("rejects scientific notation", () => { + const result = validateAmount("1e-5"); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Scientific notation is not supported"); + }); + + it("rejects malformed number strings", () => { + expect(validateAmount("1.2.3").valid).toBe(false); + expect(validateAmount("abc").valid).toBe(false); + expect(validateAmount("").valid).toBe(false); + expect(validateAmount(" ").valid).toBe(false); + }); + + it("handles decimal strings with leading dot or trailing dot", () => { + expect(validateAmount(".5").valid).toBe(true); + expect(validateAmount(".5").stroops).toBe(5_000_000n); + + expect(validateAmount("5.").valid).toBe(true); + expect(validateAmount("5.").stroops).toBe(50_000_000n); + }); + }); + + describe("Boundaries and edge cases", () => { + it("validates smallest possible non-zero amount (1 stroop)", () => { + expect(validateAmount(STELLAR_MIN_AMOUNT).valid).toBe(true); + expect(validateAmount(STELLAR_MIN_AMOUNT).stroops).toBe(1n); + }); + + it("rejects null, undefined, and non-numeric types", () => { + expect(validateAmount(null).valid).toBe(false); + expect(validateAmount(undefined).valid).toBe(false); + expect(validateAmount(true).valid).toBe(false); + expect(validateAmount({}).valid).toBe(false); + expect(validateAmount(NaN).valid).toBe(false); + expect(validateAmount(Infinity).valid).toBe(false); + }); + + it("rejects fractional numbers when unit is explicitly stroops", () => { + const result = validateAmount(10.5, { unit: "stroops" }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Stroop amounts must be integers"); + }); + }); + + describe("validateAmountOrThrow", () => { + it("returns bigint stroops on valid input", () => { + const stroops = validateAmountOrThrow("2.5"); + expect(stroops).toBe(25_000_000n); + }); + + it("throws AmountValidationError on invalid input", () => { + expect(() => validateAmountOrThrow("-1")).toThrow(AmountValidationError); + + try { + validateAmountOrThrow("0"); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(AmountValidationError); + expect(err).toBeInstanceOf(ValidationError); + expect(isAmountValidationError(err)).toBe(true); + expect((err as AmountValidationError).code).toBe("AMOUNT_VALIDATION_ERROR"); + } + }); + }); + + describe("isValidAmount helper", () => { + it("returns boolean correctly", () => { + expect(isValidAmount(100n)).toBe(true); + expect(isValidAmount(-100n)).toBe(false); + expect(isValidAmount("invalid")).toBe(false); + }); + }); + + describe("parseToStroops utility", () => { + it("parses diverse inputs to stroops", () => { + expect(parseToStroops(500n)).toBe(500n); + expect(parseToStroops("1.0")).toBe(10_000_000n); + expect(parseToStroops("0.0000001")).toBe(1n); + expect(parseToStroops(5, "decimal")).toBe(50_000_000n); + }); + + it("throws on precision violation or bad format", () => { + expect(() => parseToStroops("1.12345678")).toThrow(AmountValidationError); + expect(() => parseToStroops("bad")).toThrow(AmountValidationError); + }); + }); + + describe("AmountValidator class", () => { + it("reuses default options across validation calls", () => { + const validator = new AmountValidator({ + min: 1_000n, + max: 50_000n, + }); + + expect(validator.validate(25_000n).valid).toBe(true); + expect(validator.validate(500n).valid).toBe(false); + expect(validator.validate(60_000n).valid).toBe(false); + + expect(() => validator.validateOrThrow(500n)).toThrow(AmountValidationError); + }); + }); +}); diff --git a/test/deadlineValidator.test.ts b/test/deadlineValidator.test.ts new file mode 100644 index 0000000..0172851 --- /dev/null +++ b/test/deadlineValidator.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + validateDeadline, + validateDeadlineOrThrow, + isFutureDeadline, + parseDeadline, + DeadlineValidator, + MAX_U64, + MS_TIMESTAMP_THRESHOLD, +} from "../src/validators/deadlineValidator.js"; +import { + DeadlineValidationError, + ValidationError, + isDeadlineValidationError, +} from "../src/errors.js"; + +const BASE_TIME_SEC = 1_770_000_000n; // Fixed reference time in seconds +const BASE_TIME_MS = Number(BASE_TIME_SEC) * 1000; + +describe("DeadlineValidator (Issue #1064)", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE_TIME_MS); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("Format validation", () => { + it("accepts a valid future bigint in seconds", () => { + const deadline = BASE_TIME_SEC + 3600n; + const result = validateDeadline(deadline); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.deadlineSeconds).toBe(deadline); + }); + + it("accepts a valid future number in seconds", () => { + const deadline = Number(BASE_TIME_SEC) + 7200; + const result = validateDeadline(deadline); + + expect(result.valid).toBe(true); + expect(result.deadlineSeconds).toBe(BigInt(deadline)); + }); + + it("automatically normalizes millisecond timestamps to seconds", () => { + const msTimestamp = (Number(BASE_TIME_SEC) + 3600) * 1000; + expect(BigInt(msTimestamp)).toBeGreaterThanOrEqual(MS_TIMESTAMP_THRESHOLD); + + const result = validateDeadline(msTimestamp); + expect(result.valid).toBe(true); + expect(result.deadlineSeconds).toBe(BASE_TIME_SEC + 3600n); + }); + + it("accepts a valid future Date object", () => { + const futureDate = new Date(BASE_TIME_MS + 86400 * 1000); + const result = validateDeadline(futureDate); + + expect(result.valid).toBe(true); + expect(result.deadlineSeconds).toBe(BASE_TIME_SEC + 86400n); + }); + + it("accepts an ISO-8601 string in the future", () => { + const futureDate = new Date(BASE_TIME_MS + 3600 * 1000); + const isoStr = futureDate.toISOString(); + + const result = validateDeadline(isoStr); + expect(result.valid).toBe(true); + expect(result.deadlineSeconds).toBe(BASE_TIME_SEC + 3600n); + }); + + it("accepts a numeric string representing seconds", () => { + const target = BASE_TIME_SEC + 5000n; + const result = validateDeadline(target.toString()); + + expect(result.valid).toBe(true); + expect(result.deadlineSeconds).toBe(target); + }); + + it("rejects null and undefined", () => { + expect(validateDeadline(null).valid).toBe(false); + expect(validateDeadline(undefined).valid).toBe(false); + }); + + it("rejects empty string and whitespace", () => { + expect(validateDeadline("").valid).toBe(false); + expect(validateDeadline(" ").valid).toBe(false); + }); + + it("rejects non-numeric, non-date strings", () => { + const result = validateDeadline("invalid-date-string"); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Invalid deadline string format"); + }); + + it("rejects non-finite numbers (NaN, Infinity)", () => { + expect(validateDeadline(NaN).valid).toBe(false); + expect(validateDeadline(Infinity).valid).toBe(false); + expect(validateDeadline(-Infinity).valid).toBe(false); + }); + + it("rejects unsupported types (boolean, symbol, object)", () => { + expect(validateDeadline(true).valid).toBe(false); + expect(validateDeadline(Symbol("deadline")).valid).toBe(false); + expect(validateDeadline({ foo: "bar" }).valid).toBe(false); + }); + + it("rejects invalid Date objects", () => { + const invalidDate = new Date("invalid"); + const result = validateDeadline(invalidDate); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("NaN timestamp"); + }); + + it("rejects negative and zero deadlines", () => { + expect(validateDeadline(0n).valid).toBe(false); + expect(validateDeadline(-100n).valid).toBe(false); + expect(validateDeadline(-1).valid).toBe(false); + }); + + it("rejects values exceeding Soroban u64 limit", () => { + const overflow = MAX_U64 + 1n; + const result = validateDeadline(overflow, { autoConvertMs: false }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("exceeds maximum 64-bit unsigned integer limit"); + }); + }); + + describe("Future validation requirements", () => { + it("fails when deadline is in the past", () => { + const pastDeadline = BASE_TIME_SEC - 3600n; + const result = validateDeadline(pastDeadline); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("must be in the future"); + }); + + it("fails when deadline is exactly equal to current reference time", () => { + const result = validateDeadline(BASE_TIME_SEC); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("must be in the future"); + }); + + it("passes when deadline is strictly 1 second in the future", () => { + const result = validateDeadline(BASE_TIME_SEC + 1n); + + expect(result.valid).toBe(true); + expect(result.deadlineSeconds).toBe(BASE_TIME_SEC + 1n); + }); + + it("enforces custom referenceTime parameter", () => { + const customRef = 2_000_000_000n; + const deadline = 2_000_000_500n; + + const result = validateDeadline(deadline, { referenceTime: customRef }); + expect(result.valid).toBe(true); + + const pastResult = validateDeadline(customRef - 10n, { referenceTime: customRef }); + expect(pastResult.valid).toBe(false); + }); + + it("enforces minLeadSeconds constraint", () => { + const minLead = 3600; // 1 hour lead time required + + // Exactly at minLead -> passes + const exactResult = validateDeadline(BASE_TIME_SEC + 3600n, { minLeadSeconds: minLead }); + expect(exactResult.valid).toBe(true); + + // 1 second below minLead -> fails + const belowResult = validateDeadline(BASE_TIME_SEC + 3599n, { minLeadSeconds: minLead }); + expect(belowResult.valid).toBe(false); + expect(belowResult.errors[0]).toContain("must be at least 3600 seconds in the future"); + }); + + it("enforces maxFutureSeconds constraint", () => { + const maxFuture = 86400 * 30; // 30 days max + + // Within limit -> passes + const validResult = validateDeadline(BASE_TIME_SEC + BigInt(maxFuture), { + maxFutureSeconds: maxFuture, + }); + expect(validResult.valid).toBe(true); + + // Exceeding limit -> fails + const exceedResult = validateDeadline(BASE_TIME_SEC + BigInt(maxFuture) + 1n, { + maxFutureSeconds: maxFuture, + }); + expect(exceedResult.valid).toBe(false); + expect(exceedResult.errors[0]).toContain("exceeds maximum allowed future limit"); + }); + }); + + describe("validateDeadlineOrThrow", () => { + it("returns normalized seconds bigint on valid input", () => { + const deadline = BASE_TIME_SEC + 1000n; + const res = validateDeadlineOrThrow(deadline); + expect(res).toBe(deadline); + }); + + it("throws DeadlineValidationError on invalid input", () => { + expect(() => validateDeadlineOrThrow(BASE_TIME_SEC - 10n)).toThrow(DeadlineValidationError); + + try { + validateDeadlineOrThrow(BASE_TIME_SEC - 10n); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(DeadlineValidationError); + expect(err).toBeInstanceOf(ValidationError); + expect(isDeadlineValidationError(err)).toBe(true); + expect((err as DeadlineValidationError).code).toBe("DEADLINE_VALIDATION_ERROR"); + } + }); + }); + + describe("isFutureDeadline helper", () => { + it("returns true for future deadlines and false for past/invalid", () => { + expect(isFutureDeadline(BASE_TIME_SEC + 60n)).toBe(true); + expect(isFutureDeadline(BASE_TIME_SEC - 60n)).toBe(false); + expect(isFutureDeadline("invalid")).toBe(false); + }); + }); + + describe("parseDeadline utility", () => { + it("parses various formats accurately", () => { + expect(parseDeadline(1234567890n)).toBe(1234567890n); + expect(parseDeadline(1234567890)).toBe(1234567890n); + expect(parseDeadline(new Date(1234567890 * 1000))).toBe(1234567890n); + expect(parseDeadline("1234567890")).toBe(1234567890n); + }); + + it("throws on unparseable inputs", () => { + expect(() => parseDeadline(null)).toThrow(DeadlineValidationError); + expect(() => parseDeadline("not a timestamp")).toThrow(DeadlineValidationError); + }); + }); + + describe("DeadlineValidator class", () => { + it("reuses default options across validation calls", () => { + const validator = new DeadlineValidator({ + minLeadSeconds: 1800, + }); + + expect(validator.validate(BASE_TIME_SEC + 3600n).valid).toBe(true); + expect(validator.validate(BASE_TIME_SEC + 1000n).valid).toBe(false); + + expect(() => validator.validateOrThrow(BASE_TIME_SEC + 1000n)).toThrow( + DeadlineValidationError + ); + }); + }); +}); diff --git a/test/sdkExports.test.ts b/test/sdkExports.test.ts index 942cd0b..0483696 100644 --- a/test/sdkExports.test.ts +++ b/test/sdkExports.test.ts @@ -24,4 +24,50 @@ describe("public API surface (issues #586, #588, #589)", () => { expect(typeof sdk.WalletConnectionTimeoutError).toBe("function"); expect(typeof sdk.isWalletConnectionTimeoutError).toBe("function"); }); + + it("exports the validation suite (issues #1061, #1062, #1063, #1064)", () => { + // Errors + expect(typeof sdk.DeadlineValidationError).toBe("function"); + expect(typeof sdk.isDeadlineValidationError).toBe("function"); + expect(typeof sdk.AmountValidationError).toBe("function"); + expect(typeof sdk.isAmountValidationError).toBe("function"); + expect(typeof sdk.AddressValidationError).toBe("function"); + expect(typeof sdk.isAddressValidationError).toBe("function"); + expect(typeof sdk.SignatureValidationError).toBe("function"); + expect(typeof sdk.isSignatureValidationError).toBe("function"); + + // Deadline validator + expect(typeof sdk.validateDeadline).toBe("function"); + expect(typeof sdk.validateDeadlineOrThrow).toBe("function"); + expect(typeof sdk.isFutureDeadline).toBe("function"); + expect(typeof sdk.parseDeadline).toBe("function"); + expect(typeof sdk.DeadlineValidator).toBe("function"); + + // Amount validator + expect(typeof sdk.validateAmount).toBe("function"); + expect(typeof sdk.validateAmountOrThrow).toBe("function"); + expect(typeof sdk.isValidAmount).toBe("function"); + expect(typeof sdk.parseToStroops).toBe("function"); + expect(typeof sdk.AmountValidator).toBe("function"); + + // Address validator + expect(typeof sdk.validateAddress).toBe("function"); + expect(typeof sdk.validateAddressOrThrow).toBe("function"); + expect(typeof sdk.isStellarAddress).toBe("function"); + expect(typeof sdk.detectAddressType).toBe("function"); + expect(typeof sdk.verifyAddressChecksum).toBe("function"); + expect(typeof sdk.AddressValidator).toBe("function"); + + // Signature validator + expect(typeof sdk.validateEd25519Signature).toBe("function"); + expect(typeof sdk.validateEd25519SignatureOrThrow).toBe("function"); + expect(typeof sdk.validateMultiSig).toBe("function"); + expect(typeof sdk.validateMultiSigOrThrow).toBe("function"); + expect(typeof sdk.validateWebhookSignaturePayload).toBe("function"); + expect(typeof sdk.isValidSignatureFormat).toBe("function"); + expect(typeof sdk.normalizeSignature).toBe("function"); + expect(typeof sdk.normalizePublicKey).toBe("function"); + expect(typeof sdk.SignatureValidator).toBe("function"); + }); }); + diff --git a/test/signatureValidator.test.ts b/test/signatureValidator.test.ts new file mode 100644 index 0000000..7558f90 --- /dev/null +++ b/test/signatureValidator.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { createHmac } from "crypto"; +import { + validateEd25519Signature, + validateEd25519SignatureOrThrow, + validateMultiSig, + validateMultiSigOrThrow, + validateWebhookSignature, + isValidSignatureFormat, + normalizeSignature, + normalizePublicKey, + SignatureValidator, + ED25519_SIGNATURE_BYTE_LENGTH, +} from "../src/validators/signatureValidator.js"; +import { + SignatureValidationError, + ValidationError, + isSignatureValidationError, +} from "../src/errors.js"; + +describe("SignatureValidator (Issue #1061)", () => { + const kp1 = Keypair.random(); + const kp2 = Keypair.random(); + const kp3 = Keypair.random(); + + const message = Buffer.from("test transaction payload"); + const validSig1 = kp1.sign(message); + const validSig2 = kp2.sign(message); + + describe("Ed25519 signature validation", () => { + it("validates authentic signature produced by Keypair", () => { + const result = validateEd25519Signature({ + publicKey: kp1.publicKey(), + message, + signature: validSig1, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("accepts signature as hex string", () => { + const hexSig = Buffer.from(validSig1).toString("hex"); + const result = validateEd25519Signature({ + publicKey: kp1.publicKey(), + message, + signature: hexSig, + }); + + expect(result.valid).toBe(true); + }); + + it("accepts signature as base64 string", () => { + const b64Sig = Buffer.from(validSig1).toString("base64"); + const result = validateEd25519Signature({ + publicKey: kp1.publicKey(), + message, + signature: b64Sig, + }); + + expect(result.valid).toBe(true); + }); + + it("accepts publicKey as raw 32-byte Buffer", () => { + const rawPubKey = kp1.rawPublicKey(); + const result = validateEd25519Signature({ + publicKey: rawPubKey, + message, + signature: validSig1, + }); + + expect(result.valid).toBe(true); + }); + + it("fails when signature is cryptographically invalid (corrupted byte)", () => { + const badSig = Buffer.from(validSig1); + badSig[0] ^= 0xff; // corrupt first byte + + const result = validateEd25519Signature({ + publicKey: kp1.publicKey(), + message, + signature: badSig, + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Cryptographic signature verification failed"); + }); + + it("fails when signature does not match message", () => { + const tamperedMessage = Buffer.from("different payload"); + const result = validateEd25519Signature({ + publicKey: kp1.publicKey(), + message: tamperedMessage, + signature: validSig1, + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Cryptographic signature verification failed"); + }); + + it("fails when signature is from wrong keypair", () => { + const result = validateEd25519Signature({ + publicKey: kp2.publicKey(), // expected kp2, but signature is from kp1 + message, + signature: validSig1, + }); + + expect(result.valid).toBe(false); + }); + + it("rejects invalid signature length", () => { + const shortSig = Buffer.alloc(63); + const result = validateEd25519Signature({ + publicKey: kp1.publicKey(), + message, + signature: shortSig, + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Invalid signature format: expected 64 bytes"); + }); + + it("rejects invalid public key", () => { + const result = validateEd25519Signature({ + publicKey: "invalid-public-key", + message, + signature: validSig1, + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Invalid public key"); + }); + + it("validateEd25519SignatureOrThrow throws SignatureValidationError", () => { + const badSig = Buffer.alloc(64); + expect(() => + validateEd25519SignatureOrThrow({ + publicKey: kp1.publicKey(), + message, + signature: badSig, + }) + ).toThrow(SignatureValidationError); + + try { + validateEd25519SignatureOrThrow({ + publicKey: kp1.publicKey(), + message, + signature: badSig, + }); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(SignatureValidationError); + expect(err).toBeInstanceOf(ValidationError); + expect(isSignatureValidationError(err)).toBe(true); + expect((err as SignatureValidationError).code).toBe("SIGNATURE_VALIDATION_ERROR"); + } + }); + }); + + describe("Multi-signature threshold validation", () => { + it("passes when accumulated weight meets or exceeds threshold", () => { + const result = validateMultiSig({ + message, + signatures: [ + { signer: kp1.publicKey(), signature: validSig1 }, + { signer: kp2.publicKey(), signature: validSig2 }, + ], + signers: [ + { address: kp1.publicKey(), weight: 2 }, + { address: kp2.publicKey(), weight: 1 }, + { address: kp3.publicKey(), weight: 1 }, + ], + threshold: 3, + }); + + expect(result.valid).toBe(true); + expect(result.totalWeight).toBe(3); + expect(result.verifiedSigners).toHaveLength(2); + expect(result.verifiedSigners).toContain(kp1.publicKey()); + expect(result.verifiedSigners).toContain(kp2.publicKey()); + }); + + it("fails when accumulated weight is below threshold", () => { + const result = validateMultiSig({ + message, + signatures: [{ signer: kp2.publicKey(), signature: validSig2 }], // weight = 1 + signers: [ + { address: kp1.publicKey(), weight: 2 }, + { address: kp2.publicKey(), weight: 1 }, + ], + threshold: 3, // requires 3 + }); + + expect(result.valid).toBe(false); + expect(result.totalWeight).toBe(1); + expect(result.errors[0]).toContain("Insufficient signature weight"); + }); + + it("does not double-count weight from duplicate signatures of the same signer", () => { + const result = validateMultiSig({ + message, + signatures: [ + { signer: kp1.publicKey(), signature: validSig1 }, + { signer: kp1.publicKey(), signature: validSig1 }, // duplicate + ], + signers: [{ address: kp1.publicKey(), weight: 2 }], + threshold: 4, // 2 + 2 would pass if double counted, but should fail! + }); + + expect(result.valid).toBe(false); + expect(result.totalWeight).toBe(2); + }); + + it("ignores signatures from unauthorized signers", () => { + const unauthorizedKp = Keypair.random(); + const unauthorizedSig = unauthorizedKp.sign(message); + + const result = validateMultiSig({ + message, + signatures: [ + { signer: unauthorizedKp.publicKey(), signature: unauthorizedSig }, + { signer: kp1.publicKey(), signature: validSig1 }, + ], + signers: [{ address: kp1.publicKey(), weight: 2 }], + threshold: 2, + }); + + expect(result.valid).toBe(true); + expect(result.totalWeight).toBe(2); + expect(result.errors[0]).toContain("is not in the authorized signers list"); + }); + + it("validateMultiSigOrThrow throws on insufficient weight", () => { + expect(() => + validateMultiSigOrThrow({ + message, + signatures: [{ signer: kp2.publicKey(), signature: validSig2 }], + signers: [{ address: kp2.publicKey(), weight: 1 }], + threshold: 2, + }) + ).toThrow(SignatureValidationError); + }); + }); + + describe("Webhook signature validation", () => { + const secret = "webhook-test-secret-key-12345"; + const payload = JSON.stringify({ event: "invoice.funded", id: "inv_123" }); + const computedHmac = createHmac("sha256", secret).update(payload).digest("hex"); + + it("validates authentic webhook signature", () => { + const result = validateWebhookSignature({ + signature: computedHmac, + payload, + secret, + }); + + expect(result.valid).toBe(true); + }); + + it("handles signaturePrefix (e.g. 'sha256=')", () => { + const prefixed = `sha256=${computedHmac}`; + const result = validateWebhookSignature({ + signature: prefixed, + payload, + secret, + signaturePrefix: "sha256=", + }); + + expect(result.valid).toBe(true); + }); + + it("fails when payload is modified", () => { + const result = validateWebhookSignature({ + signature: computedHmac, + payload: payload + " modified", + secret, + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Webhook signature verification failed"); + }); + + it("fails when secret is wrong", () => { + const result = validateWebhookSignature({ + signature: computedHmac, + payload, + secret: "wrong-secret", + }); + + expect(result.valid).toBe(false); + }); + + it("validates timestamp freshness within tolerance", () => { + const nowSec = Math.floor(Date.now() / 1000); + const timestampedPayload = `${nowSec}.${payload}`; + const sig = createHmac("sha256", secret).update(timestampedPayload).digest("hex"); + + const result = validateWebhookSignature({ + signature: sig, + payload, + secret, + timestamp: nowSec, + toleranceSeconds: 60, + }); + + expect(result.valid).toBe(true); + }); + + it("fails when timestamp exceeds tolerance window", () => { + const expiredTimestamp = Math.floor(Date.now() / 1000) - 500; // 500s ago + const timestampedPayload = `${expiredTimestamp}.${payload}`; + const sig = createHmac("sha256", secret).update(timestampedPayload).digest("hex"); + + const result = validateWebhookSignature({ + signature: sig, + payload, + secret, + timestamp: expiredTimestamp, + toleranceSeconds: 60, // only 60s tolerance + }); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toContain("Webhook signature expired"); + }); + }); + + describe("Utility helpers", () => { + it("isValidSignatureFormat verifies format correctly", () => { + expect(isValidSignatureFormat(validSig1)).toBe(true); + expect(isValidSignatureFormat(Buffer.alloc(64))).toBe(true); + expect(isValidSignatureFormat(Buffer.alloc(63))).toBe(false); + expect(isValidSignatureFormat("not-hex")).toBe(false); + expect(isValidSignatureFormat(null)).toBe(false); + }); + + it("normalizeSignature returns Buffer or null", () => { + expect(normalizeSignature(validSig1)).toBeInstanceOf(Buffer); + expect(normalizeSignature(Buffer.from(validSig1).toString("hex"))).toBeInstanceOf(Buffer); + expect(normalizeSignature("bad")).toBeNull(); + }); + + it("normalizePublicKey handles addresses and buffers", () => { + expect(normalizePublicKey(kp1.publicKey())).toBe(kp1.publicKey()); + expect(normalizePublicKey(kp1.rawPublicKey())).toBe(kp1.publicKey()); + expect(normalizePublicKey("invalid")).toBeNull(); + }); + }); + + describe("SignatureValidator class", () => { + it("provides instance methods for validation", () => { + const validator = new SignatureValidator(); + + const result = validator.validateEd25519({ + publicKey: kp1.publicKey(), + message, + signature: validSig1, + }); + expect(result.valid).toBe(true); + + expect(() => + validator.validateEd25519OrThrow({ + publicKey: kp1.publicKey(), + message, + signature: Buffer.alloc(64), + }) + ).toThrow(SignatureValidationError); + }); + }); +});