Ed25519 keys, signing, and verification
All snippets below import from
algokit_crypto, the low-level cryptographic package that ships alongsidealgokit-utils. This is the layer underAccountManager— reach for it when you are wiring up a custom signer, implementing protocol helpers, or verifying signatures produced byalgokit-utils.
Generate a random Ed25519 keypair
Create a fresh Ed25519 keypair along with a raw signer function bound to it.
from algokit_crypto import ed25519_generator
keypair = ed25519_generator()
pubkey = keypair["ed25519_pubkey"]
secret_key = keypair["ed25519_secret_key"]
raw_signer = keypair["raw_ed25519_signer"]
print(len(pubkey)) # 32
print(len(secret_key)) # 32What just happened: ed25519_generator() drew a cryptographically random
32-byte secret key via PyNaCl (libsodium), derived its 32-byte public key, and
returned an Ed25519Keypair TypedDict alongside a raw_ed25519_signer
closure that already captures the secret key. Any call to
raw_ed25519_signer(bytes) will produce an Ed25519 signature over those bytes.
The default export is currently an alias for pynacl_ed25519_generator —
import pynacl_ed25519_generator directly if you want to pin the backend
explicitly.
Generate a deterministic Ed25519 keypair from a seed
Pass a 32-byte seed to produce the same keypair every time.
from algokit_crypto import ed25519_generator
seed = bytes(32) # 32 zero bytes — replace with real seed material
keypair = ed25519_generator(seed)
print(len(keypair["ed25519_pubkey"])) # 32What just happened: When you supply a 32-byte seed, PyNaCl uses it directly
as the Ed25519 secret key — no extra hashing — so the resulting keypair is fully
deterministic in the seed. A seed of any other length raises ValueError with
the message Seed must be 32 bytes for ed25519 key generation. Useful for
tests and for reproducing keys from a known source; never reuse a seed across
unrelated identities.
Sign bytes with the generated signer
Call the returned raw_ed25519_signer to produce a signature over arbitrary
bytes.
from algokit_crypto import ed25519_generator
keypair = ed25519_generator()
message = b"Hello, Algorand!"
signature = keypair["raw_ed25519_signer"](message)
print(len(signature)) # 64What just happened: raw_ed25519_signer implements the RawEd25519Signer
contract — a synchronous Callable[[bytes], bytes] that returns a 64-byte
Ed25519 signature. It signs exactly the bytes you pass in, with no added domain
separator. When signing Algorand transactions, logic sigs, or program data, let
AccountManager or generate_address_with_signers handle the Algorand prefix
(TX, Program, ProgData, MX) for you — the raw signer is deliberately
agnostic to domain separation.
Build a custom RawEd25519Signer
Implement the RawEd25519Signer type to plug any external signing source into
AlgoKit.
import httpx
from algokit_crypto import RawEd25519Signer
def custom_signer(bytes_to_sign: bytes) -> bytes:
# Forward the bytes to a hardware wallet, KMS, HSM, or remote service
response = httpx.post("https://my-signer.example.com/sign", content=bytes_to_sign)
return response.content
signer: RawEd25519Signer = custom_signerWhat just happened: RawEd25519Signer is simply
Callable[[bytes], bytes]. Anything that satisfies that shape is accepted
throughout the AlgoKit stack — AccountManager.set_signer and
generate_address_with_signers combine it with a public key (via
Ed25519SigningKey) to build full Algorand signers. The signer is expected to
produce a raw 64-byte Ed25519 signature over the bytes it receives; any domain
separation must be applied by the caller before invocation.
Construct an Ed25519SigningKey manually
Assemble an Ed25519SigningKey TypedDict from an existing public key and
signer.
from algokit_crypto import Ed25519SigningKey, RawEd25519Signer
pubkey: bytes = ... # 32 bytes from your key store
raw_signer: RawEd25519Signer = ... # e.g. a hardware wallet wrapper
signing_key: Ed25519SigningKey = {
"ed25519_pubkey": pubkey,
"raw_ed25519_signer": raw_signer,
}What just happened: Ed25519SigningKey is a minimal TypedDict — just the
32-byte public key and a RawEd25519Signer. Every AlgoKit helper that accepts
a "signing key" takes this shape, which is why third-party key sources
(hardware wallets, KMS, browser extensions) can be integrated without any
extra adapter layer: expose a public key and a sign function, bundle them in a
dict, and pass it on.
Verify an Ed25519 signature
Check whether a 64-byte signature is valid for the given message and public key.
from algokit_crypto import ed25519_generator, ed25519_verifier
keypair = ed25519_generator()
message = b"Hello, Algorand!"
signature = keypair["raw_ed25519_signer"](message)
is_valid = ed25519_verifier(signature, message, keypair["ed25519_pubkey"])
print(is_valid) # TrueWhat just happened: ed25519_verifier(signature, message, pubkey) returns
a bool. Internally it delegates to PyNaCl's VerifyKey.verify and returns
True only when the signature is a valid Ed25519 signature for exactly the
message bytes you pass in under the given public key. A bad signature is
caught as nacl.exceptions.BadSignatureError and converted into False —
flipping a single bit in any argument causes it to return False rather than
raise.
Verify a signature produced by the Algorand signing path
When the bytes you want to check came from a higher-level Algorand signer, verify against the same domain-prefixed bytes the signer saw.
from algokit_crypto import ed25519_generator, ed25519_verifier
from algokit_transact import generate_address_with_signers
keypair = ed25519_generator()
signers = generate_address_with_signers(
keypair["ed25519_pubkey"],
keypair["raw_ed25519_signer"],
)
message = b"Hello, Algorand!"
signature = signers.mx_bytes_signer(message)
# The signer prefixed `MX` before signing — feed the verifier the same bytes
signed_bytes = b"MX" + message
is_valid = ed25519_verifier(signature, signed_bytes, keypair["ed25519_pubkey"])
# The "raw" message would not verify, because mx_bytes_signer adds a domain prefix
is_raw_valid = ed25519_verifier(signature, message, keypair["ed25519_pubkey"])
print(is_valid, is_raw_valid) # True FalseWhat just happened: Every Algorand signer returned from
generate_address_with_signers — transaction signer, delegated lsig signer,
program-data signer, mx-bytes signer — applies a domain-separation prefix
before signing. When verifying a signature that came out of one of those
higher-level signers, reproduce the exact bytes that were signed (for
mx_bytes_signer that is b"MX" + data). Verifying against the raw user
message will return False.
Use the pinned PyNaCl backend explicitly
Import the pynacl_-prefixed variants when you want a guaranteed
implementation.
from algokit_crypto import (
RawEd25519Signer,
RawEd25519Verifier,
pynacl_ed25519_generator,
pynacl_ed25519_verifier,
)
keypair = pynacl_ed25519_generator()
signer: RawEd25519Signer = keypair["raw_ed25519_signer"]
verify: RawEd25519Verifier = pynacl_ed25519_verifierWhat just happened: pynacl_ed25519_generator and pynacl_ed25519_verifier
are the current concrete backings for ed25519_generator and
ed25519_verifier. The package documents that the default exports may change
in future versions — if your code depends on specific behaviour of the PyNaCl
(libsodium) implementation (constant-time guarantees, exact error messages,
etc.) import the pynacl_* symbol directly so future default-repointing
cannot break you.