aldabra/README.md
Kayos d1c9e7a732 security: enforce max_send_lovelace + sandbox *_path args
Two CRIT findings from the 2026-05-12 Opus audit. Both are
mainnet-blocking against the aldabra-mainnet container.

CRIT-1 — cap-bypass via unsigned-build → sign_partial → submit chain.
Previously `wallet_send` / `wallet_mint` / `wallet_mint_cip68_nft` /
`wallet_script_spend` enforced `max_send_lovelace`, but the unsigned-
build tools + `wallet_sign_partial` + `wallet_submit_signed_tx` did
not. A prompt-injection that walked the cold-signer chain could drain
the wallet past the cap with zero policy enforcement.

Fix:
- `wallet_send_unsigned` / `wallet_mint_unsigned` /
  `wallet_plutus_mint_unsigned` now enforce the cap on the user-
  supplied destination lovelace, mirroring their signed equivalents.
  All three gain a `force: bool` arg with `#[serde(default)]`.
- `wallet_sign_partial` and `wallet_submit_signed_tx` decode the
  Conway tx CBOR, sum lovelace across every output whose address is
  NOT this wallet's own primary address, and enforce the cap on that
  total. Both gain `force: bool`. The chokepoint covers cold-signed
  multi-sig flows and any hand-built CBOR the daemon would otherwise
  blindly sign or submit.
- New free fn `sum_non_self_lovelace` is the unit-testable core of
  the chokepoint logic; `enforce_cap_on_cbor` wraps it.
- The sum uses `try_fold` + `checked_add` (NOT `.sum::<u64>()`) so a
  crafted CBOR that overflows `u64::MAX` fails the check instead of
  wrapping silently in release builds.

CRIT-2 — path traversal via `reference_script_path` and
`policy_cbor_path`. Previously the tools called `std::fs::read_to_
string(p)` on any path the LLM passed. The MCP daemon runs as the
same user that owns `$ALDABRA_DATA/mnemonic.age` /
`$ALDABRA_DATA/root-xprv.age`. Decode-error messages included the
hex_decode position offset — a small but real information leak about
non-hex file structure.

Fix:
- New `Config::safe_reads_root` field (default `$ALDABRA_DATA/scripts/`,
  override via `ALDABRA_SAFE_READS_ROOT` env or TOML).
- New `assert_inside_sandbox` helper canonicalize()s both the root and
  the user-supplied path, then enforces `starts_with`. Rejects
  outside-root paths, `..`-traversal, and nonexistent paths with
  generic messages.
- Hardlink-rejection: post-canonicalize, stat the file and refuse if
  `nlink > 1`. `canonicalize` resolves symlinks but NOT hardlinks (a
  hardlink IS the file — same inode, different directory entry), so
  without this check an attacker with daemon-uid write access could
  plant a hardlink to the encrypted key blob inside the sandbox and
  exfiltrate bytes through the read path.
- `resolve_ref_script_bytes` + `resolve_policy_cbor_bytes` + the
  `resolve_validator_required` wrapper used by all 5 escrow spend
  tools take `&Path` and route through the sandbox.
- Error messages on hex_decode failures no longer carry the path
  string or byte-offset position — return a constant "contents are
  not valid hex" instead.
- `main.rs` creates the sandbox root with 0o700 perms at startup if
  missing. chmod errors are surfaced (not swallowed) so a broken
  filesystem doesn't silently fall back to umask 0o755.
- README documents the new `ALDABRA_SAFE_READS_ROOT` env var alongside
  `ALDABRA_MAX_SEND_LOVELACE` (also previously undocumented).

Tests (243 → 253, +10):
- 5 sandbox tests: accept-inside, reject-outside, reject-dotdot,
  reject-nonexistent, reject-hardlink.
- 1 non-hex regression: constant message (no byte-offset leak).
- 3 cap tests: self-send → 0 non-self total, outbound counts,
  overflow → Err (regression for the prompt-injection `u64::MAX`
  wraparound attempt).
- 1 garbage-CBOR test: clean error.

No new clippy warnings, no new fmt drift, `cargo audit` unchanged
(0 CVEs, 2 transitive unmaintained warnings).

Adversarial review of the first draft (3 Opus reviewers) caught the
u64 overflow, the hardlink bypass, and the swallowed chmod error.
2026-05-12 12:45:03 -07:00

162 lines
7 KiB
Markdown

# aldabra
Rust-native Cardano lite wallet with an MCP-server interface. Built
for LLM-first usage — send/receive ADA + native assets, mint, Plutus
script interaction, Conway governance, and a full Agora-on-Cardano
DAO client.
Named for the Aldabra giant tortoise: long-lived, defended, slow but
unstoppable.
## What it does
- **Wallet primitives.** Address derivation (CIP-1852), balance +
UTXO queries, ADA + native-asset transfers, multi-sig partial
signing, encrypted-at-rest mnemonic.
- **Minting.** CIP-25 + CIP-68 native assets, custom timelock /
multisig policies, unsigned-tx flows for cold signing.
- **Plutus V3.** Spending script-locked UTXOs with redeemers,
reference scripts, inline datum support.
- **Stake + Conway governance.** Pool delegation, DRep registration
+ deregistration, vote delegation, DRep vote casting on governance
actions.
- **DAO.** Agora-on-Cardano client — register multiple DAOs, view
stakes, create + cosign + vote on proposals, advance state-machine,
retract votes, destroy stakes.
- **Escrow.** Two-party agreement-with-veto Plutus V3 validator with
off-chain builders for the full open / deposit / agree / veto /
settle / refund lifecycle.
## Architecture
Cargo workspace with four crates:
| Crate | Responsibility |
|---|---|
| `aldabra-core` | Pure crypto + types. Mnemonic → root key (CIP-3), root → payment + stake keys (CIP-1852), address construction, signing. **No I/O, no network.** This is the security boundary. |
| `aldabra-chain` | Pluggable backends for chain queries. `ChainBackend` trait, with Koios as the default implementation. |
| `aldabra-dao` | Off-chain side of the Agora DAO + escrow validators. Codecs + unsigned-tx builders. |
| `aldabra-mcp` | Binary. MCP server speaking stdio. Wires the other crates together and exposes tools to the LLM client. |
```
┌─────────────────────────────┐
LLM client │ aldabra-mcp (bin) │ stdio
─────────► │ tool handlers, lifecycle │ ────►
└──────────┬──────────────────┘
┌────────┼────────┐
▼ ▼ ▼
┌──────────┐ ┌──────┐ ┌─────────┐
│ -core │ │-chain│ │ -dao │
│ keys/sig │ │ Koios│ │ Agora/ │
│ │ │ │ │ escrow │
└──────────┘ └──────┘ └─────────┘
```
## Build
```bash
# Requires rustc 1.75+
cargo build --release
```
For the Plutus validators (escrow) you also need [Aiken](https://aiken-lang.org/):
```bash
cd aiken-escrow
aiken build # produces plutus.json blueprint
```
## Run
```bash
# Smoke test (does nothing useful standalone — needs an MCP client)
./target/release/aldabra
# As an MCP server registered with Claude Code, add to ~/.claude.json:
# "aldabra": {
# "command": "/path/to/aldabra",
# "env": {
# "ALDABRA_DATA": "/path/to/wallet-data-dir",
# "ALDABRA_NETWORK": "preprod",
# "ALDABRA_KOIOS_BASE": "https://preprod.koios.rest/api/v1"
# }
# }
```
Bootstrap a wallet on first run by setting `ALDABRA_BOOTSTRAP=new`
or `ALDABRA_BOOTSTRAP=import` in env — the binary prompts for a
passphrase, generates or imports a mnemonic, and writes an
age-encrypted `mnemonic.age` to `ALDABRA_DATA`.
## Configuration
Environment variables consumed at startup:
| Var | Required | Default | Notes |
|---|---|---|---|
| `ALDABRA_DATA` | yes | — | Directory holding `mnemonic.age`. Must exist; bootstrap before first MCP run. |
| `ALDABRA_NETWORK` | no | `preprod` | One of `mainnet`, `preview`, `preprod`. |
| `ALDABRA_KOIOS_BASE` | no | public Koios for the chosen network | Override to point at a self-hosted Koios. |
| `ALDABRA_PASSPHRASE` | yes | — | Unlocks `mnemonic.age`. Source from a docker secret or systemd `EnvironmentFile` — never commit it. |
| `ALDABRA_BOOTSTRAP` | no | (unset) | Set to `new` or `import` to enter bootstrap mode on next launch. |
| `ALDABRA_MAX_SEND_LOVELACE` | no | mainnet 10 ADA / preprod\|preview 100 tADA | Hard cap on lovelace flowing to any non-self destination. Enforced by every tool that signs or submits; pass `force=true` per-tool to override. |
| `ALDABRA_SAFE_READS_ROOT` | no | `$ALDABRA_DATA/scripts/` | Sandbox dir for the `reference_script_path` / `policy_cbor_path` tool args. Files outside this dir (canonical) are refused. Created with 0o700 at startup if missing. |
## MCP tools
The server exposes ~40 tools across four prefixes. A summary:
- `wallet_*` — read (address/balance/utxos/network/stake_address),
send (with optional inline datum for script locks), mint, Plutus
script spending, stake delegation, Conway governance (vote
delegation, DRep operations).
- `chain_*` — read-only Koios passthroughs (tx info, address info,
pool list/info, epoch params, asset info, account info, tip).
- `dao_*` — Agora DAO client. Multi-DAO via config files. Live reads
(governor state, stake list, my stake) plus the full write set:
proposal create / cosign / vote / advance / retract-votes /
stake-destroy.
- `escrow_*` — two-party agreement-with-veto escrow. Build unsigned
txs for open / deposit / agree / veto / settle / refund-timeout.
Every write tool produces an unsigned tx for the caller to sign +
submit. No tool ever holds private keys outside the in-memory
derived-key scope.
## Security model
- **Mnemonic source:** interactive bootstrap on first run, paste once
or generate, encrypted at rest with
[age](https://github.com/FiloSottile/age). Never written to disk
in plaintext.
- **Derived keys:** in-memory only, `ZeroizeOnDrop` on every container.
- **Network exposure:** stdio MCP transport — the binary never opens
a TCP listener. Only the spawning client process can talk to it.
- **Multi-network:** safe to point at preprod for development; the
same binary handles mainnet when you flip `ALDABRA_NETWORK`.
## Status
Wallet + governance paths exercised on **mainnet**. DAO + escrow
paths exercised end-to-end on **preprod**; the escrow validator has
undergone internal review (`audits/`) but **no third-party audit**.
Treat the escrow flows as use-at-own-risk until external review lands
— see `aiken-escrow/README.md` for the WIP threat model.
## License
See `LICENSE`.
## Dependencies of note
- [txpipe/pallas](https://github.com/txpipe/pallas) — Rust Cardano
primitives. Aldabra uses a [fork](https://github.com/Sulkta-Coop/pallas)
on the `feat-aux-data` branch that adds `auxiliary_data` +
`voting_procedures` support to `pallas-txbuilder`. PR upstream
pending.
- [Aiken](https://aiken-lang.org/) — Plutus V3 validator language
used for the escrow contract.
- [modelcontextprotocol/rust-sdk](https://github.com/modelcontextprotocol/rust-sdk)
(`rmcp`) — MCP server framework.
- [age](https://github.com/FiloSottile/age) — at-rest encryption.