Skip to content

fix(scripts): make the robinhood credential guard catch the error it claimed to - #416

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/rh-smoke-credential-guard
Aug 19, 2026
Merged

fix(scripts): make the robinhood credential guard catch the error it claimed to#416
eaitbrahim merged 1 commit into
mainfrom
fix/rh-smoke-credential-guard

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

The guard didn't do what its comment said

scripts/robinhood_smoke.py:

A raw Ed25519 seed is 32 bytes, which is 44 base64 characters with padding. Checking this before the first request turns the most likely operator error — pasting the public key, or a PEM, or a hex string — into a precise message instead of a 401.

It cannot. A raw Ed25519 public key is also 32 bytes, so it is also 44 base64 characters, from the same alphabet. Length, alphabet and decoded size are all identical. The check never covered the case it named.

On 2026-08-19 an operator pasted the public key into ROBINHOOD_API_KEY. Every guard passed, every request signed correctly, and the venue returned 401 — indistinguishable from a revoked key, a stale clock, or a signing bug. Telling those apart cost an afternoon.

What actually separates the two values

One is derived from the other. So the guard derives it:

ROBINHOOD_API_KEY holds the base64 PUBLIC key of ROBINHOOD_PRIVATE_KEY, not an API key.

The public key is what you paste INTO Robinhood's credential page. What belongs here is the
identifier Robinhood issues back once the credential exists (rh-api-<uuid>).
Every request would sign correctly and be rejected 401, because the venue has no record of
this key.

Fix: sign in to web classic, open https://robinhood.com/account/crypto, choose Add key, paste
the value currently in ROBINHOOD_API_KEY as the public key, tick the API actions this
credential needs, and put the identifier it returns here. ROBINHOOD_PRIVATE_KEY stays as it
is -- the keypair is already correct.

That is the verbatim output of the new guard run against the real .env that produced the incident.

Two checks, most-specific first:

  1. ROBINHOOD_API_KEY equals the public key of ROBINHOOD_PRIVATE_KEY — named outright, with the fix, because this one needs no round trip to diagnose: the value in the wrong variable is exactly what the credential page wants.
  2. ROBINHOOD_API_KEY is any 32-byte base64 value — an Ed25519 key is never an API key identifier whatever the identifier format turns out to be, so this gates even with nothing to match against.

Two details that are load-bearing

validate=True on the decode. Without it b64decode discards out-of-alphabet characters rather than refusing, so a genuine rh-api-<uuid> — which contains - — could decode to 32 bytes by accident and be rejected as a pasted key. Pinned by test_a_real_api_key_is_not_mistaken_for_base64.

The rh-api-<uuid> shape is quoted, not enforced. It is an observation about one credential, not a documented contract; a hard gate would reject a valid key the day Robinhood changes the format. The two checks that do gate are the ones that cannot be wrong.

_public_key_b64 returns None rather than raising on a malformed seed or a missing pynacl — a diagnostic that crashes on the malformed input it exists to describe is worse than one that stays quiet, and the length check above already covers that path.

The length check stays (it still catches a PEM, a hex string, a truncated paste) with its overreaching claim removed and a note saying which case it does not cover.

Also

_VALID_SEED_B64 in the test suite was "A" * 44 — not a valid base64 32-byte seed at all. 44 unpadded base64 characters decode to 33 bytes, so a constant of that name was never one. Now base64.b64encode(bytes(range(32))), which the new derivation check requires anyway.

Full suite: 3656 passed, 3 skipped. ruff check and mypy clean.

🤖 Generated with Claude Code

…claimed to

`_SEED_B64_LEN`'s comment said checking the private key's length "turns the
most likely operator error -- pasting the public key -- into a precise
message instead of a 401." It cannot, and never could: a raw Ed25519 seed
and a raw Ed25519 public key are both 32 bytes, so both are 44 base64
characters, drawn from the same alphabet. Nothing about length, alphabet or
decoded size separates them.

On 2026-08-19 an operator pasted the public key into ROBINHOOD_API_KEY.
Every guard passed. Every request signed correctly and came back 401 --
indistinguishable from a revoked key, a stale clock or a signing bug -- and
telling those apart cost an afternoon.

The only thing that actually distinguishes the two values is that one is
derived from the other, so the guard now derives it. Two checks, ordered
most-specific first:

* ROBINHOOD_API_KEY equals the public key of ROBINHOOD_PRIVATE_KEY. Named
  outright, with the fix, because this one needs no round trip to diagnose:
  the value sitting in the wrong variable is exactly what Robinhood's
  credential page wants pasted into it.
* ROBINHOOD_API_KEY is any 32-byte base64 value. An Ed25519 key is never an
  API key identifier whatever the identifier format turns out to be, so this
  gates even when there is nothing to match it against.

`validate=True` on the decode is load-bearing, not defensive: without it
`b64decode` DISCARDS out-of-alphabet characters rather than refusing, so a
genuine `rh-api-<uuid>` -- which contains `-` -- could decode to 32 bytes by
accident and be rejected as a pasted key. Pinned by test.

The observed `rh-api-<uuid>` shape is quoted in the messages as guidance and
deliberately NOT enforced: it is an observation about one credential, not a
documented contract, and a hard gate would reject a valid key the day
Robinhood changes the format.

The length check stays -- it still catches a PEM, a hex string or a
truncated paste -- with its overreaching claim removed and replaced by a
note saying which case it does not cover.

`_public_key_b64` returns None rather than raising on a malformed seed or a
missing pynacl: a diagnostic that crashes on the malformed input it exists
to describe is worse than one that stays quiet, and the length check above
already covers the malformed-seed path.

Verified against the real .env that produced the incident: the guard now
names the variable, the mistake and the fix.

Also corrects the test suite's `_VALID_SEED_B64`, which was `"A" * 44` --
not a valid base64 32-byte seed at all (44 unpadded characters decode to
33 bytes), which a constant of that name should not be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 8f738e2 into main Aug 19, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the fix/rh-smoke-credential-guard branch August 19, 2026 21:26
eaitbrahim added a commit that referenced this pull request Aug 20, 2026
Minor, not patch. Three things since v0.9.3 change what an implementor or a
deployment can rely on:

* A NEW DISTRIBUTION. `keel-broker-alpaca` (#382, #384) plus the
  paper-equities profile that selects it (#386), so a deployment can now be
  US equities via the broker port rather than crypto only.
* THE PORT CONTRACT MOVED TWICE. `market_clock`/`market_schedule` made
  venues session-aware (#385), and `place_order` gained `idempotency_key`
  (#419). Both carry defaults so no CALLER breaks, but a third-party adapter
  that does not accept them is no longer a `Broker` -- the conformance suite
  now says so. That is exactly the kind of change a patch bump must not
  hide.
* THE OPERATOR CONSOLE. The TUI became keel's console across #399-#408, and
  `keel update` (#415/#417) makes a deployment self-updating.

Every pinned sibling moves with it. The four production distributions are
required `==` at this exact version (`RELEASING.md`, "Release assets"), so a
bump that missed one would install a mixed set -- the `keel-trader 0.5.7`
against `keel-core 0.5.5` failure `keel versions` exists to catch, and which
`~/keel` actually ran across two releases.

Also in this window, on the Robinhood adapter: the best_bid_ask fixture
corrected against the live venue (#414), a credential guard that catches the
error it only claimed to (#416), pre-flight sizing reported on the preview
(#418), transport backoff (#420), and the fenced one-order probe (#421).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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