SDK reference
The @soropass/core create + sign primitives, covering ES256-only registration, public-key extraction, passkey-signed Soroban auth entries, and the frozen error taxonomy.
@soropass/core is a minimal, headless, ES256-only passkey SDK for Stellar smart accounts. This page is the core reference: registration, public-key extraction, and passkey signing. Connect, recovery, multi-device signers, and the submission/indexer adapters live on Accounts, recovery & adapters.
Version
This reference documents @soropass/core 0.3.1, the npm latest, which requires
@stellar/stellar-sdk 17 or newer. It pairs with the v0.2 account contracts: key-bound address
derivation, the signer public key inline in the signature struct, and userVerification
defaulting to 'required'.
SDK at a glance
- Tree-shakeable subpaths: import only
/create,/sign,/connect,/recover,/types, or/testing; the heavy crypto (noble) is shared and pulled only when/createor/signis imported. - ~2 runtime deps:
@noble/curves+@noble/hashes; the core barrel entry is ~3.9 KB gzip, individual subpath entries from ~0.15 KB (measured on the 0.3.1 dist). @stellar/stellar-sdkis a peer dependency, never bundled into the SDK output. Stated once here, it holds for every function on this page and the accounts page. Contract-specific work lives behind theAccountDeployer/ adapters you supply.- Invariants: ES256-only (alg −7), always low-S normalized, one frozen 10-code error taxonomy.
| Subpath | What it gives | Bundle |
|---|---|---|
. | Public surface | 14.1 KB · ~3.9 KB gz |
/create | createPasskey, assertES256 | ~549 B |
/connect | connect() | ~208 B |
/recover | recover() | ~361 B |
/sign | signTransaction, normalizeLowS | 880 B · ~435 B gz |
/types | KitError, guards | 235 B · ~182 B gz |
/testing | createPasskeyKit mock mode (dev-only, own reference) | 8.3 KB |
createPasskey
Register an ES256-only passkey, extract its SEC-1 public key (RS256 hard-fails), deploy a smart account through your factory, persist the credential id, and return the account.
import {
createPasskey,
assertES256,
buildCreateOptions,
browserWebAuthnClient,
defaultCredentialStorage,
coseKeyToSec1,
} from '@soropass/core/create';ES256-only invariant. pubKeyCredParams is exactly [{ type: 'public-key', alg: -7 }]. Any other algorithm (RS256, EdDSA, …) throws KitError('ES256_NOT_SUPPORTED'). Soroban verifies only secp256r1.
createPasskey(options: CreatePasskeyOptions): Promise<PasskeyCredential>CreatePasskeyOptions
| Field | Type | Description |
|---|---|---|
rpId (required) | string | Your site's registrable domain. |
rpName (required) | string | Human-readable relying-party name shown in the OS sheet. |
userName (required) | string | Account name shown during registration. |
deployer (required) | AccountDeployer | Deploys the smart account for the new passkey (contract-specific). |
webauthn | WebAuthnClient | WebAuthn client; defaults to browserWebAuthnClient(). |
storage | CredentialStorage | Where the credential id is persisted; defaults to defaultCredentialStorage(). |
userId | Uint8Array | Optional user handle bytes; generated if omitted. |
challenge | Uint8Array | Optional registration challenge; generated if omitted. |
residentKey | 'discouraged' | 'preferred' | 'required' | authenticatorSelection.residentKey hint. |
userVerification | 'discouraged' | 'preferred' | 'required' | authenticatorSelection.userVerification. Defaults to 'required': the v0.2 account requires the User-Verified flag at sign time, so registration enrolls an authenticator that can produce it. |
userActivation | { isActive: boolean } | Pass navigator.userActivation to enforce the Safari gesture rule. |
Returns: PasskeyCredential
| Field | Type | Description |
|---|---|---|
contractId | string | C-address of the deployed smart account. |
credentialId | string | WebAuthn credential id, persisted via storage for later connect(). |
publicKey | Uint8Array | 65-byte SEC-1 secp256r1 public key (0x04‖X‖Y). |
import { createPasskey } from '@soropass/core/create';
const account = await createPasskey({
rpId: 'app.example.com',
rpName: 'Example',
userName: 'alice@example.com',
deployer, // your AccountDeployer
residentKey: 'required',
userActivation: navigator.userActivation, // Safari gesture rule
});
console.log(account.contractId); // C... smart-account address
console.log(account.credentialId); // persisted for connect()
console.log(account.publicKey.length); // 65 (SEC-1)The create-button click is the WebAuthn user gesture. Call createPasskey directly from the
click handler so the registration ceremony can prompt.
AccountDeployer
The contract-specific seam. createPasskey hands your deployer the extracted SEC-1 public key and credential id and expects the deployed smart-account address back, keeping the SDK free of contract coupling. (For the ready-made passkey-kit v1 deployer, see smartWalletV1Deployer.)
interface AccountDeployer {
deploy(input: {
publicKey: Uint8Array;
credentialId: string;
}): Promise<{ contractId: string; txHash?: string }>;
}| Field | Type | Description |
|---|---|---|
input.publicKey | Uint8Array | 65-byte SEC-1 public key of the new passkey. |
input.credentialId | string | WebAuthn credential id to bind to the account. |
→ contractId | string | C-address of the deployed smart account. |
→ txHash | string? | Optional deploy transaction hash. |
Registration primitives
createPasskey composes these internally; each is exported for advanced flows and testing. Override the WebAuthn ceremony or credential persistence via CreatePasskeyOptions.webauthn / .storage.
| Function | Signature / returns | Description |
|---|---|---|
assertES256 | assertES256(alg: number): void | Throws ES256_NOT_SUPPORTED unless alg === -7. |
assertUserActivation | assertUserActivation(activation?): void | Throws USER_CANCELLED if not an active user gesture (Safari / WebKit rule). |
buildCreateOptions | → PublicKeyCredentialCreationOptionsJSON | Builds creation options with pubKeyCredParams [{ type: 'public-key', alg: -7 }] only. |
browserWebAuthnClient | → WebAuthnClient | navigator.credentials-backed client (the default). |
defaultCredentialStorage | → CredentialStorage | localStorage with an in-memory fallback for Node / SSR, so the SDK stays import-safe outside the browser. |
registerPasskey | → RegisteredPasskey = { credentialId, publicKey } | Register + extract the SEC-1 key without deploying: the "new device" primitive used by addSigner. createPasskey is this plus a deploy. |
Public-key extraction
The path from a WebAuthn attestation to the 65-byte SEC-1 point the contract verifies. Each step is exported so you can extract from whichever shape you hold.
| Function | Returns | Description |
|---|---|---|
coseKeyToSec1(coseKey) | Uint8Array | CBOR COSE EC2 → 65-byte SEC-1 0x04‖X‖Y; a wrong alg throws ES256_NOT_SUPPORTED, any other malformed key throws INVALID_PUBLIC_KEY. |
extractPublicKeyFromAuthData(authData) | Uint8Array | Pulls the COSE key out of authenticatorData and returns the SEC-1 point. |
extractPublicKeyFromAttestationObject(attestationObject) | Uint8Array | Decodes the attestationObject and returns the SEC-1 point. |
import { extractPublicKeyFromAttestationObject } from '@soropass/core/create';
// 65-byte SEC-1 point: 0x04 ‖ X ‖ Y. Non-P-256 keys throw ES256_NOT_SUPPORTED
const publicKey = extractPublicKeyFromAttestationObject(attestationObject);Address derivation
Derive the smart account's C-address offline, with no deploy and no indexer round-trip: the same createCustomContract preimage the network hashes, computed locally. This is what makes a returning user's getAddress instant and infra-free.
| Function | Options | Description |
|---|---|---|
deriveAccountAddress(options) | { factoryContractId, credentialId, publicKey, networkPassphrase } | The address the AccountFactory deploys for a passkey. credentialId is Uint8Array: pass the same bytes the factory received, which for factoryDeployer is the UTF-8 encoding of the base64url credential id (new TextEncoder().encode(account.credentialId)). publicKey is the passkey's 65-byte SEC-1 key: the v0.2 factory salts the deploy by sha256(credential_id ‖ public_key), so both inputs are part of the address. |
deriveSmartWalletAddress(options) | { deployer, credentialId, networkPassphrase } | The passkey-kit v1 wallet address: salted by sha256 of the base64url-decoded credential id bytes, deployed by a fixed deployer address. credentialId is the base64url string. |
Binding the public key into the salt is a security property, not bookkeeping: credential ids are public (the factory emits them in events), so a salt over the credential id alone let anyone pre-deploy at a victim's derived address with their own key, capturing anything sent there. With the key bound, deploying at a derived address requires the exact public key, so a squatted deploy produces the victim's intended account.
Both functions are exported from @soropass/core/create and throw a typed KitError on an invalid deployer address, an empty credential id, or (for deriveAccountAddress) a public key that is not 65 bytes.
import { deriveAccountAddress } from '@soropass/core/create';
const address = deriveAccountAddress({
factoryContractId: 'C...FACTORY',
credentialId: new TextEncoder().encode(account.credentialId),
publicKey: account.publicKey, // 65-byte SEC-1, part of the salt
networkPassphrase,
});
// address === account.contractId, with no network callsignTransaction
Sign every address-credential Soroban auth entry carried by the InvokeHostFunction operations of a transaction (base64 XDR envelope, v1 or fee-bump). Obtains a WebAuthn assertion, low-S-normalizes it, and assembles the contract signature your __check_auth re-derives. Returns the signed envelope XDR. A transaction that carries no Soroban address-credential auth entry comes back unchanged; set signerAddress to sign only the connected account's entries and leave a co-authorizer's entries untouched.
signTransaction(txXdr: string, options: SorobanSignOptions): Promise<string>| Param | Type | Description |
|---|---|---|
txXdr | string | Base64 XDR transaction envelope to sign. |
options | SorobanSignOptions | Network passphrase + WebAuthn signer (see below). |
import { signTransaction, browserPasskeySigner } from '@soropass/core/sign';
const sign = browserPasskeySigner({ rpId, allowCredentials: [credentialId], publicKey });
const signedXdr = await signTransaction(txXdr, { networkPassphrase, sign });signAuthEntry
Obtain a WebAuthn assertion, low-S-normalize, and assemble the contract signature for a single entry. Returns the signed entry as base64 XDR.
signAuthEntry(entryXdr: string, options: SorobanSignOptions): Promise<string>| Param | Type | Description |
|---|---|---|
entryXdr | string | Base64 XDR of a single SorobanAuthorizationEntry. |
options | SorobanSignOptions | Network passphrase + WebAuthn signer (see below). |
SorobanSignOptions
Shared by signTransaction and signAuthEntry.
| Field | Type | Description |
|---|---|---|
networkPassphrase | string | Network passphrase bound into the auth challenge (networkId = SHA256(networkPassphrase)). |
sign | WebAuthnSigner | The signer that produces the assertion; browserPasskeySigner(...) is the standard implementation. |
signerAddress? | string | signTransaction only: sign only the auth entries whose credential address equals this C-address, so the passkey never signs on behalf of, or prompts for, another authorizer's entries. Omit to sign every address-credential entry. |
publicKey? | Uint8Array | The signing passkey's 65-byte SEC-1 public key, for the default single-signer target: the v0.2 account carries the signer's key inline in the signature struct and verifies against it. Takes precedence over a publicKey the signer returns on its assertion. Required for single-signer when the signer does not return one (signing throws a KitError with neither); ignored by the smart-wallet target. |
target? | WalletTarget | Which ABI to assemble for: the single-signer account (default) or 'smart-wallet' for the passkey-kit v1 Signatures(Map) shape. |
verify? | SignVerifyOptions | Opt-in pre-flight (RP-ID / origin / challenge / assertion checks) run before the entry is returned. |
signatureExpirationLedger? | number | Stamped onto every address-credential entry before the challenge is computed, so the signature binds the exact expiration __check_auth re-derives. Needed for smart-wallet writes; omit when the entry already carries the intended expiration. |
The default single-signer target assembles the four-field Secp256r1Signature struct the v0.2 webauthn-account consumes, a canonically sorted ScMap: { authenticator_data, client_data_json, public_key, signature }. The inline public_key names which enrolled signer the multi-signer account verifies against; the contract rejects a key it does not hold before any crypto runs. The smart-wallet target assembles the three-field passkey-kit v1 shape instead, which resolves the key on-chain by credential id.
For target: 'smart-wallet' and the full sign-and-submit path, see Accounts, recovery & adapters.
SignVerifyOptions
On-chain __check_auth is the real gate; the opt-in verify pre-flight catches a bad assertion client-side, turning an opaque on-chain failure into a typed KitError at the call site. Providing the object (even empty) always enforces the webauthn.get ceremony type, the challenge binding, and the User-Present (UP) flag; each field below adds its check. Omit the object entirely to skip pre-flight (the default).
| Field | Type | Check |
|---|---|---|
rpId? | string | authenticatorData.rpIdHash === SHA256(rpId), else RP_ID_MISMATCH. |
origin? | string | string[] | clientDataJSON.origin is one of these exact origins, else ORIGIN_MISMATCH. |
publicKey? | Uint8Array | The 65-byte SEC-1 key verifies the ECDSA assertion signature, else CONTRACT_AUTH_FAILED. |
requireUserVerification? | boolean | Also require the User-Verified (UV) flag (biometric / PIN), else UNSUPPORTED_AUTHENTICATOR. Default false. The v0.2 account enforces UV on-chain regardless; set this to catch a missing UV flag client-side before submit. |
allowCrossOrigin? | boolean | Accept an assertion made in a cross-origin context. Default false (ORIGIN_MISMATCH). |
browserPasskeySigner
Adapt navigator.credentials.get into the WebAuthnSigner that the sign functions expect. It receives a base64url challenge (the Soroban auth preimage), decodes it, runs the assertion, and returns the fields the auth assembler needs; the DER signature is low-S normalized downstream.
browserPasskeySigner(options: BrowserPasskeySignerOptions): WebAuthnSignerBrowserPasskeySignerOptions
| Field | Type | Description |
|---|---|---|
rpId | string | Relying Party ID (registrable domain). |
allowCredentials? | string[] | base64url credential ids; omit for a discoverable prompt. |
userVerification? | 'discouraged' | 'preferred' | 'required' | WebAuthn user-verification requirement. Defaults to 'required': the v0.2 account requires the UV flag, so a signature without it fails on-chain. |
publicKey? | Uint8Array | The passkey's 65-byte SEC-1 public key (from create / connect). WebAuthn assertions do not return the public key, so pass it here to sign for the single-signer target; it is echoed onto each assertion. You can instead supply it once via SorobanSignOptions.publicKey. |
webauthn? | WebAuthnClient | Inject a custom client (tests / non-browser). |
import { browserPasskeySigner, signTransaction } from '@soropass/core/sign';
const sign = browserPasskeySigner({
rpId: 'app.example.com',
allowCredentials: [credentialId], // omit for a discoverable (resident-key) prompt
publicKey: account.publicKey, // travels inline in the single-signer struct
});
const signedXdr = await signTransaction(txXdr, { networkPassphrase, sign });Low-S normalization
Invariant #2. Roughly 50% of Apple Touch ID / Face ID assertions come back high-S, and Soroban's secp256r1_verify does not enforce low-S, so the SDK must emit low-S before any contract sees the signature.
A high-S signature is malleable: the same message has two valid encodings. Always normalize to canonical low-S (S ≤ n/2) client-side.
| Function | Signature | Description |
|---|---|---|
isLowS(compactSignature) | (Uint8Array) => boolean | True if a 64-byte compact signature is canonical low-S (S ≤ n/2). |
normalizeLowS(compactSignature) | (Uint8Array) => Uint8Array | If S > n/2, replace S with n − S. Idempotent. |
derToCompact(der) | (Uint8Array) => Uint8Array | ASN.1 DER → 64-byte raw R‖S (noble parser). Does not enforce low-S. |
derToCompactLowS(der) | (Uint8Array) => Uint8Array | DER → 64-byte canonical low-S compact (what the ceremony uses). |
Assertion verification primitives
The parse and verify steps behind the verify pre-flight, each exported on its own from @soropass/core/sign so you can validate whichever piece you hold. The verify functions throw a typed KitError; the parse functions return structured data.
| Function | Returns | Description |
|---|---|---|
parseClientDataJSON(bytes) | ClientData | Decodes clientDataJSON into { type, challenge, origin, crossOrigin }. |
verifyClientDataJSON(bytes, expected) | ClientData | Parses, then enforces the webauthn.get type, the expected origin(s), and the challenge; throws ORIGIN_MISMATCH / CHALLENGE_MISMATCH. |
parseAuthenticatorData(bytes) | ParsedAuthenticatorData | Decodes rpIdHash, the UP / UV / AT flags, and the signature counter. |
verifyRpIdHash(authData, rpId) | void | Throws RP_ID_MISMATCH unless rpIdHash === SHA256(rpId). |
verifyAssertionSignature(input) | boolean | ECDSA-verifies { publicKey, authenticatorData, clientDataJSON, signature } over the WebAuthn payload. |
encodeChallenge(bytes) | string | Bytes to base64url, the encoding WebAuthn challenges travel in. |
decodeChallenge(text) | Uint8Array | base64url to bytes, the inverse. |
Soroban auth primitives
The primitives that derive the challenge the authenticator signs and reassemble the assertion into a verifiable SorobanAuthorizationEntry. For how the challenge is bound to the auth preimage, see Security.
| Function | Returns | Description |
|---|---|---|
reconstructSignedPayload({ authenticatorData, clientDataJSON }) | Uint8Array | SHA256(authData ‖ SHA256(clientDataJSON)): the 32-byte payload __check_auth re-derives. |
authEntryChallenge(entry, networkPassphrase) | string | base64url challenge (43 chars) the authenticator signs. |
authEntryChallengeBytes(entry, networkPassphrase) | Uint8Array | SHA256(XDR(HashIdPreimage::SorobanAuthorization{...})); networkId = SHA256(networkPassphrase). |
applyAssertionToEntry(entry, assertion, publicKey) | SorobanAuthorizationEntry | Returns a new entry whose signature is the v0.2 four-field struct ScVal::Map { authenticator_data, client_data_json, public_key: BytesN<65>, signature: BytesN<64> } (alphabetical keys); the input entry is not modified. The signature MUST already be 64-byte low-S; publicKey names the enrolled signer the contract verifies against. |
referenceCheckAuth(entry, publicKey, passphrase) | CheckAuthResult | Verifies a signed entry exactly the way the v0.2 __check_auth does: the struct's inline public_key must equal the enrolled publicKey, plus challenge binding and secp256r1_verify semantics. Returns { success, challengeBound, signatureValid, reason? }. |
referenceSmartWalletCheckAuth(entry, publicKeyFor, passphrase) | SmartWalletCheckAuthResult | The same reference verification for the passkey-kit v1 Signatures(Map) shape. publicKeyFor(credentialIdHex) looks up each signer's SEC-1 key; succeeds only when every signer verifies and is challenge-bound. Returns { success, signers, reason? }. |
The signature passed to applyAssertionToEntry must already be the 64-byte low-S compact form.
Use derToCompactLowS first.
Recording-auth simulateTransaction does not run secp256r1_verify, so it under-budgets
__check_auth. For smart-wallet accounts, prefer
sendSmartWalletTx. It re-simulates the signed tx in
enforcing mode to measure the true footprint before submit. If you submit manually, raise the
instruction budget + resource fee, or use a managed submitter.
Error taxonomy
Every throw in the SDK is a typed KitError, never a bare string. KitError extends Error with an exhaustive code from the frozen KIT_ERROR_CODES tuple; isKitError is the type guard. The v0.2 account contract additionally defines its own numeric contract errors, returned on-chain from __check_auth and the signer methods (for example UnknownSigner, UserVerifiedFlagMissing, LastSignerRemoval); those are Soroban contract errors carried in the transaction result, not KitError codes. All three come from @soropass/core/types (~182 B gzipped), which also exports BATTLE_TESTED_ANCHORS: the stable ids of the three behaviors that silently break real users (low-s-normalization, rs256-hard-fail, apple-user-gesture), referenced by name across the test suite, the threat model, and the compatibility matrix.
import { KitError, KIT_ERROR_CODES, isKitError } from '@soropass/core/types';
import type { KitErrorCode } from '@soropass/core/types';
class KitError extends Error {
readonly code: KitErrorCode; // one of KIT_ERROR_CODES
readonly name = 'KitError';
// `cause` is inherited from Error, set when the original error is wrapped
}
// exhaustive over KIT_ERROR_CODES; the compiler flags a missing case
if (isKitError(err) && err.code === 'USER_CANCELLED') return retry();| Code | When thrown | Thrown by | Styled UI copy | Recovery |
|---|---|---|---|---|
USER_CANCELLED | User dismissed the OS passkey sheet | assertUserActivation / ceremony | You closed the passkey prompt before it finished. | Try again |
UNSUPPORTED_AUTHENTICATOR | Device/passkey can't be used | create / sign / recover (fallback) | This device or passkey can't be used. Try another. | Try again |
ES256_NOT_SUPPORTED | Non-P256 key at creation (alg ≠ −7) | assertES256 / coseKeyToSec1 | This passkey isn't supported for on-chain accounts. | Try again |
INVALID_PUBLIC_KEY | COSE key couldn't be read | coseKeyToSec1 / extractPublicKey* | Couldn't read the key from this passkey. | Try again |
INVALID_SIGNATURE_DER | Malformed / >72-byte DER signature | derToCompact | There was a problem with the signature. | Try again |
RP_ID_MISMATCH | rpIdHash ≠ expected origin | verifyRpIdHash | Couldn't verify this request. It may have changed. | Try again |
ORIGIN_MISMATCH | clientDataJSON origin mismatch | verifyClientDataJSON | Couldn't verify this request. It may have changed. | Try again |
CHALLENGE_MISMATCH | Challenge changed or expired | verifyClientDataJSON | Couldn't verify this request. It may have expired. | Try again |
CONTRACT_AUTH_FAILED | __check_auth rejected on-chain | signTransaction / deploy | Couldn't set up / authorize the account. | Try again |
NETWORK_ERROR | RPC / network unreachable | submission / indexer | Couldn't reach the network. | Retry |
The styled layer maps (screen, code) → a screen-scoped copy key (e.g. create:cancelled,
sign:verify) so the same 10 codes read naturally on each screen. Unknown or unmapped codes fall
back to the screen's *:unsupported line, never a blank or a stack trace.
Theming
Restyle the entire SoroPass UI through one OKLCH token file, picking a skin or overriding tokens, while the components never change.
Accounts, recovery & adapters
Connect and recover a smart account, sign and submit passkey smart-wallet transactions, add or remove signers for multi-device recovery, and swap the pluggable submission and indexer adapters.