SoroPass

Security

How a passkey signs and a smart account verifies on-chain, plus the threat model for @soropass/core. Low-S, challenge binding, RP-ID/origin, and recovery, each backed by a test or a real on-chain proof.

The threat model for @soropass/core, and how signing actually works. Each mitigation is backed by a concrete test or living-matrix row, and the verification path is proven against a real __check_auth on Stellar testnet, not a JS model.

Trust flows left to right. The authenticator, the browser, your relayer and your indexer are all outside the trust boundary. The deployed contract's __check_auth is the ultimate authority and verifies the signature and its challenge-binding regardless of how every layer before it behaved.

Authenticator → Browser · RP JS → @soropass/core → Submission / indexer → __check_auth

__check_auth is the only layer inside the trust boundary.

How signing works

A passkey signs your transaction; the smart account verifies it on-chain (no seed phrase, no server). The whole trust model is one digest, computed in the browser and re-derived on-chain.

1 · WebAuthn ceremony. navigator.credentials.get() returns an assertion containing authenticatorData (rpIdHash + flags + counter), clientDataJSON (type, challenge, origin), and a DER signature over:

payload = SHA256( authenticatorData ‖ SHA256(clientDataJSON) )

2 · ES256-only + low-S. The SDK requests { alg: -7 } only, and low-S normalizes every signature client-side (see Low-S enforcement).

3 · Challenge binding. The WebAuthn challenge is the Soroban auth-entry preimage hash (see Challenge & replay), so a captured signature cannot be replayed for a different transaction, nonce, or network.

4 · The wire shape. The assertion maps 1:1 to the contract's Secp256r1Signature (map keys alphabetical so the ScMap is canonically sorted, public_key a 65-byte SEC-1 BytesN<65>, signature a 64-byte low-S BytesN<64>):

pub struct Secp256r1Signature {
  pub authenticator_data: Bytes,
  pub client_data_json:   Bytes,
  pub public_key:         BytesN<65>,
  pub signature:          BytesN<64>,
}

The inline public_key names which enrolled signer the multi-signer account verifies against.

5 · __check_auth. On-chain, the contract runs the audited OpenZeppelin verifier sequence: it rejects a public_key that is not an enrolled signer (UnknownSigner) before any crypto runs, parses clientDataJSON (length cap, real JSON, type == "webauthn.get"), asserts clientDataJSON.challenge == base64url(payload), checks the User-Present and User-Verified flags and Backup-Eligibility/Backup-State consistency, reconstructs authenticatorData ‖ SHA256(clientDataJSON), and calls the host secp256r1_verify(public_key, SHA256(message), signature), which traps on an invalid signature and rejects a high-S signature at decode. The same digest computed in step 1 is re-derived here; that identity is the whole model.

Low-S enforcement

ECDSA signatures are malleable: for (r, s), the reflected (r, n−s) verifies just as well. Roughly 50% of Apple Touch ID / Face ID assertions are high-S, and Soroban's secp256r1_verify does not reject high-S, so canonicality is the SDK's responsibility.

Invariant #2: the SDK low-S normalizes the DER assertion client-side (S > n/2 → n−S, derToCompactLowS) before assembling the authorization entry. A verifier that does enforce low-S still accepts the SDK's signatures, and any replay logic keyed on the signature bytes stays stable. SoroPass additionally recommends the contract reject non-canonical S as defense-in-depth.

DER ECDSA → R‖S (64 bytes) → if S > n/2 then S = n−S    // derToCompactLowS
0 ──[ accepted: 0 … n/2 ]──│ n/2 │──[ rejected: n/2 … n ]──▶ reflect S to n−S

Proven both directions in anchors.test.ts (anchor low-s-normalization): high-S in → low-S out, and a non-normalized signature is rejected by a low-S enforcer.

Challenge & replay

The WebAuthn challenge is not a random nonce. It is the Soroban auth-entry preimage hash, binding every assertion to a single network, nonce, expiration ledger and exact invocation:

challenge = base64url( SHA256( XDR( SorobanAuthorization{ networkId, nonce, sigExpLedger, invocation } ) ) )

__check_auth re-derives the same preimage and asserts clientDataJSON.challenge == base64url(payload). On mismatch it fails closed with CHALLENGE_MISMATCH. The reference verifier referenceCheckAuth mirrors the contract; soroban.test.ts proves a signature over the wrong challenge (or under the wrong network passphrase) is rejected.

signCount is parsed but never hard-gated. Synced passkeys (iCloud Keychain, Google Password Manager) report signCount = 0 or unreliable counters, so counter-based cloning detection is not viable and would break legitimate users. It is surfaced, not enforced.

RP-ID & origin binding

Passkeys are scoped to an eTLD+1, the RP-ID. Two independent checks pin an assertion to your origin:

CheckWhat it verifiesError code
verifyRpIdHashauthData.rpIdHash === SHA256(rpId): the authenticator signed for your RP-ID, not a spoofed one.RP_ID_MISMATCH
origin allow-listclientDataJSON.origin matches an allowed origin: the assertion came from a page you control.ORIGIN_MISMATCH

Multi-origin products use Related Origin Requests, a /.well-known/webauthn document listing allowed origins. The living compatibility matrix tracks its browser support as a verified row (related_origin_requests) rather than assuming it. The residual risk is structural: a passkey bound to a single domain becomes unusable if that domain is lost, which is why recovery recommends a second signer on a different domain or device.

Recovery model

There is no seed phrase to back up, so recovery is about finding the accounts a credential already controls, and adding signers safely. recover() performs a discoverable-credential assertion (no stored credential id) and resolves the credential through an indexer to every account it controls.

Credential typeSurvives device loss?Trade-off
Synced (default)Yes, via the platform cloudYou trust the platform provider (iCloud Keychain / Google Password Manager).
Device-boundNo, lost with the deviceStronger isolation, but requires a backup signer.
Multi-signerYes, any one signer recoversAdd a second passkey on-chain (add_signer); the v1 smart-wallet is multi-signer.

Add-device is an account-takeover path. A new signer can authorize the account, so you must gate add-device behind a fresh re-authentication with an existing signer, never an unauthenticated mutation. addSigner requires the existing passkey to sign the add_signer call; the styled add-device screen surfaces the trust warning.

Threat model summary

Every row maps to a backing test (security.test.ts fails the build if a cited test or anchor id does not exist) or a verified matrix row.

ThreatMitigationWhere it lives
Signature malleability (high-S)Client-side low-S normalization (S > n/2 → n−S); recommend the contract reject non-canonical S.anchors.test.ts · anchor low-s-normalization
Replay / wrong-context signingChallenge = auth-entry preimage; SDK + __check_auth reject mismatches.soroban.test.ts · CHALLENGE_MISMATCH
Non-ES256 credential (unverifiable on-chain)RS256 / other algorithms hard-fail at create-time (ES256_NOT_SUPPORTED).anchors.test.ts · anchor rs256-hard-fail
RP-ID spoofingverifyRpIdHash: authData.rpIdHash === SHA256(rpId).authData.test.ts · matrix webauthn
Origin spoofingclientDataJSON.origin allow-list (ORIGIN_MISMATCH).clientData.test.ts · matrix related_origin_requests
Ceremony auto-trigger / Safari silent rejectUser-activation guard (USER_CANCELLED).anchors.test.ts · anchor apple-user-gesture
Lost device / cleared storageDiscoverable-credential recover() → indexer resolution.ceremonies.test.ts · matrix conditional_mediation
Malicious relayer / indexerUntrusted, pluggable adapters cannot forge an authorization; at most they delay submission or mis-report a lookup.submission / indexer adapters

Not just a JS model. A real deployed v0.2 webauthn-account ran a passkey-signed XLM payment assembled and signed by @soropass/core, on mainnet and testnet: the positive run (the account's registered P-256 key) transferred XLM through the native SAC, and the negative run (a different key) failed on-chain. On mainnet, see the payment and the wrong-key rejection. On testnet, the success tx and the wrong-key rejection. Real hashes come from packages/core/scripts/transfer-e2e.ts and transfer-e2e-mainnet.ts, never fabricated.

On this page