Skip to content
ecdsa.com

Ed25519 sign & verify · Go

How to sign and verify Ed25519 signatures in Go

Go signs and verifies Ed25519 signatures with the standard library package crypto/ed25519, while crypto/x509 and encoding/pem load the keys. Unlike ECDSA in Go, ed25519.Sign takes the message itself rather than a digest, and the output is the deterministic 64-byte RFC 8032 signature.

Tested with Go 1.27.3 (crypto/ed25519, linux/arm64).

sign/main.go — load a PKCS#8 key and sign message.txt
package main
import (
"crypto/ed25519"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"log"
"os"
)
func main() {
pemBytes, err := os.ReadFile("key.pem")
if err != nil {
log.Fatal(err)
}
block, _ := pem.Decode(pemBytes) // "PRIVATE KEY" — PKCS#8
if block == nil {
log.Fatal("key.pem: no PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
log.Fatal(err)
}
privateKey, ok := parsed.(ed25519.PrivateKey) // a value type, not a pointer
if !ok {
log.Fatalf("expected an Ed25519 key, got %T", parsed)
}
message, err := os.ReadFile("message.txt")
if err != nil {
log.Fatal(err)
}
signature := ed25519.Sign(privateKey, message) // the message itself, not a digest
if err := os.WriteFile("sig.bin", signature, 0o644); err != nil {
log.Fatal(err)
}
fmt.Println("signature:", hex.EncodeToString(signature))
}
verify/main.go — load an SPKI public key and verify
package main
import (
"crypto/ed25519"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"os"
)
func main() {
pemBytes, err := os.ReadFile("pub.pem")
if err != nil {
log.Fatal(err)
}
block, _ := pem.Decode(pemBytes) // "PUBLIC KEY" — SPKI
if block == nil {
log.Fatal("pub.pem: no PEM block")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
log.Fatal(err)
}
publicKey, ok := parsed.(ed25519.PublicKey)
if !ok {
log.Fatalf("expected an Ed25519 key, got %T", parsed)
}
message, err := os.ReadFile("message.txt")
if err != nil {
log.Fatal(err)
}
signature, err := os.ReadFile("sig.bin")
if err != nil {
log.Fatal(err)
}
fmt.Println("valid:", ed25519.Verify(publicKey, message, signature))
fmt.Println("tampered message valid:", ed25519.Verify(publicKey, []byte("hello ed25520"), signature))
}

How it works

  1. x509.ParsePKCS8PrivateKey handles every PKCS#8 key type and returns any; for Ed25519 the concrete type is ed25519.PrivateKey, a 64-byte slice holding the seed followed by the public key.
  2. ed25519.Sign(privateKey, message) hashes internally with SHA-512 — do not hash the message first.
  3. x509.ParsePKIXPublicKey returns an ed25519.PublicKey (32 bytes) for a BEGIN PUBLIC KEY block, and ed25519.Verify reports the verdict as a bool.
  4. Both programs share one module and run with go run ./sign and go run ./verify; the signature matches every other recipe of this task.

Gotchas

  • The type assertion is to a value, not a pointer: parsed.(*ed25519.PrivateKey) fails, while ECDSA keys come back as *ecdsa.PrivateKey. Code ported from ECDSA trips over this first.
  • Through the generic crypto.Signer interface, passing crypto.SHA256 as options — as ECDSA code does — returns ed25519: expected opts.HashFunc() zero (unhashed message, for standard Ed25519) or SHA-512 (for Ed25519ph). Pass crypto.Hash(0) and the unhashed message.
  • Wrong key lengths panic instead of returning an error: ed25519.Verify with a 31-byte key panics with ed25519: bad public key length: 31. Check len(key) == ed25519.PublicKeySize when keys come from outside.
  • A 32-byte seed from another system is not an ed25519.PrivateKey; signing with it panics with ed25519: bad private key: ed25519: bad private key length: 32. Expand it with ed25519.NewKeyFromSeed(seed) — and privateKey.Seed() goes the other way.

Related recipes