Skip to content

Threat model

This document enumerates the security-relevant decisions the Stellar Passkey Kit takes, the attacks each decision defends against, and the code path or matrix cell that pins the defence. Read it alongside docs/architecture.md (data-flow diagram) and docs/matrix.md (which platforms are verified end-to-end).

The threat model is scoped to what the kit can attest to. Things explicitly outside this scope: the underlying Stellar consensus, the WebAuthn attestation chain, browser-internal security bugs, the user's choice of authenticator hardware, and any TLS-termination proxy in front of the user's RPC endpoint.

1 — Origin-binding

The strongest anti-phishing primitive WebAuthn provides is that the browser only releases a credential to the exact rpId it was registered for. The kit hard-codes the rpId as a constructor parameter; the SDK never accepts an attacker-supplied rpId. WebAuthn itself enforces that rpId is either the document origin's hostname or a registrable parent suffix; a page at evil.com cannot drive an assertion for passkey-demo.opak.ai.

The kit's connectPasskey and signTransaction paths pass allowCredentials: [{ id: credentialId }] to scope the assertion further to a specific credential — so even a same-origin XSS that reaches navigator.credentials.get cannot pick a different wallet.

Code: packages/core/src/sign.ts (the defaultSigner's allowCredentials), packages/core/src/connect.ts, packages/core/src/recover.ts. Matrix: every row's rpId column (in notes).

2 — Challenge replay

The WebAuthn challenge we sign is the SHA-256 of the HashIdPreimage::SorobanAuthorization for the specific auth entry being authorised. That preimage includes the networkId, the auth-entry nonce, the signatureExpirationLedger, and the full invocation tree. Two attacks the choice defends against:

  • Cross-tx replay. Distinct transactions have distinct invocations, distinct nonces, and distinct ledger heights; the preimage hash is unique per (tx, contract) pair. A signature recorded from one tx is not valid for any other.
  • Same-tx replay. The on-chain __check_auth rejects any auth entry whose signatureExpirationLedger ≤ current ledger. The kit sets that field to latest + 60 (~5 min on testnet) before computing the preimage — see packages/core/src/sign.ts.

The SDK additionally validates that the assertion's clientDataJSON.challenge (which the authenticator computes independently from what we passed in) matches the preimage hash byte-for-byte. A man-in-the-middle that swaps the challenge between the JS call and the authenticator would force the clientDataJSON.challenge string to differ from the preimage we hashed, and the kit rejects with CHALLENGE_MISMATCH. Pinned by createOptions.test.ts and the regression suite under packages/core/test/regressions/.

3 — Signature malleability

The on-chain secp256r1_verify host function deliberately rejects high-S signatures — see stellar/stellar-protocol issue #1435. Without that, every ECDSA signature would be malleable on the wire (flip s → n - s and the signature still verifies under naive ECDSA), which feeds straight into double-spend cores like cosmos-sdk #9723. The host therefore enforces "exactly one canonical signature per (message, key) pair".

Apple Safari (Touch ID, Face ID) emits high-S signatures roughly half the time. The kit normalises every signature to low-S before shipping it into __check_auth, so the user never sees a verification failure caused by a properly-emitted Apple signature.

Code: packages/core/src/sig/lowS.ts. Regression pins: apple-low-s.test.ts, apple-high-s.test.ts (5 pinned fixtures under packages/core/test/fixtures/apple-high-s/). Matrix cells: safari/macOS, safari/iOS — both lowS: normalized.

4 — Public-key encoding attacks

The contract stores a 65-byte SEC-1 uncompressed P-256 public key — 0x04 ‖ X ‖ Y — keyed by the WebAuthn credentialId. The kit validates the SDK side of that encoding at parse time:

  • 0x04 prefix byte must be present.
  • X and Y are exactly 32 bytes each.
  • Compressed encodings (0x02 / 0x03 prefix) are rejected before they reach the chain.

These checks live in packages/core/src/webauthn/createOptions.ts and the attestation parser at packages/core/src/webauthn/attestation.ts; their tests live alongside in attestation.test.ts.

Validation invariants the SDK relies on the contract for: the contract refuses to add a signer whose pk.len() != 65, and refuses any pk[0] != 0x04. Together with the SDK-side checks above, no non-uncompressed encoding ever reaches the storage map.

5 — Cross-origin / iframe lockdown

WebAuthn's permission policy default is to block publickey- credentials-create and publickey-credentials-get from cross-origin iframes. The kit relies on the default — there is no Permissions- Policy override in the demo or the smoke harness, and we do not ship JS that asks the host page to relax the policy.

Practical implication for adopters: if you intend to embed the demo (or your own kit-driven flow) inside a third-party site, you must explicitly grant the policy on the parent page:

html
<iframe allow="publickey-credentials-get; publickey-credentials-create"
        src="https://passkey-demo.opak.ai"></iframe>

Without that opt-in header, the ceremony fails with NotAllowedError; the kit surfaces it as USER_CANCELLED so the host can fall back gracefully. The reference demo (apps/demo/index.html) and the smoke harness (apps/smoke/index.html) both run as top-level documents — never inside iframes — so this never becomes a concern in the project's own deliverables. Matrix: every row's notes field.

6 — Recovery social-engineering

recoverPasskey is the cold-start ceremony — what runs when the user has no localStorage on this device. The flow is:

  1. Drive a discoverable WebAuthn assertion (no allowCredentials) so the user explicitly picks a credential from the OS chooser.
  2. Use the returned rawId to query the Soroban RPC for ("sw_v1","add", credentialId) events — every matching event's emitting contract is a wallet bound to that passkey.

Two social-engineering attacks the design defends against:

  • Phishing the contract address. The kit never accepts a contract id from the user (or from any URL parameter). The id is always re-derived from on-chain events keyed by the user's chosen credential. An attacker cannot trick the user into recovering into a wallet they don't actually own.
  • Bypassing the ceremony. There is no recoverPasskey({ credentialId }) overload that skips the WebAuthn step. The user is always asked to authenticate against their device's secure enclave; the SDK only continues if that succeeds. Code path: packages/core/src/recover.ts (single export, no skip-mode constructor option).

Matrix: chromium/macOS, safari/iOS.

7 — Authenticator counter handling

WebAuthn defines a signCount field that the authenticator MAY increment per assertion. Servers traditionally use it as a clone- detection signal: if a credential's reported signCount ever goes backwards relative to the last-seen value, the credential may have been cloned.

The kit does not enforce a counter, and the on-chain contract cannot. Soroban contracts have no out-of-band mutable storage that ratchets monotonically without a transaction-side update, and ratcheting per-signer state on every signature would burn ledger storage on every ceremony. We document this as an explicit out-of-scope decision rather than a hidden gap.

The mitigating factor: modern platform authenticators (Apple Secure Enclave, Windows TPM, Android StrongBox, YubiKey) treat the private key as non-extractable. Cloning the key material requires physical access to the device's secure element + a working side-channel attack on that hardware; both are out of scope for an SCF-track public-good wallet kit. Real apps that need counter-based clone detection should pair the kit with their own off-chain ratchet, or wait for a future Stellar host function that natively ratchets per-signer storage at the protocol level.

Note in code (no enforcement): packages/core/src/sign.ts does not read assertion.signCount. The kit's threat model assumes a trusted authenticator.

8 — Apple cloud-keychain syncing implications

iCloud Keychain syncs passkeys across the user's Apple devices. That's a usability win — a passkey registered on a Mac is available on the user's iPhone without re-registration. It also has security implications worth documenting:

  • The same public key appears on multiple devices. The contract sees one signer and accepts a signature from any device the user has signed into iCloud Keychain on.
  • If the user's Apple ID is compromised, the attacker can extract a device-bound signing capability for every wallet the user has registered with that ID. The kit's defence is therefore the user's iCloud security posture, not anything the kit can enforce.
  • Disabling iCloud Keychain on a device DELETES the local passkey copy but does not affect on-chain state — the user can still sign from any other synced device. To revoke the credential entirely, call the contract's remove(credentialId) admin operation.

Equivalents on Android (Google Password Manager passkey sync) and Windows (no first-party cross-device sync without Microsoft account PIN reset) get similar treatment in the matrix notes. The kit's API surface does not change; the risk surface depends on the platform.

This document references the following SDK source files (≥ 5 per the YK-282 validation gate):

  1. packages/core/src/sign.ts
  2. packages/core/src/connect.ts
  3. packages/core/src/recover.ts
  4. packages/core/src/webauthn/createOptions.ts
  5. packages/core/src/webauthn/attestation.ts
  6. packages/core/src/sig/lowS.ts

Plus four regression / fixture references:

MIT — SCF-43 RFP submission (2026). Status: pre-1.0.