Docs/Concepts/Verifying Proofs

Verifying Proofs

In short

The proof is self-contained. With the open-source verifier and the bytes alone, anyone can confirm offline that a proof is signed by the key it carries and has not been altered since. The SDK establishes the location on the device, and the verifier proves that record is genuine.

A proof of location is only useful if a third party can verify it independently. octet-verify is the reference verifier: a standalone command-line tool whose source you can read, build, and audit at github.com/octetproof/octet-verify. It contains none of the SDK's proof-creation or spoof-detection logic, and confirms only that a proof is signed by the key it carries and has not been altered since. That narrow scope is what makes it safe to open-source.

To run it, see the Verifier Quick Start.

The trust model

Three components, three levels of trust:

flowchart LR
    A[Device + OctetSDK<br/>produces & signs the proof] -->|uploads| B[Ingestion API<br/>api.octetproof.com<br/>stores bytes only]
    B -->|serves the same bytes| C[octet-verify<br/>independent CLI]
    A -.->|or export the proof to a file| C
    C --> D{Verdict}

The ingestion API is optional. A proof can travel straight from the device to the verifier (the dashed path above). The API is a store-and-serve relay: it holds uploaded proof bytes by id for less than 24 hours so they can be fetched and verified later. Nothing about verifying a proof depends on it.

Component Trusted to… Not trusted to…
OctetSDK (on device) produce a proof correctly, including determining the location honestly, and sign each stage with the hardware-backed key vouch for its own signatures. The proof carries everything needed to re-check them, so you verify the signing yourself rather than take the SDK's word.
Ingestion API (api.octetproof.com) receive proof bytes, store them for less than 24 hours, and serve them back decode, change, sign, or vouch for any proof. It is transport and index only.
octet-verify check signatures, chain linkage, and field bindings against the keys embedded in the proof nothing. This is the one piece a recipient must trust, and its source is public.

The load-bearing property: if the backend were fully compromised, the verifier would still reject a tampered or fabricated proof. None of the verifier's checks rely on anything the backend says. They rely only on the bytes and the cryptographic keys inside the proof. A broken or hostile backend can only fail to return a proof. It cannot fabricate a proof that the verifier will accept.

What the verifier checks

Against the bytes of a single proof, the default build of octet-verify confirms:

  • Stage signatures. Every stage of the proof is signed by the same hardware-backed P-256 key the proof carries.
  • Chain linkage. Each stage links to the one before it. The final assembly stage binds every prior signature into a single value. Change any stage and the chain breaks.
  • Field bindings. The commitment, the nullifier, and the ZK bytes match the hashes they were signed under. A field present with no signed binding fails, so nothing rides along unverified.
  • Semantic-field binding. The spoofing verdict, the region, the level, the integrity status, and the position commitment are bound to the signed proof. Editing any of them after signing is rejected.
  • Freshness. The proof falls within the age window you allow, judged against the signed timestamp rather than the editable top-level field.
  • Wire-format guard. A proof smuggling a duplicate of a single-value field is rejected.

Two more checks run when you pass the upload envelope (--envelope):

  • Transport signature. An Ed25519 signature ties the whole proof to the enrolled device identity.
  • Replay-control binding. The backend-supplied upload nonce, nullifier, and signed timestamp are confirmed against what the proof itself signed.

Semantic-field binding and replay-control binding are new in the v1.1 verifier. The exact byte layout of every signature is documented in the repo's VERIFICATION-SPEC.md.

What a verdict means

The verifier's verdict is not the same as the SDK's verdict. Keep them separate:

  • The SDK answers "is the device in the region?" It returns YES, NO, or INDETERMINATE.
  • The verifier answers "do this proof's signatures verify, and is it unaltered?" It returns one of three states:
Verdict Meaning
VALID The proof passed every check and its signatures verified cryptographically.
INCONCLUSIVE The proof is structurally fine, but its signatures could not be checked (for example, the hardware public key was not available). This is not a pass. Assurance was not established.
INVALID A check failed. The proof is rejected.

Each individual check is reported as PASS, FAIL, or NOT-CHECKED. A NOT-CHECKED line never fails a proof and never makes it valid. It is shown so the boundary of what was confirmed is explicit and never overstated.

Hardware attestation: the appattest build

The default build trusts the signing key as carried: a passing proof shows the proof is signed by that key and unaltered, not that the key is genuine device hardware. To close that gap, build with the appattest feature:

cargo build --release --features appattest

That build validates the device attestation offline:

  • iOS App Attest verified against Apple's embedded App Attest root.
  • Android key attestation validated up the Keystore certificate chain to Google's embedded hardware-attestation root, confirming a TEE or StrongBox key.

These two offline checks run through octet-attest-verify, and the attestation-root line in the output moves from NOT-CHECKED to a real PASS or FAIL. The default build keeps it NOT-CHECKED so the boundary of what was confirmed stays explicit. The Android Play Integrity token is a separate signal that verifies through Google (a Cloud-project round-trip), not offline. See Device Attestation for the full picture.

Where to go next