Octet.verify
On-device proof verification. Static, synchronous, offline. Verifies a LocationProof (or its raw bytes) against the keys inside it, with no network call. See On-device Verification for what it can and cannot establish.
Signature
public enum Octet {
public static func verify(_ proof: LocationProof,
options: VerifyOptions = VerifyOptions()) -> ProofVerification
public static func verify(proofBytes: Data,
options: VerifyOptions = VerifyOptions()) -> ProofVerification
}
object Octet {
fun verify(proof: LocationProof,
options: VerifyOptions = VerifyOptions()): ProofVerification
fun verify(proofBytes: ByteArray,
options: VerifyOptions = VerifyOptions()): ProofVerification
}
The two overloads are equivalent: verify(proof:) reads proof.proofBytes. A structurally undecodable input returns verdict == invalid with a wire-format FAIL and does not throw.
VerifyOptions
public struct VerifyOptions: Sendable {
public var expectedRegion: ProofRegion? // enables region checks; nil ⇒ NOT-CHECKED
public var maxAgeSeconds: TimeInterval // freshness window; default 300
public var requireAttestation: Bool // fail-closed on hardware-attestation; default false
public var attestationBundle: Data? // iOS: enrolment bundle to anchor assertion-only proofs
public var requireVerdict: RequiredVerdict? // fail-closed on the signed verdict tier; default nil
public var callerMetadata: CallerMetadataCheck? // manifest + content for caller-metadata; default nil
public var requireCallerMetadata: Bool // fail-closed: the proof must commit content; default false
public var expectedSessionNonce: Data? // the sessionNonce you asked with; nil ⇒ NOT-CHECKED
public init(expectedRegion: ProofRegion? = nil, maxAgeSeconds: TimeInterval = 300,
requireAttestation: Bool = false, attestationBundle: Data? = nil,
/* … */ requireVerdict: RequiredVerdict? = nil,
callerMetadata: CallerMetadataCheck? = nil, requireCallerMetadata: Bool = false,
expectedSessionNonce: Data? = nil)
}
public enum RequiredVerdict: Sendable, Hashable {
case verified // only a signed VERIFIED verdict passes
case plausible // a signed VERIFIED or PLAUSIBLE verdict passes
}
data class VerifyOptions(
val expectedRegion: ProofRegion? = null, // enables region checks; null ⇒ NOT-CHECKED
val maxAgeSeconds: Long = 300, // freshness window; default 300
val requireAttestation: Boolean = false, // fail-closed on hardware-attestation; default false
val attestationBundle: ByteArray? = null, // iOS: enrolment bundle to anchor assertion-only proofs
val requireVerdict: RequiredVerdict? = null, // fail-closed on the signed verdict tier; default null
val callerMetadata: CallerMetadataCheck? = null, // manifest + content for caller-metadata; default null
val requireCallerMetadata: Boolean = false, // fail-closed: the proof must commit content; default false
val expectedSessionNonce: ByteArray? = null, // the sessionNonce you asked with; null ⇒ NOT-CHECKED
)
enum class RequiredVerdict {
VERIFIED, // only a signed VERIFIED verdict passes
PLAUSIBLE, // a signed VERIFIED or PLAUSIBLE verdict passes
}
expectedRegionturns on theregion-claim,region-type, andcontainschecks. Whennil/null, those reportNOT-CHECKED.maxAgeSecondsis the freshness window, judged against the signed timestamp.requireAttestationadds theattestation-requiredgate: the result isFAILunlesshardware-attestationpassed.requireVerdict(new in 3.0, deprecated in 3.1) fails a proof whose signed assurance tier is below the one you require. It also fails closed when the verdict is not signed into the proof..verified/VERIFIEDrefuses aPLAUSIBLEdisc answer, which otherwise verifies like aVERIFIEDone. It matches--require-verdictinoctet-verify1.6.0. Since 3.1 it is ignored by default, and its check reportsNOT-CHECKED. See the assurance tier. It is removed in 4.0.callerMetadata(new in 3.1) checks a content commitment. See Caller metadata.requireCallerMetadata(new in 3.1) fails a proof that commits no content, so a presenter can't avoid the check by handing over a different proof.expectedSessionNonce(new in 3.1) is thesessionNonceyou passed when you asked for the proof. Thesession-bindingcheck passes only for a proof bound to this nonce, and fails for a proof bound to another nonce or to none, so a proof captured from another session can't be replayed to you. Checking that each nonce is used once stays with you. It matches--session-nonceinoctet-verify. See Proof Binding.attestationBundle(iOS) is a serialized enrolment bundle (attestationEnrolmentBundle().protoData()) that anchors an assertion-only proof to Apple's App Attest root. On Android it is ignored, because every attested Android proof carries its own key-attestation chain.
ProofVerification
The result. A flat, log-safe record: no coordinates, no proof bytes echoed, no PII.
| Field | Type | Meaning |
|---|---|---|
verdict |
Verdict |
Tri-state headline: valid / invalid / inconclusive. |
checks |
[ProofCheck] |
Every check that ran, in recipe order. |
isValid |
Bool |
No check is FAIL. |
isAuthentic |
Bool |
isValid, stage-signatures is PASS, and no check is INCONCLUSIVE. |
spoofingVerdict |
LocationProofVerdict? |
New in 3.0. The proof's signed assurance tier (VERIFIED, PLAUSIBLE, and so on), or nil / null when the tier is not signed into the proof. Since 3.1, nil / null by default. |
Verdict:valid/invalid/inconclusive(VALID/INVALID/INCONCLUSIVEon Android).ProofCheck:name: String,status: CheckStatus,detail: String(log-safe).nameis a stable string from the check taxonomy below. Most names matchoctet-verify. The on-devicehardware-attestationcheck covers what the CLI reports asattestation-root(Android) andapp-attest(iOS).CheckStatus:pass/fail/warn/notChecked/inconclusive(PASS/FAIL/WARN/NOT_CHECKED/INCONCLUSIVEon Android). The wire tags arePASS/FAIL/WARN/NOT-CHECKED/INCONCLUSIVE.inconclusiveis new in 3.1. AswitchorwhenoverCheckStatuswithout a default branch must handle it.
Verdict rules
These mirror octet-verify verbatim:
isValidis true when no check isFAIL.NOT-CHECKEDandWARNnever reject.isAuthenticis true whenisValid,stage-signaturesisPASS, and no check isINCONCLUSIVE.NOT-CHECKEDnever makes a proof authentic. New in 3.1: anINCONCLUSIVEcheck makesisAuthenticfalse.verdictisinvalidif any check isFAIL, elsevalidifisAuthentic, elseinconclusive.- A skipped check is always reported as
NOT-CHECKED.
Check taxonomy
| Check | What it confirms | On-device |
|---|---|---|
wire-format |
The bytes decode to a well-formed proof with no smuggled duplicate fields. | PASS / FAIL |
stage-signatures |
Every stage is signed by the hardware-backed P-256 key the proof carries. | PASS / FAIL |
device-attestation-sig |
The device-key signature over the proof's committed fields. | PASS / FAIL |
binding / freshness |
Field bindings match their signed hashes. The proof falls within maxAgeSeconds. |
PASS / FAIL |
region-claim / region-type / contains |
The claimed region matches expectedRegion. |
PASS / FAIL when expectedRegion set, else NOT-CHECKED |
hardware-attestation |
The device-attestation chain anchors to a bundled Apple / Google root, so the proof is hardware-attested. | PASS / FAIL when anchorable, else NOT-CHECKED |
attestation-required |
Fail-closed gate on hardware-attestation. |
PASS / FAIL only when requireAttestation |
session-binding |
The proof is bound to expectedSessionNonce. New on-device in 3.1. |
PASS / FAIL when expectedSessionNonce set, else NOT-CHECKED |
verdict-tier |
On a disc answer, the tier the verdict was signed with. New in 3.0. | Disc answers whose proof carries a signed tier |
caller-manifest |
The content list the proof carries recomputes its signed commitment, and a presented manifest matches that list. New in 3.1. | PASS / FAIL (MANIFEST_MISMATCH, MANIFEST_INVALID) when the proof lists content, else NOT-CHECKED |
caller-manifest-locator |
A listed locator looks like a credential: longer than 256 bytes, or a URL with a query string or user info. New in 3.1. | WARN, never FAIL. Present only when a locator is flagged |
caller-metadata |
The presented manifest matches the proof's content commitment, and each presented value matches its digest. New in 3.1. | PASS / FAIL / INCONCLUSIVE, else NOT-CHECKED |
| replay-uniqueness, revocation | Backend-only state. | Always NOT-CHECKED on-device |
Caller metadata
New in 3.1. To check a content commitment, pass the manifest you received with the proof, and the content you want to check, in callerMetadata.
public struct CallerMetadataCheck: Sendable {
public enum Content: Sendable { case bytes(Data), file(URL), unavailable(String) }
public enum RequiredContent: Sendable, Equatable { case none, keys([String]), all }
public var manifestJson: Data? // the manifest, as received
public var content: [String: Content] // per manifest key
public var requireContent: RequiredContent // default .none
}
class CallerMetadataCheck(
manifestJson: ByteArray?, // the manifest, as received
val content: Map<String, Content> = emptyMap(),
val requireContent: RequiredContent = RequiredContent.None,
) {
sealed class Content { class Bytes(value: ByteArray); data class FromFile(val file: File); data class Unavailable(val reason: String) }
sealed class RequiredContent { object None; data class Keys(val keys: List<String>); object All }
}
The manifest is JSON, {"v":1,"entries":[{"key","contentType","size","salt","sha256"}]}, with one entry per item in the order of the proof's contentCommitments, and salt and sha256 in lower-case hex. Build it from contentCommitments and the salts your app kept. An item you leave out of content is accepted on its digest. Use requireContent to insist on seeing particular items. Use .unavailable for content you were told about but could not fetch.
The caller-metadata check reports:
| Result | When |
|---|---|
PASS |
The manifest matches the commitment, and every presented value matches. The detail counts items matched by content and by digest. |
FAIL |
A value or a size does not match (CONTENT_MISMATCH), the manifest differs from the list the proof carries (MANIFEST_MISMATCH), the manifest is malformed (MANIFEST_INVALID), or content was presented for a proof that commits none. Also a proof without a commitment when requireCallerMetadata is set. |
INCONCLUSIVE |
Content was referenced but could not be read, was required and not presented, or was presented without a manifest (CONTENT_UNRESOLVED). |
NOT-CHECKED |
Nothing was presented. |
INCONCLUSIVE makes the verdict inconclusive and isAuthentic false, so a proof whose required content went unchecked is never accepted on either.
let check = CallerMetadataCheck(manifestJson: manifest,
content: ["note": .bytes(note), "video": .file(videoURL)],
requireContent: .all)
let result = Octet.verify(proof, options: VerifyOptions(callerMetadata: check,
requireCallerMetadata: true))
if result.verdict == .valid { accept(proof) }
val check = CallerMetadataCheck(manifest,
mapOf("note" to Content.Bytes(note), "video" to Content.FromFile(videoFile)),
RequiredContent.All)
val result = Octet.verify(proof, VerifyOptions(callerMetadata = check, requireCallerMetadata = true))
if (result.verdict == ProofVerification.Verdict.VALID) accept(proof)
Example
var options = VerifyOptions(maxAgeSeconds: 120)
options.requireVerdict = .verified // refuse a PLAUSIBLE disc answer
let result = Octet.verify(verdict.proof!, options: options)
if result.isAuthentic {
accept(verdict.proof!)
} else {
for check in result.checks where check.status == .fail {
log("verify failed: \(check.name) — \(check.detail)")
}
}
val result = Octet.verify(
verdict.proof!!,
VerifyOptions(maxAgeSeconds = 120, requireVerdict = RequiredVerdict.VERIFIED),
)
if (result.isAuthentic) {
accept(verdict.proof!!)
} else {
result.checks.filter { it.status == CheckStatus.FAIL }
.forEach { log("verify failed: ${it.name} — ${it.detail}") }
}
See also
- On-device Verification. What the verdict means and its limits.
- Verifying Proofs. The
octet-verifytool and the full trust model. OctetVerdict. The proof you pass in rides onverdict.proof.