# 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.
"""`secp256k1_rangeproof.h`: proving a commitment's value lies in a range.
A Borromean-ring-signature variant of the Back-Maxwell range proofs from
the Confidential Assets paper, over a `zkp.generator.pedersen_commit`
commitment rather than the value itself: `sign` authors a proof that the
committed value lies in `[min_value, min_value + 2**mantissa)`, `verify`
checks one, and `rewind` recovers what `sign`'s own author embedded --
the value, the blinding factor and up to `MAX_MESSAGE_LEN` bytes of
message -- from a proof made with a matching nonce. `info` reads the
public part of a proof without either.
`MAX_MESSAGE_LEN` is the one constant the header itself fixes
(`SECP256K1_RANGEPROOF_MAX_MESSAGE_LEN`); the other size this module
needs, the worst-case proof buffer, has no such `#define` -- it is what
`secp256k1_rangeproof_max_size` answers, and `sign` asks the library for
it rather than carrying a copy of the 5134 the header's own comment and
zkp's `tests_impl.h` state as today's answer, which is not a promise
about tomorrow's. `MAX_MESSAGE_LEN` cannot be asked of the library the
same way: nothing in `secp256k1_rangeproof.h` exposes it as a function,
and cffi's own cdef generation (`generate_def`'s `gcc -P -E`) preprocesses
every `#define` away before `lib` is built from what remains, so no
runtime call could read it even if this module wanted one to. The
literal is what stands in its place, and a submodule bump that moved it
would go unnoticed by anything here.
Every function here needs `BTCLIB_LIBSECP256K1_ZKP`'s extension, and none
of them reaches for it at import time, for the reason
`btclib_secp256k1.zkp.generator`'s own module docstring gives.
`rewind` takes its blinding factor out through
`btclib_secp256k1._secret.take`, the same cross-`ffi`-safe function
`zkp.generator`'s own module docstring names, but offers no `into`: the
blinding factor is one member of the 5-tuple `rewind` answers, where an
argument could not say which -- SECURITY.md gives the same reason for
the two `silentpayments` secrets that offer none either.
`borromean_verify` wraps a different function of the same header,
`secp256k1_borromean_verify`, over the ring signature the rangeproof
itself is built from rather than over a proof or a commitment --
verify-only, `secp256k1_borromean_sign` staying internal to
secp256k1-zkp. `btclib-org/btclib-secp256k1#828`'s own issue body has
the serialization it takes and why the submodule now tracks a fork.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from btclib_secp256k1 import BytesLike, CData
from btclib_secp256k1._scalar import in_range, octets, scalar
from btclib_secp256k1._secret import take, wipe
from . import context, generator
__all__ = [
"MAX_MESSAGE_LEN",
"borromean_verify",
"info",
"max_size",
"rewind",
"sign",
"verify",
]
# SECP256K1_RANGEPROOF_MAX_MESSAGE_LEN, the one constant the header
# itself defines
MAX_MESSAGE_LEN = 3968
def _gen(lib: Any, gen_bytes: BytesLike | None) -> CData:
"""Resolve the generator argument every function below takes.
`h()` by default.
Args:
lib: this subpackage's own `lib`.
gen_bytes: the generator, 33 bytes, or None for the commitment's
usual `generator.h()`.
Returns:
The parsed libsecp256k1-zkp generator object.
Raises:
ValueError: if `gen_bytes` is given and is not a valid generator.
"""
if gen_bytes is None:
return lib.secp256k1_generator_h
return generator.parse(gen_bytes, "gen")
[docs]
def max_size(max_value: int, min_bits: int) -> int:
"""Return an upper bound on the size of a rangeproof with these parameters.
An actual proof may be smaller: in particular this always
overestimates the size of a single-value proof (`exp=-1`), and a
`min_value` of 0 usually, but not always, gives a proof 8 bytes
smaller than a nonzero one would.
Args:
max_value: the largest value that might be passed as `value` to
`sign`. `2**64 - 1` gives the size of the largest possible
proof, which is what `sign` itself asks for.
min_bits: the `min_bits` that will be passed to `sign`.
Returns:
The upper bound, in bytes.
Raises:
TypeError: if `max_value` or `min_bits` is not an int.
ValueError: if `max_value` is a bool or does not fit in 8 bytes,
or if `min_bits` is out of [0, 64].
"""
_ffi, lib, ctx = context._bindings()
if not isinstance(max_value, int):
raise TypeError(f"max_value must be an int, not {type(max_value).__name__}")
if isinstance(max_value, bool) or not 0 <= max_value < 2**64:
raise ValueError("max_value must be an int in [0, 2**64)")
min_bits = in_range(min_bits, "min_bits", 64)
return int(lib.secp256k1_rangeproof_max_size(ctx, max_value, min_bits))
[docs]
def verify(
commit_bytes: BytesLike,
proof: BytesLike,
extra_commit: BytesLike = b"",
gen_bytes: BytesLike | None = None,
) -> tuple[int, int] | None:
"""Verify that a committed value lies within the range the proof states.
Args:
commit_bytes: the Pedersen commitment being proved, 33 bytes.
proof: the proof.
extra_commit: additional data the proof's signature covers, as
it was passed to `sign`.
gen_bytes: the generator the commitment was made under, 33
bytes, or None for `zkp.generator.h()`.
Returns:
`(min_value, max_value)`, the range the proof establishes for
the committed value, or None where the proof does not verify --
a verdict rather than an exception, `(0, 2**64 - 1)` being a
range a proof can legitimately establish and so not usable as a
failure sentinel.
Raises:
ValueError: if `commit_bytes` or `gen_bytes` is not a valid
33-byte generator or commitment.
"""
ffi, lib, ctx = context._bindings()
commit = generator.pedersen_commitment_parse(commit_bytes, "commitment")
proof_bytes = octets(proof, "proof")
extra_bytes = octets(extra_commit, "extra_commit")
gen = _gen(lib, gen_bytes)
min_value = ffi.new("uint64_t *")
max_value = ffi.new("uint64_t *")
ok = lib.secp256k1_rangeproof_verify(
ctx,
min_value,
max_value,
commit,
proof_bytes,
len(proof_bytes),
extra_bytes or ffi.NULL,
len(extra_bytes),
gen,
)
if not ok:
return None
return int(min_value[0]), int(max_value[0])
[docs]
def rewind(
commit_bytes: BytesLike,
proof: BytesLike,
nonce: BytesLike,
extra_commit: BytesLike = b"",
gen_bytes: BytesLike | None = None,
) -> tuple[bytes, int, bytes, int, int]:
"""Verify a proof and recover what its author embedded in it.
Args:
commit_bytes: the Pedersen commitment being proved, 33 bytes.
proof: the proof.
nonce: the 32-byte secret nonce `sign` was called with -- knowing
it is what makes this call, rather than `verify`, possible.
extra_commit: additional data the proof's signature covers, as
it was passed to `sign`.
gen_bytes: the generator the commitment was made under, 33
bytes, or None for `zkp.generator.h()`.
Returns:
`(blind, value, message, min_value, max_value)`: the 32-byte
blinding factor and the exact value `sign` committed to, the
message bytes it embedded (trailing zeros beyond what it wrote
included, up to `MAX_MESSAGE_LEN`), and the range the proof
establishes -- the same `min_value`/`max_value` `verify` answers.
Raises:
ValueError: if `commit_bytes` or `gen_bytes` is not a valid
33-byte generator or commitment, if `nonce` is not 32 bytes,
or if the proof does not verify or the rewind fails --
libsecp256k1-zkp reports both through the same return code.
"""
ffi, lib, ctx = context._bindings()
commit = generator.pedersen_commitment_parse(commit_bytes, "commitment")
proof_bytes = octets(proof, "proof")
nonce_bytes = octets(nonce, "nonce", 32)
extra_bytes = octets(extra_commit, "extra_commit")
gen = _gen(lib, gen_bytes)
# char, not unsigned char: ffi.unpack of the latter answers a list of
# ints rather than bytes, which message_out is read back as below;
# blind_out matches for the same reason and is read back through
# _secret.take instead, which does not care which it is
blind_out = ffi.new(f"char[{generator._BLIND_SIZE}]")
value_out = ffi.new("uint64_t *")
message_out = ffi.new(f"char[{MAX_MESSAGE_LEN}]")
outlen = ffi.new("size_t *", MAX_MESSAGE_LEN)
min_value = ffi.new("uint64_t *")
max_value = ffi.new("uint64_t *")
try:
ok = lib.secp256k1_rangeproof_rewind(
ctx,
blind_out,
value_out,
message_out,
outlen,
nonce_bytes,
min_value,
max_value,
commit,
proof_bytes,
len(proof_bytes),
extra_bytes or ffi.NULL,
len(extra_bytes),
gen,
)
if not ok:
raise ValueError("proof does not verify, or rewind failed")
return (
take(blind_out),
int(value_out[0]),
ffi.unpack(message_out, int(outlen[0])),
int(min_value[0]),
int(max_value[0]),
)
finally:
# the library writes the message before it can still refuse, and
# the message can be secret (Elements keeps the asset blinding
# factor there): all three are wiped on every path, `blind_out`
# again after `take`
wipe(blind_out)
wipe(value_out)
wipe(message_out)
# more arguments than PLR0913 allows. `dsa.sign`'s own such comment has
# the reasoning for keeping them rather than folding them into an options
# object: `commit_bytes`, `blind`, `nonce` and `value` are what every
# call needs, and the keyword-only ones after them are the header's own
# tuning knobs, none of which groups with another
[docs]
def sign( # noqa: PLR0913
commit_bytes: BytesLike,
blind: BytesLike | int,
nonce: BytesLike,
value: int,
*,
min_value: int = 0,
exp: int = 0,
min_bits: int = 0,
message: BytesLike = b"",
extra_commit: BytesLike = b"",
gen_bytes: BytesLike | None = None,
) -> bytes:
"""Prove that a committed value lies within a range.
Args:
commit_bytes: the Pedersen commitment being proved, as
`zkp.generator.pedersen_commit` built it from `blind`,
`value` and `gen_bytes`.
blind: the 32-byte blinding factor `pedersen_commit` used, or an
int below 2**256. May be all-zeros only where `min_bits` is
at least 3, a side effect of the underlying construction
rather than a deliberate choice.
nonce: 32 bytes that must be unique to this call and never
reused, and kept secret except from parties allowed to
`rewind` the proof: anyone who learns it recovers `value`
and `blind`, and reusing it across two proofs may expose
`blind` even to a party that never learns it.
value: the value being committed to, an int in [0, 2**64). If
`min_value` or `exp` is nonzero, `value` must be in
[0, 2**63) so the proven range does not wrap past 2**64.
min_value: the minimum value the proof states the commitment can
have. If nonzero, `value` must be in [0, 2**63) for the same
reason given above.
exp: the base-10 exponent below which digits are made public, in
[-1, 18] -- a larger value makes a smaller proof at the cost
of revealing more of it; -1 reveals `value` itself, and 0 is
the most private. If nonzero, `value` must be in [0, 2**63)
for the same reason given above.
min_bits: how many bits of the value to keep private, in
[0, 64] -- 0 asks for the library's own minimal choice.
message: up to `MAX_MESSAGE_LEN` bytes to embed, recoverable by
`rewind` with the same `nonce`.
extra_commit: additional data for the proof's signature to
cover; `verify` and `rewind` need the same bytes back.
gen_bytes: the generator `commit_bytes` was committed under, 33
bytes, or None for `zkp.generator.h()`.
Returns:
The proof.
Raises:
TypeError: if `value`, `min_value`, `exp` or `min_bits` is not
an int.
ValueError: if `commit_bytes` or `gen_bytes` is not a valid
33-byte generator or commitment, if `blind` or `nonce` is
not 32 bytes or `blind` does not fit in them, if `value` or
`min_value` is a bool or does not fit in 8 bytes, if `exp`
or `min_bits` is out of range, if `message` is longer than
`MAX_MESSAGE_LEN`, or if libsecp256k1-zkp refuses the call --
which also reports the "one in 2**100" failure the header's
own docstring names, indistinguishably from a bad argument
that reached this far.
"""
ffi, lib, ctx = context._bindings()
commit = generator.pedersen_commitment_parse(commit_bytes, "commitment")
blind_bytes = scalar(blind, "blind")
nonce_bytes = octets(nonce, "nonce", 32)
if not isinstance(value, int):
raise TypeError(f"value must be an int, not {type(value).__name__}")
if isinstance(value, bool) or not 0 <= value < 2**64:
raise ValueError("value must be an int in [0, 2**64)")
if not isinstance(min_value, int):
raise TypeError(f"min_value must be an int, not {type(min_value).__name__}")
if isinstance(min_value, bool) or not 0 <= min_value < 2**64:
raise ValueError("min_value must be an int in [0, 2**64)")
if not isinstance(exp, int):
raise TypeError(f"exp must be an int, not {type(exp).__name__}")
if not -1 <= exp <= 18:
raise ValueError("exp must be in [-1, 18]")
min_bits = in_range(min_bits, "min_bits", 64)
message_bytes = octets(message, "message")
if len(message_bytes) > MAX_MESSAGE_LEN:
raise ValueError(f"message must be at most {MAX_MESSAGE_LEN} bytes")
extra_bytes = octets(extra_commit, "extra_commit")
gen = _gen(lib, gen_bytes)
buffer_size = int(lib.secp256k1_rangeproof_max_size(ctx, 2**64 - 1, 0))
# char, not unsigned char, for the reason message_out above is: this
# is read back with ffi.unpack too
proof = ffi.new(f"char[{buffer_size}]")
plen = ffi.new("size_t *", buffer_size)
ok = lib.secp256k1_rangeproof_sign(
ctx,
proof,
plen,
min_value,
commit,
blind_bytes,
nonce_bytes,
exp,
min_bits,
value,
message_bytes or ffi.NULL,
len(message_bytes),
extra_bytes or ffi.NULL,
len(extra_bytes),
gen,
)
if not ok:
raise ValueError("rangeproof signing failed")
return bytes(ffi.unpack(proof, int(plen[0])))
[docs]
def info(proof: BytesLike) -> tuple[int, int, int, int]:
"""Extract the public parameters of a rangeproof, without verifying it.
Args:
proof: the proof.
Returns:
`(exp, mantissa, min_value, max_value)`: the exponent `sign` was
called with (-1 meaning the value is public), the number of bits
the proof covers, and the range it states -- the same
`min_value`/`max_value` `verify` answers, without checking that
the commitment matches.
Raises:
ValueError: if the proof cannot be decoded.
"""
ffi, lib, ctx = context._bindings()
proof_bytes = octets(proof, "proof")
exp = ffi.new("int *")
mantissa = ffi.new("int *")
min_value = ffi.new("uint64_t *")
max_value = ffi.new("uint64_t *")
if not lib.secp256k1_rangeproof_info(
ctx, exp, mantissa, min_value, max_value, proof_bytes, len(proof_bytes)
):
raise ValueError("invalid proof")
return int(exp[0]), int(mantissa[0]), int(min_value[0]), int(max_value[0])
[docs]
def borromean_verify(
e0: BytesLike,
s: BytesLike,
m: BytesLike,
pubkeys: Sequence[BytesLike],
rsizes: Sequence[int],
) -> bool:
"""Verify a Borromean ring signature, over serialized arguments.
`secp256k1_borromean_sign` -- the signer -- stays internal to
secp256k1-zkp, so this is verify-only, one direction of an
interoperation claim a downstream consumer's own module docstring
makes rather than checks. `btclib-org/btclib-secp256k1#828` is the
issue that asked for it, and its body has the serialization this
wraps: `e0` is the 32-octet initial challenge, `s` is `n` 32-octet
scalars in ring-major order (one per `pubkeys` entry), `m` is the
message of any length, `pubkeys` is one ordinary SEC compressed
public key per ring member in that same ring-major order -- not the
quadratic-residue encoding `zkp.generator` and
`zkp.generator.pedersen_commit` use -- and `rsizes` is the size of
each ring, summing to `len(pubkeys)`.
Args:
e0: the initial challenge, 32 bytes.
s: the ring signature's scalars, ring-major, 32 bytes per
`pubkeys` entry.
m: the message the signature covers, any length.
pubkeys: one public key per ring member, ring-major, ordinary
SEC compressed or uncompressed.
rsizes: the size of each ring, each a non-bool int in [0, 128];
must sum to `len(pubkeys)`.
Returns:
Whether the signature verifies -- `False` covers both a
well-formed signature that does not verify and a scalar in `s`
at or above the curve order, neither being misuse.
Raises:
TypeError: if an `rsizes` element is not an int.
ValueError: if `e0` is not 32 bytes, if `s` is not exactly 32
bytes per `pubkeys` entry, if any of `pubkeys` is not a
valid public key, if `pubkeys` is empty or holds more than
128 entries, if `rsizes` is empty or holds more than 32
entries, if an `rsizes` element is a bool or outside
[0, 128] -- the largest a single ring can be, `pubkeys`
holding at most 128 entries in all -- or if `sum(rsizes)`
does not equal `len(pubkeys)`: the same ring-shape
`ARG_CHECK` conditions `secp256k1_borromean_verify` itself
enforces, matched here rather than left to its illegal
callback or to cffi's own refusal of a negative `size_t`.
"""
ffi, lib, ctx = context._bindings()
if not 1 <= len(pubkeys) <= 128:
raise ValueError("pubkeys must hold between 1 and 128 entries")
if not 1 <= len(rsizes) <= 32:
raise ValueError("rsizes must hold between 1 and 32 entries")
checked_rsizes = []
for index, rsize in enumerate(rsizes):
if not isinstance(rsize, int):
raise TypeError(
f"rsizes[{index}] must be an int, not {type(rsize).__name__}"
)
if isinstance(rsize, bool) or not 0 <= rsize <= 128:
raise ValueError(f"rsizes[{index}] must be an int in [0, 128]")
checked_rsizes.append(rsize)
if sum(checked_rsizes) != len(pubkeys):
raise ValueError("sum(rsizes) must equal len(pubkeys)")
e0_bytes = octets(e0, "e0", 32)
s_bytes = octets(s, "s", 32 * len(pubkeys))
m_bytes = octets(m, "m")
parsed = [
context._pubkey_parse(
ffi, lib, ctx, pubkey_bytes, f"public key at index {index}"
)
for index, pubkey_bytes in enumerate(pubkeys)
]
pubkeys_arr = context._array_or_null(ffi, "secp256k1_pubkey *[]", parsed)
rsizes_arr = context._array_or_null(ffi, "size_t[]", checked_rsizes)
ok = lib.secp256k1_borromean_verify(
ctx,
e0_bytes,
s_bytes,
m_bytes,
len(m_bytes),
pubkeys_arr,
len(pubkeys),
rsizes_arr,
len(rsizes),
)
return bool(ok)