diff --git a/draft-irtf-cfrg-vdaf.md b/draft-irtf-cfrg-vdaf.md index bcd0e9fb..cb8ac9fe 100644 --- a/draft-irtf-cfrg-vdaf.md +++ b/draft-irtf-cfrg-vdaf.md @@ -1141,10 +1141,6 @@ algorithm, and the update algorithm advances the Aggregator to the next state. Thus, in addition to defining the number of rounds (`ROUNDS`), a VDAF instance defines the state of the Aggregator after each round. -> TODO Consider how to bake this "linear state machine" condition into the -> syntax. Given that Python 3 is used as our pseudocode, it's easier to specify -> the preparation state using a class. - The preparation-state update accomplishes two tasks: recovery of output shares from the input shares and ensuring that the recovered output shares are valid. The abstraction boundary is drawn so that an Aggregator only recovers an output @@ -1156,17 +1152,6 @@ output share. Moreover, in protocols like Prio+ {{AGJOP21}} based on oblivious transfer, it is necessary for the Aggregators to interact in order to recover aggregatable output shares at all. -Note that it is possible for a VDAF to specify `ROUNDS == 0`, in which case each -Aggregator runs the preparation-state update algorithm once and immediately -recovers its output share without interacting with the other Aggregators. -However, most, if not all, constructions will require some amount of interaction -in order to ensure validity of the output shares (while also maintaining -privacy). - -> OPEN ISSUE accommodating 0-round VDAFs may require syntax changes if, for -> example, public keys are required. On the other hand, we could consider -> defining this class of schemes as a different primitive. See issue#77. - ## Validity of Aggregation Parameters {#sec-vdaf-validity-scopes} Similar to DAFs (see {{sec-daf-validity-scopes}}), VDAFs MAY impose @@ -1180,7 +1165,7 @@ been used with the same input share. VDAFs MUST implement the following function: -* `Vdaf.is_valid(agg_param: AggParam, previous_agg_params: set[AggParam]) -> +* `Vdaf.is_valid(agg_param: AggParam, previous_agg_params: list[AggParam]) -> Bool`: Checks if the `agg_param` is compatible with all elements of `previous_agg_params`. @@ -2573,7 +2558,7 @@ Every input share MUST only be used once, regardless of the aggregation parameters used. ~~~ -def is_valid(agg_param, previous_agg_params): +def is_valid(Prio3, agg_param, previous_agg_params): return len(previous_agg_params) == 0 ~~~ {: #prio3-validity-scope title="Validity of aggregation parameters for Prio3."} @@ -3975,15 +3960,36 @@ def prep_shares_to_prep(Poplar1, agg_param, prep_shares): Aggregation parameters are valid for a given input share if no aggregation parameter with the same level has been used with the same input share before. The whole preparation phase MUST NOT be run more than once for a given -combination of input share and level. +combination of input share and level. This function checks that levels are +increasing between calls, and also enforces that the prefixes at each level are +suffixes of the previous level's prefixes. ~~~ -def is_valid(agg_param, previous_agg_params): - (level, _) = agg_param - return all( - level != other_level - for (other_level, _) in previous_agg_params - ) +def get_ancestor(input, this_level, last_level): + # Helper function to determine the prefix of `input` at `last_level`. + return input >> (this_level - last_level) + +def is_valid(Poplar1, agg_param, previous_agg_params): + # Exit early if this is the first time. + if len(previous_agg_params) < 1: + return True + + (level, prefixes) = agg_param + (last_level, last_prefixes) = previous_agg_params[-1] + last_prefixes_set = set(last_prefixes) + + # Check that the level increased. + if level <= last_level + return False + + # Check that prefixes are suffixes of the last level's prefixes. + for prefix in prefixes: + last_prefix = get_ancestor(prefix, level, last_level) + if last_prefix not in last_prefixes_set: + # Current prefix not a suffix of last level's prefixes. + return False + + return True ~~~ {: #poplar1-validity-scope title="Validity of aggregation parameters for Poplar1."} @@ -4603,8 +4609,16 @@ perhaps unlikely) for a large set of non-heavy-hitter values to share a common prefix, which would be leaked by a prefix tree with a sufficiently small threshold. -The only known, general-purpose approach to mitigating this leakage is via -differential privacy. +A malicious adversary controlling the Collector and one of the Aggregators can +further turn arbitrary non-heavy prefixes into heavy ones by tampering with the +IDPF output at any position. While our construction ensures that the nodes +evaluated at one level are children of the nodes evaluated at the previous +level, this still may allow an adversary to discover individual non-heavy +strings. + +The only practical, general-purpose approach to mitigating these leakages is via +differential privacy, which is RECOMMENDED for all protocols using Poplar1 for +heavy-hitter type applications. > TODO(issue #94) Describe (or point to some description of) the central DP > mechanism for Poplar described in {{BBCGGI21}}. diff --git a/poc/tests/test_vdaf_poplar1.py b/poc/tests/test_vdaf_poplar1.py index 5d74d7f1..7eec4e86 100644 --- a/poc/tests/test_vdaf_poplar1.py +++ b/poc/tests/test_vdaf_poplar1.py @@ -2,7 +2,7 @@ from common import TEST_VECTOR, from_be_bytes from tests.vdaf import test_vdaf -from vdaf_poplar1 import Poplar1 +from vdaf_poplar1 import Poplar1, get_ancestor class TestPoplar1(unittest.TestCase): @@ -66,13 +66,40 @@ def test_poplar1(self): [0, 2], ) + def test_get_ancestor(self): + # No change. + assert get_ancestor(0b0, 0, 0) == 0b0 + assert get_ancestor(0b100, 0, 0) == 0b100 + assert get_ancestor(0b0, 1, 1) == 0b0 + assert get_ancestor(0b100, 1, 1) == 0b100 + + # Shift once. + assert get_ancestor(0b0, 1, 0) == 0b0 + assert get_ancestor(0b1, 1, 0) == 0b0 + assert get_ancestor(0b100, 1, 0) == 0b10 + assert get_ancestor(0b100, 2, 1) == 0b10 + + # Shift twice. + assert get_ancestor(0b0, 2, 0) == 0b0 + assert get_ancestor(0b10, 2, 0) == 0b0 + assert get_ancestor(0b100, 2, 0) == 0b1 + assert get_ancestor(0b100, 4, 2) == 0b1 + def test_is_valid(self): # Test `is_valid` returns False on repeated levels, and True otherwise. cls = Poplar1.with_bits(256) - agg_params = [(0, ()), (1, (0,)), (1, (0, 1))] - assert cls.is_valid(agg_params[0], set([])) - assert cls.is_valid(agg_params[1], set(agg_params[:1])) - assert not cls.is_valid(agg_params[2], set(agg_params[:2])) + agg_params = [(0, (0b0, 0b1)), (1, (0b00,)), (1, (0b00, 0b10))] + assert cls.is_valid(agg_params[0], list([])) + assert cls.is_valid(agg_params[1], list(agg_params[:1])) + assert not cls.is_valid(agg_params[2], list(agg_params[:2])) + + # Test `is_valid` accepts level jumps. + agg_params = [(0, (0b0, 0b1)), (2, (0b010, 0b011, 0b101, 0b111))] + assert cls.is_valid(agg_params[1], list(agg_params[:1])) + + # Test `is_valid` rejects unconnected prefixes. + agg_params = [(0, (0b0,)), (2, (0b010, 0b011, 0b101, 0b111))] + assert not cls.is_valid(agg_params[1], list(agg_params[:1])) def test_aggregation_parameter_encoding(self): # Test aggregation parameter encoding. diff --git a/poc/vdaf_poplar1.py b/poc/vdaf_poplar1.py index f4129cd5..4948669d 100644 --- a/poc/vdaf_poplar1.py +++ b/poc/vdaf_poplar1.py @@ -151,16 +151,31 @@ def shard(Poplar1, measurement, nonce, rand): input_shares = list(zip(keys, corr_seed, corr_inner, corr_leaf)) return (public_share, input_shares) - def is_valid(agg_param, previous_agg_params): + @classmethod + def is_valid(Poplar1, agg_param, previous_agg_params): """ - Checks if the level of `agg_param` appears anywhere in - `previous_agg_params`. Returns `False` if it does, and `True` otherwise. + Checks that levels are increasing between calls, and also enforces that + the prefixes at each level are suffixes of the previous level's + prefixes. """ - (level, _) = agg_param - return all( - level != other_level - for (other_level, _) in previous_agg_params - ) + if len(previous_agg_params) < 1: + return True + + (level, prefixes) = agg_param + (last_level, last_prefixes) = previous_agg_params[-1] + last_prefixes_set = set(last_prefixes) + + # Check that level increased. + if level <= last_level: + return False + + # Check that prefixes are suffixes of the last level's prefixes. + for prefix in prefixes: + last_prefix = get_ancestor(prefix, level, last_level) + if last_prefix not in last_prefixes_set: + # Current prefix not a suffix of last level's prefixes. + return False + return True @classmethod def prep_init(Poplar1, verify_key, agg_id, agg_param, @@ -378,3 +393,10 @@ def encode_idpf_field_vec(vec): Field = vec[0].__class__ encoded += Field.encode_vec(vec) return encoded + + +def get_ancestor(input, this_level, last_level): + """ + Helper function to determine the prefix of `input` at `last_level`. + """ + return input >> (this_level - last_level)