hidehub 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.
hidehub-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: hidehub
3
+ Version: 0.1.0
4
+ Summary: Secure secret management for developers — zero-knowledge .env vault
5
+ Author: HideHub
6
+ License: MIT
7
+ Project-URL: Homepage, https://hidehub.com
8
+ Project-URL: Documentation, https://hidehub.com/docs
9
+ Project-URL: Repository, https://github.com/marcus20232023/hidehub_claude
10
+ Keywords: secrets,environment-variables,dotenv,encryption,security,cli
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: typer>=0.12.0
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: cryptography>=43.0.0
26
+
27
+ # HideHub CLI
28
+
29
+ Secure secret management from the command line.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install hidehub-cli
35
+ ```
36
+
37
+ Or install from source:
38
+
39
+ ```bash
40
+ cd client
41
+ pip install -e .
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ # Authenticate
48
+ hidehub init --email you@example.com
49
+
50
+ # Push secrets from a .env file
51
+ hidehub push --env-file .env.production --project my-api
52
+
53
+ # List projects
54
+ hidehub list
55
+
56
+ # List secrets in a project
57
+ hidehub list --project my-api
58
+
59
+ # Run a command with secrets injected
60
+ hidehub run --project my-api -- npm start
61
+
62
+ # Rotate a secret
63
+ hidehub rotate OPENAI_API_KEY --project my-api
64
+
65
+ # Export secrets for shell eval
66
+ eval "$(hidehub use --project my-api --export)"
67
+ ```
68
+
69
+ ## CI/CD Usage
70
+
71
+ Set the `HIDEHUB_API_KEY` environment variable to use API key auth instead of JWT:
72
+
73
+ ```bash
74
+ export HIDEHUB_API_KEY=hh_your_api_key_here
75
+ hidehub run --project my-api -- npm test
76
+ ```
@@ -0,0 +1,50 @@
1
+ # HideHub CLI
2
+
3
+ Secure secret management from the command line.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install hidehub-cli
9
+ ```
10
+
11
+ Or install from source:
12
+
13
+ ```bash
14
+ cd client
15
+ pip install -e .
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ # Authenticate
22
+ hidehub init --email you@example.com
23
+
24
+ # Push secrets from a .env file
25
+ hidehub push --env-file .env.production --project my-api
26
+
27
+ # List projects
28
+ hidehub list
29
+
30
+ # List secrets in a project
31
+ hidehub list --project my-api
32
+
33
+ # Run a command with secrets injected
34
+ hidehub run --project my-api -- npm start
35
+
36
+ # Rotate a secret
37
+ hidehub rotate OPENAI_API_KEY --project my-api
38
+
39
+ # Export secrets for shell eval
40
+ eval "$(hidehub use --project my-api --export)"
41
+ ```
42
+
43
+ ## CI/CD Usage
44
+
45
+ Set the `HIDEHUB_API_KEY` environment variable to use API key auth instead of JWT:
46
+
47
+ ```bash
48
+ export HIDEHUB_API_KEY=hh_your_api_key_here
49
+ hidehub run --project my-api -- npm test
50
+ ```
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: hidehub
3
+ Version: 0.1.0
4
+ Summary: Secure secret management for developers — zero-knowledge .env vault
5
+ Author: HideHub
6
+ License: MIT
7
+ Project-URL: Homepage, https://hidehub.com
8
+ Project-URL: Documentation, https://hidehub.com/docs
9
+ Project-URL: Repository, https://github.com/marcus20232023/hidehub_claude
10
+ Keywords: secrets,environment-variables,dotenv,encryption,security,cli
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: typer>=0.12.0
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: cryptography>=43.0.0
26
+
27
+ # HideHub CLI
28
+
29
+ Secure secret management from the command line.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install hidehub-cli
35
+ ```
36
+
37
+ Or install from source:
38
+
39
+ ```bash
40
+ cd client
41
+ pip install -e .
42
+ ```
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ # Authenticate
48
+ hidehub init --email you@example.com
49
+
50
+ # Push secrets from a .env file
51
+ hidehub push --env-file .env.production --project my-api
52
+
53
+ # List projects
54
+ hidehub list
55
+
56
+ # List secrets in a project
57
+ hidehub list --project my-api
58
+
59
+ # Run a command with secrets injected
60
+ hidehub run --project my-api -- npm start
61
+
62
+ # Rotate a secret
63
+ hidehub rotate OPENAI_API_KEY --project my-api
64
+
65
+ # Export secrets for shell eval
66
+ eval "$(hidehub use --project my-api --export)"
67
+ ```
68
+
69
+ ## CI/CD Usage
70
+
71
+ Set the `HIDEHUB_API_KEY` environment variable to use API key auth instead of JWT:
72
+
73
+ ```bash
74
+ export HIDEHUB_API_KEY=hh_your_api_key_here
75
+ hidehub run --project my-api -- npm test
76
+ ```
@@ -0,0 +1,22 @@
1
+ README.md
2
+ pyproject.toml
3
+ hidehub.egg-info/PKG-INFO
4
+ hidehub.egg-info/SOURCES.txt
5
+ hidehub.egg-info/dependency_links.txt
6
+ hidehub.egg-info/entry_points.txt
7
+ hidehub.egg-info/requires.txt
8
+ hidehub.egg-info/top_level.txt
9
+ hidehub_cli/__init__.py
10
+ hidehub_cli/__main__.py
11
+ hidehub_cli/api_client.py
12
+ hidehub_cli/cli.py
13
+ hidehub_cli/config.py
14
+ hidehub_cli/crypto.py
15
+ hidehub_cli/env_parser.py
16
+ hidehub_cli/commands/__init__.py
17
+ hidehub_cli/commands/init.py
18
+ hidehub_cli/commands/list.py
19
+ hidehub_cli/commands/push.py
20
+ hidehub_cli/commands/rotate.py
21
+ hidehub_cli/commands/run.py
22
+ hidehub_cli/commands/use.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hidehub = hidehub_cli.cli:app
@@ -0,0 +1,3 @@
1
+ typer>=0.12.0
2
+ httpx>=0.27.0
3
+ cryptography>=43.0.0
@@ -0,0 +1 @@
1
+ hidehub_cli
@@ -0,0 +1,3 @@
1
+ """HideHub CLI — Secure secret management from the command line."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m hidehub_cli`."""
2
+
3
+ from hidehub_cli.cli import app
4
+
5
+ app()
@@ -0,0 +1,114 @@
1
+ """HTTP client for HideHub API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from hidehub_cli.config import get_server, get_token, get_api_key
8
+
9
+
10
+ class ApiError(Exception):
11
+ def __init__(self, status: int, detail: str):
12
+ self.status = status
13
+ self.detail = detail
14
+ super().__init__(detail)
15
+
16
+
17
+ class HideHubClient:
18
+ def __init__(self, server: str | None = None, token: str | None = None):
19
+ self.server = server or get_server()
20
+ self.token = token or get_token()
21
+ self.api_key = get_api_key()
22
+
23
+ def _headers(self) -> dict[str, str]:
24
+ headers: dict[str, str] = {"Content-Type": "application/json"}
25
+ if self.api_key:
26
+ headers["X-API-Key"] = self.api_key
27
+ elif self.token:
28
+ headers["Authorization"] = f"Bearer {self.token}"
29
+ return headers
30
+
31
+ def _request(self, method: str, path: str, **kwargs) -> dict:
32
+ url = f"{self.server}{path}"
33
+ with httpx.Client(timeout=30) as client:
34
+ resp = client.request(method, url, headers=self._headers(), **kwargs)
35
+ if not resp.is_success:
36
+ detail = resp.text
37
+ try:
38
+ body = resp.json()
39
+ detail = body.get("detail", detail)
40
+ except Exception:
41
+ pass
42
+ raise ApiError(resp.status_code, detail)
43
+ if resp.status_code == 204:
44
+ return {}
45
+ return resp.json()
46
+
47
+ # Auth
48
+ def login(self, email: str, password: str) -> dict:
49
+ """Returns full login response (may include requires_2fa)."""
50
+ data = self._request("POST", "/auth/login", json={"email": email, "password": password})
51
+ if data.get("access_token"):
52
+ self.token = data["access_token"]
53
+ return data
54
+
55
+ def verify_2fa(self, two_fa_token: str, code: str) -> str:
56
+ data = self._request(
57
+ "POST", "/auth/2fa/verify",
58
+ json={"two_fa_token": two_fa_token, "code": code},
59
+ )
60
+ self.token = data["access_token"]
61
+ return data["access_token"]
62
+
63
+ def exchange_github_token(self, github_access_token: str) -> dict:
64
+ """Exchange a GitHub access token for a HideHub JWT."""
65
+ data = self._request(
66
+ "POST", "/auth/github/token",
67
+ json={"github_access_token": github_access_token},
68
+ )
69
+ if data.get("access_token"):
70
+ self.token = data["access_token"]
71
+ return data
72
+
73
+ def get_me(self) -> dict:
74
+ return self._request("GET", "/auth/me")
75
+
76
+ # Projects
77
+ def list_projects(self) -> list[dict]:
78
+ return self._request("GET", "/projects")["items"]
79
+
80
+ def create_project(self, name: str) -> dict:
81
+ return self._request("POST", "/projects", json={"name": name})
82
+
83
+ def get_project(self, project_id: str) -> dict:
84
+ return self._request("GET", f"/projects/{project_id}")
85
+
86
+ def find_project_by_name(self, name: str) -> dict | None:
87
+ projects = self.list_projects()
88
+ for p in projects:
89
+ if p["name"] == name:
90
+ return p
91
+ return None
92
+
93
+ # Secrets
94
+ def list_secrets(self, project_id: str) -> list[dict]:
95
+ return self._request("GET", f"/projects/{project_id}/secrets")["items"]
96
+
97
+ def get_secret(self, project_id: str, secret_id: str) -> dict:
98
+ return self._request("GET", f"/projects/{project_id}/secrets/{secret_id}")
99
+
100
+ def bulk_push(self, project_id: str, secrets: list[dict], force: bool = False) -> dict:
101
+ return self._request(
102
+ "POST",
103
+ f"/projects/{project_id}/secrets/bulk",
104
+ json={"secrets": secrets, "force": force},
105
+ )
106
+
107
+ def update_secret(
108
+ self, project_id: str, secret_id: str, encrypted_blob: str, keep_history: bool = True
109
+ ) -> dict:
110
+ return self._request(
111
+ "PUT",
112
+ f"/projects/{project_id}/secrets/{secret_id}",
113
+ json={"encrypted_blob": encrypted_blob, "keep_history": keep_history},
114
+ )
@@ -0,0 +1,39 @@
1
+ """HideHub CLI — Secure secret management from the command line."""
2
+
3
+ import typer
4
+
5
+ from hidehub_cli import __version__
6
+ from hidehub_cli.commands.init import init
7
+ from hidehub_cli.commands.push import push
8
+ from hidehub_cli.commands.use import use
9
+ from hidehub_cli.commands.run import run
10
+ from hidehub_cli.commands.list import list_cmd
11
+ from hidehub_cli.commands.rotate import rotate
12
+
13
+ app = typer.Typer(
14
+ name="hidehub",
15
+ help="Secure secret management for developers. Never commit an API key again.",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+ app.command("init")(init)
20
+ app.command("push")(push)
21
+ app.command("use")(use)
22
+ app.command("run")(run)
23
+ app.command("list")(list_cmd)
24
+ app.command("rotate")(rotate)
25
+
26
+
27
+ def version_callback(value: bool):
28
+ if value:
29
+ typer.echo(f"hidehub-cli {__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ version: bool = typer.Option(
36
+ None, "--version", "-V", callback=version_callback, is_eager=True, help="Show version"
37
+ ),
38
+ ):
39
+ """HideHub — Your secrets, your keys."""
@@ -0,0 +1 @@
1
+ # CLI commands package
@@ -0,0 +1,175 @@
1
+ """hidehub init — Authenticate and save config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import time
7
+
8
+ import httpx
9
+ import typer
10
+
11
+ from hidehub_cli.api_client import HideHubClient, ApiError
12
+ from hidehub_cli.config import save_config, DEFAULT_SERVER
13
+
14
+ app = typer.Typer()
15
+
16
+
17
+ def _prompt_2fa(client: HideHubClient, two_fa_token: str) -> str:
18
+ """Prompt for a TOTP code (or backup code) and verify."""
19
+ typer.echo("\n🔐 Two-factor authentication required.")
20
+ code = typer.prompt("Enter your 2FA code")
21
+ try:
22
+ return client.verify_2fa(two_fa_token, code)
23
+ except ApiError as e:
24
+ typer.echo(f"✗ 2FA verification failed: {e.detail}", err=True)
25
+ raise typer.Exit(1)
26
+
27
+
28
+ def _login_email(client: HideHubClient) -> str:
29
+ """Email/password login flow, returns access token."""
30
+ email = typer.prompt("Email")
31
+ password = getpass.getpass("Password: ")
32
+
33
+ try:
34
+ data = client.login(email, password)
35
+ except ApiError as e:
36
+ typer.echo(f"✗ Authentication failed: {e.detail}", err=True)
37
+ raise typer.Exit(1)
38
+
39
+ if data.get("requires_2fa"):
40
+ return _prompt_2fa(client, data["two_fa_token"])
41
+
42
+ return data["access_token"]
43
+
44
+
45
+ def _login_github(client: HideHubClient, server: str) -> str:
46
+ """GitHub Device Flow login, returns access token."""
47
+ # We need the GitHub Client ID. Fetch it from the server's config endpoint
48
+ # or use the well-known HideHub GitHub OAuth App client ID.
49
+ # For device flow, we call GitHub directly with the client_id.
50
+ typer.echo("\n🔗 Starting GitHub Device Flow...")
51
+
52
+ # Step 1: Get the GitHub client_id from the server
53
+ # We'll request a device code from GitHub
54
+ github_client_id = _get_github_client_id(server)
55
+ if not github_client_id:
56
+ typer.echo("✗ GitHub OAuth is not configured on this server.", err=True)
57
+ raise typer.Exit(1)
58
+
59
+ # Step 2: Request device code
60
+ with httpx.Client(timeout=30) as http:
61
+ resp = http.post(
62
+ "https://github.com/login/oauth/device/code",
63
+ data={"client_id": github_client_id, "scope": "user:email"},
64
+ headers={"Accept": "application/json"},
65
+ )
66
+ if resp.status_code != 200:
67
+ typer.echo("✗ Failed to start GitHub device flow.", err=True)
68
+ raise typer.Exit(1)
69
+ device = resp.json()
70
+
71
+ user_code = device["user_code"]
72
+ verification_uri = device["verification_uri"]
73
+ device_code = device["device_code"]
74
+ interval = device.get("interval", 5)
75
+ expires_in = device.get("expires_in", 900)
76
+
77
+ typer.echo(f"\n Open: {verification_uri}")
78
+ typer.echo(f" Enter code: {user_code}\n")
79
+ typer.echo("Waiting for authorization...", nl=False)
80
+
81
+ # Step 3: Poll for access token
82
+ github_token = None
83
+ deadline = time.time() + expires_in
84
+
85
+ with httpx.Client(timeout=30) as http:
86
+ while time.time() < deadline:
87
+ time.sleep(interval)
88
+ typer.echo(".", nl=False)
89
+ resp = http.post(
90
+ "https://github.com/login/oauth/access_token",
91
+ data={
92
+ "client_id": github_client_id,
93
+ "device_code": device_code,
94
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
95
+ },
96
+ headers={"Accept": "application/json"},
97
+ )
98
+ body = resp.json()
99
+ error = body.get("error")
100
+ if error == "authorization_pending":
101
+ continue
102
+ elif error == "slow_down":
103
+ interval = body.get("interval", interval + 5)
104
+ continue
105
+ elif error == "expired_token":
106
+ typer.echo("\n✗ Device code expired. Please try again.", err=True)
107
+ raise typer.Exit(1)
108
+ elif error == "access_denied":
109
+ typer.echo("\n✗ Authorization denied.", err=True)
110
+ raise typer.Exit(1)
111
+ elif error:
112
+ typer.echo(f"\n✗ GitHub error: {error}", err=True)
113
+ raise typer.Exit(1)
114
+ else:
115
+ github_token = body.get("access_token")
116
+ break
117
+
118
+ typer.echo("") # newline after dots
119
+
120
+ if not github_token:
121
+ typer.echo("✗ Failed to get GitHub access token.", err=True)
122
+ raise typer.Exit(1)
123
+
124
+ # Step 4: Exchange GitHub token for HideHub JWT
125
+ try:
126
+ data = client.exchange_github_token(github_token)
127
+ except ApiError as e:
128
+ typer.echo(f"✗ GitHub token exchange failed: {e.detail}", err=True)
129
+ raise typer.Exit(1)
130
+
131
+ if data.get("requires_2fa"):
132
+ return _prompt_2fa(client, data["two_fa_token"])
133
+
134
+ return data["access_token"]
135
+
136
+
137
+ def _get_github_client_id(server: str) -> str | None:
138
+ """Fetch the GitHub client ID from the server's config endpoint."""
139
+ try:
140
+ with httpx.Client(timeout=10) as http:
141
+ resp = http.get(f"{server}/auth/github/client-id")
142
+ if resp.status_code == 200:
143
+ return resp.json().get("client_id")
144
+ except Exception:
145
+ pass
146
+ return None
147
+
148
+
149
+ @app.command()
150
+ def init(
151
+ server: str = typer.Option(DEFAULT_SERVER, "--server", "-s", help="Custom server URL"),
152
+ github: bool = typer.Option(False, "--github", "-g", help="Login with GitHub"),
153
+ ):
154
+ """Authenticate with HideHub and save your config."""
155
+ client = HideHubClient(server=server)
156
+
157
+ if not github:
158
+ # Ask which method
159
+ typer.echo("How would you like to sign in?")
160
+ typer.echo(" [1] Email & password")
161
+ typer.echo(" [2] GitHub")
162
+ choice = typer.prompt("Choose", default="1")
163
+ github = choice.strip() == "2"
164
+
165
+ if github:
166
+ token = _login_github(client, server)
167
+ else:
168
+ token = _login_email(client)
169
+
170
+ # Save config
171
+ me = client.get_me()
172
+ save_config({"server": server, "token": token, "email": me["email"]})
173
+
174
+ typer.echo(f"✓ Authenticated as {me['email']}")
175
+ typer.echo("✓ Config saved to ~/.hidehub/config.json")
@@ -0,0 +1,70 @@
1
+ """hidehub list — View projects and secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+
7
+ import typer
8
+
9
+ from hidehub_cli.api_client import HideHubClient, ApiError
10
+ from hidehub_cli.crypto import decrypt_secret
11
+
12
+ app = typer.Typer()
13
+
14
+
15
+ @app.command("list")
16
+ def list_cmd(
17
+ project: str = typer.Option(None, "--project", "-p", help="Show secrets for a specific project"),
18
+ show_values: bool = typer.Option(False, "--show-values", help="Display decrypted values"),
19
+ ):
20
+ """View projects and their secrets."""
21
+ client = HideHubClient()
22
+
23
+ if not project:
24
+ # List projects
25
+ projects = client.list_projects()
26
+ if not projects:
27
+ typer.echo("No projects found. Run `hidehub push` to create one.")
28
+ return
29
+
30
+ typer.echo(f"{'PROJECT':<25} {'SECRETS':>8} {'LAST UPDATED'}")
31
+ typer.echo("-" * 55)
32
+ for p in projects:
33
+ updated = p["updated_at"][:10] if p.get("updated_at") else "—"
34
+ typer.echo(f"{p['name']:<25} {p['secret_count']:>8} {updated}")
35
+ return
36
+
37
+ # List secrets for a project
38
+ proj = client.find_project_by_name(project)
39
+ if not proj:
40
+ typer.echo(f"✗ Project '{project}' not found", err=True)
41
+ raise typer.Exit(1)
42
+
43
+ secrets = client.list_secrets(proj["id"])
44
+ if not secrets:
45
+ typer.echo(f"No secrets in project '{project}'.")
46
+ return
47
+
48
+ master_password = None
49
+ if show_values:
50
+ master_password = getpass.getpass("Master password: ")
51
+
52
+ typer.echo(f"\nProject: {project} ({len(secrets)} secrets)")
53
+ typer.echo("-" * 60)
54
+
55
+ if show_values:
56
+ typer.echo(f"{'KEY':<30} {'VALUE':<25} {'VERSION'}")
57
+ for s in secrets:
58
+ try:
59
+ full = client.get_secret(proj["id"], s["id"])
60
+ value = decrypt_secret(full["encrypted_blob"], master_password)
61
+ # Truncate long values
62
+ display = value[:22] + "..." if len(value) > 25 else value
63
+ typer.echo(f"{s['key_name']:<30} {display:<25} v{s['version']}")
64
+ except Exception:
65
+ typer.echo(f"{s['key_name']:<30} {'<decrypt failed>':<25} v{s['version']}")
66
+ else:
67
+ typer.echo(f"{'KEY':<30} {'VERSION':>8} {'UPDATED'}")
68
+ for s in secrets:
69
+ updated = s["updated_at"][:10] if s.get("updated_at") else "—"
70
+ typer.echo(f"{s['key_name']:<30} {'v' + str(s['version']):>8} {updated}")
@@ -0,0 +1,75 @@
1
+ """hidehub push — Encrypt and upload secrets from a .env file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+
8
+ import typer
9
+
10
+ from hidehub_cli.api_client import HideHubClient, ApiError
11
+ from hidehub_cli.crypto import encrypt_secret
12
+ from hidehub_cli.env_parser import parse_env_file
13
+
14
+ app = typer.Typer()
15
+
16
+
17
+ @app.command()
18
+ def push(
19
+ env_file: str = typer.Option(".env", "--env-file", "-f", help="Path to .env file"),
20
+ project: str = typer.Option(None, "--project", "-p", help="Project name (default: directory name)"),
21
+ clean: bool = typer.Option(False, "--clean", help="Delete local .env after upload"),
22
+ force: bool = typer.Option(False, "--force", help="Overwrite existing secrets"),
23
+ ):
24
+ """Encrypt and upload secrets from a .env file."""
25
+ if not os.path.exists(env_file):
26
+ typer.echo(f"✗ File not found: {env_file}", err=True)
27
+ raise typer.Exit(1)
28
+
29
+ if not project:
30
+ project = os.path.basename(os.getcwd())
31
+
32
+ master_password = getpass.getpass("Master password: ")
33
+
34
+ secrets = parse_env_file(env_file)
35
+ if not secrets:
36
+ typer.echo("✗ No valid key=value pairs found", err=True)
37
+ raise typer.Exit(1)
38
+
39
+ typer.echo(f"✓ Read {len(secrets)} secrets from {env_file}")
40
+
41
+ # Encrypt each secret
42
+ encrypted = []
43
+ for key, value in secrets.items():
44
+ encrypted.append({
45
+ "key_name": key,
46
+ "encrypted_blob": encrypt_secret(value, master_password),
47
+ })
48
+ typer.echo("✓ Encrypted with AES-256-GCM")
49
+
50
+ client = HideHubClient()
51
+
52
+ # Find or create project
53
+ proj = client.find_project_by_name(project)
54
+ if not proj:
55
+ try:
56
+ proj = client.create_project(project)
57
+ typer.echo(f"✓ Created project \"{project}\"")
58
+ except ApiError as e:
59
+ typer.echo(f"✗ Failed to create project: {e.detail}", err=True)
60
+ raise typer.Exit(1)
61
+
62
+ # Bulk push
63
+ try:
64
+ result = client.bulk_push(proj["id"], encrypted, force=force)
65
+ typer.echo(
66
+ f"✓ Uploaded to project \"{project}\" "
67
+ f"({result['created']} new, {result['updated']} updated)"
68
+ )
69
+ except ApiError as e:
70
+ typer.echo(f"✗ Upload failed: {e.detail}", err=True)
71
+ raise typer.Exit(1)
72
+
73
+ if clean:
74
+ os.remove(env_file)
75
+ typer.echo(f"✓ Local file deleted (--clean)")
@@ -0,0 +1,54 @@
1
+ """hidehub rotate — Update a secret's value with version history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+
7
+ import typer
8
+
9
+ from hidehub_cli.api_client import HideHubClient, ApiError
10
+ from hidehub_cli.crypto import encrypt_secret
11
+
12
+ app = typer.Typer()
13
+
14
+
15
+ @app.command()
16
+ def rotate(
17
+ key_name: str = typer.Argument(help="Name of the secret to rotate"),
18
+ project: str = typer.Option(..., "--project", "-p", help="Project containing the secret"),
19
+ value: str = typer.Option(None, "--value", "-v", help="New value (prompted if omitted)"),
20
+ keep_history: bool = typer.Option(True, "--keep-history/--no-history", help="Keep old value in version history"),
21
+ ):
22
+ """Rotate a secret to a new value."""
23
+ master_password = getpass.getpass("Master password: ")
24
+
25
+ if not value:
26
+ value = getpass.getpass(f"Enter new value for {key_name}: ")
27
+ if not value:
28
+ typer.echo("✗ Value cannot be empty", err=True)
29
+ raise typer.Exit(1)
30
+
31
+ client = HideHubClient()
32
+
33
+ proj = client.find_project_by_name(project)
34
+ if not proj:
35
+ typer.echo(f"✗ Project '{project}' not found", err=True)
36
+ raise typer.Exit(1)
37
+
38
+ # Find secret by key name
39
+ secrets = client.list_secrets(proj["id"])
40
+ secret = next((s for s in secrets if s["key_name"] == key_name), None)
41
+ if not secret:
42
+ typer.echo(f"✗ Key '{key_name}' not found in project '{project}'", err=True)
43
+ raise typer.Exit(1)
44
+
45
+ encrypted = encrypt_secret(value, master_password)
46
+
47
+ try:
48
+ result = client.update_secret(proj["id"], secret["id"], encrypted, keep_history)
49
+ typer.echo(f"✓ Rotated {key_name}")
50
+ if keep_history:
51
+ typer.echo(f"✓ Previous value saved (version {result['version'] - 1})")
52
+ except ApiError as e:
53
+ typer.echo(f"✗ Rotation failed: {e.detail}", err=True)
54
+ raise typer.Exit(1)
@@ -0,0 +1,75 @@
1
+ """hidehub run — Inject secrets and run a command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+ import subprocess
8
+ import sys
9
+
10
+ import typer
11
+
12
+ from hidehub_cli.api_client import HideHubClient, ApiError
13
+ from hidehub_cli.crypto import decrypt_secret
14
+
15
+ app = typer.Typer()
16
+
17
+
18
+ @app.command(
19
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
20
+ )
21
+ def run(
22
+ ctx: typer.Context,
23
+ project: str = typer.Option(..., "--project", "-p", help="Project to load secrets from"),
24
+ env_file: str = typer.Option(None, "--env-file", "-f", help="Also load local .env (merged)"),
25
+ silent: bool = typer.Option(False, "--silent", "-s", help="Suppress HideHub output"),
26
+ ):
27
+ """Inject secrets and run a command.
28
+
29
+ Usage: hidehub run --project my-api -- npm start
30
+ """
31
+ command = ctx.args
32
+ if not command:
33
+ typer.echo("✗ No command specified. Usage: hidehub run --project NAME -- COMMAND", err=True)
34
+ raise typer.Exit(1)
35
+
36
+ master_password = getpass.getpass("Master password: ")
37
+ client = HideHubClient()
38
+
39
+ proj = client.find_project_by_name(project)
40
+ if not proj:
41
+ typer.echo(f"✗ Project '{project}' not found", err=True)
42
+ raise typer.Exit(1)
43
+
44
+ secrets = client.list_secrets(proj["id"])
45
+
46
+ # Build environment
47
+ env = os.environ.copy()
48
+
49
+ # Load local .env file first (lower priority)
50
+ if env_file and os.path.exists(env_file):
51
+ from hidehub_cli.env_parser import parse_env_file
52
+ local_secrets = parse_env_file(env_file)
53
+ env.update(local_secrets)
54
+
55
+ # Decrypt and inject HideHub secrets (higher priority)
56
+ loaded = 0
57
+ for s in secrets:
58
+ try:
59
+ full = client.get_secret(proj["id"], s["id"])
60
+ value = decrypt_secret(full["encrypted_blob"], master_password)
61
+ env[s["key_name"]] = value
62
+ loaded += 1
63
+ except Exception as e:
64
+ typer.echo(f"✗ Failed to decrypt {s['key_name']}: {e}", err=True)
65
+
66
+ if not silent:
67
+ typer.echo(f"[hidehub] Loaded {loaded} secrets for project \"{project}\"", err=True)
68
+
69
+ # Run the command
70
+ result = subprocess.run(command, env=env)
71
+
72
+ if not silent:
73
+ typer.echo("[hidehub] Cleaning up environment variables...", err=True)
74
+
75
+ raise typer.Exit(result.returncode)
@@ -0,0 +1,94 @@
1
+ """hidehub use — Interactive secret picker, outputs export statements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import sys
7
+
8
+ import typer
9
+
10
+ from hidehub_cli.api_client import HideHubClient, ApiError
11
+ from hidehub_cli.crypto import decrypt_secret
12
+
13
+ app = typer.Typer()
14
+
15
+
16
+ def _pick_from_list(prompt: str, items: list[dict], name_key: str) -> dict | None:
17
+ """Simple numbered list picker for terminals without questionary."""
18
+ if not items:
19
+ return None
20
+ typer.echo(f"\n{prompt}")
21
+ for i, item in enumerate(items, 1):
22
+ typer.echo(f" {i}. {item[name_key]}")
23
+ while True:
24
+ choice = typer.prompt("Select", default="1")
25
+ try:
26
+ idx = int(choice) - 1
27
+ if 0 <= idx < len(items):
28
+ return items[idx]
29
+ except ValueError:
30
+ pass
31
+ typer.echo("Invalid choice, try again.")
32
+
33
+
34
+ @app.command()
35
+ def use(
36
+ project: str = typer.Option(None, "--project", "-p", help="Project name"),
37
+ key: str = typer.Option(None, "--key", "-k", help="Specific key to load"),
38
+ export: bool = typer.Option(False, "--export", help="Output as export statements"),
39
+ ):
40
+ """Load secrets into your environment."""
41
+ master_password = getpass.getpass("Master password: ")
42
+ client = HideHubClient()
43
+
44
+ # Select project
45
+ if project:
46
+ proj = client.find_project_by_name(project)
47
+ if not proj:
48
+ typer.echo(f"✗ Project '{project}' not found", err=True)
49
+ raise typer.Exit(1)
50
+ else:
51
+ projects = client.list_projects()
52
+ if not projects:
53
+ typer.echo("✗ No projects found. Run `hidehub push` first.", err=True)
54
+ raise typer.Exit(1)
55
+ proj = _pick_from_list("Select project:", projects, "name")
56
+ if not proj:
57
+ raise typer.Exit(1)
58
+
59
+ # Get secrets
60
+ secrets = client.list_secrets(proj["id"])
61
+ if not secrets:
62
+ typer.echo(f"✗ No secrets in project '{proj['name']}'", err=True)
63
+ raise typer.Exit(1)
64
+
65
+ # Filter by key if specified
66
+ if key:
67
+ secrets = [s for s in secrets if s["key_name"] == key]
68
+ if not secrets:
69
+ typer.echo(f"✗ Key '{key}' not found in project '{proj['name']}'", err=True)
70
+ raise typer.Exit(1)
71
+
72
+ # Decrypt and output
73
+ loaded = 0
74
+ for s in secrets:
75
+ try:
76
+ full = client.get_secret(proj["id"], s["id"])
77
+ value = decrypt_secret(full["encrypted_blob"], master_password)
78
+ if export:
79
+ # Output for eval: eval "$(hidehub use --export)"
80
+ sys.stdout.write(f"export {s['key_name']}={_shell_escape(value)}\n")
81
+ loaded += 1
82
+ except Exception as e:
83
+ typer.echo(f"✗ Failed to decrypt {s['key_name']}: {e}", err=True)
84
+
85
+ if not export:
86
+ typer.echo(f"✓ Loaded {loaded} secrets from project \"{proj['name']}\"")
87
+
88
+
89
+ def _shell_escape(value: str) -> str:
90
+ """Escape a value for safe shell export."""
91
+ if not value or any(c in value for c in " \t\n'\"\\$`!#&|;(){}"):
92
+ escaped = value.replace("'", "'\\''")
93
+ return f"'{escaped}'"
94
+ return value
@@ -0,0 +1,40 @@
1
+ """Manages ~/.hidehub/config.json for auth and server settings."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ CONFIG_DIR = Path.home() / ".hidehub"
8
+ CONFIG_FILE = CONFIG_DIR / "config.json"
9
+
10
+ DEFAULT_SERVER = "https://hidehub.com/api"
11
+
12
+
13
+ def _ensure_dir() -> None:
14
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
15
+
16
+
17
+ def load_config() -> dict:
18
+ if not CONFIG_FILE.exists():
19
+ return {}
20
+ return json.loads(CONFIG_FILE.read_text())
21
+
22
+
23
+ def save_config(config: dict) -> None:
24
+ _ensure_dir()
25
+ CONFIG_FILE.write_text(json.dumps(config, indent=2) + "\n")
26
+ # Restrict permissions
27
+ os.chmod(CONFIG_FILE, 0o600)
28
+
29
+
30
+ def get_server() -> str:
31
+ return load_config().get("server", DEFAULT_SERVER)
32
+
33
+
34
+ def get_token() -> str | None:
35
+ return load_config().get("token")
36
+
37
+
38
+ def get_api_key() -> str | None:
39
+ """Check HIDEHUB_API_KEY env var for headless/CI auth."""
40
+ return os.environ.get("HIDEHUB_API_KEY")
@@ -0,0 +1,123 @@
1
+ """V1 encryption compatible with frontend crypto.ts.
2
+
3
+ Scheme: PBKDF2-SHA256 (600k iter) → HKDF → AES-256-GCM + HMAC-SHA256
4
+ Output: JSON string {v, salt, iv, ct, tag, hmac, algorithm, kdf}
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import hmac as hmac_mod
12
+ import json
13
+ import os
14
+ from typing import Any
15
+
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
+ from cryptography.hazmat.primitives import hashes
20
+
21
+ V1_ITERATIONS = 600_000
22
+ V1_SALT_BYTES = 32
23
+ V1_IV_BYTES = 12
24
+
25
+
26
+ def _b64(data: bytes) -> str:
27
+ return base64.b64encode(data).decode()
28
+
29
+
30
+ def _unb64(s: str) -> bytes:
31
+ return base64.b64decode(s)
32
+
33
+
34
+ def _derive_keys(password: str, salt: bytes) -> tuple[bytes, bytes]:
35
+ """Derive encryption key and HMAC key from password + salt.
36
+
37
+ Matches frontend deriveKeysV1() exactly:
38
+ 1. PBKDF2-SHA256, 600k iterations → 32-byte master key
39
+ 2. HKDF-SHA256 with info="encryption" → 32-byte enc key
40
+ 3. HKDF-SHA256 with info="hmac" → 32-byte hmac key
41
+ """
42
+ # PBKDF2
43
+ kdf = PBKDF2HMAC(
44
+ algorithm=hashes.SHA256(),
45
+ length=32,
46
+ salt=salt,
47
+ iterations=V1_ITERATIONS,
48
+ )
49
+ master_key = kdf.derive(password.encode())
50
+
51
+ # HKDF → encryption key
52
+ hkdf_enc = HKDF(
53
+ algorithm=hashes.SHA256(),
54
+ length=32,
55
+ salt=b"", # empty salt, matching frontend
56
+ info=b"encryption",
57
+ )
58
+ enc_key = hkdf_enc.derive(master_key)
59
+
60
+ # HKDF → HMAC key
61
+ hkdf_hmac = HKDF(
62
+ algorithm=hashes.SHA256(),
63
+ length=32,
64
+ salt=b"",
65
+ info=b"hmac",
66
+ )
67
+ hmac_key = hkdf_hmac.derive(master_key)
68
+
69
+ return enc_key, hmac_key
70
+
71
+
72
+ def encrypt_secret(plaintext: str, password: str) -> str:
73
+ """Encrypt a secret value. Returns JSON string compatible with frontend."""
74
+ salt = os.urandom(V1_SALT_BYTES)
75
+ iv = os.urandom(V1_IV_BYTES)
76
+ enc_key, hmac_key = _derive_keys(password, salt)
77
+
78
+ # AES-256-GCM
79
+ aesgcm = AESGCM(enc_key)
80
+ # AESGCM.encrypt returns ciphertext + 16-byte tag appended
81
+ ct_with_tag = aesgcm.encrypt(iv, plaintext.encode(), None)
82
+ ct = ct_with_tag[:-16]
83
+ tag = ct_with_tag[-16:]
84
+
85
+ # HMAC-SHA256 over ciphertext
86
+ hmac_sig = hmac_mod.new(hmac_key, ct, hashlib.sha256).digest()
87
+
88
+ blob: dict[str, Any] = {
89
+ "v": 1,
90
+ "salt": _b64(salt),
91
+ "iv": _b64(iv),
92
+ "ct": _b64(ct),
93
+ "tag": _b64(tag),
94
+ "hmac": _b64(hmac_sig),
95
+ "algorithm": "AES-256-GCM",
96
+ "kdf": "PBKDF2-SHA256-600k+HKDF",
97
+ }
98
+ return json.dumps(blob)
99
+
100
+
101
+ def decrypt_secret(encrypted_json: str, password: str) -> str:
102
+ """Decrypt a v1 encrypted secret."""
103
+ blob = json.loads(encrypted_json)
104
+ if blob.get("v") != 1:
105
+ raise ValueError(f"Unsupported encryption version: {blob.get('v')}")
106
+
107
+ salt = _unb64(blob["salt"])
108
+ iv = _unb64(blob["iv"])
109
+ ct = _unb64(blob["ct"])
110
+ tag = _unb64(blob["tag"])
111
+ hmac_expected = _unb64(blob["hmac"])
112
+
113
+ enc_key, hmac_key = _derive_keys(password, salt)
114
+
115
+ # Verify HMAC
116
+ hmac_actual = hmac_mod.new(hmac_key, ct, hashlib.sha256).digest()
117
+ if not hmac_mod.compare_digest(hmac_actual, hmac_expected):
118
+ raise ValueError("HMAC verification failed — data may be corrupted or wrong password")
119
+
120
+ # Decrypt
121
+ aesgcm = AESGCM(enc_key)
122
+ plaintext = aesgcm.decrypt(iv, ct + tag, None)
123
+ return plaintext.decode()
@@ -0,0 +1,38 @@
1
+ """Parse .env files into key=value dicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def parse_env_file(path: str | Path) -> dict[str, str]:
9
+ """Parse a .env file into a dict of key-value pairs.
10
+
11
+ Handles:
12
+ - Blank lines and comments (lines starting with #)
13
+ - Quoted values (single and double quotes)
14
+ - Inline comments after values
15
+ """
16
+ result: dict[str, str] = {}
17
+ text = Path(path).read_text()
18
+
19
+ for line in text.splitlines():
20
+ line = line.strip()
21
+ if not line or line.startswith("#"):
22
+ continue
23
+
24
+ idx = line.find("=")
25
+ if idx == -1:
26
+ continue
27
+
28
+ key = line[:idx].strip()
29
+ value = line[idx + 1 :].strip()
30
+
31
+ # Remove surrounding quotes
32
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
33
+ value = value[1:-1]
34
+
35
+ if key:
36
+ result[key] = value
37
+
38
+ return result
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hidehub"
7
+ version = "0.1.0"
8
+ description = "Secure secret management for developers — zero-knowledge .env vault"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "HideHub"}]
13
+ keywords = ["secrets", "environment-variables", "dotenv", "encryption", "security", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ "Topic :: Software Development :: Libraries",
25
+ ]
26
+ dependencies = [
27
+ "typer>=0.12.0",
28
+ "httpx>=0.27.0",
29
+ "cryptography>=43.0.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://hidehub.com"
34
+ Documentation = "https://hidehub.com/docs"
35
+ Repository = "https://github.com/marcus20232023/hidehub_claude"
36
+
37
+ [project.scripts]
38
+ hidehub = "hidehub_cli.cli:app"
39
+
40
+ [tool.setuptools.packages.find]
41
+ include = ["hidehub_cli*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+