clearsign
v0.1.2
Published
OWS-native multi-sig with human-readable proposals, TTL enforcement, and anomaly detection. Built to prevent blind-signing exploits in DeFi protocols.
Readme
ClearSign
OWS-native multi-sig with human-readable proposals, tamper-proof TTL, and anomaly detection.
ClearSign is a security layer for DeFi treasuries and AI agent spend requests. Every action that requires multi-sig approval is wrapped in a cryptographically-bound, human-readable proposal — signers always see exactly what they are approving before a key is ever touched. Signers can be on completely separate machines; no private key ever reaches the coordinator.
The Problem
Multi-sig setups fail not because the cryptography is weak, but because signers approve things they don't fully understand:
- Signers are shown raw transaction bytes, not plain-language descriptions.
- Signatures have no expiry — a stored signature can be replayed indefinitely.
- There is no shared awareness between signers — a compromised co-signer isn't visible to others.
- Automated or coerced signing (all sigs in milliseconds) is indistinguishable from legitimate signing.
- All signers must be on the same machine, so any single compromise exposes every key.
ClearSign addresses all five.
How It Works
1. Human-Readable Proposals
Every action starts as a ProposalV1 — a structured, Zod-validated object containing:
- Plain-language
titleanddescription amount_usdandasset(shown prominently before signing)chain(CAIP-2 format),requester,payload- A hard
expires_atTTL - An
m-of-nthreshold and the list of authorized signers
Signers see a formatted signing prompt before any key operation. Skipping the review is not possible — the signing call requires the proposal object, which always renders.
2. Proposal Hash = Tamper Proof
Before collecting signatures, ClearSign computes a SHA-256 hash over the immutable content fields of the proposal (action, title, description, amount, chain, payload, signers, expiry). The hash deliberately excludes mutable state (signatures, status, anomaly_flags) so that:
- The coordinator can re-export an in-progress proposal (with some signatures already collected) to remaining signers without breaking hash verification.
- All N signers on different machines sign the same hash regardless of how many signatures have been collected so far.
proposal_hash = SHA-256(canonicalize({
id, version, action, title, description,
chain, amount_usd, asset, payload, requester,
threshold, signers, created_at, expires_at
}))Signers sign this hash — not a raw transaction payload. Altering any content field produces a different hash and invalidates every existing signature.
3. Hard TTL — No Durable Nonces
Every proposal carries an expires_at timestamp baked into its hash. The coordinator auto-expires proposals on read, and the ExecutionGate re-checks status before touching the chain. A signature collected before expiry cannot be used after — there are no durable nonces, no stored-and-replayed attacks.
Default maximum TTL: 30 minutes.
4. Remote Signers — Keys Never Leave the Signer's Machine
The coordinator registers signers by public key only. Private keys are generated and held exclusively on each signer's own machine. The full remote signing flow:
Coordinator machine Signer's machine
────────────────── ────────────────
clearsign keygen
→ public key hex
ClearSignWallet.fromPublicKey(pubKeyHex)
buildProposal({ signers: [...] })
exportProposal(proposal) → proposal.json
─── send proposal.json ──────────────────→
clearsign sign ./proposal.json
→ verifies hash
→ renders signing prompt
→ [y/N] confirm
→ signature hex + signed_at
←── send sig hex + wallet_id + signed_at ─
clearsign submit <id> <wallet-id> <name> <sig> <signed-at>No server required. Private keys never leave the signer's machine. Signatures are verified against the registered public key — the coordinator only needs the public key to check the math.
Signer-side timestamps. signMessage returns { signature, signed_at } where signed_at is captured on the signer's machine, not when the coordinator receives the submission. This is what lets two signers approve a proposal hours apart on separate machines without tripping SPEED_SIGNING when the coordinator batches the submits.
Public-key-only safety. A wallet created via ClearSignWallet.fromPublicKey(...) has no local private key. Calling .signMessage(...) on it throws explicitly — "This wallet was registered by public key only — sign on the signer's own machine" — rather than producing junk output.
5. Anomaly Detection
After every signature submission, the anomaly engine runs automatically:
| Rule | Trigger | Action |
|------|---------|--------|
| SPEED_SIGNING | All required signer-side timestamps land within 10 s | Auto-void proposal |
| HIGH_VALUE | Amount strictly greater than $50,000 USD | Flag + require independent verification |
| OFF_HOURS | Signature outside the configured business hours (IANA timezone) | Warn signers |
| SINGLE_PROCESS | Multiple sigs share identical timestamps | Flag (test/automation guard) |
All thresholds are configurable via AnomalyConfig. business_hours accepts any IANA timezone ("America/New_York", "Asia/Singapore", "UTC", …) or can be set to null to disable the rule. Findings are emitted as structured { rule, severity, message, details } records — dedup'd by rule — so downstream policy engines can route, suppress, or escalate them programmatically. Legacy string flags from older persisted state are migrated on read.
6. Execution Gate
Even after threshold is met, execution is not automatic. The ExecutionGate:
- Confirms proposal is in
APPROVEDstate (not voided, expired, or pending) - Checks no hard anomaly flags (
SPEED_SIGNING,SINGLE_PROCESS) are unresolved - Optionally re-verifies every signature from scratch —
gate.execute(id, /* paranoid */ true)— without double-counting the first signer - Only then calls the actual on-chain transaction / API
Note: the bundled
gate.execute()mocks the final chain call and returns a faketx_id. Wire in your real Solana / EVM / API submission insideExecutionGate.mockExecutefor production. See Scope & Status.
Architecture
src/
├── index.ts # Public API exports
├── demo.ts # Interactive 4-scenario demo
├── cli.ts # Signer + coordinator CLI (clearsign)
├── gate.ts # ExecutionGate — final pre-execution checkpoint
│
├── proposal/
│ ├── schema.ts # ProposalV1 Zod schema + enums
│ ├── builder.ts # buildProposal(), hashProposal(), exportProposal(), importProposal()
│ └── renderer.ts # Terminal rendering (proposal card + signing prompt)
│
├── multisig/
│ ├── coordinator.ts # Central state machine — accepts sigs, runs anomaly checks
│ ├── verifier.ts # Ed25519 signature verification, threshold check
│ └── anomaly.ts # Suspicious pattern detection engine
│
└── wallet/
└── ows.ts # OWS wallet abstraction — fromPublicKey(), create(), signMessage()Security Properties
| Property | Mechanism |
|----------|-----------|
| No blind signing | Full proposal card rendered before every signMessage call — skipping is impossible |
| Tamper-proof payload | proposal_hash commits to content fields — amount, recipient, description, expiry |
| No signature replay | Hard TTL baked into hash; expired proposals rejected at coordinator and gate level |
| Coercion detection | Speed-signing anomaly auto-voids proposals where sigs arrive within 10 s |
| Distributed key custody | fromPublicKey() — each signer holds their own private key on their own machine |
| Tamper detection in transit | importProposal() re-computes and verifies hash before any key is touched |
| Safe re-export | Hash covers only immutable content — in-progress proposals can be re-shared safely |
| Defence-in-depth | Anomaly check at signature time + independent gate check at execution time |
Getting Started
Install with Bun
bun add clearsignOr clone and run locally
git clone https://github.com/rkmonarch/clearsign.git
cd clearsign
bun install
bun run demoBuild
bun run build # compile to dist/
bun run typecheck # type-check only, no emitNative OWS mode
The CLI defaults to local Ed25519 signing for deterministic demos. In an environment with a clean OWS wallet store, enable the native OWS backend with:
export CLEARSIGN_USE_OWS_NATIVE=1Deploy the website on Vercel
For the Next.js website in apps/web, import this repository into Vercel as a monorepo project and set:
Root Directory:apps/webFramework Preset:Next.js
The app includes apps/web/vercel.json so Vercel installs and builds with Bun.
CLI
After bun add -g clearsign (or bun run build locally), the clearsign CLI is available.
Signer commands
Run these on the signer's own machine.
# Generate a new Ed25519 identity (do this once per signer)
clearsign keygen
# → prints public key (share with coordinator) and private key (keep secret)
# Review a proposal — verify hash and display the full card
clearsign review ./proposal.json
# Sign a proposal — verify hash, render prompt, confirm [y/N], output signature + signed_at
CLEARSIGN_SIGNER_NAME="Alice" \
CLEARSIGN_PRIVATE_KEY="<32-byte-hex-private-key>" \
clearsign sign ./proposal.json
# → prints wallet_id, signature hex, signed_at, and a ready-to-run submit commandCoordinator commands
Run these on the coordinator machine.
# List all pending proposals
clearsign list
# Show full status of a proposal
clearsign status <proposal-id>
# Record a remote signer's signature
clearsign submit <proposal-id> <wallet-id> <signer-name> <sig-hex> <signed-at>
# → reports: signature_accepted | threshold_met | proposal_voidedIntegration
Remote signers (recommended)
Each signer generates their keypair independently. The coordinator only holds public keys.
import {
ClearSignWallet,
buildProposal,
exportProposal,
importProposal,
MultiSigCoordinator,
ExecutionGate,
ProposalAction,
DEFAULT_ANOMALY_CONFIG,
} from "clearsign";
// 1. Signers run `clearsign keygen` on their own machines and share public keys
const alice = ClearSignWallet.fromPublicKey("Alice", alicePubKeyHex, "solana:mainnet");
const bob = ClearSignWallet.fromPublicKey("Bob", bobPubKeyHex, "solana:mainnet");
// 2. Build a proposal
const proposal = buildProposal({
action: ProposalAction.TRANSFER,
title: "Pay auditors — Q2 2026",
description: "Transfer 10,000 USDC to Trail of Bits for Q2 audit.",
chain: "solana:mainnet",
amount_usd: 10_000,
asset: "USDC",
payload: { instruction: "transfer", to: "toB...", amount: "10000000000" },
requester: "ops-agent",
threshold: { required: 2, total: 2 },
signers: [
{ wallet_id: alice.wallet.id, public_key: alice.publicKeyHex, name: "Alice" },
{ wallet_id: bob.wallet.id, public_key: bob.publicKeyHex, name: "Bob" },
],
ttl_minutes: 30,
});
// 3. Export and send to signers (email, Slack, API — any channel)
const proposalJson = exportProposal(proposal);
// ── On each signer's machine: ──────────────────────────────────────────────
// const result = importProposal(proposalJson); // verifies hash — rejects tampering
// if (!result.ok) throw new Error(result.reason);
// const signed = await aliceWallet.signMessage(result.proposal.proposal_hash);
// send signed.signature + signed.signed_at + wallet_id back to coordinator
// ──────────────────────────────────────────────────────────────────────────
// 4. Coordinator collects signatures
const coordinator = new MultiSigCoordinator(DEFAULT_ANOMALY_CONFIG);
await coordinator.init();
await coordinator.addProposal(proposal);
await coordinator.submitSignature(proposal.id, alice.wallet.id, "Alice", aliceSigHex, aliceSignedAt);
await coordinator.submitSignature(proposal.id, bob.wallet.id, "Bob", bobSigHex, bobSignedAt);
// 5. Execute through the gate
const gate = new ExecutionGate(coordinator);
const result = await gate.execute(proposal.id);Local signers (single machine / testing)
const alice = await ClearSignWallet.create("Alice", "solana:mainnet");
const bob = await ClearSignWallet.create("Bob", "solana:mainnet");
const proposal = buildProposal({ ..., signers: [...] });
const aliceSig = await alice.signMessage(proposal.proposal_hash!);
const bobSig = await bob.signMessage(proposal.proposal_hash!);
await coordinator.submitSignature(proposal.id, alice.wallet.id, "Alice", aliceSig.signature, aliceSig.signed_at);
await coordinator.submitSignature(proposal.id, bob.wallet.id, "Bob", bobSig.signature, bobSig.signed_at);Demo
bun run demoFour scenarios, end-to-end:
| Scenario | What happens |
|----------|-------------|
| A — Legitimate transfer | Coordinator registers 2 remote signers by public key. Builds and exports proposal. Each signer imports, verifies hash, signs. Coordinator re-exports in-progress proposal (with one sig) to second signer — hash still verifies. Threshold met, gate executes. |
| B — Blind-signing attack | Attacker submits malicious $280M drain. Both sigs arrive within milliseconds → SPEED_SIGNING anomaly → proposal auto-voided. Gate blocks execution. |
| C — TTL expiry | Alice tries to sign an already-expired proposal. Rejected at coordinator level. No durable nonces possible. |
| D — 3 remote signers, 3 machines | 2-of-3 contract upgrade. Each signer independently imports, verifies, and signs. Signatures arrive out of order — Carol before Bob. Threshold met, late sig gracefully rejected, gate executes upgrade. |
Configuration
Coordinator Storage
By default the coordinator writes proposal state to ~/.clearsign/proposals.json. Treat this as ephemeral coordination state — it may contain signed proposal metadata and is not encrypted at rest. For tests, demos, stateless servers, or custom encrypted storage, pass options explicitly:
const coordinator = new MultiSigCoordinator({
anomalyConfig: DEFAULT_ANOMALY_CONFIG,
inMemory: true, // skip disk entirely (tests / stateless servers)
storePath: "/secure/path/proposals.json", // custom store (mount an encrypted volume here)
logLoads: false, // suppress "[ClearSign] Loaded N proposal(s)" stderr line
});If you run the coordinator long-lived in production, plan for one of: mount an encrypted volume at storePath, run with inMemory: true and back the state with your own durable store, or post the proposal hash to an on-chain registry for the system-of-record. See Scope & Status.
Anomaly Thresholds
import type { AnomalyConfig } from "clearsign";
const config: AnomalyConfig = {
speed_signing_threshold_secs: 10, // sigs within 10s → SPEED_SIGNING
business_hours: [9, 17], // warn outside 09:00–17:00 local
business_hours_tz: "America/New_York", // IANA tz; use "UTC" or set business_hours: null to disable
high_value_threshold_usd: 50_000, // flag proposals strictly above $50k
auto_void_on_speed: true, // auto-void vs just flag
};TTL
Maximum TTL is enforced at buildProposal time — any ttl_minutes above 30 is clamped to 30. To change the cap, update MAX_TTL_MINUTES in src/proposal/builder.ts.
Scope & Status
ClearSign ships the safety primitives for human-readable, hash-locked, time-bound multi-sig — not a finished treasury product. What's in the box vs what an integrator still owns:
| In the box | Out of scope (you wire it) |
|------------|----------------------------|
| ProposalV1 schema + canonical hash | A GUI for non-CLI signers (build with the SDK + your design system) |
| ClearSignWallet.fromPublicKey() — coordinator never holds private keys | Hardware wallet / wallet-adapter integration on the signer side |
| MultiSigCoordinator — in-process state machine with optional JSON persistence | A hosted multi-tenant coordinator service (auth, REST/gRPC, audit log) |
| Anomaly detection engine — configurable, dedup'd, structured flags | On-chain anchor / attestation program for system-of-record |
| ExecutionGate — anomaly + status + paranoid re-verification | Real chain submission inside gate.execute (mocked today) |
| CLI for signer + coordinator commands | Push notifications / proposal transport (today: pass the JSON over your channel of choice) |
| Tamper-evident exportProposal / importProposal | Admin actions for signer rotation (today: rebuild the proposal model) |
If you need the missing pieces today, you build them on top — the primitives are stable and the surface is small.
Roadmap
- On-chain anchor program — post proposal hash + signer set + threshold to a Solana program at creation; signatures recorded as on-chain attestations so auditors and the gate can verify history independently of any one coordinator.
- Reference hosted coordinator — REST + auth + audit log + push notifications. Single-tenant deployable, multi-tenant ready.
- Wallet-Standard integration — render the ClearSign signing card inside any wallet that opts in (Phantom, Backpack, Solflare, Ledger), instead of inside the CLI.
- Production execution adapter — drop-in
ExecutionGatefor SolanaSystemProgram.transfer, SPL token transfer, and arbitraryTransactionMessagepayloads.
Changelog
0.1.1
- Signer-side timestamps end-to-end.
signMessage()now returns{ signature, signed_at }andsubmitSignature(..., signedAt)records the signer's timestamp instead of the coordinator's receive time. Two signers approving hours apart on separate machines no longer tripSPEED_SIGNING. - Structured anomaly flags.
anomaly_flagsis now{ rule, severity, message, details }[]instead of opaque strings; dedup'd by rule (with per-signer keying forOFF_HOURS). Legacy string flags from older persisted state are migrated on read. OFF_HOURSper IANA timezone.business_hoursaccepts any timezone ("America/New_York","Asia/Singapore", …), andbusiness_hours: nulldisables the rule entirely.HIGH_VALUEis strictly greater. A proposal exactly at the threshold no longer trips the rule.ExecutionGateparanoid mode fixed.gate.execute(id, true)re-verifies every signature without double-counting the first signer.ClearSignWallet.fromPublicKey()throws onsignMessagewith an explicit message instead of failing downstream.MultiSigCoordinatoroptions.{ inMemory, storePath, logLoads }for test isolation, stateless servers, and quieter CLI output. Backwards-compatible with the priorAnomalyConfig-only constructor signature.- Locale-pinned formatting. Amounts render in
en-US; expiry timestamps render as UTC ISO. No locale leaks into proposal output.
License
MIT
