solana-agent-auth
v0.1.0
Published
Signed HTTP requests with Solana — the Solana equivalent of ERC-8128
Maintainers
Readme
solana-agent-auth
Signed HTTP requests with Solana. The Solana equivalent of ERC-8128.
Every HTTP request carries a cryptographic proof that it came from a specific Solana keypair. The server verifies the proof without needing sessions, tokens, or shared secrets.
Built on RFC 9421 (HTTP Message Signatures) with native Ed25519 support.
Install
npm install solana-agent-authQuick Start
Client (sign requests)
import { createSignerClient } from 'solana-agent-auth'
const signer = {
publicKey: wallet.publicKey, // Solana Address (base58)
signMessage: (msg) => wallet.signMessage(msg), // returns 64-byte Uint8Array
}
const client = createSignerClient(signer)
// Sign and send in one call
const response = await client.fetch('https://api.example.com/orders', {
method: 'POST',
body: JSON.stringify({ side: 'buy', amount: 1.5 }),
})
// Or sign without sending
const signedRequest = await client.signRequest('https://api.example.com/data')Server (verify requests)
import { createVerifierClient } from 'solana-agent-auth'
const verifier = createVerifierClient({ nonceStore })
const result = await verifier.verifyRequest({ request })
if (result.ok) {
console.log(`Authenticated: ${result.publicKey}`)
console.log(`Binding: ${result.binding}`) // "request-bound" | "class-bound"
console.log(`Replayable: ${result.replayable}`) // false (nonce present) | true
} else {
console.log(`Rejected: ${result.reason}`) // e.g. "expired", "replay", "bad_signature_check"
}Verification is pure Ed25519 math -- no RPC calls, no on-chain lookups, no network requests. The built-in verifier is used automatically. No external crypto library needed.
Why
Traditional HTTP authentication is credential-based. JWTs, API keys, and session cookies can all be reused if compromised, require issuance handshakes, and don't bind to specific requests. For AI agents and programmatic clients, this is especially painful -- an agent needs to authenticate to an API it has never interacted with before, prove it controls a specific wallet, and do this without a human in the loop.
solana-agent-auth replaces bearer credentials with per-request Ed25519 signatures. The client signs each HTTP request with its Solana key. The server verifies the signature. No registration, no shared secrets -- if you have a Solana keypair, you can authenticate.
How It Works
What Gets Signed
The signature covers a canonical representation of the HTTP request following RFC 9421:
"@authority": api.example.com
"@method": POST
"@path": /orders
"@query": ?market=SOL-USD
"content-digest": sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:
"@signature-params": ("@authority" "@method" "@path" "@query" \
"content-digest");created=1772587263;expires=1772587323;\
nonce="cedf9c3d7a664e0b";keyid="solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"This UTF-8 byte string is signed directly with Ed25519. No wrapper, no envelope -- exactly what RFC 9421 specifies.
The Headers
A signed request carries additional headers:
| Header | Purpose |
|--------|---------|
| Signature-Input | Declares what was signed and the metadata (timestamps, nonce, signer identity) |
| Signature | The 64-byte Ed25519 signature, base64-encoded |
| Content-Digest | SHA-256 hash of the request body (auto-computed when body is present) |
KeyId Format
The keyid parameter identifies who signed the request:
solana:<base58-encoded-public-key>Example: solana:7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU
Security Model
Two orthogonal axes controlled by the signer, evaluated by the verifier:
Request Binding
| Mode | What's Signed | Use Case |
|------|--------------|----------|
| Request-bound (default) | @authority + @method + @path + @query + content-digest | Authorizes exactly one HTTP request |
| Class-bound | Only specified components (minimum: @authority) | Authorizes a class of requests (like a scoped token) |
Replay Protection
| Mode | Mechanism | Use Case | |------|-----------|----------| | Non-replayable (default) | Nonce included, server tracks consumed nonces | Single-use authorization | | Replayable | No nonce, time-window only | Idempotent reads, high-frequency polling |
The strongest posture (request-bound + non-replayable) is the default and is the baseline that every compliant verifier MUST accept.
Conformance
Following the ERC-8128 spec:
- Baseline (MUST accept): Request-Bound + Non-Replayable signatures
- Optional (MAY accept): Replayable or Class-Bound signatures
- Verifiers that accept Replayable signatures MUST implement early invalidation mechanisms (
replayableNotBeforeand/orreplayableInvalidated) - All signatures MUST include
keyid,created,expires, and cover at least@authority
API
Signing
signRequest(input, signer, opts?)
Sign a request and return a new Request with Signature-Input, Signature, and Content-Digest headers.
import { signRequest } from 'solana-agent-auth'
const signed = await signRequest('https://api.example.com/data', signer, {
label: 'sol', // default
binding: 'request-bound', // default
replay: 'non-replayable', // default
ttlSeconds: 60, // default
})
// Also supports (input, init, signer, opts) overload:
const signed = await signRequest(
'https://api.example.com/tx',
{ method: 'POST', body: '{"amount":100}' },
signer
)signedFetch(input, signer, opts?)
Sign and send a request in one call.
import { signedFetch } from 'solana-agent-auth'
const response = await signedFetch('https://api.example.com/data', signer, {
fetch: customFetchImpl, // optional, defaults to globalThis.fetch
})createSignerClient(signer, defaults?)
Create a client with a bound signer and default options.
import { createSignerClient } from 'solana-agent-auth'
const client = createSignerClient(signer, {
ttlSeconds: 120,
fetch: customFetch,
})
// client.signRequest(input, opts?) -> Promise<Request>
// client.signedFetch(input, opts?) -> Promise<Response>
// client.fetch(input, opts?) -> Promise<Response> (alias for signedFetch)
// All support (input, init, opts?) overload for RequestInit:
await client.fetch('https://api.example.com/tx', { method: 'POST', body: '...' })Verification
verifyRequest(args)
Verify a signed request. Uses the built-in Ed25519 verifier by default.
import { verifyRequest } from 'solana-agent-auth'
const result = await verifyRequest({
request,
nonceStore,
// verifyMessage is optional -- defaults to built-in Ed25519 verifier
policy: {
clockSkewSec: 5,
maxValiditySec: 300,
},
})
if (result.ok) {
result.publicKey // Address (base58)
result.label // "sol"
result.components // ["@authority", "@method", "@path", ...]
result.params // { created, expires, keyid, nonce? }
result.replayable // boolean
result.binding // "request-bound" | "class-bound"
}createVerifierClient(args)
Create a verifier with bound defaults. Minimal setup -- only a NonceStore is required.
import { createVerifierClient } from 'solana-agent-auth'
const verifier = createVerifierClient({
nonceStore,
// verifyMessage is optional -- built-in Ed25519 verifier used by default
defaults: {
clockSkewSec: 5,
maxValiditySec: 300,
},
})
const result = await verifier.verifyRequest({ request })Types
interface SolanaHttpSigner {
publicKey: Address // @solana/kit Address type (base58 string)
signMessage: (message: Uint8Array) => Promise<Uint8Array> // 64-byte Ed25519 sig
}
interface NonceStore {
consume(key: string, ttlSeconds: number): Promise<boolean>
// Returns true if newly stored (nonce not seen before)
// Returns false if already consumed (replay attempt)
}
type VerifyResult =
| { ok: true; publicKey: Address; label: string; components: string[];
params: SignatureParams; replayable: boolean; binding: BindingMode }
| { ok: false; reason: VerifyFailReason; detail?: string }Sign Options
type SignOptions = {
label?: string // default: "sol"
binding?: BindingMode // "request-bound" | "class-bound"
replay?: ReplayMode // "non-replayable" | "replayable"
created?: number // unix seconds; default: now
expires?: number // unix seconds; default: created + ttlSeconds
ttlSeconds?: number // default: 60
nonce?: string | (() => Promise<string>)
contentDigest?: ContentDigestMode // "auto" | "recompute" | "require" | "off"
components?: string[] // extra components to sign
}Verify Policy
type VerifyPolicy = {
label?: string // preferred label; default: "sol"
strictLabel?: boolean // reject if label not found; default: false
additionalRequestBoundComponents?: string[]
classBoundPolicies?: string[] | string[][]
replayable?: boolean // accept replayable sigs; default: false
replayableNotBefore?: (keyid: string) => number | null | Promise<number | null>
replayableInvalidated?: (args: { keyid, created, expires, label, signature, signatureBase, signatureParamsValue }) => boolean | Promise<boolean>
maxSignatureVerifications?: number // default: 3
now?: () => number // unix seconds
clockSkewSec?: number // default: 0
maxValiditySec?: number // default: 300
maxNonceWindowSec?: number
nonceKey?: (keyid: string, nonce: string) => string // default: `${keyid}:${nonce}`
}Verify Failure Reasons
| Reason | Meaning |
|--------|---------|
| missing_headers | Signature-Input or Signature header not present |
| label_not_found | No signature with the expected label |
| bad_signature_input | Failed to parse Signature-Input header |
| bad_signature | Signature did not verify |
| bad_signature_bytes | Could not decode signature from base64 |
| bad_signature_check | Cryptographic verification threw or returned false |
| bad_keyid | keyid is not a valid solana:<address> |
| bad_time | created/expires are not valid integers or expires <= created |
| not_yet_valid | Current time is before created |
| expired | Current time is after expires |
| validity_too_long | expires - created exceeds maxValiditySec |
| nonce_required | Nonce present but nonceStore missing |
| nonce_window_too_long | expires - created exceeds maxNonceWindowSec |
| replay | Nonce already consumed |
| replayable_not_allowed | Replayable sig but policy.replayable is false |
| replayable_invalidation_required | Replayable sig but no invalidation mechanism configured |
| replayable_not_before | created is before the replayableNotBefore cutoff |
| replayable_invalidated | replayableInvalidated returned true |
| not_request_bound | Signature doesn't cover required request-bound components |
| class_bound_not_allowed | Class-bound sig but no classBoundPolicies configured |
| digest_required | content-digest in components but header missing |
| digest_mismatch | Content-Digest header doesn't match recomputed body hash |
NonceStore Implementation
The NonceStore interface has one method. Here's a Redis example:
import { createClient } from 'redis'
const redis = createClient()
const nonceStore: NonceStore = {
async consume(key: string, ttlSeconds: number): Promise<boolean> {
// SET NX with TTL -- atomic check-and-insert
const result = await redis.set(`nonce:${key}`, '1', { EX: ttlSeconds, NX: true })
return result === 'OK'
},
}In-memory (for development/testing):
const nonceStore: NonceStore = {
seen: new Map<string, number>(),
async consume(key: string, ttlSeconds: number): Promise<boolean> {
const now = Date.now()
// Clean expired entries
for (const [k, exp] of this.seen) {
if (exp < now) this.seen.delete(k)
}
if (this.seen.has(key)) return false
this.seen.set(key, now + ttlSeconds * 1000)
return true
},
}Comparison with ERC-8128
This library is a direct port of @slicekit/erc8128, adapted for Solana's Ed25519 identity model.
| Concern | ERC-8128 (Ethereum) | solana-agent-auth (Solana) |
|---------|-------------------|--------------------------|
| Algorithm | secp256k1 | Ed25519 |
| Message wrapping | ERC-191 envelope required | Raw signing (RFC 9421 native) |
| Account types | EOA + Smart Contract (two verification paths) | All Ed25519 (single code path) |
| Verification | ecrecover or ERC-1271 RPC call | Pure Ed25519 verify -- no network needed |
| verifyMessage | Required (bring your own viem + RPC) | Optional (ships built-in verifier) |
| Key to address | keccak256(publicKey)[12:] (lossy) | Public key is the address |
| keyid format | eip8128:<chainId>:<0xAddress> | solana:<base58Pubkey> |
| Default label | eth | sol |
| Signature return type | Hex (0x...) | Uint8Array (64 bytes) |
| Deterministic | No (ECDSA k-value) | Yes (Ed25519) |
| Signature size | 65 bytes | 64 bytes |
| Success result | { address, chainId } | { publicKey } |
Spec Compliance
The implementation is 34/35 compliant with the ERC-8128 specification, with Ethereum-specific parts (ERC-191, secp256k1, chain-id, ERC-1271) replaced by Solana equivalents (raw Ed25519, base58 addresses). The single partial compliance (§5.2: invalidation requests must be authenticated with request-bound signatures) is an application-layer concern outside the scope of a signing library.
Architecture
src/
├── sign.ts # signRequest(), signedFetch()
├── verify.ts # verifyRequest()
├── client.ts # createSignerClient()
├── verifierClient.ts # createVerifierClient()
├── index.ts # barrel exports
└── lib/
├── types.ts # all types + SolanaAuthError
├── keyId.ts # formatKeyId(), parseKeyId()
├── nonce.ts # resolveNonce()
├── utilities.ts # base64, sha256, toRequest, etc.
├── defaultVerify.ts # built-in Ed25519 verifier
├── verifyUtils.ts # buildAttempts, time/nonce checks
├── acceptSignature.ts # Accept-Signature response header
├── engine/
│ ├── serializations.ts # RFC 8941 structured field serialization
│ ├── createSignatureBase.ts # RFC 9421 signature base construction
│ ├── createSignatureInput.ts # Signature-Input / Signature parsing
│ ├── contentDigest.ts # Content-Digest (RFC 9530)
│ └── signatureHeaders.ts # signature candidate selection
└── policies/
├── isRequestBound.ts # request-bound classification
└── normalizePolicies.ts # policy normalizationStandards
- RFC 9421 -- HTTP Message Signatures
- RFC 9530 -- Digest Fields (Content-Digest)
- RFC 8941 -- Structured Field Values
- ERC-8128 -- Signed HTTP Requests with Ethereum (the Ethereum equivalent)
Development
bun install
bun test # 225 tests
bun run buildLicense
MIT
