/// @file dpf/ppvc.hpp /// @brief Point-programmable vector commitments. /// @details `dpf::ppvc` commits to a vector in `(Z/2^s Z)^n` on a /// power-of-two domain. The committer samples a hidden index, publishes a /// Naor commitment to both roots of `s` aligned 1-bit DPF pairs, and later /// opens one side of each pair. The two keys agree off that index and /// disagree on it, so the choice of side writes the hidden coordinate and /// leaves the rest of the vector fixed. A shift `delta = xi - i` moves /// that coordinate onto a public target. `dpf::k_ppvc` is `k` independent /// copies. /// /// `verify` checks the opened Naor roots. Correction words travel with the /// opened key. `check_well_formed` checks both keys of a replica the /// committer still holds, and `audit` reruns generation from a seed. /// Evaluation stores one entry per domain point, so the input bitlength /// is at most 20. The full-domain walk is `eval_full`. /// The manual is [Point-programmable vector commitments](@ref ppvc_manual). /// @copyright Copyright (c) 2019-2026 Ryan Henry and [others](@ref authors) /// @license Released under a GNU General Public v2.0 (GPLv2) license; /// see [LICENSE.md](@ref license) for details. #ifndef LIBDPF_INCLUDE_DPF_PPVC_HPP__ #define LIBDPF_INCLUDE_DPF_PPVC_HPP__ #include "hedley/hedley.h" #include #include #include #include #include #include #include #include #include #include #include "simde/simde/x86/avx2.h" #include "dpf/bit.hpp" #include "dpf/dpf_key.hpp" #include "dpf/eval_full.hpp" #include "dpf/eval_point.hpp" #include "dpf/prg.hpp" #include "dpf/random.hpp" #include "dpf/twiddle.hpp" namespace dpf { /// @brief Point-programmable vector commitment over a power-of-two domain. /// @tparam InputT unsigned domain type. The domain size is `2` to the bit length of `InputT`. /// @tparam Width value bit width `s`, from 1 to 64. Coordinates live in `Z/2^s Z`. /// @tparam Sigma Naor statistical parameter. The string length is `m = 3 * 128 + Sigma` bits. /// @tparam PRG generator used for the DPF tree and for Naor's `G`. Defaults to `dpf::prg::aes128`. template struct ppvc { static_assert(std::is_unsigned_v, "ppvc domain must be an unsigned integer"); static_assert(Width >= 1 && Width <= 64, "ppvc width must be in 1..64"); static_assert(Sigma % 8 == 0, "ppvc sigma must be a multiple of 8"); using input_type = InputT; using value_type = std::uint64_t; using block_type = typename PRG::block_type; using bare_key = dpf::utils::dpf_type_t; static constexpr std::size_t width = Width; static constexpr std::size_t sigma = Sigma; static constexpr std::size_t kappa = 128; static constexpr std::size_t m_bits = 3 * kappa + Sigma; static constexpr std::size_t nbytes = m_bits / 8; static constexpr std::size_t domain_bits = dpf::utils::bitlength_of_v; static constexpr std::size_t domain_size = std::size_t{1} << domain_bits; static constexpr std::size_t commitment_bits = 2 * Width * m_bits; static_assert(sizeof(block_type) == 16, "ppvc PRG block must be 128 bits"); static_assert(m_bits % 8 == 0, "ppvc Naor string must be a whole number of bytes"); static_assert(domain_bits >= dpf::lg_outputs_per_leaf_v, "ppvc domain must cover one packed leaf"); static_assert(domain_bits <= 20, "ppvc evaluation materializes one entry per domain point"); /// @brief `m`-bit string, the codomain of Naor's `G`. struct naor_string { std::array bytes{}; friend bool operator==(const naor_string & a, const naor_string & b) noexcept { return a.bytes == b.bytes; } friend bool operator!=(const naor_string & a, const naor_string & b) noexcept { return !(a == b); } }; /// @brief Public matrix `A`, `m` rows by 128 columns, stored by column. struct public_params { std::array columns{}; }; /// @brief Published commitment. Slot `[j][β]` binds the root of layer `j`, side `β`. struct commitment { std::array, Width> slots{}; friend bool operator==(const commitment & a, const commitment & b) noexcept { return a.slots == b.slots; } friend bool operator!=(const commitment & a, const commitment & b) noexcept { return !(a == b); } }; /// @brief Committer state. Both keys of every pair, their Naor coins, and `i`. struct state { InputT i{}; std::array, 2>, Width> keys{}; std::array, Width> coins{}; }; /// @brief One-sided opening. One key and one Naor coin per layer, plus `delta`. struct opening { int mu = 0; value_type tau = 0; InputT delta{}; std::array, Width> keys{}; std::array coins{}; }; /// @brief All-ones mask for a `Width`-bit value. `2^64 - 1` when `Width` is 64. static constexpr value_type value_mask() noexcept { if constexpr (Width == 64) return ~value_type{0}; else return (value_type{1} << Width) - 1; } /// @brief Sample a fresh public matrix. static public_params setup() { public_params pp; for (auto & column : pp.columns) dpf::uniform_fill(column.bytes); return pp; } /// @brief Expand one 128-bit seed into the public matrix. static public_params setup_from_seed(block_type seed) { public_params pp; std::uint32_t counter = 0; for (auto & column : pp.columns) column = stretch_counter(seed, counter); return pp; } /// @brief Naor commitment `G(r) XOR A*rho`. static naor_string commit_root(const public_params & pp, block_type rho, block_type r) { return xor_strings(stretch(r), matrix_vector(pp, rho)); } /// @brief Commit at a freshly sampled index. static std::pair commit(const public_params & pp) { return commit_at(pp, dpf::uniform_sample()); } /// @brief Commit at a prescribed index. /// @details The shift hides `i` when `i` is sampled independently of the /// later target. `commit` does that sampling. static std::pair commit_at(const public_params & pp, InputT i) { state st = make_state(i, false); return {bind(pp, st), std::move(st)}; } /// @brief Commit from a replica seed. The seed determines `i` and every key. /// @details Seed expansion uses a thread-local counter. Two expansions /// must not run at the same time on one thread. static std::pair commit_from_seed(const public_params & pp, block_type seed) { using rng = seed_rng; rng::seed = seed; rng::counter = 0; InputT i = index_from_block(rng::next()); state st = make_state(i, true); return {bind(pp, st), std::move(st)}; } /// @brief Same expansion as `commit_from_seed`, with `i` supplied by the caller. /// @details The seed is spent on roots and Naor coins. A `k`-PPVC uses this /// so it can reject colliding indices and try another seed. static std::pair commit_at_from_seed(const public_params & pp, block_type seed, InputT i) { using rng = seed_rng; rng::seed = seed; rng::counter = 0; state st = make_state(i, true); return {bind(pp, st), std::move(st)}; } /// @brief Program `tau` and shift the hidden coordinate onto `xi`. /// `mu = 0` programs the coordinate. `mu = 1` programs the sum of coordinates. static opening open(const state & st, int mu, value_type tau, InputT xi) { if (mu != 0 && mu != 1) throw std::invalid_argument("ppvc: mu must be 0 or 1"); if (tau > value_mask()) throw std::invalid_argument("ppvc: tau does not fit in the value width"); const std::size_t hidden = index_of(st.i); std::array u{}; value_type target = tau; if (mu == 0) { for (std::size_t j = 0; j < Width; ++j) u[j] = bit_at(key_of(st, j, 0), hidden); } else { std::array, Width> columns{}; for (std::size_t j = 0; j < Width; ++j) columns[j] = full_bits(key_of(st, j, 0)); value_type off_sum = 0; for (std::size_t y = 0; y < domain_size; ++y) { value_type column = 0; for (std::size_t j = 0; j < Width; ++j) { if (columns[j][y] != 0) column |= value_type{1} << j; } if (y == hidden) for (std::size_t j = 0; j < Width; ++j) u[j] = columns[j][y] != 0; else off_sum = (off_sum + column) & value_mask(); } target = (tau - off_sum) & value_mask(); } opening op; op.mu = mu; op.tau = tau; op.delta = sub(xi, st.i); for (std::size_t j = 0; j < Width; ++j) { const bool want = ((target >> j) & 1u) != 0; const unsigned side = (u[j] != want) ? 1u : 0u; op.keys[j] = st.keys[j][side]; op.coins[j] = st.coins[j][side]; } return op; } /// @brief Accept the opening when every opened root matches its Naor string. static bool verify(const public_params & pp, const commitment & com, const opening & op) { for (std::size_t j = 0; j < Width; ++j) { if (!op.keys[j]) return false; const block_type rho = op.keys[j]->root(); const unsigned beta = static_cast(dpf::get_lo_bit(rho)); if (beta > 1) return false; if (commit_root(pp, rho, op.coins[j]) != com.slots[j][beta]) return false; } return true; } /// @brief One-sided vector in the hidden indexing, one entry per domain point. static std::vector eval(const opening & op) { std::array, Width> columns{}; for (std::size_t j = 0; j < Width; ++j) { if (!op.keys[j]) throw std::invalid_argument("ppvc: opening is missing a key"); columns[j] = full_bits(*op.keys[j]); } std::vector x(domain_size); for (std::size_t y = 0; y < domain_size; ++y) { value_type column = 0; for (std::size_t j = 0; j < Width; ++j) { if (columns[j][y] != 0) column |= value_type{1} << j; } x[y] = column; } return x; } /// @brief Rotated vector. Entry `y` is the unrotated entry at `y - delta`. static std::vector eval_rotated(const opening & op) { const auto x = eval(op); const std::size_t delta = index_of(op.delta); std::vector rotated(domain_size); for (std::size_t y = 0; y < domain_size; ++y) rotated[y] = x[(y - delta) & (domain_size - 1)]; return rotated; } /// @brief Sum of coordinates, reduced in `Z/2^Width Z`. static value_type column_sum(const std::vector & x) { value_type sum = 0; for (value_type column : x) sum = (sum + column) & value_mask(); return sum; } /// @brief Check the programmed statement against the unrotated vector. /// @details For `mu = 0`, the entry at `xi - delta` equals `tau`. /// For `mu = 1`, the sum of coordinates equals `tau`. static bool check_statement(const opening & op, const std::vector & x_circ, InputT xi) { if (x_circ.size() != domain_size) return false; if (op.mu == 0) return x_circ[index_of(sub(xi, op.delta))] == op.tau; if (op.mu == 1) return column_sum(x_circ) == op.tau; return false; } /// @brief `verify` and `check_statement`. static bool accept(const public_params & pp, const commitment & com, const opening & op, const std::vector & x_circ, InputT xi) { return verify(pp, com, op) && check_statement(op, x_circ, xi); } /// @brief Both sides are DPF keys for payload 1 at `state.i`, and both Naor slots open. static bool check_well_formed(const public_params & pp, const commitment & com, const state & st) { const std::size_t hidden = index_of(st.i); for (std::size_t j = 0; j < Width; ++j) { if (!st.keys[j][0] || !st.keys[j][1]) return false; const bare_key & left = *st.keys[j][0]; const bare_key & right = *st.keys[j][1]; if (dpf::get_lo_bit(left.root()) != 0 || dpf::get_lo_bit(right.root()) != 1) return false; if (commit_root(pp, left.root(), st.coins[j][0]) != com.slots[j][0]) return false; if (commit_root(pp, right.root(), st.coins[j][1]) != com.slots[j][1]) return false; if (!shared_corrections(left, right)) return false; const auto left_bits = full_bits(left); const auto right_bits = full_bits(right); int spikes = 0; std::size_t where = 0; for (std::size_t y = 0; y < domain_size; ++y) { if (left_bits[y] != right_bits[y]) { ++spikes; where = y; } } if (spikes != 1 || where != hidden) return false; } return true; } /// @brief Re-expand `seed` and accept when it reproduces `com` and a well-formed replica. static bool audit(const public_params & pp, const commitment & com, block_type seed) { auto [expanded, st] = commit_from_seed(pp, seed); return expanded == com && check_well_formed(pp, com, st); } /// @brief Domain subtraction modulo `domain_size`. static InputT sub(InputT a, InputT b) { return point((index_of(a) - index_of(b)) & (domain_size - 1)); } /// @brief Integer representative of a domain point, in `0 .. domain_size-1`. static std::size_t index_of(InputT x) { return static_cast(as_u64(x) & (domain_size - 1)); } /// @brief Domain point whose integer representative is `index` modulo `domain_size`. static InputT point(std::size_t index) { using integral = typename dpf::utils::to_integral_type::integral_type; return dpf::utils::make_from_integral_value{}( static_cast(index & (domain_size - 1))); } /// @brief Low domain bits of a PRG block, used as a hidden index. static InputT index_from_block(block_type block) { alignas(16) std::uint64_t lanes[2]; simde_mm_store_si128(reinterpret_cast(lanes), block); return point(static_cast(lanes[0])); } /// @brief Which side was opened in each layer. Bit `j` is the disclosure bit. static std::array disclosure(const opening & op) { std::array bits{}; for (std::size_t j = 0; j < Width; ++j) { if (!op.keys[j]) throw std::invalid_argument("ppvc: opening is missing a key"); bits[j] = dpf::get_lo_bit(op.keys[j]->root()) != 0; } return bits; } private: /// @brief Counter-mode draw for one replica seed. Not reentrant. struct seed_rng { static inline thread_local block_type seed{}; static inline thread_local std::uint32_t counter{0}; HEDLEY_WARN_UNUSED_RESULT static block_type next() { return PRG::eval(seed, counter++); } }; HEDLEY_WARN_UNUSED_RESULT static std::uint64_t as_u64(InputT x) { return static_cast(dpf::utils::to_integral_type{}(x)); } HEDLEY_WARN_UNUSED_RESULT static bool block_bit(block_type block, std::size_t index) { alignas(16) std::uint64_t lanes[2]; simde_mm_store_si128(reinterpret_cast(lanes), block); return ((lanes[index / 64] >> (index % 64)) & 1u) != 0; } HEDLEY_WARN_UNUSED_RESULT static naor_string xor_strings(naor_string lhs, const naor_string & rhs) { for (std::size_t i = 0; i < nbytes; ++i) lhs.bytes[i] = static_cast(lhs.bytes[i] ^ rhs.bytes[i]); return lhs; } HEDLEY_WARN_UNUSED_RESULT static naor_string stretch(block_type seed) { std::uint32_t counter = 0; return stretch_counter(seed, counter); } HEDLEY_WARN_UNUSED_RESULT static naor_string stretch_counter(block_type seed, std::uint32_t & counter) { naor_string out; std::size_t filled = 0; while (filled < nbytes) { const block_type block = PRG::eval(seed, counter++); alignas(16) std::uint8_t raw[16]; simde_mm_store_si128(reinterpret_cast(raw), block); const std::size_t take = std::min(16, nbytes - filled); std::memcpy(out.bytes.data() + filled, raw, take); filled += take; } return out; } HEDLEY_WARN_UNUSED_RESULT static naor_string matrix_vector(const public_params & pp, block_type rho) { naor_string acc; for (std::size_t bit = 0; bit < kappa; ++bit) { if (block_bit(rho, bit)) acc = xor_strings(acc, pp.columns[bit]); } return acc; } HEDLEY_WARN_UNUSED_RESULT static const bare_key & key_of(const state & st, std::size_t layer, unsigned side) { if (!st.keys[layer][side]) throw std::invalid_argument("ppvc: commit state is missing a key"); return *st.keys[layer][side]; } HEDLEY_WARN_UNUSED_RESULT static bool bit_at(const bare_key & key, std::size_t index) { return static_cast(*dpf::eval_point(key, point(index))); } HEDLEY_WARN_UNUSED_RESULT static std::vector full_bits(const bare_key & key) { auto evaluated = dpf::eval_full(key); std::vector bits; bits.reserve(domain_size); for (auto it = std::cbegin(evaluated.second); it != std::cend(evaluated.second); ++it) bits.push_back(static_cast(*it) ? std::uint8_t{1} : std::uint8_t{0}); if (bits.size() != domain_size) throw std::logic_error("ppvc: full-domain evaluation has the wrong length"); return bits; } HEDLEY_WARN_UNUSED_RESULT static bool shared_corrections(const bare_key & left, const bare_key & right) { const auto & words_l = left.correction_words(); const auto & words_r = right.correction_words(); if (std::memcmp(words_l.data(), words_r.data(), sizeof(words_l)) != 0) return false; return left.correction_advice() == right.correction_advice(); } HEDLEY_WARN_UNUSED_RESULT static commitment bind(const public_params & pp, const state & st) { commitment com; for (std::size_t j = 0; j < Width; ++j) { for (unsigned beta = 0; beta < 2; ++beta) { const bare_key & key = key_of(st, j, beta); com.slots[j][beta] = commit_root(pp, key.root(), st.coins[j][beta]); } } return com; } HEDLEY_WARN_UNUSED_RESULT static state make_state(InputT i, bool seeded) { using rng = seed_rng; state st; st.i = i; for (std::size_t j = 0; j < Width; ++j) { auto made = seeded ? dpf::make_dpf(dpf::make_dpfargs(i, dpf::bit::one), &rng::next) : dpf::make_dpf(dpf::make_dpfargs(i, dpf::bit::one)); st.keys[j][0] = made.first.key(); st.keys[j][1] = made.second.key(); st.coins[j][0] = seeded ? rng::next() : dpf::uniform_sample(); st.coins[j][1] = seeded ? rng::next() : dpf::uniform_sample(); } return st; } }; /// @brief `k` independent point-programmable commitments. /// @details Each copy has its own hidden index. The sum of the rotated /// openings is one vector. Reprogramming copy `r` changes coordinate `xi[r]` /// and leaves the other coordinates fixed. /// @tparam K number of programmable coordinates. At most the domain size. template struct k_ppvc { static_assert(K >= 1, "k-ppvc needs at least one point"); using one = ppvc; using public_params = typename one::public_params; using value_type = typename one::value_type; using input_type = InputT; using block_type = typename one::block_type; static constexpr std::size_t points = K; static constexpr std::size_t width = Width; static_assert(K <= one::domain_size, "k-ppvc asks for more distinct points than the domain has"); struct commitment { std::array copies{}; friend bool operator==(const commitment & a, const commitment & b) noexcept { return a.copies == b.copies; } friend bool operator!=(const commitment & a, const commitment & b) noexcept { return !(a == b); } }; struct state { std::array copies{}; }; struct opening { std::array copies{}; }; /// @brief Sample `K` distinct indices and commit one replica at each. static std::pair commit(const public_params & pp) { commitment com; state st; std::array used{}; for (std::size_t r = 0; r < K; ++r) { InputT index{}; for (;;) { index = dpf::uniform_sample(); bool clash = false; for (std::size_t p = 0; p < r; ++p) clash = clash || used[p] == index; if (!clash) break; } used[r] = index; auto [slot, replica] = one::commit_at(pp, index); com.copies[r] = std::move(slot); st.copies[r] = std::move(replica); } return {std::move(com), std::move(st)}; } /// @brief Expand one seed into `k` replicas with distinct hidden indices. static std::pair commit_from_seed(const public_params & pp, block_type master) { block_type material = master; for (int attempt = 0; attempt < 64; ++attempt) { if (attempt > 0) material = PRG::eval(material, 0x00ffffffu); std::uint32_t counter = 0; std::array indices{}; std::array subseeds{}; for (std::size_t r = 0; r < K; ++r) { indices[r] = one::index_from_block(PRG::eval(material, counter++)); subseeds[r] = PRG::eval(material, counter++); } if (!distinct(indices)) continue; commitment com; state st; for (std::size_t r = 0; r < K; ++r) { auto [slot, replica] = one::commit_at_from_seed(pp, subseeds[r], indices[r]); com.copies[r] = std::move(slot); st.copies[r] = std::move(replica); } return {std::move(com), std::move(st)}; } throw std::runtime_error("k-ppvc: seed did not yield distinct points"); } /// @brief Open every replica. `mu` is `0` to program each coordinate, `1` to program each sum. static opening open(const state & st, int mu, const std::array & tau, const std::array & xi) { opening op; for (std::size_t r = 0; r < K; ++r) op.copies[r] = one::open(st.copies[r], mu, tau[r], xi[r]); return op; } /// @brief Accept when every replica's opened Naor roots match. static bool verify(const public_params & pp, const commitment & com, const opening & op) { for (std::size_t r = 0; r < K; ++r) { if (!one::verify(pp, com.copies[r], op.copies[r])) return false; } return true; } /// @brief Every replica is well formed, and the hidden indices are distinct. static bool check_well_formed(const public_params & pp, const commitment & com, const state & st) { std::array indices{}; for (std::size_t r = 0; r < K; ++r) { if (!one::check_well_formed(pp, com.copies[r], st.copies[r])) return false; indices[r] = st.copies[r].i; } return distinct(indices); } /// @brief Re-expand `seed` and accept when it reproduces `com` and a well-formed object. static bool audit(const public_params & pp, const commitment & com, block_type seed) { auto [expanded, st] = commit_from_seed(pp, seed); return expanded == com && check_well_formed(pp, com, st); } /// @brief Sum of the `k` rotated vectors, reduced in `Z/2^Width Z`. static std::vector combine_rotated(const opening & op) { std::vector sum(one::domain_size, 0); for (std::size_t r = 0; r < K; ++r) { const auto rotated = one::eval_rotated(op.copies[r]); for (std::size_t y = 0; y < sum.size(); ++y) sum[y] = (sum[y] + rotated[y]) & one::value_mask(); } return sum; } private: HEDLEY_WARN_UNUSED_RESULT static bool distinct(const std::array & indices) { for (std::size_t r = 0; r < K; ++r) { for (std::size_t p = 0; p < r; ++p) { if (indices[p] == indices[r]) return false; } } return true; } }; } // namespace dpf #endif // LIBDPF_INCLUDE_DPF_PPVC_HPP__