Implementing SecAgg Masking for Spatial Gradients

Secure Aggregation (SecAgg) lets a federated server learn the sum of many clients’ spatial gradients while learning nothing about any individual update, and the mechanism that makes that possible is pairwise masking: every ordered pair of clients agrees on a shared random mask that one client adds and the other subtracts, so the masks vanish in the server’s sum and leave only the aggregate. This page is the implementation deep-dive that sits beneath the secure aggregation protocols guide — where the protocol overview, the multi-round handshake, and the graph-of-clients topology are covered end to end — inside the wider Federated Learning Workflows for Geospatial Data architecture. Here the scope is deliberately narrow: the concrete construction of one masking round over a quantized spatial gradient vector, from Diffie–Hellman seed agreement through PRG expansion to modular addition in ZR\mathbb{Z}_R. Because a coordinate-regression or heatmap model trained across regions produces gradients whose per-client distribution is skewed by geography — the same skew that motivates handling non-IID geospatial data in federated learning — the masking layer must be exact to the bit, and this page shows how to get it there.

Parameter Configuration and Calibration

SecAgg masking has four knobs, and each one is a correctness or privacy constraint rather than a performance dial. The masks live in a finite ring ZR\mathbb{Z}_R; the gradients must be quantized into that same ring before any mask is applied. Get the ring size wrong and the true sum wraps around silently; get the clip bound wrong and one client’s gradient dominates the aggregate.

Parameter Symbol Typical value Why it is set here
Ring modulus RR 2322^{32} Must satisfy R>2nSCR > 2\,n\,S\,C so the signed aggregate never wraps; wraparound corrupts the sum invisibly.
Clip bound CC 1.01.0 L2/L∞ clip caps per-client sensitivity so no single spatial gradient dominates — the same bound a DP-SGD step uses.
Fixed-point scale SS 10310^{3} Integer resolution of each gradient element; larger SS is finer but raises the overflow risk against RR.
DH group (p,g)(p, g) 2048-bit MODP (RFC 3526) Source of the shared pairwise seed; the demo below uses a small prime, production must not.
PRG SHA-256 counter mode Expands one 256-bit seed into a full-length mask; must be a CSPRNG, never a plain random.Random.

The ring constraint is the one that bites in production. Each quantized element has magnitude at most SCS \cdot C; summing nn clients gives at most nSCn\,S\,C, and because we encode signed values as residues we need headroom on both sides, so R>2nSCR > 2\,n\,S\,C. With n=500n = 500 mobile mapping nodes, S=103S = 10^3, and C=1C = 1, the sum reaches 5×1055\times10^5 — three orders of magnitude below 2322^{32}, which is why R=232R = 2^{32} is a safe default for gradient vectors up to a few thousand clients. Push nn into the tens of thousands or raise SS for fine-grained coordinate regression and you move to R=264R = 2^{64}.

The pairwise seed itself comes from a Diffie–Hellman key agreement: client ii holds a secret skisk_i and publishes pki=gskimodppk_i = g^{sk_i} \bmod p; the shared value for pair (i,j)(i,j) is gskiskjmodpg^{sk_i sk_j} \bmod p, which both parties compute independently and no one else can. That symmetry is the whole trick — it lets two clients derive the identical mask without ever exchanging it, so the additive inverse cancels perfectly. This is the same additive-inverse principle that underpins additive secret sharing for coordinates in Python, applied here to gradient vectors instead of raw coordinates.

SecAgg pairwise masking — every mask is added by one client and subtracted by the other, so the server sum leaves only Σg Three client boxes sit in a horizontal row. Between client 1 and client 2 a double-headed edge is labelled plus/minus m one-two; between client 2 and client 3 a double-headed edge is labelled plus/minus m two-three; an arc over the top connects client 1 and client 3, labelled plus/minus m one-three. Each client shows the update it uploads: y1 equals g1 plus m one-two plus m one-three, y2 equals g2 minus m one-two plus m two-three, y3 equals g3 minus m one-three minus m two-three. Three arrows feed down into a wide server box that computes the sum of y1, y2 and y3 modulo R; the box states that the pairwise masks cancel, leaving the sum of g1, g2 and g3. SecAgg pairwise masking — every mask is +added by one client, −subtracted by the other +m₁₃ / −m₁₃ +m₁₂ / −m₁₂ +m₂₃ / −m₂₃ Client 1 quantize g₁ ∈ Z_R y₁ = g₁ +m₁₂ +m₁₃ (+self-mask)

Client 2 quantize g₂ ∈ Z_R y₂ = g₂ −m₁₂ +m₂₃ (+self-mask)

Client 3 quantize g₃ ∈ Z_R y₃ = g₃ −m₁₃ −m₂₃ (+self-mask)

upload y₁ upload y₂ upload y₃ Server aggregate Σᵢ yᵢ (mod R) = Σᵢ gᵢ every +m and −m cancels — no single gradient revealed self-masks removed via secret-shared seeds of surviving clients

Reference Implementation

The three functions below implement one masking round with pure standard library plus NumPy for the vector arithmetic. derive_pairwise_seed runs the Diffie–Hellman agreement, prg_mask expands a seed into a mask vector over ZR\mathbb{Z}_R, and mask_update assembles the update a single client uploads. The self-mask is added on top of the pairwise masks: its seed is Shamir-shared with the other clients so that if this client drops out mid-round, a surviving quorum can reconstruct the seed and subtract the self-mask — the dropout-recovery path that keeps the round from stalling.

python
from __future__ import annotations

import hashlib
import secrets
from typing import Dict

import numpy as np

# --- Ring and quantization parameters (see Parameter Configuration) ---
R: int = 1 << 32                    # gradient ring Z_R; need R > 2 * n * S * C
DH_PRIME: int = (1 << 256) - 189   # DEMO prime only. A real round MUST use a
DH_GEN: int = 5                     # 2048-bit MODP group AND authenticated DH.


def derive_pairwise_seed(sk_i: int, pk_j: int, prime: int = DH_PRIME) -> int:
    """Diffie-Hellman shared secret between clients i and j -> a 256-bit PRG seed.

    Both clients compute the SAME group element: pk_j**sk_i == g**(sk_i*sk_j)
    == pk_i**sk_j, so neither has to transmit the mask.
    SECURITY: this raw DH is UNAUTHENTICATED. In production the public keys must be
    signed and exchanged over authenticated channels, or a man-in-the-middle server
    can learn every pairwise seed and strip the masks off each update.
    """
    shared: int = pow(pk_j, sk_i, prime)                    # g^{sk_i * sk_j} mod p
    # Hash the group element to a uniform seed; the raw DH value is not uniform.
    return int.from_bytes(hashlib.sha256(str(shared).encode()).digest(), "big")


def prg_mask(seed: int, length: int, modulus: int = R) -> np.ndarray:
    """Expand a seed into a deterministic mask vector in Z_modulus^length.

    SHA-256 in counter mode is the pseudo-random generator: identical seed -> identical
    mask on both clients, which is exactly what makes +mask and -mask cancel. A plain
    non-crypto RNG here would let the server predict masks and unmask a lone update.
    """
    seed_bytes: bytes = seed.to_bytes(32, "big")            # 256-bit seed
    out = np.empty(length, dtype=np.int64)
    filled, counter = 0, 0
    while filled < length:
        block = hashlib.sha256(seed_bytes + counter.to_bytes(8, "big")).digest()
        for k in range(0, 32, 4):                           # 8 x 4-byte words / block
            if filled >= length:
                break
            out[filled] = int.from_bytes(block[k:k + 4], "big") % modulus
            filled += 1
        counter += 1
    return out


def quantize(grad: np.ndarray, clip: float, scale: int) -> np.ndarray:
    """Fixed-point encode a real gradient into non-negative residues of Z_R.

    Clipping to [-clip, clip] bounds each client's contribution so one skewed
    regional gradient cannot dominate the masked sum, then scaling + mod R lands
    the value in the ring where masks live.
    """
    clipped = np.clip(grad, -clip, clip)
    q = np.round(clipped * scale).astype(np.int64)
    return np.mod(q, R)                                     # signed value -> residue


def mask_update(
    grad_q: np.ndarray,
    my_id: int,
    my_sk: int,
    peer_pks: Dict[int, int],
    self_seed: int,
) -> np.ndarray:
    """Build the masked vector client `my_id` uploads for one SecAgg round.

    For each peer j: derive s_ij by DH, expand to a mask, and add +mask if my_id < j
    else -mask. Because client j does the mirror operation, the two masks are additive
    inverses mod R and cancel in the server's sum. The self-mask on top is removed only
    if a quorum reveals this client's secret-shared self_seed (dropout recovery).
    """
    length = int(grad_q.shape[0])
    masked = grad_q.astype(np.int64).copy()
    for j, pk_j in peer_pks.items():
        if j == my_id:
            continue
        m_ij = prg_mask(derive_pairwise_seed(my_sk, pk_j), length)
        if my_id < j:
            masked = np.mod(masked + m_ij, R)              # lower id adds +m
        else:
            masked = np.mod(masked - m_ij, R)              # higher id adds -m
    masked = np.mod(masked + prg_mask(self_seed, length), R)   # per-client self-mask
    return masked

Validation Checkpoint

The invariant that must hold is exact: after the server sums every masked upload and removes the self-masks of surviving clients, the result must equal the plain modular sum of the quantized gradients — bit for bit, not approximately. A single mismatched mask leaves a large pseudo-random residue in the aggregate, so this check fails loudly rather than degrading quietly. The harness builds four clients, masks their quantized gradients, and asserts the cancellation.

python
def _validate() -> None:
    rng = np.random.default_rng(7)
    n, dim = 4, 16
    clip, scale = 1.0, 1_000

    # DH keypairs (UNAUTHENTICATED demo) and secret-shared self-seeds per client.
    sk = {i: secrets.randbelow(DH_PRIME - 2) + 1 for i in range(1, n + 1)}
    pk = {i: pow(DH_GEN, sk[i], DH_PRIME) for i in range(1, n + 1)}
    self_seed = {i: int.from_bytes(secrets.token_bytes(32), "big")
                 for i in range(1, n + 1)}

    # Each client quantizes its (geographically skewed) gradient.
    grad_q = {i: quantize(rng.normal(0.0, 0.3, dim), clip, scale)
              for i in range(1, n + 1)}

    # Each client masks and uploads.
    uploads = {}
    for i in range(1, n + 1):
        peers = {j: pk[j] for j in range(1, n + 1) if j != i}
        uploads[i] = mask_update(grad_q[i], i, sk[i], peers, self_seed[i])

    # Server: sum masked uploads, then subtract self-masks of surviving clients
    # (their self_seeds are recovered from Shamir shares; no dropouts here).
    agg = np.zeros(dim, dtype=np.int64)
    for i in range(1, n + 1):
        agg = np.mod(agg + uploads[i], R)
    for i in range(1, n + 1):
        agg = np.mod(agg - prg_mask(self_seed[i], dim), R)

    # Ground truth: plaintext modular sum of the quantized gradients.
    truth = np.zeros(dim, dtype=np.int64)
    for i in range(1, n + 1):
        truth = np.mod(truth + grad_q[i], R)

    assert np.array_equal(agg, truth), "pairwise masks failed to cancel"
    # Sanity: an individual masked upload must NOT equal its plaintext gradient.
    assert not np.array_equal(uploads[1], grad_q[1]), "update was left unmasked"
    print("SecAgg masking invariant holds: unmasked sum == plaintext gradient sum")


if __name__ == "__main__":
    _validate()

The second assertion matters as much as the first: it confirms the upload the server actually receives is not the raw gradient. If both hold, the masks are strong on the wire yet perfectly transparent in aggregate.

Incident Response and Edge Cases

SecAgg masking fails in ways that either stall a round or, worse, corrupt the aggregate without any error. The scenarios below are the ones that surface on live federated rounds over spatial data.

  • Client dropout mid-round. A mobile mapping node uploads its masked vector, then goes offline before the server can collect the reveal messages. Its pairwise masks with every surviving peer are now stuck in the sum with no matching inverse. Remediation: the surviving clients reveal their Shamir shares of the dropped client’s DH secret so the server reconstructs the missing pairwise masks and subtracts them; for clients that survived, they instead reveal the self-mask seed. The protocol must never let the server obtain both the self-seed and the pairwise seeds of the same client, or it can unmask that client’s update.
  • Quantization overflow past RR. With too many clients or too large a scale SS, the true sum exceeds RR and wraps, so the recovered aggregate is off by a multiple of RR on some coordinates — a silent, catastrophic gradient. Detection: assert R>2nSCR > 2\,n\,S\,C at round setup and log the observed max element. Remediation: raise RR to 2642^{64} or lower SS, and clip more aggressively so the per-client magnitude bound CC actually holds.
  • Mask reuse across rounds. If a client reuses the same DH keypair and self-seed for a new round, an attacker who saw last round’s reveal can subtract the stale masks from this round’s upload and recover the fresh gradient. Remediation: derive per-round seeds by hashing the DH element together with a monotonic round counter, rotate DH keys each round, and reject any upload carrying a stale round tag — the same round-nonce discipline used across async execution patterns.
  • Non-canonical vector length. Two clients quantize gradients of different lengths (a model-version skew), so their pairwise masks are expanded to different lengths and no longer cancel. Remediation: pin the model architecture hash into the round setup and refuse to admit a client whose gradient dimension does not match the round’s declared dim.

Frequently Asked Questions

Why derive masks from Diffie–Hellman instead of shipping random masks between clients?

Because the server is the one relaying messages, and you never want it to see a mask. With Diffie–Hellman each pair of clients derives the identical shared seed from their own secret keys and each other's public keys, so the mask is computed locally and independently on both ends and is never transmitted. Shipping a mask through the server would hand it exactly the value it needs to unmask a lone client. The public keys still travel through the server, which is why they must be authenticated — an unauthenticated exchange lets the server substitute its own keys and learn every seed.

How does the self-mask enable dropout recovery?

The pairwise masks alone create a dilemma: to cancel the masks of a dropped client, surviving clients must reveal their shared seeds with that client — but the same reveal would unmask a client who merely uploaded slowly rather than dropping. The self-mask breaks the tie. Every client adds an extra mask keyed by a self-seed that is Shamir-shared with the group. For a client that survived, the server collects shares of the self-seed and removes only the self-mask; for a client that dropped, the server instead collects shares of its DH secret and removes its pairwise masks. The rule is that the server may recover exactly one of the two per client, never both.

What size must the ring R be to avoid overflow?

The ring must be strictly larger than twice the maximum absolute aggregate, so R > 2 · n · S · C, where n is the client count, S the fixed-point scale, and C the per-client clip bound. The factor of two leaves room for signed values encoded as residues. For a few thousand clients at scale 10^3 and clip 1.0, R = 2^32 is comfortable; scale up the client count or resolution and move to 2^64. If the sum ever wraps, the aggregate is silently wrong on the affected coordinates, so this bound is checked at setup rather than trusted.

Does SecAgg masking provide differential privacy on its own?

No. Masking hides the individual updates during transport, but the server still learns the exact plaintext sum, and a sum can leak information about a contributor — especially with skewed regional gradients where one client's data shifts the aggregate visibly. To get a formal privacy guarantee you combine SecAgg with a differential-privacy mechanism: each client clips and adds calibrated noise before masking, or the server adds noise after aggregation, and the spend is tracked in a Rényi accountant. Masking protects the transport; differential privacy protects the output.

Up: Secure Aggregation Protocols · Federated Learning Workflows for Geospatial Data