Usage patterns
This is the integration guide for @stellar-passkey/core, @stellar-passkey/ui, and the PasskeyModule we ship for the forked @creit.tech/stellar-wallets-kit. It covers the five canonical ceremony flows you call as a consumer, and the platform-specific quirks that have eaten development time inside this project — each quirk is cross-linked to the compatibility-matrix cell that records its current status (docs/matrix.md) and the regression test that pins the fix.
If a snippet here looks unrealistically minimal, the reference demo (apps/demo) and the smoke harness (apps/smoke) wire the full versions against the deployed Soroban testnet contract.
The wallet WASM hash referenced throughout is
5ac96264b6c647cf40b25b5c19eacf534d75a45f96c99fd5122303164d28307e. The deployed reference contract on testnet isCBCDZA5QCF6BJQO2I2AIWTR4IBJDLJX632S6SG6Y6JNPYWZL56GJR3VI.
1 — The five canonical flows
The SDK ships five small functions. Every higher-level UI surface (the Lit Web Components, the PasskeyModule, the reference demo) ultimately routes back through these.
1.1 createPasskey
createPasskey is the registration ceremony. It calls navigator.credentials.create with a hard-coded pubKeyCredParams: [{ type: "public-key", alg: -7 }] (ES256), pulls the SEC-1 uncompressed P-256 public key out of the CBOR attestationObject, and then hands two callbacks to the consumer: deployContract and bootstrapAdmin. The kit never holds a Stellar signing key — those callbacks are how the consumer plugs in stellar-cli, Launchtube, a backend, or any other deployer.
Matrix cells: every "registration" column. Regression tests: apple-low-s.test.ts, apple-high-s.test.ts.
import { createPasskey } from "@stellar-passkey/core";
const { contractId, credential } = await createPasskey({
rpId: "passkey-demo.opak.ai",
rpName: "Passkey Demo",
userId: crypto.getRandomValues(new Uint8Array(16)),
userName: "alice@example.com",
walletWasmHash: hexToBytes(
"5ac96264b6c647cf40b25b5c19eacf534d75a45f96c99fd5122303164d28307e",
),
deployer: {
async deployContract({ walletWasmHash, salt }) {
// Call your backend / Launchtube / stellar-cli here.
// Returns the freshly-deployed C-address.
return await myBackend.deployWallet(walletWasmHash, salt);
},
async bootstrapAdmin({ contractId, credentialId, publicKey }) {
// Seed the wallet's first signer. Bootstrap branch in the
// contract does NOT require require_auth — only deployer pays.
await myBackend.addAdminSigner(contractId, credentialId, publicKey);
},
},
});1.2 connectPasskey
connectPasskey is the silent-reconnect ceremony — what you call on page load to restore the active session. It reads credentialId from localStorage, then queries the Soroban RPC for the historical ("sw_v1","add", credentialId) event emitted at bootstrap time; the emitting contract IS the wallet, so the contract C-address is re-derived rather than persisted. Re-derivation prevents localStorage-tampering from redirecting signing to a wallet the user never enrolled. Matrix cells: chromium/macOS, safari/macOS, chrome/Windows.
import { connectPasskey } from "@stellar-passkey/core";
const session = await connectPasskey({
rpcUrl: "https://soroban-testnet.stellar.org",
});
if (session === null) {
// No saved session — drive createPasskey or recoverPasskey instead.
} else {
console.log("Reconnected to wallet", session.contractId);
}1.3 signTransaction
signTransaction mutates a Soroban tx envelope in-place: it finds every SorobanAuthorizationEntry whose credentials.address.address equals the wallet C-address, hashes the HashIdPreimage::SorobanAuthorization envelope, drives a WebAuthn assertion with that hash as the challenge, packs the result into the contract's Signature ScVal, and re-encodes the envelope. The host's secp256r1_verify (CAP-0051) rejects high-S signatures by design (see § 2 below), so the SDK runs normalizeLowS before packing the bytes.
rpc.prepareTransaction is required upstream of this call (it populates the auth entries). And re-simulation after signing is also required — the placeholder credentials prepareTransaction installs underestimate the resource footprint of the real WebAuthn-shaped Signature ScVal. Cross-ref: matrix chromium/macOS, regression test apple-low-s.test.ts.
import { signTransaction } from "@stellar-passkey/core";
import { rpc as SorobanRpc, TransactionBuilder } from "@stellar/stellar-sdk";
const rpcServer = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const latest = (await rpcServer.getLatestLedger()).sequence;
const { signedTxXdr, signerAddress } = await signTransaction(preparedXdr, {
contractId: session.contractId,
rpId: "passkey-demo.opak.ai",
credentialId: session.credentialId,
signatureExpirationLedger: latest + 60,
});
// Re-simulate so the resource footprint matches the real signature.
const signedTx = TransactionBuilder.fromXDR(signedTxXdr, "Test SDF Network ; September 2015");
const reSim = await rpcServer.simulateTransaction(signedTx);
const finalTx = SorobanRpc.assembleTransaction(signedTx, reSim).build();1.4 signAuthEntry
signAuthEntry is the SEP-43 primitive for composing the kit's signing surface with other wallets — e.g. an atomic-swap flow where one of the auth entries belongs to your passkey-controlled smart account and the others come from a Freighter EOA. It takes a single base64-encoded SorobanAuthorizationEntry XDR (NOT an envelope), runs the same preimage-hash + WebAuthn-assertion machinery as signTransaction, and returns the mutated entry. Cross-ref: signAuthEntry.test.ts.
import { signAuthEntry } from "@stellar-passkey/core";
const { signedAuthEntry, signerAddress } = await signAuthEntry(authEntryXdr, {
contractId: session.contractId,
rpId: "passkey-demo.opak.ai",
credentialId: session.credentialId,
signatureExpirationLedger: latest + 60,
});1.5 recoverPasskey
recoverPasskey is the cold-start ceremony — what runs when the user has no localStorage (new device, cleared cache). It calls navigator.credentials.get with NO allowCredentials, so the browser prompts the user to pick from any resident passkey bound to the rpId. The returned rawId becomes the credentialId we feed into the Soroban event lookup. If the user has been enrolled on multiple wallets, every one shows up; the UI lets them pick. Cross-ref: matrix chromium/macOS, the <passkey-recover> component.
import { recoverPasskey } from "@stellar-passkey/core";
const matches = await recoverPasskey({
rpId: "passkey-demo.opak.ai",
rpcUrl: "https://soroban-testnet.stellar.org",
});
// matches: [{ contractId, credentialId }, …]
// User picks one; the host app calls saveSession({ credentialId, … })
// and reconnects via connectPasskey.2 — The Apple low-S quirk
Apple Safari (iOS + macOS Touch ID + Face ID) frequently emits ECDSA-P256 signatures with the s scalar in the upper half of the curve order N. The kit normalises every signature to low-S before shipping it into __check_auth — the Soroban host's secp256r1_verify deliberately rejects high-S sigs per stellar/stellar-protocol issue #1435, the same rationale that motivated cosmos-sdk #9723: high-S half-curve signatures are a malleability vector. Without client-side normalisation, every other Touch ID signature would fail verification at random.
The flip is arithmetic, not cryptographic — s' = n - s is still a valid signature against the same public key because ECDSA verifies r = (k·G).x mod n and the verification equation includes s only through s⁻¹ mod n. Substituting n - s for s (i.e. taking the additive inverse mod n) produces a multiplicative inverse on the other half of the curve. Verifiers that accept both halves are "non-canonical" and that's what allows malleability; verifiers that reject the upper half (Bitcoin's BIP-146, Cosmos's later fix, and Stellar's host) enforce a single canonical signature per (message, key) pair. The SDK does not invent that policy — it just makes sure callers obey it without thinking about it.
The fix lives in packages/core/src/sig/lowS.ts. Five pinned regression fixtures (YK-274, packages/core/test/fixtures/apple-high-s/) exercise the round-trip; the test asserts the flipped signature verifies against the original public key + digest so the byte-level quirk does NOT void the underlying ECDSA assertion. Real-device captures (YK-296) will overwrite the synthesised fixtures when the hardware bench lands. Matrix cells: safari/macOS, safari/iOS — lowS: normalized.
import { derToCompact, normalizeLowS } from "@stellar-passkey/core";
const compact = derToCompact(derSignatureFromWebAuthn);
const lowS = normalizeLowS(compact);
// lowS is what the contract verifies.
// `normalizeLowS` is idempotent — re-applying does nothing once
// `s` is already in the lower half. Tests pin this invariant.
const reNormalized = normalizeLowS(lowS);
// reNormalized === lowS, byte-for-byte.3 — The Windows Hello RS256-only failure mode
Older Windows Hello configurations (or Hello-bound external security keys that the device negotiates as such) sometimes advertise only RS256 (-257) ES256 (-7). The kit's create options hard-code pubKeyCredParams: [{ type: "public-key", alg: -7 }], so the WebAuthn ceremony fails immediately with NotSupportedError on those configurations — there is no silent fallback to RS256, because the on-chain verifier only supports secp256r1.
The host app's user-facing error message should be specific:
catch (e) {
if (e?.code === "UNSUPPORTED_AUTHENTICATOR" || e?.name === "NotSupportedError") {
showError(
"This security key only supports RSA signatures, which the on-chain " +
"verifier doesn't accept. Try a different device (Touch ID, Windows " +
"Hello with TPM, a recent YubiKey, an Android phone).",
);
return;
}
throw e;
}The hard-coded ES256-only requirement is verified by createOptions.test.ts. Matrix cells: chrome/Windows, firefox/Windows.
4 — The Firefox geckodriver virtual-authenticator caveat
Firefox supports the WebDriver virtual-authenticator endpoints from geckodriver 0.34.0 onwards (released 2024-01-03). The upstream CHANGES.md carries an explicit warning, repeated verbatim in 0.34.0, 0.35.0, and 0.36.0:
"Since their introduction in geckodriver 0.34.0, several Virtual Authenticator endpoints have been reported as non-functional or behaving unexpectedly. We recommend avoiding the use of these commands until the known issues have been resolved."
In CI we therefore drive Firefox via Playwright's firefox-bidi project for non-WebAuthn assertions and mark every WebAuthn-touching test as partial until the upstream fix lands. Matrix cell: firefox/Linux — tracking YK-272. There is no SDK work-around; this is purely a test-driver gap.
// In a Playwright spec, gate the WebAuthn assertion on the browser.
import { test } from "@playwright/test";
test("create passkey", async ({ page, browserName }) => {
test.skip(
browserName === "firefox",
"Firefox virtual-authenticator endpoints are non-functional " +
"across geckodriver 0.34.0–0.36.0 — see docs/guide/usage-patterns.md § 4.",
);
// … real assertion here …
});5 — The iOS Safari user-gesture requirement
Per Yubico's documented advisory, Apple platforms reject navigator.credentials.{create,get} calls that originate outside a user-activated event — typically a click, touchend, doubleclick, or keydown handler. The browser shows the error "this request has been cancelled by the user" even when no user interaction has occurred, which makes the failure mode confusing during development.
The fix is to keep the WebAuthn invocation on the synchronous tail of a user-activated handler. Don't wrap it in a setTimeout(0) or a Promise.resolve().then(...) chain — both break the user-activation token. Don't call it from a route transition or a useEffect that fires after navigation.
// ❌ user-activation lost between click and the ceremony
button.addEventListener("click", async () => {
await someUnrelatedAsyncWork();
await createPasskey({...}); // iOS Safari: "cancelled by user"
});
// ✅ the await chain stays inside the click handler tick
button.addEventListener("click", async () => {
await createPasskey({...}); // ceremony fires inside the gesture
await sendResultsToServer();
});Matrix cells: safari/iOS, safari/macOS.
6 — Cross-device authentication (CDA) caveats
WebAuthn's hybrid transport ("phone-as-authenticator" QR-code flow) is a strong UX for desktops without a TPM. Two caveats land in the SDK's path:
allowCredentialsfiltering.connectPasskeyandsignTransactionpassallowCredentials: [{ id: credentialId }]to scope the assertion to the right key. Hybrid transport honours this filter, but if the phone's iCloud Keychain has been wiped, the QR flow will silently fall through to "no matching credentials" — the SDK surfaces that asUSER_CANCELLEDbecause there is nothing else the browser exposes. Match cells: safari/iOS, chrome/Android.- rpId binding. Hybrid transport uses the desktop's origin as the rpId for the credential — so a passkey registered via QR from
passkey-demo.opak.aiis bound to that rpId, not the phone's domain. If the host app changes domains, the credential does not migrate. The SDK's regex againstclientDataJSON.challengecatches mismatches; the host app should surface them asRP_ID_MISMATCH.
// CDA flows look identical to local flows from the SDK's POV —
// the only thing the host app needs to do extra is *expect* a
// longer ceremony timeout. The default 60 s is enough on a fast
// network; bump to 120 s if you are on rural mobile.
await signTransaction(xdr, {
contractId, rpId, credentialId,
signatureExpirationLedger: latest + 60,
// navigator.credentials.get is called with timeout: 60_000.
});6.1 Hybrid transport timing characteristics
CDA over the hybrid (caBLE / "phone-as-authenticator") transport typically takes 4 – 12 seconds depending on Bluetooth advertisement latency on the phone side and the user's manual scan-and-tap time. The SDK's default timeout: 60_000 is generous enough for that but tight enough that users on a frozen Bluetooth stack get an explicit failure rather than an infinite hang. If you find your deployment regularly hits the 60 s ceiling, raise it once on the host app side rather than retrying — retries cost a fresh QR-code scan from the user.
6.2 Detecting hybrid mode
There is no first-class API to tell whether the ceremony resolved over the platform authenticator vs. hybrid; both return the same AuthenticatorAssertionResponse. If your UX wants a different hint, you can sniff PublicKeyCredential.getClientCapabilities() which exposes hybridTransport as a feature flag — but absence of the capability does not mean a hybrid ceremony didn't happen; some older browsers under-report. The kit treats both paths identically.
7 — Recommended fallbacks per environment
| Environment | Primary path | Fallback |
|---|---|---|
| Desktop Chrome / Edge with TPM | Local platform authenticator | Hybrid transport (QR to phone) → 1Password / Bitwarden if browser supports it |
| Desktop Safari (macOS) | Touch ID + iCloud Keychain | Hybrid transport from iPhone → YubiKey via USB |
| Desktop Firefox | External security key (USB) | Hybrid transport (Firefox supports CDA from Firefox 116) |
| Windows without TPM | External security key | Refuse — RS256-only authenticators fail per § 3 |
| iOS Safari | iCloud Keychain passkey | Native ASAuthorizationPasskey if running inside a hybrid wrapper |
| Android Chrome | Play Services FIDO | Pixel StrongBox external key via USB-C |
For consumer apps targeting wide reach, ship the create-passkey button only on the environments where you can guarantee the ceremony will succeed — and document the YK-271 / YK-272 / YK-273 status before adopters hit them in the wild. Matrix cells under docs/matrix.md are the canonical reference for "will this work for my users today".
// Sniff capability before showing the Create button.
const ready =
typeof PublicKeyCredential !== "undefined" &&
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!ready) {
// Hide the Create button; show a guidance link.
hideElement("#create-passkey");
showElement("#passkey-not-supported");
}7.1 Sequencing the create / connect / sign loop
The host app is in charge of the order in which the three ceremonies fire. The defensible default is:
- On every page load, run
connectPasskey. If it returns non-null, you have an active session. - If
connectPasskeyreturns null, surface a Create button. The button'sclickhandler runscreatePasskey. - After the user clicks the Create button on a device where they've previously enrolled, the browser may pop a "pick an existing passkey" sheet instead of registering a new one — that's WebAuthn's discoverable-credentials behaviour, and it's fine. The kit's create path tolerates the user picking an existing credential; it just records the existing public key + contract id.
- If the user is starting fresh on a device with no passkey for the rpId, the Recover button (running
recoverPasskey) is the right path — it drives a discoverable-credentials assertion and resolves the wallet from on-chain events.
The reference demo wires this exact sequence (apps/demo/src/main.ts), with session persistence going through saveSession / loadSession / clearSession from @stellar-passkey/core plus a demo-specific contract-id key that the SDK does not persist for security reasons (see storage.ts).
Appendix — how this guide stays current
- Every section above carries a matrix-cell or regression-test link. When a new browser × OS pair is added to
docs/matrix/data.json, the relevant section here is updated in the same PR. pnpm test:regression:applecovers § 2 (Apple low-S / high-S).pnpm test:e2e:chromiumcovers § 1 (create + sign + recover) + § 4 (the Firefox skip).pnpm validate:matrixruns the JSON Schema gate.
Filed gaps live in Linear as YK-271 (Edge install), YK-272 (Firefox gecko), YK-273 (Safari driver), YK-296 (hardware bench), YK-297 (BrowserStack mobile coverage). Closing them refreshes the matrix and the prose above.