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") + }) + }) +})