onecmd 0.2.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.
onecmd/__init__.py ADDED
File without changes
File without changes
onecmd/auth/owner.py ADDED
@@ -0,0 +1,45 @@
1
+ """Owner registration and verification.
2
+
3
+ Calling spec:
4
+ Inputs: Store instance, user_id (int)
5
+ Outputs: tuple[bool, bool] — (is_owner, just_registered)
6
+ Side effects: writes "owner_id" to store on first-ever call
7
+
8
+ Logic:
9
+ 1. Read "owner_id" from store (O(1) SQLite lookup).
10
+ 2. If no owner registered, register this user as owner.
11
+ Return (True, True).
12
+ 3. If owner exists and matches user_id, return (True, False).
13
+ 4. If owner exists and does NOT match, return (False, False).
14
+
15
+ Guarding:
16
+ - First user to message becomes owner (stored via store.set)
17
+ - No mechanism to change owner at runtime
18
+ - Owner check is O(1) lookup, not bypassable
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from onecmd.store import Store
24
+
25
+ OWNER_KEY = "owner_id"
26
+
27
+
28
+ def check_owner(store: Store, user_id: int) -> tuple[bool, bool]:
29
+ """Check whether *user_id* is the bot owner.
30
+
31
+ Returns ``(is_owner, just_registered)``.
32
+
33
+ On the very first call (no owner in the store), the caller is
34
+ registered as owner and ``(True, True)`` is returned. All
35
+ subsequent calls compare against the stored owner ID.
36
+ """
37
+ stored = store.get(OWNER_KEY)
38
+
39
+ # First user becomes owner.
40
+ if stored is None:
41
+ store.set(OWNER_KEY, str(user_id))
42
+ return True, True
43
+
44
+ is_owner = stored == str(user_id)
45
+ return is_owner, False
onecmd/auth/totp.py ADDED
@@ -0,0 +1,176 @@
1
+ """TOTP authentication for onecmd.
2
+
3
+ Calling spec:
4
+ Inputs:
5
+ totp_setup(store, enable_otp, weak_security, otp_timeout) -> bool
6
+ totp_verify(code, secret_hex) -> bool
7
+ generate_secret() -> bytes (20 random bytes via os.urandom)
8
+ base32_encode(data) -> str (RFC 4648)
9
+ totp_code(secret, time_step) -> int (6-digit TOTP)
10
+ build_otpauth_uri(secret_b32, issuer, label) -> str
11
+ print_qr(uri) -> None (text QR to stdout)
12
+ is_timed_out(last_auth_time, timeout) -> bool
13
+ Outputs: bool (verified) or None (setup side effects)
14
+ Side effects: generates secret on first run, prints QR to terminal
15
+
16
+ Sealed (deterministic):
17
+ - HMAC-SHA1 based TOTP (RFC 6238), 30s steps, +/-1 tolerance
18
+ - QR code via qrcode library
19
+
20
+ Guarding:
21
+ - Secret from os.urandom(20), stored in SQLite, never logged
22
+ - OTP codes validated as exactly 6 digits before comparison
23
+ - Constant-time comparison via hmac.compare_digest
24
+ - Timeout enforced: re-auth required after otp_timeout seconds
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import hmac
31
+ import os
32
+ import struct
33
+ import time
34
+
35
+ import qrcode # type: ignore[import-untyped]
36
+
37
+ TOTP_PERIOD = 30
38
+ TOTP_DIGITS = 6
39
+ SECRET_LENGTH = 20
40
+ TOTP_WINDOW = 1 # +/-1 time step tolerance
41
+ STORE_KEY = "totp_secret"
42
+
43
+
44
+ def generate_secret() -> bytes:
45
+ """Return 20 cryptographically secure random bytes."""
46
+ return os.urandom(SECRET_LENGTH)
47
+
48
+
49
+ _B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
50
+
51
+
52
+ def base32_encode(data: bytes) -> str:
53
+ """Encode *data* to Base32 (RFC 4648), no padding."""
54
+ buf = 0
55
+ bits = 0
56
+ out: list[str] = []
57
+ for byte in data:
58
+ buf = (buf << 8) | byte
59
+ bits += 8
60
+ while bits >= 5:
61
+ bits -= 5
62
+ out.append(_B32_ALPHABET[(buf >> bits) & 0x1F])
63
+ if bits > 0:
64
+ out.append(_B32_ALPHABET[(buf << (5 - bits)) & 0x1F])
65
+ return "".join(out)
66
+
67
+
68
+ def totp_code(secret: bytes, time_step: int) -> int:
69
+ """Compute 6-digit TOTP from *secret* and *time_step*."""
70
+ msg = struct.pack(">Q", time_step)
71
+ digest = hmac.new(secret, msg, hashlib.sha1).digest()
72
+ offset = digest[19] & 0x0F
73
+ code = (
74
+ ((digest[offset] & 0x7F) << 24)
75
+ | (digest[offset + 1] << 16)
76
+ | (digest[offset + 2] << 8)
77
+ | digest[offset + 3]
78
+ )
79
+ return code % (10**TOTP_DIGITS)
80
+
81
+
82
+ def _bytes_to_hex(data: bytes) -> str:
83
+ return data.hex()
84
+
85
+
86
+ def _hex_to_bytes(hex_str: str) -> bytes:
87
+ return bytes.fromhex(hex_str)
88
+
89
+
90
+ def build_otpauth_uri(
91
+ secret_b32: str,
92
+ issuer: str = "tgterm",
93
+ label: str = "tgterm",
94
+ ) -> str:
95
+ """Build an otpauth:// URI for TOTP QR code generation."""
96
+ return f"otpauth://totp/{label}?secret={secret_b32}&issuer={issuer}"
97
+
98
+
99
+ def print_qr(uri: str) -> None:
100
+ """Print a text-mode QR code for *uri* to stdout."""
101
+ qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
102
+ qr.add_data(uri)
103
+ qr.make(fit=True)
104
+ qr.print_ascii()
105
+
106
+
107
+ def is_timed_out(last_auth_time: float, timeout: int) -> bool:
108
+ """Return True if re-authentication is needed."""
109
+ return (time.time() - last_auth_time) >= timeout
110
+
111
+
112
+ def totp_verify(code: str, secret_hex: str) -> bool:
113
+ """Verify a TOTP *code* against *secret_hex*.
114
+
115
+ Returns True only when *code* is a valid 6-digit string matching
116
+ the current time step or +/-1. Constant-time via hmac.compare_digest.
117
+ """
118
+ if not isinstance(code, str) or len(code) != TOTP_DIGITS:
119
+ return False
120
+ if not code.isdigit():
121
+ return False
122
+
123
+ secret = _hex_to_bytes(secret_hex)
124
+ if len(secret) != SECRET_LENGTH:
125
+ return False
126
+
127
+ now_step = int(time.time()) // TOTP_PERIOD
128
+
129
+ for offset in range(-TOTP_WINDOW, TOTP_WINDOW + 1):
130
+ expected = totp_code(secret, now_step + offset)
131
+ expected_str = f"{expected:06d}"
132
+ if hmac.compare_digest(code, expected_str):
133
+ return True
134
+
135
+ return False
136
+
137
+
138
+ def totp_setup(
139
+ store: object,
140
+ enable_otp: bool,
141
+ weak_security: bool,
142
+ otp_timeout: int = 300,
143
+ ) -> bool:
144
+ """Set up TOTP authentication.
145
+
146
+ *store* must have get(key)->str|None and set(key, value) methods.
147
+ Returns True if OTP is active, False if disabled.
148
+ """
149
+ if weak_security:
150
+ return False
151
+
152
+ if not enable_otp:
153
+ return False
154
+
155
+ existing = store.get(STORE_KEY) # type: ignore[union-attr]
156
+ if existing:
157
+ timeout_str = store.get("otp_timeout") # type: ignore[union-attr]
158
+ if timeout_str:
159
+ t = int(timeout_str)
160
+ if 30 <= t <= 28800:
161
+ pass # caller reads back; we validate bounds
162
+ return True
163
+
164
+ secret = generate_secret()
165
+ store.set(STORE_KEY, _bytes_to_hex(secret)) # type: ignore[union-attr]
166
+
167
+ b32 = base32_encode(secret)
168
+ uri = build_otpauth_uri(b32)
169
+
170
+ print("\n=== TOTP Setup ===")
171
+ print("Scan this QR code with Google Authenticator:\n")
172
+ print_qr(uri)
173
+ print(f"\nOr enter this secret manually: {b32}")
174
+ print("==================\n")
175
+
176
+ return True
onecmd/bot/__init__.py ADDED
File without changes
onecmd/bot/api.py ADDED
@@ -0,0 +1,141 @@
1
+ """Telegram message helper functions.
2
+
3
+ Calling spec:
4
+ Inputs: bot (telegram.Bot instance), chat_id (int), text/msg_id/callback_id
5
+ Outputs: msg_id (int) or bool; None/False on failure
6
+ Side effects: Telegram API calls (send, edit, delete, answer callback)
7
+
8
+ Functions:
9
+ html_escape(text) -> str
10
+ Escapes <, >, & for Telegram HTML.
11
+
12
+ send_message(bot, chat_id, text, reply_markup=None, parse_mode="HTML") -> int | None
13
+ Send a text message. Returns message_id on success, None on failure.
14
+
15
+ edit_message(bot, chat_id, msg_id, text, parse_mode="HTML") -> bool
16
+ Edit an existing message. Returns True on success, False on failure.
17
+
18
+ delete_message(bot, chat_id, msg_id) -> bool
19
+ Delete a message. Returns True on success, False on failure.
20
+
21
+ answer_callback(bot, callback_id) -> bool
22
+ Acknowledge a callback query. Returns True on success, False on failure.
23
+
24
+ Guarding:
25
+ - Text length capped at 4096 chars (Telegram limit), truncated with "..."
26
+ - HTML-escapes user content in <pre> blocks (escape <, >, &)
27
+ - chat_id validated as integer
28
+ - All functions handle telegram.error.TelegramError gracefully (log + return None/False)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ from typing import TYPE_CHECKING
35
+
36
+ from telegram.error import TelegramError
37
+
38
+ if TYPE_CHECKING:
39
+ from telegram import Bot
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ MAX_TEXT_LENGTH = 4096
44
+
45
+
46
+ def html_escape(text: str) -> str:
47
+ """Escape <, >, & for Telegram HTML messages."""
48
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
49
+
50
+
51
+ def _truncate(text: str) -> str:
52
+ """Truncate text to Telegram's 4096-char limit, adding '...' if truncated."""
53
+ if len(text) <= MAX_TEXT_LENGTH:
54
+ return text
55
+ return text[: MAX_TEXT_LENGTH - 3] + "..."
56
+
57
+
58
+ def _validate_chat_id(chat_id: int) -> int:
59
+ """Validate chat_id is an integer. Raises TypeError if not."""
60
+ if not isinstance(chat_id, int):
61
+ raise TypeError(f"chat_id must be int, got {type(chat_id).__name__}")
62
+ return chat_id
63
+
64
+
65
+ def send_message(
66
+ bot: Bot,
67
+ chat_id: int,
68
+ text: str,
69
+ reply_markup: object | None = None,
70
+ parse_mode: str = "HTML",
71
+ ) -> int | None:
72
+ """Send a message via Telegram. Returns message_id or None on failure."""
73
+ try:
74
+ _validate_chat_id(chat_id)
75
+ text = _truncate(text)
76
+ msg = bot.send_message(
77
+ chat_id=chat_id,
78
+ text=text,
79
+ parse_mode=parse_mode,
80
+ reply_markup=reply_markup,
81
+ disable_web_page_preview=True,
82
+ )
83
+ return msg.message_id
84
+ except TypeError:
85
+ raise
86
+ except TelegramError as exc:
87
+ logger.error("send_message failed chat_id=%s: %s", chat_id, exc)
88
+ return None
89
+
90
+
91
+ def edit_message(
92
+ bot: Bot,
93
+ chat_id: int,
94
+ msg_id: int,
95
+ text: str,
96
+ parse_mode: str = "HTML",
97
+ ) -> bool:
98
+ """Edit an existing message. Returns True on success, False on failure."""
99
+ try:
100
+ _validate_chat_id(chat_id)
101
+ text = _truncate(text)
102
+ bot.edit_message_text(
103
+ chat_id=chat_id,
104
+ message_id=msg_id,
105
+ text=text,
106
+ parse_mode=parse_mode,
107
+ disable_web_page_preview=True,
108
+ )
109
+ return True
110
+ except TypeError:
111
+ raise
112
+ except TelegramError as exc:
113
+ logger.error(
114
+ "edit_message failed chat_id=%s msg_id=%s: %s", chat_id, msg_id, exc
115
+ )
116
+ return False
117
+
118
+
119
+ def delete_message(bot: Bot, chat_id: int, msg_id: int) -> bool:
120
+ """Delete a message. Returns True on success, False on failure."""
121
+ try:
122
+ _validate_chat_id(chat_id)
123
+ bot.delete_message(chat_id=chat_id, message_id=msg_id)
124
+ return True
125
+ except TypeError:
126
+ raise
127
+ except TelegramError as exc:
128
+ logger.error(
129
+ "delete_message failed chat_id=%s msg_id=%s: %s", chat_id, msg_id, exc
130
+ )
131
+ return False
132
+
133
+
134
+ def answer_callback(bot: Bot, callback_id: str) -> bool:
135
+ """Acknowledge a callback query. Returns True on success, False on failure."""
136
+ try:
137
+ bot.answer_callback_query(callback_query_id=callback_id)
138
+ return True
139
+ except TelegramError as exc:
140
+ logger.error("answer_callback failed callback_id=%s: %s", callback_id, exc)
141
+ return False