ethers covers everything an application needs to talk to Ethereum — providers, contracts, ABI coding, ENS — and its Wallet and SigningKey classes are what most JavaScript code uses to sign. The curve arithmetic comes from @noble/curves; ethers 6.17.0 pins version 1.2.0 of it.
wallet.signMessage() implements personal_sign: it prefixes the message with \x19Ethereum Signed Message:\n and its length (EIP-191), hashes with keccak-256, and returns 65 bytes of r ‖ s ‖ v with v = 27 or 28. The check confirmed the digest against an independent keccak implementation and the signature against @noble/curves' RFC 6979 output, so the same key and message always give the same bytes. To sign a hash you already computed — an EIP-712 digest, a transaction hash — use SigningKey.sign(), which takes exactly 32 bytes and adds no prefix; anything else fails with invalid digest length.
On input ethers is tolerant about shape and strict about form. verifyMessage() recovered the signer from v = 27/28, from v = 0/1 and from the 64-byte EIP-2098 compact form, but refused the mirrored high-S signature with non-canonical s; use ._s — even though it is mathematically valid. Signatures produced by OpenSSL, cloud KMS or hardware modules that do not normalize s must be converted to low-S (s → n − s, flip v) before ethers accepts them; the Ethereum signature verifier shows which form a signature is in.
Most verification bugs in wallet logins are not about the curve at all: a message that differs by a newline or a trailing space, a different chain id in an EIP-712 domain, or a smart-contract wallet whose signatures need ERC-1271 instead of ecrecover. Inspect typed data with the EIP-712 inspector and full sign-in messages with the SIWE verifier.
The sample signs a sign-in message, recovers the address and shows the compact form; compare recovered addresses, never signature bytes, because the same signature has several valid encodings.