televault 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.
televault/crypto.py ADDED
@@ -0,0 +1,170 @@
1
+ """Encryption utilities for TeleVault - AES-256-GCM with Argon2id."""
2
+
3
+ import os
4
+ import struct
5
+ from typing import BinaryIO, Iterator
6
+ from dataclasses import dataclass
7
+
8
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
9
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
10
+ from cryptography.hazmat.backends import default_backend
11
+
12
+ # Constants
13
+ SALT_SIZE = 16
14
+ NONCE_SIZE = 12
15
+ TAG_SIZE = 16 # GCM auth tag
16
+ KEY_SIZE = 32 # 256-bit key
17
+ HEADER_SIZE = SALT_SIZE + NONCE_SIZE # 28 bytes
18
+
19
+ # For streaming, we encrypt in blocks
20
+ BLOCK_SIZE = 64 * 1024 # 64KB blocks for streaming encryption
21
+
22
+
23
+ @dataclass
24
+ class EncryptionHeader:
25
+ """Header prepended to encrypted data."""
26
+
27
+ salt: bytes
28
+ nonce: bytes
29
+
30
+ def to_bytes(self) -> bytes:
31
+ return self.salt + self.nonce
32
+
33
+ @classmethod
34
+ def from_bytes(cls, data: bytes) -> "EncryptionHeader":
35
+ if len(data) < HEADER_SIZE:
36
+ raise ValueError(f"Header too short: {len(data)} < {HEADER_SIZE}")
37
+ return cls(
38
+ salt=data[:SALT_SIZE],
39
+ nonce=data[SALT_SIZE:HEADER_SIZE]
40
+ )
41
+
42
+ @classmethod
43
+ def generate(cls) -> "EncryptionHeader":
44
+ return cls(
45
+ salt=os.urandom(SALT_SIZE),
46
+ nonce=os.urandom(NONCE_SIZE)
47
+ )
48
+
49
+
50
+ def derive_key(password: str, salt: bytes) -> bytes:
51
+ """
52
+ Derive encryption key from password using Scrypt.
53
+
54
+ Using Scrypt instead of Argon2id for broader compatibility.
55
+ Parameters tuned for ~100ms on modern hardware.
56
+ """
57
+ kdf = Scrypt(
58
+ salt=salt,
59
+ length=KEY_SIZE,
60
+ n=2**17, # CPU/memory cost
61
+ r=8, # Block size
62
+ p=1, # Parallelization
63
+ backend=default_backend()
64
+ )
65
+ return kdf.derive(password.encode("utf-8"))
66
+
67
+
68
+ def encrypt_chunk(data: bytes, password: str) -> bytes:
69
+ """
70
+ Encrypt a chunk of data.
71
+
72
+ Returns: header (28 bytes) + ciphertext + tag (16 bytes)
73
+ """
74
+ header = EncryptionHeader.generate()
75
+ key = derive_key(password, header.salt)
76
+ cipher = AESGCM(key)
77
+
78
+ ciphertext = cipher.encrypt(header.nonce, data, None)
79
+ return header.to_bytes() + ciphertext
80
+
81
+
82
+ def decrypt_chunk(encrypted_data: bytes, password: str) -> bytes:
83
+ """
84
+ Decrypt a chunk of data.
85
+
86
+ Expects: header (28 bytes) + ciphertext + tag (16 bytes)
87
+ """
88
+ header = EncryptionHeader.from_bytes(encrypted_data)
89
+ key = derive_key(password, header.salt)
90
+ cipher = AESGCM(key)
91
+
92
+ ciphertext = encrypted_data[HEADER_SIZE:]
93
+ return cipher.decrypt(header.nonce, ciphertext, None)
94
+
95
+
96
+ class StreamingEncryptor:
97
+ """
98
+ Streaming encryptor for large files.
99
+
100
+ Note: For simplicity, this encrypts the entire file with one key/nonce.
101
+ For very large files, consider chunking with per-chunk nonces.
102
+ """
103
+
104
+ def __init__(self, password: str):
105
+ self.password = password
106
+ self.header = EncryptionHeader.generate()
107
+ self.key = derive_key(password, self.header.salt)
108
+ self.cipher = AESGCM(self.key)
109
+ self._counter = 0
110
+
111
+ def get_header(self) -> bytes:
112
+ """Get the header to prepend to encrypted output."""
113
+ return self.header.to_bytes()
114
+
115
+ def _get_nonce(self) -> bytes:
116
+ """Generate unique nonce for each block using counter mode."""
117
+ # Use base nonce + counter to ensure uniqueness
118
+ counter_bytes = struct.pack(">Q", self._counter) # 8 bytes
119
+ self._counter += 1
120
+ # XOR with base nonce (take first 8 bytes of nonce, keep last 4)
121
+ nonce = bytearray(self.header.nonce)
122
+ for i in range(8):
123
+ nonce[i] ^= counter_bytes[i]
124
+ return bytes(nonce)
125
+
126
+ def encrypt_block(self, data: bytes, is_last: bool = False) -> bytes:
127
+ """Encrypt a block of data."""
128
+ nonce = self._get_nonce()
129
+ # Prepend nonce to each block for independent decryption
130
+ ciphertext = self.cipher.encrypt(nonce, data, None)
131
+ return nonce + ciphertext
132
+
133
+
134
+ class StreamingDecryptor:
135
+ """Streaming decryptor for large files."""
136
+
137
+ def __init__(self, password: str, header: EncryptionHeader):
138
+ self.password = password
139
+ self.header = header
140
+ self.key = derive_key(password, header.salt)
141
+ self.cipher = AESGCM(self.key)
142
+
143
+ def decrypt_block(self, encrypted_block: bytes) -> bytes:
144
+ """Decrypt a block of data."""
145
+ # Extract nonce from block
146
+ nonce = encrypted_block[:NONCE_SIZE]
147
+ ciphertext = encrypted_block[NONCE_SIZE:]
148
+ return self.cipher.decrypt(nonce, ciphertext, None)
149
+
150
+
151
+ def encrypt_file_simple(input_path: str, output_path: str, password: str) -> None:
152
+ """Simple file encryption - loads entire file into memory."""
153
+ with open(input_path, "rb") as f:
154
+ data = f.read()
155
+
156
+ encrypted = encrypt_chunk(data, password)
157
+
158
+ with open(output_path, "wb") as f:
159
+ f.write(encrypted)
160
+
161
+
162
+ def decrypt_file_simple(input_path: str, output_path: str, password: str) -> None:
163
+ """Simple file decryption - loads entire file into memory."""
164
+ with open(input_path, "rb") as f:
165
+ encrypted = f.read()
166
+
167
+ decrypted = decrypt_chunk(encrypted, password)
168
+
169
+ with open(output_path, "wb") as f:
170
+ f.write(decrypted)
televault/models.py ADDED
@@ -0,0 +1,149 @@
1
+ """Data models for TeleVault - stored as JSON on Telegram."""
2
+
3
+ from dataclasses import dataclass, field, asdict
4
+ from datetime import datetime
5
+ from typing import Optional
6
+ import json
7
+
8
+
9
+ @dataclass
10
+ class ChunkInfo:
11
+ """Information about a single chunk stored on Telegram."""
12
+
13
+ index: int # Chunk order (0-based)
14
+ message_id: int # Telegram message ID
15
+ size: int # Chunk size in bytes
16
+ hash: str # BLAKE3 hash for verification
17
+
18
+ def to_dict(self) -> dict:
19
+ return asdict(self)
20
+
21
+ @classmethod
22
+ def from_dict(cls, data: dict) -> "ChunkInfo":
23
+ return cls(**data)
24
+
25
+
26
+ @dataclass
27
+ class FileMetadata:
28
+ """
29
+ Metadata for a file stored on Telegram.
30
+ This is stored as a JSON text message, with chunks replying to it.
31
+ """
32
+
33
+ id: str # Unique file ID (short hash)
34
+ name: str # Original filename
35
+ size: int # Original file size in bytes
36
+ hash: str # BLAKE3 hash of original file
37
+ chunks: list[ChunkInfo] = field(default_factory=list)
38
+
39
+ # Optional fields
40
+ encrypted: bool = True
41
+ compressed: bool = False
42
+ compression_ratio: Optional[float] = None
43
+ mime_type: Optional[str] = None
44
+
45
+ # Timestamps
46
+ created_at: float = field(default_factory=lambda: datetime.now().timestamp())
47
+ modified_at: Optional[float] = None
48
+
49
+ # Telegram reference
50
+ message_id: Optional[int] = None # Message ID of this metadata
51
+
52
+ def to_json(self) -> str:
53
+ """Serialize to JSON for storage on Telegram."""
54
+ data = asdict(self)
55
+ # Convert ChunkInfo objects
56
+ data["chunks"] = [c.to_dict() if isinstance(c, ChunkInfo) else c for c in data["chunks"]]
57
+ return json.dumps(data, separators=(",", ":")) # Compact JSON
58
+
59
+ @classmethod
60
+ def from_json(cls, text: str) -> "FileMetadata":
61
+ """Deserialize from JSON stored on Telegram."""
62
+ data = json.loads(text)
63
+ data["chunks"] = [ChunkInfo.from_dict(c) for c in data.get("chunks", [])]
64
+ return cls(**data)
65
+
66
+ @property
67
+ def chunk_count(self) -> int:
68
+ return len(self.chunks)
69
+
70
+ @property
71
+ def total_stored_size(self) -> int:
72
+ """Total size of all chunks (after compression/encryption)."""
73
+ return sum(c.size for c in self.chunks)
74
+
75
+ def is_complete(self) -> bool:
76
+ """Check if all chunks are present."""
77
+ if not self.chunks:
78
+ return False
79
+ indices = {c.index for c in self.chunks}
80
+ expected = set(range(len(self.chunks)))
81
+ return indices == expected
82
+
83
+
84
+ @dataclass
85
+ class VaultIndex:
86
+ """
87
+ Master index of all files in the vault.
88
+ Stored as pinned message in the channel.
89
+ """
90
+
91
+ version: int = 1
92
+ files: dict[str, int] = field(default_factory=dict) # file_id -> metadata_message_id
93
+ updated_at: float = field(default_factory=lambda: datetime.now().timestamp())
94
+
95
+ def to_json(self) -> str:
96
+ return json.dumps(asdict(self), separators=(",", ":"))
97
+
98
+ @classmethod
99
+ def from_json(cls, text: str) -> "VaultIndex":
100
+ data = json.loads(text)
101
+ # Only take known fields, ignore extras
102
+ return cls(
103
+ version=data.get("version", 1),
104
+ files=data.get("files", {}),
105
+ updated_at=data.get("updated_at", datetime.now().timestamp()),
106
+ )
107
+
108
+ def add_file(self, file_id: str, message_id: int) -> None:
109
+ self.files[file_id] = message_id
110
+ self.updated_at = datetime.now().timestamp()
111
+
112
+ def remove_file(self, file_id: str) -> Optional[int]:
113
+ msg_id = self.files.pop(file_id, None)
114
+ if msg_id:
115
+ self.updated_at = datetime.now().timestamp()
116
+ return msg_id
117
+
118
+
119
+ @dataclass
120
+ class TransferProgress:
121
+ """
122
+ Progress tracking for resumable transfers.
123
+ Stored as a temporary message, deleted on completion.
124
+ """
125
+
126
+ operation: str # "upload" or "download"
127
+ file_id: str
128
+ file_name: str
129
+ total_chunks: int
130
+ completed_chunks: list[int] = field(default_factory=list) # Completed chunk indices
131
+ started_at: float = field(default_factory=lambda: datetime.now().timestamp())
132
+
133
+ def to_json(self) -> str:
134
+ return json.dumps(asdict(self), separators=(",", ":"))
135
+
136
+ @classmethod
137
+ def from_json(cls, text: str) -> "TransferProgress":
138
+ return cls(**json.loads(text))
139
+
140
+ @property
141
+ def pending_chunks(self) -> list[int]:
142
+ completed = set(self.completed_chunks)
143
+ return [i for i in range(self.total_chunks) if i not in completed]
144
+
145
+ @property
146
+ def progress_percent(self) -> float:
147
+ if self.total_chunks == 0:
148
+ return 100.0
149
+ return (len(self.completed_chunks) / self.total_chunks) * 100
televault/telegram.py ADDED
@@ -0,0 +1,375 @@
1
+ """Telegram MTProto client wrapper for TeleVault."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional, AsyncIterator
7
+ from dataclasses import dataclass
8
+ import io
9
+
10
+ from telethon import TelegramClient
11
+ from telethon.sessions import StringSession
12
+ from telethon.tl.types import (
13
+ Channel,
14
+ Message,
15
+ DocumentAttributeFilename,
16
+ InputPeerChannel,
17
+ )
18
+ from telethon.tl.functions.messages import GetPinnedDialogsRequest
19
+ from telethon.errors import FloodWaitError
20
+
21
+ from .models import FileMetadata, VaultIndex, ChunkInfo, TransferProgress
22
+ from .config import Config, get_config_dir
23
+
24
+
25
+ # TeleVault Telegram app credentials
26
+ API_ID = 22399403
27
+ API_HASH = "9bf0e01ba1d63bc048172b8eb53d957b"
28
+
29
+
30
+ @dataclass
31
+ class TelegramConfig:
32
+ """Telegram connection configuration."""
33
+
34
+ api_id: int
35
+ api_hash: str
36
+ session_string: Optional[str] = None
37
+
38
+ @classmethod
39
+ def from_env(cls) -> "TelegramConfig":
40
+ """Load from environment or config file."""
41
+ import os
42
+
43
+ config_path = get_config_dir() / "telegram.json"
44
+
45
+ if config_path.exists():
46
+ with open(config_path) as f:
47
+ data = json.load(f)
48
+ return cls(**data)
49
+
50
+ return cls(
51
+ api_id=int(os.environ.get("TELEGRAM_API_ID", API_ID)),
52
+ api_hash=os.environ.get("TELEGRAM_API_HASH", API_HASH),
53
+ session_string=os.environ.get("TELEGRAM_SESSION"),
54
+ )
55
+
56
+ def save(self) -> None:
57
+ """Save config to file."""
58
+ config_path = get_config_dir() / "telegram.json"
59
+ config_path.parent.mkdir(parents=True, exist_ok=True)
60
+
61
+ with open(config_path, "w") as f:
62
+ json.dump({
63
+ "api_id": self.api_id,
64
+ "api_hash": self.api_hash,
65
+ "session_string": self.session_string,
66
+ }, f, indent=2)
67
+
68
+
69
+ class TelegramVault:
70
+ """
71
+ Telegram MTProto client for TeleVault operations.
72
+
73
+ Handles:
74
+ - Authentication
75
+ - Channel management
76
+ - File upload/download
77
+ - Index management
78
+ """
79
+
80
+ def __init__(self, config: Optional[TelegramConfig] = None):
81
+ self.config = config or TelegramConfig.from_env()
82
+ self._client: Optional[TelegramClient] = None
83
+ self._channel: Optional[Channel] = None
84
+ self._channel_id: Optional[int] = None
85
+
86
+ async def connect(self) -> None:
87
+ """Connect to Telegram."""
88
+ if self.config.session_string:
89
+ session = StringSession(self.config.session_string)
90
+ else:
91
+ session = StringSession()
92
+
93
+ self._client = TelegramClient(
94
+ session,
95
+ self.config.api_id,
96
+ self.config.api_hash,
97
+ )
98
+
99
+ await self._client.connect()
100
+
101
+ async def disconnect(self) -> None:
102
+ """Disconnect from Telegram."""
103
+ if self._client:
104
+ await self._client.disconnect()
105
+
106
+ async def login(self, phone: Optional[str] = None) -> str:
107
+ """
108
+ Interactive login flow.
109
+
110
+ Returns session string for future use.
111
+ """
112
+ if not self._client:
113
+ await self.connect()
114
+
115
+ if not await self._client.is_user_authorized():
116
+ if phone is None:
117
+ phone = input("Enter phone number: ")
118
+
119
+ await self._client.send_code_request(phone)
120
+ code = input("Enter the code you received: ")
121
+
122
+ try:
123
+ await self._client.sign_in(phone, code)
124
+ except Exception:
125
+ # 2FA required
126
+ password = input("Enter 2FA password: ")
127
+ await self._client.sign_in(password=password)
128
+
129
+ # Save session
130
+ session_string = self._client.session.save()
131
+ self.config.session_string = session_string
132
+ self.config.save()
133
+
134
+ return session_string
135
+
136
+ async def set_channel(self, channel_id: int) -> None:
137
+ """Set the storage channel."""
138
+ self._channel_id = channel_id
139
+ self._channel = await self._client.get_entity(channel_id)
140
+
141
+ async def create_channel(self, name: str = "TeleVault Storage") -> int:
142
+ """Create a new private channel for storage."""
143
+ from telethon.tl.functions.channels import CreateChannelRequest
144
+
145
+ result = await self._client(CreateChannelRequest(
146
+ title=name,
147
+ about="TeleVault encrypted storage",
148
+ megagroup=False, # Regular channel, not supergroup
149
+ ))
150
+
151
+ channel = result.chats[0]
152
+ self._channel = channel
153
+ self._channel_id = channel.id
154
+
155
+ # Return full channel ID format (negative with -100 prefix)
156
+ # Telegram channels need -100 prefix for MTProto
157
+ full_channel_id = int(f"-100{channel.id}")
158
+ return full_channel_id
159
+
160
+ # === Index Operations ===
161
+
162
+ async def get_index(self) -> VaultIndex:
163
+ """Get the vault index from pinned message."""
164
+ if not self._channel_id:
165
+ raise ValueError("No channel set")
166
+
167
+ # Get pinned messages
168
+ async for msg in self._client.iter_messages(
169
+ self._channel_id,
170
+ filter=None,
171
+ limit=10,
172
+ ):
173
+ if msg.pinned and msg.text:
174
+ try:
175
+ data = json.loads(msg.text)
176
+ # Check if it looks like our index (has 'files' key)
177
+ if "files" in data:
178
+ return VaultIndex.from_json(msg.text)
179
+ except json.JSONDecodeError:
180
+ continue
181
+
182
+ # No valid index found, create empty one
183
+ return VaultIndex()
184
+
185
+ async def save_index(self, index: VaultIndex) -> int:
186
+ """Save the vault index as pinned message."""
187
+ if not self._channel_id:
188
+ raise ValueError("No channel set")
189
+
190
+ # Find existing pinned index message
191
+ existing_msg_id = None
192
+ async for msg in self._client.iter_messages(
193
+ self._channel_id,
194
+ filter=None,
195
+ limit=10,
196
+ ):
197
+ if msg.pinned and msg.text:
198
+ try:
199
+ VaultIndex.from_json(msg.text)
200
+ existing_msg_id = msg.id
201
+ break
202
+ except json.JSONDecodeError:
203
+ continue
204
+
205
+ if existing_msg_id:
206
+ # Edit existing
207
+ await self._client.edit_message(
208
+ self._channel_id,
209
+ existing_msg_id,
210
+ index.to_json(),
211
+ )
212
+ return existing_msg_id
213
+ else:
214
+ # Create new and pin
215
+ msg = await self._client.send_message(
216
+ self._channel_id,
217
+ index.to_json(),
218
+ )
219
+ await self._client.pin_message(self._channel_id, msg.id)
220
+ return msg.id
221
+
222
+ # === File Operations ===
223
+
224
+ async def upload_metadata(self, metadata: FileMetadata) -> int:
225
+ """Upload file metadata as a text message."""
226
+ if not self._channel_id:
227
+ raise ValueError("No channel set")
228
+
229
+ msg = await self._client.send_message(
230
+ self._channel_id,
231
+ metadata.to_json(),
232
+ )
233
+ return msg.id
234
+
235
+ async def get_metadata(self, message_id: int) -> FileMetadata:
236
+ """Get file metadata from message."""
237
+ if not self._channel_id:
238
+ raise ValueError("No channel set")
239
+
240
+ msg = await self._client.get_messages(self._channel_id, ids=message_id)
241
+ if not msg or not msg.text:
242
+ raise ValueError(f"Metadata message {message_id} not found")
243
+
244
+ return FileMetadata.from_json(msg.text)
245
+
246
+ async def update_metadata(self, message_id: int, metadata: FileMetadata) -> None:
247
+ """Update file metadata message."""
248
+ if not self._channel_id:
249
+ raise ValueError("No channel set")
250
+
251
+ await self._client.edit_message(
252
+ self._channel_id,
253
+ message_id,
254
+ metadata.to_json(),
255
+ )
256
+
257
+ async def upload_chunk(
258
+ self,
259
+ data: bytes,
260
+ filename: str,
261
+ reply_to: int,
262
+ progress_callback=None,
263
+ ) -> int:
264
+ """
265
+ Upload a chunk as a file message.
266
+
267
+ Args:
268
+ data: Chunk data
269
+ filename: Chunk filename (e.g., "0001.chunk")
270
+ reply_to: Metadata message ID to reply to
271
+ progress_callback: Optional progress callback
272
+
273
+ Returns:
274
+ Message ID of uploaded chunk
275
+ """
276
+ if not self._channel_id:
277
+ raise ValueError("No channel set")
278
+
279
+ # Create file-like object
280
+ file = io.BytesIO(data)
281
+ file.name = filename
282
+
283
+ try:
284
+ msg = await self._client.send_file(
285
+ self._channel_id,
286
+ file,
287
+ reply_to=reply_to,
288
+ progress_callback=progress_callback,
289
+ attributes=[DocumentAttributeFilename(filename)],
290
+ )
291
+ return msg.id
292
+ except FloodWaitError as e:
293
+ # Rate limited, wait and retry
294
+ await asyncio.sleep(e.seconds + 1)
295
+ return await self.upload_chunk(data, filename, reply_to, progress_callback)
296
+
297
+ async def download_chunk(
298
+ self,
299
+ message_id: int,
300
+ progress_callback=None,
301
+ ) -> bytes:
302
+ """Download a chunk by message ID."""
303
+ if not self._channel_id:
304
+ raise ValueError("No channel set")
305
+
306
+ msg = await self._client.get_messages(self._channel_id, ids=message_id)
307
+ if not msg or not msg.file:
308
+ raise ValueError(f"Chunk message {message_id} not found")
309
+
310
+ return await self._client.download_media(msg, file=bytes, progress_callback=progress_callback)
311
+
312
+ async def iter_file_chunks(self, metadata_msg_id: int) -> AsyncIterator[Message]:
313
+ """Iterate over chunk messages that reply to a metadata message."""
314
+ if not self._channel_id:
315
+ raise ValueError("No channel set")
316
+
317
+ async for msg in self._client.iter_messages(
318
+ self._channel_id,
319
+ reply_to=metadata_msg_id,
320
+ ):
321
+ if msg.file:
322
+ yield msg
323
+
324
+ async def delete_file(self, file_id: str) -> bool:
325
+ """Delete a file and all its chunks."""
326
+ if not self._channel_id:
327
+ raise ValueError("No channel set")
328
+
329
+ # Get index
330
+ index = await self.get_index()
331
+
332
+ if file_id not in index.files:
333
+ return False
334
+
335
+ metadata_msg_id = index.files[file_id]
336
+
337
+ # Collect all message IDs to delete
338
+ msg_ids = [metadata_msg_id]
339
+
340
+ async for chunk_msg in self.iter_file_chunks(metadata_msg_id):
341
+ msg_ids.append(chunk_msg.id)
342
+
343
+ # Delete messages
344
+ await self._client.delete_messages(self._channel_id, msg_ids)
345
+
346
+ # Update index
347
+ index.remove_file(file_id)
348
+ await self.save_index(index)
349
+
350
+ return True
351
+
352
+ # === Listing ===
353
+
354
+ async def list_files(self) -> list[FileMetadata]:
355
+ """List all files in the vault."""
356
+ index = await self.get_index()
357
+ files = []
358
+
359
+ for file_id, msg_id in index.files.items():
360
+ try:
361
+ metadata = await self.get_metadata(msg_id)
362
+ metadata.message_id = msg_id
363
+ files.append(metadata)
364
+ except Exception:
365
+ # Skip corrupted entries
366
+ continue
367
+
368
+ return files
369
+
370
+ async def search_files(self, query: str) -> list[FileMetadata]:
371
+ """Search files by name."""
372
+ files = await self.list_files()
373
+ query_lower = query.lower()
374
+
375
+ return [f for f in files if query_lower in f.name.lower()]