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 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
Pricing the side-channel defences A grouped bar chart of five side-channel defences. Doing nothing costs nothing and leaks everything. Batching results is nearly free and removes a fifth of the signal. Dummy accesses at three to one cost four times and roughly halve it. Padding every cell to the ninety-ninth percentile costs twenty-two times and leaves the touched-cell set visible. A full index scan is nearly leak-free and costs 160 times. Pricing the side-channel defences relative cost against how much of the access-pattern signal survives 0 50 100 150 × cost / % leakage 1 100 none 1 82 batch results 4 44 dummies 3:1 22 18 pad to p99 160 2 full scan cost multiplier residual leakage (%)
There is no cheap complete defence. Pick a point on this curve deliberately and write down what it leaves exposed.

Reference Implementation

python
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

python
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()
Page packing is a free mitigation A rising curve of the observer's uncertainty per observed page fault, against the number of cells packed into each page, on a logarithmic axis. Packing four cells per page leaves only two bits of ambiguity; packing 512 leaves nine. Because packing is chosen at index-build time it costs nothing at query time. Page packing is a free mitigation cells per 4 KB page against the observer's per-fault uncertainty 10 100 0 2.5 5 7.5 cells packed per page bits of uncertainty observer's uncertainty (bits per fault)
Deliberately packing geographically unrelated cells into the same page raises the observer's uncertainty for free — and is the only defence here with no runtime cost.

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.
Query time tracks cell occupancy unless it is padded Two lines against cell occupancy on a logarithmic axis. Unpadded query time rises linearly with the number of points a cell holds, so an observer timing the query learns the local density. Padded to the ninety-ninth-percentile occupancy the line is flat, revealing nothing, at the cost of every query running at the worst case. Query time tracks cell occupancy unless it is padded per-cell processing time against how many points the cell holds 10 100 1000 0 5 10 points in the queried cell seconds unpadded query time padded to p99 occupancy
An observer with a stopwatch learns local density from an unpadded query. Flattening the curve means every query pays the p99 cost — that is the price of the defence.

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.

Up one level: Trusted Execution for Spatial Workloads · Section: Secure Multi-Party Computation in Spatial Analytics.