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).
- v0.1: verification only. No secret-key material surface at all. Signing, keypair generation, and derivation are planned for v0.2+.
- Precompiled by default — no Rust toolchain required to use it.
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:
message— the exact bytes that were signedsignature— the bare 64-byte signature (strip any encoding or envelope first)public_key— the raw 32-byte public key (a compressed Ristretto point)context— at the low level, schnorrkel's transcript-level domain-separation label (Substrate pins the 9 ASCII bytes"substrate")
Install
def deps do
[{:sr25519, "~> 0.1"}]
endPrecompiled 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
]
endThen 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:
| Linux | macOS | Windows |
|---|---|---|
| x86_64 gnu + musl | x86_64 | x86_64 msvc + gnu |
| aarch64 gnu + musl | aarch64 (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
| Return | Meaning |
|---|---|
{: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 have | You need | How |
|---|---|---|
0x-prefixed hex signature (polkadot-js signRaw result) | 64-byte binary | strip "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 key | decode 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:
substrate-interface(Python) — the widely deployed production signer.@polkadot/util-crypto(polkadot-js wasm-crypto + the exactu8aWrapBytessignRawflow) — where most real-world dapp signatures come from.@scure/sr25519(pure-JS noble, independently audited) — the one oracle of genuinely independent lineage: the other three all descend from the w3fschnorrkelcode, so only @scure proves the convention is right rather than merely self-consistent.- the
schnorrkelcrate (Rust) — confirms the wrapper behaves as the crate it wraps.
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:
#![forbid(unsafe_code)]in the Rust core.panic = "unwind"in the release profile, so a NIF panic cannot abort the BEAM VM — proven by a deliberate-panic test run in a separate OS process, and guarded by a CI check that rejectspanic = "abort".- Verification runs on a dirty CPU scheduler, so even cap-sized messages on
slow targets cannot starve the BEAM's regular schedulers; a
MAX_MESSAGE_BYTEScap bounds per-call work, and a benchmark gate asserts p99 < 1 ms at the cap as a perf-regression bound.
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
Rustler dependency is needed to force the build— you enabled the force-build path without therustlerdep; see Install for the exact line to add.Error while downloading precompiled NIF … 404— the target/NIF-version combination has no published artifact. Either your platform is not in the table above (compile from source per Install), or the version was published without that artifact — please open an issue.- Setting
SR25519_FORCE_BUILD=1after:sr25519already compiled does nothing — env vars aren't tracked by the compiler; runmix deps.clean sr25519first. (Theconfig :rustler_precompiledform recompiles automatically.)
License
Dual-licensed under
MIT or
Apache-2.0 at your
option. Bundles the BSD-3-Clause schnorrkel crate — see
NOTICE.