Skip to content

Take a credential without ever holding one - #29

Merged
Wired4ncer merged 1 commit into
mainfrom
feat/rpc-credentials-from-env
Aug 9, 2026
Merged

Wired4ncer merged 1 commit into
mainfrom
feat/rpc-credentials-from-env

Conversation

@Wired4ncer

Copy link
Copy Markdown
Owner

Everything issue #24 needs except the run itself, which requires a password this repository must never see.

Credentials that never pass through a person

BitcoinRpc.from_env reads three sources, in descending order of how well they contain the secret:

  1. systemd LoadCredential$CREDENTIALS_DIRECTORY/rpc-password, a file only the unit can read. The deployment path.
  2. COLDWATCH_RPC_PASSWORD_FILE — any path. The same property, without systemd.
  3. COLDWATCH_RPC_PASSWORD — the variable. Deliberately last: an environment is readable from /proc/<pid>/environ and is inherited by every child process the service ever spawns.

Never an argument. A command line is visible in ps to every user on the box and lands in shell history; read -rs avoids both.

Everything this raises names a variable or a path, never a value or any part of one. "The password is 12 characters" is a useful hint to someone who should not have it, and MissingCredentials is the exception most likely to be pasted into an issue by someone asking for help.

Asserted rather than assumed:

  • the value is in neither repr nor varsthe base64 blob is the credential too, not just the plaintext;
  • an empty file is refused rather than used, because an empty credential authenticates as nothing and produces a confusing 401 much later;
  • only the trailing newline is stripped from a file, never whitespace inside the value. A password with a leading space is a password, and silently correcting it produces an auth failure nobody can explain.

The proof

tools/induced_gap_proof.py sets the follower's tip a few blocks behind the chain and hands it the current tip block. Not a contrivance — that is exactly the state a service is in after any downtime.

What makes it a proof rather than a smoke test is the survey step. It first walks the window independently of the matcher, looking for a coin created in one block and spent in a later one, and watches that script. If catch-up works, the deposit and the alarm are both reconstructed from blocks no live stream ever delivered. A repair loop that has never caught anything is not known to work; this makes it catch something.

Eight checks, and it can fail — three ways, each tested:

  • repair impossible (the node stops serving blocks mid-run);
  • the window reaches below the prune height;
  • nothing in the window to catch, which exits inconclusive rather than passing, because a window with no spend in it proves nothing either way.

Output is heights and counts only. No addresses, no scripts, no txids, no amounts. Invariants I1 and I2 are not suspended because the output is going to a terminal — a terminal is where things get pasted from — and a test asserts no 64-character hex run ever reaches it.

Its closing line deliberately does not say "against the real node". The script runs against whatever it was handed, and the suite hands it a fake; claiming more than it can check is the same failure as a monitor reporting health it never measured.

Tested offline, because of where it will run

The script's body is importable and takes any object with .call, so the suite drives the whole thing against the same FakeChain the reconciler's tests use. A script whose first execution is also its first test is one whose typos surface while someone is holding a password at a production shell prompt.

Writing those tests found two unguarded RPC fetches that would have raised a traceback instead of a message. Both now report and exit.

How to run it

export COLDWATCH_RPC_USER=aw
read -rs -p "rpc password: " COLDWATCH_RPC_PASSWORD && export COLDWATCH_RPC_PASSWORD
python tools/induced_gap_proof.py --blocks 6

Read-only throughout — getblockcount, getblockhash, getblock, all within the existing whitelist. It cannot move funds.

Checks

193 tests (17 new), ruff clean. The mutation sweep runs here in CI.

Refs #24

🤖 Generated with Claude Code

Issue #24 needs a run against a real node, which needs a password, and the
whole point of the arrangement is that the password reaches the process without
passing through a person, a command line, or a file anyone might commit.

`BitcoinRpc.from_env` reads three sources in descending order of how well they
contain the secret. systemd's `LoadCredential` first -- a file only the unit can
read -- then a password file at any path, then the environment variable itself.
The variable is last on purpose: an environment is readable from
/proc/<pid>/environ and is inherited by every child process the service ever
spawns. A one-off run can fill it with `read -rs`, which keeps the value out of
shell history too.

Everything this raises names a variable or a path and never a value or any part
of one. "The password is 12 characters" is a useful hint to someone who should
not have it, and MissingCredentials is the exception most likely to be pasted
into an issue by someone asking for help. The tests assert the value appears in
neither `repr` nor `vars` -- the base64 blob is the credential too, not just the
plaintext -- and that an empty file is refused rather than used, since an empty
credential authenticates as nothing and produces a confusing 401 much later.

Only the trailing newline is stripped from a file, never whitespace inside the
value. A password with a leading space is a password, and silently correcting it
produces an authentication failure nobody can explain.

Then the proof itself. It sets the follower's tip a few blocks behind the chain
and hands it the current tip block -- not a contrivance, but exactly the state a
service is in after any downtime. What makes it a proof rather than a smoke test
is the survey step: it walks the window first, independently of the matcher, for
a coin created in one block and spent in a later one, and watches that script.
If catch-up works, the deposit and the alarm are both reconstructed from blocks
no live stream delivered. A repair loop that has never caught anything is not
known to work; this makes it catch something.

It prints heights and counts only. No addresses, no scripts, no txids, no
amounts -- invariants I1 and I2 are not suspended because the output is going to
a terminal, and a terminal is where things get pasted from. A test asserts no
64-character hex run ever reaches the output.

The script is importable and takes any object with `.call`, so the suite drives
the whole thing against the fake chain the reconciler's own tests use. A script
whose first execution is also its first test is one whose typos are discovered
while someone is holding a password at a production shell prompt -- which is
also why both of its unguarded RPC fetches now report rather than traceback.
Writing those tests found both.

It can fail, three ways, each tested: repair impossible, window below the prune
height, and nothing in the window to catch -- which exits *inconclusive* rather
than passing, because a window with no spend in it proves nothing either way.

Its closing line does not say "against the real node". The script runs against
whatever it was handed and the suite hands it a fake; claiming more than it can
check is the same failure as a monitor reporting health it never measured.

Refs #24

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Wired4ncer
Wired4ncer merged commit b65f565 into main Aug 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant