research/rlnv2: Add signalling with SMT proofs

This commit is contained in:
x
2025-10-14 13:12:33 +02:00
parent 448d516b2f
commit f6cd3fc6e6
4 changed files with 351 additions and 45 deletions

View File

@@ -6,7 +6,7 @@ CARGO = cargo
# Compile target
RUST_TARGET = $(shell rustc -Vv | grep '^host: ' | cut -d' ' -f2)
PROOFS_SRC = signal.zk slash.zk
PROOFS_SRC = register.zk signal.zk slash.zk
PROOFS_BIN = $(PROOFS_SRC:=.bin)
ZKAS = ../../../../zkas

View File

@@ -1,16 +1,16 @@
k = 13;
field = "pallas";
constant "RlnRegister" {}
constant "Rlnv2Diff_Register" {}
witness "RlnRegister" {
witness "Rlnv2Diff_Register" {
Base identity_nullifier,
Base identity_trapdoor,
Base user_message_limit,
Base message_limit,
}
circuit "RlnRegister" {
circuit "Rlnv2Diff_Register" {
identity_secret = poseidon_hash(identity_nullifier, identity_trapdoor);
identity_secret_hash = poseidon_hash(identity_secret, user_message_limit);
identity_commitment = poseidon_hash(identity_secret_hash);

View File

@@ -1,44 +1,48 @@
k = 13;
k = 14;
field = "pallas";
constant "RlnSignal" {}
constant "Rlnv2Diff_Signal" {}
witness "RlnSignal" {
Base identity_nullifier,
Base identity_trapdoor,
witness "Rlnv2Diff_Signal" {
Base identity_nullifier,
Base identity_trapdoor,
Base user_message_limit,
MerklePath identity_path,
Uint32 identity_leaf_pos,
# Inclusion proof, the leaf is the identity_commitment
SparseMerklePath path,
Base x, # The message hash
Base external_nullifier, # Hash(Epoch, RLN identifier)
# The message hash
Base x,
Base message_id,
Base message_id,
Base user_message_limit,
Base epoch,
Base epoch,
}
circuit "RlnSignal" {
constrain_instance(epoch);
constrain_instance(external_nullifier);
circuit "Rlnv2Diff_Signal" {
# Identity inclusion proof
identity_secret = poseidon_hash(identity_nullifier, identity_trapdoor);
identity_secret_hash = poseidon_hash(identity_secret, user_message_limit);
identity_commitment = poseidon_hash(identity_secret_hash);
root = sparse_merkle_root(identity_commitment, path, identity_commitment);
constrain_instance(root);
less_than_strict(message_id, user_message_limit);
# External nullifier is created from epoch and app identifier
app_id = witness_base(1000);
external_nullifier = poseidon_hash(epoch, app_id);
constrain_instance(external_nullifier);
# Identity secret hash
a_0 = poseidon_hash(identity_nullifier, identity_trapdoor);
a_1 = poseidon_hash(a_0, external_nullifier, message_id);
# Calculating internal nullifier
# a_0 = identity_secret_hash
a_0 = poseidon_hash(identity_nullifier, identity_trapdoor);
a_1 = poseidon_hash(a_0, external_nullifier, message_id);
x_a_1 = base_mul(x, a_1);
y = base_add(a_0, x_a_1);
constrain_instance(x);
constrain_instance(y);
# y = a_0 + x * a_1
x_a_1 = base_mul(x, a_1);
y = base_add(a_0, x_a_1);
constrain_instance(x);
constrain_instance(y);
# Constrain message_id to be lower than actual message limit.
less_than_strict(message_id, user_message_limit);
internal_nullifier = poseidon_hash(a_1);
constrain_instance(internal_nullifier);
identity_commitment = poseidon_hash(a_0, user_message_limit);
root = merkle_root(identity_leaf_pos, identity_path, identity_commitment);
constrain_instance(root);
internal_nullifier = poseidon_hash(a_1);
constrain_instance(internal_nullifier);
}

View File

@@ -16,7 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use std::time::Instant;
use std::{
collections::BTreeMap,
time::{Instant, UNIX_EPOCH},
};
use darkfi::{
zk::{empty_witnesses, halo2::Value, Proof, ProvingKey, VerifyingKey, Witness, ZkCircuit},
@@ -32,6 +35,7 @@ use darkfi_sdk::{
};
use rand::rngs::OsRng;
#[derive(Copy, Clone)]
struct Identity {
identity_nullifier: pallas::Base,
identity_trapdoor: pallas::Base,
@@ -55,6 +59,78 @@ impl Identity {
}
}
#[derive(Debug, Clone)]
struct ShareData {
pub x_shares: Vec<pallas::Base>,
pub y_shares: Vec<pallas::Base>,
}
impl ShareData {
fn new() -> Self {
Self { x_shares: vec![], y_shares: vec![] }
}
}
#[derive(Debug, Default)]
struct MessageMetadata {
data: BTreeMap<pallas::Base, BTreeMap<pallas::Base, ShareData>>,
}
impl MessageMetadata {
fn new() -> Self {
Self { data: BTreeMap::new() }
}
fn add_share(
&mut self,
external_nullifier: pallas::Base,
internal_nullifier: pallas::Base,
x: pallas::Base,
y: pallas::Base,
) {
let inner_map = self.data.entry(external_nullifier).or_insert_with(BTreeMap::new);
let share_data = inner_map.entry(internal_nullifier).or_insert_with(ShareData::new);
share_data.x_shares.push(x);
share_data.y_shares.push(y);
}
fn get_shares(
&self,
external_nullifier: &pallas::Base,
internal_nullifier: &pallas::Base,
) -> Vec<(pallas::Base, pallas::Base)> {
if let Some(inner_map) = self.data.get(external_nullifier) {
if let Some(share_data) = inner_map.get(internal_nullifier) {
return share_data
.x_shares
.iter()
.cloned()
.zip(share_data.y_shares.iter().cloned())
.collect()
}
}
vec![]
}
fn is_duplicate(
&self,
external_nullifier: &pallas::Base,
internal_nullifier: &pallas::Base,
x: &pallas::Base,
y: &pallas::Base,
) -> bool {
if let Some(inner_map) = self.data.get(external_nullifier) {
if let Some(share_data) = inner_map.get(internal_nullifier) {
return share_data.x_shares.contains(x) && share_data.y_shares.contains(y);
}
}
false
}
}
/// Hash message modulo Fp
/// In DarkIRC/eventgraph this could be the event ID
fn hash_message(msg: &str) -> pallas::Base {
@@ -65,6 +141,23 @@ fn hash_message(msg: &str) -> pallas::Base {
pallas::Base::from_uniform_bytes(&buf)
}
fn sss_recover(shares: &[(pallas::Base, pallas::Base)]) -> pallas::Base {
let mut secret = pallas::Base::zero();
for (j, share_j) in shares.iter().enumerate() {
let mut prod = pallas::Base::one();
for (i, share_i) in shares.iter().enumerate() {
if i != j {
prod *= share_i.0 * (share_i.0 - share_j.0).invert().unwrap();
}
}
prod *= share_j.1;
secret += prod;
}
secret
}
fn main() {
// There exists a Sparse Merkle Tree of identity commitments that
// serves as the user registry. If a leaf is NULL, it should mean
@@ -74,12 +167,11 @@ fn main() {
let mut identity_tree = SmtMemoryFp::new(store, hasher.clone(), &EMPTY_NODES_FP);
// Per-app identifier
let rln_identifier = pallas::Base::from(42);
let rln_identifier = pallas::Base::from(1000);
// Create three accounts
let id0 = Identity::new(pallas::Base::from(5));
let id1 = Identity::new(pallas::Base::from(2));
let id2 = Identity::new(pallas::Base::from(1));
// Create two accounts
let id0 = Identity::new(pallas::Base::from(2));
let id1 = Identity::new(pallas::Base::from(1));
// ============
// Registration
@@ -99,7 +191,7 @@ fn main() {
let register_vk = VerifyingKey::build(register_zkbin.k, &register_empty_circuit);
println!("[{:?}]", now.elapsed());
for (i, id) in [id0, id1, id2].iter().enumerate() {
for (i, id) in [id0, id1].iter().enumerate() {
// Create ZK proof
// This 6 message limit is arbitrary and should likely be based on stake.
let witnesses = vec![
@@ -119,17 +211,227 @@ fn main() {
// Verify ZK proof
print!("[Register] Verifying ZK proof for id{i}... ");
let now = Instant::now();
assert!(proof.verify(&register_vk, &public_inputs).is_ok());
println!("[{:?}]", now.elapsed());
let leaf = vec![id.commitment()];
let leaf: Vec<_> = leaf.into_iter().map(|l| (l, l)).collect();
// TODO: Should verify that identity doesn't exist yet before insert.
// TODO: Recipients should verify that identity doesn't exist already before insert.
identity_tree.insert_batch(leaf.clone()).unwrap(); // leaf == pos
assert_eq!(leaf[0].0, id.commitment());
assert_eq!(leaf[0].1, id.commitment());
}
// At this point we have 3 identities registered. They also all have
// different message limits per epoch.
// At this point we have 2 identities registered.
// ==========
// Signalling
// ==========
let signal_zkbin = include_bytes!("../signal.zk.bin");
let signal_zkbin = ZkBinary::decode(signal_zkbin).unwrap();
let signal_empty_circuit =
ZkCircuit::new(empty_witnesses(&signal_zkbin).unwrap(), &signal_zkbin);
print!("[Signal] Building Proving key... ");
let now = Instant::now();
let signal_pk = ProvingKey::build(signal_zkbin.k, &signal_empty_circuit);
println!("[{:?}]", now.elapsed());
print!("[Signal] Building Verifying key... ");
let now = Instant::now();
let signal_vk = VerifyingKey::build(signal_zkbin.k, &signal_empty_circuit);
println!("[{:?}]", now.elapsed());
// Our epoch length will be 10s. This normally means one message every
// 10 seconds. In RLNv2-DIFF we have N messages every epoch.
// This works because of integer division, so for example:
// 1697472000, 1697472005, and 1697472009 are the same epoch, but
// 1697472010 would be the new epoch.
// In practice, if our client realizes we're sending too fast, we could
// also queue it.
let epoch_len = 10_u64;
// =========================
// Account 0 sends a message
// =========================
// 1. Construct share
let epoch = pallas::Base::from(UNIX_EPOCH.elapsed().unwrap().as_secs() as u64 / epoch_len);
let message_id = pallas::Base::from(0); // This should increment each msg
let external_nullifier = poseidon_hash([epoch, rln_identifier]);
let a_0 = poseidon_hash([id0.identity_nullifier, id0.identity_trapdoor]);
let a_1 = poseidon_hash([a_0, external_nullifier, message_id]);
let x = hash_message("hello");
let y = a_0 + x * a_1;
let internal_nullifier = poseidon_hash([a_1]);
// 2. Inclusion proof
let root = identity_tree.root();
let path = identity_tree.prove_membership(&id0.commitment());
assert!(path.verify(&root, &id0.commitment(), &id0.commitment()));
// 3. ZK proof
let witnesses = vec![
Witness::Base(Value::known(id0.identity_nullifier)),
Witness::Base(Value::known(id0.identity_trapdoor)),
Witness::Base(Value::known(id0.user_message_limit)),
Witness::SparseMerklePath(Value::known(path.path)),
Witness::Base(Value::known(x)),
Witness::Base(Value::known(message_id)),
Witness::Base(Value::known(epoch)),
];
let public_inputs = vec![root, external_nullifier, x, y, internal_nullifier];
print!("[Signal] Creating ZK proof for 0:0... ");
let now = Instant::now();
let signal_circuit = ZkCircuit::new(witnesses, &signal_zkbin);
let proof = Proof::create(&signal_pk, &[signal_circuit], &public_inputs, &mut OsRng).unwrap();
print!("[{:?}] ", now.elapsed());
println!("({} bytes)", proof.as_ref().len());
// ============
// Verification
// ============
print!("[Signal] Verifying ZK proof for 0:0... ");
let now = Instant::now();
assert!(proof.verify(&signal_vk, &public_inputs).is_ok());
println!("[{:?}]", now.elapsed());
// Each user of the protocol must store metadata for each message
// received by each user, for the given epoch. The data can be
// deleted when the epoch passes.
let mut metadata = MessageMetadata::new();
if metadata.is_duplicate(&external_nullifier, &internal_nullifier, &x, &y) {
println!("[Signal] Duplicate Message!");
return
}
// Add share
metadata.add_share(external_nullifier, internal_nullifier, x, y);
// Now let's try to send another message in the same epoch.
// id0 has a limit of 2 so it should pass since the ZK circuit will
// allow this.
let message_id = message_id + pallas::Base::from(1);
let a_0 = poseidon_hash([id0.identity_nullifier, id0.identity_trapdoor]);
let a_1 = poseidon_hash([a_0, external_nullifier, message_id]);
let x = hash_message("hello again");
let y = a_0 + x * a_1;
let internal_nullifier = poseidon_hash([a_1]);
// Skip the inclusion proof for the demo since we have it above.
// Make the ZK proof.
let witnesses = vec![
Witness::Base(Value::known(id0.identity_nullifier)),
Witness::Base(Value::known(id0.identity_trapdoor)),
Witness::Base(Value::known(id0.user_message_limit)),
Witness::SparseMerklePath(Value::known(path.path)),
Witness::Base(Value::known(x)),
Witness::Base(Value::known(message_id)),
Witness::Base(Value::known(epoch)),
];
let public_inputs = vec![root, external_nullifier, x, y, internal_nullifier];
print!("[Signal] Creating ZK proof for 0:1... ");
let now = Instant::now();
let signal_circuit = ZkCircuit::new(witnesses, &signal_zkbin);
let proof = Proof::create(&signal_pk, &[signal_circuit], &public_inputs, &mut OsRng).unwrap();
print!("[{:?}] ", now.elapsed());
println!("({} bytes)", proof.as_ref().len());
print!("[Signal] Verifying ZK proof for 0:1... ");
let now = Instant::now();
assert!(proof.verify(&signal_vk, &public_inputs).is_ok());
println!("[{:?}]", now.elapsed());
// Each user of the protocol must store metadata for each message
// received by each user, for the given epoch. The data can be
// deleted when the epoch passes.
if metadata.is_duplicate(&external_nullifier, &internal_nullifier, &x, &y) {
println!("[Signal] Duplicate Message!");
return
}
// Add share
metadata.add_share(external_nullifier, internal_nullifier, x, y);
// Now we shouldn't be able to create more proofs unless we reuse message_id.
// This means that some internal_nullifier will have >1 shares, and it should
// be possible to recover the secret.
// We reuse the above, just try a different message.
let x = hash_message("hello again, i'm reusing a message_id");
let y = a_0 + x * a_1;
// ZK proof:
let witnesses = vec![
Witness::Base(Value::known(id0.identity_nullifier)),
Witness::Base(Value::known(id0.identity_trapdoor)),
Witness::Base(Value::known(id0.user_message_limit)),
Witness::SparseMerklePath(Value::known(path.path)),
Witness::Base(Value::known(x)),
Witness::Base(Value::known(message_id)),
Witness::Base(Value::known(epoch)),
];
let public_inputs = vec![root, external_nullifier, x, y, internal_nullifier];
print!("[Signal] Creating ZK proof for 0:1 (reused message_id) ... ");
let now = Instant::now();
let signal_circuit = ZkCircuit::new(witnesses, &signal_zkbin);
let proof = Proof::create(&signal_pk, &[signal_circuit], &public_inputs, &mut OsRng).unwrap();
print!("[{:?}] ", now.elapsed());
println!("({} bytes)", proof.as_ref().len());
print!("[Signal] Verifying ZK proof for 0:1 (reused message_id) ... ");
let now = Instant::now();
assert!(proof.verify(&signal_vk, &public_inputs).is_ok());
println!("[{:?}]", now.elapsed());
// Add share
metadata.add_share(external_nullifier, internal_nullifier, x, y);
// Now the internal_nullifier should have been repeated, and the internal
// nullifier should have 2 (or more) shares.
// Let's recover them.
let shares = metadata.get_shares(&external_nullifier, &internal_nullifier);
println!("{:#?}", shares);
let secret = sss_recover(&shares);
println!("secret: {:?}", secret);
println!("a_0: {:?}", a_0);
assert_eq!(secret, a_0);
// Additionally, it should not be possible to produce (or verify) a ZK
// proof that exceeds the set message limit for an identity.
let message_id = message_id + pallas::Base::from(2);
let a_0 = poseidon_hash([id0.identity_nullifier, id0.identity_trapdoor]);
let a_1 = poseidon_hash([a_0, external_nullifier, message_id]);
let x = hash_message("hello again");
let y = a_0 + x * a_1;
let internal_nullifier = poseidon_hash([a_1]);
// Skip the inclusion proof for the demo since we have it above.
// Make the ZK proof.
let witnesses = vec![
Witness::Base(Value::known(id0.identity_nullifier)),
Witness::Base(Value::known(id0.identity_trapdoor)),
Witness::Base(Value::known(id0.user_message_limit)),
Witness::SparseMerklePath(Value::known(path.path)),
Witness::Base(Value::known(x)),
Witness::Base(Value::known(message_id)),
Witness::Base(Value::known(epoch)),
];
let public_inputs = vec![root, external_nullifier, x, y, internal_nullifier];
println!("[Signal] Creating ZK proof for 0:2 msgid={:?}... ", message_id);
let signal_circuit = ZkCircuit::new(witnesses, &signal_zkbin);
let proof = Proof::create(&signal_pk, &[signal_circuit], &public_inputs, &mut OsRng).unwrap();
assert!(proof.verify(&signal_vk, &public_inputs).is_err());
println!("[Signal] ZK proof for 0:2 failed as expected");
}