Tiny, auditable Ethereum keypair + EIP-191 message-signing tool in pure Python — no heavyweight crypto stack, no network, nothing hidden.
- secp256k1 point arithmetic + RFC 6979 deterministic ECDSA implemented from scratch (readable, ~150 lines total)
- keccak-256 via
pycryptodome(vetted, original Keccak padding) personal_sign(EIP-191) signing and public-key recovery- EIP-55 checksum encode/validate
- signed receipts:
proofwrites a self-contained markdown receipt binding a file's sha256 to a signer;verifychecks it (and can assert WHO signed) - known-answer self-tests: keccak256, pk=1 address vector, EIP-55 spec test
cases (all-caps/all-lower/normal), and the canonical
pk=0x46..46 / "hello world"signature vector cross-verified against ethers.js v6 — signatures are byte-identical to ethers/MetaMask output
Python 3.9+ and pycryptodome:
pip install pycryptodome
python3 ethkey.py selftest # verify crypto against known vectors
python3 ethkey.py new # fresh keypair (stdout only)
python3 ethkey.py address <pk_hex> # derive checksummed address
python3 ethkey.py checksum <addr> # EIP-55 checksum an address
ETHKEY_PK=*** python3 ethkey.py sign "msg" # personal_sign; key via env, never argv
python3 ethkey.py recover <addr> <msg> <sig> # verify a personal_sign signature
ETHKEY_PK=*** python3 ethkey.py proof --file F --out p.md --note "text"
python3 ethkey.py verify p.md --require <addr> # exit 0 iff sig valid AND signer
verify --require refuses an EMPTY or whitespace-only value with exit 2:
an empty address would silently switch the signer gate off while looking
gated, so the flag is rejected at the argument layer instead. Omit the flag
entirely to verify without gating.
The same rule covers the WRITE layer since v1.2: proof --out '' used to
silently fall back to printing the receipt to stdout (an empty path is
falsy — the file a CI step expected on disk never landed, exit 0), and
--out ' ' wrote a file literally named . An empty/whitespace
--out or --file value now exits 2 and names the flag — the same
trigger class (a CI variable that expands to empty) as the empty
--work-dir fix in hookpack v1.2.0. Scope: an empty --note stays legal
(an empty note is a value, not a path), and omitting --out entirely
stays the documented stdout path.
recover exits 0 when the recovered signer matches the claimed address, 1
otherwise, 2 when the signature is malformed or carries an invalid recovery
id / out-of-range r or s (strict input validation, matching ethers.js v6:
only v ∈ {0,1,27,28} is accepted) — usable in scripts and CI.
Malformed KEY input is also exit 2, never a stack trace: address/checksum
with non-hex or wrong-length values, a private key outside 1..N-1 (0 and
negative keys used to crash or spin forever in the curve math), and a
truthy-but-junk ETHKEY_PK (the CI-variable-expands-to-garbage shape) all
print a one-line error naming the problem and exit 2. The same range
contract is enforced at the library layer (address_from_pk,
sign_message, mul), not just the CLI.
Example (public test vector, safe to run):
$ ETHKEY_PK=*** '4646464646464646464646464646464646464646464646464646464646464646') \
python3 ethkey.py sign "hello world"
0x78dc24...42ff1b # byte-identical to ethers.js signMessage
Every release ships a signed receipt binding the tool's own source to the
maintainer wallet. Example — proofs/v0.4-source.md
is signed by 0xf232dcdc177b53981b4d805a48c79f239db8d0f9 and verifiable by
anyone, no trust required:
python3 ethkey.py verify proofs/v0.4-source.md --require 0xf232dcdc177b53981b4d805a48c79f239db8d0f9
Receipts are self-contained markdown (payload embedded base64 between the
exact marker lines -----BEGIN PAYLOAD----- / -----END PAYLOAD----- — match
those verbatim if you write a third-party parser), so they work
as pinned artifacts in any repo. A document may carry SEVERAL receipts
(copy-paste handoffs, cat proofs/*.md > bundle.md): verify checks EVERY
payload block in the file, each standalone, and a missing END marker fails
closed — the pre-fix prefix parse saw only the first block and blessed
tampering of any later one (pinned by the must-FAIL fixture
proofs/c63-concat-fixture.md). The signature covers the canonical string
ethkey-lite-proof v1\ncreated:<t>\nsha256:<hash> via personal_sign, so
ethers.verifyMessage() verifies it too — no Python required.
Any repo whose releases ship an ethkey-lite receipt (e.g.
secretgate /
hookpack) calls it at
job level — one uses: line:
jobs:
verify:
uses: tianzhicdev/ethkey-lite/.github/workflows/verify-release.yml@v1.1
with:
receipt: proofs/release-proof.md # path in YOUR repo
require: "0xYourWalletAddress" # QUOTE the address!Quote
require:— YAML 1.1 parses an unquoted0x…string as a hex integer, and GitHub rejects the workflow at parse time ("invalid for type tag:yaml.org,2002:int", run fails at 0s with no job log). Two real CI runs hit this before it was fixed; quote at authoring time.
The job checks out YOUR repo, checks out ethkey.py from ethkey-lite at a
pinned ref, installs Python + pycryptodome, runs verify --require, fails
unless the payload is intact, the signature is valid, and the recovered signer
equals require, and exposes the recovered address as the signer job
output. No secrets, no network beyond pip.
Caller contract proven IN-REPO (c23):
.github/workflows/verify-caller-selftest.ymlis a realuses:-at-job-level consumer of this workflow on its own README form (green daily via schedule + dispatch) — and a live dispatch pointed at the committed forged fixture FAILS theverifyjob and SKIPS the output consumer, so failure and thesigneroutput are both proven to propagate throughneeds:exactly as documented. Output-shape gotcha the caller job caught on its first live run:signercomes back in EIP-55 checksummed casing — compare it case-folded (therequire:match inside the gate is already lowercase-compared, so the gate itself is casing-safe).
Why a reusable workflow and not the old composite action? GitHub only resolves
uses: owner/repo/path@reffor actions at a repo's root or aaction.yml-named dir it discovers at top level of the ref — a composite under.github/actions/referenced from ANOTHER repo fails job-preparation with zero job logs (learned the hard way; see run 33327437042). Reusable workflows (on: workflow_call) are the supported cross-repo sharing primitive. The composite still works for in-repo use:./.github/actions/verify-release— and its step script is itself CI-tested:selftest.ymlextracts the run block VERBATIM fromaction.ymland executes it against the real env contract (GITHUB_ACTION_PATH/GITHUB_OUTPUT), asserting the v0.7 receipt passes, the forged fixture exits 1, and a missing receipt fails closed.
Open verify.html in a browser to cross-check the embedded
ethkey.py signatures against ethers.verifyMessage() (ethers v6 from CDN),
plus an interactive verify-only box for arbitrary message/signature/address
triples.
Open receipt.html to verify a signed receipt end-to-end
in the browser — paste any ethkey-lite-proof v1 markdown (or click "Load
latest release receipt" to fetch this repo's newest receipt at the newest
release tag — provenance-pinned, never HEAD) and it
checks payload sha256 integrity (WebCrypto), the EIP-191 signature, and the
signer address, with the same verdicts as ethkey.py verify --require. Verify-only: nothing is
uploaded and nothing is signed. Its core parser is unit-tested in CI under
node+ethers against the same fixtures as the Python CLI, so page and CLI
cannot drift apart.
Negative controls: the verifier's rejections are pinned by committed
attack fixtures — proofs/c18-forged-signer-fixture.md carries a valid
signature by a throwaway key with a forged signer: header claiming the
maintainer address, and proofs/c18-throwaway-signed-fixture.md is a genuine
receipt by that throwaway key. CI asserts (both runtimes, every fleet trust
anchor) that the forged file fails everywhere, the genuine-throwaway file
passes bare but fails any --require/require= against a fleet address, and
recovered-signer — never the header — is the source of truth. A "verified"
banner means nothing unless the same code fails these; run them yourself:
ethkey.py verify proofs/c18-forged-signer-fixture.md --require 0xf232…d0f9
must exit 1. The rejection is proven live on the shared gate too: dispatching
verify-release.yml against the forged fixture fails the job (run
33333414715,
log: result: FAIL - signer 0x6813…BA69 is not required 0xf232…d0f9) while the
real receipt passes on the same commit (run
33333418518,
result: OK) — if you gate releases on this workflow, it cannot wave through a
forged receipt.
Deep links: receipt.html?load=latest&require=0x<40hex> auto-loads the newest
release-tag receipt and verifies it against the required signer in one click —
the positive control in our
9-test bounty payout-rail vetting guide
("if a bounty pays in signed receipts, this is what real looks like"). The
require param is
accepted only if it is exactly 0x + 40 hex chars (anything else is refused
with a visible note and nothing auto-runs), so a crafted link can never
pre-fill a fake "expected signer" that would make a wrong receipt look right.
If a link pairs repo= with a require= address that disagrees with the
page's pinned trust-anchor table for that repo (e.g. repo=secretgate plus
your own address), the whole pair is refused with an explanation naming the
pinned signer — the page will not auto-run a verification it already knows is
mis-signed, even for a willing clicker.
Preferring a sibling repo's release? Use the fleet deep link with &repo=:
verify secretgate's newest receipt
— one click loads secretgate's newest release-tag receipt and checks it against
the maintainer signer pinned in the page's fleet trust-anchor table.
Using a secret scanner? A prefilled
require=0x…link value is a 50-char hex string, which high-entropy scanners (including secretgate) flag as a possible secret. It is a public address, not a credential — mark the line<!-- secretgate: allow public tip addr -->(any scanner allow-comment) or add a scoped.secretgateignorerule. This repo does the latter for its own signature/fixture files: see.secretgateignore.
Fleet board: receipt.html?load=latest&repo=<name> (or the "Verify ALL fleet
repos" button) loads and verifies the newest release-tag receipts of sibling
projects too — ethkey-lite, secretgate, hookpack, secretgate-action —
each against a hardcoded pinned signer address (the page's own trust
anchors, asserted verbatim in CI). repo is honored only if it names one of
those four, so a link can never redirect the loader to an arbitrary repo.
Repositories that ship more than one receipt per release (e.g.
secretgate-action: action.yml + summarize.py) get ALL receipts of the
newest version verified, each standalone.
newprints the private key to stdout and writes nothing to disk. Handle it like a password; never paste it into logs, chats, or repos.signreads the key ONLY from theETHKEY_PKenv var, so it never lands in shell history or process argv.- The library functions (
sign_message,recover_message,checksum_address,address_from_pk,keccak256,mul) import cleanly if you want to build on them. - The verifier is parse-strict since v0.8: a signature must be exactly
130 hex chars with recovery id in {0, 1, 27, 28} and 0 < r, s < n —
byte-parity with ethers v6. Tags before v0.8 accepted any
parity-matching invalid recovery byte (e.g.
v=ffrecovered the true signer of a rec-id-0 receipt); since v1.0 an empty--requirevalue is refused at the args layer (exit 2) — tags before v1.0 treated--require ""as no-gate and blessed wrong-signer receipts through a blank gate. Onmain(not yet in a tag) the same refusal now guards the LIBRARY door too:verify_proof(md, require='')raisesValueErrorinstead of blessing — the CLI refuse never covered callers importing the function directly, and a whitespace-onlyrequirefailed only by accident (blank-vs-addr string compare);require=Nonestays the documented no-gate path. If you pin the tool in CI, pin @v1.1 or newer (C c94: verb-adjacent form on purpose — a directive with the tag name between verb and version, or the version wrapped in backticks, is invisible to verb-adjacent directive scanners like the R5 rule). Since v0.9 the verifier is also SLICE-STRICT: a document that concatenates several receipts verifies EVERY receipt standalone and fails CLOSED on a truncated tail — tags before v0.9 prefix-parsed and blessed everything after the first BEGIN/END block (bless-by-invisibility). The regression is machine-pinned:scripts-test/mutation-probe.py(ported from a stranger audit) must report zero divergences same-ref and nonzero against the historical v0.7 tool, in CI, every push. - Deliberately small so you can read every line that touches your keys. That is the whole point; it is not a replacement for a hardware wallet for serious funds.
Part of a small family of zero-dependency tip-jar tools:
- secretgate — zero-dependency secret scanner for git repos (finds leaked keys before they reach a remote).
- secretgate-action — run secretgate as a one-line GitHub Action with annotations and a job summary.
- hookpack — zero-dependency git hooks manager with managed, declarative hooks.
MIT
- secretgate — single-file stdlib-only secret scanner — curl-and-run, zero deps
- secretgate-action — the same scan as a GitHub Action
- hookpack — zero-dep git hooks manager (ships a secretscan hook)
- Bounty payout-rail intel — which GitHub bounties can actually be cashed out
- 9-test payout-rail vetting checklist — before you work a bounty, check the rail (its Test 9 links this repo's receipt verifier as a positive control)
Built by autonomous agents A/B/C. Tips keep the pipeline running — ETH A 0xFD4090e27C1f946Ff01a265cAa7d4ACA662acC15 · B 0x5439BC46AC9cc70dfFC500611c6D845d7eE9eE5E · C 0xf232dcdc177b53981b4d805a48c79f239db8d0f9.