Skip to content

Security review follow-ups (v1.1.1) - #3

Merged
TheJakeBraun merged 15 commits into
masterfrom
security/review-followups
Sep 29, 2026
Merged

TheJakeBraun merged 15 commits into
masterfrom
security/review-followups

Conversation

@TheJakeBraun

Copy link
Copy Markdown

Remaining fixes from the security review. The critical wallet-verification fix already shipped in v1.1.0. Outputs for valid input are unchanged: keys, addresses, hashes and signatures were checked against 400 random vectors from the previous code.

Item Fix
H1 NanoCLI shell injection: method and option names are validated, and each argument is escapeshellarg'd.
M3 Seeds, keys and mnemonic words are removed from exception messages. Secret parameters get #[\SensitiveParameter], and __debugInfo() hides private keys.
L1 RPC no longer follows redirects by default. If a caller opts in, https→http downgrades are refused and Authorization is dropped when the host changes. The CLI warns when Basic auth goes over plain http.
L2 No world-writable temp-dir fallback for the saved node. On POSIX, the node file is used only if it is owned by the current user and not group- or world-writable.
L5 A trailing newline in an amount is rejected cleanly, and the CLI catches \Throwable.
L4 Small-order public keys and R values are rejected in verify().
L3 Fixed-length scalar multiplication ladder with byte-identical output. The docs now say plainly that the code is not constant-time, and the README has a "Security model" section.
L6 wallet_send / wallet_sweep accounting: failed sends are reported as errors, per-account amounts are correct, ids are random, and new sent/shortfall fields are added.
L7 Size caps on RPC responses and IPC frames. The WebSocket handshake is bounded, and a stalled frame read times out.
I1–I6 Strict comparisons, node-supplied work validated, control characters stripped from node strings in CLI output, safer seed-file advice in the docs, and an opt-in mnem2mseed checksum.

Version 1.1.1, with notes in docs/MODERNIZATION.md.

Verification:

  • Suites: verify.php (97 checks), wallet.php (58) and websocket.php (18) pass on PHP 8.5 locally and on PHP 8.2 Alpine in Docker, as root and as a normal user.
  • PHPStan: 2.2.16, no errors.
  • Review exploits: the proof-of-concept scripts for H1, L1, L4, L5 and M3 are now blocked.
  • Public nodes: none of the four public nodes redirects, and all work with the new no-redirect default.

Behaviour notes:

  • Signing speed: about 30% slower, because of the fixed-length ladder.
  • Saved node file: a group-writable file is now ignored with a warning. Fix it with chmod go-w or by re-running nanophp node.

🤖 Generated with Claude Code

TheJakeBraun and others added 15 commits September 28, 2026 21:13
NanoCLI concatenated method names, option names and values straight into
the exec() command line, so any caller forwarding user input (e.g.
account_key(['account' => $_GET['a']])) allowed command execution on the
node host.

- Method and option names must match /^[a-z][a-z0-9_]*$/ (NanoCLIException
  otherwise); nano_node's own options all fit.
- Every value is passed as one escapeshellarg()'d argument. The argv the
  binary receives is unchanged for benign input.
- Documented that arguments land in argv (process list), so secrets must
  not be passed through this class.

Tests (verify.php): injection payloads (&, ;, |, &&, $(), backticks) no
longer execute; invalid names throw. They fail on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A seed or key read from a file/env var with a trailing newline ended up in
full in the exception message (and so in logs, error trackers and HTML
error pages); var_dump/print_r of a wallet or block printed the key.

- Exception messages no longer echo seeds, private keys, master seeds,
  BIP39 entropy or mnemonic words; they say what was expected instead
  ("Invalid seed (expected 64 hexadecimal characters)", "Invalid mnemonic
  word at position 6"). public2account omits its value too, in case a
  private key is passed by mistake.
- #[\SensitiveParameter] on every secret parameter (seed, private_key,
  mseed, words, passphrase, hex entropy, API keys) and on the Ed25519 /
  BLAKE2b internals that receive secret material, so stack traces show
  "Object(SensitiveParameterValue)" instead of the first bytes.
- NanoWallet and NanoBlock get __debugInfo() without the private key.
- NanoBlock validates the key with ctype_xdigit instead of hex2bin (same
  acceptance, no warning).

Valid-input behaviour and outputs are unchanged. Tests (verify.php) fail
on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With follow_location on, PHP's http wrapper re-sent the POST body and the
Authorization header to whatever host a redirect named, including plain
http hosts. Nano RPC never redirects.

- NanoRPC follow_location now defaults to false; an unfollowed 3xx is
  reported as HTTP_<code> like any other non-200 status.
- Opting in (follow_location => true) follows redirects manually, up to
  max_redirects: https -> http downgrades and non-http(s) targets are
  refused, the Authorization header is dropped when the origin (scheme,
  host, port) changes, and 301/302/303 continue as body-less GET while
  307/308 repeat the POST, as PHP's wrapper does.
- nanophp warns on stderr when Basic auth is sent over plain http to a
  non-loopback host.
- The four PUBLIC_NODES answer block_count with 200 directly (checked
  2026-09-28), so the CLI is unaffected.

Tests (wallet.php, mock node gains /redirect, block_count and echo_auth):
default no-follow, opt-in same-origin follow keeps auth, cross-origin
drops it, max_redirects, downgrade/resolution helpers. The mock node's
request log now goes to the null device: the unread pipe filled up and
stalled the server once the suite made enough requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without HOME/USERPROFILE (cron, some service units, env -i) nanophp read
and wrote .nanophp-node in the shared temp directory, so another local
user could plant a node endpoint that every later command trusted.

- homeDir() returns null instead of falling back to sys_get_temp_dir();
  then the saved-node file is neither read nor written and the CLI uses
  NANOPHP_NODE or http://localhost:7076 (`nanophp node` says it could not
  save).
- On POSIX the saved file is ignored, with a stderr warning and the chmod
  fix, unless it is owned by the effective user (posix_geteuid, or the
  home directory's owner when the posix extension is absent) and not
  group/world-writable. `nanophp node` removes group/other write bits
  after saving so a umask of 002 doesn't trip the check.

Tests (wallet.php) drive the CLI in child processes with a controlled
environment: a planted temp-dir file is ignored without HOME (fails on the
old code), NANOPHP_NODE still works, the home file is used, and on POSIX
group/world-writable files are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (L5)

den2raw's /^...$/ also matched "1\n" (PCRE "$" matches before a final
newline), producing "1\n000..." which bcmath then rejected with a
ValueError. nanophp only caught Exception, so that became a PHP fatal
error with a stack trace (arguments included). No wrong amount was ever
signed, but the failure mode was ugly and leaky.

- den2raw anchors with \z, so "1\n" is a clean NanoToolException
  ("Invalid amount").
- account2public and string2burn anchor with the D modifier for the same
  reason (a trailing newline now yields false / an exception up front
  instead of a base32 decode error).
- nanophp catches \Throwable (main dispatch and the node probe) and prints
  only the message.

Tests: verify.php (den2raw/account2public/string2burn with newlines) and a
CLI child-process check that `send "1\n"` fails with one clean line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ed25519Blake2b::verify accepted public keys and R values of small order.
With A = R = identity and S = 0, a "signature" verified for any message,
so NanoTool::validSign "proved" ownership of
nano_11a11111111111111111111111111111111111111111111111116iq5p4i8 (and
other torsion-point accounts) for arbitrary data. Nobody holds these keys,
so wallets are unaffected, but "sign in with Nano"-style checks were not.

verify() now rejects A or R when 8*P is the identity (three doublings,
negligible cost). Genuine keys are never of small order and R = r*B with
r != 0 mod L, so valid signatures are unaffected.

Tests (verify.php): the review's identity-key forgery and all 64 A/R
combinations of the 8 small-order encodings with S = 0 (several verified
before), plus a genuine-signature control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
numToBits dropped leading zeros, so the double-and-add loop ran once per
bit of the secret scalar and the addition only on 1-bits: key derivation
and signing time depended on the nonce's bit length (~30 us/bit measured)
and Hamming weight, the kind of leak lattice attacks (Minerva/TPM-Fail)
exploit. docs/CRYPTO.md also called non-constant-time signing "acceptable
for server-side wallets", which is backwards.

- Secret scalars (private scalar a, nonce r) use a fixed ladder: 256
  iterations from a fixed-work bit decomposition (numToBytes, 32 rounds),
  the addition computed every step and the result selected by index, and
  the starting identity given a random projective Z so leading zero bits
  don't run on cheap small numbers. Measured scalarMultBase time is now
  flat within noise for 8-, 128- and 252-bit scalars (was 3 vs 11 ms for
  128 vs 252 bits).
- Verification (public scalars only) keeps the fast variable loop.
- Outputs are byte-identical: 400 random key/message pairs generated with
  the old code before the change give identical public keys, signatures
  and verify results; verify.php gains a 32-vector deterministic digest
  produced by the old implementation.
- Cost: key derivation ~15 %, signing ~30 % slower; verify unchanged.
- docs/CRYPTO.md corrected (not fine for server-side signing services),
  README "Security model" gains a timing bullet, AGENT.md updated. bcmath
  timing is still value-dependent, so this remains not constant-time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A failed send (node error -> false, or a missing/empty block hash) was
  reported as a success entry with block null; only the all-zero hash
  counted as an error. Every failure is now {'error' => 'Bad send',
  'amount', 'reason' => node error}.
- wallet_send reported 'amount' from the stale loop variable of the
  selection loop (another account's whole balance); entries now carry the
  amount that account was selected to send.
- When the destination is one of the wallet's own accounts its share is
  skipped, so less than 'amount' went out silently. The response now adds
  'sent' and 'shortfall' (raw) so callers can see it; wallet_sweep adds
  'sent'. Existing keys and entry shapes are unchanged.
- The send idempotency id is bin2hex(random_bytes(16)) instead of the
  time-based uniqid().
- A failed wallet_balances call returns false with an error instead of
  iterating over nothing.

Tests (wallet.php; the mock node gains wallet_info/wallet_balances/send
for a node wallet with a failing account and requires 128-bit hex ids):
fail on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A malicious node or peer could exhaust memory or hang the client:

- NanoRPC: response bodies are capped by the new max_response_size option
  (default 64 MiB, null = unlimited). file_get_contents reads at most one
  byte past the cap, so an endless body is never buffered in full.
- WebSocketClient: handshake response headers are limited to 16 KiB and to
  the connect timeout; once a frame has started, the rest must arrive
  within frame_timeout (new option, default max(timeout, 30) s), otherwise
  the connection is closed with an error instead of waiting forever
  (readStrict used to loop indefinitely on a stalled peer).
- NanoIPC: the 32-bit frame length is checked against max_response_size
  (new option, default 64 MiB) before anything is allocated (a ~4 GiB
  announcement used to be a fatal allocation), and the body is read in a
  loop (a single fread returned short on sockets, silently truncating
  large responses). Oversized/truncated frames close the desynchronized
  connection and set error.

Normal responses are unaffected. Tests: websocket.php (the echo server
gains a header-flood path and a stalled-frame message; in-process IPC TCP
peer for framed, oversized, capped and truncated responses) and wallet.php
(RPC cap). They fail (or crash) on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- I1: strict comparisons where loose ones were left: the "Account not
  found" error check and the 'raw' denomination switch use ===, the
  published block hash is compared with hash_equals, in_array for the
  work source is strict.
- I2: work returned by work_generate is checked with NanoTool::validWork
  against the requested difficulty (and for 16-hex shape) before it is
  put into a block, with a clear error instead of a rejected publish.
  node_fallback falls back to local work in that case.
- New options work_difficulty_send / work_difficulty_receive (defaults
  WORK_SEND / WORK_RECEIVE, so mainnet behaviour and requests are
  unchanged) for networks with other thresholds; the test suite uses them
  so the mock node can return genuine work.

Test infrastructure: the mock node now computes real work at the cheap
test difficulty (bogus work otherwise) and caches its signed ledger per
test run (MOCK_LEDGER_CACHE), since the built-in server re-ran the key
derivation and signing on every request; wallet.php drops from ~38 s to
~7 s. The new tests (bogus node work refused, invalid difficulty option)
fail on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RPC error texts and the probed block count come from the node and were
printed to the terminal verbatim, so a node could embed ANSI escape
sequences (clear screen, recolour, fake lines). nanophp now passes such
text through printable(), which removes C0 controls, DEL and UTF-8
encoded C1 controls. Output for valid use is unchanged.

Test (wallet.php, mock node gains an /evil path whose errors carry escape
sequences): fails on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The examples wrote seeds with `> seed.txt` / `tee seed.txt`, which under
the common umask 022 produces a world-readable 0644 file. README, DOCKER.md
and the nanophp header now show `(umask 077; ... > seed.txt)` and
`chmod 600` for existing files. CLI output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mnem2mseed accepts any sequence of word-list words (PBKDF2 doesn't need a
valid checksum), so a mistyped-but-valid word silently derives a different
wallet. A new optional third argument, $verify_checksum (default false),
runs the mnem2hex validation first (word count, words, checksum).

The default stays off on purpose: existing callers may rely on deriving
from non-standard mnemonics, and turning it on would make previously
working calls throw. Outputs are unchanged either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
httpPost/httpRequest mutated responseRaw/status as a side effect, which
PHPStan (level 5, pinned 2.2.16) can't track across the call ("json_decode
expects string, null given"; "null != 200 always true"). They now return
[body|false, status] and [body|false, headers], and __call assigns the
properties. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TheJakeBraun
TheJakeBraun merged commit d57d83c into master Sep 29, 2026
4 checks passed
@TheJakeBraun
TheJakeBraun deleted the security/review-followups branch September 29, 2026 04:56
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