kms-wrapper is a Go CLI and REST gateway for multi-chain signing backed by HashiCorp Vault. Keys are managed by a custom Vault secrets plugin, kms-vault-plugin, that natively supports secp256k1 for both EVM and Cosmos signing. Private key material never leaves the Vault process boundary.
- Vault OSS Transit does not support
secp256k1(noecdsa-p256k1key type). - The community
vault-ethereumplugin keccak256-hashes inputs internally — incompatible with Cosmos SDK's SHA-256 signing — and does not expose raw compressed pubkeys. kms-vault-pluginis purpose-built: it signs pre-hashed 32-byte inputs with no internal hashing, so the gateway computes the chain-appropriate digest (keccak256 for EVM, SHA-256 for Cosmos) before calling the plugin.
See docs/archive/2026-05-30-multi-chain-kms-gateway/design.md for the full rationale. (This project migrated from OpenSpec to the Superpowers workflow on 2026-06-17 — see /CONSTITUTION.md.)
- Go 1.25+
- Vault 1.17+ (older versions also work for the plugin SDK API, but local dev assumes 1.17)
- Docker Compose for local development
cp .env.example .env
# 1. Cross-compile the plugin binary for linux/amd64 into vault/plugins/.
make build-plugin
# 2. Start Vault 1.17 (dev mode) + register the plugin + mount it at kms/.
make dev-up
# 3. Use the CLI to create a key and start the gateway.
export VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root KMS_GATEWAY_TOKEN=dev
go run ./cmd/kms-wrapper keys create --path proj-a/prod/alice
go run ./cmd/kms-wrapper serveThe config file (~/.kms-wrapper/config.yaml by default, override with --config) is optional. If it is missing, the CLI prints a warning to stderr and continues using environment variables and built-in defaults. If the file exists but is malformed (invalid YAML, unreadable, etc.) the CLI exits non-zero with a read config error.
Resolution precedence: defaults → config file (if present) → env overrides. After resolution, required runtime fields (vault.addr, vault.token, gateway.token) are validated and startup fails with a descriptive error if any are missing. kms-wrapper health distinguishes these config/validation errors from Vault connectivity failures.
make dev-up is idempotent — re-running it rebuilds the plugin and re-registers it with a fresh SHA-256. make dev-down tears down the stack (in-memory dev mode, so all keys are lost).
# Inside the container — VAULT_ADDR defaults to https:// in the image.
docker compose exec -e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=root vault \
vault plugin info -version="" secret kms-vault-plugin
docker compose exec -e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=root vault \
vault write -force kms/keys/proj-a/prod/aliceLogical key paths are {project}/{environment}/{username} using lowercase [a-z0-9_-] segments.
- Read / write / delete:
kms/keys/<project>/<environment>/<username> - Sign:
kms/sign/<project>/<environment>/<username>
The {environment} segment is free-form ([a-z0-9_-]), e.g. prod, staging, dev. See vault/policy-project.hcl for a sample project-scoped Vault policy.
| Method | Path | Purpose |
|---|---|---|
| POST | kms/keys/<name> |
Generate a secp256k1 key (idempotent). Response includes compressed_pub_key (base64), evm_address, source, created_at. Never returns the private key. |
| GET | kms/keys/<name> |
Read the same public-safe view as create. |
| LIST | kms/keys/<prefix>/ |
List key names under a prefix. |
| DELETE | kms/keys/<name> |
Delete the key entry. |
| POST | kms/sign/<name> |
Sign a 32-byte pre-hashed input. Body: {"input": "<64-hex>"}. Response: {"r": "<hex>", "s": "<hex>"}. |
GET /health is unauthenticated and returns {"status":"ok","vault":"reachable"} or HTTP 503 with degraded status.
All other routes require Authorization: Bearer <KMS_GATEWAY_TOKEN>.
POST /sign/evm:
{"key_path":"proj-a/prod/alice","chain_id":1,"raw_tx":"0x..."}Alternatively use personal_message or eip712_digest. Responses include signed_tx for raw transactions or a signature hex string.
POST /sign/cosmos:
{"key_path":"proj-a/staging/alice","hrp":"mantra","sign_mode":"DIRECT","sign_doc":"<base64>"}Use sign_mode: "AMINO_JSON" with a JSON sign_doc for legacy amino. Responses include base64 signature and compressed pub_key.
The gateway exposes the same key lifecycle as kms-wrapper keys. All three require Authorization: Bearer <KMS_GATEWAY_TOKEN> and share the /sign/* rate-limit budget (gateway.rate_limit / gateway.rate_burst).
| Method | Path | Purpose |
|---|---|---|
| POST | /keys |
Create a secp256k1 KMS key. Idempotent — re-create returns the existing key with already_existed: true. |
| GET | /keys/info?path=<key-path> |
Show public key (hex), EVM address, and Cosmos bech32 address. |
| GET | /keys?prefix=<prefix> |
List bare key names under prefix; prefix optional. |
# Create
curl -H "Authorization: Bearer $KMS_GATEWAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"path":"proj-a/prod/alice"}' \
http://127.0.0.1:8080/keys
# Show
curl -H "Authorization: Bearer $KMS_GATEWAY_TOKEN" \
"http://127.0.0.1:8080/keys/info?path=proj-a/prod/alice"
# List
curl -H "Authorization: Bearer $KMS_GATEWAY_TOKEN" \
"http://127.0.0.1:8080/keys?prefix=proj-a/"Swagger UI is served by the gateway at GET /swagger/index.html (or GET /swagger/) and the raw OpenAPI spec at GET /swagger/doc.json.
The committed docs/swagger.json is the regenerated artifact for codegen and Postman import. Its servers[0].url is the placeholder http://localhost:8080/ — valid OpenAPI, but a static stand-in. The runtime GET /swagger/doc.json endpoint is the source of truth for the live UI: it rewrites servers[0].url on every request to reflect the gateway's actual origin (honouring X-Forwarded-Proto and Host when behind a reverse proxy).
Install the generator CLI once:
go install github.com/swaggo/swag/v2/cmd/swag@latestRegenerate committed docs after editing handlers/types:
make swagger
make swagger-checkIn Swagger UI, click Authorize and enter Bearer <KMS_GATEWAY_TOKEN> to run authenticated /sign/* requests.
For internet-exposed deployments, set KMS_GATEWAY_SWAGGER_AUTH=true to require bearer auth on /swagger/*, or KMS_GATEWAY_SWAGGER_ENABLED=false to disable the docs surface entirely.
kms-wrapper --config ~/.kms-wrapper/config.yaml --log-level info
kms-wrapper serve
kms-wrapper keys create --path proj-a/prod/alice
kms-wrapper keys show --path proj-a/prod/alice
kms-wrapper sign evm --path proj-a/prod/alice --chain-id 1 --raw-tx 0x...
kms-wrapper sign cosmos --path proj-a/staging/alice --hrp mantra --mode DIRECT --sign-doc <base64>
kms-wrapper healthcmd/
kms-wrapper/ # CLI + gateway entrypoint
kms-vault-plugin/ # Plugin binary entrypoint
internal/
plugin/ # kms-vault-plugin (backend, path_keys, path_sign)
vault/ # Gateway-side Vault client (calls plugin API)
signer/{evm,cosmos}/ # Chain-specific hash + signature assembly
gateway/ # HTTP server, middleware, handlers
config/ # Viper config + validation
pkg/types/ # Shared types (SignRequest/Response, KeyInfo, errors)
vault/
plugins/ # Plugin binary output (git-ignored)
init.sh # Registers + enables the plugin against a running Vault
policy-project.hcl # Sample per-project Vault policy
Vault OSS HA replicates plugin registration and key data via Raft, but not the plugin binary itself. Every Vault node must have an identical kms-vault-plugin binary at the configured plugin_directory with a SHA-256 matching the registered value. The recommended pattern is a custom Vault Docker image with the plugin baked in. This is currently a deferred follow-up — see design D2c.
Contributions are welcome. The process is intentionally lightweight:
- Open an issue first for anything non-trivial (bug, feature, design change) so we can align before you spend time on a PR.
- Fork the repo and create a branch off
mainwith a descriptive name (e.g.feat/cosmos-multisig,fix/vault-renewal-race). - Follow the code style — idiomatic Go,
golangci-lintmust pass (make lintif wired up, or run it directly). No new linter warnings. - Write tests. New behaviour needs WHEN/THEN scenario coverage matching the pattern in
internal/*_test.go. Seetesting-guide.mdfor the testing approach. - Use conventional-commit prefixes in commit messages:
feat:,fix:,docs:,refactor:,test:,chore:. Mark breaking changes with aBREAKING:footer. - Keep PRs focused. One logical change per PR. If you're fixing a bug and noticing unrelated cleanup, split them.
- Security matters here. This project handles cryptographic key material. Any change that touches signing paths, Vault auth, or token handling must include a brief security rationale in the PR description. If you find a security vulnerability, please report it privately before opening a public issue.
- CI must pass. PRs are merged only when all checks are green.
There is no CLA. By submitting a PR you agree to license your contribution under the Apache License 2.0.
Apache License 2.0 — see LICENSE.