Blog / Cryptography

  • go
  • crypto-ecdh
  • x25519
  • diffie-hellman
  • elliptic-curve
  • cryptography

Diffie-Hellman in Go Without Touching Raw Curve Points

Before Go 1.20, elliptic curve Diffie-Hellman in the standard library handed you raw curve points and trusted you to check them. You reached into crypto/elliptic, called elliptic.P256().ScalarMult(x, y, scalar), and got back two *big.Int values. It was entirely up to you to have checked beforehand that (x, y) was actually a valid point on the curve.

Feed it something that isn't, and depending on the curve and the bug, you can end up:

  • leaking bits of your private scalar; or
  • landing in a small subgroup where the "shared secret" only has a handful of possible values.

That's not a hypothetical. Invalid curve point attacks and small subgroup attacks are well documented failure modes for exactly this kind of raw API, and the fix has always been "validate the point before you multiply it". Go's answer, eventually, was to stop trusting callers to remember that and put the check inside the type itself.

crypto/ecdh: one interface, no raw points

crypto/ecdh gives you two curve families behind one interface: the NIST curves (P-256, P-384, P-521) and X25519. The workflow is three steps:

  1. Get a Curve value.
  2. Generate a key pair on it.
  3. Call ECDH to produce a shared secret.

There is no scalar multiplication method exposed anywhere, and no way to construct a public key that hasn't already been validated.

package main

import (
	"crypto/ecdh"
	"crypto/rand"
	"fmt"
	"log"
)

func main() {
	curve := ecdh.X25519()

	alicePriv, err := curve.GenerateKey(rand.Reader)
	if err != nil {
		log.Fatal(err)
	}
	bobPriv, err := curve.GenerateKey(rand.Reader)
	if err != nil {
		log.Fatal(err)
	}

	// Each side only ever sends its public key over the wire.
	aliceShared, err := alicePriv.ECDH(bobPriv.PublicKey())
	if err != nil {
		log.Fatal(err)
	}
	bobShared, err := bobPriv.ECDH(alicePriv.PublicKey())
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(aliceShared) == string(bobShared)) // true
}

Validation happens when the public key is born

PrivateKey and PublicKey are opaque types wrapping a curve reference and the raw bytes. The only way to get a PublicKey from bytes you received over the network is curve.NewPublicKey(data), and that call is where the validation happens:

  • On the NIST curves it checks the point is on the curve and rejects the point at infinity.
  • On X25519 it enforces the 32-byte length and rejects the all-zero output that results from a low-order input point. That is the one attack RFC 7748 explicitly calls out as the implementer's responsibility.

If validation fails, NewPublicKey returns an error instead of a struct you can accidentally multiply against. That's the actual design win. The maths didn't change; there is simply no code path where an unchecked point reaches the multiplication step, because the type doesn't exist until the check has passed.

Quick detour, because I like this as a general trick: it is the "parse, don't validate" idea applied to cryptography. Once a value of the type exists, its validity is no longer something anyone has to remember.

What it deliberately doesn't do

No signing

This package is Diffie-Hellman only. There's no signing here: that's still crypto/ecdsa for the NIST curves and crypto/ed25519 for EdDSA. They're separate key types for good reason. Using the same key pair for both signing and key exchange on the same curve is a classic way to open up cross-protocol attacks. If you need both, generate two independent key pairs.

No key derivation

The output of ECDH is raw shared secret material, not a key. Feeding it directly into AES-GCM or HMAC is a mistake for the same reason it always was: the shared point isn't uniformly random the way a proper key needs to be. Run it through HKDF first, which I've covered separately.

import "golang.org/x/crypto/hkdf"

func deriveKey(shared []byte, salt, info []byte) ([]byte, error) {
	kdf := hkdf.New(sha256.New, shared, salt, info)
	key := make([]byte, 32)
	if _, err := io.ReadFull(kdf, key); err != nil {
		return nil, err
	}
	return key, nil
}

Compare keys with Equal, not ==

One thing that catches people out coming from the old API: PublicKey and PrivateKey are not comparable with ==, because they hold slices internally. If you need to check two public keys match (say, verifying a pinned peer identity), use Equal:

if !receivedKey.Equal(pinnedKey) {
	return errors.New("unexpected peer public key")
}

This is the same pattern crypto/x509 and other Go crypto types use, so it should feel familiar if you've worked with certificates.

When to reach for it instead of x/crypto

You'll rarely construct these directly for TLS, since crypto/tls handles the handshake itself. But it is now the correct tool, rather than reaching for golang.org/x/crypto/curve25519 directly, if you're building:

  • a Noise-style protocol;
  • a custom handshake;
  • anything doing X25519 key agreement outside of TLS.

The x/crypto package still exists, and crypto/ecdh uses compatible wire formats (32-byte X25519 keys, uncompressed point encoding for the NIST curves). So migrating existing wire data across doesn't require a format change, just swapping which package does the multiplication and validation.

Old ScalarMult calls are worth flagging in an audit

The elliptic.Curve interface itself isn't going anywhere; it's used elsewhere in the standard library and by external packages. But its ScalarMult and ScalarBaseMult methods are documented as deprecated specifically in favour of this package for anything doing key agreement.

If you're auditing a codebase for old-style ECDH and see raw big.Int coordinates being passed to ScalarMult, that's worth flagging even if nothing is obviously broken yet. The validation gap is still there, it's just not been hit.