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_secp256k1wraps the upstream libsecp256k1 C library. Use Erlang's:cryptomodule 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:cryptomodule. 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:cryptomodule also provides generic ECDSA through:crypto.sign/4and:crypto.verify/5. Use that API when you want digest selection through the standard Erlang/OpenSSL interface.Use
Secp256k1.ecdsa_sign/2andSecp256k1.ecdsa_valid?/3when 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 || ssignatures 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)