Content Commitments
New in 3.1. Pass content to isWithin, isOutside, or contains, and the device signs a commitment to that content into the proof. A verifier who later receives the content can confirm that this attested device asserted exactly this content at the proof's time and place. The content itself never enters the proof, and it stays on the device unless you have the SDK upload it to your own storage.
What a commitment proves
A content commitment binds content you choose, such as a note, a photo, a video, or a document, to one proof. It proves that the attested device that produced the proof asserted this exact content at the proof's time and place.
It does not prove that the content is authentic. A photo can be edited, staged, or come from another camera before your app passes it in, and the commitment covers it all the same. To establish that media came from a camera unaltered, use a content-provenance standard such as C2PA alongside the commitment.
How it works
For each item, the SDK computes a digest, SHA256(salt ‖ value), and notes the value's size in bytes. The proof lists each item's key, content type, size, digest and optional locator, in the order you passed them, as contentCommitments (content_commitments in the proof JSON). It signs a hash of the keys, content types, sizes and digests. The locator is listed but not signed. The values and salts never enter the proof: they stay on the device unless you upload them to your storage.
To check the commitment, a verifier needs three things from whoever presents the proof: the proof, a manifest that lists each item's key, content type, size, salt, and digest, and the content for the items it wants to see. The verifier recomputes the signed hash from the manifest, then recomputes each digest from the content. A single changed byte, a different salt, or a reordered list fails the check.
The manifest is the JSON {"v":1,"entries":[{"key","contentType","size","salt","sha256"}]}. It has one entry per item, in the order of the proof's contentCommitments, with salt and sha256 in lower-case hex. Build it from contentCommitments and the salts your app kept.
A query with content always produces a new proof. The SDK never answers it from a buffered proof, and never reuses a proof that carries content for a later query.
Adding content
Each item has a key and a content type, and holds its value in one of three forms:
| Form | Use it for | Limit |
|---|---|---|
bytes |
Values in memory: notes, IDs, small images | Hashed in the call |
file |
Files on disk | Streamed in the call, up to 64 MiB |
digest |
Files of any size, hashed before the call with Octet.hashContent |
None |
let note = ContentItem.bytes(key: "note", contentType: "text/plain", Data("valve 7 OK".utf8))
let video = try await Octet.hashContent(file: videoURL) // any size, off the proof path
let v = await sdk.loc.isWithin(region: .country(isoCode: "GB"), content: [
note,
.digest(key: "video", contentType: "video/mp4", video),
])
save(note.salt, video.salt) // a verifier needs both
val note = ContentItem.bytes("note", "text/plain", "valve 7 OK".toByteArray())
val video = Octet.hashContent(videoFile) // any size, off the proof path
val v = sdk.loc.isWithin(OctetRegion.country("GB"), content = listOf(
note,
ContentItem.digest("video", "video/mp4", video),
))
save(note.salt, video.salt) // a verifier needs both
The SDK salts every item by default with 32 random bytes. Read the salt back from ContentItem.salt or ContentDigest.salt, and keep it with its content. The verifier needs it to recompute the digest. The SDK keeps a copy only while an upload of that content is queued, encrypted, and deletes it when the upload finishes.
The rules for items:
- 1 to 16 items per call.
- A key is 1 to 64 bytes of UTF-8 and is unique within the call. Keys are compared byte for byte, so two spellings that look alike are different keys.
- A content type is a media type
type/subtypewith no parameters, for exampleimage/jpeg.text/plain; charset=utf-8is refused. Case is kept as given. - A salt is empty, 16 bytes, or 32 bytes. Pass an empty salt to commit unsalted.
Content that breaks a rule returns INDETERMINATE / INVALID_CALLER_CONTENT with no proof. The message names the rule. The SDK never truncates content to make it fit.
Why content is salted
A digest of a short or guessable value can be reversed by guessing. Anyone holding the digest of a phone number can hash every phone number until one matches. A random salt prevents this, because the guesser would also need the salt. That's why the SDK salts every item by default. Commit unsalted only for content no one could guess, such as a large file with unpredictable content.
Exact bytes
The SDK hashes exactly the bytes you pass. It does not strip metadata, re-encode, or normalise. If your app removes EXIF data from photos, or converts text to a canonical form, do it before you create the item, and keep that version: the verifier must receive the same bytes.
Permission
Caller content must be enabled for your license. Otherwise a query with content returns INDETERMINATE / CALLER_CONTENT_NOT_ENABLED with no proof.
A content commitment lives inside a proof, so it needs a location proof. Without location authorization, a query with content returns INDETERMINATE / PERMISSION_REQUIRED instead of an estimate.
Uploading content to your storage
By default the content stays on the device, and your app moves it however it likes. If caller content uploads are enabled for your license, the SDK can upload an item to your own storage after the proof is made. Content never goes to Octet.
- Mark the item:
upload: trueon abytesorfileitem, oruploadFrom(the file you hashed) on adigestitem. Optionally set alocator, an identifier for where you will keep the content. - Register an upload provider with
Octet.setContentUploadProvider, as early as possible at app launch (Application.onCreateon Android). Before each attempt, the SDK asks the provider for a fresh target, typically a presigned URL your backend has just minted. - Follow the state with
Octet.setContentUploadListener, or ask for it withOctet.contentUploads(proofId:).
struct MyUploads: ContentUploadProvider {
func uploadTarget(for request: ContentUploadRequest) async throws -> ContentUploadTarget {
// Your backend mints a presigned PUT for request.locator, and stores request.salt with it
let url = try await backend.presignUpload(request)
return ContentUploadTarget(url: url)
}
}
Octet.setContentUploadProvider(MyUploads())
let photo = ContentItem.file(key: "photo", contentType: "image/jpeg", photoURL,
upload: true, locator: "inspections/7/photo.jpg")
Octet.setContentUploadProvider { request ->
// Your backend mints a presigned PUT for request.locator, and stores request.salt with it
ContentUploadTarget(url = backend.presignUpload(request))
}
val photo = ContentItem.file("photo", "image/jpeg", photoFile,
upload = true, locator = "inspections/7/photo.jpg")
What the SDK does:
- Targets. A target is a single request (
PUTunless you set another method), a Google Cloud Storage resumable session, an Azure block blob, or an S3 multipart upload. The resumable kinds send the content in chunks and continue after an interruption. For S3 multipart, the provider also returns a URL for each part and completes the upload from your backend. - What the provider receives. The proof's id, the item's key, content type, size, digest,
locatorand salt. Store the salt with the file, for example as object metadata: a verifier needs it. - Changed content isn't uploaded. Before each attempt the SDK hashes the content again. If it no longer matches the proof, the upload stops with
contentChanged. - Retries. A failed attempt, or a provider that throws, is retried, up to 8 attempts within 24 hours. A
4xxresponse other than408or429stops the upload asrejected. Without a provider, queued uploads wait for up to 7 days. - Background. On iOS and Android uploads continue in the background. On iOS, forward
application(_:handleEventsForBackgroundURLSession:completionHandler:)toOctet.handleBackgroundUploadEvents(identifier:completionHandler:), so uploads that finish while your app is suspended are recorded. A retry waits until your app next runs. In React Native, uploads run in the foreground only, and queued uploads wait until your app runs again. - Status. Each item moves through
pending,uploading, anduploaded, orfailedwith the reason:contentChanged,contentMissing,providerFailed,rejectedorexhausted. Finished uploads are kept incontentUploadsfor 7 days. - No delivery guarantee. Check an item's status before you rely on the file being in your storage. The proof stays valid whether or not the upload arrives.
- No gateway. Uploads go straight to the URL your provider returns. A gateway and its certificate pins don't apply, so allow your storage host if you restrict egress.
- Locator. The proof lists it but doesn't sign it. Keep it to 256 bytes, and never put a credential, or a URL with a query string, in it.
An item marked for upload keeps its bytes, or its salt, on the device only until the upload finishes, stored encrypted.
Verifying a commitment
On the device, pass the manifest and the content to Octet.verify in VerifyOptions.callerMetadata. The caller-metadata check reports the result. When you check content, accept the proof only when verdict is valid.
Off the device, octet-verify 1.8.0 and later run the same check. --caller-manifest and --content need the caller-metadata build feature, which is off by default:
cargo install octet-verify --features caller-metadata
--caller-manifest <file>presents the manifest.--content <key>=<file>presents one item's content. It needs--caller-manifest.--require-caller-metadatafails a proof that commits no content. It works in the default build.
Content that can't be resolved makes the check INCONCLUSIVE, not FAIL, and octet-verify exits with code 3. Without the build feature, octet-verify refuses --caller-manifest.
Where to go next
- Predicates: the
contentparameter. Octet.verify: checking a commitment on the device.- Proof Binding: the other controls over which proof answers a call.