#data-privacy #privacy #analytics #data-analytics #protocols #behavior

star-constellation

Nested threshold aggregation built on the STAR protocol

4 releases

0.2.4 Oct 11, 2024
0.2.3 Nov 27, 2023
0.2.2 Sep 14, 2023
0.2.1 Aug 3, 2023

#992 in Cryptography

MPL-2.0 license

71KB
1.5K SLoC

Constellation

Rust library implementing the Constellation threshold aggregation mechanism. It allows clients to submit ordered, granular data at the highest that is possible whilst maintaining crowd-based anonymity. The receiving server can only decode messages whose contents were also submitted by some threshold number of other clients, blocking identification of unique behaviour.

Constellation is a nested version of the STAR protocol and this library makes use of the sta-rs Rust implementation.

Disclaimer

WARNING this library has not been audited, use at your own risk! This code is under active development and may change substantially in future versions.

Quickstart

Build & test:

cargo build
cargo test

lib.rs:

The star-constellation crate implements the Constellation aggregation mechanism: a modification of the original STAR protocol to allow clients to submit ordered, granular data at the highest resolution that is possible, whilst maintaining crowd-based anonymity.

Constellation provides both higher utility for data aggregation than STAR alone (revealing partial measurements where possible), and better privacy for fine-grained client data.

Background

Specifically, Constellation 'nests' or 'layers' an ordered vector of measurements into associated STAR messages, such that each message can only be accessed if the STAR message at the previous layer was included in a successful recovery. The privacy of unrevealed layers is provided using symmetric encryption, that can only be decrypted using a key enclosed in the previous STAR message.

Example API usage

Client

The Client produces a message for threshold aggregation using the Constellation format.

#
let threshold = 10;
let epoch = 0u8;
let random_fetcher = RandomnessFetcher::new();

// setup randomness server information
let example_aux = vec![1u8; 3];

let measurements = vec!["hello".as_bytes().to_vec(), "world".as_bytes().to_vec()];
let rrs = client::prepare_measurement(&measurements, epoch).unwrap();
let req = client::construct_randomness_request(&rrs);

let req_slice_vec: Vec<&[u8]> = req.iter().map(|v| v.as_slice()).collect();
let resp = random_fetcher.eval(&req_slice_vec, epoch).unwrap();

let points_slice_vec: Vec<&[u8]> =
  resp.serialized_points.iter().map(|v| v.as_slice()).collect();
let proofs_slice_vec: Vec<&[u8]> =
  resp.serialized_proofs.iter().map(|v| v.as_slice()).collect();
client::construct_message(
  &points_slice_vec,
  Some(&proofs_slice_vec),
  &rrs,
  &Some(random_fetcher.get_server().get_public_key()),
  &example_aux,
  threshold
).unwrap();

Server

Server aggregation takes a number of client messages as input, and outputs those measurements that were received from at least threshold clients. It also reveals prefixes of full measurements that were received by greater than threshold clients.

Full recovery

After receiving at least threshold client messages of the same full measurement, the server can run aggregation and reveal the client measurement.

#
let threshold = 10;
let epoch = 0u8;
let random_fetcher = RandomnessFetcher::new();

// construct at least `threshold` client messages with the same measurement
let measurements_1 = vec!["hello".as_bytes().to_vec(), "world".as_bytes().to_vec()];
let client_messages_to_reveal: Vec<Vec<u8>> = (0..threshold).into_iter().map(|i| {
  let example_aux = vec![i as u8; 3];
  let rrs = client::prepare_measurement(&measurements_1, epoch).unwrap();
  let req = client::construct_randomness_request(&rrs);

  let req_slice_vec: Vec<&[u8]> = req.iter().map(|v| v.as_slice()).collect();
  let resp = random_fetcher.eval(&req_slice_vec, epoch).unwrap();

  let points_slice_vec: Vec<&[u8]> =
    resp.serialized_points.iter().map(|v| v.as_slice()).collect();
  let proofs_slice_vec: Vec<&[u8]> =
    resp.serialized_proofs.iter().map(|v| v.as_slice()).collect();
  client::construct_message(
    &points_slice_vec,
    Some(&proofs_slice_vec),
    &rrs,
    &Some(random_fetcher.get_server().get_public_key()),
    &example_aux,
    threshold
  ).unwrap()
}).collect();

// construct a low number client messages with a different measurement
let measurements_2 = vec!["something".as_bytes().to_vec(), "else".as_bytes().to_vec()];
let client_messages_to_hide: Vec<Vec<u8>> = (0..2).into_iter().map(|i| {
  let example_aux = vec![i as u8; 3];
  let rrs = client::prepare_measurement(&measurements_2, epoch).unwrap();
  let req = client::construct_randomness_request(&rrs);

  let req_slice_vec: Vec<&[u8]> = req.iter().map(|v| v.as_slice()).collect();
  let resp = random_fetcher.eval(&req_slice_vec, epoch).unwrap();

  let points_slice_vec: Vec<&[u8]> =
    resp.serialized_points.iter().map(|v| v.as_slice()).collect();
  let proofs_slice_vec: Vec<&[u8]> =
    resp.serialized_proofs.iter().map(|v| v.as_slice()).collect();
  client::construct_message(
    &points_slice_vec,
    Some(&proofs_slice_vec),
    &rrs,
    &Some(random_fetcher.get_server().get_public_key()),
    &example_aux,
    threshold
  ).unwrap()
}).collect();

// aggregation reveals the client measurement that reaches the
// threshold, the other measurement stays hidden
let agg_res = server::aggregate(
  &[client_messages_to_reveal, client_messages_to_hide].concat(),
  threshold,
  epoch,
  measurements_1.len()
);
let output = agg_res.outputs();
assert_eq!(output.len(), 1);
let revealed_output = output.iter().find(|v| v.value() == vec!["world"]).unwrap();
assert_eq!(revealed_output.value(), vec!["world"]);
assert_eq!(revealed_output.occurrences(), 10);
(0..10).into_iter().for_each(|i| {
  assert_eq!(revealed_output.auxiliary_data()[i], vec![i as u8; 3]);
});

Partial recovery

Partial recovery allows revealing prefixes of full measurements that are received by enough clients, even when the full measurements themselves stay hidden.

#
let threshold = 10;
let epoch = 0u8;
let random_fetcher = RandomnessFetcher::new();

// construct a low number client messages with the same measurement
let measurements_1 = vec!["hello".as_bytes().to_vec(), "world".as_bytes().to_vec()];
let client_messages_1: Vec<Vec<u8>> = (0..5).into_iter().map(|i| {
  let example_aux = vec![i as u8; 3];
  let rrs = client::prepare_measurement(&measurements_1, epoch).unwrap();
  let req = client::construct_randomness_request(&rrs);

  let req_slice_vec: Vec<&[u8]> = req.iter().map(|v| v.as_slice()).collect();
  let resp = random_fetcher.eval(&req_slice_vec, epoch).unwrap();

  let points_slice_vec: Vec<&[u8]> =
    resp.serialized_points.iter().map(|v| v.as_slice()).collect();
  let proofs_slice_vec: Vec<&[u8]> =
    resp.serialized_proofs.iter().map(|v| v.as_slice()).collect();
  client::construct_message(
    &points_slice_vec,
    Some(&proofs_slice_vec),
    &rrs,
    &Some(random_fetcher.get_server().get_public_key()),
    &example_aux,
    threshold
  ).unwrap()
}).collect();

// construct a low number of measurements that also share a prefix
let measurements_2 = vec!["hello".as_bytes().to_vec(), "goodbye".as_bytes().to_vec()];
let client_messages_2: Vec<Vec<u8>> = (0..5).into_iter().map(|i| {
  let example_aux = vec![i as u8; 3];
  let rrs = client::prepare_measurement(&measurements_2, epoch).unwrap();
  let req = client::construct_randomness_request(&rrs);

  let req_slice_vec: Vec<&[u8]> = req.iter().map(|v| v.as_slice()).collect();
  let resp = random_fetcher.eval(&req_slice_vec, epoch).unwrap();

  let points_slice_vec: Vec<&[u8]> =
    resp.serialized_points.iter().map(|v| v.as_slice()).collect();
  let proofs_slice_vec: Vec<&[u8]> =
    resp.serialized_proofs.iter().map(|v| v.as_slice()).collect();
  client::construct_message(
    &points_slice_vec,
    Some(&proofs_slice_vec),
    &rrs,
    &Some(random_fetcher.get_server().get_public_key()),
    &example_aux,
    threshold
  ).unwrap()
}).collect();

// aggregation reveals the partial client measurement `vec!["hello"]`,
// but the full measurements stay hidden
let agg_res = server::aggregate(
  &[client_messages_1, client_messages_2].concat(),
  threshold,
  epoch,
  measurements_1.len()
);
let output = agg_res.outputs();
assert_eq!(output.len(), 1);
assert_eq!(output[0].value(), vec!["hello"]);
assert_eq!(output[0].occurrences(), 10);
(0..10).into_iter().for_each(|i| {
  let val = i % 5;
  assert_eq!(output[0].auxiliary_data()[i], vec![val as u8; 3]);
});

Dependencies

~4.5–6.5MB
~134K SLoC