diff options
| author | cenobite pinhead <pinhead@pinhead> | 2026-08-15 22:45:53 +0100 |
|---|---|---|
| committer | cenobite pinhead <pinhead@pinhead> | 2026-08-15 22:45:53 +0100 |
| commit | 2c882e39b3f23e2511f5ba90a31d0a168ff1d8ae (patch) | |
| tree | 6a3984b5682a5f8d13f1b6a63f51c26b0ec57307 | |
| parent | 274b4caccd4a13ec83897be58cfc2310a83c7af5 (diff) | |
| download | monzero-core-2c882e39b3f23e2511f5ba90a31d0a168ff1d8ae.tar.gz monzero-core-2c882e39b3f23e2511f5ba90a31d0a168ff1d8ae.tar.xz monzero-core-2c882e39b3f23e2511f5ba90a31d0a168ff1d8ae.zip | |
bind asset inputs to CLSAG ownership proofs
| -rw-r--r-- | docs/MONZERO_ASSETS_V1_SPEC.md | 20 | ||||
| -rw-r--r-- | docs/MONZERO_PHASE0_STABILIZATION.md | 1 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_confidential.cpp | 126 | ||||
| -rw-r--r-- | src/cryptonote_basic/asset_confidential.h | 39 | ||||
| -rw-r--r-- | tests/unit_tests/asset_confidential.cpp | 121 |
5 files changed, 301 insertions, 6 deletions
diff --git a/docs/MONZERO_ASSETS_V1_SPEC.md b/docs/MONZERO_ASSETS_V1_SPEC.md index 55d1391ef..0d1f928e9 100644 --- a/docs/MONZERO_ASSETS_V1_SPEC.md +++ b/docs/MONZERO_ASSETS_V1_SPEC.md @@ -268,12 +268,20 @@ issuance commitments that disagree with the declared supply. Malformed curve proofs are converted to deterministic validation failure rather than escaping as exceptions. -This layer still does **not** authorize ownership or prevent double spends. -Production integration requires ring-member references resolved against stored -asset outputs, asset key images, a domain-separated CLSAG transcript, canonical -wire serialization, and database indexes for unspent output lookup. Until that -layer exists and is reviewed, these proofs cannot make an asset transaction -valid on any Monzero network. +The next inactive layer adds domain-separated CLSAG ownership proofs. A proof +commits to the network UUID, carrier transaction, asset ID, pseudo input, and +all ring output IDs, destination keys, and amount commitments. Rings contain +exactly 16 members. Verification rejects network/carrier replay, cross-asset +members, duplicate or zero output IDs, malformed points, key-image tampering, +and any pseudo input without exactly one matching proof. Key images must also +be unique inside one transaction. + +This still does **not** provide global double-spend prevention: production +integration must resolve every claimed ring member against the authoritative +asset-output database and reject key images already spent by earlier blocks or +the mempool. Canonical wire serialization and reorg-safe output/key-image +indexes are also outstanding. Until those layers exist and are reviewed, these +proofs cannot make an asset transaction valid on any Monzero network. ## 7. Metadata diff --git a/docs/MONZERO_PHASE0_STABILIZATION.md b/docs/MONZERO_PHASE0_STABILIZATION.md index 4b9a37e72..4eef6e1dd 100644 --- a/docs/MONZERO_PHASE0_STABILIZATION.md +++ b/docs/MONZERO_PHASE0_STABILIZATION.md @@ -135,6 +135,7 @@ isolated disposable development network may be used after cryptographic review. - [x] Atomic block-extension validation against independently supplied native carrier commitments. - [x] Persistent LMDB issuance registry with restart reconstruction and native reorg rollback. - [x] Inactive per-asset Pedersen/Bulletproof+ conservation and burn verifier. +- [x] Inactive network/carrier/asset-bound CLSAG ownership proof verifier. - [x] External cryptographic-review brief and mandatory threat cases. - [ ] Reviewed confidential per-asset commitment and range-proof construction. - [ ] Versioned asset transaction serialization with strict parser limits. diff --git a/src/cryptonote_basic/asset_confidential.cpp b/src/cryptonote_basic/asset_confidential.cpp index d2473bea1..4a1d0abea 100644 --- a/src/cryptonote_basic/asset_confidential.cpp +++ b/src/cryptonote_basic/asset_confidential.cpp @@ -4,6 +4,7 @@ #include "ringct/bulletproofs_plus.h" #include "ringct/rctOps.h" +#include "ringct/rctSigs.h" namespace cryptonote { @@ -11,6 +12,8 @@ namespace assets { namespace { + constexpr char OWNERSHIP_DOMAIN[] = "MonzeroAssetOwnershipCLSAGV1"; + bool fail(std::string* error, const std::string& message) { if (error) @@ -25,6 +28,76 @@ namespace return false; return true; } + + + template<typename T> + void append_pod(std::vector<uint8_t>& bytes, const T& value) + { + const auto* begin = reinterpret_cast<const uint8_t*>(&value); + bytes.insert(bytes.end(), begin, begin + sizeof(value)); + } +} + +bool derive_asset_ownership_message(const asset_ownership_proof& proof, + network_type network, const crypto::hash& carrier_prefix_hash, + rct::key& message, std::string* error) +{ + if (network != MAINNET && network != TESTNET && network != STAGENET) + return fail(error, "ownership proof requires a public network"); + if (proof.asset_id == crypto::null_hash || carrier_prefix_hash == crypto::null_hash) + return fail(error, "ownership proof has a zero asset or carrier id"); + if (proof.ring.size() != CONFIDENTIAL_ASSET_RING_SIZE) + return fail(error, "ownership proof has the wrong ring size"); + std::set<crypto::hash> output_ids; + std::vector<uint8_t> bytes(OWNERSHIP_DOMAIN, OWNERSHIP_DOMAIN + sizeof(OWNERSHIP_DOMAIN) - 1); + const config_t& config = get_config(network); + append_pod(bytes, config.NETWORK_ID); + append_pod(bytes, carrier_prefix_hash); + append_pod(bytes, proof.asset_id); + append_pod(bytes, proof.pseudo_input); + for (const asset_ring_member& member : proof.ring) + { + if (member.asset_id != proof.asset_id) + return fail(error, "ownership ring crosses asset domains"); + if (member.output_id == crypto::null_hash || !output_ids.insert(member.output_id).second) + return fail(error, "ownership ring has a zero or duplicate output id"); + if (!rct::isInMainSubgroup(member.public_output.dest) + || !rct::isInMainSubgroup(member.public_output.mask)) + return fail(error, "ownership ring contains an invalid curve point"); + append_pod(bytes, member.output_id); + append_pod(bytes, member.public_output.dest); + append_pod(bytes, member.public_output.mask); + } + const crypto::hash digest = crypto::cn_fast_hash(bytes.data(), bytes.size()); + std::memcpy(&message, &digest, sizeof(message)); + return true; +} + +bool verify_asset_ownership_proof(const asset_ownership_proof& proof, + network_type network, const crypto::hash& carrier_prefix_hash, + std::string* error) +{ + rct::key message; + if (!derive_asset_ownership_message(proof, network, carrier_prefix_hash, message, error)) + return false; + if (proof.signature.s.size() != proof.ring.size()) + return fail(error, "ownership CLSAG response count does not match its ring"); + rct::clsag signature = proof.signature; + std::memcpy(&signature.I, &proof.key_image, sizeof(signature.I)); + rct::ctkeyV ring; + ring.reserve(proof.ring.size()); + for (const asset_ring_member& member : proof.ring) + ring.push_back(member.public_output); + try + { + if (!rct::verRctCLSAGSimple(message, signature, ring, proof.pseudo_input)) + return fail(error, "invalid asset ownership CLSAG"); + } + catch (const std::exception&) + { + return fail(error, "malformed asset ownership CLSAG"); + } + return true; } bool verify_confidential_asset_balance(const confidential_asset_balance& balance, std::string* error) @@ -127,5 +200,58 @@ bool verify_confidential_asset_transaction( return fail(error, "issuance has no matching confidential balance group"); return true; } + +bool verify_confidential_asset_transaction_with_ownership( + const std::vector<confidential_asset_balance>& balances, + const std::vector<asset_ownership_proof>& ownership_proofs, + const std::set<crypto::hash>& known_assets, + const boost::optional<issuance_descriptor>& issuance, + network_type network, const crypto::hash& carrier_prefix_hash, + std::string* error) +{ + if (!verify_confidential_asset_transaction(balances, known_assets, issuance, error)) + return false; + crypto::hash issued_id{}; + if (issuance && !derive_asset_id(*issuance, issued_id, error)) + return false; + std::vector<bool> matched(ownership_proofs.size(), false); + std::vector<crypto::key_image> key_images; + for (const confidential_asset_balance& balance : balances) + { + const bool is_issuance = issuance && balance.asset_id == issued_id; + for (size_t input_index = 0; input_index < balance.pseudo_inputs.size(); ++input_index) + { + if (is_issuance && input_index == 0) + continue; + const confidential_pseudo_input& input = balance.pseudo_inputs[input_index]; + size_t match = ownership_proofs.size(); + for (size_t proof_index = 0; proof_index < ownership_proofs.size(); ++proof_index) + { + if (!matched[proof_index] + && ownership_proofs[proof_index].asset_id == input.source_asset_id + && rct::equalKeys(ownership_proofs[proof_index].pseudo_input, input.commitment)) + { + if (match != ownership_proofs.size()) + return fail(error, "multiple ownership proofs match one pseudo input"); + match = proof_index; + } + } + if (match == ownership_proofs.size()) + return fail(error, "pseudo input has no ownership proof"); + for (const crypto::key_image& image : key_images) + if (image == ownership_proofs[match].key_image) + return fail(error, "duplicate asset key image in transaction"); + key_images.push_back(ownership_proofs[match].key_image); + if (!verify_asset_ownership_proof( + ownership_proofs[match], network, carrier_prefix_hash, error)) + return false; + matched[match] = true; + } + } + for (const bool used : matched) + if (!used) + return fail(error, "ownership proof does not match a pseudo input"); + return true; +} } } diff --git a/src/cryptonote_basic/asset_confidential.h b/src/cryptonote_basic/asset_confidential.h index d79efb56f..ac6827318 100644 --- a/src/cryptonote_basic/asset_confidential.h +++ b/src/cryptonote_basic/asset_confidential.h @@ -13,6 +13,7 @@ namespace assets { constexpr size_t MAX_CONFIDENTIAL_ASSET_INPUTS = 16; constexpr size_t MAX_CONFIDENTIAL_ASSET_OUTPUTS = 16; + constexpr size_t CONFIDENTIAL_ASSET_RING_SIZE = 16; struct confidential_pseudo_input { @@ -20,6 +21,35 @@ namespace assets rct::key commitment{}; }; + struct asset_ring_member + { + crypto::hash asset_id{}; + crypto::hash output_id{}; + rct::ctkey public_output{}; + }; + + struct asset_ownership_proof + { + crypto::hash asset_id{}; + rct::key pseudo_input{}; + crypto::key_image key_image{}; + std::vector<asset_ring_member> ring; + rct::clsag signature; + }; + + bool derive_asset_ownership_message( + const asset_ownership_proof& proof, + network_type network, + const crypto::hash& carrier_prefix_hash, + rct::key& message, + std::string* error = nullptr); + + bool verify_asset_ownership_proof( + const asset_ownership_proof& proof, + network_type network, + const crypto::hash& carrier_prefix_hash, + std::string* error = nullptr); + struct confidential_asset_balance { crypto::hash asset_id{}; @@ -38,5 +68,14 @@ namespace assets const std::set<crypto::hash>& known_assets, const boost::optional<issuance_descriptor>& issuance, std::string* error = nullptr); + + bool verify_confidential_asset_transaction_with_ownership( + const std::vector<confidential_asset_balance>& balances, + const std::vector<asset_ownership_proof>& ownership_proofs, + const std::set<crypto::hash>& known_assets, + const boost::optional<issuance_descriptor>& issuance, + network_type network, + const crypto::hash& carrier_prefix_hash, + std::string* error = nullptr); } } diff --git a/tests/unit_tests/asset_confidential.cpp b/tests/unit_tests/asset_confidential.cpp index 2b3980758..b400a2041 100644 --- a/tests/unit_tests/asset_confidential.cpp +++ b/tests/unit_tests/asset_confidential.cpp @@ -1,8 +1,12 @@ #include "gtest/gtest.h" +#include <cstring> + #include "cryptonote_basic/asset_confidential.h" #include "ringct/bulletproofs_plus.h" #include "ringct/rctOps.h" +#include "ringct/rctSigs.h" +#include "device/device.hpp" namespace { @@ -34,6 +38,48 @@ namespace balance.range_proofs.push_back(rct::bulletproof_plus_PROVE(amounts, masks)); return balance; } + + cryptonote::assets::asset_ownership_proof make_ownership_proof( + const crypto::hash& id, const crypto::hash& carrier, rct::key* generated_pseudo_mask = nullptr) + { + constexpr size_t real = 5; + cryptonote::assets::asset_ownership_proof proof; + proof.asset_id = id; + rct::ctkeyV public_ring; + rct::key spend_secret{}, input_mask{}; + const rct::key amount = rct::d2h(10); + for (size_t index = 0; index < cryptonote::assets::CONFIDENTIAL_ASSET_RING_SIZE; ++index) + { + cryptonote::assets::asset_ring_member member; + member.asset_id = id; + member.output_id.data[0] = static_cast<unsigned char>(index + 1); + rct::key ignored; + rct::skpkGen(ignored, member.public_output.dest); + rct::skpkGen(ignored, member.public_output.mask); + proof.ring.push_back(member); + } + rct::skpkGen(spend_secret, proof.ring[real].public_output.dest); + input_mask = rct::skGen(); + rct::addKeys2(proof.ring[real].public_output.mask, input_mask, amount, rct::H); + for (const auto& member : proof.ring) + public_ring.push_back(member.public_output); + const rct::key pseudo_mask = rct::skGen(); + if (generated_pseudo_mask) + *generated_pseudo_mask = pseudo_mask; + rct::addKeys2(proof.pseudo_input, pseudo_mask, amount, rct::H); + rct::key message; + std::string error; + if (!cryptonote::assets::derive_asset_ownership_message(proof, cryptonote::TESTNET, carrier, message, &error)) + throw std::runtime_error(error); + rct::ctkey input_secret; + input_secret.dest = spend_secret; + input_secret.mask = input_mask; + proof.signature = rct::proveRctCLSAGSimple( + message, public_ring, input_secret, pseudo_mask, proof.pseudo_input, + real, hw::get_device("default")); + std::memcpy(&proof.key_image, &proof.signature.I, sizeof(proof.key_image)); + return proof; + } } TEST(asset_confidential, verifies_private_transfer_and_explicit_burn) @@ -94,3 +140,78 @@ TEST(asset_confidential, validates_fixed_supply_issuance_commitment) balance.pseudo_inputs.front().commitment = rct::commit(11, rct::zero()); EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction({balance}, {}, descriptor, &error)); } + +TEST(asset_confidential, verifies_domain_separated_clsag_ownership) +{ + const crypto::hash id = asset_id(6); + crypto::hash carrier{}; + carrier.data[0] = 0x77; + const auto proof = make_ownership_proof(id, carrier); + std::string error; + ASSERT_TRUE(cryptonote::assets::verify_asset_ownership_proof( + proof, cryptonote::TESTNET, carrier, &error)) << error; + + EXPECT_FALSE(cryptonote::assets::verify_asset_ownership_proof( + proof, cryptonote::MAINNET, carrier, &error)); + crypto::hash other_carrier = carrier; + other_carrier.data[1] = 1; + EXPECT_FALSE(cryptonote::assets::verify_asset_ownership_proof( + proof, cryptonote::TESTNET, other_carrier, &error)); +} + +TEST(asset_confidential, rejects_clsag_ring_key_image_and_asset_tampering) +{ + const crypto::hash id = asset_id(7); + crypto::hash carrier{}; + carrier.data[0] = 0x78; + const auto original = make_ownership_proof(id, carrier); + std::string error; + + auto changed_reference = original; + changed_reference.ring[0].output_id.data[1] = 1; + EXPECT_FALSE(cryptonote::assets::verify_asset_ownership_proof( + changed_reference, cryptonote::TESTNET, carrier, &error)); + auto wrong_asset = original; + wrong_asset.ring[0].asset_id = asset_id(8); + EXPECT_FALSE(cryptonote::assets::verify_asset_ownership_proof( + wrong_asset, cryptonote::TESTNET, carrier, &error)); + auto wrong_image = original; + wrong_image.key_image.data[0] ^= 1; + EXPECT_FALSE(cryptonote::assets::verify_asset_ownership_proof( + wrong_image, cryptonote::TESTNET, carrier, &error)); + auto duplicate = original; + duplicate.ring[1].output_id = duplicate.ring[0].output_id; + EXPECT_FALSE(cryptonote::assets::verify_asset_ownership_proof( + duplicate, cryptonote::TESTNET, carrier, &error)); +} + +TEST(asset_confidential, binds_every_pseudo_input_to_one_unique_ownership_proof) +{ + const crypto::hash id = asset_id(9); + crypto::hash carrier{}; + carrier.data[0] = 0x79; + rct::key pseudo_mask; + const auto ownership = make_ownership_proof(id, carrier, &pseudo_mask); + cryptonote::assets::confidential_asset_balance balance; + balance.asset_id = id; + balance.pseudo_inputs.push_back({id, ownership.pseudo_input}); + balance.outputs.push_back(rct::commit(10, pseudo_mask)); + balance.range_proofs.push_back(rct::bulletproof_plus_PROVE(10, pseudo_mask)); + std::string error; + ASSERT_TRUE(cryptonote::assets::verify_confidential_asset_transaction_with_ownership( + {balance}, {ownership}, {id}, boost::none, cryptonote::TESTNET, carrier, &error)) << error; + EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction_with_ownership( + {balance}, {}, {id}, boost::none, cryptonote::TESTNET, carrier, &error)); + EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction_with_ownership( + {balance}, {ownership, ownership}, {id}, boost::none, cryptonote::TESTNET, carrier, &error)); + + auto duplicate_input = balance; + duplicate_input.pseudo_inputs.push_back(duplicate_input.pseudo_inputs.front()); + duplicate_input.outputs.push_back(balance.outputs.front()); + duplicate_input.range_proofs.clear(); + duplicate_input.range_proofs.push_back(rct::bulletproof_plus_PROVE( + std::vector<uint64_t>{10, 10}, rct::keyV{pseudo_mask, pseudo_mask})); + EXPECT_FALSE(cryptonote::assets::verify_confidential_asset_transaction_with_ownership( + {duplicate_input}, {ownership, ownership}, {id}, boost::none, + cryptonote::TESTNET, carrier, &error)); +} |
