Skip to content
Solana Devnet: test network. Documents and acceptances here are for testing, not production evidence.

Docs / Security / Threat model

Threat model

This page summarizes Stele's threat model: who might attack an agreement record, how, and what stops each attack. The model was written before the code, and every security-relevant change is reviewed against it.

Scope and assumptions#

  • Assets: the integrity of published versions, the authenticity and uniqueness of acceptances, the meaning of organization identity, the availability of evidence, users' privacy, and the confidentiality of operator and customer secrets.
  • Trusted: Solana consensus and finality, SHA-256, Ed25519 and P-256, the program (target: audited, reproducible build, multisig upgrade authority — today it is unaudited and its upgrade authority is a single key), and — only for what "verified domain" means — DNS and the domain attestor. Two things are trusted to whoever runs them: what the signing page displays, and which key is enrolled for an account (see the trust model).
  • Not trusted: Stele's API, database, indexer, relayer and web app; RPC providers; storage providers; integrators' sites; and any single party's claims about what was agreed.

Attacker profiles#

Malicious companies and employees, compromised administrators, users who later deny accepting, stolen wallets and devices, bots and Sybils, malicious frontend operators, compromised backends, rogue database administrators, API attackers with stolen credentials, phishing sites, malicious browser extensions, replay attackers, network attackers, compromised RPC and storage providers, malicious integrators, a malicious multisig signer, XSS/CSRF attackers and supply-chain attackers.

Scenario index (134 scenarios)#

Each row gives the primary defense. Where software cannot remove a risk entirely, it is listed under residual risks.

Document integrity (malicious company)#

IDScenarioPrimary defense
T01Company changes the ToS after users accepted itDocumentVersion accounts have no update or close instruction (P1).
T02Company displays one document but commits another hashStele's acceptance page and SDK component render only content whose SHA-256 the browser recomputed and matched against the on-chain account.
T03Company creates a second, similar document and claims it was the accepted oneAcceptance binds to an exact DocumentVersion via its fingerprint, which commits to organization address, document address, version number, previous fingerprint, title, label, locale, …
T04Formatting / encoding / whitespace tricks to create ambiguous hashesstele-canonical-v1 (§4): strict UTF-8, BOM stripped, CRLF/CR → LF, NFC, tabs → space, per-line trim, space-run collapse, forbidden invisible/bidi/control code points rejected, typed …
T05Company changes the content at an external URL after publishing the hashStorage is content-addressed by SHA-256.
T06Company deletes the original fileRedundant storage: Stele archive (object storage) + optional Arweave permanent copy + database copy.
T07Company changes metadata while keeping the text similarAll metadata is inside the version fingerprint, computed on-chain from the account's own fields.
T08Company backdates a ToSpublished_at/published_slot are written by the program from the cluster clock.
T09Company claims a document existed earlier than it didThe earliest provable existence of content is the publication time of the first version committing to it.
T10Company publishes many versions rapidly to create ambiguityEvery acceptance binds to an exact fingerprint.

Organization authority#

IDScenarioPrimary defense
T11Company administrator account is compromisedAdmins cannot grant ADMIN or touch other admins (owner-only).
T12Employee publishes an unauthorized ToSLeast privilege (separate PUBLISHER role).
T13One company impersonates anotherDisplay name ≠ identity.
T14Fake company registers a famous domain or brand lookalikeOnly lowercase ASCII LDH hostnames are accepted.

User disputes#

IDScenarioPrimary defense
T15User claims "I never accepted this"The record contains the user's Ed25519 signature over a message naming the organization, document, version, fingerprint, the user's own account, the network and validity window — …
T16User claims "someone else accepted this for me"We never claim human identity (P10).
T17Creating an acceptance using another user's wallet addressThe program reads the signer from the Ed25519 instruction that the runtime verified and requires the message's Account: line to equal it.
T18Backend creates an acceptance without the user signingSame as T17: the backend holds no user keys.
T19Frontend modifies the payload before signingThe wallet — not our page — displays the full message text, including organization, document, version and fingerprint.
T20Malicious site tricks the user into signing a different ToSMessage names organization + verification status + document + version + Requested by: <domain>.

Signature reuse and replay#

IDScenarioPrimary defense
T21Signature reused for another agreement.
T22Signature reused for another companyOrganization name, verification status, and (via the fingerprint) the organization address are bound.
T23Signature reused for another version of the same documentVersion label, version number (#n) and fingerprint are bound.
T24Signature replayed laterEach challenge consumes a nonce bit (shard, index) on-chain — a second recording fails with NonceAlreadyUsed.
T25Devnet signature reused on mainnetMessage includes Network: Solana Mainnet|Devnet|… read from the immutable ProtocolConfig.network_label.
T26Signature produced for another application/domain reused hereThe message begins with the fixed line Stele Protocol v1 - Accept Agreement.
T27User signs a blank or ambiguous messageWe never request signatures over blank, hash-only or free-form text.
T28User signs a hash without understandable contextThe pre-sign review screen and the wallet message both show organization, verified domain, document title, version, fingerprint, network, account, validity and requester.

Infrastructure attacks#

IDScenarioPrimary defense
T29API request modified between frontend and backendTLS everywhere (HSTS).
T30Backend database completely compromisedThe DB contains no evidence: no keys, no authority.
T31Database administrator edits acceptance recordsSame as T30.
T32RPC provider returns misleading informationThe verifier supports multiple independent endpoints and requires agreement.
T33Storage URL returns different contentHash check on every read (T05).
T34IPFS/Arweave content unavailableMirrors, receipt-embedded content, DB copy, company export.

Key lifecycle#

IDScenarioPrimary defense
T35Wallet stolen after acceptancePast records are immutable.
T36Wallet stolen before acceptanceNone cryptographic.
T37User rotates wallet/accountOld acceptances remain valid evidence for the old key.
T38Organization rotates signing authorityTwo-step owner transfer (propose_owner → accept_ownership).
T39Organization key compromisedOwner as multisig.
T40A multisig signer becomes maliciousSquads threshold (≥ 2-of-3 recommended), Squads time lock, signer rotation inside Squads without changing the Stele owner address.
T41Former owner/employee still has a keyRevoke membership (revoked_at).

Platform compromise#

IDScenarioPrimary defense
T42Frontend JavaScript is compromisedWallet-displayed message (T19).
T43DNS/domain hijackedHSTS preload, CAA records, registrar lock, DNSSEC.
T44Session hijackedHttpOnly/Secure/SameSite cookies.
T45API token stolenKeys are scoped (acceptances:read, acceptances:write, documents:read), prefixed for secret-scanning, stored hashed, revocable, optionally expiring.
T46Signature request injected into another browser sessionChallenges are bound to the requesting session/acceptance-session token and to the signer key at issuance.

Concurrency and reliability#

IDScenarioPrimary defense
T47Race conditions while publishing versionsOptimistic concurrency on-chain: the publish instruction requires version == version_count + 1 and previous_version_hash == head_hash.
T48Two admins publish simultaneouslySame as T47.
T49Transaction succeeds on frontend but indexing failsChain is the source of truth.
T50Indexer reports incorrect dataIndexer output is never evidence.
T51Blockchain transaction fails but backend marks it successfulStatus transitions require reading the transaction from RPC with meta.err == null at confirmed, and to finalized later.
T52User signs but the transaction is never submittedUI shows "Recording…" until confirmation and only then "Agreement Verified ✓".
T53User submits the same acceptance twiceNonce bit consumption (second fails).
T54Bot spams millions of acceptance recordsShards are relayer-gated (only the shard's designated relayer can record), so bots cannot self-submit.
T55Denial of service through Solana transaction spamPriority fees, retries, multiple RPC/sender endpoints, generous signed validity windows.

History hiding#

IDScenarioPrimary defense
T56Organization tries to remove historical evidenceNo close instruction for Organization, Document, DocumentVersion, Member, NonceShard.
T57Organization tries to hide a previous version from the UIStele's public pages render the full chain from version_count down to v1 by PDA derivation — not from a curated list.
T58UI intentionally shows incomplete historyHistory count is checked against on-chain version_count.
T59Explorer/backend disagrees with the blockchainChain wins.
T60Content encoding produces different hashes on different devicesCanonicalization happens once, at authoring time, producing canonical bytes that are stored and hashed.

Additional scenarios identified during design#

IDScenarioPrimary defense
A01Malicious program upgrade rewrites account stateUpgrade authority held by a Squads multisig with time lock.
A02Ed25519 instruction indirection / offset attackThe program requires: preceding instruction's program ID = Ed25519 program.
A03Fake instructions sysvar accountaddress = sysvar::instructions::ID constraint.
A04Malicious program CPIs into record_acceptanceThe program requires stack height 1 and that the instruction at the current top-level index is itself (program_id == stele), so CPI invocations are rejected (tested with a forwarding program).
A05Unicode deception (Trojan Source, invisible characters, homoglyph names)Bidi embedding/override/isolate characters (U+202A–202E, U+2066–2069), zero-width and invisible format characters (U+200B, U+2060–2064, U+FEFF), C0/C1 controls, private-use, tag and …
A06Domain expires and is re-registered by an attackerAttestations expire (≤ 400 days) and must be renewed via fresh DNS checks.
A07Nonce-bitmap exhaustion (DoS)Relayer-gated shards (only the designated relayer can consume bits).
A08Oversized names cause un-relayable transactionsStrict on-chain length limits (org name ≤ 40 B, domains ≤ 48 B, title ≤ 60 B, label ≤ 16 B) chosen so the worst-case acceptance transaction fits in 1,232 bytes.
A09Domain attestor key compromisedAttestor can only set/renew/revoke domain attestations — it cannot publish, accept, or edit history.
A10XSS via document content or namesNo HTML in content — typed blocks rendered as React text nodes.
A11CSRF against dashboard APISameSite=Lax cookies, Origin allow-list for state-changing requests, double-submit CSRF token header, JSON-only bodies.
A12Malicious integrator spoofs the SDK UIThe SDK widget renders only hash-verified content with the full review summary, and the wallet itself displays the exact message signed.
A13Company rename after publication to misleadRename is an on-chain, audited event.
A14Supply-chain attack (npm/crates/CI)Lockfiles committed.
A15Webhook spoofing / replayHMAC-SHA256 over timestamp.body with a per-endpoint secret.
A16Login message confused with acceptance message (or vice versa)Distinct, fixed first lines and structures.
A17SSRF via webhook URLs or domain verificationWebhook URLs must be https, resolve to public IPs (private/link-local/loopback/metadata ranges blocked, re-checked at connection time to defeat DNS rebinding), no redirects, short …
A18Timing attacks on secret comparisonSecrets compared via SHA-256 digests with timingSafeEqual.
A19Privacy: on-chain correlation of a user's acceptancesNo personal data on-chain — only public keys.
A20Fork/reorg between "confirmed" and "finalized"Receipts show commitment level.
A21Front-running organization creationOrganization PDA seeds include the creator's key, so nobody else can create an organization at an address someone intends to use.
A22TOCTOU between publication preparation and signingThe publish instruction carries expected_version_hash.
A23Fake "verifier" websitesOpen-source verifier library and CLI.
A24Sensitive data in logsStructured logging with redaction paths (authorization headers, cookies, tokens, secrets, signatures, emails).

Walletless signing, fee sponsorship and proofs#

IDScenarioPrimary defense
W01The database is stolen: can someone sign as a passkey user?Stele stores only AES-256-GCM ciphertext of each signing key, keyed by the passkey's PRF output, which never leaves the authenticator. No server-held or password-derived copy exists.
W02Database write access swaps a user's passkey for the attacker'sThe user's Ed25519 signature is still required and verified on-chain; the attacker has no key.
W03Stele or the organization signs "for" a walletless userImpossible as for wallets: only the key holder can sign, and the program rebuilds the message.
W04A stolen, unlocked key is used to record other messagesStele relays a passkey signer's record only with a user-verified passkey assertion over that exact message; receipts carry it.
W05A phishing site asks for the passkey signaturePasskeys are bound to the site's origin; the signed message names the requesting site; verifiers check both.
W06Offline brute force of a stored keyThe wrapping key derives from a 256-bit PRF output, not a password.
W07A browser without PRFWalletless signing is refused; the visitor is offered a wallet. Never a server-held key.
W08Email or account takeoverAdding passkeys or recovery kits requires a statement signed by the key. Losing every unlock method leads to a new key, flagged as authorized by the organization's account system — never control of the old key; past records stay valid.
W09Silent passkey changesEvery change is in the append-only audit log, sent as a webhook, and published as key statements.
W10Draining an organization's fee depositThe deposit (a nonce shard) has no private key; the program reimburses only the designated relayer, only the exact fee of a verified recording, within per-record and daily caps; only the owner withdraws.
W11Sponsorship changes who authorized a recordReimbursement runs after, and independently of, authorization; invalid records pay nothing.
W12The relayer key signs something elseOne code path, with an allowlist of the exact transaction shape, before every signature.
W13The user is made to payUsers sign messages, never transactions; the relayer pays; signer accounts need no SOL.
W14A customer is shown one order but signs anotherThe SDK hashes the statement it shows and refuses to sign unless the message names that hash; verifiers recompute it from the receipt.
W15A proof is moved to another organization or networkThe message names the organization address and chain ID; the program rebuilds both.
W16An acceptance signature is replayed as a proofDifferent message formats; shared single-use nonces.
W17Order details leakOnly a salted hash is on-chain; personal fields can be commitments; proof receipts require the organization's key or the customer's token.
W18A receipt claims passkey evidence or a fee payer it does not haveEvery claim is re-verified; altered receipts are reported invalid.
W19Biometric data leaksWebAuthn never exposes biometrics; receipts contain flags, origin and signatures only.
W20A cloned authenticatorSignature counters must increase; synced passkeys depend on their platform account (documented).

Native passkeys, batched evidence and the security review (stele/2)#

IDScenarioPrimary defense
R01A malicious Stele operator records acceptances users never madeNo user key exists outside the authenticator; every record needs a passkey signature verified by Solana's secp256r1 program and bound by the Stele program to message, site and relying party. Simulated forgeries by an operator holding the relayer key against an enrolled user are rejected. A key the operator enrolls itself is R22.
R02A malicious organization takes over a user's signerRotation needs the current passkey; recovery needs the organization's admin wallet and is shown to every verifier as an organization recovery.
R04Altered widget code shows one text and requests anotherThe SDK recomputes the challenge from chain data; published Subresource Integrity hash; isolated hosted signing page; cross-origin frames rejected on-chain.
R05A database maps an account to an attacker's keyAccount-bound keys are enrolled on-chain; the program enforces the enrollment.
R07The relayer key is stolenStrict per-instruction allowlist; it cannot pass any check that needs a user signature.
R12PhishingThe program rejects assertions whose browser-attested origin is not the requesting site.
R14A synced passkey account is compromisedNot detectable by signatures; receipts report backup state. Documented.
R15The program upgrade keySingle key today (not immutable); multisig procedure documented; verifiers re-check evidence themselves.
R16Precompile introspection tricksOne canonical layout, adjacent, self-referencing, top-level only; adversarial tests.
R17Invisible or look-alike charactersForbidden code points rejected; remaining ones flagged to publishers, readers and verifiers; per-paragraph bidi isolation.
R18A receipt is alteredEvery claim re-derived; batched leaves must prove into the anchored root.
R20A batch operator swaps or invents leavesRoot and leaf count anchored; leaves need user signatures; anchors outside the signed window are refused.
R22Whoever runs enrollment registers a software key for a user who never enrolledNot preventable by signatures (synced passkeys carry no attestation); guarantees are stated for enrolled keys only; later key changes need the current key or a labeled recovery; enrollments are public.
R23Evidence cannot be read years laterReceipts carry text, signatures and proofs; verification needs an archival Solana RPC.

Stele Gate (require_current)#

IDScenarioPrimary defense
G01A script or the CLI calls the protected instruction directly, skipping the termsrequire_current runs inside the protected program: no pass, no action (AccessPassMissing).
G02Someone presents another wallet's passThe pass's holder must be the transaction's signer, at the address derived from that signer.
G03A forged pass accountOwner must be the Stele program, with the pass type and its canonical address; only Stele writes it, and only after a verified signature.
G04A policy the attacker createdThe protected program pins its policy's address; require_current checks the policy's owner, type and address.
G05The relayer assigns a pass to a wallet that never signedThe holder must equal the signer Solana verified; the relayer cannot sign for users.
G06A gated acceptance is replayed or alteredExact message reconstruction, single-use nonce, validity window, current version only.
G07Recording through another programSignature-carrying instructions are top-level only.
G08Acting under v1 after the organization requires v2Older passes fail with AccessPassOutdated until the holder accepts v2.
G09A pass names the required number with other textEqual version numbers must have equal fingerprints.
G10A pass for another documentDocument and organization must be the policy's.
G11The victim's wallet named without its signatureUserNotSigner.
G12The requirement changes between acceptance and actionFails safe (AccessPassOutdated); the SDK re-checks and shows the new version. Not atomic — stated publicly.
G13The organization lowers the requirementOnly its role holders can, never while frozen; every change is a public PolicyChanged event with the previous value.
G14Mass new wallets drain pass rentRate-limited relay, optional server-issued acceptance sessions, allowed domains, balance alerts.
G15A protected instruction forgets the check or accepts any policyDocumented MUST rules, integration wizard, live checks and test templates. Stele cannot inspect your code.
G16The public demo's relay is abusedExactly one demo deposit for the session's own vault and policy; per-session, per-IP and daily caps; off on mainnet.

Residual risks#

Some risks are reduced but cannot be eliminated by software. They are stated openly:

RiskWhy it remainsMitigation
A key holder is not necessarily the personKeys can be stolen, shared or used under coercionStele states only what a signature proves; integrators can link acceptances to their own signed-in users
A malicious program upgradeWhoever controls upgrades could change future behaviorMultisig with time lock, reproducible builds, verifiers cross-check publication transactions; past ledger history cannot be rewritten
Every RPC provider consulted lies in the same wayThe verifier reads Solana through providersUse several independent providers, or your own node
The domain attestor's key is stolenIt could vouch for a domain falsely until rotatedLimited power, expiring attestations, live DNS re-checks by verifiers
Users do not read what they signConsent is to exact text, not proof of comprehensionShort, plain messages; a review screen before signing
Every copy of a document's text is lostSolana stores commitments, not the textReceipts embed the text; mirrors, IPFS and Arweave copies
Public keys can be linked across sitesAcceptances are public recordsNo personal data on-chain; use separate keys for privacy
Insiders misuse the roles they were givenAuthority is role-basedMultisig owners, attributable on-chain actions, alerts
Some hardware wallets cannot sign messagesA wallet limitationDocumented; an alternative signing scheme is planned
The cluster clock driftsTimestamps come from SolanaBounded skew checks; slots are recorded with times
Account recovery can produce a new walletless keyWhen a user loses every passkey and recovery kit, only the organization can vouch for themThe new key is a separate, flagged identity; the old key is retired, never recovered
Synced passkeys depend on the platform accountApple, Google and password managers sync passkeysDocumented; organizations may prefer wallets or device-bound passkeys
A compromised relayer can spend a sponsorship deposit on feesFees for throwaway recordings go to validatorsPer-record and daily caps, pause, low-balance alerts
An unlocked key on a compromised pageScript injection during the one signatureSingle-signature unlock; Stele requires the passkey assertion; strict CSP
Passkey prompts do not show the agreementWebAuthn has no "what you see is what you sign"The signing page shows only hash-verified text; the challenge is computed from it by the SDK; isolated hosted signing page
The page that requests a passkey decides what is signedThe organization controls its own siteDomain-separated statement types; rotations and recoveries are public events; prefer Stele's hosted page when the organization is not trusted
Batched acceptances can be omitted or delayedThe batch operator chooses what to anchorAs refusal to relay in direct mode; users and organizations keep the evidence
The program upgrade authority is a single keyDevnet deployment stageMove to a multisig before production (procedure published)
The first enrollment of an account is trustedThe program cannot tell a passkey from a software P-256 keyGuarantees stated for enrolled keys only; enrollments are public; prefer Stele's hosted page when the organization is not trusted
Old transactions need an archival RPCMost public RPC endpoints keep only recent historyKeep receipts and batch manifests; use an archival provider or node
Gate enforcement depends on the integrationStele cannot inspect a protected programPin the policy, call require_current in every protected instruction, keep the four bypass tests
Accept & Continue is two transactionsThe acceptance is relayed and paid by Stele; the action is the user's own transaction. Merging them would put Stele's relayer into every protected transactionThe acceptance stays recorded if the action fails; the program check fails safe
A Gate policy is the organization's decisionIt may require, raise or lower a versionEvery change is public with its previous value