{"repo":"NousResearch/hermes-agent","public_id":"8036","display_label":"Gateway pairing codes stored and hashed securely","summary":"These items address a security concern where gateway pairing codes were stored in plaintext and readable by the sandbox. The fix involves hashing the pairing codes instead of storing them as plaintext.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30383,"merged_at":"2026-05-22T11:11:50+00:00"},"resolution":{"state":"resolved","by":"pr-30383"},"freshness":{"state":"verified","last_verified_at":"2026-05-22T11:11:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":8036,"pr_number":30383,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-22T11:11:50+00:00"}],"members":[{"kind":"issue","number":8036,"title":"Security: gateway pairing codes stored in plaintext, readable by sandbox","url":null,"state":"closed"},{"kind":"pr","number":8056,"title":"fix(security): hash gateway pairing codes instead of storing plaintext","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30383","number":30383},{"kind":"pr","value":"#10195","number":10195},{"kind":"pr","value":"#8036","number":8036}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30383,"title":"fix(security): hash gateway pairing codes instead of storing plaintext (#8056)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/pairing.py","infographic/pr-8056-hash-pairing-codes/infographic.png","tests/gateway/test_pairing.py"],"fix_hunks":[{"file":"gateway/pairing.py","hunks":["@@ -18,6 +18,7 @@\n Storage: ~/.hermes/pairing/\n \"\"\"\n \n+import hashlib\n import json\n import os\n import secrets","@@ -148,6 +149,11 @@ def revoke(self, platform: str, user_id: str) -> bool:\n \n     # ----- Pending codes -----\n \n+    @staticmethod\n+    def _hash_code(code: str, salt: bytes) -> str:\n+        \"\"\"Hash a pairing code with the given salt using SHA-256.\"\"\"\n+        return hashlib.sha256(salt + code.encode(\"utf-8\")).hexdigest()\n+\n     def generate_code(\n         self, platform: str, user_id: str, user_name: str = \"\"\n     ) -> Optional[str]:","@@ -158,6 +164,9 @@ def generate_code(\n           - User is rate-limited (too recent request)\n           - Max pending codes reached for this platform\n           - User/platform is in lockout due to failed attempts\n+\n+        The code is NOT stored in plaintext.  Only a salted SHA-256 hash is\n+        persisted so that reading the pending file does not reveal codes.\n         \"\"\"\n         with self._lock:\n             self._cleanup_expired(platform)","@@ -178,8 +187,17 @@ def generate_code(\n             # Generate cryptographically random code\n             code = \"\".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))\n \n-            # Store pending request\n-            pending[code] = {\n+            # Hash the code with a random salt before storing\n+            salt = os.urandom(16)\n+            code_hash = self._hash_code(code, salt)\n+\n+            # Use a unique entry id as the key (not the code itself)\n+            entry_id = secrets.token_hex(8)\n+\n+            # Store pending request with hashed code\n+            pending[entry_id] = {\n+                \"hash\": code_hash,\n+                \"salt\": salt.hex(),\n                 \"user_id\": user_id,\n                 \"user_name\": user_name,\n                 \"created_at\": time.time(),","@@ -195,10 +213,16 @@ def approve_code(self, platform: str, code: str) -> Optional[dict]:\n         \"\"\"\n         Approve a pairing code. Adds the user to the approved list.\n \n-        Returns {user_id, user_name} on success, None if code is\n+        Returns ``{user_id, user_name}`` on success, ``None`` if the code is\n         invalid/expired OR the platform is currently locked out after\n         ``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can\n         disambiguate with ``_is_locked_out(platform)``.\n+\n+        Verification: the user-provided code is hashed with each stored\n+        entry's salt and compared to the stored hash using constant-time\n+        comparison. Pre-hash entries (legacy plaintext-key format from\n+        pre-upgrade pending.json files) are silently ignored — they get\n+        pruned at TTL by ``_cleanup_expired``.\n         \"\"\"\n         with self._lock:\n             self._cleanup_expired(platform)","@@ -213,34 +237,73 @@ def approve_code(self, platform: str, code: str) -> Optional[dict]:\n                 return None\n \n             pending = self._load_json(self._pending_path(platform))\n-            if code not in pending:\n+\n+            # Find the entry whose hash matches the provided code.\n+            # Tolerate legacy plaintext-key entries (no salt/hash) and\n+            # malformed entries — skip them rather than KeyError, so an\n+            # in-place upgrade across an existing pending.json doesn't\n+            # crash on the first approve call. Legacy entries get pruned\n+            # at their TTL by _cleanup_expired.\n+            matched_key = None\n+            matched_entry = None\n+            for entry_id, entry in pending.items():\n+                if not isinstance(entry, dict):\n+                    continue\n+                if \"salt\" not in entry or \"hash\" not in entry:\n+                    continue\n+                try:\n+                    salt = bytes.fromhex(entry[\"salt\"])\n+                except ValueError:\n+                    continue\n+                candidate_hash = self._hash_code(code, salt)\n+                if secrets.compare_digest(candidate_hash, entry[\"hash\"]):\n+                    matched_key = entry_id\n+                    matched_entry = entry\n+                    break\n+\n+            if matched_key is None:\n                 self._record_failed_attempt(platform)\n                 return None\n \n-            entry = pending.pop(code)\n+            del pending[matched_key]\n             self._save_json(self._pending_path(platform), pending)\n \n             # Add to approved list\n-            self._approve_user(platform, entry[\"user_id\"], entry.get(\"user_name\", \"\"))\n+            self._approve_user(platform, matched_entry[\"user_id\"],\n+                               matched_entry.get(\"user_name\", \"\"))\n \n             return {\n-                \"user_id\": entry[\"user_id\"],\n-                \"user_name\": entry.get(\"user_name\", \"\"),\n+                \"user_id\": matched_entry[\"user_id\"],\n+                \"user_name\": matched_entry.get(\"user_name\", \"\"),\n             }\n \n     def list_pending(self, platform: str = None) -> list:\n-        \"\"\"List pending pairing requests, optionally filtered by platform.\"\"\"\n+        \"\"\"List pending pairing requests, optionally filtered by platform.\n+\n+        Codes are stored hashed — the ``code`` field is replaced with the\n+        first 8 hex characters of the hash so admins can distinguish entries\n+        without revealing the original code. Legacy plaintext-key entries\n+        (pre-hash format) are shown with a \"legacy\" placeholder so admins\n+        can see them age out without crashing on a missing ``hash`` field.\n+        \"\"\"\n         results = []\n         platforms = [platform] if platform else self._all_platforms(\"pending\")\n         for p in platforms:\n             self._cleanup_expired(p)\n             pending = self._load_json(self._pending_path(p))\n-            for code, info in pending.items():\n-                age_min = int((time.time() - info[\"created_at\"]) / 60)\n+            for entry_id, info in pending.items():\n+                if not isinstance(info, dict):\n+                    continue\n+                created_at = info.get(\"created_at\")\n+                if not isinstance(created_at, (int, float)):\n+                    continue\n+                age_min = int((time.time() - created_at) / 60)\n+                hash_val = info.get(\"hash\")\n+                code_display = hash_val[:8] if isinstance(hash_val, str) else \"legacy\"\n                 results.append({\n                     \"platform\": p,\n-                    \"code\": code,\n-                    \"user_id\": info[\"user_id\"],\n+                    \"code\": code_display,\n+                    \"user_id\": info.get(\"user_id\", \"\"),\n                     \"user_name\": info.get(\"user_name\", \"\"),\n                     \"age_minutes\": age_min,\n                 })","@@ -297,17 +360,29 @@ def _record_failed_attempt(self, platform: str) -> None:\n     # ----- Cleanup -----\n \n     def _cleanup_expired(self, platform: str) -> None:\n-        \"\"\"Remove expired pending codes.\"\"\"\n+        \"\"\"Remove expired pending codes.\n+\n+        Tolerant of malformed / legacy entries — anything without a numeric\n+        ``created_at`` is treated as expired (it's effectively unusable\n+        with the new hash-keyed schema anyway).\n+        \"\"\"\n         path = self._pending_path(platform)\n         pending = self._load_json(path)\n         now = time.time()\n-        expired = [\n-            code for code, info in pending.items()\n-            if (now - info[\"created_at\"]) > CODE_TTL_SECONDS\n-        ]\n+        expired = []\n+        for entry_id, info in pending.items():\n+            if not isinstance(info, dict):\n+                expired.append(entry_id)\n+                continue\n+            created_at = info.get(\"created_at\")\n+            if not isinstance(created_at, (int, float)):\n+                expired.append(entry_id)\n+                continue\n+            if (now - created_at) > CODE_TTL_SECONDS:\n+                expired.append(entry_id)\n         if expired:\n-            for code in expired:\n-                del pending[code]\n+            for entry_id in expired:\n+                del pending[entry_id]\n             self._save_json(path, pending)\n \n     def _all_platforms(self, suffix: str) -> list:"]},{"file":"tests/gateway/test_pairing.py","hunks":["@@ -75,9 +75,197 @@ def test_stores_pending_entry(self, tmp_path):\n             code = store.generate_code(\"telegram\", \"user1\", \"Alice\")\n             pending = store.list_pending(\"telegram\")\n         assert len(pending) == 1\n-        assert pending[0][\"code\"] == code\n+        # list_pending no longer returns the original code — it returns a\n+        # truncated hash prefix.  Verify the metadata is correct instead.\n         assert pending[0][\"user_id\"] == \"user1\"\n         assert pending[0][\"user_name\"] == \"Alice\"\n+        # The code field is now a hash prefix, not the original plaintext code\n+        assert pending[0][\"code\"] != code\n+\n+\n+# ---------------------------------------------------------------------------\n+# Hashed storage\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestHashedStorage:\n+    def test_pending_file_contains_hash_and_salt(self, tmp_path):\n+        \"\"\"Stored entries must have 'hash' and 'salt', never the plaintext code.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            store = PairingStore()\n+            code = store.generate_code(\"telegram\", \"user1\", \"Alice\")\n+            raw = json.loads(\n+                (tmp_path / \"telegram-pending.json\").read_text(encoding=\"utf-8\")\n+            )\n+\n+        assert len(raw) == 1\n+        entry = next(iter(raw.values()))\n+        # Must have hash and salt fields\n+        assert \"hash\" in entry\n+        assert \"salt\" in entry\n+        # Hash must be a valid hex SHA-256 digest (64 hex chars)\n+        assert len(entry[\"hash\"]) == 64\n+        assert all(c in \"0123456789abcdef\" for c in entry[\"hash\"])\n+        # Salt must be a valid hex string (32 hex chars for 16 bytes)\n+        assert len(entry[\"salt\"]) == 32\n+        assert all(c in \"0123456789abcdef\" for c in entry[\"salt\"])\n+        # The plaintext code must NOT appear as a key or value anywhere\n+        assert code not in raw  # not a key\n+        for key, val in raw.items():\n+            assert code != key\n+            for field_val in val.values():\n+                if isinstance(field_val, str):\n+                    assert field_val != code\n+\n+    def test_plaintext_code_not_stored(self, tmp_path):\n+        \"\"\"The raw JSON file must not contain the plaintext code anywhere.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            store = PairingStore()\n+            code = store.generate_code(\"telegram\", \"user1\")\n+            raw_text = (tmp_path / \"telegram-pending.json\").read_text(encoding=\"utf-8\")\n+        assert code not in raw_text\n+\n+    def test_valid_code_verifies_against_hash(self, tmp_path):\n+        \"\"\"approve_code with the correct code should succeed.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            store = PairingStore()\n+            code = store.generate_code(\"telegram\", \"user1\", \"Bob\")\n+            result = store.approve_code(\"telegram\", code)\n+        assert result is not None\n+        assert result[\"user_id\"] == \"user1\"\n+        assert result[\"user_name\"] == \"Bob\"\n+\n+    def test_invalid_code_rejected(self, tmp_path):\n+        \"\"\"approve_code with a wrong code should fail.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            store = PairingStore()\n+            store.generate_code(\"telegram\", \"user1\")\n+            result = store.approve_code(\"telegram\", \"ZZZZZZZZ\")\n+        assert result is None\n+\n+    def test_different_salts_per_entry(self, tmp_path):\n+        \"\"\"Each pending entry should have a unique salt.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            store = PairingStore()\n+            store.generate_code(\"telegram\", \"user0\")\n+            store.generate_code(\"telegram\", \"user1\")\n+            store.generate_code(\"telegram\", \"user2\")\n+            raw = json.loads(\n+                (tmp_path / \"telegram-pending.json\").read_text(encoding=\"utf-8\")\n+            )\n+        salts = [entry[\"salt\"] for entry in raw.values()]\n+        assert len(set(salts)) == 3  # all unique\n+\n+    def test_hash_code_static_method(self, tmp_path):\n+        \"\"\"_hash_code should be deterministic for the same code+salt.\"\"\"\n+        salt = os.urandom(16)\n+        h1 = PairingStore._hash_code(\"ABCD1234\", salt)\n+        h2 = PairingStore._hash_code(\"ABCD1234\", salt)\n+        assert h1 == h2\n+        # Different salt should produce a different hash\n+        salt2 = os.urandom(16)\n+        h3 = PairingStore._hash_code(\"ABCD1234\", salt2)\n+        assert h3 != h1\n+\n+\n+class TestLegacyPendingFileCompat:\n+    \"\"\"Defensive coverage for pre-hash pending.json on upgraded installs.\n+\n+    Existing user installs may have a pending.json written by the old\n+    code (plaintext code as key, no hash/salt fields). The new\n+    approve_code / list_pending / _cleanup_expired must not crash on\n+    those entries — they should be ignored and aged out at TTL.\n+    \"\"\"\n+\n+    @staticmethod\n+    def _write_legacy(tmp_path, code=\"ABCD1234\", created_at=None):\n+        \"\"\"Write a pre-hash pending.json with plaintext code as the key.\"\"\"\n+        import time as _time\n+        if created_at is None:\n+            created_at = _time.time()\n+        legacy = {\n+            code: {\n+                \"user_id\": \"legacy-user\",\n+                \"user_name\": \"Legacy\",\n+                \"created_at\": created_at,\n+            }\n+        }\n+        (tmp_path / \"telegram-pending.json\").write_text(\n+            json.dumps(legacy), encoding=\"utf-8\"\n+        )\n+\n+    def test_approve_code_ignores_legacy_entries(self, tmp_path):\n+        \"\"\"A valid old-format code must NOT silently approve under the new schema.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            self._write_legacy(tmp_path, code=\"LEGACY01\")\n+            store = PairingStore()\n+            # The plaintext \"code\" used to be the key — under the new schema\n+            # it's not even looked at, and there's no hash/salt to verify.\n+            # Result: approve_code returns None, the legacy entry is left\n+            # alone (gets pruned by _cleanup_expired at TTL).\n+            result = store.approve_code(\"telegram\", \"LEGACY01\")\n+            assert result is None\n+            # Approved list must be empty\n+            assert store.is_approved(\"telegram\", \"legacy-user\") is False\n+\n+    def test_list_pending_handles_legacy_entries(self, tmp_path):\n+        \"\"\"list_pending must not KeyError on a missing 'hash' field.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            self._write_legacy(tmp_path)\n+            store = PairingStore()\n+            pending = store.list_pending(\"telegram\")\n+        assert len(pending) == 1\n+        assert pending[0][\"user_id\"] == \"legacy-user\"\n+        assert pending[0][\"code\"] == \"legacy\"  # placeholder\n+\n+    def test_cleanup_expired_removes_legacy_at_ttl(self, tmp_path):\n+        \"\"\"Legacy entries past CODE_TTL must still get pruned.\"\"\"\n+        import time as _time\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            self._write_legacy(\n+                tmp_path,\n+                code=\"LEGACY99\",\n+                created_at=_time.time() - CODE_TTL_SECONDS - 1,\n+            )\n+            store = PairingStore()\n+            store._cleanup_expired(\"telegram\")\n+            raw = json.loads(\n+                (tmp_path / \"telegram-pending.json\").read_text(encoding=\"utf-8\")\n+            )\n+        assert raw == {}\n+\n+    def test_cleanup_expired_handles_malformed_entries(self, tmp_path):\n+        \"\"\"Non-dict / missing-created_at entries get evicted, not crashed on.\"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            (tmp_path / \"telegram-pending.json\").write_text(\n+                json.dumps({\n+                    \"broken1\": \"not a dict\",\n+                    \"broken2\": {\"user_id\": \"x\"},  # no created_at\n+                    \"broken3\": {\"created_at\": \"not a number\"},\n+                }),\n+                encoding=\"utf-8\",\n+            )\n+            store = PairingStore()\n+            store._cleanup_expired(\"telegram\")\n+            raw = json.loads(\n+                (tmp_path / \"telegram-pending.json\").read_text(encoding=\"utf-8\")\n+            )\n+        assert raw == {}\n+\n+    def test_approve_code_skips_malformed_entries(self, tmp_path):\n+        \"\"\"Malformed entries must not crash approve_code's hash loop.\"\"\"\n+        import time as _time\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            (tmp_path / \"telegram-pending.json\").write_text(\n+                json.dumps({\n+                    \"broken\": {\"user_id\": \"x\", \"created_at\": _time.time(),\n+                               \"salt\": \"not-hex\", \"hash\": \"doesntmatter\"},\n+                }),\n+                encoding=\"utf-8\",\n+            )\n+            store = PairingStore()\n+            # Approving with any code must just return None, not crash.\n+            assert store.approve_code(\"telegram\", \"ABCD1234\") is None\n \n \n # ---------------------------------------------------------------------------","@@ -300,9 +488,10 @@ def test_expired_codes_cleaned_up(self, tmp_path):\n             store = PairingStore()\n             code = store.generate_code(\"telegram\", \"user1\")\n \n-            # Manually expire the code\n+            # Manually expire all pending entries\n             pending = store._load_json(store._pending_path(\"telegram\"))\n-            pending[code][\"created_at\"] = time.time() - CODE_TTL_SECONDS - 1\n+            for entry_id in pending:\n+                pending[entry_id][\"created_at\"] = time.time() - CODE_TTL_SECONDS - 1\n             store._save_json(store._pending_path(\"telegram\"), pending)\n \n             # Cleanup happens on next operation","@@ -314,9 +503,10 @@ def test_expired_code_cannot_be_approved(self, tmp_path):\n             store = PairingStore()\n             code = store.generate_code(\"telegram\", \"user1\")\n \n-            # Expire it\n+            # Expire all entries\n             pending = store._load_json(store._pending_path(\"telegram\"))\n-            pending[code][\"created_at\"] = time.time() - CODE_TTL_SECONDS - 1\n+            for entry_id in pending:\n+                pending[entry_id][\"created_at\"] = time.time() - CODE_TTL_SECONDS - 1\n             store._save_json(store._pending_path(\"telegram\"), pending)\n \n             result = store.approve_code(\"telegram\", code)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}