package main import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "encoding/pem" "os") func main() { priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { panic(err) } privDer, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { panic(err) } pubDer, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) if err != nil { panic(err) } os.WriteFile("key.pem", pem.EncodeToMemory( &pem.Block{Type: "PRIVATE KEY", Bytes: privDer}), 0o600) os.WriteFile("pub.pem", pem.EncodeToMemory( &pem.Block{Type: "PUBLIC KEY", Bytes: pubDer}), 0o644)}How it works
elliptic.P256()(andP384(),P521()) are the NIST curves in the standard library; all use constant-time implementations.MarshalPKCS8PrivateKeyproduces the modern container;MarshalECPrivateKeyexists for the legacy SEC1 layout other tools may demand.- PEM block types matter:
PRIVATE KEYfor PKCS#8,EC PRIVATE KEYfor SEC1,PUBLIC KEYfor SPKI. Wrong label, failed parse elsewhere. 0o600on the private key file is not optional hygiene — ssh-style tooling actively rejects world-readable keys.
Gotchas
- secp256k1 is not in the Go standard library —
elliptic.P256()is NIST P-256. For Bitcoin/Ethereum work use a dedicated package (e.g. dcrd's secp256k1) rather than forcing stdlib types. - Mismatched marshal/parse pairs are a classic:
MarshalPKCS8PrivateKeyoutput must be read withParsePKCS8PrivateKey, SEC1 output withParseECPrivateKey. The PEM label tells you which you have. - Always
rand.Readerfor generation. Seeding anything deterministic "for tests" and letting it leak to production is how private keys end up recoverable.