Skip to content

adlonymous/solana-agent-auth

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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-auth

Quick 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 (replayableNotBefore and/or replayableInvalidated)
  • 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 normalization

Standards

  • 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 build

License

MIT

About

Signed HTTP requests with Solana — the Solana equivalent of ERC-8128

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors