# Copyright (c) The btclib developers
# Distributed under the MIT software license, see the accompanying
# LICENSE file or https://opensource.org/license/mit for the full text.
"""BIP380 KEY expressions: what a descriptor names a public key with.
The bottom of the descriptor package, and the half of BIP380's grammar
that says nothing about scripts: a KEY expression is a public key, an
extended key with a derivation path, or BIP390's ``musig()`` aggregate of
either, optionally behind the ``[fingerprint/path]`` key origin a signer
needs. `KeyExpression` is what one parses to and `sec` is what it derives.
A module of its own because two modules above it read the same grammar.
`descriptors` reads it inside ``pk()``, ``multi()``, ``tr()`` and the rest;
`miniscript` reads it inside ``pk_k()``, ``pk_h()``, ``multi()`` and
``multi_a()`` -- BIP379's "Key expressions are specified in BIP380", the
same production and not a second dialect of it. Both import this one and
this one imports neither, which is the direction of the layering.
Three text helpers come with it, for the same reason: `_expression`,
`_split_arguments` and `_split_function` are what write a function out and
read one back, and both halves of the grammar are written in functions.
BIP380: https://github.com/bitcoin/bips/blob/master/bip-0380.mediawiki
BIP390: https://github.com/bitcoin/bips/blob/master/bip-0390.mediawiki
"""
from __future__ import annotations
import re
import string
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any, TypeVar
from btclib import b58
from btclib.base58 import decode as base58_decode
from btclib.exceptions import BTClibTypeError, BTClibValueError
from btclib.network import NETWORKS, network_from_name, xpubversions_from_network
from btclib.utils import assert_type, bytes_from_octets, is_integer
from ecc.curves import point_from_pub_key, secp256k1
from ecc.curves.sec_point import bytes_from_point
from ecc.ecc.musig2 import key_agg_var, key_sort
from typing_extensions import override
from btclib_wallet.bip32.bip32 import (
BIP328_CHAIN_CODE,
BIP32KeyData,
derive_,
pub_keyinfo_from_xkey,
xpub_from_xprv,
)
from btclib_wallet.bip32.der_path import (
_HARDENING,
HARDENED_OFFSET,
hardenings_from_der_path,
indexes_from_der_path,
str_from_der_path,
str_from_index_int,
)
from btclib_wallet.bip32.key_origin import BIP32KeyOrigin
__all__ = ["KeyExpression", "PrvKeys"]
_FINGERPRINT = re.compile(r"[0-9a-fA-F]{8}")
_HEX = re.compile(r"[0-9a-fA-F]*")
# what `xkey` holds where it is no extended key: the stand-ins the
# wallet-policy functions of `descriptors` write -- a BIP388 placeholder
# alone, or with the chain suffix it is paired with, a musig() group of
# them with that suffix, and the one key `_skeleton_key` writes everywhere
_INDEX = r"(?:0|[1-9][0-9]*)"
_CHAIN_SUFFIX = rf"(?:/\*\*|/<{_INDEX};{_INDEX}>/\*)"
_PLACEHOLDER = re.compile(
rf"K|@{_INDEX}{_CHAIN_SUFFIX}?|musig\(@{_INDEX}(?:,@{_INDEX})*\){_CHAIN_SUFFIX}"
)
# what `_split_arguments` stops at, compiled once here rather than
# handed to `re.finditer` as a pattern string per call: the cache that
# would answer for it is a dict lookup on that string, which a function
# called once per branch of a tr() tree makes for nothing
_DELIMITERS = re.compile(r"[(){},]")
# what `parse` hands back beside the descriptor, and what expansion takes
# when a hardened step needs it: the extended private key each extended
# key was read from, filed under the neutered spelling the descriptor
# keeps. Public identity to private material, which is how Bitcoin Core's
# `FlatSigningProvider` is keyed too -- by the key id there, by the xpub
# here, an extended key being what a descriptor derives from
PrvKeys = Mapping[str, str]
def _assert_prv_keys(prv_keys: object) -> None:
"""Refuse a `prv_keys` that is neither a mapping nor None.
The check `descriptors.parse` makes, written once for the methods that
take the mapping back: without it a string leaves as the
`AttributeError` of its missing `get`.
"""
assert_type(prv_keys, (Mapping, type(None)), "prv_keys")
def _assert_index_range(index: int) -> None:
"""Refuse a non-integer index or one outside 0 to 2**31 - 1.
The range a wildcard counts in: an index beyond it would add itself to
the hardening offset and derive a step the wildcard never takes.
"""
if not is_integer(index):
err_msg = f"invalid derivation index type: {type(index).__name__}"
raise BTClibTypeError(err_msg)
if not 0 <= index < HARDENED_OFFSET:
raise BTClibValueError(f"invalid derivation index: {index}")
_T = TypeVar("_T")
# the keys derived so far by the expansion in progress, with the `prv_keys`
# it derives with: `sec` and `aggregate` answer from it instead of deriving
# again. None outside an expansion, where every call derives
_DERIVED: ContextVar[tuple[PrvKeys | None, dict[Any, Any]] | None] = ContextVar(
"_DERIVED", default=None
)
@contextmanager
def _derived_once(prv_keys: PrvKeys | None) -> Iterator[None]:
"""Derive each key once, for every call made inside with these `prv_keys`.
Bitcoin Core's `Descriptor::Expand` derives each key once and fills the
scripts and the provider from it. Here the scripts and the provider are
separate methods that each read the keys, some of them more than once,
so this is what they share. Nested uses of the same `prv_keys` object
share one expansion; another object is another expansion.
A context variable rather than a parameter, which every fragment's
`_scripts` and every `sec` call would otherwise have to carry.
"""
current = _DERIVED.get()
if current is not None and current[0] is prv_keys:
yield
return
token = _DERIVED.set((prv_keys, {}))
try:
yield
finally:
_DERIVED.reset(token)
def _once(key: object, prv_keys: PrvKeys | None, derive: Callable[[], _T]) -> _T:
"""Return `derive()`, remembered under `key` inside `_derived_once`."""
current = _DERIVED.get()
if current is None or current[0] is not prv_keys:
return derive()
derived = current[1]
if key not in derived:
derived[key] = derive()
return derived[key] # type: ignore[no-any-return]
[docs]
@dataclass(frozen=True)
class KeyExpression:
"""A BIP380 KEY expression: an origin, a key, a derivation path.
One of three things is the key. Either `pub_key` is the one the
descriptor fixes, in SEC bytes; or `xkey` is the extended key that
`der_path` and `wildcard` derive from; or `participants` are the KEY
expressions BIP390's ``musig()`` aggregates, `der_path` and `wildcard`
then deriving from the aggregate key as BIP328 prescribes. An x-only
key is held as its even-y SEC form, which is what BIP340 says those 32
bytes mean, so that everything downstream sees one representation of a
public key.
`wif_prefix` is the version byte of the WIF a fixed key was read from,
the key itself being public by then; it is empty for any other spelling.
`origin` never changes the script. It says which master key and which
path the key came from, which is what a hardware signer needs and
what BIP174 carries in a PSBT.
`xkey` is public whatever the descriptor spelled: `parse` neuters an
xprv and hands the private material back to its caller, and the
constructor refuses one, so no key expression holds a key that signs.
What that costs is a hardened step, which an xpub cannot take -- `sec`
takes the keys back as a parameter for it, the way Bitcoin Core's
expansion takes a `SigningProvider`.
"""
origin: BIP32KeyOrigin | None = None
pub_key: bytes | None = None
xkey: str = ""
der_path: tuple[int, ...] = ()
# the final `/*` step, as the offset its hardening adds to the index:
# None where the descriptor has no wildcard, 0 for `/*`, 2**31 for
# `/*h`. One field rather than a flag and a bool, because "hardened"
# is only meaningful when there is a wildcard to harden
wildcard: int | None = None
x_only: bool = False
# BIP390's ``musig()``: the participants whose keys aggregate to this
# one, in the order the descriptor writes them. `KeySort` is applied
# at aggregation and not here, so what is kept is the text's order and
# what is computed does not depend on it
participants: tuple[KeyExpression, ...] = ()
# the symbol every hardened step of this expression is written with:
# BIP380 gives "h" and "'" one meaning and two spellings, and the two
# are different strings with different checksums, so a descriptor
# handed back in the other one is not the descriptor that was read.
# One symbol for the whole expression, origin included, which is
# Bitcoin Core's single `m_apostrophe` per key: a path spelled both
# ways -- BIP380's own valid `[deadbeef/0'/0h/0']` -- is read and
# written back in the symbol its last hardened step used
hardening: str = _HARDENING
# checked by `parse` against the network; not compared
wif_prefix: bytes = field(default=b"", compare=False, repr=False)
def __post_init__(self) -> None:
"""Refuse an `xkey` that is no xpub or placeholder, quoting none of it.
An extended public key of any version btclib decodes, or one of the
wallet-policy stand-ins `_PLACEHOLDER` matches, or nothing at all
where the key is `pub_key` or `participants`. What else is refused
includes an xprv, and an xprv damaged by a character, which decodes
as no key -- and where only its checksum changed still spells one.
"""
if not isinstance(self.xkey, str):
err_msg = f"invalid xkey type: {type(self.xkey).__name__}" # type: ignore[unreachable]
raise BTClibTypeError(err_msg)
if not self.xkey or _PLACEHOLDER.fullmatch(self.xkey):
return
try:
is_private = BIP32KeyData.b58decode(self.xkey).is_private
# from None: btclib's reason is about the characters, which are
# what this refuses to repeat
except ValueError:
err_msg = "invalid xkey: no extended public key and no placeholder"
raise BTClibValueError(err_msg) from None
if is_private:
err_msg = "an extended private key is no xkey: pass its xpub"
raise BTClibValueError(err_msg)
@property
def is_ranged(self) -> bool:
"""Answer whether the key has a wildcard to derive at an index.
A ``musig()`` is ranged where its own path has one and where a
participant has one: BIP390 forbids both at once, so whichever it
is, the index is read in one place.
"""
return self.wildcard is not None or any(
key.is_ranged for key in self.participants
)
@property
def is_aggregate(self) -> bool:
"""Answer whether the key is the aggregate of BIP390 participants."""
return bool(self.participants)
@property
def is_compressed(self) -> bool:
"""Return False for an uncompressed SEC public key, True otherwise.
An extended key is compressed by construction: BIP32 has no
uncompressed serialization.
"""
return self.pub_key is None or len(self.pub_key) == 33
[docs]
def sec(
self,
index: int = 0,
network: str = "mainnet",
prv_keys: PrvKeys | None = None,
) -> bytes:
"""Return the SEC public key bytes, derived at `index` if ranged.
`prv_keys` is what `parse` handed back, and is needed for a
hardened step and for nothing else: an unhardened path derives
from the xpub the descriptor holds. A key it does not name is
left as it is, so a mapping covering some of a multisig's keys
answers for those and no more.
A ``musig()`` answers with the key its participants aggregate to,
derived along its own path where it has one: BIP328's synthetic
xpub is that key at depth zero with the fixed chain code an
aggregate has instead of one of its own, and `derive_` is then what
refuses a hardened step -- there being no aggregate private key to
take one with. The index derives the participants or the aggregate
and never both, BIP390 allowing a wildcard on one side only.
An index outside 0 to 2**31 - 1 is refused, whether the key is
ranged or not: `Descriptor` refuses the same.
"""
_assert_index_range(index)
_assert_prv_keys(prv_keys)
return _once(
("sec", self, index, network),
prv_keys,
lambda: self._sec(index, network, prv_keys),
)
def _sec(self, index: int, network: str, prv_keys: PrvKeys | None) -> bytes:
"""Derive `sec`'s key."""
if self.participants:
aggregate = self.aggregate(index, network, prv_keys)
musig_path = list(self.der_path)
if self.wildcard is not None:
musig_path.append(self.wildcard + index)
if not musig_path:
return aggregate
synthetic = BIP32KeyData(
version=network_from_name(network).bip32_pub,
depth=0,
parent_fingerprint=b"\x00" * 4,
index=0,
chain_code=BIP328_CHAIN_CODE,
key=aggregate,
)
return pub_keyinfo_from_xkey(derive_(synthetic, musig_path), network)[0]
if self.pub_key is not None:
return self.pub_key
der_path = list(self.der_path)
if self.wildcard is not None:
der_path.append(self.wildcard + index)
xkey = prv_keys.get(self.xkey, self.xkey) if prv_keys else self.xkey
# `derive_` and not `derive`: the Base58Check text would be decoded
# straight back by `pub_keyinfo_from_xkey` on this same line, the
# text spelling being the dearer of the two per key at an index
# (issue btclib-org/btclib#886). The union `derive_` takes is
# `derive`'s, so the xkey a descriptor holds as a string still goes in
# as it is
return pub_keyinfo_from_xkey(derive_(xkey, der_path), network)[0]
[docs]
def participant_keys(
self,
index: int = 0,
network: str = "mainnet",
prv_keys: PrvKeys | None = None,
) -> list[bytes]:
"""Return the participant keys in the order they are aggregated in.
Which is `KeySort`'s order and not the descriptor's: BIP390 sorts
after all derivation and before aggregation, so that the order the
keys were written in does not change the key -- a set of keys is
what MuSig2 is about, and a descriptor that was not backed up does
not need the order guessed as well. BIP373 stores the participants
of a psbt in aggregation order too, which is what makes this the
list that goes into `PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS`.
"""
_assert_prv_keys(prv_keys)
derived = [key.sec(index, network, prv_keys) for key in self.participants]
return key_sort(derived)
[docs]
def aggregate(
self,
index: int = 0,
network: str = "mainnet",
prv_keys: PrvKeys | None = None,
) -> bytes:
"""Return the key BIP390's participants aggregate to, derived.
`KeyAgg` over the sorted participants, and nothing more: the
``/NUM/.../*`` path of the expression derives *from* this key, and
`sec` is what walks it. The two are separate because BIP373 keys
its MuSig2 psbt fields by the aggregate key itself even where what
the script holds is a child of it -- an aggregate key is what the
participants make, and a derivation of it is a key the same group
answers for.
"""
_assert_prv_keys(prv_keys)
return _once(
("aggregate", self, index, network),
prv_keys,
lambda: bytes_from_point(
key_agg_var(self.participant_keys(index, network, prv_keys)).Q,
secp256k1,
),
)
@override
def __str__(self) -> str:
"""Return the KEY expression, in the spelling it was read in.
Public whatever was read: an xprv is the xpub `parse` neutered it
to, and a WIF is the hex of the public key it was reduced to.
Bitcoin Core writes the same, `ToString` having only the public
key to write and `ToPrivateString` taking the private material
back from the caller.
An x-only key is written as the 32 bytes a ``tr()`` holds, which
is the spelling it was read in and the only one allowed there.
A ``musig()`` writes its participants in the order they were read,
which is not the order they aggregate in: `KeySort` is applied when
the key is computed, so the text keeps what the descriptor said.
"""
if self.participants:
text = _expression("musig", *(str(key) for key in self.participants))
for index in self.der_path:
text += "/" + str_from_index_int(index, self.hardening)
if self.wildcard is not None:
text += "/*"
return text
text = ""
if self.origin is not None:
path = str_from_der_path(
self.origin.der_path, self.origin.master_fingerprint, self.hardening
)
text = f"[{path}]"
if self.pub_key is not None:
return text + (self.pub_key[1:] if self.x_only else self.pub_key).hex()
text += self.xkey
for index in self.der_path:
text += "/" + str_from_index_int(index, self.hardening)
if self.wildcard is not None:
text += "/*" + (self.hardening if self.wildcard else "")
return text
def _expression(name: str, *args: str) -> str:
"""Return a descriptor function written out: its name and its arguments."""
return f"{name}({','.join(args)})"
def _offered_signature(
signatures: Mapping[bytes, bytes], sec: bytes, *, x_only: bool
) -> bytes | None:
"""Return the signature offered for a public key, None where none is.
A taproot key answers to either of its two spellings: the 32 x-only
bytes the script holds, and the 33-byte even-y SEC form a
KeyExpression keeps it as. Neither is the more correct one, a caller
has whichever its signer handed back, and 32 bytes cannot be taken
for 33.
"""
if sec in signatures:
return signatures[sec]
if x_only and sec[1:] in signatures:
return signatures[sec[1:]]
return None
def _split_arguments(arguments: str) -> list[str]:
"""Split a comma-separated argument list, at nesting depth zero.
Parentheses and braces only: they are what nests and what can hold a
comma of its own, a nested SCRIPT expression and a tr() branch. The
key origin brackets are deliberately not counted, so that a bracket
written wrong reaches the key expression parser, which can say which
of the two ways it is wrong. Brackets that do not pair are refused.
"""
return _split_delimited(arguments, strict=True)
def _split_unpaired_arguments(arguments: str) -> list[str]:
"""Split as `_split_arguments` does, and let brackets be unpaired.
An argument ends at a closing bracket with nothing open, as Bitcoin
Core's ``Expr`` ends it, and the text from there on is left out. An
opening one with no end takes the rest of the text. The caller reads
what is left, so that a key written with a bracket is the key's fault,
and then refuses the brackets itself.
"""
return _split_delimited(arguments, strict=False)
def _split_delimited(arguments: str, *, strict: bool) -> list[str]:
"""Split at the commas of depth zero; `strict` refuses unpaired brackets."""
depth = 0
start = 0
result = []
# the loop is over the delimiters and not over the string: every
# other character decides nothing, and `re` skips them in C. What
# makes that worth writing is the recursion above -- `_parse_tree`
# splits a branch and splits each of its branches again, so a
# character deep in a tr() tree is walked once per level above it
for match in _DELIMITERS.finditer(arguments):
char = match[0]
if char in "({":
depth += 1
elif char in ")}":
if depth == 0 and not strict:
result.append(arguments[start : match.start()])
return result
depth -= 1
if depth < 0:
raise BTClibValueError("unbalanced brackets")
elif depth == 0: # a comma, the only other character matched
result.append(arguments[start : match.start()])
start = match.end()
if depth and strict:
raise BTClibValueError("unbalanced brackets")
result.append(arguments[start:])
return result
def _read_split(arguments: str, read: Callable[[list[str]], _T]) -> _T:
"""Return what `read` makes of the split `arguments`.
Brackets that do not pair are refused, but only once `read` has read
what it can of the arguments split leniently: Core reads the keys
before it finds them, so a fault of a key comes first.
"""
try:
split = _split_arguments(arguments)
except BTClibValueError as e:
read(_split_unpaired_arguments(arguments))
# a stray bracket stays in the text of the argument it is in
raise e from None
return read(split)
def _split_function(expression: str) -> tuple[str, str]:
"""Return the function name and the argument string it encloses."""
open_bracket = expression.find("(")
if open_bracket < 1 or not expression.endswith(")"):
raise BTClibValueError("not a descriptor expression: expected name(arguments)")
return expression[:open_bracket], expression[open_bracket + 1 : -1]
def _der_path(steps: list[str]) -> list[int]:
"""Return the indexes of the steps of a derivation path.
The steps and not the path string, because a `/` with nothing after
it is a step, the empty one, and joined back into a string it cannot
be told from no path at all. No steps is no path; an empty step is
refused as any other step that is no index, which is Bitcoin Core's
``ParseKeyPathElement`` refusing an empty element.
`bip380_enforced` is the whole of the difference from what a BIP32
path may spell: an uppercase "H", a leading "m" and the spaces of
"0 h" are all read by the lenient reading and none of them is a step
BIP380 allows. Neither reading takes 2**31 or above written
unhardened, there being no such BIP32 index.
"""
if not steps:
return []
try:
return indexes_from_der_path("/".join(steps), bip380_enforced=True)
# a step is caller text that can hold a key: one written after a path
# without the comma before it is read as the path's last step. So the
# refusal carries what precedes the colon, the fault alone, whatever
# der_path's own message goes on to say
except BTClibValueError as e:
raise BTClibValueError(str(e).partition(":")[0]) from None
def _hardening(path: str) -> str:
"""Return the symbol the last hardened step of a path was written with.
Empty where the path hardens nothing, so that a caller choosing
between two paths can tell "no opinion" from "an apostrophe". The
last and not the first, which is Bitcoin Core's rule by construction:
its parser assigns the bool per hardened element, so the one it ends
on is the one that is written back.
"""
if not path:
return ""
symbols = [s for s in hardenings_from_der_path(path, bip380_enforced=True) if s]
return symbols[-1] if symbols else ""
def _assert_key_item(key: str) -> None:
"""Refuse a key item Core refuses before it reads a path.
Leading or trailing whitespace is Core's ``IsSpace`` check on the key
item (`ParsePubkeyInner`), with "Key '...' is invalid due to
whitespace"; the message here leaves out the key. Then
`_assert_no_glued_step`.
"""
# ASCII whitespace alone: the six characters of Core's ``IsSpace``
if key and (key[0] in string.whitespace or key[-1] in string.whitespace):
raise BTClibValueError("Key is invalid due to whitespace")
_assert_no_glued_step(key)
def _assert_no_glued_step(key: str) -> None:
"""Refuse ``<`` or ``>`` in a key item, as Core does.
That is a multipath step glued to the key, as in ``xprv...<0;1>/*``,
which Core reports as "key '...' is not valid". The message leaves out
the key, which can be a private one, as no message here echoes one.
"""
if "<" in key or ">" in key:
raise BTClibValueError("key is not valid")
def _assert_whole_multipath_element(element: str) -> None:
"""Refuse a path element holding ``<`` or ``>`` but not one ``<a;b>`` step.
In a key path, Bitcoin Core reads ``<0;1>h`` as a number, and ``<0;1>>``
as a step whose ``1>`` is not one: both fail as "not a valid uint32".
A key origin has its own check, `_assert_origin_path_element`. The
message leaves out the element Core quotes: it can hold a private key,
which no message here echoes.
"""
inner = element[1:-1]
if ("<" in element or ">" in element) and not (
element.startswith("<")
and element.endswith(">")
and "<" not in inner
and ">" not in inner
):
raise BTClibValueError("Key path value is not a valid uint32")
def _assert_origin_path_element(element: str) -> None:
"""Refuse an element of a key origin's path, as Core does.
Core checks for an element that starts with ``<`` and ends with ``>``
first, so ``<0;1>>`` and ``<<0;1>`` get the multipath message here, not
the uint32 one. The message leaves out the element, as
`_assert_whole_multipath_element` does. Then it reads the element as a
number, before the next one.
"""
if element.startswith("<") and element.endswith(">"):
err_msg = (
"Key path value specifies multipath "
"in a section where multipath is not allowed"
)
raise BTClibValueError(err_msg)
_assert_whole_multipath_element(element)
_der_path([element])
def _assert_fingerprint(fingerprint: str) -> None:
"""Refuse a key origin fingerprint that is not 8 hex digits.
Bitcoin Core's ``ParsePubkey`` checks it before the origin's path, so
an origin wrong in both is refused for its fingerprint.
"""
if not _FINGERPRINT.fullmatch(fingerprint):
raise BTClibValueError("invalid key origin fingerprint: 8 hex digits expected")
def _key_origin(description: str) -> tuple[BIP32KeyOrigin, str]:
"""Return the key origin of a `[fingerprint/path]` prefix, and its symbol.
The symbol is the origin's alone, and the caller picks between it and
the key path's: `BIP32KeyOrigin` does not carry it, an origin being a
fingerprint and a path whichever way the path was spelled -- two
origins that differ in nothing else are the same origin, and equality
and `serialize` both have to keep saying so.
"""
fingerprint, separator, path = description.partition("/")
_assert_fingerprint(fingerprint)
steps = path.split("/") if separator else []
for element in steps:
_assert_origin_path_element(element)
return BIP32KeyOrigin(fingerprint, _der_path(steps)), _hardening(path)
def _pub_key_from_hex(key: str, *, x_only: bool) -> tuple[bytes, bool]:
"""Return the SEC bytes of a hex key, and whether it was x-only."""
length = len(key)
if length == 64:
if not x_only:
# "inside tr()" would not be true of every position that
# refuses one: a musig() participant is inside a tr() and is
# aggregated as a point, so it needs the byte an x-only key drops
err_msg = "x-only public keys are allowed in a taproot key expression only"
raise BTClibValueError(err_msg)
# the even-y lift of BIP340, which is what an x-only key means
pub_key = b"\x02" + bytes_from_octets(key)
elif length in {66, 130}:
prefix = key[:2]
if (length == 66 and prefix not in {"02", "03"}) or (
length == 130 and prefix != "04"
):
# 06 and 07 are the hybrid encoding, which nothing in bitcoin
# produces and Bitcoin Core refuses in a descriptor too
raise BTClibValueError(f"invalid public key prefix: 0x{prefix}")
pub_key = bytes_from_octets(key)
else:
raise BTClibValueError(f"invalid public key length: {length} characters")
# a key expression is parsed in order to be used, and a point off the
# curve has no script to derive: refuse it here, where the error can
# still say it was the key
point_from_pub_key(pub_key)
return pub_key, length == 64
def _is_extended_key(key: str) -> bool:
try:
BIP32KeyData.b58decode(key)
# ValueError, not Exception: the question is whether these characters
# are an extended key, and characters that are not are False
except ValueError:
return False
return True
def _neutered(xkey: str, prv_keys: dict[str, str]) -> str:
"""Return the public spelling of an extended key, filing the private one.
The one place a descriptor's private material is separated from the
descriptor, and the reason `KeyExpression.xkey` can be said to be
public: an xprv is answered with its xpub, and the xprv goes to the
caller's mapping under that xpub. An xpub is answered with itself and
files nothing.
Filed under the public spelling, so that what indexes the mapping is
not itself a secret -- which is how Bitcoin Core keys the
`FlatSigningProvider` its own parser fills, by the id of the public
key rather than by anything that signs.
"""
if not BIP32KeyData.b58decode(xkey).is_private:
return xkey
xpub = xpub_from_xprv(xkey)
prv_keys[xpub] = xkey
return xpub
_SEVERAL_ORIGIN_ENDS = "more than one ']' in a single key expression"
_MISSING_ORIGIN_END = "missing ']' in the key origin"
_ORIGIN_WITHOUT_START = "']' without the '[' that opens a key origin"
def _origin_and_rest(expression: str) -> tuple[BIP32KeyOrigin | None, str, str]:
"""Split the `[fingerprint/path]` prefix off a KEY expression.
Bitcoin Core's ``ParsePubkey`` counts the ``]`` first, before it reads
the origin.
"""
if expression.count("]") > 1:
raise BTClibValueError(_SEVERAL_ORIGIN_ENDS)
if not expression.startswith("["):
if "]" in expression:
raise BTClibValueError(_ORIGIN_WITHOUT_START)
return None, "", expression
end = expression.find("]")
if end < 0:
raise BTClibValueError(_MISSING_ORIGIN_END)
origin, hardening = _key_origin(expression[1:end])
return origin, hardening, expression[end + 1 :]
def _assert_musig_allowed(*, musig_allowed: bool) -> None:
"""Refuse a ``musig()`` anywhere BIP390 does not put one.
Which is everywhere but a ``tr()`` and a ``rawtr()``: their internal
or output key, and the keys of the ``pk()``, ``multi_a()`` and
``sortedmulti_a()`` leaves of a script tree. The message is Bitcoin
Core's own. A ``musig()`` nested in another, which BIP390 says "cannot
be nested within another musig()", is refused before this by
`_parse_musig`, for the ")" it adds to the outer one, as Core does.
BIP390 names ``sp()`` as a third position; btclib does not read
``sp()`` at all, so there is nothing here to allow it in.
"""
if not musig_allowed:
raise BTClibValueError("musig() is only allowed in tr() and rawtr()")
# Bitcoin Core's own message, refusing a key path with two multipath steps
_MULTIPLE_MULTIPATH = "Multiple multipath key path specifiers found"
_ONE_ITEM_STEP = "a multipath step takes at least two elements"
_MULTIPATH_KEY = "multipath key expression: use multipath_descriptors first"
_UNDERIVABLE = "derivation path after a key that cannot derive"
_NO_KEY = "No key provided"
class _MultipathKeyError(BTClibValueError):
"""A key, or a ``musig()``, with a multipath step to expand first.
`length` is the number of items of the step.
"""
def __init__(self, length: int, message: str = _MULTIPATH_KEY) -> None:
super().__init__(message)
self.length = length
def _assert_same_length(lengths: list[int]) -> None:
"""Refuse steps that differ in length, where Core compares them.
`lengths` has a zero for a key with no step, which goes with any.
"""
different = sorted({length for length in lengths if length})
if len(different) > 1:
raise BTClibValueError(f"multipath steps of different length: {different}")
# the lengths of the multipath steps `_parse_key` has read and set aside, or
# None where it refuses the first one it reads. See `_steps_set_aside`.
_SET_ASIDE: ContextVar[list[int] | None] = ContextVar("_SET_ASIDE", default=None)
@contextmanager
def _steps_set_aside() -> Iterator[list[int]]:
"""Read keys with a step on their first path, noting the step's length.
`_parse_key` refuses a key with a step, for `multipath_descriptors` to
expand first. Inside this block it reads the key on its first path
instead and notes the length of the step, so that the whole text is read
once before it is expanded.
"""
steps: list[int] = []
token = _SET_ASIDE.set(steps)
try:
yield steps
finally:
_SET_ASIDE.reset(token)
@contextmanager
def _steps_compared() -> Iterator[None]:
"""Refuse steps of different length read inside, once all are read.
Outside `_steps_set_aside` this does nothing, `_parse_key` refusing the
first step there.
"""
outer = _SET_ASIDE.get()
if outer is None:
yield
return
with _steps_set_aside() as steps:
yield
_assert_same_length(steps)
outer += steps
def _first_path(key: str) -> str:
"""Return a key with the first item of each multipath step."""
return re.sub(r"<([^;>]*)[^>]*>", r"\1", key)
def _expand_first(length: int) -> None:
"""Note a step of `length` items to expand, or refuse it where none is."""
steps = _SET_ASIDE.get()
if steps is None:
raise _MultipathKeyError(length)
steps.append(length)
def _read_multipath_keys(
arguments: list[str], read: Callable[[str], KeyExpression]
) -> tuple[tuple[KeyExpression, ...], list[int]]:
"""Return the keys of a ``multi()`` or ``musig()``, and the step lengths.
A key with a step is read on its first path, for the checks that Core
makes on the keys before it compares the lengths of their steps. The
length is zero for a key with none.
"""
keys = []
lengths = []
for argument in arguments:
with _steps_set_aside() as steps:
keys.append(read(argument))
lengths.append(max(steps, default=0))
return tuple(keys), lengths
def _assert_multipath_keys(lengths: list[int]) -> None:
"""Refuse the steps of a ``multi()`` or ``multi_a()`` as `parse` does.
The steps of different length are a fault, and any other step is for
`multipath_descriptors` to expand.
"""
_assert_same_length(lengths)
if any(lengths):
_expand_first(max(lengths))
# the last elements of a key path that make it ranged
_WILDCARDS = frozenset({"*", "*'", "*h"})
def _assert_step_items(step: str) -> None:
"""Refuse the items of a multipath step as Core reads them.
Fewer than two is Core's first check. Then it reads each item as a
number and compares it with the ones before, so ``<0;0;x>`` is refused
for the second ``0`` and ``<x;0;0>`` for the ``x``.
"""
items = step[1:-1].split(";")
if len(items) < 2:
raise BTClibValueError(_ONE_ITEM_STEP)
seen = set()
for item in items:
(index,) = _der_path([item])
if index in seen:
err_msg = f"Duplicated key path value {index} in multipath specifier"
raise BTClibValueError(err_msg)
seen.add(index)
def _assert_key_path_element(element: str, *, after_step: bool) -> bool:
"""Refuse a key path element as Core does, and answer whether it is a step.
Core's ``ParseKeyPath`` reads a key path left to right, and takes an
element that starts with ``<`` and ends with ``>`` as a multipath
step. Where one was read already, it refuses the element as a second
step before it reads anything inside it. Otherwise it reads the
element, or the items of the step, as numbers before the next element.
"""
is_step = element.startswith("<") and element.endswith(">")
if is_step and after_step:
raise BTClibValueError(_MULTIPLE_MULTIPATH)
_assert_whole_multipath_element(element)
if is_step:
_assert_step_items(element)
else:
_der_path([element])
return is_step
def _assert_key_path(elements: list[str]) -> int:
"""Refuse a key path, its final wildcard off, as ``ParseKeyPath`` does.
Answer the number of items of its multipath step, zero if it has none.
"""
length = 0
for element in elements:
if _assert_key_path_element(element, after_step=bool(length)):
length = element.count(";") + 1
return length
def _assert_key_characters(rest: str, *, extended: bool) -> None:
"""Refuse what a KEY expression cannot hold once the origin is off.
`extended` says whether the key is an extended one. Core refuses a key
that is not, if a path follows it, before it reads the path.
"""
key, *steps = rest.split("/")
# Core's first check of a key, before the path or anything else in it
if not key:
raise BTClibValueError(_NO_KEY)
_assert_key_item(key)
if "(" in key:
# a function where a key belongs. BIP390's musig() is the one that
# is a key expression, and reaching here means it was written
# behind a key origin, which is not where it may be nested
if key.startswith("musig("):
raise BTClibValueError("musig() cannot be nested inside a key origin")
raise BTClibValueError("not a key expression")
if steps and not extended:
raise BTClibValueError(_UNDERIVABLE)
length = _assert_key_path(_split_wildcard(steps)[0])
if "<" in rest:
raise _MultipathKeyError(length)
def _split_wildcard(steps: list[str]) -> tuple[list[str], int | None, str]:
"""Split the final `/*` step off a path: its offset, and its symbol."""
if steps and steps[-1] in _WILDCARDS:
offset = 0 if steps[-1] == "*" else HARDENED_OFFSET
return steps[:-1], offset, steps[-1][1:]
return steps, None, ""
def _fixed_pub_key(key: str, *, x_only: bool) -> tuple[bytes, bool]:
"""Return the SEC bytes of a hex or WIF key, and whether x-only.
A hex key is x-only when it was written in 32 bytes, and a WIF when it
sits where only an x-only key is written: it has no spelling of its
own to echo, so what is written back is the spelling the position
takes. Bitcoin Core makes the same distinction with one bool per key
-- `false` for a hex key it read whole, `ctx == ParseScriptContext::
P2TR` for a private one -- which is what makes its ``tr(WIF)`` and
``rawtr(WIF)`` vectors come back in 32 bytes and not 33.
"""
if _HEX.fullmatch(key):
return _pub_key_from_hex(key, x_only=x_only)
# what is left is a WIF, which is b58's object (issue
# btclib-org/btclib#1188): prv_key_data_from_wif answers both for it and for
# characters that are no key expression at all
try:
return b58.prv_key_data_from_wif(key).pub.sec, x_only
except (TypeError, ValueError) as e:
raise BTClibValueError("invalid key expression") from e
def _assert_network(key: KeyExpression, network: str) -> None:
"""Refuse a key spelled for another network, as Bitcoin Core's parser does.
A WIF by its version byte and an extended key by its version, against
the network's own; the test networks share both. The refusal quotes
none of the key.
"""
for participant in key.participants:
_assert_network(participant, network)
if key.wif_prefix and key.wif_prefix != NETWORKS[network].wif:
raise BTClibValueError(f"not a {network} key: WIF prefix")
if key.xkey and not _PLACEHOLDER.fullmatch(key.xkey):
version = BIP32KeyData.b58decode(key.xkey).version
if version not in xpubversions_from_network(network):
raise BTClibValueError(f"not a {network} key: version 0x{version.hex()}")
_RANGED_PARTICIPANT = "musig() cannot have a ranged participant and derivation too"
# Bitcoin Core's own message, refusing a musig() that holds a ")" of its own
_MUSIG_CLOSES = "Too many ')' in musig() expression"
_HARDENED_MUSIG_STEP = "musig() cannot have hardened derivation steps"
# Bitcoin Core's own message, refusing a musig() with a multipath step in
# both its participants and its path
_MUSIG_ONE_MULTIPATH = (
"musig(): Cannot have multipath participant keys if musig() is also multipath"
)
def _path_indexes(steps: list[str]) -> list[int]:
"""Return the indexes of a key path, every item of a multipath step's.
Core's check for a hardened step in a ``musig()`` path reads them all.
"""
items = [
item
for element in steps
for item in (element[1:-1].split(";") if element.startswith("<") else [element])
]
return _der_path(items)
def _musig_der_path(suffix: str) -> tuple[tuple[int, ...], int | None, int]:
"""Return the path and wildcard a ``musig()`` derives the aggregate by.
And the number of items of the path's multipath step, zero if it has
none, which the caller refuses.
`suffix` is empty or starts with ``/``.
Every one of BIP390's rules about it is a refusal here: no hardened
step and no hardened wildcard, an aggregate key having no private half
to take either with, which is BIP328's own restriction. A fault
ParseKeyPath reports in the path carries Bitcoin Core's ``musig(): ``
prefix.
"""
if not suffix:
return (), None, 0
steps, wildcard, wildcard_hardening = _split_wildcard(suffix[1:].split("/"))
if wildcard_hardening:
raise BTClibValueError("musig() cannot have a hardened wildcard")
try:
length = _assert_key_path(steps)
except BTClibValueError as e:
raise BTClibValueError(f"musig(): {e}") from e
der_path = _path_indexes(steps)
if any(index >= HARDENED_OFFSET for index in der_path):
raise BTClibValueError(_HARDENED_MUSIG_STEP)
return tuple(der_path), wildcard, length
def _assert_musig_multipath(*, participants: list[int], path: int) -> None:
"""Refuse a ``musig()`` holding a multipath step, as `parse` does.
`participants` is the length of each participant's step, zero for one
with none, and `path` the length of the path's. Bitcoin Core refuses a
step in both the participants and the path after it has read both,
then steps of different length. A step in either alone is for
`multipath_descriptors` to expand first.
"""
if any(participants) and path:
raise BTClibValueError(_MUSIG_ONE_MULTIPATH)
_assert_same_length(participants)
if any(participants):
raise _MultipathKeyError(max(participants), f"musig(): {_MULTIPATH_KEY}")
if path:
raise _MultipathKeyError(path)
def _musig_participants(
arguments: list[str], prv_keys: dict[str, str]
) -> tuple[tuple[KeyExpression, ...], list[int]]:
"""Return the participants of a ``musig()``, and their step lengths.
Core reads a multipath participant, so what it refuses after one comes
before `parse` refuses the participant. That participant is read on its
first path, for the checks that follow it.
"""
if arguments == [""]:
raise BTClibValueError("musig() takes at least one key")
try:
return _read_multipath_keys(
arguments,
lambda key: _parse_key(
key, prv_keys, x_only=False, compressed=True, musig_allowed=False
),
)
# which of several participants was wrong is half the answer, and the
# inner message names neither the function nor the position. Bitcoin
# Core prefixes its own the same way, "musig(): ...". ValueError: a
# participant that is no point is refused by
# ecc.exceptions.EccValueError, which is no
# BTClibValueError
except ValueError as e:
raise BTClibValueError(f"musig(): {e}") from e
def _parse_musig(expression: str, prv_keys: dict[str, str]) -> KeyExpression:
"""Return the KeyExpression of a BIP390 ``musig()`` expression.
The participants are KEY expressions read as any other key in a
``tr()`` is, except that none of them may be x-only: MuSig2
aggregation is over points, so it needs the evenness byte an x-only
spelling drops -- which is Bitcoin Core's rule too, its own parse
context allowing 32-byte keys under ``tr()`` and not under
``musig()``.
Derivation on the aggregate key costs three further conditions, all
BIP390's: every participant an extended key, none of them ranged, and
no hardened step. The first two are what make one aggregate key per
index well defined -- with both sides ranged there would be two
indexes and one wildcard to read them from. Core checks the first two,
in this order, before it reads the path.
"""
# Core splits the expression at its ")" before it reads a participant
if expression.count(")") > 1:
raise BTClibValueError(_MUSIG_CLOSES)
close = expression.rfind(")")
if close < 0:
raise BTClibValueError("unbalanced brackets")
inner = expression[len("musig(") : close]
participants, lengths = _read_split(
inner, lambda args: _musig_participants(args, prv_keys)
)
suffix = expression[close + 1 :]
# Core reads what does not start with "/" as no path, and compares the
# participants' steps before anything refuses it
path = suffix if suffix.startswith("/") else ""
if path:
if any(not key.xkey for key in participants):
err_msg = "musig() derivation requires every participant to be extended"
raise BTClibValueError(err_msg)
if any(key.is_ranged for key in participants):
raise BTClibValueError(_RANGED_PARTICIPANT)
der_path, wildcard, multipath = _musig_der_path(path)
if path != suffix:
_assert_same_length(lengths)
raise BTClibValueError("not a musig() derivation path: '/' expected")
_assert_musig_multipath(
participants=lengths,
path=multipath,
)
return KeyExpression(
participants=participants, der_path=der_path, wildcard=wildcard
)
def _parse_key(
expression: str,
prv_keys: dict[str, str],
*,
x_only: bool,
compressed: bool,
musig_allowed: bool,
) -> KeyExpression:
"""Return the KeyExpression of a BIP380 KEY expression.
A step is refused, for `multipath_descriptors` to expand first, unless
`_steps_set_aside` is in force: the key is then read on its first path.
Never echo the key in an error message: a KEY expression is a WIF or
an xprv as often as it is a public key, and an error message ends up
in logs and in bug reports.
The hardening symbol is the last one the expression used, read in the
order it is written: the origin's path, then the key's, then the
wildcard. A fixed key hardens nothing and keeps the default, there
being no step of it to write.
A ``musig()`` is read before the key origin and not after it, which is
Bitcoin Core's order and where BIP390's "cannot be nested" comes from:
the expression is a musig one or it is not, so an origin in front of it
is not a musig expression with an origin -- it is a key that is no key.
"""
try:
return _read_key(
expression,
prv_keys,
x_only=x_only,
compressed=compressed,
musig_allowed=musig_allowed,
)
except _MultipathKeyError as e:
steps = _SET_ASIDE.get()
if steps is None:
raise
steps.append(e.length)
return _read_key(
_first_path(expression),
prv_keys,
x_only=x_only,
compressed=compressed,
musig_allowed=musig_allowed,
)
def _read_key(
expression: str,
prv_keys: dict[str, str],
*,
x_only: bool,
compressed: bool,
musig_allowed: bool,
) -> KeyExpression:
"""Return the KeyExpression of a KEY expression, a step refused."""
if expression.startswith("musig("):
_assert_musig_allowed(musig_allowed=musig_allowed)
return _parse_musig(expression, prv_keys)
origin, origin_hardening, rest = _origin_and_rest(expression)
key, separator, path = rest.partition("/")
extended = _is_extended_key(key)
_assert_key_characters(rest, extended=extended)
if extended:
steps, wildcard, wildcard_hardening = _split_wildcard(
path.split("/") if separator else []
)
# refused by `_der_path` before `_hardening` reads the same path
indexes = tuple(_der_path(steps))
hardening = origin_hardening
for symbol in (_hardening("/".join(steps)), wildcard_hardening):
hardening = symbol or hardening
return KeyExpression(
origin=origin,
xkey=_neutered(key, prv_keys),
der_path=indexes,
wildcard=wildcard,
hardening=hardening or _HARDENING,
)
pub_key, was_x_only = _fixed_pub_key(key, x_only=x_only)
key_expression = KeyExpression(
origin=origin,
pub_key=pub_key,
x_only=was_x_only,
hardening=origin_hardening or _HARDENING,
wif_prefix=b"" if _HEX.fullmatch(key) else base58_decode(key)[:1],
)
if compressed and not key_expression.is_compressed:
raise BTClibValueError("uncompressed public keys are not allowed here")
return key_expression