kestrix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. kestrix/__init__.py +8 -0
  2. kestrix/bitcoin/__init__.py +8 -0
  3. kestrix/bitcoin/address.py +231 -0
  4. kestrix/bitcoin/api.py +70 -0
  5. kestrix/bitcoin/chain.py +119 -0
  6. kestrix/bitcoin/cli.py +111 -0
  7. kestrix/bitcoin/guardian.py +173 -0
  8. kestrix/bitcoin/migration.py +88 -0
  9. kestrix/bitcoin/models.py +118 -0
  10. kestrix/bitcoin/risk.py +181 -0
  11. kestrix/cli.py +184 -0
  12. kestrix/code/__init__.py +39 -0
  13. kestrix/code/cli.py +27 -0
  14. kestrix/core/__init__.py +2 -0
  15. kestrix/core/config.py +73 -0
  16. kestrix/core/enums.py +15 -0
  17. kestrix/core/errors.py +73 -0
  18. kestrix/core/evidence.py +69 -0
  19. kestrix/core/netguard.py +102 -0
  20. kestrix/core/safe_load.py +103 -0
  21. kestrix/detect/__init__.py +2 -0
  22. kestrix/detect/api.py +127 -0
  23. kestrix/detect/candidate.py +175 -0
  24. kestrix/detect/cases/c00-healthy-control/case.yml +9 -0
  25. kestrix/detect/cases/c00-healthy-control/development.jsonl +9 -0
  26. kestrix/detect/cases/c00-healthy-control/mapping.yml +10 -0
  27. kestrix/detect/cases/c00-healthy-control/rule.yml +14 -0
  28. kestrix/detect/cases/c00-healthy-control/validation.jsonl +10 -0
  29. kestrix/detect/cases/c01-mapping-drift-commandline/case.yml +12 -0
  30. kestrix/detect/cases/c01-mapping-drift-commandline/development.jsonl +9 -0
  31. kestrix/detect/cases/c01-mapping-drift-commandline/healthy_mapping.yml +10 -0
  32. kestrix/detect/cases/c01-mapping-drift-commandline/mapping.yml +10 -0
  33. kestrix/detect/cases/c01-mapping-drift-commandline/rule.yml +14 -0
  34. kestrix/detect/cases/c01-mapping-drift-commandline/validation.jsonl +10 -0
  35. kestrix/detect/cases/c02-mapping-drift-parentimage/case.yml +12 -0
  36. kestrix/detect/cases/c02-mapping-drift-parentimage/development.jsonl +9 -0
  37. kestrix/detect/cases/c02-mapping-drift-parentimage/healthy_mapping.yml +10 -0
  38. kestrix/detect/cases/c02-mapping-drift-parentimage/mapping.yml +10 -0
  39. kestrix/detect/cases/c02-mapping-drift-parentimage/rule.yml +18 -0
  40. kestrix/detect/cases/c02-mapping-drift-parentimage/validation.jsonl +10 -0
  41. kestrix/detect/cases/c03-noise-updater-temp/case.yml +12 -0
  42. kestrix/detect/cases/c03-noise-updater-temp/development.jsonl +11 -0
  43. kestrix/detect/cases/c03-noise-updater-temp/healthy_rule.yml +13 -0
  44. kestrix/detect/cases/c03-noise-updater-temp/mapping.yml +10 -0
  45. kestrix/detect/cases/c03-noise-updater-temp/rule.yml +11 -0
  46. kestrix/detect/cases/c03-noise-updater-temp/validation.jsonl +13 -0
  47. kestrix/detect/cases/c04-noise-installer-schtasks/case.yml +11 -0
  48. kestrix/detect/cases/c04-noise-installer-schtasks/development.jsonl +10 -0
  49. kestrix/detect/cases/c04-noise-installer-schtasks/healthy_rule.yml +14 -0
  50. kestrix/detect/cases/c04-noise-installer-schtasks/mapping.yml +10 -0
  51. kestrix/detect/cases/c04-noise-installer-schtasks/rule.yml +12 -0
  52. kestrix/detect/cases/c04-noise-installer-schtasks/validation.jsonl +11 -0
  53. kestrix/detect/cases/c05-missing-telemetry-commandline/case.yml +11 -0
  54. kestrix/detect/cases/c05-missing-telemetry-commandline/development.jsonl +8 -0
  55. kestrix/detect/cases/c05-missing-telemetry-commandline/mapping.yml +10 -0
  56. kestrix/detect/cases/c05-missing-telemetry-commandline/rule.yml +14 -0
  57. kestrix/detect/cases/c05-missing-telemetry-commandline/validation.jsonl +8 -0
  58. kestrix/detect/cases/c06-missing-telemetry-parentimage/case.yml +11 -0
  59. kestrix/detect/cases/c06-missing-telemetry-parentimage/development.jsonl +7 -0
  60. kestrix/detect/cases/c06-missing-telemetry-parentimage/mapping.yml +10 -0
  61. kestrix/detect/cases/c06-missing-telemetry-parentimage/rule.yml +12 -0
  62. kestrix/detect/cases/c06-missing-telemetry-parentimage/validation.jsonl +7 -0
  63. kestrix/detect/cases/c07-logic-and-or/case.yml +12 -0
  64. kestrix/detect/cases/c07-logic-and-or/development.jsonl +10 -0
  65. kestrix/detect/cases/c07-logic-and-or/mapping.yml +10 -0
  66. kestrix/detect/cases/c07-logic-and-or/rule.yml +15 -0
  67. kestrix/detect/cases/c07-logic-and-or/validation.jsonl +12 -0
  68. kestrix/detect/cases/c08-modifier-exact-vs-endswith/case.yml +12 -0
  69. kestrix/detect/cases/c08-modifier-exact-vs-endswith/development.jsonl +8 -0
  70. kestrix/detect/cases/c08-modifier-exact-vs-endswith/mapping.yml +10 -0
  71. kestrix/detect/cases/c08-modifier-exact-vs-endswith/rule.yml +12 -0
  72. kestrix/detect/cases/c08-modifier-exact-vs-endswith/validation.jsonl +9 -0
  73. kestrix/detect/cases/c09-unsupported-regex/case.yml +9 -0
  74. kestrix/detect/cases/c09-unsupported-regex/development.jsonl +8 -0
  75. kestrix/detect/cases/c09-unsupported-regex/mapping.yml +10 -0
  76. kestrix/detect/cases/c09-unsupported-regex/rule.yml +11 -0
  77. kestrix/detect/cases/c09-unsupported-regex/validation.jsonl +8 -0
  78. kestrix/detect/cases/c10-logic-missing-not/case.yml +12 -0
  79. kestrix/detect/cases/c10-logic-missing-not/development.jsonl +9 -0
  80. kestrix/detect/cases/c10-logic-missing-not/mapping.yml +10 -0
  81. kestrix/detect/cases/c10-logic-missing-not/rule.yml +14 -0
  82. kestrix/detect/cases/c10-logic-missing-not/validation.jsonl +10 -0
  83. kestrix/detect/cases.py +50 -0
  84. kestrix/detect/cli.py +173 -0
  85. kestrix/detect/diagnose.py +176 -0
  86. kestrix/detect/engine.py +167 -0
  87. kestrix/detect/fixtures.py +143 -0
  88. kestrix/detect/mapping.py +86 -0
  89. kestrix/detect/repair.py +153 -0
  90. kestrix/detect/replay.py +106 -0
  91. kestrix/detect/rule.py +412 -0
  92. kestrix/detect/workflow.py +299 -0
  93. kestrix/forensics/__init__.py +11 -0
  94. kestrix/forensics/analysis.py +771 -0
  95. kestrix/forensics/api.py +124 -0
  96. kestrix/forensics/cli.py +104 -0
  97. kestrix/forensics/exif.py +158 -0
  98. kestrix/forensics/models.py +141 -0
  99. kestrix/forensics/ole.py +139 -0
  100. kestrix/forensics/url.py +447 -0
  101. kestrix/integrations/__init__.py +5 -0
  102. kestrix/integrations/cli.py +40 -0
  103. kestrix/mail/__init__.py +11 -0
  104. kestrix/mail/analyze.py +185 -0
  105. kestrix/mail/analyzers.py +893 -0
  106. kestrix/mail/api.py +129 -0
  107. kestrix/mail/cli.py +148 -0
  108. kestrix/mail/intent.py +270 -0
  109. kestrix/mail/lookalike.py +229 -0
  110. kestrix/mail/mailbox/__init__.py +11 -0
  111. kestrix/mail/mailbox/api.py +139 -0
  112. kestrix/mail/mailbox/http.py +106 -0
  113. kestrix/mail/mailbox/loopback.py +82 -0
  114. kestrix/mail/mailbox/models.py +83 -0
  115. kestrix/mail/mailbox/oauth.py +160 -0
  116. kestrix/mail/mailbox/providers.py +310 -0
  117. kestrix/mail/mailbox/service.py +281 -0
  118. kestrix/mail/mailbox/store.py +95 -0
  119. kestrix/mail/models.py +82 -0
  120. kestrix/mail/parse.py +512 -0
  121. kestrix/py.typed +0 -0
  122. kestrix/server/__init__.py +2 -0
  123. kestrix/server/app.py +428 -0
  124. kestrix/server/auth.py +689 -0
  125. kestrix/server/auth_api.py +179 -0
  126. kestrix/server/cli.py +84 -0
  127. kestrix/server/headers.py +81 -0
  128. kestrix/server/history.py +359 -0
  129. kestrix/server/history_api.py +86 -0
  130. kestrix/server/hosted.py +231 -0
  131. kestrix/server/isolation.py +171 -0
  132. kestrix/server/ratelimit.py +308 -0
  133. kestrix/server/settings.py +275 -0
  134. kestrix/server/static/.gitkeep +0 -0
  135. kestrix/server/static/THIRD_PARTY_LICENSES.txt +465 -0
  136. kestrix/server/static/assets/BitcoinPage-Dk0JdMMW.js +3 -0
  137. kestrix/server/static/assets/Circuit-D9Xtskap.js +1 -0
  138. kestrix/server/static/assets/DetectPage-BhCZhWw0.js +14 -0
  139. kestrix/server/static/assets/DocsPage-Dw5Rk3jW.js +38 -0
  140. kestrix/server/static/assets/FileAnalyzer-zviFpLEL.js +1 -0
  141. kestrix/server/static/assets/ForensicsPage-BMbyBhxb.js +1 -0
  142. kestrix/server/static/assets/GuidePage-BNbcKnHL.js +1 -0
  143. kestrix/server/static/assets/HistoryDetailPage-Dnxtq7EE.js +1 -0
  144. kestrix/server/static/assets/HistoryPage-CvZ4j3U8.js +1 -0
  145. kestrix/server/static/assets/HomePage-BFTI74Q_.js +1 -0
  146. kestrix/server/static/assets/LandingPage-DlfrTaOd.js +1 -0
  147. kestrix/server/static/assets/LegalPage-WASPu4zG.js +1 -0
  148. kestrix/server/static/assets/MailConnectPage-VtsiPKzx.js +1 -0
  149. kestrix/server/static/assets/MailPage-BD6zHN2C.js +1 -0
  150. kestrix/server/static/assets/MigrationPage-BnKZmv-L.js +1 -0
  151. kestrix/server/static/assets/NotFound-tm3xnaV2.js +1 -0
  152. kestrix/server/static/assets/OnThisPage-XM_hxomH.js +1 -0
  153. kestrix/server/static/assets/PrivacyPage-GXjlAy6Y.js +1 -0
  154. kestrix/server/static/assets/PublicDocsPage-CBkpJy7m.js +1 -0
  155. kestrix/server/static/assets/PublicNotFound-CP5fZsF0.js +1 -0
  156. kestrix/server/static/assets/SettingsPage-7a6bYB7M.js +1 -0
  157. kestrix/server/static/assets/SignInPage-BA6qL1Sk.js +1 -0
  158. kestrix/server/static/assets/Tabs-1Tk6eNDK.js +1 -0
  159. kestrix/server/static/assets/TermsPage-G_Qklepx.js +1 -0
  160. kestrix/server/static/assets/ToolGuide-nXeKjyIr.js +98 -0
  161. kestrix/server/static/assets/ToolHeader-CtvoXW5W.js +3 -0
  162. kestrix/server/static/assets/format-BElNGBcu.js +1 -0
  163. kestrix/server/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  164. kestrix/server/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  165. kestrix/server/static/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  166. kestrix/server/static/assets/index-B50yPw27.js +10 -0
  167. kestrix/server/static/assets/index-BT6P83DK.css +2 -0
  168. kestrix/server/static/assets/model-DdnZO1N3.js +1 -0
  169. kestrix/server/static/assets/motion-JYrJZm3a.js +1 -0
  170. kestrix/server/static/assets/paths-D8iG4Wfy.js +1 -0
  171. kestrix/server/static/assets/queries-CKHfLmY-.js +1 -0
  172. kestrix/server/static/assets/react-dom-7JdJetmf.js +1 -0
  173. kestrix/server/static/assets/samples-CnRKXC5B.js +2 -0
  174. kestrix/server/static/assets/useTitle-C6Ny52WT.js +1 -0
  175. kestrix/server/static/favicon.svg +1 -0
  176. kestrix/server/static/index.html +26 -0
  177. kestrix/server/store.py +343 -0
  178. kestrix/server/tokens.py +114 -0
  179. kestrix/webcheck/__init__.py +2 -0
  180. kestrix/webcheck/api.py +84 -0
  181. kestrix/webcheck/checks.py +523 -0
  182. kestrix/webcheck/cli.py +63 -0
  183. kestrix/webcheck/crawl.py +297 -0
  184. kestrix/webcheck/data/exposure.json +614 -0
  185. kestrix/webcheck/exposure.py +418 -0
  186. kestrix/webcheck/fetch.py +404 -0
  187. kestrix/webcheck/grade.py +25 -0
  188. kestrix/webcheck/models.py +122 -0
  189. kestrix/webcheck/profile.py +293 -0
  190. kestrix/webcheck/scan.py +170 -0
  191. kestrix-0.1.0.dist-info/METADATA +585 -0
  192. kestrix-0.1.0.dist-info/RECORD +196 -0
  193. kestrix-0.1.0.dist-info/WHEEL +4 -0
  194. kestrix-0.1.0.dist-info/entry_points.txt +3 -0
  195. kestrix-0.1.0.dist-info/licenses/LICENSE +202 -0
  196. kestrix-0.1.0.dist-info/licenses/NOTICE +40 -0
kestrix/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Kestrix: local-first security workbench."""
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "Ruthvik Bandari"
7
+ # Stamped on every report, export and run manifest Kestrix produces.
8
+ ATTRIBUTION = f"Kestrix by {__author__}"
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari and the BitShield (Team ACE) contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Bitcoin address quantum-exposure scanning.
4
+
5
+ Classifies addresses and raw public keys by whether their public key is visible on the
6
+ blockchain, optionally checks spend history on mempool.space, explains each result in
7
+ plain language and gives migration steps. It never holds keys, wallets or coins.
8
+ """
@@ -0,0 +1,231 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari and the BitShield (Team ACE) contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Bitcoin address and raw public key parsing, standard library only.
4
+
5
+ Base58Check addresses are verified with their double-SHA-256 checksum. Segwit
6
+ addresses follow BIP-173 (Bech32, witness version 0) and BIP-350 (Bech32m, versions
7
+ 1-16), including the checksum-variant, witness-version and program-length rules. Raw
8
+ public keys (the lock used by P2PK outputs, which have no address form) are accepted as
9
+ 33-byte compressed or 65-byte uncompressed SEC hex and must lie on secp256k1.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import string
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+
19
+ from ..core.errors import Reason, Rejected
20
+ from .models import AddressType, Network
21
+
22
+ MAX_INPUT_CHARS = 130 # an uncompressed public key in hex
23
+
24
+ _B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
25
+ _B58_INDEX = {ch: i for i, ch in enumerate(_B58_ALPHABET)}
26
+ _B58_VERSIONS = {
27
+ 0x00: (AddressType.P2PKH, Network.MAINNET),
28
+ 0x05: (AddressType.P2SH, Network.MAINNET),
29
+ 0x6F: (AddressType.P2PKH, Network.TEST),
30
+ 0xC4: (AddressType.P2SH, Network.TEST),
31
+ }
32
+
33
+ _BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
34
+ _BECH32_MAX_LENGTH = 90
35
+ _SEGWIT_HRPS = {"bc": Network.MAINNET, "tb": Network.TEST, "bcrt": Network.REGTEST}
36
+
37
+ _SECP256K1_P = 2**256 - 2**32 - 977
38
+
39
+
40
+ class Bech32Encoding(Enum):
41
+ BECH32 = 1
42
+ BECH32M = 0x2BC830A3
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ParsedAddress:
47
+ """A validated input. ``text`` is the canonical form (Bech32 and hex lowercased);
48
+ ``payload`` is the key hash, script hash, witness program or public key."""
49
+
50
+ text: str
51
+ address_type: AddressType
52
+ network: Network | None
53
+ payload: bytes
54
+
55
+
56
+ # --- Base58Check ---------------------------------------------------------------------
57
+
58
+
59
+ def base58_decode(text: str) -> bytes:
60
+ """Decode Base58 (Bitcoin alphabet). Each leading ``1`` is one leading zero byte."""
61
+ number = 0
62
+ for ch in text:
63
+ digit = _B58_INDEX.get(ch)
64
+ if digit is None:
65
+ raise ValueError(f"invalid Base58 character {ch!r}")
66
+ number = number * 58 + digit
67
+ leading_zeros = len(text) - len(text.lstrip("1"))
68
+ return b"\x00" * leading_zeros + number.to_bytes((number.bit_length() + 7) // 8, "big")
69
+
70
+
71
+ def base58check_decode(text: str) -> bytes:
72
+ """Decode Base58Check and return the payload with its 4-byte checksum verified."""
73
+ raw = base58_decode(text)
74
+ if len(raw) < 5:
75
+ raise ValueError("too short for Base58Check")
76
+ payload, checksum = raw[:-4], raw[-4:]
77
+ if hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] != checksum:
78
+ raise ValueError("Base58Check checksum mismatch")
79
+ return payload
80
+
81
+
82
+ # --- Bech32 / Bech32m ----------------------------------------------------------------
83
+
84
+
85
+ def _bech32_polymod(values: list[int]) -> int:
86
+ generators = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
87
+ checksum = 1
88
+ for value in values:
89
+ top = checksum >> 25
90
+ checksum = (checksum & 0x1FFFFFF) << 5 ^ value
91
+ for i, generator in enumerate(generators):
92
+ if (top >> i) & 1:
93
+ checksum ^= generator
94
+ return checksum
95
+
96
+
97
+ def _hrp_expand(hrp: str) -> list[int]:
98
+ return [ord(ch) >> 5 for ch in hrp] + [0] + [ord(ch) & 31 for ch in hrp]
99
+
100
+
101
+ def bech32_decode(text: str) -> tuple[str, list[int], Bech32Encoding]:
102
+ """Decode a Bech32 or Bech32m string into ``(hrp, 5-bit data without checksum, encoding)``."""
103
+ if any(not 33 <= ord(ch) <= 126 for ch in text):
104
+ raise ValueError("character out of range")
105
+ if text.lower() != text and text.upper() != text:
106
+ raise ValueError("mixed case")
107
+ if len(text) > _BECH32_MAX_LENGTH:
108
+ raise ValueError(f"longer than {_BECH32_MAX_LENGTH} characters")
109
+ text = text.lower()
110
+ separator = text.rfind("1")
111
+ if separator < 1:
112
+ raise ValueError("missing separator or empty human-readable part")
113
+ if separator + 7 > len(text):
114
+ raise ValueError("checksum too short")
115
+ hrp, data_part = text[:separator], text[separator + 1 :]
116
+ if any(ch not in _BECH32_CHARSET for ch in data_part):
117
+ raise ValueError("invalid data character")
118
+ data = [_BECH32_CHARSET.index(ch) for ch in data_part]
119
+ constant = _bech32_polymod(_hrp_expand(hrp) + data)
120
+ try:
121
+ encoding = Bech32Encoding(constant)
122
+ except ValueError:
123
+ raise ValueError("invalid checksum") from None
124
+ return hrp, data[:-6], encoding
125
+
126
+
127
+ def _convert_5_to_8_bits(data: list[int]) -> bytes:
128
+ """Regroup 5-bit values into bytes; leftover padding must be under 5 bits and zero."""
129
+ accumulator = bits = 0
130
+ out = bytearray()
131
+ for value in data:
132
+ accumulator = (accumulator << 5) | value
133
+ bits += 5
134
+ if bits >= 8:
135
+ bits -= 8
136
+ out.append((accumulator >> bits) & 0xFF)
137
+ if bits >= 5 or (accumulator << (8 - bits)) & 0xFF:
138
+ raise ValueError("invalid padding")
139
+ return bytes(out)
140
+
141
+
142
+ def decode_segwit(text: str) -> tuple[str, int, bytes]:
143
+ """Decode a segwit address into ``(hrp, witness version, witness program)``."""
144
+ hrp, data, encoding = bech32_decode(text)
145
+ if not data:
146
+ raise ValueError("empty data section")
147
+ version = data[0]
148
+ if version > 16:
149
+ raise ValueError(f"invalid witness version {version}")
150
+ program = _convert_5_to_8_bits(data[1:])
151
+ if not 2 <= len(program) <= 40:
152
+ raise ValueError(f"invalid witness program length {len(program)}")
153
+ if version == 0 and len(program) not in (20, 32):
154
+ raise ValueError(f"invalid witness program length {len(program)} for version 0")
155
+ expected = Bech32Encoding.BECH32 if version == 0 else Bech32Encoding.BECH32M
156
+ if encoding is not expected:
157
+ raise ValueError(f"witness version {version} requires {expected.name.capitalize()} checksum")
158
+ return hrp, version, program
159
+
160
+
161
+ # --- Public keys ---------------------------------------------------------------------
162
+
163
+
164
+ def _is_on_curve(sec: bytes) -> bool:
165
+ x = int.from_bytes(sec[1:33], "big")
166
+ if x >= _SECP256K1_P:
167
+ return False
168
+ rhs = (pow(x, 3, _SECP256K1_P) + 7) % _SECP256K1_P
169
+ if len(sec) == 33:
170
+ return pow(rhs, (_SECP256K1_P - 1) // 2, _SECP256K1_P) == 1 # rhs has a square root
171
+ y = int.from_bytes(sec[33:], "big")
172
+ return y < _SECP256K1_P and pow(y, 2, _SECP256K1_P) == rhs
173
+
174
+
175
+ def _parse_public_key(text: str) -> ParsedAddress:
176
+ sec = bytes.fromhex(text)
177
+ valid_prefix = (len(sec) == 33 and sec[0] in (2, 3)) or (len(sec) == 65 and sec[0] == 4)
178
+ if not valid_prefix:
179
+ raise ValueError("a public key must be 33 bytes starting 02/03 or 65 bytes starting 04")
180
+ if not _is_on_curve(sec):
181
+ raise ValueError("not a point on the secp256k1 curve")
182
+ return ParsedAddress(text.lower(), AddressType.P2PK, None, sec)
183
+
184
+
185
+ # --- Dispatch ------------------------------------------------------------------------
186
+
187
+
188
+ def _parse_segwit(text: str) -> ParsedAddress:
189
+ hrp, version, program = decode_segwit(text)
190
+ network = _SEGWIT_HRPS[hrp]
191
+ if version == 0:
192
+ address_type = AddressType.P2WPKH if len(program) == 20 else AddressType.P2WSH
193
+ elif version == 1 and len(program) == 32:
194
+ address_type = AddressType.P2TR
195
+ else:
196
+ raise ValueError(
197
+ f"witness version {version} with a {len(program)}-byte program is valid encoding "
198
+ "but not a defined output type; do not send coins to it"
199
+ )
200
+ return ParsedAddress(text.lower(), address_type, network, program)
201
+
202
+
203
+ def _parse_base58(text: str) -> ParsedAddress:
204
+ payload = base58check_decode(text)
205
+ if len(payload) != 21 or payload[0] not in _B58_VERSIONS:
206
+ raise ValueError("not a Bitcoin P2PKH or P2SH address")
207
+ address_type, network = _B58_VERSIONS[payload[0]]
208
+ return ParsedAddress(text, address_type, network, payload[1:])
209
+
210
+
211
+ def _looks_segwit(text: str) -> bool:
212
+ lowered = text.lower()
213
+ return any(lowered.startswith(hrp + "1") for hrp in _SEGWIT_HRPS)
214
+
215
+
216
+ def parse_address(raw: str) -> ParsedAddress:
217
+ """Parse an address or raw public key, raising ``Rejected(INVALID_INPUT)`` if invalid."""
218
+ text = raw.strip()
219
+ shown = text[:100]
220
+ if not text:
221
+ raise Rejected(Reason.INVALID_INPUT, "empty address")
222
+ if len(text) > MAX_INPUT_CHARS:
223
+ raise Rejected(Reason.INVALID_INPUT, f"{shown!r}...: too long to be an address or public key")
224
+ try:
225
+ if len(text) in (66, 130) and all(ch in string.hexdigits for ch in text):
226
+ return _parse_public_key(text)
227
+ if _looks_segwit(text):
228
+ return _parse_segwit(text)
229
+ return _parse_base58(text)
230
+ except ValueError as exc:
231
+ raise Rejected(Reason.INVALID_INPUT, f"{shown!r} is not a valid Bitcoin address or public key: {exc}") from None
kestrix/bitcoin/api.py ADDED
@@ -0,0 +1,70 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari and the BitShield (Team ACE) contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """HTTP routes for the Bitcoin module."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Callable
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends
11
+ from pydantic import BaseModel, ConfigDict, Field, StringConstraints
12
+
13
+ from . import guardian
14
+ from .chain import MempoolClient
15
+ from .migration import parse_address_type, plan_for
16
+ from .models import GuardianResult, MigrationPlan, PublicTestnet, ScanResult
17
+ from .risk import MAX_BATCH, scan
18
+
19
+ router = APIRouter(prefix="/api/bitcoin", tags=["bitcoin"])
20
+
21
+ ChainClientFactory = Callable[[PublicTestnet], MempoolClient]
22
+ Classifier = Callable[[str], GuardianResult]
23
+
24
+
25
+ class ScanRequest(BaseModel):
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+ addresses: list[Annotated[str, StringConstraints(max_length=200)]] = Field(min_length=1, max_length=MAX_BATCH)
29
+ lookup_chain: bool = False
30
+ testnet: PublicTestnet = PublicTestnet.TESTNET4
31
+
32
+
33
+ class GuardianRequest(BaseModel):
34
+ model_config = ConfigDict(extra="forbid")
35
+
36
+ message: str = Field(min_length=1, max_length=guardian.MAX_MESSAGE_CHARS)
37
+
38
+
39
+ def chain_client_factory() -> ChainClientFactory:
40
+ """Dependency so tests can swap in a client backed by a mock transport."""
41
+
42
+ def make(testnet: PublicTestnet) -> MempoolClient:
43
+ return MempoolClient(testnet=testnet)
44
+
45
+ return make
46
+
47
+
48
+ @router.post("/scan")
49
+ def scan_addresses(
50
+ body: ScanRequest, make_client: Annotated[ChainClientFactory, Depends(chain_client_factory)]
51
+ ) -> ScanResult:
52
+ if not body.lookup_chain:
53
+ return scan(body.addresses)
54
+ with make_client(body.testnet) as client:
55
+ return scan(body.addresses, lookup=client.activity)
56
+
57
+
58
+ @router.get("/migration/{address_type}")
59
+ def migration_plan(address_type: str) -> MigrationPlan:
60
+ return plan_for(parse_address_type(address_type))
61
+
62
+
63
+ def guardian_classifier() -> Classifier:
64
+ """Dependency so tests can swap in a fake model. The key is checked per request, after body validation."""
65
+ return lambda message: guardian.classify_intent(message, guardian.build_client())
66
+
67
+
68
+ @router.post("/guardian")
69
+ def classify(body: GuardianRequest, classifier: Annotated[Classifier, Depends(guardian_classifier)]) -> GuardianResult:
70
+ return classifier(body.message)
@@ -0,0 +1,119 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari and the BitShield (Team ACE) contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Optional spend-history lookup on the public mempool.space API.
4
+
5
+ Only used when the user asks for it: the queried addresses are sent to mempool.space.
6
+ Every request URL passes ``netguard.check_url``; redirects are not followed, responses
7
+ are size-capped and schema-validated, and any failure is ``Rejected(UPSTREAM_ERROR)``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from types import TracebackType
13
+ from typing import Literal
14
+
15
+ import httpx
16
+ from pydantic import BaseModel, Field, ValidationError
17
+
18
+ from ..core.errors import Reason, Rejected
19
+ from ..core.netguard import check_url
20
+ from .address import ParsedAddress
21
+ from .models import ChainActivity, Network, PublicTestnet
22
+
23
+ MEMPOOL_BASE_URL = "https://mempool.space"
24
+ TIMEOUT_SECONDS = 10.0
25
+ MAX_RESPONSE_BYTES = 64 * 1024
26
+
27
+ ChainName = Literal["mainnet", "testnet4", "signet"]
28
+ _API_PREFIX: dict[ChainName, str] = {"mainnet": "/api", "testnet4": "/testnet4/api", "signet": "/signet/api"}
29
+
30
+
31
+ class _TxoStats(BaseModel):
32
+ funded_txo_count: int = Field(ge=0)
33
+ funded_txo_sum: int = Field(ge=0)
34
+ spent_txo_count: int = Field(ge=0)
35
+ spent_txo_sum: int = Field(ge=0)
36
+
37
+
38
+ class _AddressStats(BaseModel):
39
+ chain_stats: _TxoStats
40
+ mempool_stats: _TxoStats
41
+
42
+
43
+ class MempoolClient:
44
+ """Looks up address spend history. Use as a context manager to close the connection pool."""
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ base_url: str = MEMPOOL_BASE_URL,
50
+ testnet: PublicTestnet = PublicTestnet.TESTNET4,
51
+ transport: httpx.BaseTransport | None = None,
52
+ ) -> None:
53
+ self._base_url = base_url.rstrip("/")
54
+ self._testnet = testnet
55
+ self._http = httpx.Client(
56
+ timeout=TIMEOUT_SECONDS,
57
+ follow_redirects=False,
58
+ transport=transport,
59
+ headers={"Accept": "application/json"},
60
+ )
61
+
62
+ def __enter__(self) -> MempoolClient:
63
+ return self
64
+
65
+ def __exit__(
66
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
67
+ ) -> None:
68
+ self._http.close()
69
+
70
+ def _chain_for(self, network: Network | None) -> ChainName:
71
+ if network is Network.MAINNET:
72
+ return "mainnet"
73
+ if network is Network.TEST:
74
+ return "testnet4" if self._testnet is PublicTestnet.TESTNET4 else "signet"
75
+ raise Rejected(Reason.INVALID_INPUT, "only mainnet and public test-network addresses can be looked up")
76
+
77
+ def _get(self, url: str) -> bytes:
78
+ check_url(url)
79
+ try:
80
+ with self._http.stream("GET", url) as response:
81
+ if response.status_code == 429:
82
+ raise Rejected(
83
+ Reason.UPSTREAM_ERROR,
84
+ "mempool.space rate limit reached; try again later or scan fewer addresses",
85
+ )
86
+ if response.status_code >= 500:
87
+ raise Rejected(Reason.UPSTREAM_ERROR, f"mempool.space server error (HTTP {response.status_code})")
88
+ if response.status_code != 200:
89
+ raise Rejected(Reason.UPSTREAM_ERROR, f"mempool.space returned HTTP {response.status_code}")
90
+ body = bytearray()
91
+ for chunk in response.iter_bytes():
92
+ body += chunk
93
+ if len(body) > MAX_RESPONSE_BYTES:
94
+ raise Rejected(Reason.UPSTREAM_ERROR, "mempool.space response exceeded the size limit")
95
+ return bytes(body)
96
+ except httpx.TimeoutException:
97
+ raise Rejected(
98
+ Reason.UPSTREAM_ERROR, f"mempool.space did not respond within {TIMEOUT_SECONDS:.0f} s"
99
+ ) from None
100
+ except httpx.HTTPError as exc:
101
+ raise Rejected(Reason.UPSTREAM_ERROR, f"could not reach mempool.space: {type(exc).__name__}") from None
102
+
103
+ def activity(self, parsed: ParsedAddress) -> ChainActivity:
104
+ chain = self._chain_for(parsed.network)
105
+ body = self._get(f"{self._base_url}{_API_PREFIX[chain]}/address/{parsed.text}")
106
+ try:
107
+ stats = _AddressStats.model_validate_json(body)
108
+ except ValidationError:
109
+ raise Rejected(Reason.UPSTREAM_ERROR, "mempool.space returned an unexpected response") from None
110
+ confirmed, pending = stats.chain_stats, stats.mempool_stats
111
+ return ChainActivity(
112
+ chain=chain,
113
+ funded_outputs=confirmed.funded_txo_count + pending.funded_txo_count,
114
+ spent_outputs=confirmed.spent_txo_count + pending.spent_txo_count,
115
+ balance_sats=confirmed.funded_txo_sum
116
+ + pending.funded_txo_sum
117
+ - confirmed.spent_txo_sum
118
+ - pending.spent_txo_sum,
119
+ )
kestrix/bitcoin/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ # SPDX-FileCopyrightText: 2026 Ruthvik Bandari and the BitShield (Team ACE) contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """``kestrix bitcoin`` commands.
4
+
5
+ Exit codes: 0 success, 2 rejected input, 3 upstream service or AI key unavailable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, NoReturn
11
+
12
+ import typer
13
+
14
+ from ..core.errors import Reason, Rejected
15
+ from . import guardian
16
+ from .chain import MempoolClient
17
+ from .migration import parse_address_type, plan_for
18
+ from .models import AddressAssessment, PublicTestnet
19
+ from .risk import scan as scan_inputs
20
+
21
+ app = typer.Typer(no_args_is_help=True)
22
+
23
+ EXIT_REJECTED = 2
24
+ EXIT_UNAVAILABLE = 3
25
+ _UNAVAILABLE = frozenset(
26
+ {Reason.UPSTREAM_ERROR, Reason.AI_KEY_MISSING, Reason.TARGET_UNREACHABLE, Reason.TARGET_NOT_ALLOWED}
27
+ )
28
+
29
+
30
+ def _fail(exc: Rejected) -> NoReturn:
31
+ typer.echo(f"error ({exc.reason.value}): {exc.detail}", err=True)
32
+ raise typer.Exit(EXIT_UNAVAILABLE if exc.reason in _UNAVAILABLE else EXIT_REJECTED)
33
+
34
+
35
+ def _print_assessment(item: AddressAssessment) -> None:
36
+ network = item.network.value if item.network else "no network"
37
+ typer.echo(f"[{item.risk.value.upper()}] {item.input} ({item.address_type.value}, {network})")
38
+ typer.echo(f" {item.explanation}")
39
+ typer.echo(f" What to do: {item.recommendation}")
40
+
41
+
42
+ @app.command()
43
+ def scan(
44
+ addresses: Annotated[list[str], typer.Argument(help="Addresses or hex public keys (up to 100).")],
45
+ lookup_chain: Annotated[
46
+ bool, typer.Option("--lookup-chain", help="Check spend history on mempool.space (sends the addresses there).")
47
+ ] = False,
48
+ testnet: Annotated[
49
+ PublicTestnet, typer.Option(help="Public test chain for tb1/m/n/2 addresses.")
50
+ ] = PublicTestnet.TESTNET4,
51
+ as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
52
+ ) -> None:
53
+ """Check addresses for public-key exposure to future quantum attacks."""
54
+ try:
55
+ if lookup_chain:
56
+ with MempoolClient(testnet=testnet) as client:
57
+ result = scan_inputs(addresses, lookup=client.activity)
58
+ else:
59
+ result = scan_inputs(addresses)
60
+ except Rejected as exc:
61
+ _fail(exc)
62
+ if as_json:
63
+ typer.echo(result.model_dump_json(indent=2))
64
+ return
65
+ for item in result.results:
66
+ _print_assessment(item)
67
+ counts = ", ".join(f"{risk.value} {count}" for risk, count in result.summary.by_risk.items())
68
+ typer.echo(f"\n{result.summary.total} scanned: {counts}")
69
+
70
+
71
+ @app.command()
72
+ def migration(
73
+ address_type: Annotated[str, typer.Argument(help="P2PK, P2PKH, P2SH, P2WPKH, P2WSH or P2TR.")],
74
+ as_json: Annotated[bool, typer.Option("--json", help="Print the plan as JSON.")] = False,
75
+ ) -> None:
76
+ """Show step-by-step migration advice for an address type."""
77
+ try:
78
+ plan = plan_for(parse_address_type(address_type))
79
+ except Rejected as exc:
80
+ _fail(exc)
81
+ if as_json:
82
+ typer.echo(plan.model_dump_json(indent=2))
83
+ return
84
+ typer.echo(f"{plan.address_type.value} (urgency: {plan.urgency})\n{plan.summary}\n")
85
+ for number, step in enumerate(plan.steps, start=1):
86
+ typer.echo(f"{number}. {step}")
87
+ typer.echo("\nCautions:")
88
+ for caution in plan.cautions:
89
+ typer.echo(f"- {caution}")
90
+
91
+
92
+ @app.command()
93
+ def ask(
94
+ message: Annotated[str, typer.Argument(help="A question or request in plain language.")],
95
+ as_json: Annotated[bool, typer.Option("--json", help="Print the result as JSON.")] = False,
96
+ ) -> None:
97
+ """Classify a plain-language request with your own Anthropic API key. Nothing is executed."""
98
+ try:
99
+ result = guardian.classify_intent(message, guardian.build_client())
100
+ except Rejected as exc:
101
+ _fail(exc)
102
+ if as_json:
103
+ typer.echo(result.model_dump_json(indent=2))
104
+ return
105
+ typer.echo(f"Intent: {result.intent.value}")
106
+ if result.addresses:
107
+ typer.echo(f"Addresses: {' '.join(result.addresses)}")
108
+ if result.ignored_addresses:
109
+ typer.echo(f"Ignored (not valid or not in your message): {' '.join(result.ignored_addresses)}")
110
+ typer.echo(f"Understood as (model-written): {result.model_summary}")
111
+ typer.echo(f"Next step: {result.next_step}")