@burnt-labs/expo-satya-attest
v3.2.0
Published
SATYA-W mobile TLS app-attestation (App Attest + Secure Enclave / Play Integrity) backed by the Rust satya-sdk core
Readme
@burnt-labs/expo-satya-attest
Expo / React Native native module for SATYA mobile app attestation. The module uses
the Rust satya-sdk / satya-ffi pipeline for artifact construction and verifier
policy, while Swift and Kotlin provide platform-specific WebView capture, native HTTPS
replay, hardware-backed signing, and platform attestation evidence.
Prerequisite: register your app for an app id
SDK 3.x requires an appId. Register your app at
https://provider-registry.burnt.com (Apps module) with your iOS identity
(Apple Team ID + bundle id) and/or Android identity (package name + Play
app-signing SHA-256). After a Burnt admin approves the registration and grants
your app its providers, configure the id in any one of:
transportConfig.appIdonSatyaProviderAttestButton,initSatyaAttest({ appId })at startup, orEXPO_PUBLIC_SATYA_APP_IDin the build environment.
The app id is public by design — verification is gated by OS attestation
matching your registered identities plus a per-app provider allowlist, so
possession of the id alone grants nothing, and store binaries ship no verifier
secret. Without a configured app id a verifier-backed flow fails before the
provider WebView opens with APP_ID_REQUIRED; a malformed id fails validation
with CONFIG_INVALID. An unregistered or unapproved id fails at the verifier
with APP_NOT_REGISTERED / APP_DISABLED, and an ungranted provider fails with
PROVIDER_ACCESS_DENIED. See
docs/MULTI_TENANT_VERIFICATION.md.
Flows also fail with app_platform_not_configured when the app has no
registered identity for the current platform, so register every platform you
develop and test on.
Current Flow
The supported Expo flow is certificate-pinned native replay. Third-party apps can use the SDK-owned button component for the full managed flow:
- The app renders
SatyaProviderAttestButtonwith a provider id and transport config. - The SDK refreshes SPKI pin policy from the verifier, falling back to registry pins.
- The SDK opens an in-app
react-native-webviewmodal for login and request discovery. - The SDK waits for a provider request that matches template
requestMatchRules. Forhtml/textendpoints it surfaces the matched page load to the interceptor by re-issuing it as a credentialedfetch(). - Swift/Kotlin replay the trusted template URL natively with shared WebView cookies.
- The native client uses system trust without user roots and requires SPKI pins.
- Native extraction applies the endpoint's reveal rules to the pinned response — JSON
paths for
jsonbodies, and regex (regex/jsonRegex) over the raw body — producing the disclosed claims. The Rust core emits anative-pinned-observedSATYA artifact. - The SDK stamps Expo package metadata onto the artifact envelope, verifies locally,
optionally posts the artifact to
satya-verifier-service, and returns the result throughonComplete.
The earlier mock and plain webview-observed modes are no longer exposed through the
high-level Expo client because they do not prove a provider response fetched through a
pinned native TLS connection.
TeeLS Hosted Passthrough (Opt-In)
The SDK also has an isolated, opt-in TeeLS hosted flow. It uses a normal registry provider and the normal app/provider grant, but it does not run request capture, native replay, SATYA artifact construction, local SATYA verification, or verifier artifact submission. SATYA performs one provider-policy request before opening the WebView solely to confirm that the configured app is active and has access to the provider.
Enable the flow for exactly one provider/app pair at initialization:
import {
initSatyaAttest,
SatyaProviderAttestButton,
type SatyaTeelsAttestation,
type SatyaTeelsClaimData,
} from '@burnt-labs/expo-satya-attest';
initSatyaAttest({
providerId: 'satya.teels.v1',
appId: 'app_your-registered-app-id',
teelsFlow: true,
});
function TeelsAttestButton() {
const verifyTeelsResult = async (
attestation: SatyaTeelsAttestation,
claimData?: SatyaTeelsClaimData
) => {
// Required: verify the TeeLS quote, freshness, and expected data binding
// in your application or backend before trusting the result.
// `claimData` is the first argument to TeeLS success, usually the
// JSON-safe payload forwarded by the hosted provider.
};
return (
<SatyaProviderAttestButton
providerId="satya.teels.v1"
transportConfig={{
verifierUrl: 'https://satya-verifier-v2-staging.burnt.com',
appId: 'app_your-registered-app-id',
}}
onTeelsComplete={verifyTeelsResult}
onError={(error) => console.error(error.code, error.message)}
/>
);
}The provider's existing login.url opens in the SDK WebView and its normal
Registry injection adapts the hosted page to the SDK-owned bridge. A page that
already uses the standard TeeLS callbacks and EarnOS success event/snapshot
does not need frontend changes. See
Provider Config
for the adapter and device-validation contract.
Only a non-null, JSON-safe attestation object can complete the flow. If provided,
claimData is additionally forwarded as a second argument to onTeelsComplete.
Success is returned only through onTeelsComplete; invalid/null data, hosted
failures, timeout, WebView failure, and access denial use the normal onError
path. Closing the sheet before a terminal result uses onCancel.
Native-replay-only props are not used by TeeLS. A component may keep an
onComplete handler for normal mode, but TeeLS never calls it. Initialize the
SDK before mounting the button. A later init with teelsFlow: false or with
the option omitted disables TeeLS for subsequently mounted buttons; normal
providers continue to use the existing native replay flow unchanged.
The returned object is an opaque, unverified TeeLS result and is delivered as
the first argument. claimData is the hosted-provider payload whose inner
data object is hashed into the quote's report_data; treat both as
untrusted input until verified. Do not treat callback delivery as a SATYA
verification claim, and do not log or persist the result by default.
Verifying the TeeLS result
The SATYA SDK performs no verification of the TeeLS result — it neither verifies the quote nor checks that the claim data matches it. Before trusting the result, the consuming application or its backend must verify it:
DCAP quote.
attestation.quoteis a hex-encoded Intel TDX quote. Post it to the independent TeeLS verifier service and requiresuccess: true,quote.verified: true, and atcb_statusyour policy accepts:curl -X POST https://attest.burnt.com/verify \ -H 'content-type: application/json' \ -d '{"hex":"<quote hex>"}'Measurements. Compare the verified quote's
mrtdandrtmr0–rtmr3against the expected-measurements registry published independently by the verifier (GET https://attest.burnt.com/measurements); require a match against one of itsvalid[]sets.Data binding. Use
reportdatafrom the verified quote response (never the advisoryattestation.reportDataHexfield — it is server-supplied). Its first 32 bytes must equalSHA-256(JSON.stringify(data))over the exact result-data object the page delivered (in this flow,claimData.data; remaining bytes are zero-padded). When the provider binds a policy hash, the envelope is{ data, policyHash }instead of baredata. Any mismatch means the claim data was substituted after attestation.
The result carries no nonce, so enforce your own freshness and replay policy wherever the attestation crosses a trust boundary.
Native Trust Tiers
| Platform | Witness key | Platform evidence | Typical tier |
| ---------------- | ------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------- |
| iOS device | Secure Enclave P-256 | App Attest assertion/attestation | ios-app-attest-sep-bound |
| iOS simulator | software P-256 | honest simulator fallback | ios-simulator-dev |
| Android device | Keystore EC, StrongBox when available | Play Integrity hook | android-strongbox-play-integrity / android-keystore-play-integrity-no-strongbox |
| Android emulator | software EC | honest emulator fallback | android-emulator-dev |
Artifacts are signed by the app/witness path, not by a separate signing service. Production-grade verification relies on canonical statement hashes, device/witness signatures, active provider policy, fresh revocation epochs, backend-trusted SPKI pins, and pass-2 platform verification where appropriate.
Artifact Identity And Versioning
Every artifact includes a stable attestationId. It is derived from the signed
statement hash, so a replay of the same artifact keeps the same id while a fresh
attestation gets a new one.
New SDK artifacts also include envelope metadata:
{
"schemaVersion": "1.1.0",
"attestationId": "satya_w_...",
"attestationIdVersion": "statement-sha256-96-v1",
"producer": {
"sdk": "@burnt-labs/expo-satya-attest",
"sdkVersion": "3.1.0",
"artifactSchemaVersion": "1.1.0",
"coreVersion": "0.1.0"
}
}The verifier uses this metadata for compatibility routing and diagnostics. It is not allowed to downgrade cryptographic checks; policy decisions still come from the signed statement, provider registry policy, and platform evidence.
Build The Rust Core
The generated native artifacts are gitignored:
ios/Frameworks/ios/Generated/android/src/main/jniLibs/- generated uniffi Swift/Kotlin glue
Build them from the workspace root before opening Xcode or Gradle:
# iOS XCFramework + Swift glue
platform_sdks/expo-satya-attest/scripts/build-rust-ios.sh
# Android .so files + Kotlin glue
# One-time prereqs: Android NDK, JDK 17, cargo-ndk, and Android Rust targets.
platform_sdks/expo-satya-attest/scripts/build-rust-android.shFrom the module directory, the same commands are available as:
npm run build:native:ios
npm run build:native:androidbuild:native:ios strips local/debug symbols from the XCFramework. Local builds include
the physical-device arm64 iOS slice by default; publishing builds set
SATYA_IOS_INCLUDE_SIMULATOR=1 so simulator fallback slices ship too. See
PUBLISHING.md for release and binary-size knobs.
Build TypeScript after editing src/*.ts or src/*.tsx:
cd platform_sdks/expo-satya-attest
npm install
npm run build
npm run lintThe example app consumes the compiled build/ directory, so restart Metro with
--clear after rebuilding the module.
Provider Registry
Provider configs are loaded from the Provider Registry API at runtime. The SDK default is
https://api.provider-registry.burnt.com, and apps can override it once at startup:
import { initSatyaAttest } from '@burnt-labs/expo-satya-attest';
initSatyaAttest({
providerId: 'kaggle.current_user.v1',
providerRegistryApi: process.env.EXPO_PUBLIC_PROVIDER_REGISTRY_API,
logLevel: 'warn',
});The SDK fetches /api/providers/{providerId} and fails closed if the provider is
unavailable, inactive, or unsafe to execute on-device. The registry service owns
provider-schema governance; the SDK only normalizes fields it needs at runtime and
enforces mobile safety checks such as HTTPS URLs, SPKI pins, request matching, and
reveal parsing. The raw provider schema is documented in
PROVIDER_CONFIG.md; runtime loading rules, errors, and logging
are documented in
PROVIDER_REGISTRY.md.
Useful exports:
import {
initSatyaAttest,
loadProviderTemplate,
SatyaProviderAttestButton,
validateProviderTemplate,
withProviderSpkiPins,
SatyaMobileClient,
} from '@burnt-labs/expo-satya-attest';Load the configured provider for custom diagnostics or a fully custom UI:
const providerTemplate = await loadProviderTemplate();Native replay requires at least one SPKI SHA-256 base64 pin at runtime. By default,
SatyaProviderAttestButton fetches
GET /v1/providers/{providerId}/policy from transportConfig.verifierUrl before opening
the WebView, validates that the returned provider id, host, and policy hash match the
selected template, and freezes those pins for the attestation run.
For a local verifyBackend={false} flow, an unavailable verifier can fall back to the
SDK registry pins. Set providerPolicyRefresh="verifier-required" to disable that
fallback, or providerPolicyRefresh="template-only" for offline demos. When
verifyBackend is enabled, every mode—including template-only and caller-supplied
spkiPins—still completes the app/provider access preflight before opening the WebView;
an unavailable verifier blocks that attempt as retryable. Only a fully local
verifyBackend={false} flow skips the verifier gate.
Installation Options
Every install path also needs the WebView and safe-area peer dependencies:
npx expo install react-native-webview react-native-safe-area-contextUse npm for released apps:
npx expo install @burnt-labs/expo-satya-attestUse a local checkout while developing the SDK or testing unpublished changes:
cd /path/to/tls-app-attest/platform_sdks/expo-satya-attest
npm install
npm run build
npm run check:native-artifacts
cd /path/to/your-expo-app
npm install /path/to/tls-app-attest/platform_sdks/expo-satya-attest
npx expo install react-native-webview react-native-safe-area-contextRe-run npm run build in the SDK directory after TypeScript edits. Re-run the native
build scripts after Rust/UniFFI edits so the local package still contains
ios/Generated, ios/Frameworks, Android jniLibs, and generated Kotlin.
Use GitHub when you want a pinned unreleased version. The most reliable GitHub install
is a release tarball produced by npm pack, because it contains the same files npm
would publish:
npm install https://github.com/<owner>/<repo>/releases/download/expo-satya-attest-v3.1.0/burnt-labs-expo-satya-attest-3.1.0.tgz
npx expo install react-native-webview react-native-safe-area-contextInstalling from a Git ref also works when that ref has the SDK package at the repository root, such as a dedicated SDK repo or a subtree/split branch:
npm install github:<owner>/<sdk-repo>#v3.1.0
npm install git+https://github.com/<owner>/<sdk-repo>.git#v3.1.0
npx expo install react-native-webview react-native-safe-area-contextDo not point npm at the monorepo root unless that Git ref is prepared so
package.json, build/, native artifacts, and Expo module files are at the root of the
Git package. npm does not install this monorepo's platform_sdks/expo-satya-attest
subdirectory directly from a normal GitHub URL.
After changing the install source, rebuild the native app:
npx expo prebuild --clean
npx expo run:ios --device
npx expo run:androidPlug-And-Play API
Add the Expo config plugin as shown below, rebuild the native app, then render the SDK-owned button:
import { initSatyaAttest, SatyaProviderAttestButton } from '@burnt-labs/expo-satya-attest';
initSatyaAttest({
providerId: 'kaggle.current_user.v1',
});
export function KaggleAttestation() {
return (
<SatyaProviderAttestButton
providerPolicyRefresh="verifier-first"
transportConfig={{
verifierUrl: 'https://satya-verifier.example.com',
cloudProjectNumber: 0,
}}
verifyBackend
buttonLabel="Open Kaggle"
onComplete={(result) => {
saveSatyaArtifact(result.raw);
handleSatyaVerification(result.verify, result.backendVerify);
}}
onError={(error) => {
reportSatyaError(error);
}}
/>
);
}When pressed, the component opens the provider WebView inside the same app, captures the
matching request, performs native replay with cookie sharing, verifies locally, posts to
the backend verifier when verifyBackend is set, and returns a
SatyaProviderAttestationResult.
Apps can customize the button label, modal title, WebView props, and callbacks. Apps
that use a custom provider allow-list can pass providerTemplate instead of loading the
configured provider id. Explicit spkiPins are still supported for controlled tests and
override only the TLS pin source; verifier-backed flows still preflight app/provider
access before opening the WebView. The managed WebView uses provider userAgent
settings when present, otherwise it falls back to a platform-matched mobile browser user
agent (Safari on iOS, Chrome on Android); pass webViewUserAgent to override it
explicitly.
Presentation
By default the provider WebView opens as an in-app sheet that keeps the host app visible
behind it, so the flow reads as part of the app rather than a separate browser window. On
iOS this is the native pageSheet (rounded card, interactive swipe-to-dismiss); on
Android it is an animated bottom sheet with a scrim and drag grabber. The sheet is
presented immediately on tap and the provider policy resolves behind a skeleton, so there
is no blank pause before the sheet appears. The header shows a browser-style secure-host
pill (lock icon + domain), a reload button, and a page-load line that advances while a page
loads and clears when it finishes; both the main page and the managed OAuth popup fade in
over their first load instead of flashing white.
Once the provider's injection confirms the session is authenticated (the __satyaReady
login signal), the sheet conceals the provider page behind a "Verifying your account…"
cover while capture and native replay continue underneath, so users see only the login
window and never the SDK driving the authenticated site. The cover is presentation-only
(capture gating and verification are unchanged); if capture does not start within a
short watchdog window it offers a "Return to provider" path that keeps the flow alive.
Providers whose injection does not emit __satyaReady keep the previous behavior and
are covered from request capture onward. See PROVIDER_CONFIG.md for the signal
contract.
Pass presentation="fullScreen" to keep the legacy full-window takeover. Set prefetch
to resolve the provider policy and warm the WebView engine at mount so the sheet opens
straight into the loading login page (it trades an eager registry/policy round-trip on
mount for a faster first open). The sheet's look is tunable through theme:
<SatyaProviderAttestButton
presentation="sheet" // default; use "fullScreen" for the legacy full-window modal
prefetch // resolve policy + warm the WebView at mount for an instant first open
theme={{
accentColor: '#2752d9',
sheetCornerRadius: 20, // top corner radius of the sheet (default 16)
backdropColor: 'rgba(9, 14, 26, 0.45)', // Android scrim behind the sheet
showGrabber: true, // drag grabber at the top of the sheet
}}
transportConfig={{ verifierUrl: 'https://satya-verifier.example.com', cloudProjectNumber: 0 }}
/>Injection, request capture, native replay, and OAuth popup handling are identical in both
presentation modes; only the container around the WebView changes. Keyboard handling is
also identical: on iOS the modal content automatically insets by the exact keyboard
overlap when the keyboard appears (WKWebView does not resize its layout viewport on its
own), so provider pages that pin their primary button to the bottom of the screen stay
reachable while typing; Android relies on the standard adjustResize behavior.
Error Diagnostics
SDK failures are reported as SatyaError. The error has a stable code, an inferred
stage, a per-occurrence errorId, and a redacted diagnostics object that can be
forwarded to app telemetry. Native Expo promise rejections are preserved under
diagnostics.native when the platform exposes a native code or domain.
import { SatyaError } from '@burnt-labs/expo-satya-attest';
function reportSatyaError(error: SatyaError) {
telemetry.capture('satya_attestation_failed', error.diagnostics);
}Diagnostics reach your app through two channels:
onError(error)receives theSatyaErroritself — readerror.code,error.stage,error.errorId, or forwarderror.diagnostics.onProgress(progress)carries the same payload on the terminalfailedphase asprogress.error(typedSatyaErrorDiagnostics), so progress-driven UIs can capture diagnostics without a separateonErrorhandler.
import type { SatyaErrorDiagnostics } from '@burnt-labs/expo-satya-attest';
<SatyaProviderAttestButton
// ...
onProgress={(progress) => {
if (progress.phase === 'failed' && progress.error) {
telemetry.capture('satya_attestation_failed', progress.error);
}
}}
onError={(error) => reportSatyaError(error)}
/>;JSON.stringify(error) emits the same diagnostics payload. The SDK redacts sensitive
context keys such as cookies, authorization headers, tokens, credentials, and request or
response bodies before they appear in diagnostics.
SatyaMobileClient.verify() also wraps native verifier rejections as SatyaError.
Consumers that previously branched on a native rejection code should inspect
error.diagnostics.native?.code or error.diagnostics.cause?.code.
Diagnostics payload
error.diagnostics (type SatyaErrorDiagnostics) is the stable, redacted shape
to log or forward to telemetry:
| Field | Type | Notes |
| --- | --- | --- |
| errorId | string | Per-occurrence id (satya_<time>_<rand>); correlate logs to a single failure. |
| code | SatyaErrorCode | Stable machine-readable code (see below). |
| stage | SatyaErrorStage | Coarse pipeline bucket (see below). |
| message | string | Human-readable summary (not redacted — don't put secrets in messages). |
| context? | object | Operation context, recursively redacted (sensitive keys → [redacted Nb]). |
| native? | { code?, domain?, message?, name? } | Present when the cause is a native Expo rejection exposing a code/domain. |
| cause? | { code?, message?, name? } | Present when an underlying error was wrapped. |
stage is inferred from code, though some operations refine it (e.g. a native
module init failure reports stage: 'initialization' while keeping
code: 'SATYA_NATIVE_REPLAY_FAILED'). Use code for precise branching and
stage for dashboards/alerting.
stage values: initialization, configuration, providerRegistry,
providerPolicy, providerTemplate, webViewCapture, nativeReplay,
artifactVerification, backendVerification, validation, unexpected.
code values (grouped by the stage they normally map to):
| Stage | Codes |
| --- | --- |
| configuration | SATYA_CONFIG_INVALID, SATYA_VERIFIER_URL_INVALID |
| providerRegistry | SATYA_PROVIDER_ID_REQUIRED, SATYA_PROVIDER_NOT_FOUND, SATYA_REGISTRY_FETCH_FAILED, SATYA_REGISTRY_INFO_MISMATCH, SATYA_REGISTRY_INVALID |
| providerPolicy | SATYA_PROVIDER_POLICY_FETCH_FAILED, SATYA_PROVIDER_POLICY_INVALID, SATYA_PROVIDER_POLICY_MISMATCH |
| providerTemplate | SATYA_PROVIDER_ENDPOINT_INVALID, SATYA_PROVIDER_RULE_INVALID, SATYA_PROVIDER_TEMPLATE_INVALID, SATYA_PROVIDER_URL_INVALID |
| webViewCapture | SATYA_PROVIDER_CAPTURE_INVALID, SATYA_PROVIDER_LOCATION_PERMISSION_DENIED |
| nativeReplay | SATYA_NATIVE_REPLAY_FAILED |
| artifactVerification | SATYA_ARTIFACT_VERIFICATION_FAILED |
| initialization | SATYA_UNSUPPORTED_PLATFORM |
| validation | SATYA_VALIDATION_FAILED |
| unexpected | SATYA_UNEXPECTED |
SatyaError, SatyaErrorCode, SatyaErrorDiagnostics, and SatyaErrorStage are
all exported from the package root.
App-Owned Buttons And Auto-Start
Apps with their own connect button can mount the same managed flow when they are ready to open the WebView. In this mode, the app owns the visible button and the SDK owns the WebView, capture, native replay, and verification flow:
export function AppOwnedConnectButton() {
const [openSatya, setOpenSatya] = useState(false);
return (
<>
<Pressable onPress={() => setOpenSatya(true)}>
<Text>Connect Kaggle</Text>
</Pressable>
{openSatya ? (
<SatyaProviderAttestButton
autoStart
renderButton={false}
transportConfig={{
verifierUrl: 'https://satya-verifier.example.com',
cloudProjectNumber: 0,
}}
verifyBackend
onComplete={(result) => {
setOpenSatya(false);
saveSatyaArtifact(result.raw);
}}
onCancel={() => setOpenSatya(false)}
onError={(error) => {
setOpenSatya(false);
reportSatyaError(error);
}}
/>
) : null}
</>
);
}autoStart opens once per component mount. To start another flow, unmount and remount
the component when the app-owned button is pressed again. To open without a user pressing
a visible SATYA button, set the state from your own route, onboarding step, deep link, or
effect, then render the hidden launcher:
{shouldOpenSatya ? (
<SatyaProviderAttestButton autoStart renderButton={false} {...satyaProps} />
) : null}Composite Proofs
A composite proof attests claims from several governed endpoints gathered in one
authenticated provider session. Strict-all artifacts are schema 2.0.0 (legacy) or
app-bound 2.1.0; signed partial-endpoint artifacts use 2.2.0. Instead of a
single endpointId, the app passes a proofPlanId — a
registry-defined proof plan that selects an ordered set of 1–8 required endpoint
ids. The app never supplies URLs or an endpoint list; every endpoint the plan replays is a
pre-approved endpoints[] entry, and the plan is folded into the provider policyHash.
Provider-selective partial endpoints
Some providers cannot guarantee that every proof-plan endpoint fires in every session. An app may opt exact provider ids into partial completion without changing provider or proof-plan config:
initSatyaAttest({
partialEndpointProviderIds: ['provider.identity.v1'],
});Matching is exact and case-sensitive; entries are never treated as patterns. An empty or omitted list keeps every provider strict-all. There is no matching Provider Registry, Devtool, or verifier setting. The SDK list is an app availability preference; the backend remains the security authority.
An authenticated __satyaReady signal starts the capture window and releases
readiness-gated endpoints. Without it, the first accepted ungated capture starts the window.
The capture layer attempts the full plan until maxDurationMs. Full capture success stays on the
existing strict-all path. Otherwise, it sends the confirmed 2xx request subset to native replay,
which requires at least one successful exchange and may further omit availability, timeout,
non-2xx, or missing-claim failures. TLS trust, SPKI, origin, request-policy, and binding failures
remain fatal.
- the signed set must be a non-empty proper subset in proof-plan order;
- every claim-equality account-binding endpoint must be present in the final subset;
- schema
2.2.0signscompletionMode: "partial-endpoint-subset"; the semantic hash, exchange root, and domain-separated proof challenge bind the exact ordered endpoint IDs; - schemas
2.0.0and2.1.0have no completion mode and remain strict-all; - the verifier directly compares the signed subset with the current full registry proof plan and fully verifies every included exchange;
- both the managed component and public
SatyaMobileClientAPI require successful backend verification for schema2.2.0, even whenverifyBackendis false; - native/local
verifyArtifact*returnsbackend-requiredfor schema2.2.0and is not an acceptance verdict; - the verifier verdict always distinguishes
strict-allfrompartial-endpoint-subsetand returnsendpointIds,omittedEndpointIds, andplanComplete; - relying parties that require full completion must require
planComplete: true(equivalently,completionMode: "strict-all"and an emptyomittedEndpointIds).
The verifier proves that every presented exchange is valid and belongs to the configured plan. It cannot prove that the omitted endpoints were actually attempted; that is SDK execution behavior, not a property recoverable from a subset proof.
If the window expires without a successful endpoint, capture fails with
no-endpoints-captured. Timeout diagnostics list unconfirmedEndpointIds.
proofPlanId is mutually exclusive with endpointId — pass exactly one. It works on
the same SatyaProviderAttestButton and low-level client:
Providers without proofPlans[] remain single-endpoint compatible when no plan is requested.
Requesting a proofPlanId from a provider with no plans, or requesting an unknown plan id, is
rejected instead of silently changing the requested policy.
<SatyaProviderAttestButton
providerId="provider.example.v1"
proofPlanId="account-summary" // mutually exclusive with endpointId
providerPolicyRefresh="verifier-first"
transportConfig={{ verifierUrl: 'https://satya-verifier.example.com', cloudProjectNumber: 0 }}
verifyBackend
onComplete={(result) => {
// result.proofPlanId, result.capturedRequests (every request in plan order),
// result.capturedRequest aliases the first entry for backward compatibility.
saveSatyaArtifact(result.raw);
}}
/>The SDK captures every required request inside the one WebView session, then native code
(iOS Swift / Android Kotlin) replays each endpoint sequentially over a fresh
SPKI-pinned connection using a proof-local cookie jar (seeded once from the WebView store,
never written back; cookie contents are never serialized or hashed), with redirects
disabled and only 2xx responses accepted. onProgress reports proofPlanId, endpointId,
exchangeIndex, and exchangeCount as each exchange runs.
Limits
- 1–8 endpoints per plan, ordered; the order is order-sensitive and participates in
policyHash. - Per-response 8 MiB and aggregate 16 MiB response-byte caps across the plan.
- Capture selection runs for at most the plan's
maxDurationMs(an integer in[1000, 120000]ms). A readiness signal starts the window when received; otherwise the first accepted capture starts it. Later readiness never resets an earlier start.
What a v2 artifact contains
The Rust core emits schema 2.0.0 for legacy strict-all, 2.1.0 for app-bound strict-all,
or 2.2.0 for a signed endpoint subset. Each artifact carries, per witnessed
exchange, its own TLS/handshake commitments,
request/response commitments, record-log evidence, and endpoint-scoped disclosed claims
(the same alias may repeat across different endpoint scopes; endpoint provenance is
preserved). All exchanges are bound by one domain-separated, order- and count-bound
aggregate exchangeCommitmentRoot signed by the device key: omitting, duplicating,
reordering, or mutating any exchange changes the root. The device statement signature
covers the aggregate root, proof-plan id, policy hash, exchange count, freshness window,
revocation epoch, and account-binding evidence.
Account-binding assurance
Every plan declares how it proves the endpoints belong to one account, surfaced on the
backend verdict as accountBindingAssurance:
shared-session-attested— all exchanges ran under the one attested on-device session (the plan declares this asaccountBinding.mode: "shared-session"); no cookie hashing is involved.claim-equality— named claims from ≥2 endpoint scopes must be equal; the verifier compares the canonical claim values. Claims stay endpoint-scoped.
The account-binding evidence is a bare attested marker plus claim references only — it never carries cookie values, cookie hashes, authorization values, or session tokens.
Pass-2 platform binding
For v2 artifacts the pass-2 evidence binds to the prepared composite proof challenge: the
App Attest assertion clientDataHash = SHA256(proofChallengeDigestHex ASCII), and the
Play Integrity nonce =
base64url(SHA256("satya-w-playintegrity-bind-v2" || 0x00 || proofChallengeDigestHex ASCII)).
Composite pass-2 policies reject a v2 artifact that carries only key-enrollment
evidence. Single-endpoint schema-1 binding is unchanged.
v1 / v2 compatibility
Single-endpoint schema-v1 verification remains fully available; endpointId still emits a
schema-v1 native-pinned-observed artifact, and proofPlanId emits schema v2. This SDK
release can emit both (dual-schema support since 2.0.0). The backend dispatches on schema major before full
deserialization (ParsedArtifact::{Single,Composite}): schema 1 selects
the preserved single-artifact profile for SDK 1–3, and schema 2 selects
expo-sdk-2-composite for SDK 2–3. The backend loads the provider, plan, endpoints, reveal allowlists,
SNI, pins, freshness, and revocation policy independently from the registry (never from
the artifact), recomputes the provider's canonical policyHash, requires the artifact's
exact hash, and returns a typed verdict with proofPlanId, completionMode, endpointIds,
omittedEndpointIds, planComplete, exchangeCount, and accountBindingAssurance.
Strict-all verification rejects any missing, duplicated, unknown, reordered, failed, or
policy-mismatched exchange. Partial mode applies the same checks to every endpoint in the
exact signed subset and validates that subset against the registry's full proof plan.
Tier and activation gate
Like single-endpoint proofs, composite production evidence still requires
physical-device validation (real App Attest / Secure Enclave / Play Integrity /
Keystore / WebView cookie sharing). Simulator/emulator results are DEVELOPMENT tier and are
never production evidence. The Provider Registry validates proofPlans[] and folds the
canonical plan into policyHash. The proofPlans[] registry schema is documented in
PROVIDER_CONFIG.md §8.1.
Low-Level API
import {
initSatyaAttest,
loadProviderTemplate,
SatyaMobileClient,
withProviderSpkiPins,
} from '@burnt-labs/expo-satya-attest';
initSatyaAttest({
appId: 'app_00000000-0000-4000-8000-000000000000',
providerId: 'kaggle.current_user.v1',
});
const providerTemplate = withProviderSpkiPins(await loadProviderTemplate(), [
'KWQsjul7mJDk+i6fAisGLhWLd43FUs5ayVmkz5Y54DU=',
]);
const client = new SatyaMobileClient();
const { artifact, raw, verify } = await client.attestNativeReplay({
providerTemplate,
capturedRequest,
transportConfig: {
verifierUrl: 'http://127.0.0.1:7047',
cloudProjectNumber: 0,
},
});capturedRequest should come from the WebView interceptor and must match the selected
provider template. The returned raw string is the artifact JSON to post to
POST /v1/artifacts/verify on satya-verifier-service.
Native replay artifacts are compact by default: signed public values live in
artifact.semanticStatement.disclosedClaims with UI-friendly name aliases and
audit-friendly sourcePath JSON paths. Legacy/full artifacts may still include a
top-level disclosure object, but new native replay artifacts omit that duplicate
surface. Operational telemetry/logging metrics such as latency, byte counters, and
backend labels are also omitted from new proof artifacts.
Config Plugin
Add the plugin to inject App Attest entitlements and platform pinning config:
{
"expo": {
"plugins": [
[
"@burnt-labs/expo-satya-attest",
{
"appAttestEnvironment": "development",
"pinnedProviders": [
{
"host": "www.kaggle.com",
"spkiSha256B64Pins": ["KWQsjul7mJDk+i6fAisGLhWLd43FUs5ayVmkz5Y54DU="]
}
]
}
]
]
}
}Use development for debug/TestFlight and production for App Store builds. iOS App
Attest requires a real device plus an App ID with the DeviceCheck/App Attest capability.
enableWebViewGeolocation is off by default. Enable it only when a registry provider uses
requiresLocation: true; the plugin then adds the iOS usage description and Android fine/coarse
location permissions. On Android, the SDK requests runtime access after resolving an opted-in
provider and before loading its WebView. A denial reports
SATYA_PROVIDER_LOCATION_PERMISSION_DENIED. Providers without requiresLocation never trigger
the permission request or receive WebView geolocation.
{
"enableWebViewGeolocation": true,
"locationUsageDescription": "Location is used to verify provider eligibility."
}The plugin also accepts cleartextHosts, an array of hosts (or URLs — only the hostname
is kept) allowed to use plain-HTTP on Android via the generated network security config.
Use it only for development setups where the example app must reach a LAN verifier over
http://; provider pin entries always keep cleartext disabled:
{
"appAttestEnvironment": "development",
"cleartextHosts": ["192.168.1.50"]
}Example App
cd platform_sdks/expo-satya-attest/expo-satya-attest-example
npm install --ignore-scripts
mkdir -p node_modules/@burnt-labs
ln -sfn ../../.. node_modules/@burnt-labs/expo-satya-attest
npx expo run:ios --device
npx expo run:androidSee expo-satya-attest-example/README.md for device env setup and backend verifier commands.
Verification Backend
SDK 3 staging verification uses the multi-tenant
https://satya-verifier-v2-staging.burnt.com deployment: configure your public
app id (EXPO_PUBLIC_SATYA_APP_ID / transportConfig.appId, from the Satya
devtool) — there is no bearer token. Schema 0/1 artifacts stay on the
single-artifact verifier and schema 2/2.1 selects the composite verifier; the
service requires pass-2 for real devices and preserves /verify and
/v1/verify for older clients alongside the canonical /v1/artifacts/verify
route. Older SDK 1.x/2.x builds keep using the frozen legacy
https://satya-verifier-v1-staging.burnt.com deployment (bearer-authenticated,
legacy-v1 branch) during migration.
The top-level producer block drives compatibility routing but is not witness-signed.
Cryptographic acceptance comes from the signed statement, registry policy, TLS/SPKI
evidence, and App Attest or Play Integrity.
Run the SATYA verifier service from the workspace root:
SATYA_ALLOW_PLATFORM_PASS1=true cargo run -p satya-verifier-serviceThe verifier loads provider policy from the Provider Registry API and does not require a separate signing-service public-key bundle.
SDK verifier URLs must use HTTPS, except loopback HTTP for local development
(http://localhost, http://127.0.0.1, or http://[::1]). For Android
physical-device testing against a laptop verifier, use
adb reverse tcp:7047 tcp:7047 and set
EXPO_PUBLIC_VERIFIER_URL=http://127.0.0.1:7047; otherwise expose the verifier through an
HTTPS tunnel or deployed HTTPS endpoint.
Provider Health Monitoring
Every native replay reports one anonymous health ping to the Burnt provider
monitor (POST https://provider-monitor.burnt.workers.dev/ingest) so per-provider
success rates and proof-generation latency show up on the shared dashboard. This
is on by default and needs no app configuration or token.
outcome: "ok"fires as soon as the attestation artifact is produced, withlatencyMsmeasured from captured-request handoff to artifact creation.outcome: "failed"fires when the native replay pipeline fails to produce an artifact (no latency attached).- Artifact verification — the embedded check and the backend verifier — is outside the ping: its result depends on device trust (App Attest / Play Integrity) and verifier policy, so it never changes the reported outcome and is tracked on the verifier's own monitor rows.
The ping is fire-and-forget: it runs concurrently with verification, times out
after 10 s, and can never block, slow down, or fail an attestation. The payload
is only provider, outcome, and latencyMs — no user data, claims, session
identifiers, or artifact contents. The endpoint enforces a registry allow-list;
unknown provider ids are acknowledged but not recorded.
Checks
cd platform_sdks/expo-satya-attest
npm run build
npm run lint
npm run release:check
cd expo-satya-attest-example
./node_modules/.bin/tsc --pretty false --noEmit
cd ../../..
cargo test -p satya-ffi native_replay_observed_nested_json_paths_verify
cargo test -p satya-verifier-servicePublishing
The Expo SDK should publish to npm. The npm package must include the generated Android
.so files, generated UniFFI Kotlin/Swift glue, and iOS XCFramework so third-party apps
can install it without building Rust locally. See PUBLISHING.md for
the release checklist, dry-run commands, binary-size guidance, and scoped-package naming
recommendations.
Security Notes
- WebView response bytes are not attested. The WebView is only a login/request discovery surface.
- The managed WebView bridge currently treats SDK-injected request, response, bearer, and login-ready messages as control-plane signals. Native replay still fetches the trusted template URL with system TLS and SPKI checks, but apps should treat provider account binding as a backend responsibility when bearer/session state matters.
- Cookies are shared into native replay through platform cookie stores.
- Native replay blocks user roots and requires client-side SPKI pins so the replay connection fails closed before producing an artifact.
- The SDK never learns production trust by fetching the provider's live certificate and trusting it for the same run. Dynamic pins come from verifier policy, are validated against the selected template, and are frozen before WebView capture starts.
- Backend verification independently re-derives trusted policy hashes and pins from backend config, not from the client artifact alone. Client pins protect the fetch; backend pins protect acceptance.
- For production deployments, serve verifier policy over an authenticated channel controlled by the verifier operator. Raw provider SPKI discovery is only suitable as a manual/dev maintenance aid, not as automatic trust.
- Development artifacts remain dev-tier and should not be accepted by production verifiers.
