clearsign
v0.1.0
Published
OWS-native multi-sig with human-readable proposals, TTL enforcement, and anomaly detection. Built to prevent blind-signing exploits in DeFi protocols.
Downloads
121
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
←── send sig hex + wallet_id ────────────
clearsign submit <id> <wallet-id> <name> <sig>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.
5. Anomaly Detection
After every signature submission, the anomaly engine runs automatically:
| Rule | Trigger | Action |
|------|---------|--------|
| SPEED_SIGNING | All required sigs arrive within 10 s | Auto-void proposal |
| HIGH_VALUE | Amount exceeds $50,000 USD | Flag + require independent verification |
| OFF_HOURS | Signature outside 08:00–20:00 UTC | Warn signers |
| SINGLE_PROCESS | Multiple sigs share identical timestamps | Flag (test/automation guard) |
All thresholds are configurable via AnomalyConfig.
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 (paranoid mode)
- Only then calls the actual on-chain transaction / API
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 from npm
npm install clearsignOr clone and run locally
git clone https://github.com/rkmonarch/clearsign.git
cd clearsign
npm install
npm run demoBuild
npm run build # compile to dist/
npm run typecheck # type-check only, no emitCLI
After npm install -g clearsign (or npm 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
CLEARSIGN_SIGNER_NAME="Alice" \
CLEARSIGN_PRIVATE_KEY="<32-byte-hex-private-key>" \
clearsign sign ./proposal.json
# → prints wallet_id and signature hex to send back to coordinatorCoordinator 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>
# → 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 sig = aliceWallet.sign(result.proposal.proposal_hash);
// send sig + 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);
await coordinator.submitSignature(proposal.id, bob.wallet.id, "Bob", bobSigHex);
// 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);
await coordinator.submitSignature(proposal.id, bob.wallet.id, "Bob", bobSig);Demo
npm 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
Anomaly Thresholds
import type { AnomalyConfig } from "clearsign";
const config: AnomalyConfig = {
speed_signing_threshold_secs: 10, // sigs within 10s → SPEED_SIGNING
business_hours_utc: [8, 20], // warn outside 08:00–20:00 UTC
high_value_threshold_usd: 50_000, // flag proposals 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.
License
MIT
