codexfer 0.1.0__tar.gz
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-0.1.0/PKG-INFO +11 -0
- codexfer-0.1.0/README.md +68 -0
- codexfer-0.1.0/codexfer/__init__.py +2 -0
- codexfer-0.1.0/codexfer/api_client.py +73 -0
- codexfer-0.1.0/codexfer/cli.py +134 -0
- codexfer-0.1.0/codexfer/compression.py +73 -0
- codexfer-0.1.0/codexfer/config.py +24 -0
- codexfer-0.1.0/codexfer/encryption.py +55 -0
- codexfer-0.1.0/codexfer/package_format.py +57 -0
- codexfer-0.1.0/codexfer/utils.py +29 -0
- codexfer-0.1.0/codexfer.egg-info/PKG-INFO +11 -0
- codexfer-0.1.0/codexfer.egg-info/SOURCES.txt +17 -0
- codexfer-0.1.0/codexfer.egg-info/dependency_links.txt +1 -0
- codexfer-0.1.0/codexfer.egg-info/entry_points.txt +2 -0
- codexfer-0.1.0/codexfer.egg-info/requires.txt +7 -0
- codexfer-0.1.0/codexfer.egg-info/top_level.txt +1 -0
- codexfer-0.1.0/pyproject.toml +24 -0
- codexfer-0.1.0/setup.cfg +4 -0
- codexfer-0.1.0/tests/test_core.py +45 -0
codexfer-0.1.0/PKG-INFO
ADDED
|
@@ -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"
|
codexfer-0.1.0/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# CodeXfer
|
|
2
|
+
|
|
3
|
+
End-to-end encrypted code sharing for developers. The server never sees
|
|
4
|
+
plaintext source code — compression and encryption both happen locally.
|
|
5
|
+
|
|
6
|
+
## Install (pure Python, cross-platform)
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
python -m venv .venv
|
|
10
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
11
|
+
pip install -e ".[dev]" # or: pip install -e .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd your-project
|
|
18
|
+
codexfer pack
|
|
19
|
+
codexfer share my-project
|
|
20
|
+
|
|
21
|
+
# on the receiving machine
|
|
22
|
+
codexfer build CXF8A92K
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuring the backend
|
|
26
|
+
|
|
27
|
+
By default, with no backend configured, `share`/`build` fall back to a local
|
|
28
|
+
folder (`~/.codexfer/packages`, override with `CODEXFER_LOCAL_DIR`) so you can
|
|
29
|
+
test the full flow on one machine before deploying anything.
|
|
30
|
+
|
|
31
|
+
To use the real Cloudflare-backed server, deploy `worker/` (see
|
|
32
|
+
`worker/README.md`) and set:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export CODEXFER_API_URL="https://your-worker.your-subdomain.workers.dev"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Security model
|
|
39
|
+
|
|
40
|
+
- AES-256-GCM authenticated encryption, key derived from the Access Code via
|
|
41
|
+
PBKDF2-HMAC-SHA256 (390,000 iterations) with a random salt per package.
|
|
42
|
+
- The Access Code is generated locally and never transmitted to or stored on
|
|
43
|
+
the server — only the Package ID and ciphertext travel over the network.
|
|
44
|
+
- GCM's authentication tag detects any tampering or corruption at decrypt time.
|
|
45
|
+
|
|
46
|
+
## Project layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
codexfer/
|
|
50
|
+
__init__.py
|
|
51
|
+
cli.py Typer CLI: pack / share / build
|
|
52
|
+
config.py Environment-driven settings
|
|
53
|
+
compression.py Project -> zip (excludes .git, venv, node_modules, etc.)
|
|
54
|
+
encryption.py AES-256-GCM + PBKDF2 key derivation
|
|
55
|
+
package_format.py .cxf container read/write
|
|
56
|
+
api_client.py Upload/download, with local-folder fallback
|
|
57
|
+
utils.py ID/access-code generation, logging
|
|
58
|
+
tests/
|
|
59
|
+
test_core.py
|
|
60
|
+
worker/ Cloudflare Worker backend (R2 + D1)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Running tests
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install pytest
|
|
67
|
+
pytest
|
|
68
|
+
```
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -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,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
codexfer/__init__.py
|
|
4
|
+
codexfer/api_client.py
|
|
5
|
+
codexfer/cli.py
|
|
6
|
+
codexfer/compression.py
|
|
7
|
+
codexfer/config.py
|
|
8
|
+
codexfer/encryption.py
|
|
9
|
+
codexfer/package_format.py
|
|
10
|
+
codexfer/utils.py
|
|
11
|
+
codexfer.egg-info/PKG-INFO
|
|
12
|
+
codexfer.egg-info/SOURCES.txt
|
|
13
|
+
codexfer.egg-info/dependency_links.txt
|
|
14
|
+
codexfer.egg-info/entry_points.txt
|
|
15
|
+
codexfer.egg-info/requires.txt
|
|
16
|
+
codexfer.egg-info/top_level.txt
|
|
17
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
codexfer
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codexfer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "End-to-end encrypted code sharing for developers."
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"typer>=0.12",
|
|
12
|
+
"rich>=13.7",
|
|
13
|
+
"cryptography>=42.0",
|
|
14
|
+
"requests>=2.31",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8.0"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
codexfer = "codexfer.cli:app"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
include = ["codexfer*"]
|
codexfer-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from codexfer.encryption import encrypt_bytes, decrypt_bytes
|
|
2
|
+
from codexfer.compression import compress_project, extract_project
|
|
3
|
+
from codexfer.package_format import write_cxf, read_cxf
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_encrypt_decrypt_roundtrip():
|
|
7
|
+
plaintext = b"print('hello world')"
|
|
8
|
+
code = "A9KP-29QX-L81M"
|
|
9
|
+
blob = encrypt_bytes(plaintext, code)
|
|
10
|
+
assert decrypt_bytes(blob, code) == plaintext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_wrong_access_code_fails():
|
|
14
|
+
plaintext = b"secret source code"
|
|
15
|
+
blob = encrypt_bytes(plaintext, "RIGHT-CODE-0000")
|
|
16
|
+
try:
|
|
17
|
+
decrypt_bytes(blob, "WRONG-CODE-1111")
|
|
18
|
+
assert False, "should have raised"
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_pack_and_extract_roundtrip(tmp_path):
|
|
24
|
+
project = tmp_path / "proj"
|
|
25
|
+
(project / "src").mkdir(parents=True)
|
|
26
|
+
(project / "src" / "main.py").write_text("print(1)")
|
|
27
|
+
(project / ".hidden").write_text("secret")
|
|
28
|
+
|
|
29
|
+
archive = tmp_path / "out.zip"
|
|
30
|
+
compress_project(project, archive)
|
|
31
|
+
|
|
32
|
+
restored = tmp_path / "restored"
|
|
33
|
+
extract_project(archive, restored)
|
|
34
|
+
|
|
35
|
+
assert (restored / "src" / "main.py").read_text() == "print(1)"
|
|
36
|
+
assert (restored / ".hidden").read_text() == "secret"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cxf_header_roundtrip(tmp_path):
|
|
40
|
+
blob = encrypt_bytes(b"data", "CODE-0000-0000")
|
|
41
|
+
cxf_path = tmp_path / "pkg.cxf"
|
|
42
|
+
write_cxf(cxf_path, "demo-project", blob)
|
|
43
|
+
header, ciphertext = read_cxf(cxf_path)
|
|
44
|
+
assert header.package_name == "demo-project"
|
|
45
|
+
assert ciphertext == blob.ciphertext
|