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 +76 -0
- hidehub-0.1.0/README.md +50 -0
- hidehub-0.1.0/hidehub.egg-info/PKG-INFO +76 -0
- hidehub-0.1.0/hidehub.egg-info/SOURCES.txt +22 -0
- hidehub-0.1.0/hidehub.egg-info/dependency_links.txt +1 -0
- hidehub-0.1.0/hidehub.egg-info/entry_points.txt +2 -0
- hidehub-0.1.0/hidehub.egg-info/requires.txt +3 -0
- hidehub-0.1.0/hidehub.egg-info/top_level.txt +1 -0
- hidehub-0.1.0/hidehub_cli/__init__.py +3 -0
- hidehub-0.1.0/hidehub_cli/__main__.py +5 -0
- hidehub-0.1.0/hidehub_cli/api_client.py +114 -0
- hidehub-0.1.0/hidehub_cli/cli.py +39 -0
- hidehub-0.1.0/hidehub_cli/commands/__init__.py +1 -0
- hidehub-0.1.0/hidehub_cli/commands/init.py +175 -0
- hidehub-0.1.0/hidehub_cli/commands/list.py +70 -0
- hidehub-0.1.0/hidehub_cli/commands/push.py +75 -0
- hidehub-0.1.0/hidehub_cli/commands/rotate.py +54 -0
- hidehub-0.1.0/hidehub_cli/commands/run.py +75 -0
- hidehub-0.1.0/hidehub_cli/commands/use.py +94 -0
- hidehub-0.1.0/hidehub_cli/config.py +40 -0
- hidehub-0.1.0/hidehub_cli/crypto.py +123 -0
- hidehub-0.1.0/hidehub_cli/env_parser.py +38 -0
- hidehub-0.1.0/pyproject.toml +41 -0
- hidehub-0.1.0/setup.cfg +4 -0
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
|
+
```
|
hidehub-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hidehub_cli
|
|
@@ -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*"]
|
hidehub-0.1.0/setup.cfg
ADDED