Take a credential without ever holding one - #29
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_envreads three sources, in descending order of how well they contain the secret:LoadCredential—$CREDENTIALS_DIRECTORY/rpc-password, a file only the unit can read. The deployment path.COLDWATCH_RPC_PASSWORD_FILE— any path. The same property, without systemd.COLDWATCH_RPC_PASSWORD— the variable. Deliberately last: an environment is readable from/proc/<pid>/environand is inherited by every child process the service ever spawns.Never an argument. A command line is visible in
psto every user on the box and lands in shell history;read -rsavoids 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
MissingCredentialsis the exception most likely to be pasted into an issue by someone asking for help.Asserted rather than assumed:
reprnorvars— the base64 blob is the credential too, not just the plaintext;The proof
tools/induced_gap_proof.pysets 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:
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 sameFakeChainthe 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
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