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.
/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 whosetomust equal the treasury address pinned for the source row's(chain, network), read fromvault_treasuriesand never from the request — the caller does not get to name the destination, not even redundantly. An unpinned chain signs nothing: 403no_treasury_pinned, resolved before anything is decrypted. And the pin is writable only throughPUT /admin/treasuries/:chain/:networkbehind 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, withpurpose: '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 alreadyPOST /addresseswithpurpose: '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}. Makingdepositsignable widens what a deposit key may sign in every family at once, not only the ones that gained a pinned-destination shape. Onsolanait would expose a customer's key to forge-mint's SPL mint-creation set —createAccountcan park 50,000,000 lamports somewhere nothing here can recover it from — and onbitcointo 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 insigning.tsand neither is built, so adepositaddress in those families is refused with 403purpose_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.userIdis 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.
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.
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.
pnpm install
pnpm dev
pnpm --filter @cloudsforge/forge-keyvault test # the refusals, the pin's guard,
# holder liveness, and this fileMIT — see LICENSE.
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.