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.
- secshare_cli/__init__.py +0 -0
- secshare_cli/api.py +60 -0
- secshare_cli/cli.py +176 -0
- secshare_cli/config.py +29 -0
- secshare_cli/crypto.py +28 -0
- secshare_cli-0.1.0.dist-info/METADATA +15 -0
- secshare_cli-0.1.0.dist-info/RECORD +9 -0
- secshare_cli-0.1.0.dist-info/WHEEL +4 -0
- secshare_cli-0.1.0.dist-info/entry_points.txt +2 -0
secshare_cli/__init__.py
ADDED
|
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,,
|