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.
- kestrix/__init__.py +8 -0
- kestrix/bitcoin/__init__.py +8 -0
- kestrix/bitcoin/address.py +231 -0
- kestrix/bitcoin/api.py +70 -0
- kestrix/bitcoin/chain.py +119 -0
- kestrix/bitcoin/cli.py +111 -0
- kestrix/bitcoin/guardian.py +173 -0
- kestrix/bitcoin/migration.py +88 -0
- kestrix/bitcoin/models.py +118 -0
- kestrix/bitcoin/risk.py +181 -0
- kestrix/cli.py +184 -0
- kestrix/code/__init__.py +39 -0
- kestrix/code/cli.py +27 -0
- kestrix/core/__init__.py +2 -0
- kestrix/core/config.py +73 -0
- kestrix/core/enums.py +15 -0
- kestrix/core/errors.py +73 -0
- kestrix/core/evidence.py +69 -0
- kestrix/core/netguard.py +102 -0
- kestrix/core/safe_load.py +103 -0
- kestrix/detect/__init__.py +2 -0
- kestrix/detect/api.py +127 -0
- kestrix/detect/candidate.py +175 -0
- kestrix/detect/cases/c00-healthy-control/case.yml +9 -0
- kestrix/detect/cases/c00-healthy-control/development.jsonl +9 -0
- kestrix/detect/cases/c00-healthy-control/mapping.yml +10 -0
- kestrix/detect/cases/c00-healthy-control/rule.yml +14 -0
- kestrix/detect/cases/c00-healthy-control/validation.jsonl +10 -0
- kestrix/detect/cases/c01-mapping-drift-commandline/case.yml +12 -0
- kestrix/detect/cases/c01-mapping-drift-commandline/development.jsonl +9 -0
- kestrix/detect/cases/c01-mapping-drift-commandline/healthy_mapping.yml +10 -0
- kestrix/detect/cases/c01-mapping-drift-commandline/mapping.yml +10 -0
- kestrix/detect/cases/c01-mapping-drift-commandline/rule.yml +14 -0
- kestrix/detect/cases/c01-mapping-drift-commandline/validation.jsonl +10 -0
- kestrix/detect/cases/c02-mapping-drift-parentimage/case.yml +12 -0
- kestrix/detect/cases/c02-mapping-drift-parentimage/development.jsonl +9 -0
- kestrix/detect/cases/c02-mapping-drift-parentimage/healthy_mapping.yml +10 -0
- kestrix/detect/cases/c02-mapping-drift-parentimage/mapping.yml +10 -0
- kestrix/detect/cases/c02-mapping-drift-parentimage/rule.yml +18 -0
- kestrix/detect/cases/c02-mapping-drift-parentimage/validation.jsonl +10 -0
- kestrix/detect/cases/c03-noise-updater-temp/case.yml +12 -0
- kestrix/detect/cases/c03-noise-updater-temp/development.jsonl +11 -0
- kestrix/detect/cases/c03-noise-updater-temp/healthy_rule.yml +13 -0
- kestrix/detect/cases/c03-noise-updater-temp/mapping.yml +10 -0
- kestrix/detect/cases/c03-noise-updater-temp/rule.yml +11 -0
- kestrix/detect/cases/c03-noise-updater-temp/validation.jsonl +13 -0
- kestrix/detect/cases/c04-noise-installer-schtasks/case.yml +11 -0
- kestrix/detect/cases/c04-noise-installer-schtasks/development.jsonl +10 -0
- kestrix/detect/cases/c04-noise-installer-schtasks/healthy_rule.yml +14 -0
- kestrix/detect/cases/c04-noise-installer-schtasks/mapping.yml +10 -0
- kestrix/detect/cases/c04-noise-installer-schtasks/rule.yml +12 -0
- kestrix/detect/cases/c04-noise-installer-schtasks/validation.jsonl +11 -0
- kestrix/detect/cases/c05-missing-telemetry-commandline/case.yml +11 -0
- kestrix/detect/cases/c05-missing-telemetry-commandline/development.jsonl +8 -0
- kestrix/detect/cases/c05-missing-telemetry-commandline/mapping.yml +10 -0
- kestrix/detect/cases/c05-missing-telemetry-commandline/rule.yml +14 -0
- kestrix/detect/cases/c05-missing-telemetry-commandline/validation.jsonl +8 -0
- kestrix/detect/cases/c06-missing-telemetry-parentimage/case.yml +11 -0
- kestrix/detect/cases/c06-missing-telemetry-parentimage/development.jsonl +7 -0
- kestrix/detect/cases/c06-missing-telemetry-parentimage/mapping.yml +10 -0
- kestrix/detect/cases/c06-missing-telemetry-parentimage/rule.yml +12 -0
- kestrix/detect/cases/c06-missing-telemetry-parentimage/validation.jsonl +7 -0
- kestrix/detect/cases/c07-logic-and-or/case.yml +12 -0
- kestrix/detect/cases/c07-logic-and-or/development.jsonl +10 -0
- kestrix/detect/cases/c07-logic-and-or/mapping.yml +10 -0
- kestrix/detect/cases/c07-logic-and-or/rule.yml +15 -0
- kestrix/detect/cases/c07-logic-and-or/validation.jsonl +12 -0
- kestrix/detect/cases/c08-modifier-exact-vs-endswith/case.yml +12 -0
- kestrix/detect/cases/c08-modifier-exact-vs-endswith/development.jsonl +8 -0
- kestrix/detect/cases/c08-modifier-exact-vs-endswith/mapping.yml +10 -0
- kestrix/detect/cases/c08-modifier-exact-vs-endswith/rule.yml +12 -0
- kestrix/detect/cases/c08-modifier-exact-vs-endswith/validation.jsonl +9 -0
- kestrix/detect/cases/c09-unsupported-regex/case.yml +9 -0
- kestrix/detect/cases/c09-unsupported-regex/development.jsonl +8 -0
- kestrix/detect/cases/c09-unsupported-regex/mapping.yml +10 -0
- kestrix/detect/cases/c09-unsupported-regex/rule.yml +11 -0
- kestrix/detect/cases/c09-unsupported-regex/validation.jsonl +8 -0
- kestrix/detect/cases/c10-logic-missing-not/case.yml +12 -0
- kestrix/detect/cases/c10-logic-missing-not/development.jsonl +9 -0
- kestrix/detect/cases/c10-logic-missing-not/mapping.yml +10 -0
- kestrix/detect/cases/c10-logic-missing-not/rule.yml +14 -0
- kestrix/detect/cases/c10-logic-missing-not/validation.jsonl +10 -0
- kestrix/detect/cases.py +50 -0
- kestrix/detect/cli.py +173 -0
- kestrix/detect/diagnose.py +176 -0
- kestrix/detect/engine.py +167 -0
- kestrix/detect/fixtures.py +143 -0
- kestrix/detect/mapping.py +86 -0
- kestrix/detect/repair.py +153 -0
- kestrix/detect/replay.py +106 -0
- kestrix/detect/rule.py +412 -0
- kestrix/detect/workflow.py +299 -0
- kestrix/forensics/__init__.py +11 -0
- kestrix/forensics/analysis.py +771 -0
- kestrix/forensics/api.py +124 -0
- kestrix/forensics/cli.py +104 -0
- kestrix/forensics/exif.py +158 -0
- kestrix/forensics/models.py +141 -0
- kestrix/forensics/ole.py +139 -0
- kestrix/forensics/url.py +447 -0
- kestrix/integrations/__init__.py +5 -0
- kestrix/integrations/cli.py +40 -0
- kestrix/mail/__init__.py +11 -0
- kestrix/mail/analyze.py +185 -0
- kestrix/mail/analyzers.py +893 -0
- kestrix/mail/api.py +129 -0
- kestrix/mail/cli.py +148 -0
- kestrix/mail/intent.py +270 -0
- kestrix/mail/lookalike.py +229 -0
- kestrix/mail/mailbox/__init__.py +11 -0
- kestrix/mail/mailbox/api.py +139 -0
- kestrix/mail/mailbox/http.py +106 -0
- kestrix/mail/mailbox/loopback.py +82 -0
- kestrix/mail/mailbox/models.py +83 -0
- kestrix/mail/mailbox/oauth.py +160 -0
- kestrix/mail/mailbox/providers.py +310 -0
- kestrix/mail/mailbox/service.py +281 -0
- kestrix/mail/mailbox/store.py +95 -0
- kestrix/mail/models.py +82 -0
- kestrix/mail/parse.py +512 -0
- kestrix/py.typed +0 -0
- kestrix/server/__init__.py +2 -0
- kestrix/server/app.py +428 -0
- kestrix/server/auth.py +689 -0
- kestrix/server/auth_api.py +179 -0
- kestrix/server/cli.py +84 -0
- kestrix/server/headers.py +81 -0
- kestrix/server/history.py +359 -0
- kestrix/server/history_api.py +86 -0
- kestrix/server/hosted.py +231 -0
- kestrix/server/isolation.py +171 -0
- kestrix/server/ratelimit.py +308 -0
- kestrix/server/settings.py +275 -0
- kestrix/server/static/.gitkeep +0 -0
- kestrix/server/static/THIRD_PARTY_LICENSES.txt +465 -0
- kestrix/server/static/assets/BitcoinPage-Dk0JdMMW.js +3 -0
- kestrix/server/static/assets/Circuit-D9Xtskap.js +1 -0
- kestrix/server/static/assets/DetectPage-BhCZhWw0.js +14 -0
- kestrix/server/static/assets/DocsPage-Dw5Rk3jW.js +38 -0
- kestrix/server/static/assets/FileAnalyzer-zviFpLEL.js +1 -0
- kestrix/server/static/assets/ForensicsPage-BMbyBhxb.js +1 -0
- kestrix/server/static/assets/GuidePage-BNbcKnHL.js +1 -0
- kestrix/server/static/assets/HistoryDetailPage-Dnxtq7EE.js +1 -0
- kestrix/server/static/assets/HistoryPage-CvZ4j3U8.js +1 -0
- kestrix/server/static/assets/HomePage-BFTI74Q_.js +1 -0
- kestrix/server/static/assets/LandingPage-DlfrTaOd.js +1 -0
- kestrix/server/static/assets/LegalPage-WASPu4zG.js +1 -0
- kestrix/server/static/assets/MailConnectPage-VtsiPKzx.js +1 -0
- kestrix/server/static/assets/MailPage-BD6zHN2C.js +1 -0
- kestrix/server/static/assets/MigrationPage-BnKZmv-L.js +1 -0
- kestrix/server/static/assets/NotFound-tm3xnaV2.js +1 -0
- kestrix/server/static/assets/OnThisPage-XM_hxomH.js +1 -0
- kestrix/server/static/assets/PrivacyPage-GXjlAy6Y.js +1 -0
- kestrix/server/static/assets/PublicDocsPage-CBkpJy7m.js +1 -0
- kestrix/server/static/assets/PublicNotFound-CP5fZsF0.js +1 -0
- kestrix/server/static/assets/SettingsPage-7a6bYB7M.js +1 -0
- kestrix/server/static/assets/SignInPage-BA6qL1Sk.js +1 -0
- kestrix/server/static/assets/Tabs-1Tk6eNDK.js +1 -0
- kestrix/server/static/assets/TermsPage-G_Qklepx.js +1 -0
- kestrix/server/static/assets/ToolGuide-nXeKjyIr.js +98 -0
- kestrix/server/static/assets/ToolHeader-CtvoXW5W.js +3 -0
- kestrix/server/static/assets/format-BElNGBcu.js +1 -0
- kestrix/server/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- kestrix/server/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- kestrix/server/static/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- kestrix/server/static/assets/index-B50yPw27.js +10 -0
- kestrix/server/static/assets/index-BT6P83DK.css +2 -0
- kestrix/server/static/assets/model-DdnZO1N3.js +1 -0
- kestrix/server/static/assets/motion-JYrJZm3a.js +1 -0
- kestrix/server/static/assets/paths-D8iG4Wfy.js +1 -0
- kestrix/server/static/assets/queries-CKHfLmY-.js +1 -0
- kestrix/server/static/assets/react-dom-7JdJetmf.js +1 -0
- kestrix/server/static/assets/samples-CnRKXC5B.js +2 -0
- kestrix/server/static/assets/useTitle-C6Ny52WT.js +1 -0
- kestrix/server/static/favicon.svg +1 -0
- kestrix/server/static/index.html +26 -0
- kestrix/server/store.py +343 -0
- kestrix/server/tokens.py +114 -0
- kestrix/webcheck/__init__.py +2 -0
- kestrix/webcheck/api.py +84 -0
- kestrix/webcheck/checks.py +523 -0
- kestrix/webcheck/cli.py +63 -0
- kestrix/webcheck/crawl.py +297 -0
- kestrix/webcheck/data/exposure.json +614 -0
- kestrix/webcheck/exposure.py +418 -0
- kestrix/webcheck/fetch.py +404 -0
- kestrix/webcheck/grade.py +25 -0
- kestrix/webcheck/models.py +122 -0
- kestrix/webcheck/profile.py +293 -0
- kestrix/webcheck/scan.py +170 -0
- kestrix-0.1.0.dist-info/METADATA +585 -0
- kestrix-0.1.0.dist-info/RECORD +196 -0
- kestrix-0.1.0.dist-info/WHEEL +4 -0
- kestrix-0.1.0.dist-info/entry_points.txt +3 -0
- kestrix-0.1.0.dist-info/licenses/LICENSE +202 -0
- 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)
|
kestrix/bitcoin/chain.py
ADDED
|
@@ -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}")
|