From 9e25614ffed7df06e8cba6bea9be390afa33b671 Mon Sep 17 00:00:00 2001 From: "daniil.dovgal" Date: Wed, 20 May 2026 20:51:37 +0000 Subject: [PATCH 1/3] feat(wallets): add approval signature validation per signer type Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/wallets/src/utils/errors.ts | 9 +- .../src/utils/signature-validation.test.ts | 290 ++++++++++++++++++ .../wallets/src/utils/signature-validation.ts | 139 +++++++++ packages/wallets/src/wallets/wallet.ts | 7 +- 4 files changed, 442 insertions(+), 3 deletions(-) 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/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/utils/signature-validation.test.ts b/packages/wallets/src/utils/signature-validation.test.ts new file mode 100644 index 000000000..716d1e99c --- /dev/null +++ b/packages/wallets/src/utils/signature-validation.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertApprovalSignatureFormat, registerSignatureValidator } from "./signature-validation"; +import { InvalidSignatureForApprovalError } from "./errors"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ERC_6492_MAGIC_SUFFIX = "6492649264926492649264926492649264926492649264926492649264926492"; +const P256_ORDER = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"); + +function validEcdsaSig65(): string { + // 65 bytes = 130 hex chars, valid r/s/v structure + const r = "ab".repeat(32); + const s = "cd".repeat(32); + const v = "1b"; // recovery id 27 + return `0x${r}${s}${v}`; +} + +function validEcdsaSig64(): string { + // 64 bytes = 128 hex chars (compact signature without v) + const r = "ab".repeat(32); + const s = "cd".repeat(32); + return `0x${r}${s}`; +} + +function erc6492WrappedSig(): string { + return "0x" + "aa".repeat(20) + "bb".repeat(100) + "cc".repeat(65) + ERC_6492_MAGIC_SUFFIX; +} + +function validP256Approval(signer: string, r = "0x1a2b3c", s = "0x4d5e6f") { + return { signer, signature: { r, s } }; +} + +function validPasskeyApproval(signer: string) { + return { + signer, + signature: { r: "0x1a2b3c", s: "0x4d5e6f" }, + metadata: { + authenticatorData: "0xauthdata", + clientDataJSON: '{"type":"webauthn.get"}', + challengeIndex: 23, + typeIndex: 1, + userVerificationRequired: true, + }, + }; +} + +// --------------------------------------------------------------------------- +// ecdsaValidator +// --------------------------------------------------------------------------- + +describe("assertApprovalSignatureFormat", () => { + const ecdsaSignerLocators = [ + "external-wallet:0xAbC123", + "server:0xDef456", + "email:user@example.com", + "phone:+1234567890", + ]; + + describe("ecdsaValidator", () => { + describe.each(ecdsaSignerLocators)("signer %s", (signer) => { + it("accepts a valid 65-byte hex signature", () => { + expect(() => assertApprovalSignatureFormat({ signer, signature: validEcdsaSig65() })).not.toThrow(); + }); + + it("accepts a valid 64-byte hex signature", () => { + expect(() => assertApprovalSignatureFormat({ signer, signature: validEcdsaSig64() })).not.toThrow(); + }); + + it("rejects a non-hex string", () => { + expect(() => assertApprovalSignatureFormat({ signer, signature: "not-a-hex-string" })).toThrow( + InvalidSignatureForApprovalError + ); + }); + + it("rejects a signature with ERC-6492 suffix", () => { + expect(() => assertApprovalSignatureFormat({ signer, signature: erc6492WrappedSig() })).toThrow( + InvalidSignatureForApprovalError + ); + + expect(() => assertApprovalSignatureFormat({ signer, signature: erc6492WrappedSig() })).toThrow( + /ERC-6492/ + ); + }); + + it("rejects wrong byte length (e.g. 32 bytes)", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: ("0x" + "ab".repeat(32)) as `0x${string}` }) + ).toThrow(InvalidSignatureForApprovalError); + + expect(() => + assertApprovalSignatureFormat({ signer, signature: ("0x" + "ab".repeat(32)) as `0x${string}` }) + ).toThrow(/32 bytes/); + }); + + it("rejects structurally invalid sig that passes length check", () => { + // 65 bytes but all zeros — parseSignature will fail on invalid r/s + const invalidSig = ("0x" + "00".repeat(65)) as `0x${string}`; + expect(() => assertApprovalSignatureFormat({ signer, signature: invalidSig })).toThrow( + InvalidSignatureForApprovalError + ); + + expect(() => assertApprovalSignatureFormat({ signer, signature: invalidSig })).toThrow( + /not a valid ECDSA signature/ + ); + }); + + it("rejects a structured { r, s } object when string is expected", () => { + expect(() => + assertApprovalSignatureFormat({ signer, signature: { r: "0x1", s: "0x2" } } as any) + ).toThrow(InvalidSignatureForApprovalError); + }); + }); + }); + + // --------------------------------------------------------------------------- + // p256Validator + // --------------------------------------------------------------------------- + + describe("p256Validator", () => { + const signer = "device:testkey123"; + + it("accepts valid { r, s } within curve order", () => { + expect(() => assertApprovalSignatureFormat(validP256Approval(signer))).not.toThrow(); + }); + + it("accepts r and s just below P256_ORDER", () => { + const justBelowOrder = "0x" + (P256_ORDER - 1n).toString(16); + expect(() => + assertApprovalSignatureFormat(validP256Approval(signer, justBelowOrder, justBelowOrder)) + ).not.toThrow(); + }); + + it("rejects r = 0", () => { + expect(() => assertApprovalSignatureFormat(validP256Approval(signer, "0x0", "0x1"))).toThrow( + InvalidSignatureForApprovalError + ); + expect(() => assertApprovalSignatureFormat(validP256Approval(signer, "0x0", "0x1"))).toThrow( + /positive integer values/ + ); + }); + + it("rejects s = 0", () => { + expect(() => assertApprovalSignatureFormat(validP256Approval(signer, "0x1", "0x0"))).toThrow( + InvalidSignatureForApprovalError + ); + }); + + it("rejects r >= P256_ORDER", () => { + const atOrder = "0x" + P256_ORDER.toString(16); + expect(() => assertApprovalSignatureFormat(validP256Approval(signer, atOrder, "0x1"))).toThrow( + InvalidSignatureForApprovalError + ); + }); + + it("rejects s >= P256_ORDER", () => { + const aboveOrder = "0x" + (P256_ORDER + 1n).toString(16); + expect(() => assertApprovalSignatureFormat(validP256Approval(signer, "0x1", aboveOrder))).toThrow( + InvalidSignatureForApprovalError + ); + }); + }); + + // --------------------------------------------------------------------------- + // passkeyValidator + // --------------------------------------------------------------------------- + + describe("passkeyValidator", () => { + const signer = "passkey:credential-abc"; + + it("accepts valid { r, s } + well-formed metadata", () => { + expect(() => assertApprovalSignatureFormat(validPasskeyApproval(signer))).not.toThrow(); + }); + + it("rejects valid { r, s } + null metadata", () => { + expect(() => + assertApprovalSignatureFormat({ + ...validP256Approval(signer), + metadata: null, + } as any) + ).toThrow(InvalidSignatureForApprovalError); + }); + + it("rejects valid { r, s } + missing metadata", () => { + expect(() => assertApprovalSignatureFormat(validP256Approval(signer) as any)).toThrow( + InvalidSignatureForApprovalError + ); + }); + + it("p256 check fires first when { r, s } is invalid even with valid metadata", () => { + expect(() => + assertApprovalSignatureFormat({ + signer, + signature: { r: "0x0", s: "0x0" }, + metadata: validPasskeyApproval(signer).metadata, + }) + ).toThrow(/positive integer values/); + }); + }); + + // --------------------------------------------------------------------------- + // api-key signer (bypass all validation) + // --------------------------------------------------------------------------- + + describe("api-key signer", () => { + it("skips validation for api-key signer", () => { + expect(() => assertApprovalSignatureFormat({ signer: "api-key", signature: "anything" })).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Unknown signer types (warn + skip) + // --------------------------------------------------------------------------- + + describe("unknown signer types", () => { + it("logs a warning and skips for unrecognized signer types", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(() => + assertApprovalSignatureFormat({ signer: "future-signer:xyz", signature: "anything" }) + ).not.toThrow(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No validator for signer type")); + + warnSpy.mockRestore(); + }); + }); + + // --------------------------------------------------------------------------- + // Cross-chain compatibility + // --------------------------------------------------------------------------- + + describe("cross-chain compatibility", () => { + it("accepts valid ECDSA for EVM external-wallet signer", () => { + expect(() => + assertApprovalSignatureFormat({ + signer: "external-wallet:0x1234567890123456789012345678901234567890", + signature: validEcdsaSig65(), + }) + ).not.toThrow(); + }); + + it("accepts valid ECDSA for Solana external-wallet signer", () => { + expect(() => + assertApprovalSignatureFormat({ + signer: "external-wallet:9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + signature: validEcdsaSig65(), + }) + ).not.toThrow(); + }); + + it("accepts valid ECDSA for Stellar external-wallet signer", () => { + expect(() => + assertApprovalSignatureFormat({ + signer: "external-wallet:GCKFBEIYTKP6RCZX6LRQW2JVAVLMGGVSNESWKN7L2YGQNI2DCOHVHJVY", + signature: validEcdsaSig65(), + }) + ).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // Extensibility (registerSignatureValidator) + // --------------------------------------------------------------------------- + + describe("registerSignatureValidator", () => { + it("allows registering a custom validator for a new signer type", () => { + const customValidator = { + validate: (approval: { signer: string; signature: unknown }) => { + if (typeof approval.signature !== "string" || !approval.signature.startsWith("custom:")) { + throw new InvalidSignatureForApprovalError( + "Custom validator: signature must start with 'custom:'" + ); + } + }, + }; + + registerSignatureValidator("my-custom-signer", customValidator); + + expect(() => + assertApprovalSignatureFormat({ signer: "my-custom-signer:abc", signature: "custom:valid" }) + ).not.toThrow(); + + expect(() => + assertApprovalSignatureFormat({ signer: "my-custom-signer:abc", signature: "invalid" }) + ).toThrow(InvalidSignatureForApprovalError); + }); + }); +}); diff --git a/packages/wallets/src/utils/signature-validation.ts b/packages/wallets/src/utils/signature-validation.ts new file mode 100644 index 000000000..58c1485d3 --- /dev/null +++ b/packages/wallets/src/utils/signature-validation.ts @@ -0,0 +1,139 @@ +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 > 0n && n < P256_ORDER; + } catch { + return false; + } +} + +const ecdsaValidator: SignatureValidator = { + validate(approval): void { + const { signature, signer } = approval; + + if (!isHex(signature)) { + throw new InvalidSignatureForApprovalError( + `Expected ECDSA hex signature for signer "${signer}", received: ${typeof signature}` + ); + } + + if (signature.endsWith(ERC_6492_MAGIC_SUFFIX)) { + throw new InvalidSignatureForApprovalError( + `ERC-6492 wrapped signatures are not supported for signer "${signer}"` + ); + } + + const byteLength = size(signature); + if (byteLength !== 64 && byteLength !== 65) { + throw new InvalidSignatureForApprovalError( + `Expected 64 or 65-byte ECDSA signature for signer "${signer}", received: ${byteLength} bytes` + ); + } + + if (byteLength === 65) { + try { + parseSignature(signature); + } catch { + throw new InvalidSignatureForApprovalError( + `Signature for signer "${signer}" is not a valid ECDSA signature` + ); + } + } + }, +}; + +const p256Validator: SignatureValidator = { + validate(approval): void { + const { signature, signer } = approval; + const { r, s } = signature; + + if (!isValidP256Component(r) || !isValidP256Component(s)) { + throw new InvalidSignatureForApprovalError( + `Expected P256 signature { r, s } with positive integer values for signer "${signer}", ` + + `received: ${JSON.stringify(signature)}` + ); + } + }, +}; + +const passkeyValidator: SignatureValidator = { + validate(approval): void { + p256Validator.validate(approval); + + if (approval.metadata == null) { + throw new InvalidSignatureForApprovalError( + `Expected passkey metadata for signer "${approval.signer}", received: none` + ); + } + }, +}; + +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); +} diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index abd8cdfa1..fb8935a37 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -52,6 +52,7 @@ import { WalletTypeNotSupportedError, } from "../utils/errors"; import { STATUS_POLLING_INTERVAL_MS } from "../utils/constants"; +import { assertApprovalSignatureFormat } from "../utils/signature-validation"; import { validateChainForEnvironment, type Chain } from "../chains/chains"; import type { DeviceSignerConfig, @@ -1743,8 +1744,9 @@ export class Wallet { return signature; } - // If an external signature is provided, use it to approve the transaction + // If an external signature is provided, validate and use it to approve the signature if (options?.approval != null) { + assertApprovalSignatureFormat(options.approval); const approvals = [options.approval]; return await this.executeApproveSignatureWithErrorHandling(signatureId, approvals); @@ -1794,8 +1796,9 @@ export class Wallet { return transaction; } - // If an external signature is provided, use it to approve the transaction + // If an external signature is provided, validate and use it to approve the transaction if (options?.approval != null) { + assertApprovalSignatureFormat(options.approval); const approvals = [options.approval]; return await this.executeApproveTransactionWithErrorHandling(transactionId, approvals); From 850e9216106aa4a4be488d7811e6a91b10d4196b Mon Sep 17 00:00:00 2001 From: "daniil.dovgal" Date: Wed, 20 May 2026 20:51:53 +0000 Subject: [PATCH 2/3] chore: add changeset for approval signature validation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/approval-signature-validation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/approval-signature-validation.md diff --git a/.changeset/approval-signature-validation.md b/.changeset/approval-signature-validation.md new file mode 100644 index 000000000..b10746d18 --- /dev/null +++ b/.changeset/approval-signature-validation.md @@ -0,0 +1,5 @@ +--- +"@crossmint/wallets-sdk": patch +--- + +Add SDK-side approval signature validation in `wallet.approve()`. Validates signature format per signer type before submitting to the API: ECDSA hex format and byte length for EVM signers, P256 curve order bounds for device signers, and metadata presence for passkey signers. Rejects ERC-6492-wrapped signatures with a clear error message. From 2b6f3efcdb162007b0d518e51dcac46acb23d09a Mon Sep 17 00:00:00 2001 From: "daniil.dovgal" Date: Wed, 20 May 2026 20:54:11 +0000 Subject: [PATCH 3/3] fix(wallets): use BigInt() instead of 0n literal for es2015 target Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/wallets/src/utils/signature-validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallets/src/utils/signature-validation.ts b/packages/wallets/src/utils/signature-validation.ts index 58c1485d3..41f6f5120 100644 --- a/packages/wallets/src/utils/signature-validation.ts +++ b/packages/wallets/src/utils/signature-validation.ts @@ -26,7 +26,7 @@ type PasskeyApproval = P256Approval & { function isValidP256Component(value: string): boolean { try { const n = BigInt(value); - return n > 0n && n < P256_ORDER; + return n > BigInt(0) && n < P256_ORDER; } catch { return false; }