Powered by AppSignal & Oban Pro

Usage Guide

docs/usage.livemd

Usage Guide

# Use the local checkout when the notebook runs from this repo, Hex otherwise
lib_secp256k1 =
  if File.exists?(Path.expand("../mix.exs", __DIR__)) do
    {:lib_secp256k1, path: Path.expand("..", __DIR__)}
  else
    {:lib_secp256k1, "~> 0.8"}
  end

Mix.install([lib_secp256k1])

Introduction

This guide covers the basic usage of the lib_secp256k1 library for Elixir. See the README for installation instructions; the setup cell above installs the library for this notebook.

Scope: lib_secp256k1 wraps the upstream libsecp256k1 C library. Use Erlang's :crypto module for generic hashing, random bytes, generic ECDSA, and raw ECDH. Use this library when you need libsecp256k1-specific key formats, signatures, Schnorr, MuSig2, or the libsecp256k1 ECDH output contract.

Keys and signatures in this library are raw binaries. There is no single official string encoding, but lowercase hex is the de-facto convention for raw key and signature bytes — BIP test vectors and Bitcoin Core RPC use it, and Nostr uses it as its canonical key format. Protocols layer their own encodings on top: WIF (Base58Check) encodes Bitcoin private keys, Nostr's bech32 npub/nsec encode public/secret keys, and bc1p bech32m strings are addresses derived from keys. Base64 works for arbitrary bytes but is not conventional in this ecosystem. The helper below is used throughout this notebook to display binaries; parse incoming hex with Base.decode16!(string, case: :lower).

hex = &Base.encode16(&1, case: :lower)

Keypair Generation

The library allows generating secure random secret keys and deriving public keys in various formats.

Generating a Random Keypair

You can generate a new random keypair with a specified public key format (:compressed, :uncompressed, or :xonly).

# Generate a keypair with a compressed public key (33 bytes)
{seckey, pubkey} = Secp256k1.keypair(:compressed)

%{seckey: hex.(seckey), pubkey: hex.(pubkey)}
# Generate a keypair with an uncompressed public key (65 bytes)
{_seckey, pubkey_uncompressed} = Secp256k1.keypair(:uncompressed)

hex.(pubkey_uncompressed)
# Generate a keypair with an x-only public key (32 bytes, used for Schnorr/Taproot)
{_seckey, pubkey_xonly} = Secp256k1.keypair(:xonly)

hex.(pubkey_xonly)

Deriving a Public Key

If you already have a secret key (32 bytes), you can derive the public key from it.

# Derive the compressed public key, matching the one generated above
Secp256k1.pubkey(seckey, :compressed) == pubkey
# Derive x-only public key
xonly_pubkey = Secp256k1.pubkey(seckey, :xonly)

hex.(xonly_pubkey)

If you receive a compressed public key but do not own its secret key, convert it directly to the 32-byte x-only format:

Secp256k1.convert_pubkey(pubkey, :xonly) == xonly_pubkey

The conversion removes the compressed key's parity prefix and does not require secret-key material.

Key Tweaks

Full-key tweaks support BIP-32-style private and public derivation. Both operations add the same scalar, so deriving a public key from the tweaked secret key produces the same point as tweaking the original public key.

# Raw arithmetic example only. Derive this scalar according to BIP-32 or BIP-341.
tweak = <<1::256>>
compressed_pubkey = Secp256k1.pubkey(seckey, :compressed)

tweaked_seckey = Secp256k1.ec_seckey_tweak_add(seckey, tweak)
tweaked_pubkey = Secp256k1.ec_pubkey_tweak_add(compressed_pubkey, tweak)

Secp256k1.pubkey(tweaked_seckey, :compressed) == tweaked_pubkey

For Taproot, tweak the even-Y x-only internal key. Public tweaking returns the output key and its full-point parity. Secret tweaking performs the same even-Y normalization, so the resulting secret key signs for that output key.

internal_pubkey = Secp256k1.pubkey(seckey, :xonly)
{:ok, output_pubkey, parity} = Secp256k1.xonly_pubkey_tweak_add(internal_pubkey, tweak)

true =
  Secp256k1.xonly_pubkey_tweak_add_check(output_pubkey, parity, internal_pubkey, tweak)

output_seckey = Secp256k1.xonly_seckey_tweak_add(seckey, tweak)
Secp256k1.pubkey(output_seckey, :xonly) == output_pubkey

All tweaks are 32-byte big-endian scalars. Zero is valid; values at or above the curve order and tweaks that produce an invalid key return {:error, reason}. These functions perform key arithmetic only. Applications must derive child-key tweaks according to BIP-32 or commitment tweaks according to BIP-341.

ECDSA Signatures

ECDSA is the traditional signature scheme used in Bitcoin and other cryptocurrencies.

Signing a Message

To sign a message, you first need to hash it (typically using SHA-256).

Hashing belongs to :crypto: hashing is intentionally done with Erlang's built-in :crypto module. This library does not reimplement generic hashing APIs.

# 1. Prepare the message hash
message = "Hello, World!"
msg_hash = :crypto.hash(:sha256, message)

# 2. Sign the hash with your secret key
# Returns a 64-byte compact signature
signature = Secp256k1.ecdsa_sign(msg_hash, seckey)

hex.(signature)

Verifying a Signature

To verify a signature, you need the signature, the message hash, and the public key.

# Verify the signature
Secp256k1.ecdsa_valid?(signature, msg_hash, pubkey)

DER Wire Signatures and Low-S Normalization

The signing and verification APIs use compact 64-byte r || s signatures. Convert strict DER signatures at protocol boundaries:

der_signature = Secp256k1.ecdsa_signature_serialize_der(signature)
compact_signature = Secp256k1.ecdsa_signature_parse_der(der_signature)

hex.(der_signature)

Bitcoin transaction signatures append a sighash byte after the DER signature. Remove that byte before parsing and append the required value after serialization.

libsecp256k1 verification rejects high-S signatures. Protocols requiring canonical low-S must keep that rejection. Normalize only when the surrounding protocol deliberately accepts the mathematically equivalent high-S form:

compact_signature = Secp256k1.ecdsa_signature_normalize(compact_signature)
Secp256k1.ecdsa_valid?(compact_signature, msg_hash, pubkey)

Normalization accepts a malleable alternate signature. Use the normalized bytes for all subsequent identity, hashing, storage, and transmission operations.

ECDSA and :crypto: Erlang's :crypto module also provides generic ECDSA through :crypto.sign/4 and :crypto.verify/5. Use that API when you want digest selection through the standard Erlang/OpenSSL interface.

Use Secp256k1.ecdsa_sign/2 and Secp256k1.ecdsa_valid?/3 when you want the libsecp256k1/Bitcoin-oriented contract:

  • sign an already prepared 32-byte message hash
  • verify with compressed or uncompressed secp256k1 public keys
  • exchange compact r || s signatures internally and strict DER at wire boundaries

Schnorr Signatures

Schnorr signatures (BIP-340) are simpler and more efficient than ECDSA. They use x-only public keys.

Signing a Message

Schnorr signatures can sign a 32-byte hash or an arbitrary length message.

# Signing a hash (recommended for Bitcoin)
msg_hash = :crypto.hash(:sha256, "Hello Schnorr")
signature = Secp256k1.schnorr_sign(msg_hash, seckey)

hex.(signature)

Verifying a Signature

Verification requires the signature, the original message (or hash), and the x-only public key.

# Derive x-only pubkey if you haven't already
xonly_pubkey = Secp256k1.pubkey(seckey, :xonly)

# Verify
Secp256k1.schnorr_valid?(signature, msg_hash, xonly_pubkey)

ECDH Shared Secrets

Secp256k1.ecdh/2 computes libsecp256k1's default hashed ECDH shared secret. It accepts a 32-byte secret key and a compressed or uncompressed secp256k1 public key, and returns a 32-byte binary.

{alice_seckey, _alice_pubkey} = Secp256k1.keypair(:compressed)
{_bob_seckey, bob_pubkey} = Secp256k1.keypair(:compressed)

shared_secret = Secp256k1.ecdh(alice_seckey, bob_pubkey)

hex.(shared_secret)

ECDH and :crypto: this is not raw generic ECDH. It wraps the upstream C library behavior, currently SHA256 over the compressed shared point. This output intentionally differs from Erlang/OpenSSL raw ECDH. If you need raw ECDH instead, call:

:crypto.compute_key(:ecdh, bob_pubkey, alice_seckey, :secp256k1)