Provenance Guard (PBPG)

Console-only three-channel device verification: sensor fingerprint, kinematic handshake, and a quorum attestation from a second enrolled device.

Last reviewed September 21, 2026Fresh

Overview

Provenance Guard checks that a request really comes from a specific piece of enrolled hardware. A device answers a challenge with three kinds of evidence: a composite hash of its sensor readings, a kinematic measurement, and an attestation from a second device in the same user's constellation. All three must pass. There is no partial credit and no probabilistic score.

Where it sits. The verifier is the shield-provenance-guard edge function, backed by three tables: shield_constellation_devices (enrolled devices and their rank), shield_provenance_challenges (one row per challenge and its verdict) and shield_succession_tokens.

Who can use it. The Enterprise plan, and a signed-in tenant admin. The function is console-only: it is absent from the public function allowlist, so https://api.passkeybridge.io/v1/shield-provenance-guard answers 404 with x-pb-reason: unknown-function. It requires the admin scope, which no API key the platform will mint can carry, because admin is excluded from the provisionable scope list. The one way in is supabase.functions.invoke from a dashboard session whose user passes is_tenant_admin for the tenant_id in the request body.

What has a screen. Dashboard, Security, Device provenance lists enrolled devices and challenge history. That is the whole UI. Enrolment, challenge and verdict have no screen and no public endpoint, so a deployment that uses them drives them from its own console-side integration.

Three-channel architecture

A verdict is authorized only when all three channels pass, and nothing substitutes for a channel. How strictly Channels 2 and 3 are checked depends on two columns on shield_tenants. Both default to false, and no screen writes either one today, so an operator sets them in the database:

  • provenance_hardening_enabled turns on server-derived Doppler in Channel 2 and mandatory signature verification in Channel 3.
  • provenance_classb_standalone_enabled lets a Wi-Fi or cell co-presence pass satisfy Channel 3 on its own.

Channel 1, atomic fingerprint. The device submits emi_spectral_hash, thermal_variance_hash, accelerometer_hash, network_jitter_hash and capture_duration_ms. The server joins those four hashes with | in that order, takes SHA-256 of the result, and requires an exact match against the device_fingerprint_hash recorded at enrolment. Any difference fails the channel with similarity 0. Drift-aware matching appears in the patent application and is not implemented, so the enrolled value has to be the composite computed in exactly that order.

Channel 2, kinematic handshake. mode is ntn, terrestrial or hybrid.

  • terrestrial passes when carrier_jitter_entropy is above 0.01 and at most 1.0.
  • ntn with hardening on compares the device's observed_doppler_hz against a line-of-sight Doppler shift the server propagates with SGP4, for the satellite (norad_id) and observer bound into the challenge. Send observed_at, an ISO timestamp of when the device took the reading: the server propagates the ephemeris to that instant rather than to the verdict instant, which removes an unbounded error term. It is refused if it is more than 120 seconds old or more than 5 seconds in the future, and an unusable value fails the channel closed rather than falling back. The expected value is computed by the server, not taken from the request. It passes within 15 percent of that value, with the comparison denominator floored at 1,000 Hz so a near-zero expectation at closest approach cannot widen the window. The satellite must be at least 10 degrees above the horizon, and the carrier is fixed server-side at 11.325 GHz.
  • ntn with hardening off falls back to comparing the caller's own doppler_variance against the caller's own expected_doppler_variance, which establishes nothing about where the device is.
  • hybrid requires both the NTN and the terrestrial check.

The ephemeris comes from one durable catalog row holding the Starlink group. Since 2026-09-21 it is refreshed hourly from Space-Track.org, the USSPACECOM publication channel, with CelesTrak kept as an automatic backup: CelesTrak's four-hourly job defers while Space-Track is keeping the row fresh and resumes on its own if Space-Track goes quiet for three hours. Rows older than 24 hours are refused and the request path never fetches an upstream itself, so a missing, stale or out-of-group catalog fails the channel closed. Background: Ground truth for space-based auth.

Channel 3, quorum attestation. A second device belonging to the same tenant vouches for the one being challenged. The attesting device must be enrolled, active and senior rank. Class A protocols carry a measured distance; Class B protocols compare sets of observed network landmarks.

ProtocolClassPasses when
nfcAdistance at most 0.1 m, and with hardening on the verdict is reached within 15 seconds of the challenge
uwbAdistance at most 5 m
bleAdistance at most 30 m
doppler_correlationAdistance at most 50 km
wifi_copresenceBat least 2 shared Wi-Fi landmarks, observed within a 60 second window
cell_copresenceBat least 1 shared cell landmark, observed within a 60 second window

A missing proximity_distance_m is treated as infinite, so an omitted distance fails Class A rather than passing it. Raw BSSIDs and cell identities never reach a column or a log: the server keyed-hashes each one with HMAC-SHA-256 under the server-held pepper, compares digests, and stores only the count of shared landmarks.

With hardening off, Class A checks rank and distance only, and records signature_valid: null. With hardening on, the attesting device's enrolled ML-DSA-65 public key must verify a signature over a canonical payload that binds the live challenge nonce, so a replayed attestation fails; a device enrolled without a public key fails the channel outright. A Class B pass corroborates but does not satisfy Channel 3 unless provenance_classb_standalone_enabled is set for the tenant.

Actions and request bodies

Seven actions on one function. Every request carries action and tenant_id. A missing tenant_id answers 400 tenant_id required, and an unrecognised action answers 400 Unknown action.

const { data, error } = await supabase.functions.invoke("shield-provenance-guard", {
  body: { action: "pg_devices", tenant_id: tenantId },
});
ActionRequired fieldsOptional fields
pg_challengeuser_hash, device_idnorad_id, observer with lat and lon
pg_verifychallenge_idfingerprint, kinematic, quorum_attestation
pg_enrolluser_hash, device_fingerprint_hashdevice_label, enrolled_by_device_id, device_public_jwk
pg_promotedevice_idnone
pg_successionuser_hash, new_device_id, vouching_device_ids, entropy_chain_hashkinematic_context_hash, proximity_protocol, proximity_distance_m
pg_devicesnoneuser_hash to filter to one subject
pg_historynonelimit, default 50 and capped at 200

Omitting an evidence object on pg_verify is not a client error: that channel simply fails, so an empty submission returns a rejected verdict with 200 rather than a 400.

pg_challenge binds the satellite and observer for Channel 2 at issue time and returns the nonce the attesting device signs:

{
  "ok": true,
  "challenge_id": "uuid",
  "challenge_nonce": "64 hex characters",
  "expires_at": "2026-09-16T17:00:05Z",
  "channels_required": ["atomic_fingerprint", "kinematic_handshake", "quorum_attestation"],
  "correlation_id": "uuid"
}

The challenge expires five seconds after it is created. Evidence collection has to be under way before the challenge is issued; a flow that starts capturing sensor data afterwards will miss the window and get 410.

pg_enroll records a junior device and returns { ok, device: { id, rank, enrolled_at }, correlation_id }. device_public_jwk must be an ML-DSA-65 public JWK (kty is AKP, public component only). It is validated whenever it is supplied, and it is required once hardening is on.

Device constellation and ranks

Every device enrolls as junior. Only a senior device can vouch in Channel 3, and pg_promote is the only path from one rank to the other.

pg_promote refuses while attestation_count is below promotion_threshold, which defaults to 5, answering 400 Device needs N more attestations before promotion. A device that is already senior returns 200 with Already senior. One caveat to plan around: no shipped code path increments attestation_count, and none writes last_attested_at. Today those counters move only if something outside this function writes them, so promotion in practice is a database step.

Succession. pg_succession records a hardware handover in shield_succession_tokens. It requires at least two named devices that are senior and active, and answers 400 Need >=2 senior vouching devices, found N otherwise. The row stores the caller-supplied entropy_chain_hash, the voucher ids, the arriving device's fingerprint and the proximity protocol, which defaults to ble. The token is unsigned, no chain is recomputed, and the departing device stays active. Recursive entropy chaining sets out exactly what that action does.

Revocation. There is no pg_revoke action. is_active and revoked_at on shield_constellation_devices are set outside this function, and an inactive device fails a challenge with 404 Device not found or inactive.

Constellation fracture detection appears in the patent application. No code in this build watches for Doppler discontinuity or reissues a challenge on its own.

Verdict and errors

pg_verify returns the per-channel results and the combined verdict. The verdict itself carries no signature: this function verifies signatures the devices supply and signs nothing of its own.

{
  "ok": true,
  "authorized": false,
  "channel_1": { "pass": true, "similarity": 1 },
  "channel_2": { "pass": true, "mode": "terrestrial", "trust_weight": 1 },
  "channel_3": { "pass": false, "signature_valid": false, "reason": "Attesting device has no enrolled public key" },
  "reason": "Provenance FAILED: Channel 3 (Quorum) did not pass. No fallback.",
  "latency_ms": 42,
  "correlation_id": "uuid"
}

authorized is the AND of the three pass flags. reason appears only on a rejection and names each channel that failed. Every verdict also updates the challenge row with the per-channel results, the expected and observed Doppler when hardening computed them, the TLE epoch and age, the shared landmark count and the assurance tier.

StatusBody or headerCause
400tenant_id requiredno tenant_id in the body
400user_hash and device_id requiredincomplete pg_challenge
400device_public_jwk is not a valid ML-DSA-65 public JWKmalformed key on pg_enroll
401x-pb-reason: invalid-jwtthe session token did not verify
403x-pb-reason: not-tenant-adminsigned in without admin rights on that tenant
403code: plan_requiredthe tenant is below Enterprise
404Device not found or inactiveunknown or revoked device_id
404Challenge not found or already resolvedunknown challenge_id, or one already scored
405Method not allowedany method other than POST
410Challenge expiredthe verdict arrived more than five seconds after the challenge
404 with x-pb-reason: unknown-functionat api.passkeybridge.iothe function is console-only

Verify it worked

Dashboard, Security, Device provenance is the only screen, and it is read-only.

  • The four tiles count senior devices, junior devices, and the authorized and rejected verdicts among the loaded challenges.
  • Device constellation lists every active device with its rank badge, the first 16 characters of its fingerprint hash, its enrolment date and, for a junior device, a progress bar toward promotion_threshold.
  • Challenge history shows the 20 newest challenges: an Authorized or Rejected badge, the Channel 2 mode rendered as Satellite, Terrestrial or Hybrid, the decision time the function recorded, and a tick or cross for Fingerprint, Handshake and Quorum.
  • Refresh re-reads both lists. Neither polls, so a verdict issued from your integration appears on the next refresh.

Each verdict also writes an audit entry. Dashboard, Operations, Audit log holds provenance_authorized or provenance_rejected with the challenge id as the resource, alongside constellation_enroll, constellation_promote and succession_ceremony.

Related from the blog