Skip to content

Repository files navigation

ForgeKeyvault

Custodial key service for CloudsForge. Mints and stores private keys for five chain families — EVM, Solana, Bitcoin, XRP and Ember — encrypts them at rest with AES-GCM, and signs narrowly-scoped payloads on request.

Runs on port 4005. It is never exposed publicly: it binds to 127.0.0.1 and is reachable only from the other services.

What it will and won't sign

/sign is bound, not an oracle. Three things constrain a request, and the service token is the weakest of them — it proves "a peer service is calling", not "this user".

The row. The caller must restate purpose, chain, network, orderId and family, and each is checked against the stored record. (GET /addresses/:address deliberately does not return orderId, userId, family or purpose, or that restatement would just be a replay of a read anyone with the token could do.)

The row's purpose is not a label — it picks the transaction shape, and the three shapes are disjoint:

purpose may sign destination chosen by
deployer forge-mint's zero-value contract creation — a creation has none
treasury forge-pay's plain value transfer out of the platform's payout address the caller
deposit a customer deposit address's value transfer into the treasury this vault pinned for that address's own chain and network — and nothing else this vault

A deployer therefore cannot move value and a treasury cannot deploy code, so mislabelling an address is a refusal rather than a wider signature.

deposit used to be unsignable outright, and its absence from the signable set was the rule that stopped one leaked service token draining every customer address. Sweeping deposits into the treasury needs it to be signable, so that rule was replaced rather than relaxed, by two narrower ones:

  • The pin. A deposit address's only shape (sweep) is a transfer whose to must equal the treasury address pinned for the source row's (chain, network), read from vault_treasuries and never from the request — the caller does not get to name the destination, not even redundantly. An unpinned chain signs nothing: 403 no_treasury_pinned, resolved before anything is decrypted. And the pin is writable only through PUT /admin/treasuries/:chain/:network behind the Nimbus admin credential, never from the service-token surface, which can read it and nothing more. It may only ever name an address this vault minted itself, with purpose: 'treasury', on the same chain and network. Without that last property the shape would be no containment at all: anyone holding the service token can already POST /addresses with purpose: 'treasury', so a writable pin means mint T′, pin T′, sweep every customer deposit into T′, transfer T′ out — three calls, one shared secret.
  • SWEEPABLE_FAMILIES = {evm, ember, xrp}. Making deposit signable widens what a deposit key may sign in every family at once, not only the ones that gained a pinned-destination shape. On solana it would expose a customer's key to forge-mint's SPL mint-creation set — createAccount can park 50,000,000 lamports somewhere nothing here can recover it from — and on bitcoin to a spend of the address's own outputs to any destination a PSBT names, which is the exact hole the deposit rule existed to plug. Both output policies are written down in signing.ts and neither is built, so a deposit address in those families is refused with 403 purpose_forbidden, before the binding comparison, before chain-id resolution, before the pin lookup and before anything is decrypted.

The residual, said plainly. Someone who holds the service token and can restate a deposit address's binding can force that deposit into the treasury and then drain the treasury with a second /sign against the treasury row; before the sweep shape they could only drain what was already in the treasury. The binding is the part they are unlikely to have — its entropy is entirely in orderId and userId, which is why GET /addresses/:address stopped publishing them — so this needs a compromised peer service (forge-pay holds the bindings) or a database read, not the token alone. The pin does not shrink that residual: it removes one class of attack, the redirect, and leaves exactly the exposure the treasury already had. Bounding the rest is forge-pay's job — sweep to a target float, not to zero.

The transaction's own origin. Every family checks the payload against the address it is being signed for, so a caller cannot obtain a signature over a transaction that address did not author:

family bound by payload
evm chain id resolved from the row (generic 'evm' addresses are refused outright — a chain-id-less signature is replayable everywhere) unsigned tx object → signed raw hex
ember the same, with Hearth's own chain id (7411 mainnet, 7412 testnet) and legacy pricing only unsigned tx object → signed raw hex
solana fee payer base64 tx → base64 tx
bitcoin every input's witnessUtxo script base64 PSBT → finalized raw hex
xrp Account unsigned tx object → tx_blob hex

The transaction's shape. Each family refuses everything outside the one shape a caller in this estate needs: EVM and Ember sign exactly one of a zero-value contract creation, a plain value transfer, or a transfer to the pinned treasury — never two of them from one address, and all three with a bounded gas limit and fee ceiling; Solana signs only forge-mint's SPL mint creation, instruction by instruction; Bitcoin signs only SIGHASH_ALL spends of its own P2WPKH outputs; XRP signs only a plain XRP Payment (no SetRegularKey, no SignerListSet, no issued currencies, no paths), and from a deposit address only one whose Destination is the pin.

A value transfer carries no calldata, which is the whole difference between it and a signing oracle: with calldata the same transaction is an arbitrary contract call, and approve(attacker, 2^256-1) is to != null, value = 0. That holds for a sweep too, whose to is an address this vault generated as an EOA: the calldata is refused because nothing needs it, not because the destination is provably safe. EIP-7702 lets an EOA delegate to code, so "safe" would be a claim about another chain's fork schedule.

Not yet real: per-user authorization. row.userId is still compared to nothing, because nothing in a signing request proves which user is behind it. Fixing that needs the callers to forward a Nimbus access token, and an answer for treasury addresses, which belong to no user. It is a change to forge-mint and to forge-pay's withdrawal path, not one this service can make alone.

This section is the summary. The long version — every refusal, per family, with the arguments for each — is MAP.md §4, and the two files are checked against the code, and so against each other, by src/docs.test.ts. Where they ever disagree again, MAP.md and the code are right and this is stale.

Ember keys

Hearth was rebuilt from a UTXO chain into an account-model EVM chain, so Ember custody is now ordinary EVM custody: secp256k1 keys, 0x addresses that are the last 20 bytes of keccak256(pubkey) rendered EIP-55, and legacy EIP-155 transactions bound to chain id 7411 on mainnet and 7412 on testnet.

Everything Hearth-specific went away with the old chain. There is no Ed25519 keypair, no ember1… bech32 address, no canonical-JSON transaction body, and no @cloudsforge/hearth-node dependency — this service no longer imports anything from the chain at all. The ^0.1.0 pin that guarded against the records skew between two node builds is moot: there is no longer a signed body for a records key to appear in.

ember survives as a distinct key family rather than being folded into evm, for reasons that are about bindings rather than cryptography: its chain id is not in shared's deploy-target list, it accepts legacy pricing only, and family is a stored value that every caller restates at signing time.

The two ids are Hearth's, read from its CHAIN_IDS (node/src/params.js) and copied into chains.ts EMBER_CHAIN_IDS; nothing else in the estate holds a third copy, and these two have to track Hearth's. A node rejects a signature carrying the wrong id, and this service refuses one whose restated id is not the expected one — a 403 binding_mismatch that reads like a corrupted binding and would really be a configuration disagreement.

Configuration

KEYVAULT_SERVICE_TOKEN and KEYVAULT_MASTER_SECRET are required, must be at least 24 characters, and known placeholders are rejected by name. The service will not boot without them — deliberately, since a keyvault that starts on default secrets is worse than one that doesn't start.

Stored ciphertext is versioned: v<n>: + base64(iv || authTag || ciphertext), where the version selects the key derivation. Blobs written before versioning have no prefix, are read as v0 under their original salt, and keep working untouched. Rotating KEYVAULT_MASTER_SECRET therefore means adding a v2 derivation and re-encrypting in the background rather than a flag day — the envelope is in place; the second secret and the re-encryption pass are not written yet.

Running it

pnpm install
pnpm dev
pnpm --filter @cloudsforge/forge-keyvault test   # the refusals, the pin's guard,
                                                 # holder liveness, and this file

License

MIT — see LICENSE.

How this was built

Parts of this repository were produced with AI assistance, and it seems worth saying so plainly rather than leaving it to be inferred.

  • Code — written with Claude Opus 5 and Claude Opus 4.8 (Anthropic), reviewed and directed by a human, and gated on the same tests and CI as anything else here.
  • Artwork — brand marks, icons and in-game art generated with OpenAI's image models (GPT Image 1, 1.5 and 2), driven by the manifest pipeline in asset-forge.

The models were used under paid API access and the output is the project's to use. Nothing here is claimed to be hand-written that is not, and nothing is claimed to work that has not been tested.

About

Custodial key service: mints, encrypts and narrowly signs for EVM, Solana, Bitcoin, XRP and Ember

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages