{
 "number": 36142,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36142",
 "title": "net: validate Tor onion service replies and cached keys",
 "author": "l0rinc",
 "author_association": "MEMBER",
 "created_at": "2026-09-01T20:09:10Z",
 "updated_at": "2026-09-16T11:23:44Z",
 "age_days": 15,
 "draft": false,
 "labels": [
  "P2P",
  "Needs rebase"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "668335829636c3d9aa44f60a0b595cfefd201340",
 "head_ref": "l0rinc/tor-control-command-framing",
 "head_repo": "l0rinc/bitcoin",
 "head_history": [
  {
   "t": "2026-09-04T20:01:27Z",
   "sha": "668335829636c3d9aa44f60a0b595cfefd201340"
  }
 ],
 "additions": 106,
 "deletions": 17,
 "changed_files": 2,
 "commit_count": 3,
 "size_bucket": "M",
 "mergeable_state": "dirty",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "winterrdog",
      "url": "https://github.com/bitcoin/bitcoin/pull/36142#pullrequestreview-5087972696"
     },
     {
      "login": "jeanpablojp",
      "url": "https://github.com/bitcoin/bitcoin/pull/36142#pullrequestreview-5097038039"
     },
     {
      "login": "vasild",
      "url": "https://github.com/bitcoin/bitcoin/pull/36142#issuecomment-5526250741"
     }
    ]
   },
   "conflicts": [
    {
     "number": 36260,
     "title": "torcontrol: Use reconnect backoff after dropped connections",
     "author": "fjahr"
    },
    {
     "number": 35292,
     "title": "test: Add coverage for Tor control `HASHEDPASSWORD` authentication",
     "author": "winterrdog"
    },
    {
     "number": 34486,
     "title": "net: Reduce local network activity when networkactive=0",
     "author": "willcl-ark"
    }
   ]
  }
 },
 "acks_parsed": {
  "winterrdog": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-02T09:35:58Z",
   "stale": false
  },
  "jeanpablojp": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-03T02:06:26Z",
   "stale": false
  },
  "vasild": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-09-03T13:06:37Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 0,
  "concept_ack": 3,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "jeanpablojp",
   "vasild",
   "winterrdog"
  ]
 },
 "signals": {
  "needs_rebase": true,
  "ci_failed": false,
  "mergeable_state": "dirty",
  "last_author_activity": "2026-09-04T20:09:14Z",
  "last_reviewer_activity": "2026-09-08T14:02:39Z",
  "last_reviewer": "winterrdog",
  "author_silent_days": 12,
  "waiting_on_author_days": 9,
  "days_since_update": 1
 },
 "refs": {
  "mentioned": [],
  "depends_on": [],
  "fixes": [],
  "linked_issues": [],
  "references": [],
  "conflicts": [
   36260,
   35292,
   34486
  ]
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/torcontrol.cpp"
 ],
 "body": "**Problem:** A node creates its onion service through Tor's control protocol and caches the returned private key for reconnects.\nTor reply parsing unescapes quoted values, so a control endpoint can return a private key containing a line break or space.\nWhen the node reconnects, it inserts that key unquoted into an `ADD_ONION` command, where CRLF frames the remainder as a separate command and a space adds further arguments.\nAn unprivileged local process can exploit this by impersonating the default loopback endpoint while Tor is unavailable, seeding the cache, and releasing the port before Tor returns.\nA compromised operator-configured endpoint can return the same malicious key.\nThe reply handler also continues after receiving an invalid service ID, logging it and caching the key before attempting to advertise the invalid address.\n\n**Fix:** This PR validates the returned service ID as a Tor v3 onion address before logging it, caching the key, or advertising the service.\nIt accepts `NEW:ED25519-V3` or an `ED25519-V3` key whose Base64 payload decodes to 64 bytes.\nReturned keys are validated before adoption or caching, and cached keys are validated before reuse.\nA malformed cached key leaves the onion service unavailable until the operator removes the file and restarts the node, but cannot inject another command or argument.",
 "commits": [
  {
   "sha": "3896015744c583b256aa31d6c821677dd706a81f",
   "date": "2026-09-04T19:57:48Z",
   "message": "refactor: prepare ADD_ONION validation\n\nExtract the `NEW:ED25519-V3` key request and let Tor control tests configure the service ID and private key returned by `ADD_ONION`.\nThe restart helper stops the node before clearing or seeding the key cache, giving each test explicit reply and cache state.\n\n`NoPowServer` only overrides the PoW rejection because the base mock supplies the successful retry response."
  },
  {
   "sha": "a04f9ca413c015866220df273e7c95964f85481a",
   "date": "2026-09-04T19:57:56Z",
   "message": "test: characterize ADD_ONION reply handling\n\n`ADD_ONION` replies provide the onion service ID and may include a generated private key.\nRecord that a malformed service ID still reaches logging and key caching, while quoted private keys are unescaped and cached verbatim.\nReusing the same bytes from the cache lets an embedded CRLF or space inject another control command or argument."
  },
  {
   "sha": "668335829636c3d9aa44f60a0b595cfefd201340",
   "date": "2026-09-04T19:58:04Z",
   "message": "torcontrol: validate ADD_ONION reply fields\n\nA control endpoint can return a malformed service ID or private key in an `ADD_ONION` reply.\nA cached key containing CRLF can frame another control command when reused, while a space can add another `ADD_ONION` argument.\nA malformed service ID reaches logging and key caching before the node tries to advertise it.\n\nRequire the service ID to resolve to a valid onion address through `LookupNumeric()` before continuing.\nBefore adopting, caching, or reusing a private key, require `NEW:ED25519-V3` or an `ED25519-V3` key with a Base64 blob that decodes to 64 bytes.\nA malformed reply stops onion service creation, and a malformed cached key keeps the service unavailable until the operator removes the file."
  }
 ],
 "timeline": [
  {
   "t": "2026-09-02T09:35:58Z",
   "kind": "review",
   "who": "winterrdog",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "b6ed2bcc7719eda7bafc38b715a7aad824d5310c",
   "text": "concept ACK"
  },
  {
   "t": "2026-09-03T02:06:26Z",
   "kind": "review",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "state": "COMMENTED",
   "commit": "b6ed2bcc7719eda7bafc38b715a7aad824d5310c",
   "text": "Concept ACK\n\nThe CRLF side looks well covered. The space still gets through. The key goes into `ADD_ONION` unquoted, and in the control protocol a space is what separates arguments. A returned key carrying one still passes the check, gets cached, and goes back out on the next startup, this time to the real Tor.\n\nIf the injected token is a `Port=` on the same virtual port the node publishes, Tor ends up with two targets for it and connections go to either one. Pointed at a real Tor instead of the mock, fifteen of thirty connections to the node's onion address landed on the injected target, with no warning about it in the log. Swap the `Port=` for ` Flags=Detach` and the service stays up after bitcoind exits."
  },
  {
   "t": "2026-09-03T02:06:26Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/torcontrol.cpp",
   "commit": "b6ed2bcc7719eda7bafc38b715a7aad824d5310c",
   "in_reply_to": null,
   "text": "Would it make sense to reject the space here alongside CR and LF? It is the only other byte that works as a separator there, and none of the twenty keys I asked Tor for had any of the three.\n\n```suggestion\n            // A line break frames a second command and a space smuggles further\n            // ADD_ONION arguments, so never adopt or cache such a key.\n            if (private_key->find_first_of(\" \\r\\n\") != std::string::npos) {\n                LogWarning(\"tor: ADD_ONION returned a private key containing a line break or a space\");\n                return;\n            }\n```\n\nI applied this locally and the torcontrol unit and functional tests still pass, with no assertion changed."
  },
  {
   "t": "2026-09-03T02:06:26Z",
   "kind": "review_comment",
   "who": "jeanpablojp",
   "assoc": "CONTRIBUTOR",
   "path": "src/torcontrol.cpp",
   "commit": "b6ed2bcc7719eda7bafc38b715a7aad824d5310c",
   "in_reply_to": null,
   "text": "This guard covers a cache that already has CR or LF, but it can't be extended to the space, since control commands legitimately contain spaces. Would it make sense to check the cached key in `auth_cb`, before the `ADD_ONION` is built? The warning could name the file there. That is what the operator has to delete, and it doesn't appear in the default log today."
  },
  {
   "t": "2026-09-03T13:06:37Z",
   "kind": "comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "text": "Concept ACK\n\nInstead of chasing bad characters from the Tor router reponses, it would be more robust to only allow legit characters and frown upon anything else. For example, for the `ADD_ONION` command:\n\nhttps://spec.torproject.org/control-spec/commands.html#add_onion\n\n[quoted text omitted]\n```\n    \"250-ServiceID=\" ServiceID CRLF\n    [\"250-PrivateKey=\" KeyType \":\" KeyBlob CRLF]\n    *(\"250-ClientAuth=\" ClientName \":\" ClientBlob CRLF)\n    \"250 OK\" CRLF\n```\n\n[quoted text omitted]\nThat is - better to treat anything that is not a valid base64 character as a malformed reply for `KeyBlob`."
  },
  {
   "t": "2026-09-04T16:49:01Z",
   "kind": "comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "text": "In general, we can be more strict when receiving replies from the Tor daemon, not just the private key blob. For example, the ServiceID received as a reply to our `ADD_ONION` command is passed to `LookupNumeric()` and `LogInfo()` (see `TorController::add_onion_cb()`) without much checking. But it must be exactly 56 base32 characters. If it is not, then it is safer to assume malformed reply from Tor, stop the processing right there, and not pass the malformed stuff to other functions."
  },
  {
   "t": "2026-09-04T20:01:27Z",
   "kind": "force_push",
   "who": "l0rinc",
   "commit": "668335829636c3d9aa44f60a0b595cfefd201340"
  },
  {
   "t": "2026-09-04T20:09:14Z",
   "kind": "comment",
   "who": "l0rinc",
   "assoc": "MEMBER",
   "text": "Rebased and pushed, addressed all comments, thank you!\nPrivate-key format is now checked when received and before cached reuse, blocking both CRLF and space injection. Service IDs are validated before caching the key or advertising the service, and malformed-cache warnings name the file to remove.\n\nThanks @jeanpablojp for identifying the space-injection gap and suggesting cached-key checks, and @vasild for suggesting strict key-format and service-ID validation."
  },
  {
   "t": "2026-09-04T22:04:59Z",
   "kind": "review_comment",
   "who": "winterrdog",
   "assoc": "CONTRIBUTOR",
   "path": "src/torcontrol.cpp",
   "commit": "668335829636c3d9aa44f60a0b595cfefd201340",
   "in_reply_to": null,
   "text": "just a suggestion we can consider separately: should we explicitly enforce _owner-only_ permissions when writing the cached onion private key ?\n\nthis feels kind of similar to how we handle other sensitive files, e.g. `GenerateAuthCookie()` explicitly (if provided) sets restrictive permissions on the RPC cookie (`fs::perms`, `-rpccookieperms`):\nhttps://github.com/bitcoin/bitcoin/blob/4519933391dd23dbf1a4eceec6dd53d2e9e71cc3/src/rpc/request.cpp#L100-L146\n\nsince this file contains the private key controlling the `.onion` identity, it seems worth doing the same here. just wanted to flag it while we are touching an area adjacent to private key handling\n\nany thoughts? or the current approach is just fine ?"
  },
  {
   "t": "2026-09-08T12:57:18Z",
   "kind": "review_comment",
   "who": "vasild",
   "assoc": "MEMBER",
   "path": "src/torcontrol.cpp",
   "commit": "668335829636c3d9aa44f60a0b595cfefd201340",
   "in_reply_to": 3938169152,
   "text": "Yes, makes sense. But the correct way is to set the filesystem permissions first and after that write the sensitive data to it, or create it right away with the desired permissions using umask. The above snippet first writes the data and then sets the permissions :( `while : ; do cat /path/to/sensitive_file ; done` is a trivial way to exploit the first-write-then-set-permissions approach.\n\nThere is `SetupEnvironment()` which calls `umask(0077)` which should make the above safe, but still it would be better to call `fs::permissions()` first and then `file << COOKIEAUTH_USER << \":\" << rand_pwd_hex;` in the above snippet. I am not sure why `fs::permissions()` is needed if this relies on the global umask."
  },
  {
   "t": "2026-09-08T14:02:39Z",
   "kind": "review_comment",
   "who": "winterrdog",
   "assoc": "CONTRIBUTOR",
   "path": "src/torcontrol.cpp",
   "commit": "668335829636c3d9aa44f60a0b595cfefd201340",
   "in_reply_to": 3938169152,
   "text": "[quoted text omitted]\n\nah, yes! case closed.\n\n[quoted text omitted]\nyes, not necessary\n\n[quoted text omitted]\ncorrect! i will find out why that is so"
  }
 ],
 "labels_log": [
  {
   "t": "2026-09-01T20:09:14Z",
   "action": "labeled",
   "label": "P2P",
   "who": "DrahtBot"
  },
  {
   "t": "2026-09-16T11:23:43Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  }
 ],
 "state_log": [
  {
   "t": "2026-09-04T20:07:19Z",
   "kind": "renamed",
   "who": "l0rinc",
   "from": "net: prevent persisted Tor control command injection",
   "to": "net: validate Tor onion service replies and cached keys"
  }
 ],
 "text_chars": 7839,
 "text_tokens_estimate": 1959,
 "changed_paths": [
  "src/torcontrol.cpp",
  "test/functional/feature_torcontrol.py"
 ],
 "files": [
  {
   "path": "src/torcontrol.cpp",
   "add": 26,
   "del": 2
  },
  {
   "path": "test/functional/feature_torcontrol.py",
   "add": 80,
   "del": 15
  }
 ],
 "test_lines": 95,
 "git": {
  "head": "668335829636c3d9aa44f60a0b595cfefd201340",
  "head_matches_backup": true,
  "base": "4519933391dd23dbf1a4eceec6dd53d2e9e71cc3",
  "commits": [
   {
    "sha": "3896015744",
    "subject": "refactor: prepare ADD_ONION validation",
    "files": 2,
    "add": 21,
    "del": 16
   },
   {
    "sha": "a04f9ca413",
    "subject": "test: characterize ADD_ONION reply handling",
    "files": 1,
    "add": 61,
    "del": 0
   },
   {
    "sha": "6683358296",
    "subject": "torcontrol: validate ADD_ONION reply fields",
    "files": 2,
    "add": 31,
    "del": 8
   }
  ],
  "patch_truncated": false
 },
 "input_hash": "9b06596e10e7f532",
 "extracted_at": "2026-09-17T16:15:31+00:00"
}