diff --git a/packages/siws/src/crypto/verify.ts b/packages/siws/src/crypto/verify.ts
index c023b25..24417ba 100644
--- a/packages/siws/src/crypto/verify.ts
+++ b/packages/siws/src/crypto/verify.ts
@@ -141,7 +141,9 @@ const verifySingle = (
* Verifies that `signature` over `message` was produced by `addressOrPublicKey`.
* Supports sr25519, ed25519 and ecdsa signatures (raw or type-prefixed), and retries
* with the message wrapped in / stripped of `...`, matching how wallet
- * extensions wrap `signRaw` payloads.
+ * extensions wrap `signRaw` payloads. Payloads over 256 bytes are also retried as
+ * their blake2b-256 hash, matching signers that follow the Substrate convention of
+ * hashing large payloads before signing (e.g. Ledger Polkadot Generic app).
*/
export function verifySignature(
message: string | Uint8Array,
@@ -157,16 +159,22 @@ export function verifySignature(
`Invalid signature length, expected [64..66] bytes, found ${signatureU8a.length}`,
)
- const first = verifySingle(messageU8a, signatureU8a, publicKey)
- if (first.isValid) return { ...first, publicKey }
+ const candidates: Uint8Array[] = [messageU8a]
// ethereum-prefixed messages are never -wrapped, no retry
- if (startsWith(messageU8a, ETHEREUM_PREFIX) && !isWrappedBytes(messageU8a))
- return { ...first, publicKey }
-
- const retryMessage = isWrappedBytes(messageU8a)
- ? messageU8a.subarray(WRAP_PREFIX.length, messageU8a.length - WRAP_POSTFIX.length)
- : concatBytes(WRAP_PREFIX, messageU8a, WRAP_POSTFIX)
+ if (!(startsWith(messageU8a, ETHEREUM_PREFIX) && !isWrappedBytes(messageU8a))) {
+ const toggled = isWrappedBytes(messageU8a)
+ ? messageU8a.subarray(WRAP_PREFIX.length, messageU8a.length - WRAP_POSTFIX.length)
+ : concatBytes(WRAP_PREFIX, messageU8a, WRAP_POSTFIX)
+ candidates.push(toggled)
+
+ for (const m of [messageU8a, toggled])
+ if (m.length > 256) candidates.push(blake2b(m, { dkLen: 32 }))
+ }
- return { ...verifySingle(retryMessage, signatureU8a, publicKey), publicKey }
+ for (const candidate of candidates) {
+ const result = verifySingle(candidate, signatureU8a, publicKey)
+ if (result.isValid) return { ...result, publicKey }
+ }
+ return { crypto: "none", isValid: false, publicKey }
}
diff --git a/packages/siws/test/vectors.ts b/packages/siws/test/vectors.ts
index 18df5b4..448e9bc 100644
--- a/packages/siws/test/vectors.ts
+++ b/packages/siws/test/vectors.ts
@@ -78,6 +78,30 @@ export const ECDSA_VECTOR: SchemeVector = {
"0x0255d39eedbecaea93f4abc7ac337d05f76e985c7a443e738c50d8eb28436d38bf095d01a5ea0f821981120d29f70fe306660b56d8de0c9872f9e47da47984af0b00",
}
+/**
+ * Captured from a real Ledger device (Polkadot Generic app) signing a Talisman
+ * `signRaw` request. The app follows the Substrate convention for payloads over
+ * 256 bytes: it signs blake2b-256(message), not the raw bytes.
+ * ed25519, signature is over the 32-byte hash of the 328-byte wrapped message.
+ */
+export const LEDGER_ED25519_HASHED_VECTOR = {
+ address: "13TtFyPPgw2ZU4TmH8bmR27Q1qTiT6XPTAprUbkgsJEWEjJx",
+ message: [
+ `siws.xyz wants you to sign in with your Polkadot account:`,
+ `13TtFyPPgw2ZU4TmH8bmR27Q1qTiT6XPTAprUbkgsJEWEjJx`,
+ ``,
+ `Welcome to SIWS! Sign in to see how it works.`,
+ ``,
+ `URI: https://siws.xyz`,
+ `Version: 1.0.0`,
+ `Nonce: 6fdc70db-e2b8-4c54-880b-91908df961e5`,
+ `Issued At: 2026-07-06T07:48:58.645Z`,
+ `Expiration Time: 2026-07-06T07:50:58.645Z`,
+ ].join("\n"),
+ signature:
+ "0x1d883bbf527b8483959e5a61effbee330ff85d820901ca0081a4d4876b9adbef9f27b90d5e01d1d96222d037fa9fa591b88f6eb3faac3186f53023a674ed6b0e",
+}
+
/** encodeAddress(publicKey, prefix) outputs, covering 1-byte and 2-byte ss58 prefixes */
export const SS58_VECTOR = {
publicKey: "0x289356c5b3ae788acb730b508830de4a297a3d4ac4519f6859c899b49673c67c",
diff --git a/packages/siws/test/verify.test.ts b/packages/siws/test/verify.test.ts
new file mode 100644
index 0000000..8aa4397
--- /dev/null
+++ b/packages/siws/test/verify.test.ts
@@ -0,0 +1,55 @@
+import { ed25519 } from "@noble/curves/ed25519.js"
+import { blake2b } from "@noble/hashes/blake2.js"
+import { concatBytes, utf8ToBytes } from "@noble/hashes/utils.js"
+import { u8aToHex } from "../src/crypto/bytes"
+import { verifySignature } from "../src/crypto/verify"
+import { LEDGER_ED25519_HASHED_VECTOR } from "./vectors"
+
+const wrapBytes = (message: string) =>
+ concatBytes(utf8ToBytes(""), utf8ToBytes(message), utf8ToBytes(""))
+
+describe("verifySignature", () => {
+ describe("blake2b-hashed payloads (Ledger Polkadot Generic app)", () => {
+ it("should verify a real Ledger signature over blake2b-256 of the wrapped message", () => {
+ const { message, signature, address } = LEDGER_ED25519_HASHED_VECTOR
+ const result = verifySignature(message, signature, address)
+ expect(result.isValid).toEqual(true)
+ expect(result.crypto).toEqual("ed25519")
+ })
+
+ it("should not verify the Ledger signature against a tampered message", () => {
+ const { message, signature, address } = LEDGER_ED25519_HASHED_VECTOR
+ const result = verifySignature(`${message} `, signature, address)
+ expect(result.isValid).toEqual(false)
+ })
+
+ it("should verify a hashed signature only when the payload exceeds 256 bytes", () => {
+ const privateKey = ed25519.utils.randomSecretKey()
+ const publicKey = ed25519.getPublicKey(privateKey)
+
+ // wrapped length > 256: signers may hash before signing, must verify
+ const longMessage = "a".repeat(300)
+ const longSignature = ed25519.sign(blake2b(wrapBytes(longMessage), { dkLen: 32 }), privateKey)
+ expect(verifySignature(longMessage, longSignature, publicKey).isValid).toEqual(true)
+
+ // wrapped length <= 256: hashing convention does not apply, must not verify
+ const shortMessage = "a".repeat(100)
+ const shortSignature = ed25519.sign(
+ blake2b(wrapBytes(shortMessage), { dkLen: 32 }),
+ privateKey,
+ )
+ expect(verifySignature(shortMessage, shortSignature, publicKey).isValid).toEqual(false)
+ })
+
+ it("should still verify unhashed signatures over long messages", () => {
+ const privateKey = ed25519.utils.randomSecretKey()
+ const publicKey = ed25519.getPublicKey(privateKey)
+
+ const longMessage = "a".repeat(300)
+ const signature = ed25519.sign(wrapBytes(longMessage), privateKey)
+ const result = verifySignature(longMessage, u8aToHex(signature), publicKey)
+ expect(result.isValid).toEqual(true)
+ expect(result.crypto).toEqual("ed25519")
+ })
+ })
+})