> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Local Private Key Signer

> Development-only local signer for quick testing without KMS

# 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

| Property                  | AWS KMS (Tier 0)           | LocalPrivateKeySignerEvm (Tier 3)               |
| ------------------------- | -------------------------- | ----------------------------------------------- |
| Sign-without-expose       | Yes — key never leaves HSM | No — key loaded into process memory             |
| Key at rest               | AWS-managed HSM            | 1Password vault (`op`) or `.env` file (`plain`) |
| Access audit              | CloudTrail per-sign        | 1Password server log (`op`) or none (`plain`)   |
| Core-dump exposure        | Not applicable             | Mitigated (prctl + RLIMIT\_CORE)                |
| Production recommendation | Default                    | Dev / evaluation only                           |

***

## 2. Quickstart

### Backend A: 1Password CLI (`op`) — recommended Tier-3

1. Install the [1Password CLI](https://developer.1password.com/docs/cli/get-started).
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:

```bash theme={null}
MG_LOCAL_SIGNER_BACKEND=local_private_key_evm
MG_LOCAL_SIGNER_KEY=proving-mainnet
MG_LOCAL_SIGNER_KEY_SOURCE=op
MG_LOCAL_SIGNER_KEY_REF=op://QTG/EvmProving/private-key
MG_LOCAL_SIGNER_REFETCH_PER_SIGN=true
```

4. Sign in to 1Password CLI before starting QTG:

```bash theme={null}
op signin
uv run uvicorn qtg.main:app --port 8100
```

`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.**

```bash theme={null}
MG_LOCAL_SIGNER_BACKEND=local_private_key_evm
MG_LOCAL_SIGNER_KEY=proving-dev
MG_LOCAL_SIGNER_KEY_SOURCE=plain
MG_LOCAL_SIGNER_KEY_REF=MG_LOCAL_SIGNER_PRIVATE_KEY_HEX
MG_LOCAL_SIGNER_PRIVATE_KEY_HEX=0xaaaa...  # 64 hex chars (32 bytes)
MG_LOCAL_SIGNER_REFETCH_PER_SIGN=true
```

To combine `plain` with `MG_WORKERS_ENABLED=true` (necessary for dispatch/observe
workers), add the explicit opt-in:

```bash theme={null}
MG_LOCAL_SIGNER_ALLOW_PLAIN_WITH_WORKERS=true
```

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.

```ini theme={null}
[Unit]
Description=quant-transfer-guard
After=network.target

[Service]
User=qtg
Group=qtg
WorkingDirectory=/opt/qtg
EnvironmentFile=/opt/qtg/.env
ExecStart=/opt/qtg/.venv/bin/uvicorn qtg.main:app --host 127.0.0.1 --port 8100

# --- Hardening directives (required for LocalPrivateKeySignerEvm) ---
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProcSubset=pid
LimitCORE=0
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

[Install]
WantedBy=multi-user.target
```

> **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).

```xml theme={null}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.jephalabs.qtg</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/qtg/.venv/bin/uvicorn</string>
        <string>qtg.main:app</string>
        <string>--host</string>
        <string>127.0.0.1</string>
        <string>--port</string>
        <string>8100</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/opt/qtg</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>MG_LOCAL_SIGNER_BACKEND</key>
        <string>local_private_key_evm</string>
        <!-- add remaining MG_* vars here -->
    </dict>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <!-- Disable core dumps at the launchd resource-limit level.
         QTG also calls setrlimit(RLIMIT_CORE, (0, 0)) in-process. -->
    <key>SoftResourceLimits</key>
    <dict>
        <key>Core</key>
        <integer>0</integer>
    </dict>
    <key>HardResourceLimits</key>
    <dict>
        <key>Core</key>
        <integer>0</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>/var/log/qtg/stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/qtg/stderr.log</string>
</dict>
</plist>
```

Load with:

```bash theme={null}
launchctl load ~/Library/LaunchAgents/com.jephalabs.qtg.plist
```

***

## 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):**

```bash theme={null}
MG_LOCAL_SIGNER_ALLOW_DEGRADED_HYGIENE=true
```

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:

```json theme={null}
{
  "hygiene": {
    "prctl_set_dumpable": "skipped_not_linux",
    "rlimit_core": "applied",
    "all_critical_applied": true
  }
}
```

| `prctl_set_dumpable` value     | Meaning                                                  |
| ------------------------------ | -------------------------------------------------------- |
| `"applied"`                    | `prctl(PR_SET_DUMPABLE, 0)` succeeded                    |
| `"skipped_not_linux"`          | Non-Linux platform, not applicable (macOS/WSL2 excluded) |
| `"unavailable_libc_not_found"` | libc not discoverable (rare container config)            |
| `"failed_errno_<N>"`           | prctl syscall returned error                             |

| `rlimit_core` value | Meaning                                    |
| ------------------- | ------------------------------------------ |
| `"applied"`         | `setrlimit(RLIMIT_CORE, (0, 0))` succeeded |
| `"failed_<msg>"`    | setrlimit raised OSError or ValueError     |

`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)

| Limitation                                                          | Root cause                                                  | Mitigation                                                                                                |
| ------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `bytes(self._key_buffer)` immutable copy for `eth_keys` constructor | `eth_keys.PrivateKey` requires `bytes`                      | Accepted — GC handles eventually. Short-lived per sign call when `refetch_per_sign=True`.                 |
| `eth_keys` internal storage (C extension)                           | Not zeroable from Python                                    | Accepted. Buffer lifetime bounded by `eth_keys.PrivateKey` object lifetime.                               |
| `plain` backend: `os.environ[ref]` persists in process env block    | Python process environment is live for process lifetime     | Documented. Wipe applies to working copy only; original env var persists. Use `op` backend to avoid this. |
| Hex decode intermediate `str` from `op` stdout                      | `bytes.decode("ascii").strip()` creates a short-lived `str` | Accepted. Hex `str` is released before `_load_key()` returns.                                             |

**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:

```json theme={null}
{
  "signer_kind": "local_private_key_evm",
  "key_source": "op",
  "key_ref_hash": "a3f1c8e2b0d4f7a1",
  "signer_address": "0x1234...abcd",
  "refetch_per_sign": true,
  "warnings": ["plaintext_in_process"]
}
```

Field notes:

| Field              | Description                                                                                                          |
| ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `signer_kind`      | Always `"local_private_key_evm"` for this implementation.                                                            |
| `key_source`       | `"op"` or `"plain"` — which backend loaded the key.                                                                  |
| `key_ref_hash`     | First 16 hex chars of `sha256(key_ref)`. Identifies the reference without exposing it.                               |
| `signer_address`   | Lowercase EVM address derived at bootstrap via `keccak256(pubkey_64_bytes)[-20:]`. Same format as `AwsKmsEvmSigner`. |
| `refetch_per_sign` | `true` = key buffer is wiped between signs; `false` = key resident in memory.                                        |
| `warnings`         | Always includes `"plaintext_in_process"`. Additionally `"plain_backend_active"` when `key_source="plain"`.           |

***

## 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:

| File                                                 | Strict check                             | Behavior with `local_private_key_evm` |
| ---------------------------------------------------- | ---------------------------------------- | ------------------------------------- |
| `src/qtg/interfaces/tools/cctp_live_preflight.py`    | Raises if `backend != "aws_kms_evm"`     | Preflight unusable                    |
| `src/qtg/interfaces/tools/gateway_live_preflight.py` | Constructs `AwsKmsEvmSigner` directly    | Preflight unusable                    |
| Stargate send drill (Pro tool)                       | Requires `backend.startswith("aws_kms")` | Drill skipped (Pro tool)              |
| `src/qtg/interfaces/tools/_drill_common.py`          | Matches `aws_kms*` prefix only           | Drill helper short-circuits           |

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.
