SenseCrypt Docs
SDKs & appsMobile SDK

Face-PKI ceremony flow

Run a face-PKI ceremony with the SenseCrypt Mobile SDK — receive the pushed link, show the approval screen, capture a face, deliver the signed result to the application, and post the completion.

The face-PKI ceremony flow answers a request from an application for the user's face-locked keys. The application asks for one of three things: the user's public keys, a signature, or a shared secret. The request arrives on the phone as a CIBA link. The user approves it with a face scan. The SDK then does the key operation, delivers the signed result to the application, and posts the completion to SenseCrypt.

Your app supplies the same inputs as for a login: the link, an optional password, and a face capture. It observes the state machine to drive its UI. For the application side, see Face PKI over CIBA.

The result never reaches your app. The SDK derives the keys from the face token and the matched face, uses them once, and sends the result to the application's endpoint itself. No key, secret or signature crosses the SDK boundary.

The keys are specific to the application and to the context it asks for. Two applications that ask for the same context get different keys, even in the same tenant. Your app does not see the context and cannot change it.

Starting the flow

let flow = authn.startPkiCeremonyFlow()

Task {
    for await state in flow.stateStream() {
        handle(state)
    }
}

stateStream() yields the current state first, then every transition.

val flow = withContext(Dispatchers.IO) { authn.startPkiCeremonyFlow() }

lifecycleScope.launch {
    flow.state.collect { state -> handle(state) }
}

PkiCeremonyFlow.state is a SharedFlow, not a StateFlow. It has no .value. Read the current state with the currentState property.

startPkiCeremonyFlow() does not throw. Start it when a link arrives, not at app launch.

The state machine

submitLinkPayload refused link submitPassword (wrong) submitFaceCapture liveness failed (retry) face mismatch (retry) endpoint refused / unreachable retry transient retry cancelCeremony cancelCeremony key refusal / budget spent hard error hard error cancel Idle AwaitingLink DownloadingFaceToken AwaitingFaceCapture RunningLiveness MatchingFace DeliveringResult PostingResult DeliveryFailed Succeeded PostingResultFailed Failed Cancelled

The flow runs the liveness check before the face match. A badly framed frame then fails on liveness and does not spend a face-mismatch attempt.

States and their payloads

StatePayloadWhat your UI does
idle—Nothing yet.
awaitingLinklastError: LinkError?Wait for the pushed link. On a non-nil lastError, show the matching message. See Step 1.
downloadingFaceTokenrpLabel: String?, passwordRequired: Bool, passwordIsNumeric: Bool, lastPasswordError: PasswordError?Show a fetching interstitial. When passwordRequired is true, show a password prompt.
awaitingFaceCapturerpLabel: String, kind: PkiCeremonyKind, lastError: FaceCaptureRetryReason?, branding: Branding, bindingMessage: String, expiresAtUnix: Int64?, attemptsUsed: UInt32, attemptsMax: UInt32Show the approval screen, then the camera. See Step 3.
runningLiveness—On-device work in progress. Stop submitting frames.
matchingFace—As above.
deliveringResult—Network POST to the application in flight. Show a blocking scrim.
deliveryFailedstatus: UInt16?Not terminal. The application's endpoint refused the result, or it could not be reached (status is nil). Offer "Try again" → retry(). Offer "Cancel" → cancelCeremony().
postingResult—Network POST to SenseCrypt in flight. Show a blocking scrim.
postingResultFailedreason: FailureReasonNot terminal. Offer "Try again" → retry().
succeededrpLabel: String, kind: PkiCeremonyKind, timestamp: Int64Terminal success.
cancelled—Terminal. Your app dropped the flow with cancel().
failedreason: FailureReasonTerminal. See Errors.

PkiCeremonyKind is one of publicKeys, sign or decapsulate. Use it to word the approval screen.

A ceremony arrives on the same pushed link and universal link as a CIBA sign-in. Pass the raw link bytes to the flow. The call returns immediately. Observe the state for the outcome.

flow.submitLinkPayload(payload: linkBytes)
flow.submitLinkPayload(linkBytes)

Your app cannot tell a ceremony link from a sign-in link before it submits it. Each flow refuses the other flow's link, and names it:

The flow answersMeaningWhat your app does
awaitingLink(lastError: .signIn) on a PkiCeremonyFlowThe link is a sign-in.Hand the same bytes to a LoginFlow.
LinkError.pkiCeremony on a LoginFlowThe link is a ceremony.Hand the same bytes to a PkiCeremonyFlow.

You can try either flow first. The SenseCrypt Authenticator app submits every link to a LoginFlow first, and re-submits a refused ceremony link to a PkiCeremonyFlow.

Other refusals stay on awaitingLink:

LinkErrorMeaning
invalidLinkNot a SenseCrypt link, or the ceremony request in it is malformed. The SDK has signed an invalid_request abort to SenseCrypt.
expiredThe session behind the link has expired.
unknownKey(…)The link targets an identity this device holds no key for.
networkThe lookup could not reach SenseCrypt.
attestationFailedDevice attestation refused to issue a token and the tenant enforces attestation. Terminal for this link.
accessDeniedThe user no longer has access to the application. Terminal for this link.

Step 2 — the optional password step

If downloadingFaceToken.passwordRequired is true, the user's face token needs a password. Collect it and submit it:

flow.submitPassword(password: enteredPassword)
flow.submitPassword(enteredPassword)

A wrong password keeps the flow on downloadingFaceToken and sets lastPasswordError. This is the same step as in the login flow.

Step 3 — show the approval screen and capture a face

awaitingFaceCapture carries what the user approves. Show all of it before you open the camera:

  • bindingMessage — the text the application sent. It is always present on a ceremony. Show it in full.
  • kind — what the application asks for. Word the title from it, for example "Acme requests your signature".
  • rpLabel and branding — the application's name, logo and colours.
  • expiresAtUnix — when the request expires. Show a countdown.

Do not hide bindingMessage. A signature over an opaque hash means nothing without it. The user must see what they approve. The key context is never shown; the SDK does not expose it.

Then run a face capture session and submit it:

flow.submitFaceCapture(captureSession: session)
flow.submitFaceCapture(captureSession = session)

The SDK checks liveness, matches the face, opens the face token, does the key operation, and moves to deliveringResult. A liveness failure or a face mismatch returns the flow to awaitingFaceCapture with a FaceCaptureRetryReason. The retry budget is the same as for a login: attemptsMax attempts. When the budget is spent, the SDK signs an abort to SenseCrypt and the flow ends in failed.

Some refusals are known only after the face match, because the answer would tell a stranger something about the user's keys. They end the flow in failed with one of the face-PKI failure reasons. Your app cannot fix them. Tell the user to contact the application.

Step 4 — delivery and completion

After the face match the SDK does two network calls in order:

  1. Deliver. It POSTs the signed result to the application's delivery endpoint (deliveringResult).
  2. Complete. It POSTs the completion to SenseCrypt (postingResult).

The order matters. SenseCrypt marks the request complete only after the application has accepted the result.

If the application refuses the result or cannot be reached, the flow moves to deliveryFailed(status:). The state is not terminal. Show a "Not delivered" sheet with two actions:

  • Try again → retry(). The SDK re-sends the same signed bytes. It does not run a second face scan.
  • Cancel → cancelCeremony(). The SDK signs a delivery_failed abort to SenseCrypt. The flow ends in failed.

If the completion fails on a transient error, the flow moves to postingResultFailed(reason:). Call retry() to re-post it. The application already holds the result.

succeeded is terminal. It carries rpLabel, kind and timestamp. Show a confirmation and dismiss.

Cancelling

Two calls end the flow. They differ in what SenseCrypt learns.

CallUse it whenWhat happens
cancelCeremony()The user cancels on screen.The SDK signs an abort to SenseCrypt — delivery_failed after a failed delivery, user_cancelled otherwise. The application's request ends. The flow ends in failed, with .deliveryFailed(status) after a failed delivery and .userDeclined otherwise. Before a session exists it falls back to a local cancel and ends in cancelled.
cancel()Your app drops the flow, for example on view teardown.Local only. Nothing is sent. The flow ends in cancelled. The request stays open until it expires.

Prefer cancelCeremony() for a user action. The application then learns the outcome at once instead of after the request expires.

What your app observes about keys

Nothing, in the normal case. Three rules decide what happens underneath, and each shows up in your app in one way only.

  • Keys arrive with the policy, not only at enrolment. The tenant's key policy reaches the phone on every sign-in and ceremony. If a user's face token predates it and holds no keys, the SDK seals keys into the token after the face match and before the key operation, as part of the token rotation every completion already performs. The upgrade is fill-only: a key the token already holds never changes, so a key an application pinned never moves. The ceremony that finds such a token completes on the keys it just gained.
  • Two rotations from one token. A ceremony and a sign-in, or two devices, can be pending at once, and both rotate from the same token. SenseCrypt stores one of the two rotations. A ceremony whose rotation is not the one stored is refused at completion: the flow does not reach succeeded, the application's token request answers stale_token, and the application starts a new request, which stages the current token. The phone's stored state needs nothing fixed, but the flow stops in postingResultFailed and a retry cannot succeed: offer only cancel, and let the application start again.
  • Protocol floor. Ceremonies need device protocol 7. An app built on an older SDK is refused when it submits a ceremony link and ends in failed(reason: .upgradeRequired). Prompt for an update; there is no retry.

What the SDK sends

You do not build the delivery. The SDK does. For reference, the delivery is a JSON body signed with the user's ML-DSA key in an X-SenseCrypt-Signature header, with the application's client_notification_token as the bearer. Its fields are described in Face PKI over CIBA. Of the result, the completion sends SenseCrypt only the SHA-256 of that body. SenseCrypt returns the hash to the application in the ID token as a receipt.

  • Face PKI over CIBA — the application side: the request, the endpoint checks, and the receipt.
  • Login flow — the sign-in flow this one mirrors.
  • Errors — the failure reasons a ceremony can end with.
  • Push notifications — how the ceremony's link reaches the phone.

On this page