Practical Homomorphic Encryption for Spatial Queries
Running proximity searches, bounding-box filters, and polygon-containment checks directly over ciphertext is the operational payoff of homomorphic encryption basics: the cluster establishes the CKKS scheme, coordinate encoding, and noise-budget accounting, and this page turns those primitives into a query engine you can debug, validate, and audit. It sits inside the broader secure multi-party computation in spatial analytics architecture, where homomorphic evaluation is the single-party compute path that keeps raw coordinates inaccessible while a metropolitan-scale workload still answers “is this point within r metres of that one?” The hard part in production is not the cryptography — it is keeping floating-point coordinate precision, multiplicative noise consumption, and regulatory drift thresholds aligned across every query. This guide covers the exact knobs, a focused reference engine, a runnable validation harness, and the incident-response paths for the failures that actually surface in the field.
Parameter Configuration and Calibration
Spatial queries impose constraints that generic homomorphic workloads do not: real-valued WGS84 coordinates, non-linear distance metrics, and a precision floor set by regulation rather than by taste. Each knob below has a concrete privacy or correctness consequence, so tune it deliberately instead of copying defaults between deployments.
The squared Euclidean distance the engine evaluates homomorphically is
which has multiplicative depth 1 per axis when latitude and longitude are packed into separate ciphertexts, so the modulus chain must carry at least one rescale level with margin.
poly_modulus_degree— ring dimension (≥16384for metropolitan extents). Sets both the SIMD slot count and the security level.8192is fine for a single point pair; metropolitan bounding-box batches need16384so a full coordinate vector packs into one ciphertext without rotation gymnastics. Larger degrees raise security and depth headroom at the cost of ciphertext size — the≤ 4KB/querybudget below is what bounds it.scaling_factor(Δ) — fixed-point precision (2^40). Aligns plaintext precision with the ciphertext representation. At2^40the quantization error on a latitude/longitude pair stays well under the ±0.5 m tolerance that healthcare and financial releases require; drop to2^30and sub-metre joins start failing shadow validation. Δ must match the library’s per-level rescale prime or you get systematic coordinate drift.noise_threshold_bits— pre-decrypt budget floor (15). Distance and aggregation consume the noise budget multiplicatively. A pre-query estimator that rejects any ciphertext below 15 remaining bits prevents the silent decryption failures that break audit trails. Treat this as a hard gate, not a warning.tolerance_meters— utility/drift ceiling (0.5). The maximum decryption drift a release may exhibit while still counting as faithful. This is a compliance parameter: HIPAA Safe Harbor practice for location data and GLBA data-minimization both depend on the assertion that obfuscation never degrades the answer past a stated bound. Above ±1.2 m the result is treated as a drift incident, not a rounding artefact.
Before encryption, resolve spatial partitioning — H3, S2, or GeoHash cells — in plaintext, because CKKS ciphertexts cannot traverse a B-tree or quadtree. Encode grid membership as one-hot boolean vectors and evaluate containment with a homomorphic dot product. When the masking layer is in play, align the masking vector to the same plaintext space described in coordinate masking protocols so modulus switching does not introduce drift during spatial joins, and split decryption authority across regional boundaries with secret sharing for coordinates so no single node ever reconstructs raw plaintext. Map every parameter to a binding clause through the compliance framework mapping so an auditor can trace scaling_factor and tolerance_meters back to the regulation that fixed them.
| Compliance requirement | HE control | Validation metric |
|---|---|---|
| HIPAA Safe Harbor (geo < 20k pop) | CKKS scaling + coordinate masking | Decryption drift ≤ ±0.5 m |
| GLBA data minimization | Pre-encryption grid hashing | Ciphertext size ≤ 4 KB/query |
| PCI-DSS tokenization | Noise-budget thresholding | Budget ≥ 15 bits pre-decrypt |
Reference Implementation
The engine below is backend-agnostic: subclass it for TenSEAL, SEAL-Python, or OpenFHE and override the cryptographic hooks. The base class enforces the noise gate, explicit rescaling discipline, and the drift tolerance before any result leaves the boundary. Inline comments mark the points where a choice has a privacy consequence.
from __future__ import annotations
from typing import Protocol, Sequence
class CKKSBackend(Protocol):
"""Minimal cryptographic surface the engine depends on."""
def encrypt(self, values: Sequence[float]) -> object: ...
def sub(self, a: object, b: object) -> object: ...
def mul(self, a: object, b: object) -> object: ...
def add(self, a: object, b: object) -> object: ...
def rescale_to_next(self, ct: object) -> object: ...
def noise_budget_bits(self, ct: object) -> float: ...
def decrypt(self, ct: object) -> float: ...
class SpatialHEQueryEngine:
"""CKKS spatial proximity evaluator with explicit noise-budget tracking
and compliance-aware drift validation.
The engine answers an encrypted "within r metres" predicate without ever
exposing plaintext coordinates. Every tunable here has a privacy meaning:
the noise gate prevents silent decryption failure, and the metre tolerance
is the regulatory faithfulness bound, not a cosmetic rounding choice.
"""
def __init__(
self,
backend: CKKSBackend,
scaling_factor: int = 2 ** 40,
noise_threshold_bits: int = 15,
tolerance_meters: float = 0.5,
) -> None:
self.backend = backend
self.scaling_factor = scaling_factor
self.noise_threshold_bits = noise_threshold_bits
self.tolerance_meters = tolerance_meters
def encrypt_point(self, lat: float, lon: float) -> tuple[object, object]:
"""Encrypt a WGS84 point into two axis-aligned ciphertexts.
Splitting lat/lon keeps each squared difference at multiplicative
depth 1 — no rotation needed and the modulus chain lasts longer.
"""
return self.backend.encrypt([lat]), self.backend.encrypt([lon])
def evaluate_proximity(
self,
query: tuple[object, object],
target: tuple[object, object],
radius_m: float,
) -> bool:
"""Return True iff target is within radius_m of query.
Raises if the noise budget cannot guarantee a faithful decrypt — a
degraded ciphertext must fail loudly, never silently leak a wrong
membership answer into a released result.
"""
if self.backend.noise_budget_bits(query[0]) < self.noise_threshold_bits:
raise RuntimeError("Noise budget exhausted; re-encrypt required.")
# (q_lat - t_lat)^2 + (q_lon - t_lon)^2, rescaled after every multiply
# so the lattice scale stays aligned with scaling_factor.
d_lat = self.backend.sub(query[0], target[0])
d_lon = self.backend.sub(query[1], target[1])
sq_lat = self.backend.rescale_to_next(self.backend.mul(d_lat, d_lat))
sq_lon = self.backend.rescale_to_next(self.backend.mul(d_lon, d_lon))
dist_sq = self.backend.add(sq_lat, sq_lon)
# Decrypt only the scalar distance, never the coordinates themselves.
decrypted = self.backend.decrypt(dist_sq)
# Compare in metres via an equirectangular metres-per-degree factor.
m_per_deg = 111_320.0
return decrypted * (m_per_deg ** 2) <= radius_m ** 2
def assert_drift_within_tolerance(
self, encrypted_distance_m: float, plaintext_distance_m: float
) -> None:
"""Deterministic shadow check against a synthetic plaintext baseline.
Exceeding tolerance is a compliance event: it means the cryptographic
pipeline, not the data, moved the answer beyond the stated bound.
"""
drift = abs(encrypted_distance_m - plaintext_distance_m)
if drift > self.tolerance_meters:
raise AssertionError(
f"Drift {drift:.3f} m exceeds ±{self.tolerance_meters} m; "
"trace to scaling_factor / rescale misalignment."
)
Validation Checkpoint
Encrypted query logic must never reach production untested — a misaligned rescale is a silent correctness fault that no downstream system will catch. The harness below uses a deterministic plaintext test double so the invariants (noise gating, rescale discipline, drift tolerance, membership correctness) run in CI without a crypto backend installed.
from dataclasses import dataclass
@dataclass
class _PlainCT:
value: float
budget: float = 60.0
class _PlaintextBackend:
"""Exact, deterministic stand-in for a CKKS backend — for tests only."""
def encrypt(self, values):
return _PlainCT(float(values[0]))
def sub(self, a, b):
return _PlainCT(a.value - b.value, min(a.budget, b.budget))
def mul(self, a, b):
return _PlainCT(a.value * b.value, min(a.budget, b.budget) - 10)
def add(self, a, b):
return _PlainCT(a.value + b.value, min(a.budget, b.budget))
def rescale_to_next(self, ct):
return _PlainCT(ct.value, ct.budget - 5)
def noise_budget_bits(self, ct):
return ct.budget
def decrypt(self, ct):
return ct.value
def _test_spatial_he_engine() -> None:
engine = SpatialHEQueryEngine(_PlaintextBackend())
q = engine.encrypt_point(40.7128, -74.0060) # NYC
near = engine.encrypt_point(40.7129, -74.0061) # ~14 m away
far = engine.encrypt_point(40.7300, -74.0060) # ~1.9 km away
# 1. Membership predicate is correct in both directions.
assert engine.evaluate_proximity(q, near, radius_m=50.0) is True
assert engine.evaluate_proximity(q, far, radius_m=50.0) is False
# 2. The noise gate fails loudly below threshold, never silently decrypts.
starved = (_PlainCT(0.0, budget=5.0), _PlainCT(0.0, budget=5.0))
try:
engine.evaluate_proximity(starved, near, radius_m=50.0)
raise AssertionError("starved ciphertext was not rejected")
except RuntimeError:
pass
# 3. Drift inside tolerance passes; drift past it is a compliance failure.
engine.assert_drift_within_tolerance(123.40, 123.45) # 0.05 m, ok
try:
engine.assert_drift_within_tolerance(123.0, 125.0) # 2.0 m
raise AssertionError("excess drift was accepted")
except AssertionError as exc:
assert "exceeds" in str(exc)
print("All spatial HE query invariants hold.")
if __name__ == "__main__":
_test_spatial_he_engine()
Incident Response and Edge Cases
A passing unit test does not guarantee a safe production query. The failures below are the ones that surface under live load, each with a concrete remediation path.
- Precision drift past the metre ceiling. When audit sampling shows decryption drift above ±1.2 m, the cause is almost always a
scaling_factormisaligned with the library’s default rescale prime, or a missingrescale_to_nextafter a spatial multiplication. Remediation: quarantine the affected ciphertext batch, force explicit rescales after every multiply, regenerate the coordinate masking vector, and re-encrypt at a higherpoly_modulus_degree. Log the full noise-consumption trajectory to prove the drift was cryptographic, not data corruption or unauthorized plaintext exposure. - Noise budget exhausted mid-query. Deep aggregations (centroids over large cohorts, chained distance filters) can deplete the budget below 15 bits before the result is ready, yielding a garbage decrypt. Remediation: pre-compute operator depth from the modulus chain, gate at the threshold before dispatch, and either bootstrap or fall back to a deterministic plaintext-with-noise predicate rather than releasing an unfaithful answer.
- Post-encryption grid lookup fails. A query that tries to traverse an H3 or quadtree index after encryption silently returns no matches, because ciphertexts cannot participate in tree traversal. Remediation: resolve all partition IDs in plaintext before encryption, encode cell membership as one-hot vectors, and evaluate containment via homomorphic dot product.
- Masking divergence across query and dataset. Applying different masking vectors to the query point and the stored coordinates injects systematic bias that compounds through spatial joins and shows up as coordinate drift. Remediation: bind a single masking vector to both sides before encryption and verify alignment in the shadow baseline. For asynchronous dispatch that retries failed ciphertexts, route re-encryption through async routing for MPC so a quarantined batch is rebuilt off the main transaction thread, and reconcile the model choice against the privacy model comparison when HE alone proves too heavy for the latency budget.
Frequently Asked Questions
Why CKKS instead of BFV or BGV for spatial queries?
Spatial coordinates are real-valued and carry inherent measurement uncertainty from GPS and survey instruments. CKKS performs approximate arithmetic on real numbers natively, so its controlled precision loss aligns with the data’s own error bars. BFV and BGV are exact integer schemes — usable only after quantizing coordinates to integers, which adds an encoding layer and wastes the very tolerance that makes CKKS efficient here.
How do I keep coordinate precision within a regulatory tolerance?
Set scaling_factor to 2^40 so fixed-point quantization error stays well under the ±0.5 m faithfulness bound, force an explicit rescale_to_next after every spatial multiplication so the ciphertext scale never drifts from the plaintext scale, and run a deterministic shadow baseline that fails the build when drift exceeds tolerance. Treat the metre tolerance as a compliance parameter traced through your compliance mapping, not a cosmetic rounding choice.
Can homomorphic ciphertexts use a spatial index like H3 or a quadtree?
No. Encrypted values cannot be compared for ordering or traversed in a tree, so any index lookup must happen in plaintext before encryption. Resolve the H3, S2, or GeoHash cell first, encode membership as a one-hot boolean vector, and evaluate containment with a homomorphic dot product against the encrypted vector.
What should trigger a re-encryption rather than a retry?
Re-encrypt whenever the pre-query noise estimate falls below 15 bits or audit sampling shows drift above the ±1.2 m incident threshold. A plain retry reuses the same degraded ciphertext and reproduces the fault; re-encryption at a higher poly_modulus_degree with a freshly aligned masking vector restores both budget headroom and precision.
Related
- Homomorphic encryption basics — the CKKS parameterization, coordinate encoding, and DP-budget foundation this engine builds on.
- Coordinate masking protocols — aligning masking vectors to the HE plaintext space to prevent join-time drift.
- Secret sharing for coordinates — splitting decryption authority across regional compliance boundaries.
- Async routing for MPC — pipelining and re-encrypting failed ciphertext batches off the main thread.
- Compliance framework mapping — binding
scaling_factor, drift tolerance, and noise thresholds to specific regulatory clauses.
Up: Homomorphic encryption basics · Secure Multi-Party Computation in Spatial Analytics