revault-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli.py ADDED
@@ -0,0 +1,172 @@
1
+ # cli.py
2
+ import typer
3
+ from getpass import getpass
4
+ import json
5
+ import base64
6
+ import sys
7
+ import time
8
+ import threading
9
+ from vault import init_vault, add_entry, get_entry, remove_entry, list_entries, get_salt
10
+ from crypto import derive_key, encrypt_data, decrypt_data
11
+
12
+ app = typer.Typer()
13
+
14
+ @app.command()
15
+ def add(identifier: str):
16
+ """Add username + password for a service."""
17
+ vault = init_vault()
18
+ master_password = getpass("Master password: ")
19
+ key = derive_key(master_password, get_salt(vault))
20
+
21
+ username = input("Username: ")
22
+ password = getpass("Password: ")
23
+
24
+ # Prepare entry JSON
25
+ entry_json = json.dumps({"username": username, "password": password}).encode()
26
+ encrypted_blob = encrypt_data(key, entry_json)
27
+ encrypted_b64 = base64.b64encode(encrypted_blob).decode()
28
+
29
+ add_entry(vault, identifier, encrypted_b64)
30
+ typer.echo(f"Entry '{identifier}' added successfully!")
31
+
32
+ @app.command()
33
+ def show(identifier: str):
34
+ """Show username + password for a service."""
35
+ vault = init_vault()
36
+ encrypted_b64 = get_entry(vault, identifier)
37
+ if not encrypted_b64:
38
+ typer.echo(f"No entry found for '{identifier}'")
39
+ raise typer.Exit()
40
+
41
+ master_password = getpass("Master password: ")
42
+ key = derive_key(master_password, get_salt(vault))
43
+
44
+ encrypted_blob = base64.b64decode(encrypted_b64)
45
+ try:
46
+ decrypted_bytes = decrypt_data(key, encrypted_blob)
47
+ entry = json.loads(decrypted_bytes.decode())
48
+ typer.echo(f"Username: {entry['username']}")
49
+ typer.echo(f"Password: {entry['password']}")
50
+ except Exception:
51
+ typer.echo("Incorrect master password or corrupted entry!")
52
+
53
+
54
+ def _connect_pexpect(ssh_target: str, password: str):
55
+ import pexpect
56
+ child = pexpect.spawn(f"ssh {ssh_target}")
57
+ child.expect("password:")
58
+ child.sendline(password)
59
+ child.interact()
60
+
61
+
62
+ def _connect_paramiko(ssh_target: str, password: str):
63
+ import paramiko
64
+
65
+ if "@" not in ssh_target:
66
+ typer.echo("Error: SSH target must be in 'user@host' format.")
67
+ return
68
+
69
+ user, host = ssh_target.split("@", 1)
70
+ client = paramiko.SSHClient()
71
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
72
+
73
+ try:
74
+ client.connect(host, username=user, password=password)
75
+ except paramiko.AuthenticationException:
76
+ typer.echo("Authentication failed: wrong password.")
77
+ return
78
+ except Exception as e:
79
+ typer.echo(f"Connection failed: {e}")
80
+ return
81
+
82
+ channel = client.invoke_shell()
83
+ stop = threading.Event()
84
+
85
+ def _read_output():
86
+ while not stop.is_set():
87
+ try:
88
+ data = channel.recv(4096)
89
+ if not data:
90
+ stop.set()
91
+ break
92
+ sys.stdout.buffer.write(data)
93
+ sys.stdout.buffer.flush()
94
+ except Exception:
95
+ stop.set()
96
+ break
97
+
98
+ reader = threading.Thread(target=_read_output, daemon=True)
99
+ reader.start()
100
+
101
+ try:
102
+ import msvcrt
103
+ while not stop.is_set():
104
+ if msvcrt.kbhit():
105
+ ch = msvcrt.getch()
106
+ if not channel.send(ch):
107
+ break
108
+ else:
109
+ time.sleep(0.05)
110
+ except KeyboardInterrupt:
111
+ pass
112
+ finally:
113
+ stop.set()
114
+ channel.close()
115
+ client.close()
116
+
117
+
118
+ @app.command()
119
+ def connect(identifier: str):
120
+ """SSH into a host using stored credentials."""
121
+ vault = init_vault()
122
+ encrypted_b64 = get_entry(vault, identifier)
123
+ if not encrypted_b64:
124
+ typer.echo(f"No entry found for '{identifier}'")
125
+ return
126
+
127
+ master_password = getpass("Master password: ")
128
+ key = derive_key(master_password, get_salt(vault))
129
+ encrypted_blob = base64.b64decode(encrypted_b64)
130
+
131
+ try:
132
+ decrypted_bytes = decrypt_data(key, encrypted_blob)
133
+ entry = json.loads(decrypted_bytes.decode())
134
+ except Exception:
135
+ typer.echo("Incorrect master password or corrupted entry!")
136
+ return
137
+
138
+ ssh_target = entry["username"]
139
+ password = entry["password"]
140
+
141
+ typer.echo(f"Connecting to {ssh_target}...")
142
+
143
+ if sys.platform == "win32":
144
+ _connect_paramiko(ssh_target, password)
145
+ else:
146
+ _connect_pexpect(ssh_target, password)
147
+
148
+
149
+ @app.command()
150
+ def remove(identifier: str):
151
+ """Remove a stored entry."""
152
+ vault = init_vault()
153
+ if not remove_entry(vault, identifier):
154
+ typer.echo(f"No entry found for '{identifier}'")
155
+ raise typer.Exit(1)
156
+ typer.echo(f"Entry '{identifier}' removed.")
157
+
158
+
159
+ @app.command()
160
+ def list():
161
+ """List all stored identifiers."""
162
+ vault = init_vault()
163
+ entries = list_entries(vault)
164
+ if entries:
165
+ typer.echo("Stored entries:")
166
+ for e in entries:
167
+ typer.echo(f"- {e}")
168
+ else:
169
+ typer.echo("No entries stored yet.")
170
+
171
+ if __name__ == "__main__":
172
+ app()
crypto.py ADDED
@@ -0,0 +1,26 @@
1
+ # crypto.py
2
+ import base64
3
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
4
+ from cryptography.fernet import Fernet
5
+
6
+ def derive_key(master_password: str, salt: bytes) -> bytes:
7
+ """
8
+ Derive a symmetric key from master password and salt
9
+ """
10
+ kdf = Scrypt(
11
+ salt=salt,
12
+ length=32,
13
+ n=2**14,
14
+ r=8,
15
+ p=1,
16
+ )
17
+ key = kdf.derive(master_password.encode())
18
+ return base64.urlsafe_b64encode(key) # Fernet requires base64 key
19
+
20
+ def encrypt_data(key: bytes, data: bytes) -> bytes:
21
+ f = Fernet(key)
22
+ return f.encrypt(data)
23
+
24
+ def decrypt_data(key: bytes, token: bytes) -> bytes:
25
+ f = Fernet(key)
26
+ return f.decrypt(token)
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: revault-cli
3
+ Version: 1.0.0
4
+ Summary: CLI password manager for server credentials with SSH connector
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/sufi-an/re-vault
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer>=0.9.0
10
+ Requires-Dist: cryptography>=41.0.3
11
+ Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
12
+ Requires-Dist: paramiko>=3.4.0
13
+
14
+ Author was lost in a sea of server credentials, brain.exe could not handle so much info
15
+ Wrote a password manager to store server credentails(Yes, ENCRYPTED)
16
+ then thought why should I connect.
17
+ so
18
+ re-valut connect myserver :)
19
+
20
+ ## Install
21
+
22
+ ### macOS (Homebrew)
23
+ ```bash
24
+ brew tap sufi-an/re-vault
25
+ brew install re-vault
26
+ ```
27
+
28
+ ### Linux / macOS (pip / pipx)
29
+ ```bash
30
+ # pipx keeps it isolated (recommended)
31
+ pipx install revault-cli
32
+
33
+ # or plain pip
34
+ pip install revault-cli
35
+ ```
36
+
37
+ Then use `re-vault` instead of `python cli.py`:
38
+ ```bash
39
+ re-vault add myserver
40
+ re-vault connect myserver
41
+ ```
42
+
43
+ > **apt install:** Not yet in official Debian/Ubuntu repos (that process takes months).
44
+ > Use `pip`/`pipx` on Linux for now.
45
+
46
+ ---
47
+
48
+ ## Windows .exe
49
+
50
+ **Quick test (run on your Windows machine):**
51
+ ```bat
52
+ build.bat
53
+ dist\passkeeper.exe add myserver
54
+ ```
55
+
56
+ **Automated releases:** push a version tag and GitHub Actions builds + attaches the exe to the release automatically.
57
+ ```bash
58
+ git tag v1.0.0 && git push origin v1.0.0
59
+ ```
60
+
61
+ ---
62
+
63
+ ## How to use
64
+
65
+ ### Install
66
+
67
+ ```bash
68
+ pip install -r requirements.txt
69
+ ```
70
+
71
+ ### Commands
72
+
73
+ Each command prompts for your **master password** (it is never stored). The vault
74
+ lives at `~/.passkeeper/vault.json` and is locked to your user account on both
75
+ Unix (`chmod 0o600`) and Windows (`icacls`).
76
+
77
+ | Command | What it does |
78
+ | --- | --- |
79
+ | `python cli.py add <identifier>` | Prompt for username + password and store them encrypted under `<identifier>`. |
80
+ | `python cli.py show <identifier>` | Decrypt and print the username + password. |
81
+ | `python cli.py list` | List all stored identifiers. |
82
+ | `python cli.py connect <identifier>` | Decrypt the entry and SSH in, auto-sending the password (works on Unix **and** Windows). |
83
+ | `python cli.py remove <identifier>` | Delete a stored entry permanently. |
84
+
85
+ ### Example
86
+
87
+ ```bash
88
+ # Store credentials for a server (username should be the full SSH target, e.g. user@host)
89
+ $ python cli.py add myserver
90
+ Master password:
91
+ Username: deploy@198.51.100.10
92
+ Password:
93
+ Entry 'myserver' added successfully!
94
+
95
+ # Look them up later
96
+ $ python cli.py show myserver
97
+ Master password:
98
+ Username: deploy@198.51.100.10
99
+ Password: ...
100
+
101
+ # List everything you've stored
102
+ $ python cli.py list
103
+ Stored entries:
104
+ - myserver
105
+
106
+ # Connect over SSH without typing the password (Unix/macOS/Windows)
107
+ $ python cli.py connect myserver
108
+ ```
109
+
110
+ > **Note:** On Unix/macOS `connect` uses `pexpect` to drive the system `ssh` binary.
111
+ > On Windows it uses `paramiko` to connect directly — no external `ssh` binary needed.
@@ -0,0 +1,8 @@
1
+ cli.py,sha256=gEqqsI8abZRD5sbyln4uciHwrZul4e92plzDkZqXw2I,4909
2
+ crypto.py,sha256=myB4KnjmL7bVVY61vgLp6XK0RunzjYEBjpDQ_1uwINg,685
3
+ vault.py,sha256=xfaJmqDIQjE1a9MLiKZOgGA0mJQ8QOXQYpZWYsUWAfY,2460
4
+ revault_cli-1.0.0.dist-info/METADATA,sha256=0JxsiWHyH3jejee4_ZCr8VulaXCtjENe2QBM5YzAaQI,2952
5
+ revault_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ revault_cli-1.0.0.dist-info/entry_points.txt,sha256=gKXIZ3Xz9c_j-M0FyS-7M3Qf_gstYYkYGOtPdHE_XLE,37
7
+ revault_cli-1.0.0.dist-info/top_level.txt,sha256=dnoB5T5DWeC8LBVMIpHUIkoukpLrodsa7oTw5soLUIc,17
8
+ revault_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ re-vault = cli:app
@@ -0,0 +1,3 @@
1
+ cli
2
+ crypto
3
+ vault
vault.py ADDED
@@ -0,0 +1,77 @@
1
+ # vault.py
2
+ import json
3
+ from pathlib import Path
4
+ import os
5
+ import base64
6
+ import getpass
7
+ import subprocess
8
+
9
+ VAULT_DIR = Path.home() / ".passkeeper"
10
+ VAULT_FILE = VAULT_DIR / "vault.json"
11
+
12
+ def _restrict_to_current_user(path: Path):
13
+ """Restrict a path so only the current user can read/write it.
14
+
15
+ On POSIX this is a simple chmod 0o600/0o700. On Windows os.chmod can only
16
+ toggle the read-only bit and cannot express per-user access, so the secrets
17
+ would stay readable by every account on the machine. We use icacls instead
18
+ to drop inherited permissions and grant access to the current user only.
19
+ """
20
+ if os.name == "nt":
21
+ user = os.environ.get("USERNAME") or getpass.getuser()
22
+ try:
23
+ subprocess.run(
24
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:(F)"],
25
+ check=True,
26
+ capture_output=True,
27
+ )
28
+ except (subprocess.CalledProcessError, FileNotFoundError):
29
+ # icacls unavailable (e.g. some minimal environments) — fall back to
30
+ # the read-only bit so we at least do something.
31
+ os.chmod(path, 0o600)
32
+ else:
33
+ mode = 0o700 if path.is_dir() else 0o600
34
+ os.chmod(path, mode)
35
+
36
+ def init_vault():
37
+ VAULT_DIR.mkdir(exist_ok=True)
38
+ _restrict_to_current_user(VAULT_DIR)
39
+ if not VAULT_FILE.exists():
40
+ # Generate random salt
41
+ salt = os.urandom(16)
42
+ vault = {
43
+ "version": 1,
44
+ "salt": base64.b64encode(salt).decode(),
45
+ "entries": {}
46
+ }
47
+ save_vault(vault)
48
+ _restrict_to_current_user(VAULT_FILE)
49
+ return load_vault()
50
+
51
+ def load_vault():
52
+ with VAULT_FILE.open("r", encoding="utf-8") as f:
53
+ return json.load(f)
54
+
55
+ def save_vault(vault: dict):
56
+ with VAULT_FILE.open("w", encoding="utf-8") as f:
57
+ json.dump(vault, f, indent=4)
58
+
59
+ def get_salt(vault: dict) -> bytes:
60
+ return base64.b64decode(vault["salt"])
61
+
62
+ def add_entry(vault: dict, identifier: str, encrypted_blob: str):
63
+ vault["entries"][identifier] = encrypted_blob
64
+ save_vault(vault)
65
+
66
+ def get_entry(vault: dict, identifier: str):
67
+ return vault["entries"].get(identifier)
68
+
69
+ def remove_entry(vault: dict, identifier: str) -> bool:
70
+ if identifier not in vault["entries"]:
71
+ return False
72
+ del vault["entries"][identifier]
73
+ save_vault(vault)
74
+ return True
75
+
76
+ def list_entries(vault: dict):
77
+ return list(vault["entries"].keys())