skip to content

Signing through crypto.Signer with a SHA-256 digest works for ECDSA keys but fails for Ed25519. Why?

level: seniorimportance: nice to knowfreq 30%

answer

  1. one of the two algorithms hashes for you
  2. the parameter name is a convention
  3. what HashFunc must report for Ed25519
  4. zero means no pre-hashing
  5. the pre-hashed variant needs its own verifier

basics

~20 s

Ed25519 signs the whole message and hashes it internally, so ed25519.PrivateKey.Sign requires opts.HashFunc() to be crypto.Hash(0) and rejects a SHA-256 digest. ECDSA and RSA keys expect the opposite: a named hash and a digest of matching length.

solid answer

~40 s

`crypto.Signer.Sign`'s second parameter is named `digest`, but Ed25519 does not sign digests — it hashes the message internally, so `ed25519.Sign` takes the message. `ed25519.PrivateKey.Sign` therefore requires `opts.HashFunc()` to report `crypto.Hash(0)` and returns an error when handed `crypto.SHA256` with a 32-byte digest, while `*ecdsa.PrivateKey` and `*rsa.PrivateKey` require exactly the reverse. So a generic hash-then-sign helper breaks the day an Ed25519 key is wired in behind the same interface. The fixes, in order of preference: pass the message with `crypto.Hash(0)` for Ed25519 keys; or, if you genuinely cannot hold the message, use the pre-hashed Ed25519ph variant via `&ed25519.Options{Hash: crypto.SHA512}` over a 64-byte SHA-512 hash — but its signatures verify only with `ed25519.VerifyWithOptions`, never with plain `ed25519.Verify`, so both sides must agree.

code

go · 7 lines
go
sum := sha256.Sum256(msg)
sig, err := signer.Sign(rand.Reader, sum[:], crypto.SHA256)
// *ecdsa.PrivateKey: fine, sum is exactly the digest it wants.
// ed25519.PrivateKey: err is non-nil, because Ed25519 wants the
// message itself and crypto.Hash(0), not a SHA-256 digest.

sig, err = edPriv.Sign(rand.Reader, msg, crypto.Hash(0)) // correct for Ed25519

go deeper

for a junior

Know the one difference that explains it: Ed25519 takes the whole message because it hashes internally, while ECDSA and RSA take a digest you computed first.

for a middle

Explain that crypto.Signer's digest parameter is a naming convention rather than a contract, and that ed25519.PrivateKey.Sign checks opts.HashFunc() and demands crypto.Hash(0).

for a senior

Walk the diagnosis out loud: which key type changed, which call discarded an error, what the panic's stack trace in the service log points at. Then propose an API in which the mistake cannot be expressed.

for a principal

Decide whether the signing service is algorithm-agnostic at all, and price it: a message-based API buys you agility across algorithms but forces whole payloads through the service, while pinning one algorithm buys simplicity you cannot easily undo.

## The parameter name is a convention, not a contract `crypto.Signer` is one interface over algorithms that disagree about what they consume: ```go Sign(rand io.Reader, digest []byte, opts SignerOpts) (signature []byte, err error) ``` For `*ecdsa.PrivateKey` and `*rsa.PrivateKey`, `digest` is a digest: you hash the message, pass the bytes, and name the hash in `opts` so the implementation can encode or size-check it. For `ed25519.PrivateKey`, `digest` is the **message**. Ed25519 is specified to hash internally — it passes over the input twice as part of producing the signature — which is why the package-level `ed25519.Sign(priv, message)` takes no hash argument at all. The method enforces this. `ed25519.PrivateKey.Sign` inspects `opts.HashFunc()` and accepts two shapes only: - `crypto.Hash(0)` — no pre-hashing; the second argument is the whole message. This is plain Ed25519. - `crypto.SHA512` — the pre-hashed variant, Ed25519ph; the second argument must be a 64-byte SHA-512 hash. Anything else, `crypto.SHA256` included, comes back as an error. Nothing about your code is wrong except the assumption baked into the parameter name. ## How this reaches production The usual path is a helper everyone was happy with: ```go sum := sha256.Sum256(payload) sig, err := signer.Sign(rand.Reader, sum[:], crypto.SHA256) ``` That is correct for every key the service has ever been configured with, until someone provisions an Ed25519 key — often as part of moving keys into a token or a key service, since Ed25519 is the modern default. Now every request against that key fails. Two things make it worse than it needs to be: 1. **A dropped error.** If the helper is written as `sig, _ := signer.Sign(...)`, a `nil` signature travels onward and the failure surfaces far from its cause — as an empty signature field a consumer rejects, or as a panic when something dereferences or slices it. 2. **A fallback path calling the package function.** Code that reaches for `ed25519.Sign(key, digest)` directly panics instead of erroring if the key was rebuilt at the wrong length. The stack trace in the service log then shows a frame inside `crypto/ed25519` rather than in your own package, which is the tell: the key type or key material changed, your marshalling did not. So the diagnosis is: read the error the interface actually returned, and check which key type is configured for the failing signer. The moment you see one algorithm working and another failing at the same call site, look at `opts` and at what the second argument really holds. ## The fixes, and their costs **Pass the message.** The clean answer is to make the signing helper take the message and decide per algorithm, in one place: ```go switch signer.Public().(type) { case ed25519.PublicKey: return signer.Sign(rand.Reader, msg, crypto.Hash(0)) default: sum := sha256.Sum256(msg) return signer.Sign(rand.Reader, sum[:], crypto.SHA256) } ``` Cost: the whole message must be in memory, or streamable, at signing time. For an RPC that signs release artifacts, sending a megabyte manifest instead of a 32-byte digest may be fine; sending a gigabyte artifact is not. **Use Ed25519ph.** `&ed25519.Options{Hash: crypto.SHA512}` selects the pre-hashed variant, so a 64-byte SHA-512 hash is all that crosses the wire. `ed25519.Options` also has a `Context` string for the Ed25519ctx domain-separation variant. The trap is on the verifying side: Ed25519ph is a *different scheme*, not a shortcut to the same signature, so plain `ed25519.Verify` returns `false` and you must use `ed25519.VerifyWithOptions` with identical options. Every verifier you do not control has to support it, which for a public-facing signature format usually settles the argument against it. **Restrict the algorithm.** A perfectly good third answer for an internal service is to declare that it signs with one algorithm, assert the dynamic type of `Public()` once at construction, and fail loudly at startup rather than per request. Fewer supported algorithms means fewer of these mismatches, and the check costs one type assertion. ## Related traps in the same area - `rand` is ignored by Ed25519 entirely; passing `nil` there is legal, though `crypto/rand.Reader` is the habit worth keeping. - RSA reads `opts` even harder: an `*rsa.PSSOptions` selects PSS while a bare `crypto.Hash` selects PKCS number 1 v1.5, so the same digest and the same key produce different, non-interchangeable signatures depending on the options value. - `opts` must never be `nil` for any implementation — `HashFunc()` is called immediately.

  • How would you design the signing API so it works for both key types without special cases at every call site?
    Take the message rather than a digest at the boundary, and put the algorithm knowledge in one wrapper that inspects signer.Public() and either hashes and passes crypto.SHA256 or passes the message with crypto.Hash(0). Callers then cannot express the mistake. If holding whole messages is impossible, pick one algorithm and enforce it at construction instead.
  • The failure surfaces as a nil signature far from the signing call. How do you track it down?
    Look for a Sign call whose error was discarded; a nil signature then travels until something encodes or slices it. When it panics, the stack trace in the service log names the frame: crypto/ed25519 rather than your own package means the key type or key material is the variable that changed, not your marshalling.
  • Does an Ed25519ph signature interoperate with a verifier that calls plain ed25519.Verify?
    No. Ed25519ph is a distinct scheme that signs a SHA-512 pre-hash with its own domain separation, so ed25519.Verify returns false every time. Both sides must agree, and the verifier must call ed25519.VerifyWithOptions with the same ed25519.Options, including any Context string. For a format verified by parties you do not control, that alone usually rules it out.

saying these in an interview costs you the question

  • Says Ed25519 needs a SHA-256 digest like ECDSA does
  • Treats the digest parameter name as proof every scheme pre-hashes
  • Assumes Ed25519ph signatures verify with ed25519.Verify
  • Discards the error from Sign and passes a nil signature on
  • Hashes the message and passes crypto.Hash(0) together
  • Blames the key material when the error names the hash