From 5187f3bce761670e2e4d7da886b81caad67f28f1 Mon Sep 17 00:00:00 2001 From: Daniil Dovgal Date: Wed, 20 May 2026 16:46:48 -0400 Subject: [PATCH 1/2] feat(wallets): add approval signature validation per signer type --- .../src/utils/signature-validation.test.ts | 341 ++++++++++++++++++ .../wallets/src/utils/signature-validation.ts | 161 +++++++++ 2 files changed, 502 insertions(+) create mode 100644 packages/wallets/src/utils/signature-validation.test.ts create mode 100644 packages/wallets/src/utils/signature-validation.ts diff --git a/packages/wallets/src/utils/signature-validation.test.ts b/packages/wallets/src/utils/signature-validation.test.ts new file mode 100644 index 000000000..e697b6000 --- /dev/null +++ b/packages/wallets/src/utils/signature-validation.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; +import type { Approval } from "@/wallets/types"; +import { assertApprovalSignatureFormat, registerSignatureValidator } from "./signature-validation"; +import { InvalidSignatureForApprovalError } from "./errors"; + + +const ERC_6492_MAGIC_SUFFIX = "6492649264926492649264926492649264926492649264926492649264926492"; + +function ecdsaSig65Bytes(): string { + return "0x" + "1a".repeat(32) + "2b".repeat(32) + "1b"; +} + +function ecdsaSig64Bytes(): string { + return "0x" + "1a".repeat(32) + "2b".repeat(32); +} + +function erc6492WrappedSig(): string { + return "0x" + "aa".repeat(20) + "bb".repeat(100) + "cc".repeat(65) + ERC_6492_MAGIC_SUFFIX; +} + +function p256Approval(signer: string, r = "0x1a2b3c", s = "0x4d5e6f"): Approval { + return { signer, signature: { r, s } }; +} + +function passkeyApproval( + signer: string, + r = "0x1a2b3c", + s = "0x4d5e6f", + metadata: Record = { + authenticatorData: "0xdeadbeef", + clientDataJSON: "{}", + challengeIndex: 0, + typeIndex: 0, + userVerificationRequired: true, + } +): Approval { + return { signer, signature: { r, s }, metadata } as unknown as Approval; +} + +function forceApproval(signer: string, signature: unknown, extra?: Record): Approval { + return { signer, signature, ...extra } as unknown as Approval; +} + +// --------------------------------------------------------------------------- +// Error message matchers — mirror invalidSignature() output exactly +// --------------------------------------------------------------------------- + +function expectedError(signer: string, message: string): RegExp { + return new RegExp(`Invalid signature for signer "${escapeRegex(signer)}": ${escapeRegex(message)}`); +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// --------------------------------------------------------------------------- +// ECDSA signer types +// --------------------------------------------------------------------------- + +describe("assertApprovalSignatureFormat", () => { + const ecdsaSignerLocators = [ + "external-wallet:0xAbC123", + "server:0xDef456", + "email:user@example.com", + "phone:+1234567890", + ]; + + describe("ECDSA signers", () => { + describe.each(ecdsaSignerLocators)("signer %s", (signer) => { + it("accepts a valid 65-byte ECDSA signature", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: ecdsaSig65Bytes() }) + ).not.toThrow(); + }); + + it("accepts a valid 64-byte ECDSA signature", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: ecdsaSig64Bytes() }) + ).not.toThrow(); + }); + + it("rejects a non-hex signature", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: "ab".repeat(65) }) + ).toThrow(expectedError(signer, "expected a hex string")); + }); + + it("rejects a { r, s } object when a hex string is expected", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: { r: "0x1", s: "0x2" } }) + ).toThrow(expectedError(signer, "expected a hex string")); + }); + + it("rejects an ERC-6492-wrapped signature", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: erc6492WrappedSig() }) + ).toThrow(expectedError(signer, "ERC-6492 wrapped signatures are not supported — provide a raw ECDSA signature")); + }); + + it("rejects a signature with incorrect byte length", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: "0x" + "ab".repeat(32) }) + ).toThrow(expectedError(signer, "expected ECDSA with 64 or 65 bytes")); + }); + + it("rejects a structurally invalid signature that passes length check", () => { + // 65 bytes but invalid r/s/v structure + const badStructure = "0x" + "00".repeat(64) + "ff"; + expect(() => + assertApprovalSignatureFormat({ signer, signature: badStructure }) + ).toThrow(expectedError(signer, "failed structural parse — not a valid ECDSA signature")); + }); + }); + }); + + // --------------------------------------------------------------------------- + // P256 device signer + // --------------------------------------------------------------------------- + + describe("P256 device signer", () => { + const signer = "device:testkey123"; + + it("accepts a valid { r, s } signature", () => { + expect(() => assertApprovalSignatureFormat(p256Approval(signer))).not.toThrow(); + }); + + it("accepts decimal bigint r and s values", () => { + expect(() => + assertApprovalSignatureFormat(p256Approval(signer, "123456789", "987654321")) + ).not.toThrow(); + }); + + it("rejects a plain string signature", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: ecdsaSig65Bytes() }) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects missing r field", () => { + expect(() => + assertApprovalSignatureFormat(forceApproval(signer, { s: "0x1" })) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects missing s field", () => { + expect(() => + assertApprovalSignatureFormat(forceApproval(signer, { r: "0x1" })) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects zero r value", () => { + expect(() => + assertApprovalSignatureFormat(p256Approval(signer, "0", "0x1")) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects negative s value", () => { + expect(() => + assertApprovalSignatureFormat(p256Approval(signer, "0x1", "-1")) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects non-numeric r value", () => { + expect(() => + assertApprovalSignatureFormat(p256Approval(signer, "not-a-number", "0x1")) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects empty r value", () => { + expect(() => + assertApprovalSignatureFormat(p256Approval(signer, "", "0x1")) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + }); + + // --------------------------------------------------------------------------- + // Passkey signer + // --------------------------------------------------------------------------- + + describe("P256 passkey signer", () => { + const signer = "passkey:credential-abc"; + + it("accepts a valid passkey approval with well-formed metadata", () => { + expect(() => assertApprovalSignatureFormat(passkeyApproval(signer))).not.toThrow(); + }); + + it("rejects invalid { r, s } (delegates to p256Validator)", () => { + expect(() => + assertApprovalSignatureFormat(passkeyApproval(signer, "not-a-number", "0x1")) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + + it("rejects passkey approval without metadata", () => { + expect(() => + assertApprovalSignatureFormat(p256Approval(signer)) + ).toThrow(expectedError(signer, "passkey metadata is required")); + }); + + it("rejects passkey approval with null metadata", () => { + expect(() => + assertApprovalSignatureFormat(forceApproval(signer, { r: "0x1a2b3c", s: "0x4d5e6f" }, { metadata: null })) + ).toThrow(expectedError(signer, "passkey metadata is required")); + }); + + it("rejects non-hex authenticatorData", () => { + expect(() => + assertApprovalSignatureFormat(passkeyApproval(signer, "0x1a2b3c", "0x4d5e6f", { + authenticatorData: "not-hex", + clientDataJSON: "{}", + challengeIndex: 0, + typeIndex: 0, + userVerificationRequired: true, + })) + ).toThrow(expectedError(signer, "metadata.authenticatorData must be a hex string")); + }); + + it("rejects empty clientDataJSON", () => { + expect(() => + assertApprovalSignatureFormat(passkeyApproval(signer, "0x1a2b3c", "0x4d5e6f", { + authenticatorData: "0xdeadbeef", + clientDataJSON: "", + challengeIndex: 0, + typeIndex: 0, + userVerificationRequired: true, + })) + ).toThrow(expectedError(signer, "metadata.clientDataJSON must be a non-empty string")); + }); + + it("rejects negative challengeIndex", () => { + expect(() => + assertApprovalSignatureFormat(passkeyApproval(signer, "0x1a2b3c", "0x4d5e6f", { + authenticatorData: "0xdeadbeef", + clientDataJSON: "{}", + challengeIndex: -1, + typeIndex: 0, + userVerificationRequired: true, + })) + ).toThrow(expectedError(signer, "metadata.challengeIndex must be a non-negative integer")); + }); + + it("rejects negative typeIndex", () => { + expect(() => + assertApprovalSignatureFormat(passkeyApproval(signer, "0x1a2b3c", "0x4d5e6f", { + authenticatorData: "0xdeadbeef", + clientDataJSON: "{}", + challengeIndex: 0, + typeIndex: -1, + userVerificationRequired: true, + })) + ).toThrow(expectedError(signer, "metadata.typeIndex must be a non-negative integer")); + }); + + it("rejects non-boolean userVerificationRequired", () => { + expect(() => + assertApprovalSignatureFormat(passkeyApproval(signer, "0x1a2b3c", "0x4d5e6f", { + authenticatorData: "0xdeadbeef", + clientDataJSON: "{}", + challengeIndex: 0, + typeIndex: 0, + userVerificationRequired: "yes", + })) + ).toThrow(expectedError(signer, "metadata.userVerificationRequired must be a boolean")); + }); + + it("rejects a plain string signature for passkey", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: ecdsaSig65Bytes() }) + ).toThrow(expectedError(signer, "Expected P256 signature { r, s } with positive integer within curve order")); + }); + }); + + describe("api-key signer", () => { + it("skips validation entirely", () => { + expect(() => + assertApprovalSignatureFormat({ signer: "api-key", signature: "anything" }) + ).not.toThrow(); + }); + }); + + describe("unknown signer types", () => { + it("passes through with a console warning", () => { + expect(() => + assertApprovalSignatureFormat({ signer: "future-signer:xyz", signature: "anything" }) + ).not.toThrow(); + }); + }); + + describe("cross-chain compatibility", () => { + it("accepts valid ECDSA for EVM external-wallet signer", () => { + expect(() => + assertApprovalSignatureFormat({ + signer: "external-wallet:0x1234567890123456789012345678901234567890", + signature: ecdsaSig65Bytes(), + }) + ).not.toThrow(); + }); + + it("accepts valid ECDSA for Solana external-wallet signer", () => { + expect(() => + assertApprovalSignatureFormat({ + signer: "external-wallet:9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + signature: ecdsaSig65Bytes(), + }) + ).not.toThrow(); + }); + + it("accepts valid ECDSA for Stellar external-wallet signer", () => { + expect(() => + assertApprovalSignatureFormat({ + signer: "external-wallet:GCKFBEIYTKP6RCZX6LRQW2JVAVLMGGVSNESWKN7L2YGQNI2DCOHVHJVY", + signature: ecdsaSig65Bytes(), + }) + ).not.toThrow(); + }); + }); + + describe("registerSignatureValidator", () => { + it("allows registering a custom validator for a new signer type", () => { + // Unique per run to avoid shared-state interference across test runs + const signerType = `test-custom-${Math.random().toString(36).slice(2)}`; + + const customValidator = { + validate: (approval: Approval) => { + if (typeof approval.signature !== "string" || !approval.signature.startsWith("custom:")) { + throw new InvalidSignatureForApprovalError("Custom validator: signature must start with 'custom:'"); + } + }, + }; + + registerSignatureValidator(signerType, customValidator); + + expect(() => + assertApprovalSignatureFormat({ signer: `${signerType}:abc`, signature: "custom:valid" }) + ).not.toThrow(); + + expect(() => + assertApprovalSignatureFormat({ signer: `${signerType}:abc`, signature: "invalid" }) + ).toThrow("Custom validator: signature must start with 'custom:'"); + }); + }); +}); \ No newline at end of file diff --git a/packages/wallets/src/utils/signature-validation.ts b/packages/wallets/src/utils/signature-validation.ts new file mode 100644 index 000000000..76b8c3e8f --- /dev/null +++ b/packages/wallets/src/utils/signature-validation.ts @@ -0,0 +1,161 @@ +import type { Approval } from "@/wallets/types"; +import type { BaseSignResult, DeviceSignResult, PasskeySignResult, SignerLocator } from "@/signers/types"; +import { parseSignerLocator } from "./signer-locator"; +import { InvalidSignatureForApprovalError } from "./errors"; +import { isHex, size, parseSignature } from "viem"; + +const ERC_6492_MAGIC_SUFFIX = "6492649264926492649264926492649264926492649264926492649264926492"; +const P256_ORDER = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"); + +interface SignatureValidator { + validate(approval: TApproval): void; +} + +type EcdsaApproval = Approval & { + signature: BaseSignResult["signature"]; +}; + +type P256Approval = Approval & { + signature: DeviceSignResult["signature"]; +}; + +type PasskeyApproval = P256Approval & { + metadata: PasskeySignResult["metadata"]; +}; + + +function isValidP256Component(value: string): boolean { + try { + const n = BigInt(value); + return n > BigInt(0) && n < P256_ORDER; + } catch { + return false; + } +} + + +function signerContext(signer: string): string { + return `signer "${signer}"`; +} + +function invalidSignature(signer: string, message: string, received?: unknown): never { + throw new InvalidSignatureForApprovalError( + `Invalid signature for ${signerContext(signer)}: ${message}`, + received !== undefined ? `Received: ${JSON.stringify(received)}` : undefined + ); +} + +const ecdsaValidator: SignatureValidator = { + validate(approval): void { + const { signature, signer } = approval; + + if (!isHex(signature)) { + invalidSignature(signer, "expected a hex string", signature); + } + + if (signature.endsWith(ERC_6492_MAGIC_SUFFIX)) { + invalidSignature(signer, "ERC-6492 wrapped signatures are not supported — provide a raw ECDSA signature"); + } + + const byteLength = size(signature); + if (byteLength !== 64 && byteLength !== 65) { + invalidSignature(signer, `expected ECDSA with 64 or 65 bytes`, `${byteLength} bytes`); + } + + if (byteLength === 65) { + try { + parseSignature(signature); + } catch { + invalidSignature(signer, "failed structural parse — not a valid ECDSA signature"); + } + } + }, +}; + +const p256Validator: SignatureValidator = { + validate(approval): void { + const { signature, signer } = approval; + const { r, s } = signature; + + if (!isValidP256Component(r) || !isValidP256Component(s)) { + invalidSignature(signer, "Expected P256 signature { r, s } with positive integer within curve order", { r, s }); + } + }, +}; + +const passkeyValidator: SignatureValidator = { + validate(approval): void { + p256Validator.validate(approval); + + const { metadata, signer } = approval; + + if (metadata == null) { + invalidSignature(signer, "passkey metadata is required", metadata); + } + + if (!isHex(metadata.authenticatorData)) { + invalidSignature(signer, "metadata.authenticatorData must be a hex string", metadata.authenticatorData); + } + + if (typeof metadata.clientDataJSON !== "string" || metadata.clientDataJSON.length === 0) { + invalidSignature(signer, "metadata.clientDataJSON must be a non-empty string", metadata.clientDataJSON); + } + + if (!Number.isInteger(metadata.challengeIndex) || metadata.challengeIndex < 0) { + invalidSignature(signer, "metadata.challengeIndex must be a non-negative integer", metadata.challengeIndex); + } + + if (!Number.isInteger(metadata.typeIndex) || metadata.typeIndex < 0) { + invalidSignature(signer, "metadata.typeIndex must be a non-negative integer", metadata.typeIndex); + } + + if (typeof metadata.userVerificationRequired !== "boolean") { + invalidSignature(signer, "metadata.userVerificationRequired must be a boolean", metadata.userVerificationRequired); + } + }, +}; + +const validatorRegistry = new Map([ + ["external-wallet", ecdsaValidator], + ["server", ecdsaValidator], + ["email", ecdsaValidator], + ["phone", ecdsaValidator], + ["passkey", passkeyValidator], + ["device", p256Validator], +]); + +/** + * Register a custom validator for a new signer type. + * Existing validators can be extended without modifying this module. + */ +export function registerSignatureValidator(signerType: string, validator: SignatureValidator): void { + validatorRegistry.set(signerType, validator); +} + +/** + * Validates that the externally-provided approval signature matches the + * expected format for the given signer type. + * + * Call this in the `options.approval` branch of `approveTransactionInternal` + * and `approveSignatureInternal` **before** submitting to the API. + * + * @throws {InvalidSignatureForApprovalError} when the signature is malformed, + * ERC-6492-wrapped, or otherwise incompatible with the signer type. + */ +export function assertApprovalSignatureFormat(approval: Approval): void { + + const { type: signerType } = parseSignerLocator(approval.signer as SignerLocator); + + if (signerType === "api-key") { + return; + } + + const validator = validatorRegistry.get(signerType); + if (validator == null) { + console.warn(`[assertApprovalSignatureFormat] No validator for signer type "${signerType}" — skipping validation`); + + return; + } + + validator.validate(approval); +} \ No newline at end of file From 7c9828c0f10e0d9b6e555e27940acfecb7181600 Mon Sep 17 00:00:00 2001 From: Daniil Dovgal Date: Wed, 20 May 2026 17:01:48 -0400 Subject: [PATCH 2/2] feat: errors, assertApprovalSignatureFormat check --- packages/wallets/src/utils/errors.ts | 9 ++++++++- packages/wallets/src/wallets/wallet.ts | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/wallets/src/utils/errors.ts b/packages/wallets/src/utils/errors.ts index bf5c28be5..3ce3c2ba9 100644 --- a/packages/wallets/src/utils/errors.ts +++ b/packages/wallets/src/utils/errors.ts @@ -168,6 +168,12 @@ export class InvalidAddressError extends CrossmintSDKError { } } +export class InvalidSignatureForApprovalError extends CrossmintSDKError { + constructor(message: string, details?: string) { + super(message, WalletErrorCode.SIGNING_FAILED, details); + } +} + export type WalletError = | InvalidTransferAmountError | InvalidApiKeyError @@ -196,4 +202,5 @@ export type WalletError = | TransactionHashNotFoundError | TransactionFailedError | PendingApprovalsError - | InvalidAddressError; + | InvalidAddressError + | InvalidSignatureForApprovalError; diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index abd8cdfa1..d56d35299 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -74,6 +74,7 @@ import { walletsLogger } from "../logger"; import { getSignerLocator } from "../utils/signer-locator"; import { createDeviceSigner } from "@/utils/device-signers"; import type { DeviceSignerKeyStorage } from "@/utils/device-signers/DeviceSignerKeyStorage"; +import { assertApprovalSignatureFormat } from "@/utils/signature-validation"; type WalletContructorType = { chain: C; @@ -1745,6 +1746,7 @@ export class Wallet { // If an external signature is provided, use it to approve the transaction if (options?.approval != null) { + assertApprovalSignatureFormat(options.approval); const approvals = [options.approval]; return await this.executeApproveSignatureWithErrorHandling(signatureId, approvals); @@ -1796,6 +1798,8 @@ export class Wallet { // If an external signature is provided, use it to approve the transaction if (options?.approval != null) { + assertApprovalSignatureFormat(options.approval); + const approvals = [options.approval]; return await this.executeApproveTransactionWithErrorHandling(transactionId, approvals);