@telaro/sacp-signer-server
v0.9.0
Published
Reference HTTP signing server for the sACP RemoteSigner. Wraps a local keypair, a Turnkey vault, or an AWS KMS asymmetric key behind a uniform POST /sign endpoint with HMAC auth.
Readme
@telaro/sacp-signer-server
Reference HTTP signing gateway for the sACP RemoteSigner. Holds a
Solana private key (in process, in a file, in a vault) and exposes a
single POST /sign endpoint so SDK consumers can stay key-less.
When to use
- Production Provider / Evaluator operators that don't want a private key sitting next to their job worker.
- Multi-tenant setups where one process drives many Job sessions and a separate signer pod holds the secrets.
- KMS-backed deployments (Turnkey, AWS KMS asymmetric ed25519,
Fireblocks). implement
SigningBackendand you're done.
For local dev or a single-tenant smoke test, LocalSigner in
@telaro/sacp/signer is fine. you don't need this package at all.
Quick start (dev)
pnpm install
export SACP_SIGNER_API_SECRET=$(openssl rand -hex 32)
pnpm --filter @telaro/sacp-signer-server dev
# → listening on :7777 as <pubkey>Then on the client:
import { Connection, PublicKey } from "@solana/web3.js";
import { SacpClient, RemoteSigner } from "@telaro/sacp";
import { HttpRemoteSignTransport } from "@telaro/sacp-signer-server";
const transport = new HttpRemoteSignTransport({
url: "http://localhost:7777/sign",
apiSecret: process.env.SACP_SIGNER_API_SECRET!,
});
const providerSigner = new RemoteSigner(
new PublicKey("..."), // pubkey reported by GET /pubkey
transport,
);
const sacp = new SacpClient({ connection: new Connection("...") });
const session = await sacp.openJob({
jobId: 1n,
actors: { client: clientSigner, provider: providerSigner, evaluator: ... },
});
await session.submitWork("ipfs://...");The SDK builds + serializes the transaction, the transport ships the
message bytes to /sign, the backend produces the ed25519 signature.
The private key never leaves the signer process.
Auth
Every request carries:
x-sacp-timestamp: <unix seconds>
x-sacp-api-key: hex(HMAC-SHA256(secret, timestamp + "." + body))Server rejects:
- Missing / malformed headers (401)
- Timestamps more than
toleranceSecs(default 60 s) away from server clock (401) - HMAC mismatch (401)
- Non-JSON body or missing
messagefield (400)
HttpRemoteSignTransport produces these headers automatically.
Backends
SigningBackend is one method:
interface SigningBackend {
readonly publicKey: PublicKey;
sign(message: Uint8Array): Promise<Uint8Array>; // 64-byte ed25519 sig
}Ship two reference impls:
LocalKeypairBackend. wraps aKeypair(in-memory).KeypairFileBackend. reads asolana-keygenJSON file.
Write your own for Turnkey / AWS KMS / Fireblocks / hardware:
class TurnkeyBackend implements SigningBackend {
publicKey: PublicKey;
constructor(private client: TurnkeyClient, private walletId: string) { ... }
async sign(message: Uint8Array): Promise<Uint8Array> {
const { signature } = await this.client.signRawPayload({
payload: Buffer.from(message).toString("hex"),
hashFunction: "HASH_FUNCTION_NOT_APPLICABLE",
walletId: this.walletId,
});
return Buffer.from(signature, "hex");
}
}Drop into createSigningServer(new TurnkeyBackend(...), { apiSecret })
and the HTTP shape is unchanged.
Hardening for prod
This is reference code, not a hardened deployment. Before exposing beyond loopback you should at minimum:
- Run behind TLS (Caddy / nginx / ALB).
- Add rate limiting (
express-rate-limit). - Run as a non-root user inside a read-only container.
- Rotate
SACP_SIGNER_API_SECRETregularly; the server has no rotation built in. - Audit log every
/signcall with the requested message hash.
The included CLI is a single-process runner. fine for first
deployments, not for high-availability. For multi-replica setups,
stick the same backend behind multiple shells of createSigningServer
and load-balance.
