How it works
How a passkey signs a Stellar transaction and a smart account verifies it on-chain — ES256-only, always low-S, no seed phrase or trusted backend.
A passkey signs your transaction; a Stellar smart account verifies it on-chain — no seed phrase, no server, no trusted backend.
- ES256-only, hardened
- Always low-S normalized
- Proven on testnet
Master flow
Device · Secure Enclave → Browser · navigator.credentials → @soropass/core → Stellar network → WebauthnAccount.__check_auth → host secp256r1_verifyThree artifacts travel the wire: the assertion { authenticatorData, clientDataJSON, signature(DER) }, the 43-char base64url challenge, and the Secp256r1Signature ScVal.
Stage 1 — WebAuthn ceremony
| Artifact | What it is |
|---|---|
authenticatorData | rpIdHash + flags + counter |
clientDataJSON | type, challenge, origin |
signature (DER) | secp256r1 signature over authData ‖ SHA256(cdj) |
payload = SHA256( authData ‖ SHA256(clientDataJSON) )The challenge in clientDataJSON binds the signature to one specific request — the root of phishing resistance.
Stage 2 — ES256-only + always low-S
We request { alg: -7 } only; anything else hard-fails on both request and response side. Then every signature is normalized to low-S: if S is in the upper half of the curve order, reflect it to n−S.
0 ───────────────[ accepted: 0 … n/2 ]───────────────│ n/2 │───────────[ rejected: n/2 … n ]───────────── n
S ──reflect──▶ n−SThe host secp256r1_verify doesn't enforce low-S, so we do it once, client-side — malleability closed before a signature ever reaches a contract.
Stage 3 — challenge binding
SorobanAuthorizationEntry → HashIdPreimage::SorobanAuthorization {
networkId = SHA256(passphrase), nonce, signatureExpirationLedger, invocation
} → XDR → SHA256 → base64url = 43-char challengeA captured signature cannot be replayed for a different transaction, nonce, or network.
Stage 4 — the Soroban wire shape
const sig = buildSignatureScVal({ authenticator_data, client_data_json, signature }); // keys alphabetical, signature 64-byte low-Spub struct Secp256r1Signature {
pub authenticator_data: Bytes,
pub client_data_json: Bytes,
pub signature: BytesN<64>,
}Stage 5 — __check_auth + secp256r1_verify
Challenge binding
clientDataJSON.challenge == base64url(signature_payload), else ChallengeMismatch.
Reconstruct the message
authenticator_data ‖ SHA256(client_data_json).
Verify
secp256r1_verify(public_key, SHA256(message), signature) — traps on invalid.
payload = SHA256( authData ‖ SHA256(clientDataJSON) )The same digest is computed in the browser (Stage 1) and re-derived on-chain (Stage 5) — that's the whole trust model.
On-chain proof — positive and negative
✓ Correct key → SUCCESS
tx f256288c… — verified on testnet. View on stellar.expert →
✕ Wrong key → TRAPS
On-chain failure, explorer-linked. Not a mock — a live testnet round-trip you can re-run.
Simulation under-budgets CPU because it skips secp256r1_verify — inflate the instruction budget before submit. Real tx hashes come from scripts/onchain-e2e.ts, never fabricated.
The living-matrix pipeline
BCD ingest → typed snapshot → CI grid → dated snapshot → diff → commitBCD ingest → typed snapshot → virtual-authenticator CI grid → dated MergedMatrixSnapshot (provenance / tier / lastVerified) → diff vs previous → commit back. See the matrix →
Compatibility
A living, machine-probed WebAuthn/passkey support matrix rendered from the real, dated SoroPass matrix-pipeline snapshot — never hand-written.
Security
The threat model for @soropass/core — low-S enforcement, challenge binding, RP-ID/origin checks, and a recovery model, each backed by a test or a real on-chain proof.