Rotating Masks Across Streaming Releases
A coordinate mask is unbreakable and trivially defeated. Unbreakable, because a 128-bit mask over a prime field is information-theoretically hiding for a single release. Trivially defeated, because two releases of the same coordinate under the same mask subtract to the difference of the plaintexts — and if one plaintext is known, or the coordinate did not move, the mask cancels out entirely. In a streaming pipeline, where the same device is masked thousands of times a day, mask rotation is not housekeeping; it is the protocol. This page specifies it, under coordinate masking protocols in Secure Multi-Party Computation in Spatial Analytics.
Parameter Configuration and Calibration
derivation— masks from a KDF, never from a counter or a clock. Derive each mask as . This makes masks reproducible for authorised reconstruction, unpredictable to everyone else, and — crucially — unique per (release, record) pair without a stateful counter that can be reset.release_id— unique per emitted release, including retries. A retried release that reuses its identifier reuses its mask, which is correct only when the retry is a byte-identical replay of a transmission that never landed. Anything recomputed gets a new identifier.epoch_key_lifetime— how long the epoch key lives. The epoch key is the compromise blast radius: an attacker who obtains it can derive every mask in that epoch. Rotate it on a fixed schedule and destroy the old one once the reconstruction window has closed.reconstruction_window— how long masks must remain derivable. Masks must survive long enough for legitimate reconstruction and not one hour longer. This window is what a deletion claim rests on: once the epoch key is destroyed, the masked records are unrecoverable by anyone.
| Failure | Mechanism | Consequence |
|---|---|---|
| Same mask, two releases | counter reset, cached mask | difference reveals plaintext delta |
| Same mask, stationary device | mask keyed on position | difference is zero — device is stationary |
| Predictable mask | PRNG seeded at start-up | mask recoverable by replay |
| Mask logged | debug output, crash dump | protocol reduces to obfuscation |
Reference Implementation
from __future__ import annotations
import hashlib
import hmac
import secrets
from dataclasses import dataclass, field
from typing import Mapping
FIELD = 2 ** 127 - 1
class MaskReuse(RuntimeError):
"""Raised when a (release, record) pair would be masked twice."""
@dataclass
class MaskDeriver:
"""Deterministic, per-release mask derivation with reuse detection.
Determinism is what lets an authorised party reconstruct without storing every
mask; the reuse check is what stops determinism from becoming a vulnerability.
"""
epoch_key: bytes
epoch_id: str
_issued: set[tuple[str, str]] = field(default_factory=set)
def mask(self, release_id: str, record_id: str) -> int:
key = (release_id, record_id)
if key in self._issued:
raise MaskReuse(
f"mask already issued for release {release_id!r}, record {record_id!r} — "
"a recomputed release must take a NEW release id"
)
self._issued.add(key)
material = hmac.new(
self.epoch_key,
f"{self.epoch_id}|{release_id}|{record_id}".encode(),
hashlib.blake2b,
).digest()
return int.from_bytes(material, "big") % FIELD
def rotate(self, new_epoch_id: str) -> None:
"""Start a new epoch with a fresh key; old masks become underivable."""
self.epoch_key = secrets.token_bytes(32)
self.epoch_id = new_epoch_id
self._issued.clear()
def mask_coordinate(value_scaled: int, mask: int) -> int:
"""Additive mask over the prime field."""
return (value_scaled + mask) % FIELD
def unmask_coordinate(masked: int, mask: int) -> int:
return (masked - mask) % FIELD
def difference_attack(masked_a: int, masked_b: int, mask: int) -> int:
"""What an adversary computes when two releases share a mask.
Present so the failure is testable, not because it should ever be reachable:
with a shared mask the difference of two masked values IS the difference of the
plaintexts, and for a stationary device that difference is zero.
"""
return (masked_a - masked_b) % FIELD
Validation Checkpoint
def _validate() -> None:
deriver = MaskDeriver(epoch_key=secrets.token_bytes(32), epoch_id="2026-W33")
lat_scaled = 51_507_351 # 51.507351 deg at 1e6 fixed point
# 1. Masking then unmasking with the same derivation round-trips exactly.
m1 = deriver.mask("r-001", "dev-9")
assert unmask_coordinate(mask_coordinate(lat_scaled, m1), m1) == lat_scaled
# 2. Re-issuing the same (release, record) pair must raise, not return the mask.
try:
deriver.mask("r-001", "dev-9")
except MaskReuse:
pass
else:
raise AssertionError("mask reuse must be detected at derivation time")
# 3. Different releases of the SAME record get independent masks.
m2 = deriver.mask("r-002", "dev-9")
assert m1 != m2
# 4. With distinct masks, the difference of two masked values reveals nothing:
# it is not the plaintext difference.
a = mask_coordinate(lat_scaled, m1)
b = mask_coordinate(lat_scaled, m2)
assert difference_attack(a, b, m1) != 0
# 5. With a SHARED mask, a stationary device is exposed exactly — the attack.
shared = deriver.mask("r-003", "dev-1")
c = mask_coordinate(lat_scaled, shared)
d = mask_coordinate(lat_scaled, shared)
assert difference_attack(c, d, shared) == 0, "shared masks reveal stationarity"
# 6. Rotation must make old masks underivable.
old_key = deriver.epoch_key
deriver.rotate("2026-W34")
assert deriver.epoch_key != old_key
assert deriver.mask("r-001", "dev-9") != m1
# 7. Derivation must be reproducible for an authorised party holding the key.
twin = MaskDeriver(epoch_key=deriver.epoch_key, epoch_id=deriver.epoch_id)
assert twin.mask("r-777", "dev-3") == MaskDeriver(
epoch_key=deriver.epoch_key, epoch_id=deriver.epoch_id
).mask("r-777", "dev-3")
print("mask rotation: all assertions passed")
_validate()
Assertion 5 is the attack in three lines, and it is worth running in CI precisely because it looks like a trivial test. A device that has not moved produces two identical masked values under a shared mask — the adversary does not even need to compute a difference to notice.
Incident Response and Edge Cases
- A release is retried after a failure. If the payload is byte-identical and never landed, reuse the release id and the mask; if anything was recomputed, issue a new id. Encode this in the retry path rather than leaving it to the operator, because under incident pressure the instinct is to replay.
- Two pipeline instances mask the same record. Both derive from the same epoch key and the same record id, so the reuse detector in one process cannot see the other’s issuance. Partition the release-id space per instance — a prefix is enough — so collisions are impossible by construction rather than by coordination.
- The epoch key leaks. Every mask in that epoch is derivable, so every masked record in it is plaintext to the holder. Rotate immediately, re-mask anything still within its reconstruction window under a new key, and treat the old epoch’s records as disclosed.
- A mask appears in a log line. That record is compromised, and probably others: whatever printed one mask will have printed many. Audit the log path, purge, and add a test that the mask type has no string representation containing its value.
- Reconstruction is needed after the epoch key was destroyed. By design, impossible. This is the property that makes the deletion claim true, so the answer is to widen the reconstruction window going forward, not to retain keys “just in case”.
A useful review question for any masking pipeline: name every place a mask could be persisted, and show the test that proves it is not. The list is longer than it first appears — application logs, crash dumps, metrics labels, tracing spans, debugging fixtures, and the test corpus itself — and a mask surviving in any one of them reduces the whole protocol to obfuscation for the records it covers. The derivation design above helps here too: because masks are derived rather than stored, the only long-lived secret is the epoch key, and there is exactly one place to protect it.
Frequently Asked Questions
Why derive masks instead of storing them?
A stored mask table is a second copy of the sensitive data — hold it and you must protect it exactly as you protect the coordinates. Derivation from an epoch key plus a release and record identifier gives reproducibility without storage, and destroying one key destroys every mask in the epoch at once.
Is rotating per epoch enough, or must it be per release?
Per release. An epoch key is the derivation secret, not the mask: within an epoch every (release, record) pair still gets its own unique mask. Rotating only the epoch key while reusing masks inside it leaves the difference attack fully available.
What if the same coordinate is legitimately released twice?
It gets two independent masks, and the two masked values look unrelated — which is the intended behaviour. The consumer that needs to know they are the same record uses the record identifier, which is handled at a different layer with its own linkability decision.
How does mask rotation relate to secure aggregation's masking?
They solve the same problem at different scales. SecAgg's pairwise masks cancel within one round's sum; coordinate masks hide individual records across a stream. Both fail the same way — reuse — and both need per-release derivation with an explicit reuse check.
Related
- Coordinate Masking Protocols — the parent protocol and its envelope construction.
- Coordinate Masking with Paillier Encryption — masking that supports homomorphic aggregation.
- Additive Secret Sharing for Coordinates in Python — the same field arithmetic, split across parties.
- Dropout-Resilient SecAgg Key Recovery — mask cancellation in the federated setting.
Up one level: Coordinate Masking Protocols · Section: Secure Multi-Party Computation in Spatial Analytics.