passtui 0.0.1__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.
passtui/__init__.py ADDED
File without changes
passtui/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ import click
2
+ from passtui.app import PassTUI
3
+
4
+
5
+ @click.command()
6
+ def cli():
7
+ app = PassTUI()
8
+ app.run()
passtui/app.py ADDED
@@ -0,0 +1,147 @@
1
+ from functools import Placeholder
2
+ from textual.app import App
3
+ from textual import on, work
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.widgets import Footer, Input, Tree
7
+ from textual.widgets.tree import TreeNode
8
+ from passtui.security import passcli
9
+ from passtui.widgets.search import Search
10
+ from passtui.widgets.pass_list import PassList
11
+ from passtui.widgets.pass_data import PassData
12
+ from passtui.screens.input_modal import InputModalScreen
13
+ from passtui.screens.gpg_keygen_modal import GpgKeygenModalScreen
14
+ from passtui.screens.gpg_export_modal import GpgExportModalScreen
15
+
16
+
17
+ class PassTUI(App):
18
+ keys = passcli.list_keys()
19
+
20
+ CSS_PATH = "app.tcss"
21
+
22
+ BINDINGS = [
23
+ Binding("/", "search_password", "Search password"),
24
+ Binding("n", "add_new_password", "Add new password"),
25
+ Binding("e", "focus_editor", "Focus editor"),
26
+ Binding("t", "focus_explorer", "Focus explorer"),
27
+ Binding("s", "sync", "Sync"),
28
+ Binding("g", "create_gpg_store", "Create new GPG Store"),
29
+ Binding("x", "export_gpg", "Export GPG key"),
30
+ Binding("z", "import_gpg", "Import GPG key"),
31
+ ]
32
+
33
+ def compose(self) -> ComposeResult:
34
+ yield Search(data=self.keys, id="search")
35
+ yield PassList(items=self.keys)
36
+ yield PassData()
37
+ yield Footer()
38
+
39
+ def on_mount(self) -> None:
40
+ self.theme = "rose-pine-moon"
41
+
42
+ def action_search_password(self) -> None:
43
+ self.query_one(Search).set_focus()
44
+
45
+ def action_focus_editor(self) -> None:
46
+ self.query_one(PassData).set_focus()
47
+
48
+ def action_focus_explorer(self) -> None:
49
+ self.screen.focus_next(PassList)
50
+
51
+ @on(Input.Changed, "#search")
52
+ async def on_search_changed(self, event: Input.Changed) -> None:
53
+ pass_list = self.query_one(Search).results
54
+ widget: PassList = self.query_one(PassList)
55
+ widget.is_filter = True if event.value else False
56
+ widget.items = pass_list
57
+
58
+ @on(Tree.NodeSelected)
59
+ async def handle_tree_node_selected(self, event: Tree.NodeSelected) -> None:
60
+ self.update_selected(event.node)
61
+
62
+ @work
63
+ async def update_selected(self, node: TreeNode) -> None:
64
+ if node.data:
65
+ pass_model = passcli.get_store_key(node.data)
66
+ if not pass_model:
67
+ return
68
+ self.query_one(PassData).set_password(
69
+ pass_model=pass_model, pass_path=node.data
70
+ )
71
+
72
+ def action_add_new_password(self) -> None:
73
+ self.query_one(PassData).add_new_password()
74
+
75
+ @work
76
+ async def action_sync(self) -> None:
77
+ if passcli.is_git_initialized():
78
+ try:
79
+ passcli.sync_git()
80
+ self.notify("Git sync completed")
81
+ except Exception as e:
82
+ self.notify(f"Failed to sync git: {e}", severity="error")
83
+ else:
84
+ repo_url = await self.push_screen_wait(
85
+ InputModalScreen(
86
+ label="Enter git repository URL",
87
+ placeholder="(e.g., git@github.com:user/repo.git)",
88
+ )
89
+ )
90
+ if repo_url:
91
+ try:
92
+ passcli.init_git(repo_url)
93
+ self.notify("Git initialized and pushed")
94
+ except Exception as e:
95
+ self.notify(f"Failed to init git: {e}", severity="error")
96
+
97
+ @work
98
+ async def action_create_gpg_store(self) -> None:
99
+ key_data = await self.push_screen_wait(GpgKeygenModalScreen())
100
+ if key_data:
101
+ try:
102
+ fingerprint = passcli.create_gpg_store(
103
+ key_data.name, key_data.email, key_data.path
104
+ )
105
+ if not fingerprint:
106
+ self.notify("Failed to create GPG Store", severity="error")
107
+ return
108
+
109
+ self.notify(f"GPG Store created: {fingerprint}")
110
+ except Exception as e:
111
+ self.notify(f"Error: {e}", severity="error")
112
+
113
+ @work
114
+ async def action_export_gpg(self) -> None:
115
+ export_data = await self.push_screen_wait(GpgExportModalScreen())
116
+ if export_data:
117
+ try:
118
+ output_path = passcli.export_gpg_key(
119
+ passphrase=export_data.passphrase,
120
+ output_path=export_data.output_path,
121
+ )
122
+ if not output_path:
123
+ self.notify("Failed to export GPG key", severity="error")
124
+ return
125
+
126
+ self.notify(f"GPG key exported to {output_path}")
127
+ except Exception as e:
128
+ self.notify(f"Error: {e}", severity="error")
129
+
130
+ @work
131
+ async def action_import_gpg(self) -> None:
132
+ filepath = await self.push_screen_wait(
133
+ InputModalScreen(
134
+ label="Enter path to GPG key file",
135
+ placeholder="(e.g., ~/passtui/gpg-export.asc)",
136
+ )
137
+ )
138
+ if filepath:
139
+ try:
140
+ success = passcli.import_gpg_key(filepath)
141
+ if not success:
142
+ self.notify("Failed to import GPG key", severity="error")
143
+ return
144
+
145
+ self.notify("GPG key imported successfully")
146
+ except Exception as e:
147
+ self.notify(f"Error: {e}", severity="error")
passtui/app.tcss ADDED
@@ -0,0 +1,10 @@
1
+ * {
2
+ scrollbar-size-vertical: 1;
3
+ }
4
+
5
+ PassTUI Screen {
6
+ layout: grid;
7
+ grid-size: 2;
8
+ grid-columns: 1fr 2fr;
9
+ grid-rows: 1fr 5fr;
10
+ }
@@ -0,0 +1,68 @@
1
+ from dataclasses import dataclass, fields
2
+
3
+
4
+ @dataclass(init=False)
5
+ class PassModel:
6
+ """
7
+ This model follows the passwordstore.org data organization.
8
+
9
+ The copy features in `pass` and `passpy` copy only the first line of an
10
+ entry. Following the recommended pattern:
11
+
12
+ "This is the preferred organizational scheme used by the author. The
13
+ --clip / -c options will only copy the first line of such a file to the
14
+ clipboard, thereby making it easy to fetch the password for login forms,
15
+ while retaining additional information in the same file."
16
+
17
+ Reference: https://www.passwordstore.org/
18
+ """
19
+
20
+ password: str
21
+ username: str
22
+ url: str
23
+ meta: list[str]
24
+
25
+ _raw_data: str
26
+ _template_ignored: tuple[str, ...] = (
27
+ "_template_ignored",
28
+ "password",
29
+ "_raw_data",
30
+ "meta",
31
+ )
32
+
33
+ def __init__(self, data: str | None) -> None:
34
+ if data is None:
35
+ lines = []
36
+ else:
37
+ lines = data.strip().split("\n")
38
+
39
+ password = lines[0] if lines else ""
40
+ username = ""
41
+ url = ""
42
+ meta = []
43
+
44
+ for line in lines[1:]:
45
+ if line.startswith("Username:"):
46
+ username = line.split(":", 1)[1].strip()
47
+ elif line.startswith("URL:"):
48
+ url = line.split(":", 1)[1].strip()
49
+ else:
50
+ meta.append(line.strip())
51
+
52
+ self.password = password
53
+ self.username = username
54
+ self.url = url
55
+ self.meta = meta
56
+ self._raw_data = data if data is not None else ""
57
+
58
+ def __str__(self) -> str:
59
+ return self._raw_data
60
+
61
+ @classmethod
62
+ def get_new_entry_template(cls) -> tuple[PassModel, str]:
63
+ lines = ["(add your password here)"]
64
+ for field in fields(cls):
65
+ if field.name not in cls._template_ignored:
66
+ lines.append(f"{field.name.capitalize()}: ")
67
+ template = "\n".join(lines)
68
+ return cls(template), template
@@ -0,0 +1,95 @@
1
+ from typing import NamedTuple
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Grid, Horizontal
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Input, Label, Button
9
+
10
+
11
+ class GpgExportData(NamedTuple):
12
+ passphrase: str
13
+ output_path: str | None
14
+
15
+
16
+ class GpgExportModalScreen(ModalScreen[GpgExportData | None]):
17
+ """
18
+ This only works for GPG keys that were created with passtui,
19
+ where we use only one GPG key per store.
20
+ """
21
+
22
+ BINDINGS = [
23
+ Binding("enter", "submit", "Submit", priority=True),
24
+ Binding("escape", "cancel", "Cancel"),
25
+ ]
26
+
27
+ DEFAULT_CSS = """
28
+ GpgExportModalScreen {
29
+ align: center middle;
30
+ }
31
+ GpgExportModalScreen Grid {
32
+ column-span: 2;
33
+ grid-size: 2;
34
+ grid-gutter: 0 2;
35
+ padding: 0 2;
36
+ width: 80;
37
+ height: 11;
38
+ border: thick $background 80%;
39
+ background: $surface;
40
+ }
41
+ GpgExportModalScreen Horizontal {
42
+ column-span: 2;
43
+ border: solid $primary-muted;
44
+ }
45
+ GpgExportModalScreen Horizontal Label {
46
+ margin: 0 1 0 0;
47
+ }
48
+ GpgExportModalScreen Button {
49
+ width: 1fr;
50
+ height: 1fr;
51
+ }
52
+ """
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Grid():
56
+ with Horizontal():
57
+ yield Label("Enter GPG key passphrase:")
58
+ yield Input(
59
+ placeholder="(e.g., password)",
60
+ id="passphrase",
61
+ password=True,
62
+ compact=True,
63
+ )
64
+ with Horizontal():
65
+ yield Label("Output file path (optional):")
66
+ yield Input(
67
+ placeholder="(defaults: ~/passtui/gpg-export.asc)",
68
+ id="output",
69
+ compact=True,
70
+ )
71
+ yield Button("(Enter) Submit", variant="primary", id="submit")
72
+ yield Button("(Esc) Cancel", variant="error", id="cancel")
73
+
74
+ @on(Button.Pressed, "#submit")
75
+ async def handle_button_submit(self) -> None:
76
+ self.action_submit()
77
+
78
+ @on(Button.Pressed, "#cancel")
79
+ async def handle_button_cancel(self) -> None:
80
+ self.action_cancel()
81
+
82
+ def action_submit(self) -> None:
83
+ passphrase = self.query_one("#passphrase", Input).value
84
+ output = self.query_one("#output", Input).value
85
+ if passphrase:
86
+ self.dismiss(
87
+ GpgExportData(
88
+ passphrase=passphrase, output_path=output if output else None
89
+ )
90
+ )
91
+ else:
92
+ self.notify("Passphrase is required", severity="error")
93
+
94
+ def action_cancel(self) -> None:
95
+ self.dismiss(None)
@@ -0,0 +1,96 @@
1
+ from typing import NamedTuple
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Grid, Horizontal
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Input, Label, Button
9
+
10
+
11
+ class GpgKeyData(NamedTuple):
12
+ name: str
13
+ email: str
14
+ path: str | None
15
+
16
+
17
+ class GpgKeygenModalScreen(ModalScreen[GpgKeyData | None]):
18
+ BINDINGS = [
19
+ Binding("enter", "submit", "Submit", priority=True),
20
+ Binding("escape", "cancel", "Cancel"),
21
+ ]
22
+
23
+ DEFAULT_CSS = """
24
+ GpgKeygenModalScreen {
25
+ align: center middle;
26
+ }
27
+ GpgKeygenModalScreen Grid {
28
+ column-span: 2;
29
+ grid-size: 2;
30
+ grid-gutter: 0 2;
31
+ padding: 0 2;
32
+ width: 80;
33
+ height: 14;
34
+ border: thick $background 80%;
35
+ background: $surface;
36
+ }
37
+ GpgKeygenModalScreen Horizontal {
38
+ column-span: 2;
39
+ border: solid $primary-muted;
40
+ }
41
+ GpgKeygenModalScreen Horizontal Label {
42
+ margin: 0 1 0 0;
43
+ }
44
+ GpgKeygenModalScreen Button {
45
+ width: 1fr;
46
+ height: 1fr;
47
+ }
48
+ """
49
+
50
+ def compose(self) -> ComposeResult:
51
+ with Grid():
52
+ with Horizontal():
53
+ yield Label("Enter your name:")
54
+ yield Input(
55
+ placeholder="(e.g., name)",
56
+ id="name",
57
+ compact=True,
58
+ )
59
+ with Horizontal():
60
+ yield Label("Enter your email:")
61
+ yield Input(
62
+ placeholder="(e.g., name@email.com)",
63
+ id="email",
64
+ compact=True,
65
+ )
66
+ with Horizontal():
67
+ yield Label("Password Store path (optional):")
68
+ yield Input(
69
+ placeholder="(e.g., my-passwords/emails/gmail) ",
70
+ id="path",
71
+ compact=True,
72
+ )
73
+ yield Button("(Enter) Submit", variant="primary", id="submit")
74
+ yield Button("(Esc) Cancel", variant="error", id="cancel")
75
+
76
+ @on(Button.Pressed, "#submit")
77
+ async def handle_button_submit(self) -> None:
78
+ self.action_submit()
79
+
80
+ @on(Button.Pressed, "#cancel")
81
+ async def handle_button_cancel(self) -> None:
82
+ self.action_cancel()
83
+
84
+ def action_submit(self) -> None:
85
+ name = self.query_one("#name", Input).value
86
+ email = self.query_one("#email", Input).value
87
+ path = self.query_one("#path", Input).value
88
+ if name and email:
89
+ self.dismiss(
90
+ GpgKeyData(name=name, email=email, path=path if path else None)
91
+ )
92
+ else:
93
+ self.notify("Name and email are required", severity="error")
94
+
95
+ def action_cancel(self) -> None:
96
+ self.dismiss(None)
@@ -0,0 +1,79 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.binding import Binding
4
+ from textual.containers import Grid, Horizontal
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import Input, Label, Button
7
+ from typing import Any
8
+
9
+
10
+ class InputModalScreen(ModalScreen[str | None]):
11
+ BINDINGS = [
12
+ Binding("escape", "cancel", "Cancel"),
13
+ ]
14
+
15
+ DEFAULT_CSS = """
16
+ InputModalScreen {
17
+ align: center middle;
18
+ }
19
+ InputModalScreen Grid {
20
+ column-span: 2;
21
+ grid-size: 2;
22
+ grid-gutter: 1 2;
23
+ grid-rows: 1fr 3;
24
+ padding: 0 2;
25
+ width: 80;
26
+ height: 9;
27
+ border: thick $background 80%;
28
+ background: $surface;
29
+ }
30
+ InputModalScreen Horizontal {
31
+ column-span: 2;
32
+ border: solid $primary-muted;
33
+ }
34
+ InputModalScreen Horizontal Label {
35
+ margin: 0 1 0 0;
36
+ }
37
+ InputModalScreen Button {
38
+ width: 1fr;
39
+ height: 1fr;
40
+ }
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ label: str = "Input",
46
+ placeholder: str = "Enter value",
47
+ *args: Any,
48
+ **kwargs: Any,
49
+ ) -> None:
50
+ super().__init__(*args, **kwargs)
51
+ self._label = label
52
+ self._placeholder = placeholder
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Grid():
56
+ with Horizontal():
57
+ yield Label(self._label + ":")
58
+ yield Input(placeholder=self._placeholder, compact=True)
59
+ yield Button("(Enter) Submit", variant="primary", id="submit")
60
+ yield Button("(Esc) Cancel", variant="error", id="cancel")
61
+
62
+ @on(Button.Pressed, "#submit")
63
+ async def handle_button_submit(self) -> None:
64
+ value = self.query_one(Input).value
65
+ self.submit(value)
66
+
67
+ @on(Button.Pressed, "#cancel")
68
+ async def handle_button_cancel(self) -> None:
69
+ self.action_cancel()
70
+
71
+ @on(Input.Submitted)
72
+ async def on_submit(self, event: Input.Submitted) -> None:
73
+ self.submit(event.value)
74
+
75
+ def submit(self, value: str | None) -> None:
76
+ self.dismiss(value if value else None)
77
+
78
+ def action_cancel(self) -> None:
79
+ self.dismiss(None)
passtui/security.py ADDED
@@ -0,0 +1,170 @@
1
+ import os
2
+ import subprocess
3
+ import secrets
4
+
5
+ import passpy
6
+
7
+ from passpy.gpg import GPG, _get_gpg_recipients, reencrypt_path
8
+ from passpy.git import git_add_path
9
+ from pathlib import Path
10
+ from passtui.models.pass_store import PassModel
11
+
12
+
13
+ class PassCLI:
14
+ def __init__(self):
15
+ self._store = passpy.Store()
16
+ self._gpg = GPG(gpgbinary=self._store.gpg_bin, options=self._store.gpg_opts)
17
+
18
+ def get_store_key(self, key: str) -> PassModel | None:
19
+ key_data = self._store.get_key(key)
20
+ return PassModel(key_data) if key_data else None
21
+
22
+ def save_store_key(self, path: str, pass_model: PassModel) -> None:
23
+ self._store.set_key(path, str(pass_model), force=True)
24
+
25
+ def list_keys(self) -> list[str]:
26
+ if not self._store.is_init():
27
+ return []
28
+ return list(self._store)
29
+
30
+ def is_git_initialized(self) -> bool:
31
+ return self._store.repo is not None
32
+
33
+ def init_git(self, repo_url: str) -> None:
34
+ """
35
+ This method is intended for new stores only.
36
+ Stores that were previously created in the repository will cause an error and will not sync.
37
+ """
38
+ if self.is_git_initialized():
39
+ return
40
+
41
+ self._store.init_git()
42
+ self._store.git("remote", "add", "origin", repo_url)
43
+ self._store.git("push", "origin", "master")
44
+
45
+ def sync_git(self) -> None:
46
+ if not self.is_git_initialized():
47
+ return
48
+
49
+ self._store.git("pull", "--rebase", "origin", "HEAD")
50
+ self._store.git("push", "origin", "HEAD")
51
+
52
+ def create_gpg_store(self, name: str, email: str, path=None) -> str | None:
53
+ """
54
+ This method creates a GPG key and a store in the provided path. If no path is provided,
55
+ it will use the default store path "~/.password-store" or the "PASSWORD_STORE_DIR"
56
+ environment variable.
57
+
58
+ If the path already has a initialired Store, it will configure the new GPG key created and
59
+ then reencrypt all the existing entries.
60
+ """
61
+ if self._store.is_init():
62
+ return
63
+
64
+ input_data = self._gpg.gen_key_input(
65
+ name_real=name,
66
+ name_email=email,
67
+ key_type="RSA",
68
+ key_length=4096,
69
+ expire_date="0",
70
+ )
71
+
72
+ key = self._gpg.gen_key(input_data)
73
+ self._store.init_store(key.fingerprint, path)
74
+ return key.fingerprint if key else None
75
+
76
+ def export_gpg_key(
77
+ self, passphrase: str, output_path: str | None = None
78
+ ) -> str | None:
79
+ """
80
+ This method exports the GPG key associated with the store. The store must be initialised and
81
+ it should have only one GPG key.
82
+ """
83
+ if not self._store.is_init():
84
+ return
85
+
86
+ if output_path is None:
87
+ export_dir = Path.home() / "passtui"
88
+ export_dir.mkdir(exist_ok=True)
89
+ output_path = str(export_dir / "gpg-export.asc")
90
+ else:
91
+ output_path = str(Path(output_path).expanduser())
92
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
93
+
94
+ keys = _get_gpg_recipients(self._store.store_dir)
95
+ keys_data = self._gpg.export_keys(keys, passphrase=passphrase, secret=True)
96
+ if not keys_data:
97
+ return
98
+ with open(output_path, "w") as f:
99
+ f.write(keys_data)
100
+ return output_path
101
+
102
+ def import_gpg_key(self, file_path: str) -> bool:
103
+ """
104
+ Import a GPG key and use it to re-encrypt the password store.
105
+ """
106
+ if not self._store.is_init():
107
+ return False
108
+
109
+ input_path = str(Path(file_path).expanduser())
110
+ results = self._gpg.import_keys_file(input_path)
111
+
112
+ gpg_ids = results.fingerprints
113
+ if gpg_ids is None:
114
+ return False
115
+ if not isinstance(gpg_ids, list):
116
+ gpg_ids = [gpg_ids]
117
+
118
+ gpg_ids = list(dict.fromkeys(gpg_ids))
119
+ current_ids = _get_gpg_recipients(self._store.store_dir)
120
+ key_names = self.list_keys()
121
+ if not key_names:
122
+ return False
123
+
124
+ # Unlock the password store
125
+ try:
126
+ key = self.get_store_key(key_names[0])
127
+ if not key:
128
+ return False
129
+ except Exception:
130
+ return False
131
+
132
+ # Make the new key trusted
133
+ try:
134
+ self._gpg.trust_keys(gpg_ids, "TRUST_ULTIMATE")
135
+ except Exception:
136
+ return False
137
+
138
+ # Update .gpg-id
139
+ all_keys = list(dict.fromkeys(current_ids + gpg_ids))
140
+ gpg_id_path = os.path.join(self._store.store_dir, ".gpg-id")
141
+ with open(gpg_id_path, "w") as gpg_id_file:
142
+ gpg_id_file.write("\n".join(all_keys))
143
+ gpg_id_file.write("\n")
144
+
145
+ git_add_path(
146
+ self._store.repo,
147
+ gpg_id_path,
148
+ f"Set GPG id to {', '.join(all_keys)}. [{secrets.token_hex(4)}]",
149
+ verbose=False,
150
+ )
151
+
152
+ # once the newly imported key is trusted, we need to reencrypt the store.
153
+ # All the fingerprints listed in the in .gpg-id file need to be signed
154
+ reencrypt_path(
155
+ self._store.store_dir,
156
+ gpg_bin=self._store.gpg_bin,
157
+ gpg_opts=self._store.gpg_opts,
158
+ )
159
+
160
+ git_add_path(
161
+ self._store.repo,
162
+ self._store.store_dir,
163
+ f"Reencrypt password store using new GPG id {', '.join(all_keys)}. [{secrets.token_hex(4)}]",
164
+ verbose=False,
165
+ )
166
+
167
+ return results.count > 0
168
+
169
+
170
+ passcli = PassCLI()