crux-inject 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Akash Bhujbal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: crux-inject
3
+ Version: 0.1.0
4
+ Summary: CRUX — Credential Runtime Unified eXecutor. Securely inject credentials into any AI tool.
5
+ Author-email: Akash Bhujbal <akashbhujbal100@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/akashbhujbalwebsite/crux
8
+ Project-URL: Issues, https://github.com/akashbhujbalwebsite/crux/issues
9
+ Keywords: credentials,security,ai,cli,secrets,injection
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: typer<1.0.0,>=0.12.0
14
+ Requires-Dist: rich>=13.0.0
15
+ Requires-Dist: cryptography<51.0.0,>=42.0.0
16
+ Requires-Dist: keyring<26.0.0,>=25.0.0
17
+ Requires-Dist: secretstorage<4.0.0,>=3.3.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
20
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # CRUX — Credential Runtime Unified eXecutor
24
+
25
+ > Store credentials encrypted on your machine. Auto-inject into any AI tool. Credentials never appear in chat transcripts.
26
+
27
+ ---
28
+
29
+ ## The Problem
30
+
31
+ Every day, developers paste passwords, API keys, and server credentials directly into AI chat tools to get help with tasks.
32
+
33
+ ```
34
+ ❌ You type in chat: "my server password is MyP@ssw0rd123, can you SSH in?"
35
+ ```
36
+
37
+ That credential is now in the conversation — stored on Anthropic/OpenAI servers, visible in your transcript forever.
38
+
39
+ **Real numbers (2026):**
40
+ - 29 million secrets leaked on GitHub in 2025 — 34% YoY increase
41
+ - 1.27 million AI-specific keys exposed (OpenAI, Anthropic, Hugging Face)
42
+ - 3.2% secret-leak rate in AI-assisted commits vs 1.5% baseline
43
+
44
+ ---
45
+
46
+ ## The Solution
47
+
48
+ CRUX stores your credentials encrypted locally and auto-injects them as environment variables into any AI tool.
49
+
50
+ ```
51
+ ✅ You say in chat: "SSH into $VM_USER@$VM_IP using $VM_PASS"
52
+ ```
53
+
54
+ Claude/ChatGPT sees the variable names — never the actual values.
55
+
56
+ ---
57
+
58
+ ## Install
59
+
60
+ **Linux / macOS (recommended):**
61
+ ```bash
62
+ pipx install crux-inject
63
+ crux install
64
+ source ~/.bashrc
65
+ ```
66
+
67
+ > **Ubuntu/Debian users:** `pip install` is blocked system-wide by default. Use `pipx` instead.
68
+ > Install pipx first if needed: `sudo apt install pipx && pipx ensurepath`
69
+
70
+ **Inside a virtual environment:**
71
+ ```bash
72
+ pip install crux-inject
73
+ ```
74
+
75
+ > **Platform support:** Linux and macOS only. Windows users: use WSL2.
76
+
77
+ ---
78
+
79
+ ## Quick Start
80
+
81
+ **1. Add credentials for a profile:**
82
+ ```bash
83
+ crux add --profile my-server
84
+
85
+ # Adding credentials → profile: my-server
86
+ # Key name: VM_IP
87
+ # Value: 10.0.1.100
88
+ # Key name: VM_USER
89
+ # Value: admin
90
+ # Key name: VM_PASS
91
+ # Value: ••••••••••
92
+ ```
93
+
94
+ **2. Switch to that profile:**
95
+ ```bash
96
+ crux use my-server
97
+ source ~/.bashrc
98
+ ```
99
+
100
+ **3. Your creds are now live as env vars:**
101
+ ```bash
102
+ echo $VM_IP # 10.0.1.100
103
+ echo $VM_USER # admin
104
+ ```
105
+
106
+ **4. Launch any AI tool with creds injected:**
107
+ ```bash
108
+ crux run claude
109
+ ```
110
+
111
+ Now tell Claude: *"SSH into $VM_USER@$VM_IP and check disk space"* — Claude runs the command, your password never enters the chat.
112
+
113
+ ---
114
+
115
+ ## Commands
116
+
117
+ | Command | What it does |
118
+ |---------|-------------|
119
+ | `crux install` | One-time setup — adds shell hook to `~/.bashrc` |
120
+ | `crux add --profile <name>` | Add credentials interactively (hidden input) |
121
+ | `crux list` | Show all profiles and their key names |
122
+ | `crux use <profile>` | Switch active profile |
123
+ | `crux run <tool>` | Launch any tool with credentials injected |
124
+ | `crux remove <key> --profile <name>` | Delete a credential |
125
+ | `crux status` | Show current active profile and keys |
126
+ | `crux doctor` | Health check — CRUX setup + AI tools |
127
+
128
+ ---
129
+
130
+ ## AI Tool Support
131
+
132
+ | You type | CRUX resolves to |
133
+ |----------|-----------------|
134
+ | `crux run claude` | `claude` |
135
+ | `crux run cursor` | `cursor-agent` |
136
+ | `crux run gemini` | `gemini-cli` |
137
+ | `crux run copilot` | `copilot` |
138
+ | `crux run <anything>` | any binary in PATH |
139
+
140
+ ---
141
+
142
+ ## Multi-Profile Workflow
143
+
144
+ Working across multiple environments? Create a profile per environment:
145
+
146
+ ```bash
147
+ crux add --profile server-prod
148
+ crux add --profile server-staging
149
+ crux add --profile aws-dev
150
+
151
+ crux list
152
+ # Profiles: server-prod (active), server-staging, aws-dev
153
+
154
+ crux use server-staging
155
+ source ~/.bashrc
156
+ crux run claude # Claude now gets staging creds
157
+ ```
158
+
159
+ ---
160
+
161
+ ## How It Works
162
+
163
+ CRUX adds a shell hook to `~/.bashrc`:
164
+
165
+ ```bash
166
+ eval "$(crux _hook)"
167
+ ```
168
+
169
+ On every terminal open, this hook:
170
+ 1. Exports credentials from the active profile as `export KEY=value`
171
+ 2. Adds all credential key names to `HISTIGNORE` so they never appear in shell history
172
+ 3. Runs `ulimit -c 0` to disable core dumps (prevents credentials leaking in crash files)
173
+
174
+ Credentials are stored encrypted at `~/.crux/store.enc` using **Fernet (AES-128)**. The encryption key is stored in your OS keyring (Linux Secret Service / macOS Keychain) with a file-based fallback at `~/.crux/.key` for headless servers.
175
+
176
+ ---
177
+
178
+ ## Security
179
+
180
+ **What CRUX protects:**
181
+ - Credentials never typed into AI chat
182
+ - Credentials never in shell history
183
+ - Credentials never in core dump files
184
+ - Encrypted at rest (AES-128 Fernet)
185
+ - OS keyring integration for key storage
186
+
187
+ **Golden Rules:**
188
+ 1. Never ask AI to *reveal* a credential — ask it to *use* it: `ssh into $VM_USER@$VM_IP`
189
+ 2. Rotate credentials you shared in AI chat before using CRUX
190
+ 3. Use `sshpass -e` not `sshpass -p` (avoids password visible in `ps aux`)
191
+ 4. Use `sudo -E` to preserve env vars when sudoing
192
+ 5. Never commit `~/.crux/` to git — excluded by default in `.gitignore`
193
+
194
+ See [SECURITY.md](SECURITY.md) for full details.
195
+
196
+ ---
197
+
198
+ ## Requirements
199
+
200
+ - Python 3.10+
201
+ - Linux / macOS
202
+
203
+ ---
204
+
205
+ ## License
206
+
207
+ MIT © 2026 Akash Bhujbal
@@ -0,0 +1,185 @@
1
+ # CRUX — Credential Runtime Unified eXecutor
2
+
3
+ > Store credentials encrypted on your machine. Auto-inject into any AI tool. Credentials never appear in chat transcripts.
4
+
5
+ ---
6
+
7
+ ## The Problem
8
+
9
+ Every day, developers paste passwords, API keys, and server credentials directly into AI chat tools to get help with tasks.
10
+
11
+ ```
12
+ ❌ You type in chat: "my server password is MyP@ssw0rd123, can you SSH in?"
13
+ ```
14
+
15
+ That credential is now in the conversation — stored on Anthropic/OpenAI servers, visible in your transcript forever.
16
+
17
+ **Real numbers (2026):**
18
+ - 29 million secrets leaked on GitHub in 2025 — 34% YoY increase
19
+ - 1.27 million AI-specific keys exposed (OpenAI, Anthropic, Hugging Face)
20
+ - 3.2% secret-leak rate in AI-assisted commits vs 1.5% baseline
21
+
22
+ ---
23
+
24
+ ## The Solution
25
+
26
+ CRUX stores your credentials encrypted locally and auto-injects them as environment variables into any AI tool.
27
+
28
+ ```
29
+ ✅ You say in chat: "SSH into $VM_USER@$VM_IP using $VM_PASS"
30
+ ```
31
+
32
+ Claude/ChatGPT sees the variable names — never the actual values.
33
+
34
+ ---
35
+
36
+ ## Install
37
+
38
+ **Linux / macOS (recommended):**
39
+ ```bash
40
+ pipx install crux-inject
41
+ crux install
42
+ source ~/.bashrc
43
+ ```
44
+
45
+ > **Ubuntu/Debian users:** `pip install` is blocked system-wide by default. Use `pipx` instead.
46
+ > Install pipx first if needed: `sudo apt install pipx && pipx ensurepath`
47
+
48
+ **Inside a virtual environment:**
49
+ ```bash
50
+ pip install crux-inject
51
+ ```
52
+
53
+ > **Platform support:** Linux and macOS only. Windows users: use WSL2.
54
+
55
+ ---
56
+
57
+ ## Quick Start
58
+
59
+ **1. Add credentials for a profile:**
60
+ ```bash
61
+ crux add --profile my-server
62
+
63
+ # Adding credentials → profile: my-server
64
+ # Key name: VM_IP
65
+ # Value: 10.0.1.100
66
+ # Key name: VM_USER
67
+ # Value: admin
68
+ # Key name: VM_PASS
69
+ # Value: ••••••••••
70
+ ```
71
+
72
+ **2. Switch to that profile:**
73
+ ```bash
74
+ crux use my-server
75
+ source ~/.bashrc
76
+ ```
77
+
78
+ **3. Your creds are now live as env vars:**
79
+ ```bash
80
+ echo $VM_IP # 10.0.1.100
81
+ echo $VM_USER # admin
82
+ ```
83
+
84
+ **4. Launch any AI tool with creds injected:**
85
+ ```bash
86
+ crux run claude
87
+ ```
88
+
89
+ Now tell Claude: *"SSH into $VM_USER@$VM_IP and check disk space"* — Claude runs the command, your password never enters the chat.
90
+
91
+ ---
92
+
93
+ ## Commands
94
+
95
+ | Command | What it does |
96
+ |---------|-------------|
97
+ | `crux install` | One-time setup — adds shell hook to `~/.bashrc` |
98
+ | `crux add --profile <name>` | Add credentials interactively (hidden input) |
99
+ | `crux list` | Show all profiles and their key names |
100
+ | `crux use <profile>` | Switch active profile |
101
+ | `crux run <tool>` | Launch any tool with credentials injected |
102
+ | `crux remove <key> --profile <name>` | Delete a credential |
103
+ | `crux status` | Show current active profile and keys |
104
+ | `crux doctor` | Health check — CRUX setup + AI tools |
105
+
106
+ ---
107
+
108
+ ## AI Tool Support
109
+
110
+ | You type | CRUX resolves to |
111
+ |----------|-----------------|
112
+ | `crux run claude` | `claude` |
113
+ | `crux run cursor` | `cursor-agent` |
114
+ | `crux run gemini` | `gemini-cli` |
115
+ | `crux run copilot` | `copilot` |
116
+ | `crux run <anything>` | any binary in PATH |
117
+
118
+ ---
119
+
120
+ ## Multi-Profile Workflow
121
+
122
+ Working across multiple environments? Create a profile per environment:
123
+
124
+ ```bash
125
+ crux add --profile server-prod
126
+ crux add --profile server-staging
127
+ crux add --profile aws-dev
128
+
129
+ crux list
130
+ # Profiles: server-prod (active), server-staging, aws-dev
131
+
132
+ crux use server-staging
133
+ source ~/.bashrc
134
+ crux run claude # Claude now gets staging creds
135
+ ```
136
+
137
+ ---
138
+
139
+ ## How It Works
140
+
141
+ CRUX adds a shell hook to `~/.bashrc`:
142
+
143
+ ```bash
144
+ eval "$(crux _hook)"
145
+ ```
146
+
147
+ On every terminal open, this hook:
148
+ 1. Exports credentials from the active profile as `export KEY=value`
149
+ 2. Adds all credential key names to `HISTIGNORE` so they never appear in shell history
150
+ 3. Runs `ulimit -c 0` to disable core dumps (prevents credentials leaking in crash files)
151
+
152
+ Credentials are stored encrypted at `~/.crux/store.enc` using **Fernet (AES-128)**. The encryption key is stored in your OS keyring (Linux Secret Service / macOS Keychain) with a file-based fallback at `~/.crux/.key` for headless servers.
153
+
154
+ ---
155
+
156
+ ## Security
157
+
158
+ **What CRUX protects:**
159
+ - Credentials never typed into AI chat
160
+ - Credentials never in shell history
161
+ - Credentials never in core dump files
162
+ - Encrypted at rest (AES-128 Fernet)
163
+ - OS keyring integration for key storage
164
+
165
+ **Golden Rules:**
166
+ 1. Never ask AI to *reveal* a credential — ask it to *use* it: `ssh into $VM_USER@$VM_IP`
167
+ 2. Rotate credentials you shared in AI chat before using CRUX
168
+ 3. Use `sshpass -e` not `sshpass -p` (avoids password visible in `ps aux`)
169
+ 4. Use `sudo -E` to preserve env vars when sudoing
170
+ 5. Never commit `~/.crux/` to git — excluded by default in `.gitignore`
171
+
172
+ See [SECURITY.md](SECURITY.md) for full details.
173
+
174
+ ---
175
+
176
+ ## Requirements
177
+
178
+ - Python 3.10+
179
+ - Linux / macOS
180
+
181
+ ---
182
+
183
+ ## License
184
+
185
+ MIT © 2026 Akash Bhujbal
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,43 @@
1
+ import shutil
2
+
3
+ # Friendly name → list of real binary names to try (in order)
4
+ AI_ALIASES: dict[str, list[str]] = {
5
+ "claude": ["claude"],
6
+ "chatgpt": ["codex", "chatgpt"],
7
+ "codex": ["codex"],
8
+ "gemini": ["gemini", "antigravity"],
9
+ "antigravity": ["antigravity"],
10
+ "copilot": ["copilot", "gh-copilot"],
11
+ "cursor": ["cursor-agent", "agent", "cursor"],
12
+ }
13
+
14
+ INSTALL_GUIDES: dict[str, str] = {
15
+ "claude": "npm install -g @anthropic-ai/claude-code",
16
+ "codex": "See https://learn.chatgpt.com/docs/codex/cli",
17
+ "gemini": "npm install -g @google/gemini-cli",
18
+ "antigravity": "npm install -g @google/antigravity-cli",
19
+ "copilot": "npm install -g @github/copilot",
20
+ "cursor-agent": "curl https://cursor.com/install -fsS | bash",
21
+ }
22
+
23
+
24
+ def resolve_command(name: str) -> str | None:
25
+ """Resolve a friendly name or direct command to the actual binary in PATH."""
26
+ candidates = AI_ALIASES.get(name.lower(), [name])
27
+ for cmd in candidates:
28
+ if shutil.which(cmd):
29
+ return cmd
30
+ return None
31
+
32
+
33
+ def get_install_guide(name: str) -> str:
34
+ key = name.lower()
35
+ candidates = AI_ALIASES.get(key, [key])
36
+ for c in candidates:
37
+ if c in INSTALL_GUIDES:
38
+ return INSTALL_GUIDES[c]
39
+ return f"Install '{name}' and make sure it is in your PATH"
40
+
41
+
42
+ def get_all_known_tools() -> dict[str, list[str]]:
43
+ return AI_ALIASES
@@ -0,0 +1,78 @@
1
+ import shutil
2
+ from pathlib import Path
3
+ from crux.keyring_manager import is_keyring_available
4
+ from crux.store import STORE_FILE, CRUX_DIR
5
+ from crux.aliases import INSTALL_GUIDES
6
+
7
+ SHELL_CONFIGS = [
8
+ Path.home() / ".bashrc",
9
+ Path.home() / ".zshrc",
10
+ Path.home() / ".config/fish/config.fish",
11
+ ]
12
+
13
+ HOOK_MARKER = "crux _hook"
14
+
15
+ AI_TOOLS: dict[str, dict] = {
16
+ "claude": {
17
+ "commands": ["claude"],
18
+ "install": INSTALL_GUIDES["claude"],
19
+ },
20
+ "codex (ChatGPT)": {
21
+ "commands": ["codex"],
22
+ "install": INSTALL_GUIDES["codex"],
23
+ },
24
+ "gemini": {
25
+ "commands": ["gemini", "antigravity"],
26
+ "install": INSTALL_GUIDES["gemini"],
27
+ },
28
+ "copilot": {
29
+ "commands": ["copilot"],
30
+ "install": INSTALL_GUIDES["copilot"],
31
+ },
32
+ "cursor": {
33
+ "commands": ["cursor-agent", "agent"],
34
+ "install": INSTALL_GUIDES["cursor-agent"],
35
+ },
36
+ }
37
+
38
+
39
+ def check_all() -> dict:
40
+ results: dict = {}
41
+
42
+ # Shell hook check
43
+ hook_found = False
44
+ for config in SHELL_CONFIGS:
45
+ if config.exists() and HOOK_MARKER in config.read_text():
46
+ hook_found = True
47
+ break
48
+ results["shell_hook"] = hook_found
49
+
50
+ # OS keyring
51
+ results["os_keyring"] = is_keyring_available()
52
+
53
+ # Store file
54
+ results["store"] = STORE_FILE.exists()
55
+
56
+ # Directory permissions
57
+ if CRUX_DIR.exists():
58
+ perms = oct(CRUX_DIR.stat().st_mode)[-3:]
59
+ results["permissions"] = perms == "700"
60
+ else:
61
+ results["permissions"] = False
62
+
63
+ # AI tool binaries
64
+ tool_results: dict = {}
65
+ for tool_name, info in AI_TOOLS.items():
66
+ found_cmd = None
67
+ for cmd in info["commands"]:
68
+ if shutil.which(cmd):
69
+ found_cmd = cmd
70
+ break
71
+ tool_results[tool_name] = {
72
+ "found": found_cmd is not None,
73
+ "command": found_cmd,
74
+ "install": info["install"],
75
+ }
76
+ results["tools"] = tool_results
77
+
78
+ return results
@@ -0,0 +1,99 @@
1
+ import os
2
+ import re
3
+ import subprocess
4
+ from crux.store import get_active_profile, get_profile_creds
5
+ from crux.aliases import resolve_command
6
+
7
+ # Key names must be safe shell identifiers — alphanumeric + underscore + hyphen only
8
+ _SAFE_KEY_RE = re.compile(r'^[A-Za-z0-9_-]+$')
9
+
10
+
11
+ def _sanitize_value(value: str) -> str:
12
+ """
13
+ Strip null bytes and control characters that could break shell export statements.
14
+ Newlines are replaced with a space — credentials should never contain them.
15
+ """
16
+ value = value.replace('\x00', '') # strip null bytes
17
+ value = re.sub(r'[\r\n]', ' ', value) # collapse newlines → space
18
+ value = re.sub(r'[\x01-\x1f\x7f]', '', value) # strip other control chars
19
+ return value
20
+
21
+
22
+ def _shell_single_quote(value: str) -> str:
23
+ """
24
+ Wrap value in single quotes with proper escaping.
25
+ Inside single quotes nothing is interpreted — the only character
26
+ that can break out is ' itself, which we replace with '\''.
27
+ """
28
+ return "'" + value.replace("'", "'\\''") + "'"
29
+
30
+
31
+ def inject_and_run(command: str, args: tuple) -> tuple:
32
+ """
33
+ Inject credentials from the active profile as env vars and run the command.
34
+ Returns (subprocess.CompletedProcess | None, error_string | None).
35
+ """
36
+ profile = get_active_profile()
37
+
38
+ if not profile:
39
+ return None, "no_profile"
40
+
41
+ creds = get_profile_creds(profile)
42
+
43
+ if not creds:
44
+ return None, "empty_profile"
45
+
46
+ resolved = resolve_command(command)
47
+ if not resolved:
48
+ return None, f"not_found:{command}"
49
+
50
+ # Merge: system env + sanitized profile credentials
51
+ env = os.environ.copy()
52
+ env.update({k: _sanitize_value(v) for k, v in creds.items()})
53
+
54
+ full_cmd = [resolved] + list(args)
55
+
56
+ try:
57
+ result = subprocess.run(full_cmd, env=env)
58
+ return result, None
59
+ except FileNotFoundError:
60
+ return None, f"not_found:{resolved}"
61
+ except KeyboardInterrupt:
62
+ return None, "interrupted"
63
+
64
+
65
+ def get_hook_exports() -> str:
66
+ """
67
+ Output shell export statements for the active profile.
68
+ Called by the shell hook: eval "$(crux _hook)"
69
+
70
+ Also outputs:
71
+ - HISTIGNORE patterns for every credential key (fix 1)
72
+ - ulimit -c 0 to disable core dumps (fix 4)
73
+ """
74
+ profile = get_active_profile()
75
+ if not profile:
76
+ return ""
77
+
78
+ creds = get_profile_creds(profile)
79
+ if not creds:
80
+ return ""
81
+
82
+ lines = []
83
+
84
+ # Export each credential — sanitized + single-quote escaped
85
+ for key, value in creds.items():
86
+ if not _SAFE_KEY_RE.match(key):
87
+ continue # skip any key that somehow has unsafe characters
88
+ safe_value = _shell_single_quote(_sanitize_value(value))
89
+ lines.append(f"export {key}={safe_value}")
90
+
91
+ # Fix 1: Dynamically extend HISTIGNORE with every credential key name
92
+ # so commands containing these var names are never saved to history
93
+ histignore_patterns = ":".join(f"*{key}*" for key in creds.keys())
94
+ lines.append(f'export HISTIGNORE="${{HISTIGNORE}}:{histignore_patterns}"')
95
+
96
+ # Fix 4: Disable core dumps to prevent credentials leaking into crash files
97
+ lines.append("ulimit -c 0")
98
+
99
+ return "\n".join(lines)
@@ -0,0 +1,58 @@
1
+ import os
2
+ from pathlib import Path
3
+ from cryptography.fernet import Fernet
4
+
5
+ CRUX_DIR = Path.home() / ".crux"
6
+ KEY_FILE = CRUX_DIR / ".key"
7
+ SERVICE_NAME = "crux-cli"
8
+ USERNAME = "master-key"
9
+
10
+
11
+ def get_key() -> bytes:
12
+ """Get encryption key from OS keyring, fallback to file."""
13
+ try:
14
+ import keyring
15
+ key = keyring.get_password(SERVICE_NAME, USERNAME)
16
+ if key:
17
+ return key.encode()
18
+ except Exception as e:
19
+ import sys
20
+ print(f"[crux] OS keyring unavailable ({e}), using file-based fallback.", file=sys.stderr)
21
+
22
+ # Fallback: file-based key
23
+ if KEY_FILE.exists():
24
+ return KEY_FILE.read_bytes()
25
+
26
+ # Generate new key if nothing found
27
+ return _generate_key()
28
+
29
+
30
+ def set_key(key: bytes):
31
+ """Store encryption key in OS keyring, fallback to file."""
32
+ try:
33
+ import keyring
34
+ keyring.set_password(SERVICE_NAME, USERNAME, key.decode())
35
+ return
36
+ except Exception as e:
37
+ import sys
38
+ print(f"[crux] OS keyring write failed ({e}), storing key in file.", file=sys.stderr)
39
+
40
+ # Fallback: file-based key
41
+ CRUX_DIR.mkdir(mode=0o700, exist_ok=True)
42
+ KEY_FILE.write_bytes(key)
43
+ KEY_FILE.chmod(0o600)
44
+
45
+
46
+ def _generate_key() -> bytes:
47
+ key = Fernet.generate_key()
48
+ set_key(key)
49
+ return key
50
+
51
+
52
+ def is_keyring_available() -> bool:
53
+ try:
54
+ import keyring
55
+ keyring.get_password("crux-test", "crux-test")
56
+ return True
57
+ except Exception:
58
+ return False