import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

# Content Commitments

:::note[In short]

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](https://c2pa.org) 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](#uploading-content-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 |

<Tabs groupId="platform">
  <TabItem value="swift" label="Swift (iOS)">

```swift
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
```

  </TabItem>
  <TabItem value="kotlin" label="Kotlin (Android)">

```kotlin
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
```

  </TabItem>
</Tabs>

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/subtype` with no parameters, for example `image/jpeg`. `text/plain; charset=utf-8` is 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.

1. Mark the item: `upload: true` on a `bytes` or `file` item, or `uploadFrom` (the file you hashed) on a `digest` item. Optionally set a `locator`, an identifier for where you will keep the content.
2. Register an upload provider with `Octet.setContentUploadProvider`, as early as possible at app launch (`Application.onCreate` on Android). Before each attempt, the SDK asks the provider for a fresh target, typically a presigned URL your backend has just minted.
3. Follow the state with `Octet.setContentUploadListener`, or ask for it with `Octet.contentUploads(proofId:)`.

<Tabs groupId="platform">
  <TabItem value="swift" label="Swift (iOS)">

```swift
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")
```

  </TabItem>
  <TabItem value="kotlin" label="Kotlin (Android)">

```kotlin
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")
```

  </TabItem>
</Tabs>

What the SDK does:

- **Targets.** A target is a single request (`PUT` unless 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, `locator` and 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 `4xx` response other than `408` or `429` stops the upload as `rejected`. 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:)` to `Octet.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`, and `uploaded`, or `failed` with the reason: `contentChanged`, `contentMissing`, `providerFailed`, `rejected` or `exhausted`. Finished uploads are kept in `contentUploads` for 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`](/docs/mobile/api-reference/octet-verify/#caller-metadata) 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:

```bash
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-metadata` fails 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](/docs/mobile/api-reference/predicates/#content): the `content` parameter.
- [`Octet.verify`](/docs/mobile/api-reference/octet-verify/#caller-metadata): checking a commitment on the device.
- [Proof Binding](/docs/mobile/concepts/proof-binding/): the other controls over which proof answers a call.
