aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcenobite pinhead <pinhead@pinhead>2026-08-15 22:45:53 +0100
committercenobite pinhead <pinhead@pinhead>2026-08-15 22:45:53 +0100
commit2c882e39b3f23e2511f5ba90a31d0a168ff1d8ae (patch)
tree6a3984b5682a5f8d13f1b6a63f51c26b0ec57307
parent274b4caccd4a13ec83897be58cfc2310a83c7af5 (diff)
downloadmonzero-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.md20
-rw-r--r--docs/MONZERO_PHASE0_STABILIZATION.md1
-rw-r--r--src/cryptonote_basic/asset_confidential.cpp126
-rw-r--r--src/cryptonote_basic/asset_confidential.h39
-rw-r--r--tests/unit_tests/asset_confidential.cpp121
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));
+}