SoroPass
SDK

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 /create or /sign is 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-sdk is 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 the AccountDeployer / adapters you supply.
  • Invariants: ES256-only (alg −7), always low-S normalized, one frozen 10-code error taxonomy.
SubpathWhat it givesBundle
.Public surface14.1 KB · ~3.9 KB gz
/createcreatePasskey, assertES256~549 B
/connectconnect()~208 B
/recoverrecover()~361 B
/signsignTransaction, normalizeLowS880 B · ~435 B gz
/typesKitError, guards235 B · ~182 B gz
/testingcreatePasskeyKit 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

FieldTypeDescription
rpId (required)stringYour site's registrable domain.
rpName (required)stringHuman-readable relying-party name shown in the OS sheet.
userName (required)stringAccount name shown during registration.
deployer (required)AccountDeployerDeploys the smart account for the new passkey (contract-specific).
webauthnWebAuthnClientWebAuthn client; defaults to browserWebAuthnClient().
storageCredentialStorageWhere the credential id is persisted; defaults to defaultCredentialStorage().
userIdUint8ArrayOptional user handle bytes; generated if omitted.
challengeUint8ArrayOptional 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

FieldTypeDescription
contractIdstringC-address of the deployed smart account.
credentialIdstringWebAuthn credential id, persisted via storage for later connect().
publicKeyUint8Array65-byte SEC-1 secp256r1 public key (0x04‖X‖Y).
register.ts
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 }>;
}
FieldTypeDescription
input.publicKeyUint8Array65-byte SEC-1 public key of the new passkey.
input.credentialIdstringWebAuthn credential id to bind to the account.
→ contractIdstringC-address of the deployed smart account.
→ txHashstring?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.

FunctionSignature / returnsDescription
assertES256assertES256(alg: number): voidThrows ES256_NOT_SUPPORTED unless alg === -7.
assertUserActivationassertUserActivation(activation?): voidThrows USER_CANCELLED if not an active user gesture (Safari / WebKit rule).
buildCreateOptions→ PublicKeyCredentialCreationOptionsJSONBuilds creation options with pubKeyCredParams [{ type: 'public-key', alg: -7 }] only.
browserWebAuthnClient→ WebAuthnClientnavigator.credentials-backed client (the default).
defaultCredentialStorage→ CredentialStoragelocalStorage 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.

FunctionReturnsDescription
coseKeyToSec1(coseKey)Uint8ArrayCBOR 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)Uint8ArrayPulls the COSE key out of authenticatorData and returns the SEC-1 point.
extractPublicKeyFromAttestationObject(attestationObject)Uint8ArrayDecodes the attestationObject and returns the SEC-1 point.
extract.ts
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.

FunctionOptionsDescription
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.

derive.ts
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 call

signTransaction

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>
ParamTypeDescription
txXdrstringBase64 XDR transaction envelope to sign.
optionsSorobanSignOptionsNetwork passphrase + WebAuthn signer (see below).
sign-tx.ts
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>
ParamTypeDescription
entryXdrstringBase64 XDR of a single SorobanAuthorizationEntry.
optionsSorobanSignOptionsNetwork passphrase + WebAuthn signer (see below).

SorobanSignOptions

Shared by signTransaction and signAuthEntry.

FieldTypeDescription
networkPassphrasestringNetwork passphrase bound into the auth challenge (networkId = SHA256(networkPassphrase)).
signWebAuthnSignerThe signer that produces the assertion; browserPasskeySigner(...) is the standard implementation.
signerAddress?stringsignTransaction 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?Uint8ArrayThe 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?WalletTargetWhich ABI to assemble for: the single-signer account (default) or 'smart-wallet' for the passkey-kit v1 Signatures(Map) shape.
verify?SignVerifyOptionsOpt-in pre-flight (RP-ID / origin / challenge / assertion checks) run before the entry is returned.
signatureExpirationLedger?numberStamped 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).

FieldTypeCheck
rpId?stringauthenticatorData.rpIdHash === SHA256(rpId), else RP_ID_MISMATCH.
origin?string | string[]clientDataJSON.origin is one of these exact origins, else ORIGIN_MISMATCH.
publicKey?Uint8ArrayThe 65-byte SEC-1 key verifies the ECDSA assertion signature, else CONTRACT_AUTH_FAILED.
requireUserVerification?booleanAlso 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?booleanAccept 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): WebAuthnSigner

BrowserPasskeySignerOptions

FieldTypeDescription
rpIdstringRelying 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?Uint8ArrayThe 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?WebAuthnClientInject a custom client (tests / non-browser).
signer.ts
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.

FunctionSignatureDescription
isLowS(compactSignature)(Uint8Array) => booleanTrue if a 64-byte compact signature is canonical low-S (S ≤ n/2).
normalizeLowS(compactSignature)(Uint8Array) => Uint8ArrayIf S > n/2, replace S with n − S. Idempotent.
derToCompact(der)(Uint8Array) => Uint8ArrayASN.1 DER → 64-byte raw R‖S (noble parser). Does not enforce low-S.
derToCompactLowS(der)(Uint8Array) => Uint8ArrayDER → 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.

FunctionReturnsDescription
parseClientDataJSON(bytes)ClientDataDecodes clientDataJSON into { type, challenge, origin, crossOrigin }.
verifyClientDataJSON(bytes, expected)ClientDataParses, then enforces the webauthn.get type, the expected origin(s), and the challenge; throws ORIGIN_MISMATCH / CHALLENGE_MISMATCH.
parseAuthenticatorData(bytes)ParsedAuthenticatorDataDecodes rpIdHash, the UP / UV / AT flags, and the signature counter.
verifyRpIdHash(authData, rpId)voidThrows RP_ID_MISMATCH unless rpIdHash === SHA256(rpId).
verifyAssertionSignature(input)booleanECDSA-verifies { publicKey, authenticatorData, clientDataJSON, signature } over the WebAuthn payload.
encodeChallenge(bytes)stringBytes to base64url, the encoding WebAuthn challenges travel in.
decodeChallenge(text)Uint8Arraybase64url 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.

FunctionReturnsDescription
reconstructSignedPayload({ authenticatorData, clientDataJSON })Uint8ArraySHA256(authData ‖ SHA256(clientDataJSON)): the 32-byte payload __check_auth re-derives.
authEntryChallenge(entry, networkPassphrase)stringbase64url challenge (43 chars) the authenticator signs.
authEntryChallengeBytes(entry, networkPassphrase)Uint8ArraySHA256(XDR(HashIdPreimage::SorobanAuthorization{...})); networkId = SHA256(networkPassphrase).
applyAssertionToEntry(entry, assertion, publicKey)SorobanAuthorizationEntryReturns 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)CheckAuthResultVerifies 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)SmartWalletCheckAuthResultThe 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();
CodeWhen thrownThrown byStyled UI copyRecovery
USER_CANCELLEDUser dismissed the OS passkey sheetassertUserActivation / ceremonyYou closed the passkey prompt before it finished.Try again
UNSUPPORTED_AUTHENTICATORDevice/passkey can't be usedcreate / sign / recover (fallback)This device or passkey can't be used. Try another.Try again
ES256_NOT_SUPPORTEDNon-P256 key at creation (alg ≠ −7)assertES256 / coseKeyToSec1This passkey isn't supported for on-chain accounts.Try again
INVALID_PUBLIC_KEYCOSE key couldn't be readcoseKeyToSec1 / extractPublicKey*Couldn't read the key from this passkey.Try again
INVALID_SIGNATURE_DERMalformed / >72-byte DER signaturederToCompactThere was a problem with the signature.Try again
RP_ID_MISMATCHrpIdHash ≠ expected originverifyRpIdHashCouldn't verify this request. It may have changed.Try again
ORIGIN_MISMATCHclientDataJSON origin mismatchverifyClientDataJSONCouldn't verify this request. It may have changed.Try again
CHALLENGE_MISMATCHChallenge changed or expiredverifyClientDataJSONCouldn't verify this request. It may have expired.Try again
CONTRACT_AUTH_FAILED__check_auth rejected on-chainsignTransaction / deployCouldn't set up / authorize the account.Try again
NETWORK_ERRORRPC / network unreachablesubmission / indexerCouldn'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.

On this page