# 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.
"""Elliptic Curve Diffie-Hellman (ECDH)."""
from __future__ import annotations
from typing import overload
from . import BytesLike, CData, MutableBytesLike, ffi, keys, lib
from ._scalar import scalar
from ._secret import take, wipe
from .context import _checked, ctx
__all__ = ["shared_point", "shared_secret"]
# the SEC1 tags of the two serializations `keys.serialize` answers: the
# compressed one adds the parity of y to the even tag
_EVEN_Y = 0x02
_UNCOMPRESSED = 0x04
@overload
def _shared_secret_(pubkey: CData, prvkey: BytesLike | int) -> bytes: ...
@overload
def _shared_secret_(
pubkey: CData, prvkey: BytesLike | int, *, into: MutableBytesLike
) -> None: ...
@overload
def _shared_secret_(
pubkey: CData, prvkey: BytesLike | int, *, into: MutableBytesLike | None
) -> bytes | None: ...
def _shared_secret_(
pubkey: CData, prvkey: BytesLike | int, *, into: MutableBytesLike | None = None
) -> bytes | None:
"""Compute the ECDH shared secret from an already-parsed public key.
The private half of `shared_secret`, for a caller who already holds
the other party's parsed key -- one exchanging with the same
counterparty more than once, or one that validated the key on
receipt: see the package docstring for what the two underscores mean
throughout.
Args:
pubkey: the other party's already-parsed public key, as
`keys.parse` returns.
prvkey: this party's private key, 32 bytes or an int below
2**256.
into: a writable 32-byte buffer to receive the result, instead
of the `bytes` this otherwise returns. See `_secret.take`
and SECURITY.md for what that does and does not buy.
Returns:
The 32-byte shared secret, the SHA256 of the compressed shared
point as `shared_secret` documents it -- or None where `into`
was given and holds it.
Raises:
TypeError: if `into` is not a writable bytearray or memoryview
of octets.
ValueError: if the public key is not one libsecp256k1 will read
-- a NULL pointer, or a `secp256k1_pubkey` nothing has
written to -- the message then being libsecp256k1's own, as
`context.check` words it; or if the private key is not 32
bytes, does not fit in them, or is not a valid scalar.
"""
prvkey_bytes = scalar(prvkey, "private key")
output = ffi.new("char[32]")
# a NULL hash function selects secp256k1_ecdh_hash_function_sha256,
# which writes 32 bytes to output
try:
computed = _checked(
lib.secp256k1_ecdh, ctx, output, pubkey, prvkey_bytes, ffi.NULL, ffi.NULL
)
except ValueError:
# libsecp256k1 computes with a key it cannot read, and what it
# wrote here can depend on the private key
wipe(output)
raise
if not computed:
raise ValueError("invalid private key")
return take(output, into=into)
@overload
def shared_secret(pubkey_bytes: BytesLike, prvkey: BytesLike | int) -> bytes: ...
@overload
def shared_secret(
pubkey_bytes: BytesLike, prvkey: BytesLike | int, *, into: MutableBytesLike
) -> None: ...
@overload
def shared_secret(
pubkey_bytes: BytesLike, prvkey: BytesLike | int, *, into: MutableBytesLike | None
) -> bytes | None: ...
[docs]
def shared_secret(
pubkey_bytes: BytesLike,
prvkey: BytesLike | int,
*,
into: MutableBytesLike | None = None,
) -> bytes | None:
"""Compute the ECDH shared secret.
The result is the SHA256 of the compressed shared point, i.e. the
libsecp256k1 default hash function; it is computed in constant time.
The hash function is not configurable, by decision. libsecp256k1
takes it as a C callback, so exposing it would mean calling back into
python from the middle of the computation, with the shared point
passing through python objects; and it would buy nothing, the point
being available as `shared_point(pubkey_bytes, prvkey)`, through
this same call and its constant-time `secp256k1_ecmult_const`. A
protocol needing another derivation applies it to that: SHA256 of
its compressed form is what this function returns. Wanting both of
them is where the private halves earn their keep -- one
`keys.parse`, then `_shared_secret_` and `_shared_point_` of it --
the two public halves parsing the same key twice.
Args:
pubkey_bytes: the other party's public key, 33 or 65 bytes.
prvkey: this party's private key, 32 bytes or an int below
2**256.
into: a writable 32-byte buffer to receive the result, instead
of the `bytes` this otherwise returns. See `_secret.take`
and SECURITY.md for what that does and does not buy.
Returns:
The 32-byte shared secret -- or None where `into` was given and
holds it.
Raises:
TypeError: if `into` is not a writable bytearray or memoryview
of octets.
ValueError: if the public key is not a valid point, if the
private key is not 32 bytes or does not fit in them, or if
it is not a valid scalar.
"""
return _shared_secret_(keys.parse(pubkey_bytes), prvkey, into=into)
@overload
def _shared_point_(
pubkey: CData, prvkey: BytesLike | int, compressed: bool = True
) -> bytes: ...
@overload
def _shared_point_(
pubkey: CData,
prvkey: BytesLike | int,
compressed: bool = True,
*,
into: MutableBytesLike,
) -> None: ...
@overload
def _shared_point_(
pubkey: CData,
prvkey: BytesLike | int,
compressed: bool = True,
*,
into: MutableBytesLike | None,
) -> bytes | None: ...
def _shared_point_(
pubkey: CData,
prvkey: BytesLike | int,
compressed: bool = True,
*,
into: MutableBytesLike | None = None,
) -> bytes | None:
"""Compute the ECDH shared point from an already-parsed public key.
The private half of `shared_point`, for a caller who already holds
the other party's parsed key: see the package docstring for what the
two underscores mean throughout.
Args:
pubkey: the other party's already-parsed public key, as
`keys.parse` returns.
prvkey: this party's private key, 32 bytes or an int below
2**256.
compressed: whether to return 33 bytes rather than 65.
into: a writable buffer of 33 bytes, or of 65 where `compressed`
is False, to receive the result instead of the `bytes` this
otherwise returns. See `_secret.take` and SECURITY.md for
what that does and does not buy.
Returns:
The serialized shared point, as `shared_point` documents it --
or None where `into` was given and holds it.
Raises:
TypeError: if `into` is not a writable bytearray or memoryview
of octets.
ValueError: if the public key is not one libsecp256k1 will read,
as `_shared_secret_` says; if the private key is not 32
bytes, does not fit in them, or is not a valid scalar; or if
`into` is not the length of the serialization.
"""
prvkey_bytes = scalar(prvkey, "private key")
coordinates = ffi.new("unsigned char[64]")
try:
computed = _checked(
lib.secp256k1_ecdh,
ctx,
coordinates,
pubkey,
prvkey_bytes,
lib.btclib_secp256k1_ecdh_hash_function_xy,
ffi.NULL,
)
except ValueError:
# what `_shared_secret_` wipes, and for its reason
wipe(coordinates)
raise
if not computed:
# libsecp256k1 multiplies by one in place of a scalar it refuses,
# so what the hash function copied is the public key itself and
# there is nothing here to wipe
raise ValueError("invalid private key")
# the octets `keys.serialize` would write, assembled from x || y
# rather than through a `keys.parse` of them, which would prove again
# a point libsecp256k1 has just computed, and with a variable-time
# validation. cdata to cdata: no `bytes` of the point is made before
# `take`, and the tag is arithmetic on the parity rather than a
# branch on it
if compressed:
point = ffi.new("unsigned char[33]")
point[0] = _EVEN_Y | (coordinates[63] & 1)
ffi.memmove(point + 1, coordinates, 32)
else:
point = ffi.new("unsigned char[65]")
point[0] = _UNCOMPRESSED
ffi.memmove(point + 1, coordinates, 64)
wipe(coordinates)
return take(point, into=into)
@overload
def shared_point(
pubkey_bytes: BytesLike, prvkey: BytesLike | int, compressed: bool = True
) -> bytes: ...
@overload
def shared_point(
pubkey_bytes: BytesLike,
prvkey: BytesLike | int,
compressed: bool = True,
*,
into: MutableBytesLike,
) -> None: ...
@overload
def shared_point(
pubkey_bytes: BytesLike,
prvkey: BytesLike | int,
compressed: bool = True,
*,
into: MutableBytesLike | None,
) -> bytes | None: ...
[docs]
def shared_point(
pubkey_bytes: BytesLike,
prvkey: BytesLike | int,
compressed: bool = True,
*,
into: MutableBytesLike | None = None,
) -> bytes | None:
"""Multiply a public key by a private key, in constant time.
The point `keys.pubkey_tweak_mul` answers, and the one `shared_secret`
hashes: this is `secp256k1_ecdh`, whose multiplication is
`secp256k1_ecmult_const`, constant time in the scalar, where
`pubkey_tweak_mul` runs the variable-time `secp256k1_ecmult`. So a
secret scalar belongs here -- an ECDH private key, BIP352's scan key
-- and a public one, a verification equation's, in `keys`.
`secp256k1_ecdh` answers the point only to a hash function, which
libsecp256k1 takes as a C callback. This one is C too, compiled into
the vendored library by this package's build: it copies x and y out,
and nothing enters python until the call has returned. A python
callback would run in the middle of the constant-time call, with the
point in python objects, and is why `shared_secret`'s hash is not
configurable.
What the guarantee covers is the multiplication. The serialization is
assembled in python from the coordinates, and the point is then a
python object like any other secret answered here: SECURITY.md is
what that does and does not mean.
Args:
pubkey_bytes: the other party's public key, 33 or 65 bytes.
prvkey: this party's private key, 32 bytes or an int below
2**256.
compressed: whether to return 33 bytes rather than 65.
into: a writable buffer of 33 bytes, or of 65 where `compressed`
is False, to receive the result instead of the `bytes` this
otherwise returns. See `_secret.take` and SECURITY.md for
what that does and does not buy.
Returns:
The serialized point kP, as `keys.serialize` writes it -- or None
where `into` was given and holds it.
Raises:
TypeError: if `into` is not a writable bytearray or memoryview
of octets.
ValueError: if the public key is not a valid point, if the
private key is not 32 bytes or does not fit in them, if it
is zero or at or above the group order, or if `into` is not
the length of the serialization.
Example:
>>> from btclib_secp256k1 import ecdh, keys
>>> # 3 * (7 * G) is 21 * G
>>> point = ecdh.shared_point(keys.pubkey_from_prvkey(7), 3)
>>> point == keys.pubkey_from_prvkey(21)
True
"""
return _shared_point_(keys.parse(pubkey_bytes), prvkey, compressed, into=into)