# 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.
"""ECIES in the BIE1 layout, with the block cipher supplied by the caller.
BIE1 is the ECIES variant the bitcoin world converged on: Electrum's
`encrypt` / `decrypt` commands, bitcore and bitcoinjs all speak it, and it
is what turns the shared secret of :mod:`ecc.ecc.dh` into an actual
encrypted message. A sender who has the recipient's public key:
- generates an ephemeral key pair and runs ECDH against that public key
- takes the sha512 of the *compressed* shared point and splits the 64
bytes into iv | key_e | key_m, 16 | 16 | 32
- encrypts the message with AES-128-CBC and PKCS#7 padding, under key_e
and that iv
- frames it as ``b"BIE1" + ephemeral pub key + ciphertext``, appends the
HMAC-SHA256 of that framing under key_m, and base64-encodes the lot
The recipient re-derives the same three values from the ephemeral public
key carried in the envelope, so nothing but the envelope has to travel.
**Why the cipher is a parameter.** This package has no cryptographic
dependency:
hashlib and the secp256k1 bindings are the whole of it, and its install
story is "Python plus the bindings". AES is not in the standard library,
so shipping BIE1 whole would mean taking `cryptography` or `pycryptodome`
for one convenience function, on every user, forever. A pure-Python AES
inside this package is the worse answer rather than the cheaper one: a
table-driven block cipher leaks its key through cache timing, and a
timing-vulnerable cipher is a worse thing to ship than no cipher at all.
So this module implements the half that is this package's business -- the key
agreement, the derivation, the framing, the MAC and the armor -- and takes
the other half as two callables. Interoperability is preserved for anyone
who brings their own AES, and the dependency is theirs to choose.
**The contract those callables must honour.** Both are called
positionally, as ``f(key, iv, data)``, with a 16-byte key_e and a 16-byte
iv:
- ``encrypt_f(key, iv, plaintext)`` returns AES-128-CBC ciphertext with
PKCS#7 padding already applied. The padding is not optional and not
this package's to add: it is what makes the result a whole number of 16-byte
blocks, and PKCS#7 appends a *full* block when the plaintext is already
block-aligned, so the ciphertext is always strictly longer than the
plaintext. :func:`encrypt` checks both of those and refuses a cipher
that does not pad, which is the mistake that otherwise ships an envelope
no other implementation can read back.
- ``decrypt_f(key, iv, ciphertext)`` is the inverse, and strips the
padding. It is called only after the MAC has verified, so it is never
handed a ciphertext this library has not authenticated.
Anything other than AES-128-CBC with PKCS#7 will round-trip against
itself and interoperate with nothing, which is the whole point of the
scheme; the callables are a way to source AES, not a choice of cipher.
**What BIE1 is not.** It is not a BIP and has no specification: its definition
is its implementations, of which Electrum's is the most read. This module is
written against `ecies_encrypt_message` and `ecies_decrypt_message` in
electrum/crypto.py and matches them byte for byte, and the test suite decrypts
ciphertexts taken from Electrum's own test vectors. That is also why nothing
here is parameterized by curve or hash function, unlike the rest of
:mod:`ecc.ecc`: every implementation that exists is secp256k1 with
sha512 and HMAC-SHA256, so a parameter would advertise an interoperability that
has no other end.
The magic bytes are a parameter, though, because Electrum itself varies
them: `BIE1` for a user password and `BIE2` for an xpub-derived one, over
an otherwise identical layout.
"""
from __future__ import annotations
import base64
import hmac
import secrets
from dataclasses import dataclass
from hashlib import sha256, sha512
from ecc._utils import assert_type, bytes_from_octets, str_from_string
from ecc.alias import CipherF, Integer, Octets, String
from ecc.curves import (
PubKey,
bytes_from_point,
mult,
point_from_pub_key,
scalar_from_prv_key,
secp256k1,
)
from ecc.curves.sec_point import _mult_sec_compressed, _sec_from_pub_key
from ecc.exceptions import (
EccRuntimeError,
EccValueError,
)
__all__ = [
"MAGIC",
"Envelope",
"decrypt",
"derive_keys",
"encrypt",
]
# the four bytes Electrum writes for a user-password envelope, and the name
# the scheme goes by. A comment and not an attribute docstring: the
# check-docstring-first hook reads the second string literal of a module as
# a second module docstring, whatever indentation it has
MAGIC = b"BIE1"
_MAGIC_SIZE = 4
# compressed secp256k1 SEC point: BIE1 never carries the uncompressed form,
# and the offsets below are fixed because of it
_EPH_PUB_KEY_SIZE = 33
_MAC_SIZE = 32
# the AES block, which the layout inherits from the cipher it names even
# though this module does not run it
_BLOCK_SIZE = 16
[docs]
def derive_keys(prv_key: Integer, pub_key: PubKey) -> tuple[bytes, bytes, bytes]:
"""Return the (iv, key_e, key_m) triple BIE1 derives from an ECDH exchange.
The shared point is serialized compressed and hashed with sha512; the
64 bytes are cut 16 | 16 | 32. Both ends call this: the sender with the
ephemeral private key and the recipient's public key, the recipient
with its own private key and the ephemeral public key from the
envelope.
The multiplication is delegated and the derivation is not;
:mod:`ecc.ecc.dh` says why.
No infinity check on the shared point, unlike
:func:`ecc.ecc.dh.diffie_hellman`, which takes a bare int: the
scalars that could produce one are exactly those `scalar_from_prv_key`
rejects, and both inputs here go through a validating conversion.
"""
q = scalar_from_prv_key(prv_key)
# the public key stays octets: no coordinate of it is read here, and
# `_mult_sec_compressed` is the multiplication without a point on
# either side of it where the bindings serve.
# `_sec_from_pub_key` hands them over unproven -- the multiplication
# below is the proof, its own parse refusing what is not a point of
# the curve, the same trade `ecc.ecc.dsa` makes for its own delegated
# calls (issue btclib-org/btclib#887)
sec = _sec_from_pub_key(pub_key, secp256k1)
shared_point_bytes = _mult_sec_compressed(sec, q, secp256k1)
digest = sha512(shared_point_bytes).digest()
return digest[:16], digest[16:32], digest[32:]
[docs]
@dataclass(frozen=True, init=False)
class Envelope:
"""The BIE1 framing: magic, ephemeral public key, ciphertext, MAC.
Everything here is cipher-free. The ciphertext is carried as opaque
bytes, so an envelope can be parsed, validated, MAC-checked and
re-serialized by code that has no AES at all.
"""
magic: bytes
eph_pub_key: bytes
ciphertext: bytes
mac: bytes
# written out rather than an InitVar[bool] field and a __post_init__:
# see the comment on dsa.Sig.__init__
def __init__(
self,
magic: Octets,
eph_pub_key: Octets,
ciphertext: Octets,
mac: Octets,
*,
check_validity: bool = True,
) -> None:
# `Octets` and the coercion an octet field takes in its
# constructor. Assigned as they came, a value with no `len` would
# leave `assert_valid` as a TypeError about a builtin, and one with
# a `len` would be reported as a *size*: a two-character str is not
# a two-byte magic, it is not a magic at all. Hex is what a caller
# holds as often as bytes, an envelope being copied and pasted
object.__setattr__(self, "magic", bytes_from_octets(magic))
object.__setattr__(self, "eph_pub_key", bytes_from_octets(eph_pub_key))
object.__setattr__(self, "ciphertext", bytes_from_octets(ciphertext))
object.__setattr__(self, "mac", bytes_from_octets(mac))
if check_validity:
self.assert_valid()
[docs]
def assert_valid(self) -> None:
"""Raise unless every field has the size and shape BIE1 gives it.
This is the structure alone: whether the MAC is the *right* MAC is
:meth:`assert_valid_mac`, which needs a key this object does not
have.
"""
if len(self.magic) != _MAGIC_SIZE:
raise EccValueError(f"invalid magic size: {len(self.magic)} bytes")
if len(self.eph_pub_key) != _EPH_PUB_KEY_SIZE:
raise EccValueError(
f"invalid ephemeral public key size: {len(self.eph_pub_key)} bytes"
)
# on the curve, and compressed: point_from_pub_key would take the
# uncompressed form too, and the size check above is what rules it out
point_from_pub_key(self.eph_pub_key)
# a CBC ciphertext is whole blocks, and PKCS#7 makes it a non-empty
# number of them even for an empty message. Stricter than Electrum,
# which checks the total length alone: a misaligned ciphertext
# cannot have come from the cipher BIE1 names, and saying so here
# beats handing it to the caller's AES to fail on
if len(self.ciphertext) < _BLOCK_SIZE:
raise EccValueError(
f"invalid ciphertext size: {len(self.ciphertext)} bytes"
)
if len(self.ciphertext) % _BLOCK_SIZE:
raise EccValueError(
f"ciphertext is not a whole number of {_BLOCK_SIZE}-byte blocks: "
f"{len(self.ciphertext)} bytes"
)
if len(self.mac) != _MAC_SIZE:
raise EccValueError(f"invalid MAC size: {len(self.mac)} bytes")
[docs]
def mac_from_key(self, key_m: Octets) -> bytes:
"""Return the HMAC-SHA256 the framed envelope has under key_m."""
key_m = bytes_from_octets(key_m)
framing = self.magic + self.eph_pub_key + self.ciphertext
return hmac.digest(key_m, framing, sha256)
[docs]
def assert_valid_mac(self, key_m: Octets) -> None:
"""Raise unless the MAC is the one key_m produces over this envelope.
A failure here is the one error the scheme reports for two
different causes, and it cannot tell them apart: the envelope was
tampered with, or it was addressed to somebody else. A wrong
private key derives a wrong key_m, and a wrong key_m is exactly
what a forged MAC looks like.
"""
# compare_digest and not ==: the MAC is checked before anything is
# decrypted, so the comparison is the gate, and a byte-at-a-time
# early exit is what lets a forgery be searched for one byte at a time
if not hmac.compare_digest(self.mac, self.mac_from_key(key_m)):
raise EccRuntimeError(
"invalid MAC: wrong private key, or tampered envelope"
)
[docs]
@classmethod
def from_ciphertext(
cls,
eph_pub_key: Octets,
ciphertext: Octets,
key_m: Octets,
*,
magic: Octets = MAGIC,
) -> Envelope:
"""Frame an already-encrypted ciphertext and MAC it under key_m.
The magic is coerced here and not left to the constructor below,
which would take it: this method concatenates before it builds,
so a magic that is not bytes failed on the `+` rather than as the
argument it is.
"""
magic = bytes_from_octets(magic)
ciphertext = bytes_from_octets(ciphertext)
eph_pub_key = bytes_from_octets(eph_pub_key)
# the MAC covers the magic and the ephemeral key as well as the
# ciphertext, so the envelope has to exist before its own MAC does:
# build it with a placeholder, read the MAC off that framing, and
# re-build it with the real one. check_validity waits for the
# second, the first being a correct framing under a MAC known to be
# wrong -- and mac_from_key reads the framing alone, never self.mac
placeholder = cls(
magic, eph_pub_key, ciphertext, bytes(_MAC_SIZE), check_validity=False
)
return cls(magic, eph_pub_key, ciphertext, placeholder.mac_from_key(key_m))
[docs]
def serialize(self, *, check_validity: bool = True) -> bytes:
"""Return the envelope as the octets that go under the base64."""
if check_validity:
self.assert_valid()
return self.magic + self.eph_pub_key + self.ciphertext + self.mac
[docs]
@classmethod
def parse(
cls, data: Octets, *, magic: bytes = MAGIC, check_validity: bool = True
) -> Envelope:
"""Return the envelope the octets carry, at BIE1's fixed offsets.
The magic is compared here rather than left to the caller: the
first four bytes are what answer "is this a BIE1 envelope at all",
and every offset below is meaningless if they do not.
Its type is asked before that comparison, and `b64decode` is
covered by the same question, handing this one what it was given:
a magic of no bytes type is unequal to whatever the buffer starts
with, so every envelope would have been refused for the bytes it
does carry rather than for the argument that cannot be any.
"""
assert_type(magic, bytes, "magic")
data = bytes_from_octets(data)
# the shortest envelope there can be: the four fixed-size fields
# with a single block of ciphertext between them
min_size = _MAGIC_SIZE + _EPH_PUB_KEY_SIZE + _BLOCK_SIZE + _MAC_SIZE
if len(data) < min_size:
raise EccValueError(
f"invalid envelope size: {len(data)} bytes, not at least {min_size}"
)
magic_found = data[:_MAGIC_SIZE]
if magic_found != magic:
raise EccValueError(f"invalid magic bytes: {magic_found!r}, not {magic!r}")
eph_pub_key_end = _MAGIC_SIZE + _EPH_PUB_KEY_SIZE
return cls(
magic_found,
data[_MAGIC_SIZE:eph_pub_key_end],
data[eph_pub_key_end:-_MAC_SIZE],
data[-_MAC_SIZE:],
check_validity=check_validity,
)
[docs]
def b64encode(self, *, check_validity: bool = True) -> str:
"""Return the envelope as its base64 armor."""
return base64.b64encode(self.serialize(check_validity=check_validity)).decode(
"ascii"
)
[docs]
@classmethod
def b64decode(
cls, data: String, *, magic: bytes = MAGIC, check_validity: bool = True
) -> Envelope:
"""Return the envelope a base64 armor carries."""
# b64decode discards whatever is not in the base64 alphabet, which would
# make one envelope reachable from unboundedly many strings;
# validate=True rejects that junk, and requiring the canonical
# re-encoding covers what validate leaves to the interpreter -- the
# reasoning is the one any strict base64 armor makes. The coercion
# before the strip, as that method does it and for the reason issue
# btclib-org/btclib#814 gives: what is neither text nor bytes reached
# `.strip` untouched and left as an AttributeError about a missing
# method. Stripping covers bytes as well as str: the whitespace around a
# copied and pasted envelope is the one laxity worth tolerating
text = str_from_string(data, "base64 envelope")
# a str is not checked by `str_from_string`. The refusal is the one
# it gives bytes, with the position in the unstripped input, and not
# the codec's error, which names the character and holds the input
if not text.isascii():
position = next(i for i, char in enumerate(text) if ord(char) > 127)
err_msg = f"non-ascii character in base64 envelope at position {position}"
raise EccValueError(err_msg)
data_bin = text.strip().encode("ascii")
try:
data_decoded = base64.b64decode(data_bin, validate=True)
except ValueError as e: # binascii.Error
raise EccValueError(f"invalid base64 encoding: {e}") from e
if base64.b64encode(data_decoded) != data_bin:
raise EccValueError("invalid base64 encoding: not canonical")
return cls.parse(data_decoded, magic=magic, check_validity=check_validity)
[docs]
def encrypt(
msg: bytes,
pub_key: PubKey,
encrypt_f: CipherF,
*,
eph_prv_key: Integer | None = None,
magic: bytes = MAGIC,
) -> str:
"""Encrypt a message to a public key, returning the BIE1 base64 armor.
`encrypt_f` must be AES-128-CBC with PKCS#7 padding; the module
docstring has the contract in full. `eph_prv_key` overrides the random
ephemeral key, which is what makes a fixed test vector reproducible --
reusing one across two messages to the same recipient reuses the whole
key stream, so leave it alone outside of tests.
The plaintext is bytes and not Octets, alone among the message
parameters of this library: a hex string is how this package spells binary
everywhere else, and reading `"deadbeef"` as four bytes rather than as
the eight characters somebody meant to hide is not a mistake the
recipient can notice. `assert_type` is what refuses a caller's
mistake here instead of leaking whatever `encrypt_f` does with a
non-bytes plaintext (issue btclib-org/ecc#11).
"""
assert_type(msg, bytes, "msg")
if eph_prv_key is None:
# in the range [1, n-1], as everywhere else a key is generated here
eph_prv_key = 1 + secrets.randbelow(secp256k1.n - 1)
q = scalar_from_prv_key(eph_prv_key)
iv, key_e, key_m = derive_keys(q, pub_key)
ciphertext = encrypt_f(key_e, iv, msg)
# the padding check the contract promises: PKCS#7 always grows the
# plaintext, by a whole block when it is already aligned. A cipher
# handed through without padding fails here rather than three
# implementations later, and the aligned case is the one that would
# otherwise slip past a length check that only looked at the remainder
if len(ciphertext) <= len(msg):
raise EccValueError(
f"encrypt_f did not pad: {len(ciphertext)} bytes of ciphertext "
f"for {len(msg)} bytes of plaintext"
)
eph_pub_key = bytes_from_point(mult(q), compressed=True)
envelope = Envelope.from_ciphertext(eph_pub_key, ciphertext, key_m, magic=magic)
return envelope.b64encode()
[docs]
def decrypt(
armor: String,
prv_key: Integer,
decrypt_f: CipherF,
*,
magic: bytes = MAGIC,
) -> bytes:
"""Decrypt a BIE1 base64 armor with the recipient private key.
`decrypt_f` must be AES-128-CBC with PKCS#7 padding; the module
docstring has the contract in full. It is reached only once the MAC
has verified, so a wrong key or a tampered envelope raises
EccRuntimeError and the caller's cipher never runs.
"""
envelope = Envelope.b64decode(armor, magic=magic)
iv, key_e, key_m = derive_keys(prv_key, envelope.eph_pub_key)
envelope.assert_valid_mac(key_m)
return decrypt_f(key_e, iv, envelope.ciphertext)