epistery
v2.4.0
Published
Epistery brings blockchain capabilities to mundane web tasks like engagement metrics, authentication and commerce of all sorts.
Readme
Epistery
Epistemology is the study of knowledge. An Epistery, it follows, is a place to share the knowledge of knowledge.
Epistery is the identity foundation for web applications. It gives a host one thing it can trust on every request — a cryptographically proven address — and binds that address to an on-chain IdentityContract when the user wants a durable multi-device identity. Everything else (data, ACLs, naming, content) is the host application's concern, not epistery's.
Status — sealed contract, v1.2 (2026-05-27). This README defines what epistery is responsible for, what it is not, and its interface. Code is held to this document. The
agent.solsurface (data wallets, approvals, whitelist / lists / roles, name registry, notabot) was removed in v1.2; see the wiki archives ([[NotABot]], [[Whitelist]], [[DataWallets]], [[Approvals]], [[ContractStandards]]) and git tagepistery-pre-identity-refactorfor the retired implementations. The dated Known divergences section at the end lists where the current code still fails this contract. If behavior and this document disagree, that is a bug in the code, not the doc.
Responsibility (what epistery OWNS)
Epistery is the single owner of:
- Identity — proving who a request is from. The proof is a wallet
signature, carried either by a short-lived signed session cookie (
_epistery, established via the/connecthandshake) or a per-requestBotsignature. The result is a trusted address onreq.episteryClient. - Identity binding — relating a device key (rivet) to an IdentityContract,
verified on-chain (
isAuthorized). When bound, epistery presents the contract as the identity. - Key custody (client) — generating and protecting the user's signing key in the browser (non-extractable; see Key custody).
- Domain/server wallet & config — the host's own wallet and the path-based
~/.episteryconfiguration. - FIDO blob storage — server-side backup of WebAuthn-PRF-wrapped rivet keys so they survive iOS ITP IndexedDB eviction.
No consumer may bypass, re-derive, or duplicate any of these. In particular: a downstream service never trusts a client-supplied identity header and never re-implements identity resolution.
What Epistery DOES
- Authenticates every request to a trusted address (
req.episteryClient), via signed_episterysession cookie orBotsignature. ABotsignature covers the request it authorises — method, URI, audience host and a digest of the body, with a timestamp and single-use nonce — so it is a message signature, not a bearer token. The bytes are built byclient/bot-auth-message.mjs, the single definition shared by every signer and the verifier. - Mints/loads wallets for the browser (rivet / FIDO / web3) and server (per-domain).
- Binds a device to an IdentityContract and verifies that binding on-chain.
- Serves client libraries at
/lib/*(witness.js,wallet.js,ethers.js, …) and contract artifacts at/artifacts/*for consumers. - Persists FIDO blobs (
/fido/blob) — encrypted, PRF-wrapped rivet keys for WebAuthn-backed identities. - Backs a CLI (
@epistery/cli) for stateless bot-authenticated requests (curl), the Streamable-HTTP MCP bridge (mcp), domain initialization, and basic info.
What Epistery does NOT do
- Does not store application data. Apps own their storage; epistery records identity, not your documents.
- Does not manage contracts. It binds keys to existing contracts and
verifies the binding on-chain — it does not deploy, write to, or own
application contracts. Contract creation and on-chain ACL/state live in host
contracts (e.g.
IdentityContractV3.sol,DomainContract.sol). - Does not define application- or session-level ACLs. Authorization is the host's job, evaluated against the trusted address epistery provides.
- Does not run a name registry. Per-domain naming is a relay service; epistery carries no name → address mapping.
- Does not accept a client's claim of identity. The only identity is the one
epistery itself proved (
req.episteryClient). There is no "I am contract X" header. Contract identity claims are verified on-chain at/connectand sealed into the signed cookie. - Does not let downstream code adjudicate auth. Re-deriving identity or re-checking signatures outside epistery is a contract violation.
The trust contract: req.episteryClient
The attach middleware sets exactly this on each request (or leaves it undefined):
| Field | Meaning |
|-------------------|---------|
| signerAddress | The signer. The rivet whose signature was verified (cookie session or Bot). Always non-null. The only thing the client can assert by itself. |
| contractAddress | A verified contract claim. When the client claimed an IdentityContract at /connect, this is that contract, verified on-chain via isAuthorized(contractAddress, signerAddress). null when no claim. |
| identityAddress | The canonical identity. Derived: contractAddress || signerAddress. This is what host ACLs evaluate against. Always non-null. |
| publicKey | The signer's public key. |
| authenticated | Whether the session/handshake completed. |
| authType | "bot" for Bot-signed requests; "cookie" for session-cookie. |
The three roles are kept separate on purpose. signerAddress is a fact the
client proves; contractAddress is a claim the server verifies; identityAddress
is the server's derivation. The wire never asks the client to pick which role
its address plays.
Rule for consumers: authorize against identityAddress. The signer vs.
contract distinction is available but rarely your concern.
app.get('/thing', (req, res) => {
const me = req.episteryClient; // the ONLY source of identity
if (!me?.authenticated) return res.status(401).end();
// authorize against your host's contracts / policy using me.identityAddress
});The wire (POST /connect)
The handshake body carries facts only:
| Field | Required | Meaning |
|-------------------|----------|---------|
| signerAddress | yes | The rivet. Must equal the address recovered from signature over message. |
| signerPublicKey | yes | The signer's public key. |
| contractAddress | yes | An IdentityContract claim, or null. When non-null, the server verifies it on-chain via isAuthorized(contractAddress, signerAddress). |
| challenge, message, signature | yes | Proof of signer (see Identity & key custody). |
| walletSource | no | "rivet" / "fido" / "web3" / etc. — informational. |
There is no clientAddress, no identityAddress on the wire. Either of those
would force the receiver to guess which role the address plays. The server
derives identityAddress from the two facts and exposes it on
req.episteryClient; the client never tells the server what its identity is.
Tab sessions — two tabs, two rivets
A browser origin can hold several rivets. Which one is active is a property of the tab, not of the device, so two tabs can be two different identities at once and neither disturbs the other.
| Where | What it holds |
|-------|---------------|
| sessionStorage["epistery.tab"] | This tab's id. Per-tab by construction; survives reload, dies with the tab. |
| sessionStorage["epistery.wallet"] | The wallet id this tab is being. Outranks the device default. |
| localStorage["epistery"].defaultWalletId | The device default — what a brand new tab starts as. Last switch wins. |
| _epistery cookie | A jar of proven sessions, one slot per tab. Still httpOnly; still facts only. |
A request says which slot it means:
X-Epistery-Tab: <tab>— added to every same-originfetchbyclient/tab.js's shim.?_tab=<tab>— for a WebSocket upgrade, which cannot carry a header.- Nothing at all — resolves to the jar's default slot, the one written by the most recent key exchange.
Two rules make the isolation real rather than decorative:
- A request that names a slot the jar does not hold resolves to no session, never the default. Falling back would hand a tab an identity it never proved; instead the tab hand-shakes for itself.
- The tab header is sent only after that tab has performed a key exchange.
Until then a request carries no tab header and reads the default slot — which
is exactly the pre-tab behaviour, so an older client, a
curl, and a page that never hand-shakes all keep working unchanged.
sessionFromJar(req) is the single resolver; the attach middleware and
resolveClient() both call it, so an ordinary request and a WebSocket upgrade
can never disagree about who a tab is.
Two limits, not papered over. A document navigation cannot carry a header and
a cookie cannot be scoped to a tab, so the HTML request for a page resolves
against the default slot — tab identity covers the API surface, not the page
load. And "Duplicate tab" copies sessionStorage, so the copy starts out
sharing the original's slot until it switches; opening a new tab normally gives a
fresh, independent one.
Identity & key custody
The browser signing key is created and protected by epistery. Custody depends on wallet type:
| Wallet | Key custody | Security property |
|----------------|-------------|-------------------|
| RivetWallet (default) | secp256k1 private key encrypted at rest by a non-extractable AES-GCM CryptoKey held in IndexedDB (WebCrypto). Only ciphertext + a key id are persisted. Refuses to create the wallet if WebCrypto is unavailable — no plaintext fallback. | The signing key cannot be exported — the core "unextractable device key" property. |
| FidoWallet | Rivet key wrapped by a WebAuthn PRF secret (Secure Enclave); blob optionally backed up server-side via /fido/blob (survives iOS ITP eviction). | Key release gated by platform authenticator. |
| Web3Wallet | External plugin (e.g. MetaMask) holds the key. | Custody is the plugin's. |
A device can hold multiple independent rivets (Browser/FIDO/Web3 are all rivets — different ways of presenting a device-locked signing key). This is how the system enforces one-key-one-identity without a hard cross-context check: the user mints another isolated rivet rather than pointing one key at two contracts.
Paper backup rivets. A rivet can also be derived from a secret the user keeps
offline — either a generated 12-word BIP39 phrase or an arbitrary passphrase
(e.g. peanut butter, stretched with PBKDF2). Its address is authorized as an
ordinary signer on the IdentityContract, but its key lives only on paper, so it
has no agency on any device until it is used to recover. On recovery the
secret re-derives the key and it is re-homed as a normal RivetWallet
(non-extractable, encrypted at rest) on the recovering device — no new wallet
type, no secret persisted in the clear. This is the sovereign counterpart to
server-side FIDO blob backup: nothing is escrowed; the human holds the only copy.
A passphrase must be at least 10 characters (letters, numbers, spaces and
punctuation); its strength beyond that is the user's choice. See the paper
helpers under Client API below.
Server/domain wallets live in ~/.epistery/<domain>/config.ini as cleartext
mnemonics — there is no browser-style non-extractable key on the server side, and
even once keys move into device hardware this stays as the fallback. The floor is
filesystem-level: epistery creates every directory in that tree 0700 and every
file 0600, tightens a file that predates the rule as it writes it, and warns on
stderr when it loads key material from a file other users can read.
epistery permissions # audit ~/.epistery (exit 1 if anything is group/other-readable)
epistery permissions --fix # tighten itauditTree / secureTree are exported from the package so a host can run the
same check at startup.
The /connect handshake & contract binding
- The client
Witnesssigns a challenge with its rivet and POSTs to/connectwithsignerAddress(the rivet),signerPublicKey, andcontractAddress(the claim, ornull). - The server verifies the signature recovers to
signerAddress. IfcontractAddressis non-null, it callsIdentityContract.isAuthorized(signerAddress)on-chain — the chain is truth. - On success it issues the signed
_episterycookie, recordingsignerAddressand (if verified)contractAddress. The auth middleware then exposesreq.episteryClient.identityAddress = contractAddress || signerAddress.
A rivet is bound to a contract client-side via wallet.upgradeToContract(contract)
— afterward the wallet's derived identityAddress is the contract while
signerAddress is still the rivet. A fresh key exchange follows; the witness
short-circuits when (and only when) the cookie's identityAddress already
matches the wallet's identityAddress. The rivet→contract relation in
localStorage is not cryptographically sealed in the browser — but spoofing it
is useless: the contract knows its authorized signers and can't be spoofed; the
on-chain verification at /connect is the gate.
HTTP interface
Mounted under rootPath (default /.well-known/epistery, RFC 8615):
| Path | Methods | Purpose |
|------|---------|---------|
| / | GET | Server status JSON (Witness.connect probes this for chain/provider info). No HTML UI. |
| /lib/:module | GET | Client libraries (witness.js, wallet.js, client.js, ethers.js, …) |
| /artifacts/:file | GET | Contract ABIs/artifacts |
| /connect | GET / POST | Session check / key-exchange handshake (sets _epistery; on-chain isAuthorized verify for contract claims) |
| /create | GET | Wallet creation helper |
| /auth/account/claim, /auth/dns/claim, /auth/account/check-admin | GET/POST | Domain claiming & admin checks |
| /identity/prepare-add-rivet | POST | Unsigned tx for adding a rivet to an existing IdentityContract (client signs, then /data/submit-signed-style broadcast) |
| /domain/initialize | POST | Initialize a domain wallet |
| /fido/blob, /fido/blob/:credentialId | POST/GET | PRF-wrapped rivet key blob storage |
Server API
import { Epistery, Config } from 'epistery';
const epistery = await Epistery.connect({
authentication: async (clientInfo) => { /* return profile or null */ },
onAuthenticated: async (clientInfo, req, res) => { /* post-auth hook */ },
});
await epistery.setDomain('mydomain.com');
await epistery.attach(app); // mounts middleware + routes under rootPathThe clientInfo passed to both hooks has the same shape as
req.episteryClient: { signerAddress, contractAddress, identityAddress,
publicKey } (plus authenticated and profile after authentication
resolves). Authorize against identityAddress.
Epistery (exported as EpisteryAttach): connect, setDomain, attach,
resolveClient(req) (auth resolution for non-middleware contexts, e.g. WebSocket
upgrades), buildStatus, routes.
Also exported: auditTree, secureTree, Config, chainFor, registerChain, configuredChains,
defaultChainId, Chain.
The core Epistery static API (src/epistery.ts): initialize, createWallet,
getStatus, handleKeyExchange (consumed by /connect),
prepareAddRivetToContract (unsigned tx builder), submitSignedTransaction
(generic broadcaster for client-signed transactions — this is the
"server-requests-signature, interactive wallet (FIDO/MetaMask) signs, then submit"
path).
Config
Path-based ini config under ~/.epistery (src/utils/Config.ts):
import { Config } from 'epistery';
const config = new Config();
config.setPath('/'); // ~/.epistery/config.ini (root)
config.load();
config.data.profile.email = '[email protected]';
config.save();
config.setPath('/mydomain.com'); // ~/.epistery/mydomain.com/config.iniMethods: setPath, getPath, load, save (+ data).
Client API (Witness)
Served at /.well-known/epistery/lib/witness.js:
import Witness from '/.well-known/epistery/lib/witness.js';
const witness = await Witness.connect({ rootPath: '/' }); // creates/loads wallet, runs key exchangePublic surface: connect, performKeyExchange, getWallets, getStatus,
addBrowserWallet / addFidoWallet / addWeb3Wallet, setActiveWallet,
removeWallet, updateWalletLabel, bindToEpisteryIdentity (cross-host identity
ferry). Wallet classes: RivetWallet, FidoWallet, Web3Wallet; binding via
wallet.upgradeToContract.
setActiveWallet(id) makes a rivet active in the calling tab and the device
default for tabs opened after it; other open tabs keep the rivet they loaded
with. Follow it with performKeyExchange() so the server session in this tab's
slot moves too. setDefaultWallet is the pre-tab name for it, kept working.
getWallets() reports both facts per wallet — isActive (what this tab is
being) and isDefault (what a new tab starts as) — plus activeWalletId and
tabId. See Tab sessions.
Paper backup (BIP39 phrase or passphrase):
| Method | Purpose |
|--------|---------|
| witness.generatePaperPhrase() | A fresh 12-word BIP39 phrase to show the user once (the only copy). |
| witness.paperRivetAddress(secret) | Resolve a phrase or passphrase to { address, publicKey } — authorize that address as a signer (via your on-chain addRivet) to register a backup. No wallet is stored. |
| witness.recoverPaperIdentity(secret) | Re-derive the rivet from the secret, re-home it as a durable non-extractable RivetWallet, and install it live + default. Follow with performKeyExchange and contract discovery/adopt to re-root on the identity. |
The underlying derivation is RivetWallet.paperPrivateKeyFromInput(secret, ethers):
a standard-length valid BIP39 phrase is HD-derived (ethers default path); any
other non-empty string is a passphrase, PBKDF2-stretched (fixed app salt, 210k
iterations, SHA-256). Both are case- and whitespace-normalized, so the same
secret always resolves to the same address.
Identity properties on every wallet — the canonical surface for client code deciding "who am I right now":
| Property | Meaning |
|-----------------------|---------|
| wallet.signerAddress | The rivet — the address we sign with. |
| wallet.contractAddress | The bound IdentityContract, or null. |
| wallet.identityAddress | Derived: contractAddress || signerAddress. What host UI and ACLs should reference. |
CLI
The epistery command is its own package, @epistery/cli:
npm install -g @epistery/cli
epistery initialize -c polygon localhost
epistery curl https://api.example.com/data
epistery mcp https://epistery.com/p/wiki/<owner>/<session> # a session, as its own memberIt uses this package's public surface (CliWallet, Config, chain selection, key-file
modes), and it serves a console session's tools on the device through
@epistery/plugins — which is why it is not part of this package, which servers attach
as middleware and keeps to identity.
Chains
Each EVM chain is a Chain object owning its RPC, fee policy, and gas strategy.
Only chainId is required; everything else comes from the class. Use
chainFor({ chainId }); add a chain by extending Chain + registerChain().
A wallet's chain is chosen at epistery initialize --chain <id|alias> time and
stored in its domain config; epistery set-chain <domain> <chain> moves an
existing domain (keeping its wallet), and epistery set-default-chain sets what
new wallets get (Polygon mainnet out of the box).
See src/chains/README.md.
Versioning & local development
- Consumers depend on the published package:
npm install epistery@latest. - Local cross-package work installs a temporary relative path
(
npm install ../../rootz/epistery) for testing only. - Publishing to npm and any deployment is a deliberate, human-performed step. No tooling or agent publishes, bumps versions, or deploys on its own.
Known divergences (audit)
Where the code currently fails the contract above. Dated; remove as fixed.
Resolved in v1.2 (2026-05-27 identity-only refactor)
- Plaintext private keys.
BrowserWallet(extractable-key legacy wallet) is removed;RivetWalletWebCrypto fallback now throws rather than silently storing a plaintext key. No code path persists a cleartext signing key. agent.solsurface. Data wallets (/data/*), approvals (/approval/*), whitelist (/whitelist/*), on-chain lists/roles (/lists,/list), name registry (resolveName/setAddressName),notabot, contract creation (/identity/prepare-deploy),contracts/directory — all removed. epistery is now identity + storage/config + FIDO blob only.- Client header trust path. Removed at the server boundary in v1.2: the
middleware no longer reads
x-identity-contract; identity is the verified identity epistery itself proved.
Resolved in v1.2 follow-up (2026-05-28 naming cutover)
- Ambiguous identity vocabulary; no in-session rivet→contract upgrade. The
wire used
clientAddress(alternately the signer or the identity), the server reconstructed which-was-meant on the fly, andWitness.performKeyExchangeshort-circuited by comparing the cookie's address to the signer — so a device that already had a rivet cookie could never have its session re-issued as contract-bound. Replaced with three distinct names everywhere:signerAddress(fact, asserted),contractAddress(claim, server-verified on-chain), and the derivedidentityAddress(=contractAddress || signerAddress, server-only). The witness short-circuits when (and only when) the cookie'sidentityAddressmatches the wallet'sidentityAddress. The pre-cutover wire shape (clientAddress/clientPublicKey) is removed without aliases — old consumers fail at the handshake instead of silently degrading.
Resolved in v2.3.0 (2026-09-06 bot-auth message binding)
Botauth was a bearer token.createBotAuthHeader()signed the fixed string"Rhonda Bot Authentication - <ISO ts>"and the server checked only that the signature recovered the claimed address — no method, URI, audience, body digest, freshness or replay check. One captured header authorised any endpoint, on any epistery host, with any body, indefinitely. The signed bytes now come fromclient/bot-auth-message.mjs(same pattern asstorage-message.mjs) and cover method + URI + audience host + body digest + timestamp + nonce. The pre-binding wire is removed without an alias: an old signer fails at the header rather than degrading silently.- Duplicated identity resolution.
resolveClient()and theattach()middleware each carried their own copy of bot verification. Both now call the single exportedverifyBotAuth()— the README's rule that no consumer may re-derive identity applies to epistery itself first.
Outstanding
Downstream identity bypass (consumer:
epistery.app). Consumers have asserted contract identity via a spoofablex-identity-contractheader + localStorage instead of consuming the verified_episterycookie. Now unblocked by the cutover above: the consumer's adopt path should callwallet.upgradeToContract(C)+Witness.performKeyExchange()and read identity fromreq.episteryClient.identityAddress.Wallet-internal
addressfield still flips on upgrade.RivetWallet.upgradeToContractstill overwriteswallet.addresswith the contract address (the original rivet survives aswallet.rivetAddress). The newwallet.signerAddress/wallet.identityAddressgetters cover the boundary, but every internal caller ofwallet.addressreads an overloaded value. Phase 1b: rename the persistence shape (with one-time IndexedDB migration so existing user wallets keep working) and convert call sites.PrepareTransactionRequest/Responsetypes. Reference removedagent.soloperations (write/transferOwnership/createApproval/ etc.); imported but no longer consumed. Delete in the next dead-code sweep.
License
MIT — see LICENSE.
Links
- Repository: https://github.com/rootz-global/epistery
- See @epistery/cli, Architecture.md, SESSION.md
