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 +0 -0
- passtui/__main__.py +8 -0
- passtui/app.py +147 -0
- passtui/app.tcss +10 -0
- passtui/models/pass_store.py +68 -0
- passtui/screens/gpg_export_modal.py +95 -0
- passtui/screens/gpg_keygen_modal.py +96 -0
- passtui/screens/input_modal.py +79 -0
- passtui/security.py +170 -0
- passtui/utils/clipboard.py +46 -0
- passtui/utils/input.py +11 -0
- passtui/widgets/pass_data.py +186 -0
- passtui/widgets/pass_list.py +166 -0
- passtui/widgets/search.py +61 -0
- passtui-0.0.1.dist-info/METADATA +1026 -0
- passtui-0.0.1.dist-info/RECORD +19 -0
- passtui-0.0.1.dist-info/WHEEL +4 -0
- passtui-0.0.1.dist-info/entry_points.txt +2 -0
- passtui-0.0.1.dist-info/licenses/LICENSE +674 -0
passtui/__init__.py
ADDED
|
File without changes
|
passtui/__main__.py
ADDED
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,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()
|