Configure & Attest
How per-deployment configuration and per-app attestation extensions work on the Privasys Developer Platform.
Privasys apps configure themselves on first boot and bind opaque values into their RA-TLS leaf certificate at runtime. Two declarations drive this:
| Declaration | Where | Purpose |
|---|---|---|
config_api | org.privasys.config_api OCI label (containers), or privasys.json (containers fallback), or the @config-api WIT decoration (wasm) | One-shot in-process endpoint the deployer POSTs to before the app is allowed to serve other traffic. For wasm, naming a function with @config-api automatically marks it as the freeze-gate AND restricts it to the app's owner team — no separate @auth line is required. |
| Per-app OID extensions | Set by the app at runtime via the SDK (setAttestationExtension) | Per-app X.509 extensions under 1.3.6.1.4.1.65230.3.5.*, baked into the RA-TLS leaf and replayed across restarts. |
Per-app owners team
The deployer of an app is automatically added to its owners team (a
list of platform OIDC sub claims persisted on the management service
under app_owners). Owners can:
- Deploy / re-deploy the app.
- Call
@config-api(ororg.privasys.config_api) endpoints — i.e. the freeze-gate. Even a platformmanagercannot configure an app they don't own; the configure entrypoint is private to the team. - Manage the team itself via:
GET /api/v1/apps/{id}/ownersPOST /api/v1/apps/{id}/owners(body:{sub, email, name})DELETE /api/v1/apps/{id}/owners/{sub}
The original creator (apps.owner_sub) cannot be removed from the team
— this guarantees there is always at least one principal able to
recover the app. The owners list is shipped to the enclave on
wasm_load/container_load and persisted with the app metadata, so
owner-only calls keep working across enclave restarts without
consulting the platform.
Lifecycle
- Deploy. A user (or a token bearing
privasys-platform:manager) deploys an app version to an enclave. The deploy call carries onlyenclave_idand the version pin — no per-app secrets. - Wait for app. The container or wasm module starts and binds its listener.
- Freeze. The manager intercepts the first request to the app's
hostname and replies
503 Retry-Afterto anything that does not matchconfig_api.{method,path}. The configure path is forwarded in-process. - Configure. The deployer POSTs the configuration payload to
${config_api.path}. The app validates it, seals it to its own storage (encrypted volume / sealed KV), and callssetConfigComplete(). The manager flips the freeze flag. - Serve. All subsequent traffic is forwarded normally.
- Restart. On any restart, the app reloads its sealed config from storage; the manager keeps the freeze flag set so the configure step never runs twice for the same payload.
Per-app attestation extensions (OID 1.3.6.1.4.1.65230.3.5.*)
Apps that want to bind opaque values into their RA-TLS leaf certificate — for example: an MCP server URL list, a vector-DB project ID, a hash of a configured API key — call the SDK at runtime:
setAttestationExtension(arc_suffix: u32, value: list<u8>)The platform:
- Asserts the OID is under
1.3.6.1.4.1.65230.3.5.<arc_suffix>and under the calling app's namespace. - Stores the
(oid, value)pair durably so it survives restarts. - Re-issues the per-app RA-TLS certificate so subsequent connections see the new extension immediately.
- On restart, replays the stored extensions into the leaf cert before unblocking traffic.
This means the OID set is part of the verified app surface and is owned by the app itself, not by the operator at deploy time.
SDK surface
Both wasm and container apps expose the same two SDK calls:
| Call | Effect |
|---|---|
setAttestationExtension(arc_suffix, value) | Install or replace the per-app OID extension. Updates the RA-TLS cert. |
setConfigComplete() | Mark this app as configured. Lifts the manager's freeze gate. Idempotent. |
The intended pattern in the configure handler is:
- Validate the inbound payload.
- Persist anything that needs to survive restart (sealed KV / encrypted volume).
setAttestationExtension(<your-arc>, sha256(payload))so verifying clients can confirm the running app saw exactly the configuration they delivered.setConfigComplete().- Return 2xx.
Verifying as a client
A client connecting to the app:
- Performs the standard RA-TLS handshake; the leaf cert carries the TDX/SGX quote.
- Pulls the per-app OIDs out of the leaf and recomputes the expected value (e.g. SHA-256 of the API key it provisioned).
- Equality means this enclave instance was configured with exactly that input — no env var indirection, no out-of-band attestation document needed.
Charging for API calls (@price)
A tool can declare a per-call fee next to its auth policy. The rule is a
JSON object in a @price doc annotation on the export (wasm WIT shown;
container apps declare the same object under x-privasys.price per tool
in their manifest):
/// Verify an identity document.
/// @auth authenticated
/// @price {"credits":5000,"payer":"caller"}
export verify-document: func(image: string) -> result<fields, string>;Fields of the price rule:
| Field | Values | Meaning |
|---|---|---|
credits | integer ≥ 0 | Fee charged to the payer per successful call. 1,000,000 credits = £1, so 5000 is 0.5p. 0 or absent = free. |
payer | "caller" (default) | "sponsor" | Who pays: the authenticated caller, or a third party named in the request. |
sponsor_from | request parameter name | Required for payer:"sponsor": the parameter carrying the sponsoring relying party's id, resolved platform-side to a funded account. |
free_for | ["wallet"] | Caller classes exempt from the fee. wallet = the caller authenticated through a Privasys wallet ceremony (their token carries the IdP's non-identifying wallet marker). Exempt callers pay 0; you still bear the compute. |
The rule is part of the app's measured configuration: it is folded into the same configuration hash as the auth policy, so the price a caller reads off the schema is attested alongside the code. Changing a price is a new version.
At call time the runtime enforces informed consent. A caller-priced
function requires the request header X-Billing-Approved: <credits> credits
matching the attested price exactly; without it (or with any other amount)
the call is refused with 402 Payment Required and the response header
X-Billing-Price stating the price to approve, before any of your code
runs. On success the response carries X-Billing-Charged, the fee is
recorded, and you earn 85% of it (the platform keeps 15%); a failed call
charges nothing. Because the enforcement and the price are both inside the
measurement, a successful priced call is attestable proof the caller
pre-approved exactly the price they were charged.
The developer portal, the App Explorer, and the CLI read the price off the schema, show it to the caller, and send the approval header after explicit consent, so callers through the standard surfaces never craft it by hand.