Verifying Proofs
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, orINDETERMINATE. - 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
- Verifier Quick Start: build it and verify your first proof.
- Device Attestation: what attestation proves, and the
appattestverification path. - Proof of Location: what a proof is and why it can be checked.
- Verdicts: the SDK's
YES,NO, andINDETERMINATE, which answer a different question.