"""ED25519 and sr25519 accounts: key derivation, signatures and message encryption.
ED25519 runs on PyNaCl, sr25519 on the ``[sr25519]`` extra (:mod:`.keys_sr25519`).
Wire formats match substrate-interface byte for byte: the same mini secret
from a mnemonic, the same ed25519 → curve25519 conversion and the same NaCl
box with the nonce in front. Reports already encrypted by the 2.x stack stay
readable, and the ones produced here open with the old tools.
"""
from __future__ import annotations
import hashlib
import re
from collections.abc import Mapping
from typing import Any, NoReturn, Self
import nacl.bindings
import nacl.exceptions
import nacl.public
import nacl.signing
from . import ecies_sr25519, keys_sr25519
from .backup import open_backup
from .bip39 import generate_mnemonic, mnemonic_to_mini_secret
from .errors import DecryptionError, InvalidKey, NoSecretKey
from .ss58 import ROBONOMICS_SS58_FORMAT, address_format, decode_address, encode_address
__all__ = ["DEV_PHRASE", "ED25519", "SR25519", "Keypair"]
# Substrate's MultiSignature ordering; also the signature tag in an extrinsic.
ED25519 = 0
SR25519 = 1
_KEY_TYPES = {"ed25519": ED25519, "sr25519": SR25519}
_KEY_NAMES = {value: name for name, value in _KEY_TYPES.items()}
# The well-known development phrase behind //Alice, //Bob and friends.
DEV_PHRASE = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"
_SEED_BYTES = 32
_PUBLIC_KEY_BYTES = 32
_JUNCTION_BYTES = 32
_HDKD_CONTEXT = b"\x2cEd25519HDKD" # SCALE-encoded str: compact length 11, then the bytes
_URI = re.compile(r"^(?P<phrase>[^/]*?)(?P<path>(?://?[^/]+)*)(?:///(?P<password>.*))?$")
_JUNCTION = re.compile(r"(//?)([^/]+)")
def _compact_length(length: int) -> bytes:
if length < 1 << 6:
return bytes([length << 2])
if length < 1 << 14:
return ((length << 2) | 0b01).to_bytes(2, "little")
if length < 1 << 30:
return ((length << 2) | 0b10).to_bytes(4, "little")
raise InvalidKey("a derivation junction is too long")
def _chain_code(junction: str) -> bytes:
"""A junction's chain code, as ``sp_core::crypto::DeriveJunction`` builds it."""
if junction.isdecimal() and int(junction) < 1 << 64:
code = int(junction).to_bytes(8, "little")
else:
raw = junction.encode("utf-8")
code = _compact_length(len(raw)) + raw
if len(code) > _JUNCTION_BYTES:
return hashlib.blake2b(code, digest_size=32).digest()
return code.ljust(_JUNCTION_BYTES, b"\x00")
def _derive_hard(seed: bytes, junction: str) -> bytes:
# sp_core::ed25519: ("Ed25519HDKD", seed, chain_code).using_encoded(blake2_256)
return hashlib.blake2b(_HDKD_CONTEXT + seed + _chain_code(junction), digest_size=32).digest()
def _seed_from_hex(text: str) -> bytes:
try:
seed = bytes.fromhex(text.removeprefix("0x"))
except ValueError:
raise InvalidKey("a raw seed must be 0x-prefixed hex") from None
if len(seed) != _SEED_BYTES:
raise InvalidKey(f"a raw seed is {_SEED_BYTES} bytes, not {len(seed)}")
return seed
def _as_bytes(message: bytes | str) -> bytes:
return message.encode("utf-8") if isinstance(message, str) else bytes(message)
def _key_type(crypto_type: int | str) -> int:
"""``ED25519``/``SR25519``, or their names as a config file would hold them."""
kind = (
_KEY_TYPES.get(crypto_type.strip().lower()) if isinstance(crypto_type, str) else crypto_type
)
if kind not in _KEY_NAMES:
raise InvalidKey("the key type must be ED25519 or SR25519 (or 'ed25519', 'sr25519')")
return kind
[docs]
class Keypair:
"""An ED25519 or sr25519 account, with or without its secret key.
Build one with a ``from_*`` class method; ``crypto_type`` picks the kind
(``ED25519`` by default, so an existing mnemonic keeps its address). A
keypair built from a public key or an address can verify signatures and be
encrypted to, but not sign or decrypt. The secret is held only inside the
signing key: the mnemonic or URI it came from is not kept, and neither
:func:`repr` nor exceptions ever show it.
"""
__slots__ = ("_public_key", "_signing_key", "_sr25519_secret", "crypto_type", "ss58_format")
_public_key: bytes
_signing_key: nacl.signing.SigningKey | None
_sr25519_secret: bytes | None
crypto_type: int
ss58_format: int
def __init__(self) -> None:
raise TypeError("use Keypair.from_mnemonic(), from_seed(), from_uri() or from_address()")
@classmethod
def _build(
cls,
crypto_type: int,
signing_key: nacl.signing.SigningKey | None,
sr25519_secret: bytes | None,
public_key: bytes,
ss58_format: int,
) -> Self:
keypair = object.__new__(cls)
keypair.crypto_type = crypto_type
keypair._signing_key = signing_key
keypair._sr25519_secret = sr25519_secret
keypair._public_key = public_key
keypair.ss58_format = ss58_format
return keypair
# Construction
[docs]
@classmethod
def from_seed(
cls,
seed: bytes | str,
ss58_format: int = ROBONOMICS_SS58_FORMAT,
*,
crypto_type: int | str = ED25519,
) -> Self:
"""From a 32-byte seed (the Substrate "mini secret"), raw or as 0x hex."""
kind = _key_type(crypto_type)
if isinstance(seed, str):
seed = _seed_from_hex(seed)
if len(seed) != _SEED_BYTES:
raise InvalidKey(f"a seed is {_SEED_BYTES} bytes, not {len(seed)}")
if kind == SR25519:
public_key, secret = keys_sr25519.pair_from_seed(bytes(seed))
return cls._build(kind, None, secret, public_key, ss58_format)
signing_key = nacl.signing.SigningKey(bytes(seed))
return cls._build(kind, signing_key, None, bytes(signing_key.verify_key), ss58_format)
[docs]
@classmethod
def from_mnemonic(
cls,
mnemonic: str,
password: str = "",
ss58_format: int = ROBONOMICS_SS58_FORMAT,
*,
crypto_type: int | str = ED25519,
) -> Self:
"""From a BIP39 English mnemonic, as polkadot.js and substrate-interface derive it."""
return cls.from_seed(
mnemonic_to_mini_secret(mnemonic, password), ss58_format, crypto_type=crypto_type
)
[docs]
@classmethod
def from_uri(
cls,
uri: str,
ss58_format: int = ROBONOMICS_SS58_FORMAT,
*,
crypto_type: int | str = ED25519,
) -> Self:
"""From a Substrate secret URI: ``<phrase or 0x seed>//hard/soft///password``.
An empty phrase means the development phrase, so ``//Alice`` gives the
well-known development account. ED25519 has hard derivation only; a
soft junction (``/path``) is refused. Use this for development and
tests; for keys that people paste, use :meth:`from_secret`.
"""
kind = _key_type(crypto_type)
match = _URI.match(uri.strip())
if match is None:
raise InvalidKey("the secret URI is malformed")
phrase = match["phrase"].strip() or DEV_PHRASE
password = match["password"] or ""
if phrase.startswith("0x"):
if password:
raise InvalidKey("a password applies to a mnemonic, not to a raw seed")
seed = _seed_from_hex(phrase)
else:
seed = mnemonic_to_mini_secret(phrase, password)
junctions = _JUNCTION.findall(match["path"])
if kind == SR25519:
public_key, secret = keys_sr25519.pair_from_seed(seed)
for separator, junction in junctions:
public_key, secret = keys_sr25519.derive(
public_key, secret, _chain_code(junction), hard=separator == "//"
)
return cls._build(kind, None, secret, public_key, ss58_format)
for separator, junction in junctions:
if separator == "/":
raise InvalidKey("ED25519 supports hard derivation (//path) only")
seed = _derive_hard(seed, junction)
return cls.from_seed(seed, ss58_format)
[docs]
@classmethod
def from_secret(
cls,
secret: str,
ss58_format: int = ROBONOMICS_SS58_FORMAT,
*,
crypto_type: int | str = ED25519,
) -> Self:
"""From what a person may paste: a mnemonic or a raw 0x seed.
Derivation paths, and development keys such as ``//Alice`` with them,
are refused on purpose: those secrets are public.
"""
secret = secret.strip()
if "/" in secret:
raise InvalidKey("derivation paths are not accepted here; use Keypair.from_uri()")
if secret.startswith("0x"):
return cls.from_seed(secret, ss58_format, crypto_type=crypto_type)
return cls.from_mnemonic(secret, ss58_format=ss58_format, crypto_type=crypto_type)
[docs]
@classmethod
def from_json(
cls,
backup: str | bytes | Mapping[str, Any],
password: str,
ss58_format: int = ROBONOMICS_SS58_FORMAT,
) -> Self:
"""From the JSON backup a wallet exports (polkadot.js version 3) and its password.
The file says whether the account is ED25519 or sr25519, so nothing else
is needed. The address in the file may be in any network's format; the
keypair gets ``ss58_format``. Opening it takes a few seconds on a small
board: call it off an event loop.
:raises InvalidKey: not a usable backup, or the wrong password.
"""
key_type, secret, public_key = open_backup(backup, password)
if key_type == "sr25519":
restored, converted = keys_sr25519.pair_from_polkadot_js(secret)
if restored != public_key:
raise InvalidKey("the key in the wallet backup does not match its public key")
return cls._build(SR25519, None, converted, restored, ss58_format)
keypair = cls.from_seed(secret[:_SEED_BYTES], ss58_format)
if keypair.public_key != public_key or secret[_SEED_BYTES:] != public_key:
raise InvalidKey("the key in the wallet backup does not match its public key")
return keypair
[docs]
@classmethod
def from_public_key(
cls,
public_key: bytes | str,
ss58_format: int = ROBONOMICS_SS58_FORMAT,
*,
crypto_type: int | str = ED25519,
) -> Self:
"""A counterparty known by its public key: can verify and be encrypted to."""
kind = _key_type(crypto_type)
if isinstance(public_key, str):
try:
public_key = bytes.fromhex(public_key.removeprefix("0x"))
except ValueError:
raise InvalidKey("a public key must be 0x-prefixed hex") from None
if len(public_key) != _PUBLIC_KEY_BYTES:
raise InvalidKey(f"a public key is {_PUBLIC_KEY_BYTES} bytes, not {len(public_key)}")
return cls._build(kind, None, None, bytes(public_key), ss58_format)
[docs]
@classmethod
def from_address(
cls,
address: str,
ss58_format: int | None = None,
*,
crypto_type: int | str = ED25519,
) -> Self:
"""A counterparty known by its SS58 address.
:param ss58_format: when given, an address of another network is
refused; otherwise the address's own format is kept.
An address carries no key type: say which it is with ``crypto_type``.
A wrong one only shows later, when a signature does not verify or a
message does not decrypt.
"""
kind = _key_type(crypto_type)
public_key = decode_address(address, ss58_format)
return cls._build(kind, None, None, public_key, address_format(address))
[docs]
@staticmethod
def generate_mnemonic(word_count: int = 12) -> str:
"""A new random mnemonic; build the keypair with :meth:`from_mnemonic`."""
return generate_mnemonic(word_count)
# Identity
@property
def public_key(self) -> bytes:
return self._public_key
@property
def address(self) -> str:
"""The SS58 address in this keypair's format (32, Robonomics, by default)."""
return encode_address(self._public_key, self.ss58_format)
@property
def has_secret(self) -> bool:
return self._signing_key is not None or self._sr25519_secret is not None
def __repr__(self) -> str:
public_only = "" if self.has_secret else ", public only"
return f"<Keypair {_KEY_NAMES[self.crypto_type]} {self.address}{public_only}>"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Keypair):
return NotImplemented
return (self.crypto_type, self._public_key) == (other.crypto_type, other._public_key)
def __hash__(self) -> int:
return hash((self.crypto_type, self._public_key))
def __reduce__(self) -> NoReturn:
# Pickling would write the secret key out; refuse rather than leak.
raise TypeError("Keypair cannot be pickled")
# Signatures
def _secret(self) -> nacl.signing.SigningKey:
if self._signing_key is None:
raise NoSecretKey(f"{self.address} is a public key only")
return self._signing_key
[docs]
def sign(self, message: bytes | str) -> bytes:
"""A 64-byte signature. A ``str`` is signed as its UTF-8 bytes.
An sr25519 signature is randomized: signing twice gives different
bytes, and both verify.
"""
if self.crypto_type == SR25519:
if self._sr25519_secret is None:
raise NoSecretKey(f"{self.address} is a public key only")
return keys_sr25519.sign(self._public_key, self._sr25519_secret, _as_bytes(message))
return bytes(self._secret().sign(_as_bytes(message)).signature)
def verify(self, message: bytes | str, signature: bytes) -> bool:
if self.crypto_type == SR25519:
return keys_sr25519.verify(self._public_key, _as_bytes(message), bytes(signature))
try:
nacl.signing.VerifyKey(self._public_key).verify(_as_bytes(message), bytes(signature))
except (nacl.exceptions.BadSignatureError, nacl.exceptions.ValueError, TypeError):
return False
return True
# Encryption
def _box(self, counterparty_public_key: bytes) -> nacl.public.Box:
signing_key = self._secret()
own = nacl.bindings.crypto_sign_ed25519_sk_to_curve25519(
bytes(signing_key) + self._public_key
)
try:
other = nacl.bindings.crypto_sign_ed25519_pk_to_curve25519(
bytes(counterparty_public_key)
)
except (nacl.exceptions.RuntimeError, nacl.exceptions.ValueError, TypeError):
raise InvalidKey("the counterparty public key is not a valid ED25519 point") from None
return nacl.public.Box(nacl.public.PrivateKey(own), nacl.public.PublicKey(other))
[docs]
def encrypt_message(
self, message: bytes | str, recipient_public_key: bytes, nonce: bytes | None = None
) -> bytes:
"""Encrypt for one recipient.
An ED25519 keypair makes a NaCl box with the 24-byte nonce in front; the
recipient needs this keypair's public key to open it. An sr25519 keypair
makes an ECIES message (:mod:`.ecies_sr25519`) from a one-time key, so
its own key plays no part: nothing in the message names the sender, and
a public-only keypair can encrypt too.
:param nonce: for ED25519 test vectors only; a random nonce is used otherwise.
"""
if self.crypto_type == SR25519:
if nonce is not None:
raise TypeError("a nonce can be given for ED25519 only")
return ecies_sr25519.encrypt(_as_bytes(message), bytes(recipient_public_key))
box = self._box(recipient_public_key)
return bytes(box.encrypt(_as_bytes(message), nonce))
[docs]
def decrypt_message(self, encrypted_message: bytes, sender_public_key: bytes) -> bytes:
"""Open what :meth:`encrypt_message` made (an ED25519 box may be from substrate-interface).
``sender_public_key`` opens an ED25519 box; an sr25519 message carries
its own one-time key, so the argument is not used.
"""
if self.crypto_type == SR25519:
if self._sr25519_secret is None:
raise NoSecretKey(f"{self.address} is a public key only")
return ecies_sr25519.decrypt(bytes(encrypted_message), self._sr25519_secret)
box = self._box(sender_public_key)
try:
return bytes(box.decrypt(bytes(encrypted_message)))
except (nacl.exceptions.CryptoError, nacl.exceptions.ValueError, TypeError):
raise DecryptionError("the message cannot be decrypted with these keys") from None