loz 0.2.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.
- loz/__init__.py +10 -0
- loz/__main__.py +395 -0
- loz/cry.py +130 -0
- loz/exceptions.py +17 -0
- loz/loz.py +422 -0
- loz/models.py +49 -0
- loz-0.2.0.dist-info/METADATA +172 -0
- loz-0.2.0.dist-info/RECORD +13 -0
- loz-0.2.0.dist-info/WHEEL +5 -0
- loz-0.2.0.dist-info/entry_points.txt +2 -0
- loz-0.2.0.dist-info/licenses/AUTHORS +7 -0
- loz-0.2.0.dist-info/licenses/LICENSE +29 -0
- loz-0.2.0.dist-info/top_level.txt +1 -0
loz/__init__.py
ADDED
loz/__main__.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""loz main module."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from getpass import getpass
|
|
7
|
+
from os import path
|
|
8
|
+
from typing import IO
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from click_aliases import ClickAliasedGroup
|
|
12
|
+
from prompt_toolkit import prompt
|
|
13
|
+
|
|
14
|
+
import loz as app
|
|
15
|
+
from loz import loz
|
|
16
|
+
from loz.exceptions import (
|
|
17
|
+
LozFileDoesNotExist,
|
|
18
|
+
LozIncompatibleFileVersion,
|
|
19
|
+
LozInvalidStorage,
|
|
20
|
+
)
|
|
21
|
+
from loz.models import ChangeRecord, StorageDict
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("loz")
|
|
24
|
+
|
|
25
|
+
_DEFAULT_LOZ_FILE = path.expanduser("~/.loz")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Runtime state dataclass (stored in ctx.obj)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class LozState:
|
|
35
|
+
"""Per-invocation CLI runtime state."""
|
|
36
|
+
|
|
37
|
+
loz_file: str = field(default=_DEFAULT_LOZ_FILE)
|
|
38
|
+
check_mode: bool = False
|
|
39
|
+
yes_mode: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# ANSI colour helpers
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class color:
|
|
48
|
+
PURPLE = "\033[95m"
|
|
49
|
+
CYAN = "\033[96m"
|
|
50
|
+
DARKCYAN = "\033[36m"
|
|
51
|
+
BLUE = "\033[94m"
|
|
52
|
+
GREEN = "\033[92m"
|
|
53
|
+
YELLOW = "\033[93m"
|
|
54
|
+
RED = "\033[91m"
|
|
55
|
+
BOLD = "\033[1m"
|
|
56
|
+
UNDERLINE = "\033[4m"
|
|
57
|
+
END = "\033[0m"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# CLI helpers
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_state(ctx: click.Context) -> LozState:
|
|
66
|
+
"""Return the :class:`LozState` attached to *ctx*."""
|
|
67
|
+
return ctx.ensure_object(LozState)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load(state: LozState, warn: bool = True) -> StorageDict:
|
|
71
|
+
"""Load the loz file; print a message and exit 1 on failure."""
|
|
72
|
+
try:
|
|
73
|
+
return loz.load(state.loz_file)
|
|
74
|
+
except LozFileDoesNotExist as e:
|
|
75
|
+
logger.debug(e)
|
|
76
|
+
if warn:
|
|
77
|
+
print("First time use. Run init command.")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
except LozIncompatibleFileVersion as e:
|
|
80
|
+
logger.debug(e)
|
|
81
|
+
if warn:
|
|
82
|
+
print(f"Update loz. {e}")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
except LozInvalidStorage as e:
|
|
85
|
+
logger.debug(e)
|
|
86
|
+
if warn:
|
|
87
|
+
print(
|
|
88
|
+
f"Lozfile is invalid and cannot be loaded. "
|
|
89
|
+
f"Inspect or delete {state.loz_file!r}. Detail: {e}"
|
|
90
|
+
)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _confirm_change(state: LozState, change: ChangeRecord) -> bool:
|
|
95
|
+
if state.check_mode or state.yes_mode:
|
|
96
|
+
return True
|
|
97
|
+
if "-" not in change[0]:
|
|
98
|
+
# non-destructive change
|
|
99
|
+
return True
|
|
100
|
+
answer = input("Is this ok [Y/n]: ")
|
|
101
|
+
if answer.lower() in ["", "y", "yes"]:
|
|
102
|
+
print("Confirmed!")
|
|
103
|
+
return True
|
|
104
|
+
print("Abandoning changes.")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _print_change(change: ChangeRecord) -> None:
|
|
109
|
+
col = color.GREEN
|
|
110
|
+
if "+" in change[0]:
|
|
111
|
+
col = color.YELLOW
|
|
112
|
+
if "-" in change[0]:
|
|
113
|
+
col = color.RED
|
|
114
|
+
line = (
|
|
115
|
+
f"{col}{change[0]} {color.BOLD}{change[1]}/{change[2]}"
|
|
116
|
+
f"{color.END} {col}{change[3]}{color.END}"
|
|
117
|
+
)
|
|
118
|
+
print(line)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _save(state: LozState, storage: StorageDict, change: ChangeRecord) -> None:
|
|
122
|
+
_print_change(change)
|
|
123
|
+
if not state.check_mode:
|
|
124
|
+
if _confirm_change(state, change):
|
|
125
|
+
loz.save(storage, state.loz_file)
|
|
126
|
+
return
|
|
127
|
+
print("Check mode is on, so nothing is really changed.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _enter_password(storage: StorageDict) -> str:
|
|
131
|
+
while True:
|
|
132
|
+
password = getpass("enter password:")
|
|
133
|
+
if loz.validate_key(storage, password):
|
|
134
|
+
return password
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _enter_new_password() -> str:
|
|
138
|
+
password: str | None = None
|
|
139
|
+
while password is None:
|
|
140
|
+
password = getpass("enter new password:")
|
|
141
|
+
if password != getpass("repeat:"):
|
|
142
|
+
print("passwords do not match")
|
|
143
|
+
password = None
|
|
144
|
+
return password
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# CLI group
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@click.group(cls=ClickAliasedGroup)
|
|
153
|
+
@click.version_option(
|
|
154
|
+
version=app.__version__, message=f"%(prog)s %(version)s - {app.__copyright__}"
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"-d",
|
|
158
|
+
"--debug",
|
|
159
|
+
is_flag=True,
|
|
160
|
+
help="Enable debug mode with output of each action in the log.",
|
|
161
|
+
)
|
|
162
|
+
@click.option(
|
|
163
|
+
"-c",
|
|
164
|
+
"--check",
|
|
165
|
+
is_flag=True,
|
|
166
|
+
help="Enable check mode that shows changes but changes nothing.",
|
|
167
|
+
)
|
|
168
|
+
@click.option(
|
|
169
|
+
"-y",
|
|
170
|
+
"--yes",
|
|
171
|
+
is_flag=True,
|
|
172
|
+
help="Answer yes to all questions.",
|
|
173
|
+
)
|
|
174
|
+
@click.option(
|
|
175
|
+
"-f",
|
|
176
|
+
"--file",
|
|
177
|
+
type=str,
|
|
178
|
+
default=_DEFAULT_LOZ_FILE,
|
|
179
|
+
help=f"Specify custom loz file. Defaults to: {_DEFAULT_LOZ_FILE}",
|
|
180
|
+
)
|
|
181
|
+
@click.pass_context
|
|
182
|
+
def cli(ctx: click.Context, debug: bool, check: bool, yes: bool, file: str) -> None:
|
|
183
|
+
ctx.ensure_object(LozState)
|
|
184
|
+
state = _get_state(ctx)
|
|
185
|
+
|
|
186
|
+
# Configure logging once, inside CLI setup.
|
|
187
|
+
if not logging.root.handlers:
|
|
188
|
+
logging.basicConfig()
|
|
189
|
+
level = logging.DEBUG if debug else logging.WARNING
|
|
190
|
+
logging.getLogger("loz").setLevel(level)
|
|
191
|
+
if debug:
|
|
192
|
+
logger.info("debug mode is on")
|
|
193
|
+
|
|
194
|
+
state.check_mode = check
|
|
195
|
+
state.yes_mode = yes
|
|
196
|
+
state.loz_file = file
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Commands
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@cli.command()
|
|
205
|
+
@click.pass_context
|
|
206
|
+
def init(ctx: click.Context) -> None:
|
|
207
|
+
"Initialize .loz file with master password."
|
|
208
|
+
state = _get_state(ctx)
|
|
209
|
+
change: ChangeRecord = ["", "", "", "Initialize lozfile with password."]
|
|
210
|
+
if path.isfile(state.loz_file):
|
|
211
|
+
change = ["+-", "", "", "Overwrite lozfile and reinitialize."]
|
|
212
|
+
password = _enter_new_password()
|
|
213
|
+
storage = loz.init(password)
|
|
214
|
+
_save(state, storage, change)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@cli.command()
|
|
218
|
+
@click.pass_context
|
|
219
|
+
def passwd(ctx: click.Context) -> None:
|
|
220
|
+
"Change password and re-encrypt storage."
|
|
221
|
+
state = _get_state(ctx)
|
|
222
|
+
storage = _load(state)
|
|
223
|
+
_enter_password(storage) # validates key; loads private key into global pto
|
|
224
|
+
new_password = _enter_new_password()
|
|
225
|
+
new_storage = loz.change_password(storage, new_password)
|
|
226
|
+
change: ChangeRecord = ["-+", "", "", "Change master password."]
|
|
227
|
+
_save(state, new_storage, change)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@cli.command(aliases=["set"])
|
|
231
|
+
@click.argument("domain")
|
|
232
|
+
@click.argument("user")
|
|
233
|
+
@click.pass_context
|
|
234
|
+
def add(ctx: click.Context, domain: str, user: str) -> None:
|
|
235
|
+
"Set secret for 'domain user' pair."
|
|
236
|
+
state = _get_state(ctx)
|
|
237
|
+
storage = _load(state)
|
|
238
|
+
secret = prompt(
|
|
239
|
+
"Write multiline secret. Alt+Return to save:\n", default="", multiline=True
|
|
240
|
+
)
|
|
241
|
+
change = loz.store(storage, domain, user, secret)
|
|
242
|
+
_save(state, storage, change)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@cli.command()
|
|
246
|
+
@click.argument("domain")
|
|
247
|
+
@click.argument("user")
|
|
248
|
+
@click.pass_context
|
|
249
|
+
def make(ctx: click.Context, domain: str, user: str) -> None:
|
|
250
|
+
"Make random secret for 'domain user' pair."
|
|
251
|
+
state = _get_state(ctx)
|
|
252
|
+
storage = _load(state)
|
|
253
|
+
change = loz.make(storage, domain, user)
|
|
254
|
+
_save(state, storage, change)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@cli.command()
|
|
258
|
+
@click.argument("domain")
|
|
259
|
+
@click.argument("user")
|
|
260
|
+
@click.pass_context
|
|
261
|
+
def get(ctx: click.Context, domain: str, user: str) -> None:
|
|
262
|
+
"Get secret for 'domain user' pair."
|
|
263
|
+
state = _get_state(ctx)
|
|
264
|
+
storage = _load(state)
|
|
265
|
+
if not loz.exists(storage, domain, user):
|
|
266
|
+
print(f"user not found: {color.BOLD}{domain}/{user}{color.END}")
|
|
267
|
+
sys.exit(1)
|
|
268
|
+
_enter_password(storage) # validates key; loads private key into global pto
|
|
269
|
+
secret = loz.get(storage, domain, user)
|
|
270
|
+
print(secret)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@cli.command(aliases=["del"])
|
|
274
|
+
@click.argument("domain")
|
|
275
|
+
@click.argument("user", required=False, default=None)
|
|
276
|
+
@click.pass_context
|
|
277
|
+
def rm(ctx: click.Context, domain: str, user: str | None) -> None:
|
|
278
|
+
"Delete user from domain or whole domain."
|
|
279
|
+
state = _get_state(ctx)
|
|
280
|
+
storage = _load(state)
|
|
281
|
+
change = loz.rm(storage, domain, user)
|
|
282
|
+
_save(state, storage, change)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@cli.command()
|
|
286
|
+
@click.argument("domain")
|
|
287
|
+
@click.pass_context
|
|
288
|
+
def show(ctx: click.Context, domain: str) -> None:
|
|
289
|
+
"List all secrets in one domain."
|
|
290
|
+
state = _get_state(ctx)
|
|
291
|
+
storage = _load(state)
|
|
292
|
+
if not loz.exists(storage, domain):
|
|
293
|
+
print(f"domain not found: {color.BOLD}{domain}{color.END}")
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
_enter_password(storage) # validates key; loads private key into global pto
|
|
296
|
+
for dom, user, secret in loz.show(storage, domain):
|
|
297
|
+
print(f"\n{color.BOLD}{dom}/{user}{color.END}\n{secret}")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@cli.command()
|
|
301
|
+
@click.argument("domain", required=False, default=None)
|
|
302
|
+
@click.pass_context
|
|
303
|
+
def ls(ctx: click.Context, domain: str | None) -> None:
|
|
304
|
+
"List all storage or users under one domain."
|
|
305
|
+
state = _get_state(ctx)
|
|
306
|
+
storage = _load(state)
|
|
307
|
+
results = loz.ls(storage, domain)
|
|
308
|
+
print("\n".join(results))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@cli.command(aliases=["search"])
|
|
312
|
+
@click.argument("word")
|
|
313
|
+
@click.pass_context
|
|
314
|
+
def find(ctx: click.Context, word: str) -> None:
|
|
315
|
+
"List storage and users that contain the search phrase."
|
|
316
|
+
state = _get_state(ctx)
|
|
317
|
+
storage = _load(state)
|
|
318
|
+
results = loz.find(storage, word)
|
|
319
|
+
for res in results:
|
|
320
|
+
bold = f"{color.BOLD}{res[-1]}{color.END}"
|
|
321
|
+
res[-1] = bold
|
|
322
|
+
print("/".join(res))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@cli.command("import")
|
|
326
|
+
@click.argument("csv_file", type=click.File("r"))
|
|
327
|
+
@click.pass_context
|
|
328
|
+
def import_csv(ctx: click.Context, csv_file: IO[str]) -> None:
|
|
329
|
+
"Import CSV file and merge with existing lozfile."
|
|
330
|
+
state = _get_state(ctx)
|
|
331
|
+
storage = _load(state)
|
|
332
|
+
total_change: ChangeRecord = ["", "", "", "total change"]
|
|
333
|
+
for change in loz.import_csv(storage, csv_file):
|
|
334
|
+
_print_change(change)
|
|
335
|
+
total_change[0] += change[0]
|
|
336
|
+
if not total_change[0]:
|
|
337
|
+
print("\bNo changes detected.")
|
|
338
|
+
return
|
|
339
|
+
_save(state, storage, total_change)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@cli.command("export")
|
|
343
|
+
@click.argument("csv_file", type=click.File("w"), required=False, default=sys.stdout)
|
|
344
|
+
@click.pass_context
|
|
345
|
+
def export_csv(ctx: click.Context, csv_file: IO[str]) -> None:
|
|
346
|
+
"Export plaintext storage and print to output or save to CSV file."
|
|
347
|
+
state = _get_state(ctx)
|
|
348
|
+
storage = _load(state)
|
|
349
|
+
_enter_password(storage) # validates key; loads private key into global pto
|
|
350
|
+
loz.export_csv(storage, csv_file)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@cli.command()
|
|
354
|
+
def bash_completion() -> None:
|
|
355
|
+
"Generate bash completion file contents."
|
|
356
|
+
completion = (
|
|
357
|
+
f"_loz()\n"
|
|
358
|
+
f"{{\n"
|
|
359
|
+
f" local cur prev\n"
|
|
360
|
+
f" COMPREPLY=()\n"
|
|
361
|
+
f' cur="${{COMP_WORDS[COMP_CWORD]}}"\n'
|
|
362
|
+
f' prev="${{COMP_WORDS[COMP_CWORD - 1]}}"\n'
|
|
363
|
+
f" words=\n"
|
|
364
|
+
f"\n"
|
|
365
|
+
f' if [ "$COMP_CWORD" == "1" ]; then\n'
|
|
366
|
+
f' words="{" ".join([n for n, v in cli.commands.items()])}"\n'
|
|
367
|
+
f" fi\n"
|
|
368
|
+
f"\n"
|
|
369
|
+
f' cmd="${{COMP_WORDS[1]}}"\n'
|
|
370
|
+
f' case "$cmd" in\n'
|
|
371
|
+
f" get|set|add|make|rm|del)\n"
|
|
372
|
+
f' case "$COMP_CWORD" in\n'
|
|
373
|
+
f" 2)\n"
|
|
374
|
+
f' words="$(loz ls)"\n'
|
|
375
|
+
f" ;;\n"
|
|
376
|
+
f" 3)\n"
|
|
377
|
+
f' words="$(loz ls $prev)"\n'
|
|
378
|
+
f" ;;\n"
|
|
379
|
+
f" esac\n"
|
|
380
|
+
f" ;;\n"
|
|
381
|
+
f" show|ls)\n"
|
|
382
|
+
f' if [ "$COMP_CWORD" == "2" ]; then\n'
|
|
383
|
+
f' words="$(loz ls)"\n'
|
|
384
|
+
f" fi\n"
|
|
385
|
+
f" ;;\n"
|
|
386
|
+
f" esac\n"
|
|
387
|
+
f' COMPREPLY=( $(compgen -W "${{words}}" -- ${{cur}}) )\n'
|
|
388
|
+
f"}}\n"
|
|
389
|
+
f"complete -F _loz loz\n"
|
|
390
|
+
)
|
|
391
|
+
print(completion)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
if __name__ == "__main__":
|
|
395
|
+
cli()
|
loz/cry.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Cryptographic primitives for loz."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import string
|
|
6
|
+
from base64 import b64decode, b64encode, urlsafe_b64encode
|
|
7
|
+
|
|
8
|
+
from cryptography.fernet import Fernet
|
|
9
|
+
from cryptography.hazmat.backends import default_backend
|
|
10
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
|
13
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
14
|
+
|
|
15
|
+
key_size = 4096
|
|
16
|
+
msg_size = 446
|
|
17
|
+
salt_size = 16
|
|
18
|
+
|
|
19
|
+
_SELECTABLE_PUNCTUATION = "+-=_&?/\\%#@~,."
|
|
20
|
+
_PASSWORD_CHARS = (
|
|
21
|
+
string.ascii_lowercase
|
|
22
|
+
+ string.ascii_uppercase
|
|
23
|
+
+ string.digits
|
|
24
|
+
+ _SELECTABLE_PUNCTUATION
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Pto:
|
|
29
|
+
"""cry.Pto see what I did there?"""
|
|
30
|
+
|
|
31
|
+
private_key: RSAPrivateKey
|
|
32
|
+
public_key: RSAPublicKey
|
|
33
|
+
|
|
34
|
+
def __init__(self, pem: str, password: str | None = None) -> None:
|
|
35
|
+
if password:
|
|
36
|
+
# pem is private key
|
|
37
|
+
password_bytes = password.encode()
|
|
38
|
+
raw = b64decode(pem.encode())
|
|
39
|
+
salt = raw[:salt_size]
|
|
40
|
+
encrypted_pvt = raw[salt_size:]
|
|
41
|
+
fernet_key = Scrypt(salt=salt, length=32, n=2**18, r=8, p=1).derive(
|
|
42
|
+
password_bytes
|
|
43
|
+
)
|
|
44
|
+
fernet = Fernet(urlsafe_b64encode(fernet_key))
|
|
45
|
+
pvt = fernet.decrypt(encrypted_pvt)
|
|
46
|
+
loaded_pvt = serialization.load_pem_private_key(
|
|
47
|
+
data=pvt,
|
|
48
|
+
backend=default_backend(),
|
|
49
|
+
password=None,
|
|
50
|
+
)
|
|
51
|
+
if not isinstance(loaded_pvt, RSAPrivateKey):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"Expected RSAPrivateKey, got {type(loaded_pvt).__name__!r}."
|
|
54
|
+
)
|
|
55
|
+
self.private_key = loaded_pvt
|
|
56
|
+
self.public_key = self.private_key.public_key()
|
|
57
|
+
return
|
|
58
|
+
# pem is public key
|
|
59
|
+
loaded_pub = serialization.load_pem_public_key(
|
|
60
|
+
data=pem.encode(), backend=default_backend()
|
|
61
|
+
)
|
|
62
|
+
if not isinstance(loaded_pub, RSAPublicKey):
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"Expected RSAPublicKey, got {type(loaded_pub).__name__!r}."
|
|
65
|
+
)
|
|
66
|
+
self.public_key = loaded_pub
|
|
67
|
+
|
|
68
|
+
def encrypt(self, message: str) -> str:
|
|
69
|
+
bmsg = message.encode()
|
|
70
|
+
chunks = [bmsg[i : i + msg_size] for i in range(0, len(bmsg), msg_size)]
|
|
71
|
+
ciphertext = bytearray()
|
|
72
|
+
for chunk in chunks:
|
|
73
|
+
ciphertext += self.public_key.encrypt(
|
|
74
|
+
chunk,
|
|
75
|
+
padding.OAEP(
|
|
76
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
77
|
+
algorithm=hashes.SHA256(),
|
|
78
|
+
label=None,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
return b64encode(bytes(ciphertext)).decode("utf-8")
|
|
82
|
+
|
|
83
|
+
def decrypt(self, ciphertext: str) -> str:
|
|
84
|
+
bciph = b64decode(ciphertext.encode())
|
|
85
|
+
block = key_size // 8
|
|
86
|
+
chunks = [bciph[i : i + block] for i in range(0, len(bciph), block)]
|
|
87
|
+
message = bytearray()
|
|
88
|
+
for chunk in chunks:
|
|
89
|
+
message += self.private_key.decrypt(
|
|
90
|
+
chunk,
|
|
91
|
+
padding.OAEP(
|
|
92
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
93
|
+
algorithm=hashes.SHA256(),
|
|
94
|
+
label=None,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return bytes(message).decode("utf-8")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def generate_key_pair(password: str) -> tuple[str, str]:
|
|
101
|
+
"""Generate an RSA key pair protected by *password*.
|
|
102
|
+
|
|
103
|
+
Returns ``(serialized_pvt, serialized_pub)`` -- both base64/PEM strings.
|
|
104
|
+
"""
|
|
105
|
+
password_bytes = password.encode()
|
|
106
|
+
private_key = rsa.generate_private_key(
|
|
107
|
+
public_exponent=65537, key_size=key_size, backend=default_backend()
|
|
108
|
+
)
|
|
109
|
+
public_key = private_key.public_key()
|
|
110
|
+
pem_pvt = private_key.private_bytes(
|
|
111
|
+
encoding=serialization.Encoding.PEM,
|
|
112
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
113
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
114
|
+
)
|
|
115
|
+
pem_pub = public_key.public_bytes(
|
|
116
|
+
encoding=serialization.Encoding.PEM,
|
|
117
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
118
|
+
)
|
|
119
|
+
salt = os.urandom(salt_size)
|
|
120
|
+
fernet_key = Scrypt(salt=salt, length=32, n=2**18, r=8, p=1).derive(password_bytes)
|
|
121
|
+
fernet = Fernet(urlsafe_b64encode(fernet_key))
|
|
122
|
+
encrypted_pvt = fernet.encrypt(pem_pvt)
|
|
123
|
+
serialized_pvt = b64encode(salt + encrypted_pvt).decode("utf-8")
|
|
124
|
+
serialized_pub = pem_pub.decode("utf-8")
|
|
125
|
+
return (serialized_pvt, serialized_pub)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def make_password(length: int = 16) -> str:
|
|
129
|
+
"""Generate a cryptographically secure random password of *length* characters."""
|
|
130
|
+
return "".join(secrets.choice(_PASSWORD_CHARS) for _ in range(length))
|
loz/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""loz custom exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LozException(Exception):
|
|
5
|
+
"""Generic loz exception."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LozIncompatibleFileVersion(LozException):
|
|
9
|
+
"""Current loz version does not support existing lozfile version."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LozFileDoesNotExist(LozException):
|
|
13
|
+
"""Lozfile not found."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LozInvalidStorage(LozException):
|
|
17
|
+
"""Lozfile exists but contains malformed JSON or an invalid storage schema."""
|
loz/loz.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""loz core functions."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Generator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO, cast
|
|
12
|
+
|
|
13
|
+
from cryptography.fernet import InvalidToken
|
|
14
|
+
from packaging.version import InvalidVersion, Version
|
|
15
|
+
|
|
16
|
+
from loz import cry
|
|
17
|
+
from loz.exceptions import (
|
|
18
|
+
LozFileDoesNotExist,
|
|
19
|
+
LozIncompatibleFileVersion,
|
|
20
|
+
LozInvalidStorage,
|
|
21
|
+
)
|
|
22
|
+
from loz.models import ChangeRecord, DataSection, EntryRecord, StorageDict
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("loz")
|
|
25
|
+
storage_version = "1.0"
|
|
26
|
+
|
|
27
|
+
# Module-level Pto instance; set after load() or validate_key().
|
|
28
|
+
pto: cry.Pto | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Internal helpers
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require_pto() -> cry.Pto:
|
|
37
|
+
"""Return the current unlocked :class:`cry.Pto` or raise *RuntimeError*.
|
|
38
|
+
|
|
39
|
+
Use this instead of bare ``assert pto is not None`` in production paths.
|
|
40
|
+
"""
|
|
41
|
+
if pto is None:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
"Crypto state not initialised; call load() then validate_key() first."
|
|
44
|
+
)
|
|
45
|
+
return pto
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate_storage_schema(raw: object) -> StorageDict:
|
|
49
|
+
"""Validate *raw* against the expected storage schema.
|
|
50
|
+
|
|
51
|
+
Raises :class:`~loz.exceptions.LozInvalidStorage` with a descriptive
|
|
52
|
+
message if any structural requirement is violated. Returns the coerced
|
|
53
|
+
:class:`~loz.models.StorageDict` on success.
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(raw, dict):
|
|
56
|
+
raise LozInvalidStorage("Storage root must be a JSON object.")
|
|
57
|
+
|
|
58
|
+
for required_key in ("version", "pvt", "pub", "data"):
|
|
59
|
+
if required_key not in raw:
|
|
60
|
+
raise LozInvalidStorage(
|
|
61
|
+
f"Storage is missing required key: '{required_key}'."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if not isinstance(raw["version"], str):
|
|
65
|
+
got = type(raw["version"]).__name__
|
|
66
|
+
raise LozInvalidStorage(f"Storage 'version' must be a string, got {got!r}.")
|
|
67
|
+
if not isinstance(raw["pvt"], str):
|
|
68
|
+
raise LozInvalidStorage(
|
|
69
|
+
f"Storage 'pvt' must be a string, got {type(raw['pvt']).__name__!r}."
|
|
70
|
+
)
|
|
71
|
+
if not isinstance(raw["pub"], str):
|
|
72
|
+
raise LozInvalidStorage(
|
|
73
|
+
f"Storage 'pub' must be a string, got {type(raw['pub']).__name__!r}."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
data = raw["data"]
|
|
77
|
+
if not isinstance(data, dict):
|
|
78
|
+
raise LozInvalidStorage(
|
|
79
|
+
f"Storage 'data' must be a JSON object, got {type(data).__name__!r}."
|
|
80
|
+
)
|
|
81
|
+
for domain_key, domain_val in data.items():
|
|
82
|
+
if not isinstance(domain_key, str):
|
|
83
|
+
raise LozInvalidStorage(
|
|
84
|
+
f"Storage 'data' domain key must be a string, got {domain_key!r}."
|
|
85
|
+
)
|
|
86
|
+
if not isinstance(domain_val, dict):
|
|
87
|
+
raise LozInvalidStorage(
|
|
88
|
+
f"Storage 'data[{domain_key!r}]' must be a JSON object, "
|
|
89
|
+
f"got {type(domain_val).__name__!r}."
|
|
90
|
+
)
|
|
91
|
+
for user_key, ciphertext in domain_val.items():
|
|
92
|
+
if not isinstance(user_key, str):
|
|
93
|
+
raise LozInvalidStorage(
|
|
94
|
+
f"Storage 'data[{domain_key!r}]' user key must be a string, "
|
|
95
|
+
f"got {user_key!r}."
|
|
96
|
+
)
|
|
97
|
+
if not isinstance(ciphertext, str):
|
|
98
|
+
raise LozInvalidStorage(
|
|
99
|
+
f"Storage 'data[{domain_key!r}][{user_key!r}]' ciphertext "
|
|
100
|
+
f"must be a string, got {type(ciphertext).__name__!r}."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Safe cast - structure has been verified above.
|
|
104
|
+
return cast(StorageDict, raw)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Public API
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load(loz_file: str | Path) -> StorageDict:
|
|
113
|
+
"""Load a .loz file and return the validated storage dict.
|
|
114
|
+
|
|
115
|
+
Raises
|
|
116
|
+
------
|
|
117
|
+
LozFileDoesNotExist
|
|
118
|
+
When *loz_file* does not exist.
|
|
119
|
+
LozInvalidStorage
|
|
120
|
+
When the file is not valid JSON or does not match the storage schema.
|
|
121
|
+
LozIncompatibleFileVersion
|
|
122
|
+
When the file was created by a newer version of loz.
|
|
123
|
+
"""
|
|
124
|
+
global pto
|
|
125
|
+
loz_path = Path(loz_file)
|
|
126
|
+
if not loz_path.is_file():
|
|
127
|
+
raise LozFileDoesNotExist(f"No loz file found at {loz_file}.")
|
|
128
|
+
logger.debug("opening %s", loz_file)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
raw: object = json.loads(loz_path.read_text(encoding="utf-8"))
|
|
132
|
+
except json.JSONDecodeError as exc:
|
|
133
|
+
raise LozInvalidStorage(
|
|
134
|
+
f"Lozfile at {loz_file} contains invalid JSON: {exc}"
|
|
135
|
+
) from exc
|
|
136
|
+
|
|
137
|
+
storage = _validate_storage_schema(raw)
|
|
138
|
+
logger.debug("loaded storage from %s", loz_file)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
file_version = Version(storage["version"])
|
|
142
|
+
app_version = Version(storage_version)
|
|
143
|
+
except InvalidVersion as exc:
|
|
144
|
+
raise LozInvalidStorage(
|
|
145
|
+
f"Lozfile version value {storage['version']!r} is not a valid version "
|
|
146
|
+
f"string: {exc}"
|
|
147
|
+
) from exc
|
|
148
|
+
|
|
149
|
+
if file_version > app_version:
|
|
150
|
+
raise LozIncompatibleFileVersion(
|
|
151
|
+
f"Found lozfile version {storage['version']} but "
|
|
152
|
+
f"supports up to {storage_version}."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
pto = cry.Pto(storage["pub"])
|
|
157
|
+
except (TypeError, ValueError) as exc:
|
|
158
|
+
raise LozInvalidStorage(
|
|
159
|
+
"Lozfile contains an invalid public encryption key."
|
|
160
|
+
) from exc
|
|
161
|
+
return storage
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _atomic_write(loz_path: Path, text: str) -> None:
|
|
165
|
+
"""Write *text* to *loz_path* atomically on POSIX systems.
|
|
166
|
+
|
|
167
|
+
The write goes to a sibling temporary file which is fsynced and then
|
|
168
|
+
renamed into place, so the original file is never truncated if the write
|
|
169
|
+
fails. Permissions are set to 0o600 before the rename.
|
|
170
|
+
"""
|
|
171
|
+
dir_path = loz_path.parent
|
|
172
|
+
fd, tmp_path_str = tempfile.mkstemp(dir=dir_path, suffix=".loz.tmp")
|
|
173
|
+
tmp_path = Path(tmp_path_str)
|
|
174
|
+
try:
|
|
175
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
176
|
+
fh.write(text)
|
|
177
|
+
fh.flush()
|
|
178
|
+
os.fsync(fh.fileno())
|
|
179
|
+
tmp_path.chmod(0o600)
|
|
180
|
+
tmp_path.rename(loz_path)
|
|
181
|
+
except Exception:
|
|
182
|
+
# Clean up the temp file on any failure; suppress secondary errors.
|
|
183
|
+
try:
|
|
184
|
+
tmp_path.unlink(missing_ok=True)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def save(storage: StorageDict, loz_file: str | Path) -> None:
|
|
191
|
+
"""Save *storage* as a pretty-printed UTF-8 JSON .loz file.
|
|
192
|
+
|
|
193
|
+
The write is atomic on POSIX: the existing file is never truncated if
|
|
194
|
+
writing fails. The resulting file has permissions 0600.
|
|
195
|
+
"""
|
|
196
|
+
loz_path = Path(loz_file)
|
|
197
|
+
logger.debug("opening %s", loz_file)
|
|
198
|
+
storage["version"] = storage_version
|
|
199
|
+
_atomic_write(loz_path, json.dumps(storage, indent=4))
|
|
200
|
+
logger.debug("storage written to %s", loz_file)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate_key(storage: StorageDict, password: str | None = None) -> bool:
|
|
204
|
+
"""Try to unlock storage with *password*; store key globally for later use.
|
|
205
|
+
|
|
206
|
+
Only expected crypto / password-decoding failures return ``False``.
|
|
207
|
+
Unexpected errors (e.g. programming mistakes, schema bugs) are re-raised.
|
|
208
|
+
A failed validation clears the global *pto* so a prior successful unlock
|
|
209
|
+
cannot be reused.
|
|
210
|
+
"""
|
|
211
|
+
global pto
|
|
212
|
+
# Expected crypto failure types from the cryptography library.
|
|
213
|
+
_CRYPTO_ERRORS = (
|
|
214
|
+
InvalidToken,
|
|
215
|
+
ValueError,
|
|
216
|
+
TypeError,
|
|
217
|
+
UnicodeDecodeError,
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
logger.debug("validating password")
|
|
221
|
+
pto = cry.Pto(storage["pvt"], password)
|
|
222
|
+
return True
|
|
223
|
+
except _CRYPTO_ERRORS:
|
|
224
|
+
logger.debug("password validation failed (expected crypto error)")
|
|
225
|
+
pto = None
|
|
226
|
+
return False
|
|
227
|
+
except Exception:
|
|
228
|
+
# Unexpected error - clear state then re-raise.
|
|
229
|
+
pto = None
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def init(password: str) -> StorageDict:
|
|
234
|
+
"""Initialize a new storage dict protected by *password*."""
|
|
235
|
+
pvt, pub = cry.generate_key_pair(password)
|
|
236
|
+
storage: StorageDict = {
|
|
237
|
+
"version": storage_version,
|
|
238
|
+
"pvt": pvt,
|
|
239
|
+
"pub": pub,
|
|
240
|
+
"data": {},
|
|
241
|
+
}
|
|
242
|
+
return storage
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def export_csv(storage: StorageDict, csv_file: IO[str] | None = None) -> str | None:
|
|
246
|
+
"""Export plaintext storage to *csv_file* or return contents as a string."""
|
|
247
|
+
buf: io.StringIO | None = None
|
|
248
|
+
if csv_file is None:
|
|
249
|
+
buf = io.StringIO()
|
|
250
|
+
csv_file = buf
|
|
251
|
+
writer = csv.writer(
|
|
252
|
+
csv_file,
|
|
253
|
+
delimiter=" ",
|
|
254
|
+
quotechar="|",
|
|
255
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
256
|
+
lineterminator="\n",
|
|
257
|
+
)
|
|
258
|
+
for row in get_all(storage):
|
|
259
|
+
writer.writerow(row)
|
|
260
|
+
if buf is not None:
|
|
261
|
+
return buf.getvalue()
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def import_csv(
|
|
266
|
+
storage: StorageDict, csv_file: IO[str]
|
|
267
|
+
) -> Generator[ChangeRecord, None, None]:
|
|
268
|
+
"""Import rows from *csv_file* into *storage*, yielding each change list.
|
|
269
|
+
|
|
270
|
+
All rows are pre-validated before any mutations are applied. If any row
|
|
271
|
+
does not have exactly three fields a :class:`ValueError` is raised with the
|
|
272
|
+
1-based row number and no changes are made to *storage*.
|
|
273
|
+
"""
|
|
274
|
+
reader = csv.reader(
|
|
275
|
+
csv_file,
|
|
276
|
+
delimiter=" ",
|
|
277
|
+
quotechar="|",
|
|
278
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
279
|
+
lineterminator="\n",
|
|
280
|
+
)
|
|
281
|
+
rows = list(reader)
|
|
282
|
+
for i, row in enumerate(rows, start=1):
|
|
283
|
+
if len(row) != 3:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"import_csv: row {i} has {len(row)} field(s); expected exactly 3."
|
|
286
|
+
)
|
|
287
|
+
# All rows validated - now mutate.
|
|
288
|
+
for row in rows:
|
|
289
|
+
domain, user, secret = row[0], row[1], row[2]
|
|
290
|
+
change = store(storage, domain, user, secret)
|
|
291
|
+
yield change
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def change_password(storage: StorageDict, new_pass: str) -> StorageDict:
|
|
295
|
+
"""Re-encrypt all secrets under *new_pass* and return the new storage dict."""
|
|
296
|
+
pvt, pub = cry.generate_key_pair(new_pass)
|
|
297
|
+
new_storage: StorageDict = {
|
|
298
|
+
"version": storage_version,
|
|
299
|
+
"pvt": pvt,
|
|
300
|
+
"pub": pub,
|
|
301
|
+
"data": {},
|
|
302
|
+
}
|
|
303
|
+
new_pto = cry.Pto(new_storage["pvt"], new_pass)
|
|
304
|
+
for domain, user, secret in get_all(storage):
|
|
305
|
+
logger.debug("moving %s/%s to new storage", domain, user)
|
|
306
|
+
encrypted = new_pto.encrypt(secret)
|
|
307
|
+
domain_data: DataSection = new_storage["data"]
|
|
308
|
+
if domain in domain_data:
|
|
309
|
+
domain_data[domain][user] = encrypted
|
|
310
|
+
else:
|
|
311
|
+
domain_data[domain] = {user: encrypted}
|
|
312
|
+
return new_storage
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def store(storage: StorageDict, domain: str, user: str, secret: str) -> ChangeRecord:
|
|
316
|
+
"""Store *secret* for *domain*/*user*.
|
|
317
|
+
|
|
318
|
+
Returns ``[change_type, domain, user, message]``.
|
|
319
|
+
"""
|
|
320
|
+
active_pto = _require_pto()
|
|
321
|
+
encrypted = active_pto.encrypt(secret)
|
|
322
|
+
if exists(storage, domain, user): # update existing
|
|
323
|
+
logger.debug("updating secret for %s/%s", domain, user)
|
|
324
|
+
storage["data"][domain][user] = encrypted
|
|
325
|
+
return ["-+", domain, user, "Update existing secret."]
|
|
326
|
+
if exists(storage, domain): # add new user
|
|
327
|
+
logger.debug("adding user %s with secret for %s", user, domain)
|
|
328
|
+
storage["data"][domain][user] = encrypted
|
|
329
|
+
return ["+", domain, user, "Add user and secret."]
|
|
330
|
+
# add user and domain
|
|
331
|
+
logger.debug("adding domain %s and user %s with secret", domain, user)
|
|
332
|
+
storage["data"][domain] = {user: encrypted}
|
|
333
|
+
return ["+", domain, user, "Add domain, user and secret."]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def make(storage: StorageDict, domain: str, user: str) -> ChangeRecord:
|
|
337
|
+
"""Generate a random secret for *domain*/*user* and print it; return change list."""
|
|
338
|
+
secret = cry.make_password()
|
|
339
|
+
print(secret)
|
|
340
|
+
return store(storage, domain, user, secret)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def exists(storage: StorageDict, domain: str, user: str | None = None) -> bool:
|
|
344
|
+
"""Return True if *domain* (and optionally *user*) exists in storage."""
|
|
345
|
+
if domain not in storage["data"]:
|
|
346
|
+
logger.debug("domain %s does not exist", domain)
|
|
347
|
+
return False
|
|
348
|
+
if user is None:
|
|
349
|
+
logger.debug("domain %s exists", domain)
|
|
350
|
+
return True
|
|
351
|
+
if user in storage["data"][domain]:
|
|
352
|
+
logger.debug("user %s exists in domain %s", user, domain)
|
|
353
|
+
return True
|
|
354
|
+
logger.debug("user %s does not exist in domain %s", user, domain)
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get(storage: StorageDict, domain: str, user: str) -> str | None:
|
|
359
|
+
"""Return the decrypted secret for *domain*/*user*, or None if not found."""
|
|
360
|
+
active_pto = _require_pto()
|
|
361
|
+
if not exists(storage, domain, user):
|
|
362
|
+
logger.warning("%s/%s does not exist", domain, user)
|
|
363
|
+
return None
|
|
364
|
+
secret = storage["data"][domain][user]
|
|
365
|
+
return active_pto.decrypt(secret)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def get_all(storage: StorageDict) -> Generator[EntryRecord, None, None]:
|
|
369
|
+
"""Yield ``[domain, user, secret]`` for every entry in storage."""
|
|
370
|
+
for domain in storage["data"]:
|
|
371
|
+
for user in storage["data"][domain]:
|
|
372
|
+
secret = get(storage, domain, user)
|
|
373
|
+
if secret is not None:
|
|
374
|
+
yield [domain, user, secret]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def rm(storage: StorageDict, domain: str, user: str | None = None) -> ChangeRecord:
|
|
378
|
+
"""Delete *user* from *domain* (or the whole *domain*); return change list."""
|
|
379
|
+
if not exists(storage, domain):
|
|
380
|
+
return ["|", "", "", "No change."]
|
|
381
|
+
if user is not None and not exists(storage, domain, user):
|
|
382
|
+
return ["|", "", "", "No change."]
|
|
383
|
+
if user is not None:
|
|
384
|
+
del storage["data"][domain][user]
|
|
385
|
+
if not storage["data"][domain]:
|
|
386
|
+
del storage["data"][domain]
|
|
387
|
+
return ["-", domain, "*", "Delete whole domain."]
|
|
388
|
+
return ["-", domain, user, "Delete user."]
|
|
389
|
+
del storage["data"][domain]
|
|
390
|
+
return ["-", domain, "*", "Delete whole domain."]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def show(storage: StorageDict, domain: str) -> Generator[EntryRecord, None, None]:
|
|
394
|
+
"""Yield ``[domain, user, secret]`` for every user in *domain*."""
|
|
395
|
+
if not exists(storage, domain):
|
|
396
|
+
return
|
|
397
|
+
for user in ls(storage, domain):
|
|
398
|
+
logger.debug("user found, decrypting secret")
|
|
399
|
+
decrypted = get(storage, domain, user)
|
|
400
|
+
if decrypted is not None:
|
|
401
|
+
yield [domain, user, decrypted]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def ls(storage: StorageDict, domain: str | None = None) -> list[str]:
|
|
405
|
+
"""Return list of domains, or list of users within *domain*."""
|
|
406
|
+
if domain is None:
|
|
407
|
+
return list(storage["data"].keys())
|
|
408
|
+
if not exists(storage, domain):
|
|
409
|
+
return []
|
|
410
|
+
return list(storage["data"][domain].keys())
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def find(storage: StorageDict, word: str) -> list[list[str]]:
|
|
414
|
+
"""Return list of ``[domain]`` or ``[domain, user]`` entries matching *word*."""
|
|
415
|
+
results: list[list[str]] = []
|
|
416
|
+
for domain in storage["data"]:
|
|
417
|
+
if word in domain:
|
|
418
|
+
results.append([domain])
|
|
419
|
+
for user in storage["data"][domain]:
|
|
420
|
+
if word in user:
|
|
421
|
+
results.append([domain, user])
|
|
422
|
+
return results
|
loz/models.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Typed data-model definitions for loz storage and change/entry records.
|
|
2
|
+
|
|
3
|
+
All persisted JSON must conform to :class:`StorageDict`.
|
|
4
|
+
Change records returned by mutating functions are :data:`ChangeRecord`.
|
|
5
|
+
Entry records yielded by read functions are :data:`EntryRecord`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TypeAlias, TypedDict
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Nested types
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
# Mapping of username to base64-encoded ciphertext.
|
|
17
|
+
DomainData: TypeAlias = "dict[str, str]"
|
|
18
|
+
|
|
19
|
+
# Allow arbitrary string keys for domains.
|
|
20
|
+
DataSection: TypeAlias = "dict[str, DomainData]"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Top-level storage
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StorageDict(TypedDict):
|
|
29
|
+
"""The complete in-memory / on-disk representation of a loz storage file.
|
|
30
|
+
|
|
31
|
+
All four keys are required and must be present in every valid lozfile.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
version: str
|
|
35
|
+
pvt: str
|
|
36
|
+
pub: str
|
|
37
|
+
data: DataSection
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Functional records
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
# ``[change_type, domain, user, message]``
|
|
45
|
+
# e.g. ["+", "example.com", "alice", "Add domain, user and secret."]
|
|
46
|
+
ChangeRecord: TypeAlias = "list[str]"
|
|
47
|
+
|
|
48
|
+
# ``[domain, user, secret]``
|
|
49
|
+
EntryRecord: TypeAlias = "list[str]"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loz
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: loz - password manager
|
|
5
|
+
Author-email: Predrag Mandic <predrag@nul.one>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://gitlab.nul.one/mush/loz
|
|
8
|
+
Project-URL: Source Code, https://gitlab.nul.one/mush/loz
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Information Technology
|
|
12
|
+
Classifier: Natural Language :: English
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
License-File: AUTHORS
|
|
24
|
+
Requires-Dist: click>=8.1
|
|
25
|
+
Requires-Dist: click-aliases>=1.0.1
|
|
26
|
+
Requires-Dist: cryptography>=38.0
|
|
27
|
+
Requires-Dist: packaging>=23.0
|
|
28
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: coverage[toml]>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.5; extra == "dev"
|
|
32
|
+
Requires-Dist: pre-commit>=3.5; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
36
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
loz
|
|
42
|
+
==================================================
|
|
43
|
+
Command line password manager.
|
|
44
|
+
|
|
45
|
+
Why?
|
|
46
|
+
-------------------------
|
|
47
|
+
You may ask why is there a need for another open source password manager. Here are some features that most currently available password managers lack and that loz is focusing on:
|
|
48
|
+
|
|
49
|
+
- **Designed for command line.** Loz allows you to perform all tasks directly on the command line.
|
|
50
|
+
- You can pipe the secrets out of it directly to files.
|
|
51
|
+
- Bash completion is available for all commands and even domains/usernames.
|
|
52
|
+
- Commands are entered directly without loading any custom loz console.
|
|
53
|
+
|
|
54
|
+
- **Asymmetric encryption.** Need to quickly add new entry or generate a password? No problem. With asymmetric encryption, you don't need to type your master password each time you add new entry. It even makes no sense to have to do that, right? You already know what you are entering.
|
|
55
|
+
|
|
56
|
+
- **Single file storage.** [Lozfile](#lozfile-and-encryption) contains all entries, secrets and encryption keys in a simple json format. You can just copy it over to another machine and continue where you left off.
|
|
57
|
+
|
|
58
|
+
Requirements
|
|
59
|
+
-------------------------
|
|
60
|
+
- Python >= 3.10
|
|
61
|
+
|
|
62
|
+
Install
|
|
63
|
+
-------------------------
|
|
64
|
+
```
|
|
65
|
+
pip install loz
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Setup bash completion
|
|
69
|
+
Add the following to your `.bashrc` or `.profile`:
|
|
70
|
+
```bash
|
|
71
|
+
eval "$(loz bash-completion)"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Alternatively generate a bash completion file in a directory specific for your system,
|
|
75
|
+
e.g. for Debian, Ubuntu and Fedora:
|
|
76
|
+
```bash
|
|
77
|
+
loz bash-completion | sudo tee /etc/bash_completion.d/loz
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Usage
|
|
81
|
+
-------------------------
|
|
82
|
+
|
|
83
|
+
### Initialize lozfile
|
|
84
|
+
|
|
85
|
+
`loz init` - This is required first step that will generate storage file at default location `~/.loz`. If you want to use a different location or separate storage file, you can specify that with `loz -f path/to/lozfile [COMMAND] [OPTIONS]`, but then you always need to include that switch when using other commands.
|
|
86
|
+
|
|
87
|
+
You will be asked to enter the master password which will be required for decrypting your secrets. Read more about the lozfile, what it contains, what is being encrypting and what it's always revealing in the [lozfile section](#lozfile-and-encryption) of this doc.
|
|
88
|
+
|
|
89
|
+
### Add secret
|
|
90
|
+
|
|
91
|
+
`loz add mydomain.com username@email.com` - After this you will be prompted to enter your secret in a multi-line prompt. Press **Alt-Return** to save.
|
|
92
|
+
|
|
93
|
+
If you want loz to generate a secret for you, just use `loz make mydomain.com username@email.com` instead. It will generate and save a new secret and print it out for you.
|
|
94
|
+
|
|
95
|
+
### Read secret
|
|
96
|
+
|
|
97
|
+
`loz get mydomain.com username@email.com` - After this you will be prompted for the master password. Note that you can autocomplete name of domains and usernames by pressing **Tab**.
|
|
98
|
+
|
|
99
|
+
`loz show mydomain.com` will print all secrets for all usernames under selected domain.
|
|
100
|
+
|
|
101
|
+
### List entries
|
|
102
|
+
|
|
103
|
+
`loz ls` will list domains and `loz ls mydomain.com` will list usernames under selected domain.
|
|
104
|
+
|
|
105
|
+
`loz find searchword` will list all domains and/or usernames that contain a word `searchword`.
|
|
106
|
+
|
|
107
|
+
### Delete an entry
|
|
108
|
+
|
|
109
|
+
`loz rm mydomain.com username@email.com` will delete selected username. If that's the only username under selected domain, the whole domain will be removed. If you want to delete a domain with all usernames under it, just type `loz rm mydomain.com`.
|
|
110
|
+
|
|
111
|
+
### Export/Import
|
|
112
|
+
|
|
113
|
+
`loz export > backup.csv` will export all secrets in a plain text csv file. You can import a backed up file with `loz import backup.csv`. It will ask you if you want to overwrite existing entries.
|
|
114
|
+
|
|
115
|
+
### Change password
|
|
116
|
+
|
|
117
|
+
`loz passwd` will prompt you for old and new password. It will then generate new keys and re-encrypt all secrets.
|
|
118
|
+
|
|
119
|
+
Development
|
|
120
|
+
-------------------------
|
|
121
|
+
|
|
122
|
+
Clone the repository and create a virtual environment:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone git@gitlab.nul.one:mush/loz.git
|
|
126
|
+
cd loz
|
|
127
|
+
python -m venv .venv
|
|
128
|
+
source .venv/bin/activate
|
|
129
|
+
pip install -e ".[dev]"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Running checks
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Lint
|
|
136
|
+
ruff check loz/ tests/
|
|
137
|
+
|
|
138
|
+
# Format check
|
|
139
|
+
ruff format --check loz/ tests/
|
|
140
|
+
|
|
141
|
+
# Type check
|
|
142
|
+
mypy loz/
|
|
143
|
+
|
|
144
|
+
# Tests with coverage
|
|
145
|
+
pytest
|
|
146
|
+
|
|
147
|
+
# Build and verify distribution
|
|
148
|
+
python -m build
|
|
149
|
+
twine check dist/*
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Pre-commit hooks
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pre-commit install
|
|
156
|
+
pre-commit run --all-files
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Lozfile and encryption
|
|
160
|
+
-------------------------
|
|
161
|
+
|
|
162
|
+
Lozfile is the json storage of all secrets. It contains plaintext RSA public key and Fernet (password) encrypted private key as well. Public key is used in the background when you are entering or generating new secrets. Private key is useless without your master password, but weak password means weak encryption.
|
|
163
|
+
|
|
164
|
+
All secrets are encrypted with RSA 4096. This is slow for encryption and decryption, but allows the asymmetric approach and doesn't have a big performance impact on short secrets.
|
|
165
|
+
|
|
166
|
+
Password generation uses `secrets.choice` (the Python standard-library CSPRNG) to ensure cryptographic strength.
|
|
167
|
+
|
|
168
|
+
### Compatibility
|
|
169
|
+
Backwards compatibility will be guaranteed for all lozfiles created with loz versions 0.1.0 and above. If there is a change in lozfile format, you may be prompted to update your lozfile.
|
|
170
|
+
|
|
171
|
+
### WARNING
|
|
172
|
+
Lozfile is revealing domains and usernames. This is by design to enable autocomplete. Future versions of loz may offer full encryption option, but for now it's not available and it's not a focus. If you are concerned about privacy of your usernames, then loz is not for you!
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
loz/__init__.py,sha256=JEOdGdiSnZLcJs30unDEEb5n5Bj7lBjWt-JGAlgA7io,238
|
|
2
|
+
loz/__main__.py,sha256=q9DugFX9cAsUsO-EtANsFJYNER7Exax0ku0y7DMQKF4,11682
|
|
3
|
+
loz/cry.py,sha256=-pog2zDgLPnLEzNspr_us1xvys_cdfnlGWRpWKe_I1U,4716
|
|
4
|
+
loz/exceptions.py,sha256=WUyFLFifTuJoZKaK5oA-sZugxfLTjGaaufKDM-fPeaY,414
|
|
5
|
+
loz/loz.py,sha256=Z_wTuUwE1RoxsmqgF6-PB-S47jeb73pWAUCOMkhd0cc,14830
|
|
6
|
+
loz/models.py,sha256=bETDgdS9xPGYeqvC1Nm-aXJ2g4XhWG3zTHf4AHSsKsY,1545
|
|
7
|
+
loz-0.2.0.dist-info/licenses/AUTHORS,sha256=zYlcvpx-TdX50K8-F7z5HkIeHfehk_zJpytmC5QKLjg,313
|
|
8
|
+
loz-0.2.0.dist-info/licenses/LICENSE,sha256=MQJ5gH7ejduo6MrbpzAxVVJGbiaWDmNDl_rCMl0f_ts,1521
|
|
9
|
+
loz-0.2.0.dist-info/METADATA,sha256=-gJDTfyRRbGeeOqFgAvJW6NeYYN1p6y-tbngRvly6I4,6803
|
|
10
|
+
loz-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
loz-0.2.0.dist-info/entry_points.txt,sha256=yMW7Lo7d4Y6wnii6ghnWJLc_YMAEJauPXXZFHae5TNE,41
|
|
12
|
+
loz-0.2.0.dist-info/top_level.txt,sha256=azMmi87gQ_2PGJ5YvSNkQ994TYBCfwW3bJSfvMZFFMA,4
|
|
13
|
+
loz-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# This is the list of loz authors for copyright purposes.
|
|
2
|
+
#
|
|
3
|
+
# This does not necessarily list everyone who has contributed code, since in
|
|
4
|
+
# some cases, their employer may be the copyright holder. To see the full list
|
|
5
|
+
# of contributors, see the revision history in source control.
|
|
6
|
+
|
|
7
|
+
Predrag Mandic <predrag@nul.one>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021-2026, the loz authors.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
* Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loz
|