secshare-cli 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.
File without changes
secshare_cli/api.py ADDED
@@ -0,0 +1,60 @@
1
+ import httpx
2
+ from .config import get_token, get_api_base
3
+
4
+ TIMEOUT = 15
5
+
6
+
7
+ def _headers(auth: bool = False) -> dict:
8
+ h = {"Content-Type": "application/json"}
9
+ if auth:
10
+ token = get_token()
11
+ if token:
12
+ h["Authorization"] = f"Bearer {token}"
13
+ return h
14
+
15
+
16
+ def login(email: str, password: str) -> dict:
17
+ r = httpx.post(
18
+ f"{get_api_base()}/auth/login",
19
+ json={"email": email, "password": password, "turnstile_token": ""},
20
+ timeout=TIMEOUT,
21
+ )
22
+ r.raise_for_status()
23
+ return r.json()
24
+
25
+
26
+ def whoami() -> dict:
27
+ r = httpx.get(f"{get_api_base()}/auth/me", headers=_headers(auth=True), timeout=TIMEOUT)
28
+ r.raise_for_status()
29
+ return r.json()
30
+
31
+
32
+ def create_secret(encrypted_content: str, iv: str, max_views: int, expires_in_hours: int) -> dict:
33
+ r = httpx.post(
34
+ f"{get_api_base()}/secrets",
35
+ json={
36
+ "encrypted_content": encrypted_content,
37
+ "iv": iv,
38
+ "max_views": max_views,
39
+ "expires_in_hours": expires_in_hours,
40
+ },
41
+ headers=_headers(auth=True),
42
+ timeout=TIMEOUT,
43
+ )
44
+ if r.status_code == 401:
45
+ raise RuntimeError("Session expired — run: secshare login")
46
+ if r.status_code == 403:
47
+ detail = r.json().get("detail", "Forbidden")
48
+ raise RuntimeError(detail)
49
+ r.raise_for_status()
50
+ return r.json()
51
+
52
+
53
+ def fetch_secret(secret_id: str) -> dict:
54
+ r = httpx.get(f"{get_api_base()}/secrets/{secret_id}", timeout=TIMEOUT)
55
+ if r.status_code == 404:
56
+ raise RuntimeError("Secret not found.")
57
+ if r.status_code == 410:
58
+ raise RuntimeError("Secret has expired or already been viewed.")
59
+ r.raise_for_status()
60
+ return r.json()
secshare_cli/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ import json
2
+ import sys
3
+ from urllib.parse import urlparse
4
+
5
+ import click
6
+
7
+ from .api import create_secret, fetch_secret
8
+ from .api import login as api_login
9
+ from .api import whoami as api_whoami
10
+ from .config import get_token, get_api_base, load as load_config, save as save_config
11
+ from .crypto import decrypt, encrypt
12
+
13
+
14
+ def _parse_link(link: str) -> tuple[str, str]:
15
+ """Return (secret_id, key_fragment) from a SecShare URL."""
16
+ if "#" not in link:
17
+ raise click.ClickException("Invalid link — missing key fragment after #.")
18
+ url_part, hash_part = link.split("#", 1)
19
+ key_fragment = hash_part[4:] if hash_part.startswith("key=") else hash_part
20
+ if not key_fragment:
21
+ raise click.ClickException("Invalid link — key fragment is empty.")
22
+ path = urlparse(url_part).path.strip("/").split("/")
23
+ try:
24
+ idx = path.index("s")
25
+ secret_id = path[idx + 1]
26
+ except (ValueError, IndexError):
27
+ raise click.ClickException("Invalid link — could not extract secret ID.")
28
+ return secret_id, key_fragment
29
+
30
+
31
+ def _parse_expiry(expiry: str) -> int:
32
+ """Parse '24h', '7d', '1w' → hours."""
33
+ s = expiry.lower().strip()
34
+ try:
35
+ if s.endswith("w"):
36
+ return int(s[:-1]) * 24 * 7
37
+ if s.endswith("d"):
38
+ return int(s[:-1]) * 24
39
+ if s.endswith("h"):
40
+ return int(s[:-1])
41
+ except ValueError:
42
+ pass
43
+ raise click.ClickException(f"Invalid expiry '{expiry}'. Use e.g. 1h, 24h, 7d, 2w.")
44
+
45
+
46
+ @click.group()
47
+ @click.version_option(package_name="secshare-cli")
48
+ def cli():
49
+ """SecShare — zero-knowledge secret sharing from your terminal.
50
+
51
+ \b
52
+ Quick start:
53
+ secshare login
54
+ secshare create .env --expires 24h
55
+ secshare get https://secshare.link/s/abc123#KEY
56
+
57
+ \b
58
+ Inject .env secrets directly into your shell:
59
+ eval "$(secshare get <link>)"
60
+ """
61
+
62
+
63
+ @cli.command()
64
+ def login():
65
+ """Authenticate with SecShare."""
66
+ email = click.prompt("Email")
67
+ password = click.prompt("Password", hide_input=True)
68
+ try:
69
+ data = api_login(email, password)
70
+ except Exception as e:
71
+ raise click.ClickException(f"Login failed: {e}")
72
+ cfg = load_config()
73
+ cfg["token"] = data["access_token"]
74
+ cfg["email"] = email
75
+ save_config(cfg)
76
+ click.echo(f"Logged in as {email}")
77
+
78
+
79
+ @cli.command()
80
+ def logout():
81
+ """Clear stored credentials."""
82
+ cfg = load_config()
83
+ cfg.pop("token", None)
84
+ cfg.pop("email", None)
85
+ save_config(cfg)
86
+ click.echo("Logged out.")
87
+
88
+
89
+ @cli.command()
90
+ def whoami():
91
+ """Show the currently authenticated user."""
92
+ if not get_token():
93
+ raise click.ClickException("Not logged in. Run: secshare login")
94
+ try:
95
+ data = api_whoami()
96
+ click.echo(f"{data['email']} ({data.get('plan', 'FREE')})")
97
+ except Exception as e:
98
+ raise click.ClickException(str(e))
99
+
100
+
101
+ @cli.command()
102
+ @click.argument("link")
103
+ def get(link):
104
+ """Fetch and decrypt a secret.
105
+
106
+ \b
107
+ Examples:
108
+ secshare get https://secshare.link/s/abc123#KEY
109
+ eval "$(secshare get https://secshare.link/s/abc123#KEY)"
110
+ """
111
+ try:
112
+ secret_id, key_fragment = _parse_link(link)
113
+ data = fetch_secret(secret_id)
114
+ plaintext = decrypt(data["encrypted_content"], data["iv"], key_fragment)
115
+ except click.ClickException:
116
+ raise
117
+ except Exception as e:
118
+ raise click.ClickException(str(e))
119
+
120
+ # .env payload — output KEY=VALUE lines
121
+ try:
122
+ parsed = json.loads(plaintext)
123
+ if isinstance(parsed, dict) and parsed.get("__secshare_type") == "env":
124
+ click.echo(parsed["content"], nl=False)
125
+ return
126
+ except (json.JSONDecodeError, AttributeError):
127
+ pass
128
+
129
+ click.echo(plaintext, nl=False)
130
+
131
+
132
+ @cli.command()
133
+ @click.argument("file", required=False, type=click.Path(exists=True, readable=True))
134
+ @click.option("--secret", is_flag=True, help="Enter a plaintext secret interactively.")
135
+ @click.option("--views", default=1, show_default=True, metavar="N", help="Max views before self-destruct.")
136
+ @click.option("--expires", default="24h", show_default=True, metavar="DURATION", help="Expiry: 1h, 24h, 7d, 2w.")
137
+ def create(file, secret, views, expires):
138
+ """Encrypt and upload a secret or .env file.
139
+
140
+ \b
141
+ Examples:
142
+ secshare create .env
143
+ secshare create .env --views 3 --expires 7d
144
+ secshare create --secret
145
+ secshare create --secret --expires 1h
146
+ """
147
+ if not get_token():
148
+ raise click.ClickException("Not logged in. Run: secshare login")
149
+
150
+ if secret and file:
151
+ raise click.ClickException("Use either FILE or --secret, not both.")
152
+ if not secret and not file:
153
+ raise click.ClickException("Provide a FILE or use --secret for interactive input.")
154
+
155
+ expires_hours = _parse_expiry(expires)
156
+
157
+ if secret:
158
+ plaintext = click.prompt("Secret", hide_input=True, prompt_suffix="\n> ")
159
+ payload = plaintext
160
+ else:
161
+ content = open(file).read()
162
+ payload = json.dumps({"__secshare_type": "env", "content": content})
163
+
164
+ try:
165
+ enc_content, iv, key_fragment = encrypt(payload)
166
+ data = create_secret(enc_content, iv, max_views=views, expires_in_hours=expires_hours)
167
+ except Exception as e:
168
+ raise click.ClickException(str(e))
169
+
170
+ api_host = get_api_base().replace("/api/v1", "").replace("api.", "")
171
+ url = f"{api_host}/s/{data['id']}#{key_fragment}"
172
+ click.echo(url)
173
+
174
+
175
+ def main():
176
+ cli()
secshare_cli/config.py ADDED
@@ -0,0 +1,29 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ CONFIG_DIR = Path.home() / ".secshare"
5
+ CONFIG_FILE = CONFIG_DIR / "config"
6
+ DEFAULT_API = "https://api.secshare.link/api/v1"
7
+
8
+
9
+ def load() -> dict:
10
+ if CONFIG_FILE.exists():
11
+ try:
12
+ return json.loads(CONFIG_FILE.read_text())
13
+ except Exception:
14
+ return {}
15
+ return {}
16
+
17
+
18
+ def save(data: dict) -> None:
19
+ CONFIG_DIR.mkdir(exist_ok=True)
20
+ CONFIG_FILE.write_text(json.dumps(data, indent=2))
21
+ CONFIG_FILE.chmod(0o600)
22
+
23
+
24
+ def get_token() -> str | None:
25
+ return load().get("token")
26
+
27
+
28
+ def get_api_base() -> str:
29
+ return load().get("api_base", DEFAULT_API)
secshare_cli/crypto.py ADDED
@@ -0,0 +1,28 @@
1
+ import os
2
+ import base64
3
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
4
+
5
+
6
+ def encrypt(plaintext: str) -> tuple[str, str, str]:
7
+ """AES-256-GCM encrypt — matches browser encryptSecret()."""
8
+ key_bytes = os.urandom(32)
9
+ iv_bytes = os.urandom(12)
10
+ ciphertext = AESGCM(key_bytes).encrypt(iv_bytes, plaintext.encode("utf-8"), None)
11
+ return (
12
+ base64.b64encode(ciphertext).decode(),
13
+ base64.b64encode(iv_bytes).decode(),
14
+ base64.urlsafe_b64encode(key_bytes).decode().rstrip("="),
15
+ )
16
+
17
+
18
+ def decrypt(encrypted_content: str, iv: str, key_fragment: str) -> str:
19
+ """AES-256-GCM decrypt — matches browser decryptSecret()."""
20
+ pad = len(key_fragment) % 4
21
+ if pad:
22
+ key_fragment += "=" * (4 - pad)
23
+ key_bytes = base64.urlsafe_b64decode(key_fragment)
24
+ return AESGCM(key_bytes).decrypt(
25
+ base64.b64decode(iv),
26
+ base64.b64decode(encrypted_content),
27
+ None,
28
+ ).decode("utf-8")
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: secshare-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for SecShare — zero-knowledge secret sharing
5
+ Project-URL: Homepage, https://secshare.link
6
+ Project-URL: Source, https://github.com/rdney/secshare
7
+ License: MIT
8
+ Keywords: cli,encryption,secrets,security
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: click>=8.1
14
+ Requires-Dist: cryptography>=44.0
15
+ Requires-Dist: httpx>=0.27
@@ -0,0 +1,9 @@
1
+ secshare_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ secshare_cli/api.py,sha256=XyenBDexujmGttSToIGMLJUs9PuN7v6duaX-b7SQ0hs,1722
3
+ secshare_cli/cli.py,sha256=MqlZbzpp-ncuv_XX-xc9MNJtyvnGFVShYHAxQnaLVGk,5465
4
+ secshare_cli/config.py,sha256=M7WidT7GDvIXQJ1jVDxqy2P_vOo0TNwiqV9u0VT8Ul0,635
5
+ secshare_cli/crypto.py,sha256=MWi5S0w5rbDr_u1qSeUuS51MCvcw8t_DzkLEsR8HtTU,962
6
+ secshare_cli-0.1.0.dist-info/METADATA,sha256=4Dm6mWM5Jkxwas43DdXNhQcnLKfKkyALete1G-nF7OA,527
7
+ secshare_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ secshare_cli-0.1.0.dist-info/entry_points.txt,sha256=f8lNSexn8tt_Byvt2uhQqrSbBvIVVx-SPvb1jsTpgNM,51
9
+ secshare_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ secshare = secshare_cli.cli:main