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 ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ loz - password manager
3
+ """
4
+
5
+ __version__ = "0.2.0"
6
+ __license__ = "BSD"
7
+ __year__ = "2022-2026"
8
+ __author__ = "Predrag Mandic"
9
+ __author_email__ = "predrag@nul.one"
10
+ __copyright__ = f"Copyright {__year__} {__author__} <{__author_email__}>"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loz = loz.__main__:cli
@@ -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