@crediolabs/agent-web-wallets
v0.1.0
Published
Browser-resident agent wallet for Stellar dApps. A Stellar Wallets Kit module backed by a non-extractable WebCrypto key.
Readme
@crediolabs/agent-web-wallets
A Stellar Wallets Kit module that lets an AI agent hold a browser-resident key and drive a dApp with ordinary UI interaction. No extension, no popup, no human approval.
Security
Removing the popup removes the last human check. What replaces it is smaller than people will assume, so it gets written down here in plain words.
The key is stored in IndexedDB as a non-extractable WebCrypto CryptoKey.
It survives reloads and tab closes, and its raw bytes can never be read back
by JavaScript - exportKey rejects.
What that buys: script running on the origin (XSS, a malicious dependency, a compromised CDN) cannot steal the key. Compromise is bounded to the session rather than permanent and portable.
What it does not buy: it is not a policy boundary. Any script on the
origin can ask the key to sign while the page is open. isAvailable()
returning false only hides the module from the connect dialog - the
module id is public and any caller may select it. Do not treat the hidden
module as a security control.
Treat the balance of the key you put here as the blast radius. Keep it
small, and call clearAgentKey() when the run ends.
Caveats you should know before using this
Encoding: signedMessage and signedAuthEntry are base64
SEP-43 contradicts itself - the TypeScript interface for signMessage and
signAuthEntry declares the return as a hex string, while the prose says
base64. We chose base64 for internal consistency (signAuthEntryXdr already
receives its input as base64). If a counterparty expects the SEP-43 hex
shape, decode with a base64-to-hex conversion at the edge before
comparing.
signAuthEntry is proven equivalent to the reference signer, not verified on-chain
signAuthEntry has never been run against a live Soroban host. It is
proven byte-identical to the signature produced by stellar-sdk's own
contract.basicNodeSigner(...).signAuthEntry(...) on the exact wire format
AssembledTransaction sends - the outer xdr.HashIdPreimage union
encoded as base64. The two halves are not the same:
- the proof: same input, same output, byte for byte, against the same
reference implementation that ships in
@stellar/stellar-sdk. - the limit: no Soroban host has ever accepted the resulting signature. Treat the on-chain path as unproven until exercised end to end.
Key zeroing is partial: we zero our copies
Keypair.fromSecret(secret) retains the raw seed inside the Keypair
object for the lifetime of the call, with no public wipe API. The
secret string the caller passed in is an immutable JavaScript string we
cannot erase. What setAgentKey zeroes is the buffers it allocated
itself (the raw seed buffer and the PKCS#8 encoding it derives from it).
The honest claim is "we zero our copies", not "the secret leaves no trace
in the heap". Do not load this key from a context that captures the
secret in long-lived closures, error logs, or dev tools.
isAvailable() hides, it does not prevent
isAvailable() returns false until setAgentKey has run, which keeps
the module out of a human user's Stellar Wallets Kit connect dialog. That
is a UX affordance for agent-driven flows. It is not a security control:
AGENT_WALLET_ID is "agent-web-wallet", it is public in this README
and in src/stellar/module.ts, and any script on the origin can select
it directly with kit.setWallet("agent-web-wallet").
Storage: IndexedDB or nothing
IndexedDB is the only browser store that can hold a non-extractable
CryptoKey (it is structured-cloneable; localStorage and
sessionStorage only take strings, which would put the key in plaintext
where any script on the origin can read it). When IndexedDB is
unavailable, setAgentKey throws StorageUnavailableError:
- Firefox private windows expose no IndexedDB by design.
- Partitioned third-party iframes (Safari ITP, cross-site iframes) have no IndexedDB access.
- Chrome with third-party cookies blocked can throw a
SecurityErrorfromindexedDB.open()rather thanundefined. Both leave the module unusable; callers must catch and surface the error.
Network passphrase is pinned at module construction
new AgentWalletModule({ networkPassphrase: Networks.PUBLIC }) fixes the
passphrase for the lifetime of that instance. A conflicting per-call
passphrase is refused with NetworkMismatchError (transaction envelope
signing) or detected against the embedded networkId and refused with
NetworkMismatchError (Soroban auth entry signing, where the preimage
carries its own network identity).
Why this matters: a transaction envelope carries no network identity of its own - the passphrase is supplied alongside it. A signature produced under a wrong-but-valid passphrase is byte-valid as an ed25519 signature but is bound to a network the recipient will not accept. With a human approval popup, the human notices the wrong network name and cancels. This wallet has no popup, so no one would notice. Refuse the request at the boundary instead.
Install
npm i @crediolabs/agent-web-walletsPeer dependencies:
@stellar/stellar-sdk(>= 14)@creit.tech/stellar-wallets-kit(>= 1.9.1)
Use
The agent places a key once through whatever channel it has for out-of-band calls to the page, then drives the dApp with ordinary UI interaction. No special signing channel; the dApp uses the kit exactly as it would for any other wallet.
import {
StellarWalletsKit,
WalletNetwork,
allowAllModules,
} from "@creit.tech/stellar-wallets-kit";
import { AgentWalletModule } from "@crediolabs/agent-web-wallets/stellar";
import { setAgentKey, clearAgentKey } from "@crediolabs/agent-web-wallets";
const kit = new StellarWalletsKit({
network: WalletNetwork.PUBLIC,
modules: [
...allowAllModules(),
new AgentWalletModule({ networkPassphrase: WalletNetwork.PUBLIC }),
],
});
// The agent does this once, through its automation channel:
// await setAgentKey("S...");
// then drives the app with normal clicks; the kit now resolves every
// signing request without a popup.
// At the end of the run, remove the key:
// await clearAgentKey();The key persists across reloads and tab closes until clearAgentKey()
runs. That is the deliberate consequence of choosing IndexedDB over
sessionStorage - see the Security section above.
API surface
From @crediolabs/agent-web-wallets (the package root):
| export | purpose |
| --- | --- |
| setAgentKey(secret) | import a Stellar secret seed, store a non-extractable CryptoKey, return the derived address |
| clearAgentKey() | remove the stored key from IndexedDB |
| getAgentAddress() | read the address stored alongside the key (or null) |
| hasAgentKey() | whether a key is stored in this browser; what the module's isAvailable() delegates to |
| signPayload(bytes) | sign arbitrary bytes with the session key (raw ed25519 R||S) |
| NoAgentKeyError | no key in this browser |
| Ed25519UnsupportedError | WebCrypto Ed25519 missing on this browser |
| StorageUnavailableError | no IndexedDB in this browsing context |
| NetworkMismatchError | per-call passphrase disagrees with the pinned one (or disagrees with the Soroban preimage's networkId) |
| SignerMismatchError | per-call address does not match the agent key in this browser |
| InvalidAuthEntryPreimageError | signAuthEntryXdr was given a non-Soroban HashIdPreimage variant alongside an expected network passphrase |
From @crediolabs/agent-web-wallets/stellar:
| export | purpose |
| --- | --- |
| AgentWalletModule | the SWK ModuleInterface implementation |
| AGENT_WALLET_ID | the string "agent-web-wallet", the SWK product id |
| signTransactionXdr(envelopeXdr, networkPassphrase) | sign a base64 transaction envelope |
| signAuthEntryXdr(preimageBase64, expectedNetworkPassphrase?) | sign a Soroban auth entry preimage |
| signMessageBytes(message) | sign a UTF-8 message |
signPayload and the three sign*Xdr helpers are the building blocks for
non-SWK consumers - the module wires them up to the SEP-43 surface.
Browser requirements
- WebCrypto Ed25519.
crypto.subtle.importKey("pkcs8", ..., "Ed25519", ...)must succeed. Chrome 137+, Firefox 132+, Safari 17+ all support it. Older targets fail withEd25519UnsupportedError. - IndexedDB. Required to persist the key across reload. See the Storage caveat above for the contexts in which it can silently be missing.
- No
Buffer. The shipped runtime code uses Web APIs only. NodeBufferis not a browser global and no polyfill ships with this package.
Scope: v1 is Stellar only
This package ships one adapter: a custom Stellar Wallets Kit module. There is no EVM support. The equivalent for EVM is a custom wagmi connector, which is a separate surface (EIP-1193, chain switching, no auth-entry concept). The core session APIs are chain-agnostic, but no EVM code ships in v1.
The signing surface intentionally covers the three SEP-43 entry points
SWK exposes - signTransaction, signAuthEntry, signMessage - and
nothing else. There is no policy engine, no hosted signer, and no
browser automation: the agent brings its own driver. The key is local
to the browser; a remote signer would make a public npm package
unusable by anyone who installs it.
