sr25519

Hex.pmDocsOpenSSF Scorecard

sr25519 (schnorrkel) signature verification for the BEAM.

sr25519 is Schnorr signing over the Ristretto255 group of Curve25519, with protocol-level domain separation via Merlin transcripts — the scheme defined by the w3f schnorrkel crate. This library is a thin, safety-critical Rustler NIF over that crate (independently audited upstream; the wrapper itself is human-reviewed, not independently audited — see SECURITY.md).

Motivation

The BEAM ecosystem has had no maintained sr25519 library, so Elixir services that need to check sr25519 signatures in-process — for example, signatures produced by Substrate/Polkadot or Bittensor tooling — had to shell out or trust a sidecar. This package provides exactly the verification primitive, hardened for server-side use.

Design

The library verifies exact bytes. It never decodes, normalizes, or canonicalizes input: no hex, base64, SS58, SCALE, JSON, UTF-8, or MultiSignature tag handling happens inside it. Those are caller responsibilities, so the crypto core can never verify "the wrong thing". Each real-world signing convention is a named, vector-backed function — never a flag or a hidden branch inside an ambiguous "verify".

All inputs are raw-byte binaries:

Install

def deps do
[{:sr25519, "~> 0.1"}]
end

Precompiled NIFs are downloaded and checked against a committed SHA256 checksum.

To compile from source instead (an unlisted target, or by choice), you need a Rust toolchain and the rustler package — it is an optional dependency of this library, so Hex does not fetch it for you:

def deps do
[
{:sr25519, "~> 0.1"},
{:rustler, "~> 0.38"} # only needed when force-building from source
]
end

Then set SR25519_FORCE_BUILD=1 for the compile (or use config :rustler_precompiled, :force_build, sr25519: true in your config).

Prove the install works (30 seconds)

Paste this into iex -S mix — it verifies a frozen known-answer vector from the independent @scure/sr25519 oracle (committed in this repo's vector corpus):

msg = "sr25519 known-answer anchor"
sig =
Base.decode16!(
"9a0d379ebe5a8158576e7064c01adcaf80f76cf26f4c74b10ee25fffe79bf657" <>
"91e1e9cf7b46ee152ca95bafde4c2d4a3128d67ad7738b40d21a098d09e5b88d",
case: :lower
)
pk = Base.decode16!("189dac29296d31814dc8c56cf3d36a0543372bba7538fa322a4aebfebc39e056", case: :lower)
{:ok, true} = Sr25519.Substrate.verify_raw_message(msg, sig, pk)
{:ok, false} = Sr25519.Substrate.verify_raw_message("tampered" <> msg, sig, pk)

Supported platforms

Precompiled artifacts (NIF version 2.15, so OTP ≥ 24; the package requires Elixir ~> 1.15) ship for:

LinuxmacOSWindows
x86_64 gnu + muslx86_64x86_64 msvc + gnu
aarch64 gnu + muslaarch64 (Apple Silicon)
arm gnueabihf, riscv64gc

Anything else (FreeBSD, other archs) works via the force-build path above.

API

# The "substrate" signing context, message signed as-is (no wrapping) — what
# `sign(bytes)` produces in substrate-interface, subkey, py-sr25519-bindings,
# and the keyrings derived from them.
Sr25519.Substrate.verify_raw_message(message, signature, public_key)
#=> {:ok, true} | {:ok, false} | {:error, reason}
# The polkadot-js extension / `signRaw` message-signing convention. Mirrors
# `u8aWrapBytes` exactly: wraps the message in <Bytes>…</Bytes> unless it is
# already wrapped or carries the Ethereum signed-message prefix (those are
# signed — and verified — as-is).
Sr25519.Substrate.verify_wrapped_bytes(message, signature, public_key)
# Low-level: you supply the signing context yourself.
Sr25519.verify_raw(message, signature, public_key, context)

Per-call work is bounded: messages are capped at Sr25519.max_message_bytes/0 (64 KiB) and contexts at Sr25519.max_context_bytes/0 (1 KiB); oversized input returns a typed error instead of absorbing unbounded bytes into the transcript.

Return contract

ReturnMeaning
{:ok, true}valid signature over the exact bytes
{:ok, false}32/64-byte inputs that parse but do not verify (incl. a structurally-invalid but length-correct signature — never raises)
{:error, :invalid_type}a non-binary argument
{:error, :invalid_length}public key ≠ 32 bytes, or signature ≠ 64 bytes
{:error, :message_too_large}message exceeds Sr25519.max_message_bytes/0
{:error, :context_too_large}signing context exceeds Sr25519.max_context_bytes/0
{:error, :invalid_public_key}public-key bytes schnorrkel rejects structurally

Both :error and {:ok, false} fail closed — the distinction is for metrics/alerting, not control flow.

Legacy-format note: signatures from pre-0.8 schnorrkel (missing the 0x80 "schnorrkel-marked" bit in byte 63) parse-fail and return {:ok, false}, never an error. The deprecated legacy encoding (preaudit_deprecated) is deliberately not enabled; all modern signers emit the marker.

Interop cheat-sheet

The library takes raw bytes only — here is how the common encodings map to them:

You haveYou needHow
0x-prefixed hex signature (polkadot-js signRaw result)64-byte binarystrip "0x", Base.decode16!(hex, case: :mixed)
65-byte MultiSignature blob (0x01 ‖ sig)64-byte binary<<0x01, signature::binary-size(64)>> = blob
SS58 address (e.g. 5FHneW…)32-byte public keydecode on the signer side (decodeAddress in polkadot-js, Keypair.public_key in substrate-interface) or use any Base58 lib: SS58 = prefix ‖ pubkey ‖ checksum. This library deliberately ships no codec.

polkadot-js dapp (signRaw)

// browser side
const { signature } = await signer.signRaw({ address, data: stringToHex(message), type: 'bytes' });
// send `signature` (0x-hex) and the address's raw public key (decodeAddress(address)) to your API
# BEAM side — signRaw wraps in <Bytes>…</Bytes>; this mirrors it exactly
sig = Base.decode16!(String.trim_leading(signature_hex, "0x"), case: :mixed)
Sr25519.Substrate.verify_wrapped_bytes(message, sig, public_key)

substrate-interface / subkey (sign over bytes)

# Keypair.sign(data) signs the bytes as-is under the "substrate" context
Sr25519.Substrate.verify_raw_message(message, signature, public_key)

If a protocol built on sr25519 signs a composite payload (a domain tag plus fields, a canonical serialization, …), reconstruct the exact signed byte string on your side and pass it to verify_raw_message/3 — constructing those bytes is deliberately outside this library.

Pitfall: extrinsic (transaction) signatures

Substrate signs the SCALE-encoded ExtrinsicPayload, and when that payload exceeds 256 bytes the signature is over its blake2_256 hash, not the payload itself. If you verify transaction signatures, reproduce that rule when constructing the bytes you pass in — otherwise long extrinsics return {:ok, false} with no other symptom.

Correctness & safety

Correctness is defined by real-world vectors, not prose. The vector corpus in test/vectors/ is generated from four oracles and frozen:

All four derive the same keypair from a shared seed, every production signer's signatures verify alongside @scure's over identical tuples (cross-oracle agreement), and the corpus carries known-answer anchors lifted verbatim from the published scure-sr25519 test suite — including the canonical polkadot-js Alice vector.

Safety properties are enforced, not assumed:

Run the whole ladder with one command:

mix conformance # L0–L7 + property & safety suites → conformance_report.json

Verifying release artifacts

Precompiled NIFs involve a supply chain; here is exactly what protects it and how to check it yourself.

The trust chain. Each release's NIF binaries are built by release.yml and signed with a GitHub build-provenance attestation. The Hex package embeds checksum-Elixir.Sr25519.Native.exs; at install time rustler_precompiled downloads the artifact for your platform and rejects it unless its SHA-256 matches that file. Before every publish, the release-verify.yml workflow independently re-checks the release: committed checksums must equal the release assets (both directions), every asset's attestation must verify against this repo's release workflow, and the real download-verify-load install path must work on Linux/macOS/Windows.

Check an artifact yourself (requires the GitHub CLI):

gh release download vX.Y.Z --repo VFe/sr25519 --pattern '*.tar.gz' --dir assets
gh attestation verify assets/<artifact>.tar.gz --repo VFe/sr25519 \
--signer-workflow VFe/sr25519/.github/workflows/release.yml

Residual trust. The checksum file binds your install to the attested bytes, and the attestation proves those bytes were built by this repository's release workflow at the tagged commit. What remains is the Hex tarball itself (hex.pm does not attest packages): it is published from the maintainer's machine, from the same commit release-verify validated. If you need stronger guarantees, build from source with SR25519_FORCE_BUILD=1 — the package ships the full Rust source and the exact Cargo.lock.

Versioning

schnorrkel is pinned exactly (=0.11.5); a change to its verification behavior is treated as breaking and versioned deliberately. See CHANGELOG.md and SECURITY.md.

Troubleshooting

License

Dual-licensed under MIT or Apache-2.0 at your option. Bundles the BSD-3-Clause schnorrkel crate — see NOTICE.