Side-Channel Risks for Geospatial Enclave Queries
An enclave keeps the host from reading your coordinates. It does not keep the host from watching how the enclave touches memory, how long each operation takes, or which pages fault — and for spatial workloads those observations are unusually informative, because access patterns follow geography almost exactly. A host that learns which index pages were touched learns which regions the query concerned; a host that times the join learns where the data was dense. This page quantifies the leakage and prices the defences, under trusted execution for spatial workloads in Secure Multi-Party Computation in Spatial Analytics.
Parameter Configuration and Calibration
page_size— the observation granularity. A malicious host observes page faults at 4 KB. If your cell index packs 32 cells per page, the host learns which group of 32 was touched, which for a metro-scale index is often a neighbourhood. Deliberately choosing a packing that maps unrelated cells to the same page raises the observer’s uncertainty for free.padding_target— the fixed cost every cell is padded to. The core oblivious defence: process every candidate cell for the same amount of work regardless of how many points it holds. Set the target from a high percentile of the occupancy distribution — the 99th, typically — because the cost of the whole query becomes that target times the cell count.dummy_ratio— dummy accesses per real access. Cheaper than full padding and weaker: it raises the observer’s uncertainty without eliminating the correlation. Useful where full obliviousness is unaffordable, and it must be reported honestly as a mitigation rather than a fix.batch_boundary— the unit whose timing is observable. If results are returned per query, the host times each one. Batching many queries and returning them together makes the observable timing a property of the batch, and it is the cheapest meaningful defence available.
| Defence | Cost multiplier | Leakage remaining |
|---|---|---|
| None | 1× | access pattern ≈ geography |
| Batch results | ~1× | per-batch aggregate timing |
| Dummy accesses at 3:1 | ~4× | correlation weakened, not removed |
| Full padding to p99 | 10–40× | occupancy hidden, cell set still visible |
| Scan the whole index | index/query size | nothing beyond the query’s existence |
Reference Implementation
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Iterable, Mapping, Sequence
@dataclass(frozen=True)
class AccessTrace:
"""What a malicious host can observe: pages touched, in order, with timings."""
pages: tuple[int, ...]
total_units: int
def distinct_pages(self) -> int:
return len(set(self.pages))
def naive_lookup(
query_cells: Sequence[str], occupancy: Mapping[str, int], *, cells_per_page: int
) -> AccessTrace:
"""The default implementation: touch only what is needed, cost what it costs.
Both observables are data-dependent — the page sequence names the region, and
the unit count reveals its density.
"""
pages: list[int] = []
units = 0
for cell in query_cells:
page = abs(hash(cell)) % 4096 // max(1, cells_per_page)
pages.append(page)
units += max(1, occupancy.get(cell, 0))
return AccessTrace(tuple(pages), units)
def padded_lookup(
query_cells: Sequence[str],
occupancy: Mapping[str, int],
*,
cells_per_page: int,
padding_target: int,
) -> AccessTrace:
"""Oblivious in cost: every cell is processed for exactly `padding_target` units.
Cells above the target are truncated, which is a correctness trade — set the
target from a high occupancy percentile so truncation is rare, and account for
the truncation as a bias in the released aggregate.
"""
pages: list[int] = []
for cell in query_cells:
pages.append(abs(hash(cell)) % 4096 // max(1, cells_per_page))
return AccessTrace(tuple(pages), padding_target * len(query_cells))
def dummy_padded_lookup(
query_cells: Sequence[str],
occupancy: Mapping[str, int],
all_cells: Sequence[str],
*,
cells_per_page: int,
dummy_ratio: int,
seed: int = 0,
) -> AccessTrace:
"""Interleave `dummy_ratio` decoy cells per real cell, in shuffled order.
Weaker than padding: the observer still sees a cost that correlates with real
occupancy, just diluted. Shuffling matters — appending dummies after the real
accesses makes them trivially separable by position.
"""
rng = random.Random(seed)
plan = list(query_cells)
for _ in range(dummy_ratio * len(query_cells)):
plan.append(rng.choice(all_cells))
rng.shuffle(plan)
pages = [abs(hash(c)) % 4096 // max(1, cells_per_page) for c in plan]
units = sum(max(1, occupancy.get(c, 0)) for c in plan)
return AccessTrace(tuple(pages), units)
def leakage_score(traces: Sequence[AccessTrace]) -> float:
"""How distinguishable a set of traces is: 0 means identical, 1 means unique.
A crude but honest proxy — if two queries over different regions produce traces
an observer can tell apart, the access pattern is leaking the region regardless
of what the formal enclave guarantee says.
"""
if len(traces) < 2:
return 0.0
signatures = {(t.distinct_pages(), t.total_units) for t in traces}
return (len(signatures) - 1) / (len(traces) - 1)
Validation Checkpoint
def _validate() -> None:
rng = random.Random(11)
all_cells = [f"c{i}" for i in range(4_000)]
occupancy = {c: max(1, int(rng.lognormvariate(3.0, 1.4))) for c in all_cells}
dense = [c for c in all_cells if occupancy[c] > 120][:24]
sparse = [c for c in all_cells if occupancy[c] < 8][:24]
# 1. The naive implementation leaks: dense and sparse queries look different.
naive_traces = [naive_lookup(dense, occupancy, cells_per_page=32),
naive_lookup(sparse, occupancy, cells_per_page=32)]
assert naive_traces[0].total_units > 4 * naive_traces[1].total_units
assert leakage_score(naive_traces) == 1.0
# 2. Padding removes the cost signal entirely.
padded = [padded_lookup(dense, occupancy, cells_per_page=32, padding_target=200),
padded_lookup(sparse, occupancy, cells_per_page=32, padding_target=200)]
assert padded[0].total_units == padded[1].total_units
# 3. Padding does NOT hide which cells were queried — be honest about that.
assert padded[0].pages != padded[1].pages
# 4. Padding's cost is the target times the cell count, which is the price.
assert padded[0].total_units == 200 * len(dense)
assert padded[0].total_units > naive_traces[1].total_units * 10
# 5. Dummy accesses reduce, but do not remove, the correlation.
d_dense = dummy_padded_lookup(dense, occupancy, all_cells,
cells_per_page=32, dummy_ratio=3, seed=1)
d_sparse = dummy_padded_lookup(sparse, occupancy, all_cells,
cells_per_page=32, dummy_ratio=3, seed=1)
ratio_naive = naive_traces[0].total_units / naive_traces[1].total_units
ratio_dummy = d_dense.total_units / d_sparse.total_units
assert 1.0 < ratio_dummy < ratio_naive, (ratio_dummy, ratio_naive)
# 6. Coarser page packing raises the observer's uncertainty at no runtime cost.
fine = naive_lookup(dense, occupancy, cells_per_page=8)
coarse = naive_lookup(dense, occupancy, cells_per_page=128)
assert coarse.distinct_pages() <= fine.distinct_pages()
print("side-channel analysis: all assertions passed")
_validate()
Assertion 3 is the one that keeps the analysis honest. Padding hides how much data each cell held; it does not hide which cells were touched. Only scanning the whole index — or an ORAM-style access protocol — hides the cell set, and both are expensive enough that the decision must be made explicitly rather than assumed.
Incident Response and Edge Cases
- A researcher demonstrates region recovery from page faults. Expected under the naive implementation, and it should not be a surprise. Have the honest posture documented in advance: which observables are unprotected, and what an adversary with host access can infer.
- Padding makes the query too slow to ship. Reduce the candidate cell set instead of the padding target — a query that touches 24 cells instead of 400 is 16× cheaper and leaks less. Coarsening the grid does both at once.
- Occupancy exceeds the padding target. Truncation drops real points and biases the result downward, systematically in the densest cells. Either raise the target or account for the truncation explicitly in the release, because a silent downward bias in dense areas is exactly the kind of error that survives review.
- Timing varies with the host’s load, not the data. Good for the defender, but do not rely on it: a patient observer averages over many queries and recovers the data-dependent component.
- Dummies are appended rather than interleaved. Then they are separable by position and provide no protection. Shuffle the plan, and test that the real accesses are not identifiable by order.
Frequently Asked Questions
Are side channels a theoretical concern or a practical one?
Practical, and more so for spatial data than for most workloads. Controlled-channel attacks let a malicious host single-step an enclave and observe page-level access exactly. Because spatial indexes are laid out geographically, the page sequence is close to a map of the query.
Does full padding solve the problem?
It removes the occupancy signal, which is usually the most sensitive one, and leaves the set of touched cells visible. That is a meaningful improvement and not a complete defence. Hiding the cell set as well requires scanning the whole index or an oblivious access protocol, at a cost most deployments decline once they see it.
Can differential privacy help against side channels?
Not directly — DP bounds what the released output reveals, and a side channel bypasses the output entirely. What does help is applying DP to the query pattern itself: randomising which cells are touched, or adding decoy queries, so the observed pattern is a noisy function of the real one.
What should the design document say?
Explicitly, in one paragraph: which observables are protected, which are not, what an adversary with host access can infer from the unprotected ones, and which mitigation was chosen with its cost. A design that simply does not mention side channels is the one that will be criticised, regardless of what it actually does.
Related
- Trusted Execution for Spatial Workloads — the parent design and its trust boundary.
- Enclave-Side Spatial Joins with Sealed Data — the chunked access pattern analysed here.
- Spatial PSI for Proximity Queries — a protocol whose leakage is analysed cryptographically rather than empirically.
- Threat Mapping for GIS Data — where this leakage belongs on the map.
Up one level: Trusted Execution for Spatial Workloads · Section: Secure Multi-Party Computation in Spatial Analytics.