codexfer 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.
codexfer/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """CodeXfer: end-to-end encrypted code sharing."""
2
+ __version__ = "0.1.0"
codexfer/api_client.py ADDED
@@ -0,0 +1,73 @@
1
+ """HTTP client for the CodeXfer backend (Cloudflare Worker).
2
+
3
+ If CODEXFER_API_URL is not configured, CodeXfer falls back to a local
4
+ "packages" directory so the whole pack/share/build flow still works
5
+ end-to-end on one machine without any cloud account -- useful for testing
6
+ before you deploy the Worker.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import requests
15
+
16
+ from .config import settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ApiClient:
22
+ def __init__(self, base_url: str | None = None):
23
+ self.base_url = (base_url or settings.api_base_url).rstrip("/")
24
+ self.local_dir = Path(settings.local_fallback_dir)
25
+ self.local_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ @property
28
+ def is_remote(self) -> bool:
29
+ return bool(self.base_url)
30
+
31
+ def upload(self, package_id: str, cxf_path: Path, expiry_hours: int, max_downloads: int) -> None:
32
+ if self.is_remote:
33
+ with open(cxf_path, "rb") as f:
34
+ resp = requests.post(
35
+ f"{self.base_url}/upload",
36
+ params={
37
+ "package_id": package_id,
38
+ "expiry_hours": expiry_hours,
39
+ "max_downloads": max_downloads,
40
+ },
41
+ data=f,
42
+ headers={"Content-Type": "application/octet-stream"},
43
+ timeout=120,
44
+ )
45
+ resp.raise_for_status()
46
+ else:
47
+ dest = self.local_dir / f"{package_id}.cxf"
48
+ shutil.copyfile(cxf_path, dest)
49
+ logger.warning(
50
+ "CODEXFER_API_URL not set -- saved package locally to %s "
51
+ "(no real upload happened; set CODEXFER_API_URL to use the "
52
+ "Cloudflare backend).",
53
+ dest,
54
+ )
55
+
56
+ def download(self, package_id: str, destination: Path) -> Path:
57
+ if self.is_remote:
58
+ resp = requests.get(f"{self.base_url}/download/{package_id}", timeout=120, stream=True)
59
+ resp.raise_for_status()
60
+ with open(destination, "wb") as f:
61
+ for chunk in resp.iter_content(chunk_size=settings.chunk_size_bytes):
62
+ f.write(chunk)
63
+ return destination
64
+ else:
65
+ src = self.local_dir / f"{package_id}.cxf"
66
+ if not src.exists():
67
+ raise FileNotFoundError(
68
+ f"No local package '{package_id}' found in {self.local_dir}. "
69
+ "If the sender used a real deployment, set CODEXFER_API_URL "
70
+ "to the same Worker URL before running 'codexfer build'."
71
+ )
72
+ shutil.copyfile(src, destination)
73
+ return destination
codexfer/cli.py ADDED
@@ -0,0 +1,134 @@
1
+ """CodeXfer CLI -- exactly three commands: pack, share, build."""
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+
11
+ from .api_client import ApiClient
12
+ from .compression import compress_project, extract_project
13
+ from .config import settings
14
+ from .encryption import EncryptedBlob, decrypt_bytes, encrypt_bytes
15
+ from .package_format import read_cxf, write_cxf
16
+ from .utils import generate_access_code, generate_package_id, setup_logging
17
+
18
+ app = typer.Typer(add_completion=False, help="CodeXfer: end-to-end encrypted code sharing.")
19
+ console = Console()
20
+
21
+ PACK_FILENAME = ".codexfer_pack.zip"
22
+
23
+
24
+ @app.command()
25
+ def pack(
26
+ project_dir: str = typer.Argument(".", help="Project directory to pack (default: current dir)."),
27
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
28
+ ):
29
+ """Scan and compress the current project locally. No upload happens here."""
30
+ setup_logging(verbose)
31
+ project_path = Path(project_dir).resolve()
32
+ out_path = project_path / PACK_FILENAME
33
+
34
+ with console.status("[bold cyan]Scanning and compressing project..."):
35
+ compress_project(project_path, out_path)
36
+
37
+ size_mb = out_path.stat().st_size / (1024 * 1024)
38
+ console.print(Panel.fit(
39
+ f"[green]Packed successfully[/green]\n"
40
+ f"Archive: {out_path}\nSize: {size_mb:.2f} MB",
41
+ title="codexfer pack",
42
+ ))
43
+
44
+
45
+ @app.command()
46
+ def share(
47
+ package_name: str = typer.Argument(..., help="Friendly name for this package."),
48
+ project_dir: str = typer.Option(".", "--dir", help="Project directory (must already be packed)."),
49
+ expiry_hours: int = typer.Option(settings.default_expiry_hours, "--expiry"),
50
+ max_downloads: int = typer.Option(settings.default_max_downloads, "--max-downloads"),
51
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
52
+ ):
53
+ """Encrypt the packed project locally and upload only the ciphertext."""
54
+ setup_logging(verbose)
55
+ project_path = Path(project_dir).resolve()
56
+ pack_path = project_path / PACK_FILENAME
57
+
58
+ if not pack_path.exists():
59
+ console.print("[yellow]No pack found -- packing now...[/yellow]")
60
+ compress_project(project_path, pack_path)
61
+
62
+ package_id = generate_package_id()
63
+ access_code = generate_access_code()
64
+
65
+ with console.status("[bold cyan]Encrypting locally..."):
66
+ plaintext = pack_path.read_bytes()
67
+ blob = encrypt_bytes(plaintext, access_code)
68
+
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ cxf_path = Path(tmp) / f"{package_id}.cxf"
71
+ write_cxf(cxf_path, package_name, blob)
72
+
73
+ with console.status("[bold cyan]Uploading encrypted package..."):
74
+ client = ApiClient()
75
+ client.upload(package_id, cxf_path, expiry_hours, max_downloads)
76
+
77
+ pack_path.unlink(missing_ok=True)
78
+
79
+ console.print(Panel.fit(
80
+ f"[bold]Package ID[/bold]\n{package_id}\n\n"
81
+ f"[bold]Access Code[/bold]\n{access_code}\n\n"
82
+ f"Expires: {expiry_hours} Hours\n"
83
+ f"Max Downloads: {max_downloads}",
84
+ title="[green]Share ready[/green]",
85
+ ))
86
+ console.print("[dim]Share ONLY the Package ID and Access Code with the receiver.[/dim]")
87
+
88
+
89
+ @app.command()
90
+ def build(
91
+ package_id: str = typer.Argument(..., help="Package ID given by the sender."),
92
+ destination: str = typer.Option(".", "--dest", help="Where to restore the project."),
93
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
94
+ ):
95
+ """Download, verify, decrypt, and restore a shared project."""
96
+ setup_logging(verbose)
97
+ access_code = typer.prompt("Enter Access Code", hide_input=True)
98
+
99
+ with tempfile.TemporaryDirectory() as tmp:
100
+ cxf_path = Path(tmp) / f"{package_id}.cxf"
101
+
102
+ with console.status("[bold cyan]Downloading..."):
103
+ client = ApiClient()
104
+ try:
105
+ client.download(package_id, cxf_path)
106
+ except Exception as exc: # noqa: BLE001
107
+ console.print(f"[red]Download failed:[/red] {exc}")
108
+ raise typer.Exit(code=1)
109
+
110
+ with console.status("[bold cyan]Decrypting..."):
111
+ header, ciphertext = read_cxf(cxf_path)
112
+ blob = EncryptedBlob(
113
+ salt=bytes.fromhex(header.salt_hex),
114
+ nonce=bytes.fromhex(header.nonce_hex),
115
+ ciphertext=ciphertext,
116
+ )
117
+ try:
118
+ plaintext = decrypt_bytes(blob, access_code)
119
+ except Exception:
120
+ console.print("[red]Decryption failed -- wrong access code or corrupted package.[/red]")
121
+ raise typer.Exit(code=1)
122
+
123
+ zip_path = Path(tmp) / "restore.zip"
124
+ zip_path.write_bytes(plaintext)
125
+
126
+ with console.status("[bold cyan]Restoring project..."):
127
+ dest_dir = Path(destination).resolve() / header.package_name
128
+ extract_project(zip_path, dest_dir)
129
+
130
+ console.print(Panel.fit(f"[green]Done[/green]\nRestored to: {dest_dir}", title="codexfer build"))
131
+
132
+
133
+ if __name__ == "__main__":
134
+ app()
@@ -0,0 +1,73 @@
1
+ """Project compression: zip a project tree while excluding noise directories,
2
+ preserving hidden files, binary files, and folder structure exactly.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ import logging
8
+ import os
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_EXCLUDES = [
15
+ ".git", ".git/*", "__pycache__", "__pycache__/*", "*.pyc",
16
+ "node_modules", "node_modules/*", ".venv", ".venv/*", "venv", "venv/*",
17
+ ".mypy_cache", ".mypy_cache/*", ".pytest_cache", ".pytest_cache/*",
18
+ "dist", "dist/*", "build", "build/*", "*.egg-info", "*.egg-info/*",
19
+ ".codexfer_pack.zip", "*.cxf",
20
+ ]
21
+
22
+
23
+ def _is_excluded(rel_path: str, patterns: list[str]) -> bool:
24
+ parts = rel_path.replace("\\", "/").split("/")
25
+ for pattern in patterns:
26
+ if fnmatch.fnmatch(rel_path.replace("\\", "/"), pattern):
27
+ return True
28
+ if any(fnmatch.fnmatch(part, pattern) for part in parts):
29
+ return True
30
+ return False
31
+
32
+
33
+ def compress_project(
34
+ project_dir: str | Path,
35
+ output_path: str | Path,
36
+ excludes: list[str] | None = None,
37
+ ) -> Path:
38
+ """Zip project_dir -> output_path, streaming file-by-file (no full read into RAM)."""
39
+ project_dir = Path(project_dir).resolve()
40
+ output_path = Path(output_path).resolve()
41
+ patterns = DEFAULT_EXCLUDES + (excludes or [])
42
+
43
+ file_count = 0
44
+ with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
45
+ for root, dirnames, filenames in os.walk(project_dir):
46
+ rel_root = os.path.relpath(root, project_dir)
47
+ dirnames[:] = [
48
+ d for d in dirnames
49
+ if not _is_excluded(os.path.join(rel_root, d), patterns)
50
+ ]
51
+ for name in filenames:
52
+ rel_path = os.path.normpath(os.path.join(rel_root, name))
53
+ abs_path = Path(os.path.join(root, name)).resolve()
54
+ # Never zip the archive into itself (it may already exist on
55
+ # disk while we're actively writing to it) and skip any
56
+ # leftover .cxf packages from a previous share.
57
+ if abs_path == output_path or abs_path.suffix == ".cxf":
58
+ continue
59
+ if _is_excluded(rel_path, patterns):
60
+ continue
61
+ zf.write(abs_path, arcname=rel_path)
62
+ file_count += 1
63
+
64
+ logger.info("Packed %d files from %s into %s", file_count, project_dir, output_path)
65
+ return output_path
66
+
67
+
68
+ def extract_project(archive_path: str | Path, destination_dir: str | Path) -> Path:
69
+ destination_dir = Path(destination_dir)
70
+ destination_dir.mkdir(parents=True, exist_ok=True)
71
+ with zipfile.ZipFile(archive_path, mode="r") as zf:
72
+ zf.extractall(destination_dir)
73
+ return destination_dir
codexfer/config.py ADDED
@@ -0,0 +1,24 @@
1
+ """Central configuration for CodeXfer.
2
+
3
+ All values can be overridden via environment variables so the CLI works
4
+ identically for every user without editing source.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Settings:
14
+ api_base_url: str = os.environ.get("CODEXFER_API_URL", "")
15
+ local_fallback_dir: str = os.environ.get(
16
+ "CODEXFER_LOCAL_DIR", os.path.expanduser("~/.codexfer/packages")
17
+ )
18
+ default_expiry_hours: int = int(os.environ.get("CODEXFER_EXPIRY_HOURS", "24"))
19
+ default_max_downloads: int = int(os.environ.get("CODEXFER_MAX_DOWNLOADS", "1"))
20
+ chunk_size_bytes: int = 4 * 1024 * 1024 # 4 MB streaming chunks
21
+ kdf_iterations: int = 390_000 # PBKDF2-HMAC-SHA256 iterations
22
+
23
+
24
+ settings = Settings()
codexfer/encryption.py ADDED
@@ -0,0 +1,55 @@
1
+ """AES-256-GCM encryption with PBKDF2-HMAC-SHA256 key derivation.
2
+
3
+ Design:
4
+ - Access code is the only secret. It is generated locally, never sent
5
+ to the server, and never stored anywhere.
6
+ - A random salt (16 bytes) + random nonce (12 bytes) are stored alongside
7
+ the ciphertext (they are not secret; they make the key/ciphertext unique).
8
+ - GCM's authentication tag provides integrity verification: any tampering
9
+ with the ciphertext causes decryption to raise InvalidTag.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
18
+ from cryptography.hazmat.primitives import hashes
19
+
20
+ from .config import settings
21
+
22
+ SALT_SIZE = 16
23
+ NONCE_SIZE = 12
24
+ KEY_SIZE = 32 # AES-256
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EncryptedBlob:
29
+ salt: bytes
30
+ nonce: bytes
31
+ ciphertext: bytes # includes GCM tag appended
32
+
33
+
34
+ def _derive_key(access_code: str, salt: bytes) -> bytes:
35
+ kdf = PBKDF2HMAC(
36
+ algorithm=hashes.SHA256(),
37
+ length=KEY_SIZE,
38
+ salt=salt,
39
+ iterations=settings.kdf_iterations,
40
+ )
41
+ return kdf.derive(access_code.encode("utf-8"))
42
+
43
+
44
+ def encrypt_bytes(plaintext: bytes, access_code: str) -> EncryptedBlob:
45
+ salt = os.urandom(SALT_SIZE)
46
+ nonce = os.urandom(NONCE_SIZE)
47
+ key = _derive_key(access_code, salt)
48
+ ciphertext = AESGCM(key).encrypt(nonce, plaintext, associated_data=None)
49
+ return EncryptedBlob(salt=salt, nonce=nonce, ciphertext=ciphertext)
50
+
51
+
52
+ def decrypt_bytes(blob: EncryptedBlob, access_code: str) -> bytes:
53
+ key = _derive_key(access_code, blob.salt)
54
+ # Raises cryptography.exceptions.InvalidTag on wrong code or tampering.
55
+ return AESGCM(key).decrypt(blob.nonce, blob.ciphertext, associated_data=None)
@@ -0,0 +1,57 @@
1
+ """The .cxf container format: a small JSON header + the encrypted archive.
2
+
3
+ Layout on disk:
4
+ [4 bytes] header length (big-endian uint32)
5
+ [N bytes] UTF-8 JSON header
6
+ [rest] raw ciphertext bytes (AES-GCM, tag appended)
7
+
8
+ The header never contains secrets. It is safe for the server to read it,
9
+ though CodeXfer's server never needs to.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import struct
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+
18
+ from .encryption import EncryptedBlob
19
+
20
+ FORMAT_VERSION = 1
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CxfHeader:
25
+ version: int
26
+ package_name: str
27
+ compression: str # "zip"
28
+ salt_hex: str
29
+ nonce_hex: str
30
+
31
+
32
+ def write_cxf(path: str | Path, package_name: str, blob: EncryptedBlob) -> Path:
33
+ header = CxfHeader(
34
+ version=FORMAT_VERSION,
35
+ package_name=package_name,
36
+ compression="zip",
37
+ salt_hex=blob.salt.hex(),
38
+ nonce_hex=blob.nonce.hex(),
39
+ )
40
+ header_bytes = json.dumps(asdict(header)).encode("utf-8")
41
+ path = Path(path)
42
+ with open(path, "wb") as f:
43
+ f.write(struct.pack(">I", len(header_bytes)))
44
+ f.write(header_bytes)
45
+ f.write(blob.ciphertext)
46
+ return path
47
+
48
+
49
+ def read_cxf(path: str | Path) -> tuple[CxfHeader, bytes]:
50
+ path = Path(path)
51
+ with open(path, "rb") as f:
52
+ (header_len,) = struct.unpack(">I", f.read(4))
53
+ header_bytes = f.read(header_len)
54
+ ciphertext = f.read()
55
+ header_dict = json.loads(header_bytes.decode("utf-8"))
56
+ header = CxfHeader(**header_dict)
57
+ return header, ciphertext
codexfer/utils.py ADDED
@@ -0,0 +1,29 @@
1
+ """Small shared helpers: ids, access codes, logging setup."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import secrets
6
+ import string
7
+
8
+
9
+ def setup_logging(verbose: bool = False) -> None:
10
+ level = logging.DEBUG if verbose else logging.WARNING
11
+ logging.basicConfig(
12
+ level=level,
13
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
14
+ )
15
+
16
+
17
+ def generate_package_id(length: int = 8) -> str:
18
+ alphabet = string.ascii_uppercase + string.digits
19
+ return "CXF" + "".join(secrets.choice(alphabet) for _ in range(length - 3))
20
+
21
+
22
+ def generate_access_code(groups: int = 3, group_len: int = 4) -> str:
23
+ """Human-friendly high-entropy code, e.g. A9KP-29QX-L81M."""
24
+ alphabet = string.ascii_uppercase + string.digits
25
+ parts = [
26
+ "".join(secrets.choice(alphabet) for _ in range(group_len))
27
+ for _ in range(groups)
28
+ ]
29
+ return "-".join(parts)
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: codexfer
3
+ Version: 0.1.0
4
+ Summary: End-to-end encrypted code sharing for developers.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.12
7
+ Requires-Dist: rich>=13.7
8
+ Requires-Dist: cryptography>=42.0
9
+ Requires-Dist: requests>=2.31
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,13 @@
1
+ codexfer/__init__.py,sha256=drozDaPDpjHrNJOXUa03J_Nvrz-wqsrxVCLxPhe9eRk,73
2
+ codexfer/api_client.py,sha256=ahsmN_Y5GcbP3Qddwk1_ypmuuQG1Cl05Irdy0u2fV4k,2769
3
+ codexfer/cli.py,sha256=PtsvdM4ElQTLYlE_5HXdr9UrGp8IdTip7aLOeRf-0O8,5032
4
+ codexfer/compression.py,sha256=OgNkYDn7mSaRfoEvCH0aFDUg_KAGwmJCWDaCyIwP3FU,2813
5
+ codexfer/config.py,sha256=c2SPTx-3hOMvujsChfHgjEB9th2eVwtZumiSAwrC3FM,804
6
+ codexfer/encryption.py,sha256=NaPpHONcrHsUd4da4YHWK5iclwq3hgJZ-WsTCF_AgYU,1834
7
+ codexfer/package_format.py,sha256=lvJlxNdaycLq1G1GXojsv68PiNia3qJyvpd3cINYvf8,1585
8
+ codexfer/utils.py,sha256=zRqpnOdhBZi4RjZYz9XdJxEDE7Imt5P_Sd8Qd6ychx0,894
9
+ codexfer-0.1.0.dist-info/METADATA,sha256=8zE04Q7x4VW2lFU5C-VDvDLA2BPxc4TIXtNRYb2HOAw,326
10
+ codexfer-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ codexfer-0.1.0.dist-info/entry_points.txt,sha256=8RYV3mDcqDKz3U1rhBpEamda9bKZKitgKhLh4IFbcPo,46
12
+ codexfer-0.1.0.dist-info/top_level.txt,sha256=q62rCjiO-6t8hygdogenwLzugiWdbKN2ANhZowZ27qY,9
13
+ codexfer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codexfer = codexfer.cli:app
@@ -0,0 +1 @@
1
+ codexfer