Skip to main content

LocalPrivateKeySignerEvm — Operator Reference

Tier-3 signer. Plaintext key material is held in process memory. This is the correct choice for local evaluation and development. For production fund movement, migrate to AWS KMS (Tier 0) or a Pro Tier-1 signer (Vault Transit, GCP KMS) before increasing volume.
Source of truth:
  • src/qtg/infrastructure/signers/local_private_key_evm.py

1. When to use / when not to use

Use when

  • Evaluating QTG before committing AWS infrastructure.
  • Running a development or CI environment where AWS KMS is unavailable.
  • Non-AWS deployments (GCP, Azure, bare-metal) that have not yet provisioned a Tier-1 signer.
  • Single operator / solo quant experimentation at low fund-movement volume.

Do not use when

  • Fund movement volume is high or consequence of key compromise is severe.
  • You require a cryptographic sign-without-expose guarantee (key material never leaves the HSM or KMS).
  • You are running on Windows (use WSL2, which gives you a Linux environment).
  • Compliance requirements mandate hardware-backed key storage (YubiHSM 2, HSM-backed KMS).

Security posture summary


2. Quickstart

  1. Install the 1Password CLI.
  2. Store your EVM private key (32-byte hex, with or without 0x prefix) as a password field in a 1Password item. Note the op:// reference URI: op://<vault>/<item>/<field>.
  3. Set env vars:
  1. Sign in to 1Password CLI before starting QTG:
REFETCH_PER_SIGN=true (default) re-invokes op read on every sign() call. The working key buffer is wiped immediately after signing. The op subprocess adds ~200–500 ms per signing call, which is acceptable for CCTP/Gateway workloads of 1–2 operations per minute.

Backend B: Plain env var (plain) — quickstart / dev only

This backend emits a startup warning and should never be used in production.
To combine plain with MG_WORKERS_ENABLED=true (necessary for dispatch/observe workers), add the explicit opt-in:
Without this flag, bootstrap raises a ValueError at startup when both key_source=plain and workers_enabled=true are set. This prevents accidental production deployment with plaintext keys in a fully-live worker context.

3. systemd unit example (Linux)

Place the following fragment in /etc/systemd/system/qtg.service. The hardening directives block core dumps and kernel module loading at the OS level, complementing the in-process prctl(PR_SET_DUMPABLE=0) + setrlimit(RLIMIT_CORE=0) that QTG applies at bootstrap when this signer is active.
Operator note on SystemCallFilter: @system-service permits fork/execve so subprocess calls to the op binary work. If you tighten beyond this set, verify that the chosen key-source backend’s subprocess invocation is not blocked. Test with journalctl -u qtg | grep -i seccomp after deploy.
Operator note on PR_SET_DUMPABLE: With prctl(PR_SET_DUMPABLE=0) in effect, the QTG process is non-traceable by gdb/strace even from the same UID. Operators who rely on attaching debuggers for live diagnosis should use AWS KMS or a Pro Tier-1 signer instead. The prctl call is scoped to the local_private_key_evm bootstrap branch only; AWS KMS bootstrap paths are unaffected.

4. macOS launchd plist example

Place at ~/Library/LaunchAgents/com.jephalabs.qtg.plist (user-space) or /Library/LaunchDaemons/com.jephalabs.qtg.plist (system-space for server installs).
Load with:

5. Memory hygiene + Python str-on-heap limitations

What QTG does

  • The 32-byte private key working copy is stored as a bytearray (mutable, zeroable).
  • The buffer is zeroed immediately after signing completes when REFETCH_PER_SIGN=true. When refetch_per_sign=True, a stack-local bytearray is used per sign call — the buffer never touches self._key_buffer, making concurrent sign() calls safe.
  • __repr__ and __str__ return a masked string (key=REDACTED). The _key_buffer field is excluded from the dataclass auto-repr (repr=False).
  • The op binary is resolved to an absolute path at signer construction via shutil.which("op"). The absolute path (e.g. /usr/local/bin/op) is used as argv[0] in every subsequent subprocess.run call, preventing PATH-mutation TOCTOU attacks.
  • To pin a specific binary explicitly, set MG_LOCAL_SIGNER_OP_PATH=/absolute/path/to/op (must be an absolute path to an existing executable). The signer rejects relative paths and non-existent paths at construction with FatalMovementError(SIGNER_REJECTED). An empty or unset value falls back to shutil.which("op") on PATH.
  • The op subprocess uses bytes mode (text=False) so the raw output goes directly into the bytearray without Python str interning.
  • The signer module never logs the key buffer, signature components (r, s), or raw env var values. key_ref is logged only as sha256(key_ref)[:16] fingerprint.

Process hygiene: fail-closed startup behavior

prctl(PR_SET_DUMPABLE=0) (Linux) and setrlimit(RLIMIT_CORE, 0) (Linux + macOS) are applied at bootstrap before the key is loaded. If these fail, QTG blocks startup by default rather than starting in a silently degraded state. Override (not recommended for production):
Setting this allows startup to continue when process hardening fails and logs a warning. The actual hygiene status is surfaced in signer.health() under the "hygiene" key:
all_critical_applied is true only when every platform-applicable hardening succeeds. On non-Linux: only rlimit_core must be "applied". On Linux: both must be "applied".

Acknowledged limitations (Python cannot overcome these)

No global log redaction filter is installed. A regex ^[0-9a-fA-F]{64}$ would also redact valid 32-byte audit data (CCTP burn txids, signed tx hashes, keccak digests) which QTG intentionally logs. Defense stays at the signer-module boundary.

6. Health output schema

GET /v3/signers/{signer_key}/health (or the batch health endpoint) returns:
Field notes:

7. Migration path to AWS KMS or Pro Tier-1

Workers must be paused during the cutover. Switching the signer while in-progress nodes hold references to the old signer can produce signatures from the wrong address (allowlist drift, double-spend windows in worst case).
  1. Provision the target backend out-of-band (AWS KMS key + IAM role, Vault Transit mount + token, GCP KMS keyring + service account, etc.). Derive the new EVM address from the new backend (call bootstrap_signer_address() on a throwaway init or use the health endpoint after a test deploy).
  2. Add the new signer address to any on-chain allowlists (CCTP attester allowlist, Stargate trusted bridge address, etc.) while keeping the old address active. Both addresses authorized simultaneously bridges the cutover.
  3. Pause workers: set MG_WORKERS_ENABLED=false, restart, wait for in-flight nodes to reach a terminal or dispatchable boundary. Monitor node.state until no node is in SUBMITTING or PREPARED for the affected signer.
  4. Switch MG_LOCAL_SIGNER_BACKEND and supporting env vars, restart QTG with workers still disabled.
  5. Verify: the health() endpoint for the new signer returns the expected signer_address. Run a dry-run movement (no actual broadcast) end-to-end.
  6. Send a small testnet transaction through a real movement on testnet to confirm the proving lane works against the new backend.
  7. Re-enable workers (MG_WORKERS_ENABLED=true, restart). Watch the first 1–2 movements closely.
  8. Drain the old proving wallet to the new address (safe now — no live signing happens with the old key).
  9. Remove the old address from on-chain allowlists (revoke).
  10. Wipe old key material: remove the 1Password entry, or unset MG_LOCAL_SIGNER_PRIVATE_KEY_HEX and remove it from .env.

8. Why no Windows support

LocalPrivateKeySignerEvm relies on:
  • prctl(PR_SET_DUMPABLE, 0) — Linux-only syscall (no equivalent in Win32 API usable from pure Python without a C extension).
  • resource.setrlimit(RLIMIT_CORE, (0, 0)) — POSIX only (available on Linux + macOS; not available in CPython on Windows).
  • systemd unit hardening directives — Linux-only.
  • The op CLI and 1Password desktop app integration path is tested on Linux + macOS; Windows agent behavior is outside this spec’s scope.
Operators on Windows: use WSL2. A WSL2 environment is a full Linux kernel and all process-hygiene primitives apply. The launchd plist example (§4) does not apply in WSL2; use the systemd unit example (§3) instead.

AWS-strict tooling caveat

Local private-key signer has no live-readiness preflight in v1. The following operator tools remain AWS-KMS-only and are intentionally unchanged in this release. They will return errors or skip when MG_LOCAL_SIGNER_BACKEND=local_private_key_evm is active: This is a deliberate scope boundary. Touching these tools would expand blast radius beyond the agreed Tier-3 scope (each tool would need its own loader + tests + signer-construction branch). A capability-based preflight refactor is planned for the first Pro Tier-1 signer release (Vault Transit / GCP KMS). To validate before mainnet use: sign a known unsigned testnet transaction through a real movement (template + approval + actual broadcast on testnet) and confirm the recovered address + on-chain inclusion. Operators who need a richer programmatic preflight should defer mainnet adoption until either (a) they migrate to AWS KMS where the existing preflights cover them, or (b) the Pro Tier-1 sign-without-expose signers ship with a capability-based preflight refactor.