Documentation
Integrate IP Vault
Declare your assets in one file. Your CI seals them on its own runner and registers their keys; your build packages the sealed files; a check after the build proves nothing shipped in the clear; the SDK opens them in your app, offline, for licensed installations only.
Early access. Self-serve signup, which connects your CI, is not open yet; until it is, we connect design partners’ repositories ourselves (talk to us). The scan at the end of this page needs no account.
Overview
How it fits together
- Begin. The release step asks the vault service for a new key version per asset. The service allocates them (a reserved version is never handed out again) and returns your tenant’s ingestion public key.
- Seal, on your runner. Each asset gets a fresh key, is sealed to its output path, and its key is wrapped to the ingestion key. The raw key never touches disk; the plaintext never leaves the runner.
- Register. The wrapped keys and the sealed files’ hashes are committed in one transaction, and grants are added to the entitlements you named. Grants are only ever added, so licences for builds you already shipped keep working.
- Build and check. Your build packages the sealed files. The check fails the build if one is missing or a plaintext input is in the package.
- Open. The SDK activates each installation once, with an activation code or a voucher from your backend, and opens assets in memory after that with no network.
Every step is idempotent. Repeating one resumes the same release with the same sealed files; a re-run of the CI job is a new attempt with fresh keys. Nothing is ever sealed twice under one key.
Step 1
Declare your assets
ipvault.toml, at the root of your repository, declares every asset. Models, datasets, maps, content and rules are all assets; a model additionally needs the SDK adapter for its runtime.
version = 1
[app]
platform = "android"
id = "com.example.app"
[[asset]]
id = "model:classifier" # what your app opens
version = 3 # your version of the asset
source = "private/classifier.ort" # plaintext: never inside a packaged directory
output = "app/src/main/assets/classifier.ipva"
entitlements = ["premium"] # licences of these entitlements can open it
[[asset]]
id = "data:tiles"
version = 1
source = "private/tiles.bin"
output = "app/src/main/assets/tiles.ipva"
entitlements = ["premium"]
mode = "H" # import the key into the Keystore where the device can| Field | Meaning |
|---|---|
id | Stable asset id, what your app asks the SDK for. Required. |
version | Your version of the asset. Required. |
source | The plaintext file, relative to the manifest. It must not be inside a directory the manifest packages from: the release refuses it, so it cannot ship. Required. |
output | Where the sealed file is written, inside your app’s sources. Required. |
entitlements | The entitlements whose licences can open the asset; your CI credential must be allowed to add grants to each. Required. |
mode | "W" (default) or "H": with "H", a device that passes the secure-import self-test gets the key imported into its Keystore. |
key_id | Defaults to the asset id. The first app to publish a key id owns it. |
content_type, seg_size | Media type of the plaintext, and bytes per sealed segment (a power of two). The defaults suit both device modes. |
Keep the plaintext out of version control if you can: fetch it into the runner in an earlier step, from wherever you keep it. For a model, the runtime’s own in-place format (.ort for ONNX Runtime) leaves nothing of the model in the process after a session closes.
Step 2
Connect your CI
Publishing takes a credential for one app, scoped to the entitlements it was created for. It cannot read a key, replace a version that is already registered, remove a grant or touch another app. It expires, and revoking it takes effect on its next request.
- OIDC, on GitHub and GitLab (recommended). Bind your repository to the app. The job then proves who it is with the short-lived token GitHub or GitLab signs for it, so there is no secret to store, rotate or leak. A binding names the repository and its owner by numeric id, which a rename does not change, and the refs that may publish (exact, or a prefix such as
refs/tags/v*); it can also require an environment or a workflow. - A CI token, for any other runner. Store it as
IPVAULT_TOKEN: a repository or environment secret on GitHub, a masked and protected CI/CD variable on GitLab. Tokens start withipvk_, so secret scanners can recognise one that leaks. - Neither is an end-user credential, and neither goes into your app. Your app carries only public settings: the tenant, the service address and the licence verification key.
Step 3
GitHub Actions
name: release
on:
push:
tags: ["v*"] # publish from trusted refs only
jobs:
android:
runs-on: ubuntu-24.04
environment: release # optional: required reviewers; a binding can require it
permissions:
contents: read
id-token: write # the job's OIDC identity: no secret to store
steps:
- uses: actions/checkout@<commit-sha>
# Seal on this runner and register the keys. Writes the sealed outputs.
# Without OIDC, pass a CI token instead: with: token: ${{ secrets.IPVAULT_TOKEN }}
- id: ipvault
uses: rev3rse-dev/ipvault-release-action@<commit-sha>
- run: ./gradlew assembleRelease
# Fail the build if a sealed file is missing or a plaintext input shipped.
- uses: rev3rse-dev/ipvault-release-action@<commit-sha>
with:
verify: app/build/outputs/apk/release/app-release.apk
receipt: ${{ steps.ipvault.outputs.receipt }}- Pin the action by commit SHA. The commit pins the
ipvaultbinary too: the action downloads it and refuses to run it unless its SHA-256 matches the value in the same commit. - Publish from trusted refs. The action refuses, by name, to publish from a pull request opened from a fork, and from
pull_request_target, which runs with your secrets and identity next to code from the pull request. IP Vault itself refuses an OIDC token from apull_requestorpull_request_targetjob, whatever a binding says. The check and the scan need no credential and run anywhere. - Outputs:
release-id,receipt(pass it to the check),passed,exit-code,reportandjunit. The job summary shows what was sealed and registered.
Step 3
GitLab CI/CD
Coming soon: CI/CD Catalog components that do this for you. Until they are listed, run the CLI in a job. It reads the job’s ID token, and derives the attempt’s name from the job.
ipvault-release:
stage: .pre
image: alpine:3.20 # pin it by digest
rules:
- if: $CI_COMMIT_REF_PROTECTED == "true" # IP Vault publishes from protected refs only
id_tokens:
IPVAULT_ID_TOKEN: # the job's identity: no secret to store
aud: https://vault.rev3rse.dev
script:
- apk add --no-cache curl
- v=0.1.0; t=x86_64-unknown-linux-musl
- curl -fsSLO "https://github.com/rev3rse-dev/ipvault-cli/releases/download/v$v/ipvault-$v-$t.tar.gz"
- echo "6851005905e89080eda704445ec621f1a6feee1cc4d73d76701ad5b8ce9454a8 ipvault-$v-$t.tar.gz" | sha256sum -c -
- tar -xzf "ipvault-$v-$t.tar.gz" && mkdir -p ipvault-release
- ./ipvault-$v-$t/ipvault release publish --json-out ipvault-release/receipt.json
artifacts:
paths: ["**/*.ipva", "ipvault-release/"] # the build job packages the sealed files- IP Vault admits the ID token through your binding, from protected branches and tags only. A CI token in
IPVAULT_TOKEN, a masked and protected variable, works too and takes precedence. - Check the package after the build the same way:
ipvault release verify --package <apk> --receipt ipvault-release/receipt.json. - Self-managed GitLab: use a CI token. The hosted service accepts ID tokens from GitLab.com.
Anywhere else
Any other CI
The integrations run the same commands, which work on any runner with curl. Pass --operation-id outside GitHub and GitLab: it names the attempt, and repeating it resumes the attempt.
ipvault release plan # what ipvault.toml would release; no token, no network
export IPVAULT_TOKEN=... # a CI token from your secret store, never on the command line
ipvault release publish --operation-id "build-$BUILD_ID" --json-out receipt.json
./gradlew assembleRelease
ipvault release verify --package app/build/outputs/apk/release/app-release.apk --receipt receipt.jsonStep 4
Open the assets in your app
Android 9 and later. The SDK is distributed with your account during early access.
dependencies {
implementation("dev.rev3rse.ipvault:ipvault:<version>")
// the adapter for your runtime, if the asset is a model:
implementation("dev.rev3rse.ipvault:ipvault-onnxruntime:<version>") // or ipvault-litert
// optional: bind a Play Integrity verdict to each activation and renewal
implementation("dev.rev3rse.ipvault:ipvault-playintegrity:<version>")
}val vault = IPVault.open(context, VaultSettings(
tenant = "<your tenant>",
endpoint = "https://vault.rev3rse.dev",
licenceKeys = listOf(LicenceKey(kid, publicKey)), // public: GET /v1/licence-keys
))
// Every call blocks: run them off the main thread.
when (val s = vault.status()) {
is VaultStatus.NeedsActivation -> vault.activate(code) // or activateWithVoucher(...)
is VaultStatus.Expired -> vault.renew()
is VaultStatus.Unusable -> vault.activate(code)
is VaultStatus.Active ->
vault.session(File(filesDir, "classifier.ipva"), OnnxRuntime()).use { session ->
val out = session.run(mapOf("input" to FloatTensor(shape, values)))
// the app gets results; it never holds the model
}
}- Who gets a licence is your decision, made with activation codes you issue, or with vouchers your own backend signs for a signed-in customer. Activation happens once per installation; renewal follows the lease.
- Ending access. Revoke the licence when a subscription ends: renewal does not ask your subscription system by itself. The installation stops opening assets once its lease and offline grace run out.
- Models run through a session: requests in, results out, the model loaded in place from the SDK’s memory and wiped when the session closes. Adapters exist for ONNX Runtime and LiteRT. Other data is read through an explicit, opt-in view.
- Mode H (the manifest’s
mode = "H") imports the key into the Keystore on devices that pass a self-test, and falls back to mode W elsewhere, so every device still activates.
Step 5
What the check after the build does
- Every sealed output in the manifest is in the package, byte for byte, as the receipt recorded it. A stale or missing sealed file fails.
- No plaintext input is in the package, as a whole file or as a fragment. Each input is sampled throughout, so a shipped piece of a few kilobytes is found; nested archives are opened.
- Names inside the package are untrusted, and are reported without being interpreted.
It checks the assets you declared. It does not prove that nothing else valuable is in the package — that is what the scan and an assessment are for.
No account
The free scan
Before you integrate anything: does a release you already ship carry a model in plaintext? The scan fails the build if it does, and needs no account and no token.
- uses: rev3rse-dev/ipvault-scan-action@<commit-sha>
with:
path: app/build/outputs/apk/release/app-release.apkipvault-scan:
stage: test
image: alpine:3.20
needs: [assemble-release]
script:
- apk add --no-cache curl
- v=0.1.0; t=x86_64-unknown-linux-musl
- curl -fsSLO "https://github.com/rev3rse-dev/ipvault-cli/releases/download/v$v/ipvault-$v-$t.tar.gz"
- echo "6851005905e89080eda704445ec621f1a6feee1cc4d73d76701ad5b8ce9454a8 ipvault-$v-$t.tar.gz" | sha256sum -c -
- tar -xzf "ipvault-$v-$t.tar.gz"
- ./ipvault-$v-$t/ipvault scan app/build/outputs/apk/release/app-release.apkA clean scan means no known plaintext model format was found within the scan’s limits. It does not mean the package is safe.
Troubleshooting
When something is refused
Every refusal names its reason. The common ones, as error / reason:
| Refusal | Means | Do |
|---|---|---|
unauthorized / revoked, expired | The CI token was revoked or has expired. | Create a new token and update the secret. |
policy / oidc_binding | No binding admits this job. The message names what did not match: the ref, environment, workflow or repository. | Publish from a ref the binding names, or change the binding. |
policy / untrusted_event | A pull_request or pull_request_target job presented its OIDC token. | Publish on push to a protected branch or tag, or from a release. |
policy / unprotected_ref | A GitLab job on a ref that is not protected. | Protect the branch or tag, or publish from one that is. |
policy / app_scope | The manifest names a different app from the one the credential publishes for. | Use that app’s binding or token, or fix app.id. |
policy / entitlement_scope | An asset names an entitlement the credential may not add grants to. | Widen the binding or token to that entitlement, or fix the manifest. |
policy / key_owner | Another app already publishes this key id. | Set key_id on the asset. |
policy / open_limit | Too many releases for this app were begun and never finished. | Abandon the stale ones: ipvault release abandon --release-id <id>. |
conflict / operation_id | This attempt was begun from a different manifest or input. | Restore the inputs, or publish as a new attempt (a re-run of the job is one). |
(local) cannot be rebuilt | A committed release’s sealed files were lost from the runner. | Publish as a new attempt. Sealed files are never rebuilt under a key that is already registered. |
The full interface is in the API reference.