dotseal 0.1.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.
- dotseal/__init__.py +56 -0
- dotseal/cli.py +240 -0
- dotseal/core.py +200 -0
- dotseal/crypto.py +160 -0
- dotseal/exceptions.py +44 -0
- dotseal/loader.py +79 -0
- dotseal/parser.py +170 -0
- dotseal-0.1.0.dist-info/METADATA +288 -0
- dotseal-0.1.0.dist-info/RECORD +12 -0
- dotseal-0.1.0.dist-info/WHEEL +4 -0
- dotseal-0.1.0.dist-info/entry_points.txt +2 -0
- dotseal-0.1.0.dist-info/licenses/LICENSE +21 -0
dotseal/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""dotseal: Git-friendly encrypted env var manager with cleartext keys and sealed values.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
* :func:`load_env` -- runtime loader (decrypt into ``os.environ``); drop-in for
|
|
6
|
+
``python-dotenv``'s ``load_dotenv``.
|
|
7
|
+
* :func:`encrypt_text` / :func:`decrypt_text` -- whole-file transforms.
|
|
8
|
+
* :func:`decrypt_to_dict` -- decrypt into a mapping, in memory.
|
|
9
|
+
* :func:`generate_master_key` / :func:`resolve_master_key` -- key helpers.
|
|
10
|
+
* The exception hierarchy rooted at :class:`DotsealError`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .core import (
|
|
16
|
+
ENV_VAR_NAME,
|
|
17
|
+
KEY_FILE_NAME,
|
|
18
|
+
decrypt_text,
|
|
19
|
+
decrypt_to_dict,
|
|
20
|
+
encrypt_text,
|
|
21
|
+
resolve_master_key,
|
|
22
|
+
)
|
|
23
|
+
from .crypto import generate_master_key, key_fingerprint, load_key_bytes
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
DecryptionError,
|
|
26
|
+
EncryptionError,
|
|
27
|
+
InvalidMasterKeyError,
|
|
28
|
+
KeyFingerprintMismatchError,
|
|
29
|
+
MasterKeyNotFoundError,
|
|
30
|
+
ParseError,
|
|
31
|
+
DotsealError,
|
|
32
|
+
)
|
|
33
|
+
from .loader import load_env
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"__version__",
|
|
39
|
+
"load_env",
|
|
40
|
+
"encrypt_text",
|
|
41
|
+
"decrypt_text",
|
|
42
|
+
"decrypt_to_dict",
|
|
43
|
+
"generate_master_key",
|
|
44
|
+
"resolve_master_key",
|
|
45
|
+
"load_key_bytes",
|
|
46
|
+
"key_fingerprint",
|
|
47
|
+
"ENV_VAR_NAME",
|
|
48
|
+
"KEY_FILE_NAME",
|
|
49
|
+
"DotsealError",
|
|
50
|
+
"MasterKeyNotFoundError",
|
|
51
|
+
"InvalidMasterKeyError",
|
|
52
|
+
"KeyFingerprintMismatchError",
|
|
53
|
+
"DecryptionError",
|
|
54
|
+
"EncryptionError",
|
|
55
|
+
"ParseError",
|
|
56
|
+
]
|
dotseal/cli.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Command-line interface for dotseal (built on argparse, no extra deps).
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
init create a master key + gitignore it
|
|
5
|
+
encrypt [in] [out] .env -> .env.enc
|
|
6
|
+
decrypt [in] [out] .env.enc -> .env
|
|
7
|
+
edit [file] decrypt -> $EDITOR -> re-encrypt (sops-style)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from . import __version__, core, crypto
|
|
21
|
+
from .exceptions import DotsealError
|
|
22
|
+
|
|
23
|
+
_GITIGNORE_NOTE = "# Added by `dotseal init` -- never commit your master key"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- small IO helpers -------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def _read(path: str) -> str:
|
|
29
|
+
if not os.path.isfile(path):
|
|
30
|
+
raise DotsealError(f"Input file not found: {path}")
|
|
31
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
32
|
+
return fh.read()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _err(msg: str) -> None:
|
|
36
|
+
print(f"dotseal: error: {msg}", file=sys.stderr)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_key_bytes(args: argparse.Namespace, *, search_dir: str) -> bytes:
|
|
40
|
+
key_str = core.resolve_master_key(
|
|
41
|
+
getattr(args, "key", None),
|
|
42
|
+
key_file=getattr(args, "key_file", None),
|
|
43
|
+
search_dir=search_dir,
|
|
44
|
+
)
|
|
45
|
+
return crypto.load_key_bytes(key_str)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _secure_delete(path: str) -> None:
|
|
49
|
+
"""Best-effort secure delete: overwrite then unlink."""
|
|
50
|
+
try:
|
|
51
|
+
size = os.path.getsize(path)
|
|
52
|
+
with open(path, "r+b") as fh:
|
|
53
|
+
fh.write(b"\x00" * size)
|
|
54
|
+
fh.flush()
|
|
55
|
+
os.fsync(fh.fileno())
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
finally:
|
|
59
|
+
try:
|
|
60
|
+
os.unlink(path)
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- gitignore handling -----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def _ensure_gitignored(name: str, directory: str) -> str:
|
|
68
|
+
"""Make sure ``name`` is ignored by git. Returns a human-readable status."""
|
|
69
|
+
gitignore = os.path.join(directory, ".gitignore")
|
|
70
|
+
if os.path.isfile(gitignore):
|
|
71
|
+
with open(gitignore, "r", encoding="utf-8") as fh:
|
|
72
|
+
content = fh.read()
|
|
73
|
+
if any(line.strip() == name for line in content.splitlines()):
|
|
74
|
+
return f"{name} already present in .gitignore"
|
|
75
|
+
sep = "" if content.endswith("\n") or content == "" else "\n"
|
|
76
|
+
with open(gitignore, "a", encoding="utf-8") as fh:
|
|
77
|
+
fh.write(f"{sep}{_GITIGNORE_NOTE}\n{name}\n")
|
|
78
|
+
return f"appended {name} to existing .gitignore"
|
|
79
|
+
with open(gitignore, "w", encoding="utf-8") as fh:
|
|
80
|
+
fh.write(f"{_GITIGNORE_NOTE}\n{name}\n")
|
|
81
|
+
return f"created .gitignore and added {name}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- commands ---------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
87
|
+
directory = os.getcwd()
|
|
88
|
+
key_path = os.path.join(directory, core.KEY_FILE_NAME)
|
|
89
|
+
|
|
90
|
+
if os.path.exists(key_path) and not args.force:
|
|
91
|
+
_err(
|
|
92
|
+
f"{core.KEY_FILE_NAME} already exists. Refusing to overwrite "
|
|
93
|
+
"(use --force to replace it -- this will make existing .env.enc "
|
|
94
|
+
"files undecryptable)."
|
|
95
|
+
)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
key_str = crypto.generate_master_key()
|
|
99
|
+
core.write_secret_file(key_path, key_str + "\n", mode=0o600)
|
|
100
|
+
fingerprint = crypto.key_fingerprint(crypto.load_key_bytes(key_str))
|
|
101
|
+
gitignore_status = _ensure_gitignored(core.KEY_FILE_NAME, directory)
|
|
102
|
+
|
|
103
|
+
print(f"Created master key: {key_path} (mode 0600)")
|
|
104
|
+
print(f"Key fingerprint: {fingerprint}")
|
|
105
|
+
print(f"gitignore: {gitignore_status}")
|
|
106
|
+
print()
|
|
107
|
+
print("To use this key in CI/containers instead of the file, export:")
|
|
108
|
+
print(f" export {core.ENV_VAR_NAME}=$(cat {core.KEY_FILE_NAME})")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def cmd_encrypt(args: argparse.Namespace) -> int:
|
|
113
|
+
text = _read(args.input)
|
|
114
|
+
key_bytes = bytearray(_resolve_key_bytes(args, search_dir=os.path.dirname(os.path.abspath(args.input))))
|
|
115
|
+
try:
|
|
116
|
+
out = core.encrypt_text(text, bytes(key_bytes))
|
|
117
|
+
finally:
|
|
118
|
+
crypto._zero(key_bytes)
|
|
119
|
+
# .env.enc is safe to commit -> default permissions are fine.
|
|
120
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
121
|
+
fh.write(out)
|
|
122
|
+
print(f"Encrypted {args.input} -> {args.output}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_decrypt(args: argparse.Namespace) -> int:
|
|
127
|
+
text = _read(args.input)
|
|
128
|
+
key_bytes = bytearray(_resolve_key_bytes(args, search_dir=os.path.dirname(os.path.abspath(args.input))))
|
|
129
|
+
try:
|
|
130
|
+
out = core.decrypt_text(text, bytes(key_bytes))
|
|
131
|
+
finally:
|
|
132
|
+
crypto._zero(key_bytes)
|
|
133
|
+
# Cleartext output contains secrets -> owner-only perms.
|
|
134
|
+
core.write_secret_file(args.output, out, mode=0o600)
|
|
135
|
+
print(f"Decrypted {args.input} -> {args.output} (mode 0600)")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_edit(args: argparse.Namespace) -> int:
|
|
140
|
+
search_dir = os.path.dirname(os.path.abspath(args.input))
|
|
141
|
+
if os.path.isfile(args.input):
|
|
142
|
+
text = _read(args.input)
|
|
143
|
+
key_bytes = bytearray(_resolve_key_bytes(args, search_dir=search_dir))
|
|
144
|
+
try:
|
|
145
|
+
cleartext = core.decrypt_text(text, bytes(key_bytes))
|
|
146
|
+
finally:
|
|
147
|
+
crypto._zero(key_bytes)
|
|
148
|
+
else:
|
|
149
|
+
# Allow creating a new encrypted file by editing from scratch.
|
|
150
|
+
key_bytes = bytearray(_resolve_key_bytes(args, search_dir=search_dir))
|
|
151
|
+
crypto._zero(key_bytes)
|
|
152
|
+
cleartext = "# New encrypted env file. Add KEY=value lines.\n"
|
|
153
|
+
|
|
154
|
+
fd, tmp_path = tempfile.mkstemp(suffix=".env", prefix=".dotseal-edit-")
|
|
155
|
+
try:
|
|
156
|
+
os.fchmod(fd, 0o600)
|
|
157
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
158
|
+
fh.write(cleartext)
|
|
159
|
+
|
|
160
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
161
|
+
cmd = shlex.split(editor) + [tmp_path]
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(cmd)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
_err(f"Editor not found: {editor!r}. Set $EDITOR to a valid editor.")
|
|
166
|
+
return 1
|
|
167
|
+
if result.returncode != 0:
|
|
168
|
+
_err(f"Editor exited with status {result.returncode}; aborting (no changes saved).")
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
with open(tmp_path, "r", encoding="utf-8") as fh:
|
|
172
|
+
edited = fh.read()
|
|
173
|
+
|
|
174
|
+
key_bytes = bytearray(_resolve_key_bytes(args, search_dir=search_dir))
|
|
175
|
+
try:
|
|
176
|
+
out = core.encrypt_text(edited, bytes(key_bytes))
|
|
177
|
+
finally:
|
|
178
|
+
crypto._zero(key_bytes)
|
|
179
|
+
with open(args.input, "w", encoding="utf-8") as fh:
|
|
180
|
+
fh.write(out)
|
|
181
|
+
print(f"Saved encrypted changes to {args.input}")
|
|
182
|
+
return 0
|
|
183
|
+
finally:
|
|
184
|
+
_secure_delete(tmp_path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- argument parsing -------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
190
|
+
parser = argparse.ArgumentParser(
|
|
191
|
+
prog="dotseal",
|
|
192
|
+
description="Git-friendly encrypted .env manager with cleartext keys and sealed values.",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument("--version", action="version", version=f"dotseal {__version__}")
|
|
195
|
+
|
|
196
|
+
def add_key_args(p: argparse.ArgumentParser) -> None:
|
|
197
|
+
p.add_argument("-k", "--key", help="Master key (base64). Overrides env var and key file.")
|
|
198
|
+
p.add_argument("--key-file", help=f"Path to a key file (default: discover {core.KEY_FILE_NAME}).")
|
|
199
|
+
|
|
200
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
201
|
+
|
|
202
|
+
p_init = sub.add_parser("init", help="Generate a master key and gitignore it.")
|
|
203
|
+
p_init.add_argument("--force", action="store_true", help="Overwrite an existing key file.")
|
|
204
|
+
p_init.set_defaults(func=cmd_init)
|
|
205
|
+
|
|
206
|
+
p_enc = sub.add_parser("encrypt", help="Encrypt a cleartext .env into .env.enc.")
|
|
207
|
+
p_enc.add_argument("input", nargs="?", default=".env")
|
|
208
|
+
p_enc.add_argument("output", nargs="?", default=".env.enc")
|
|
209
|
+
add_key_args(p_enc)
|
|
210
|
+
p_enc.set_defaults(func=cmd_encrypt)
|
|
211
|
+
|
|
212
|
+
p_dec = sub.add_parser("decrypt", help="Decrypt .env.enc into a cleartext .env.")
|
|
213
|
+
p_dec.add_argument("input", nargs="?", default=".env.enc")
|
|
214
|
+
p_dec.add_argument("output", nargs="?", default=".env")
|
|
215
|
+
add_key_args(p_dec)
|
|
216
|
+
p_dec.set_defaults(func=cmd_decrypt)
|
|
217
|
+
|
|
218
|
+
p_edit = sub.add_parser("edit", help="Decrypt, open $EDITOR, then re-encrypt (sops-style).")
|
|
219
|
+
p_edit.add_argument("input", nargs="?", default=".env.enc")
|
|
220
|
+
add_key_args(p_edit)
|
|
221
|
+
p_edit.set_defaults(func=cmd_edit)
|
|
222
|
+
|
|
223
|
+
return parser
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
227
|
+
parser = build_parser()
|
|
228
|
+
args = parser.parse_args(argv)
|
|
229
|
+
try:
|
|
230
|
+
return args.func(args)
|
|
231
|
+
except DotsealError as exc:
|
|
232
|
+
_err(str(exc))
|
|
233
|
+
return 1
|
|
234
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
235
|
+
_err("interrupted")
|
|
236
|
+
return 130
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__": # pragma: no cover
|
|
240
|
+
sys.exit(main())
|
dotseal/core.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""High-level orchestration: key resolution and whole-file transforms.
|
|
2
|
+
|
|
3
|
+
This ties together :mod:`dotseal.crypto` (primitives) and
|
|
4
|
+
:mod:`dotseal.parser` (structure) into the operations the CLI and the
|
|
5
|
+
runtime loader actually need:
|
|
6
|
+
|
|
7
|
+
* find the master key (explicit arg -> env var -> local key file),
|
|
8
|
+
* encrypt / decrypt an entire file's text while preserving structure,
|
|
9
|
+
* embed and verify a key fingerprint so a wrong key fails fast.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Dict, Optional
|
|
16
|
+
|
|
17
|
+
from . import crypto, parser
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
KeyFingerprintMismatchError,
|
|
20
|
+
MasterKeyNotFoundError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# --- Well-known names -------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
KEY_FILE_NAME = ".dotseal.key"
|
|
26
|
+
ENV_VAR_NAME = "DOTSEAL_MASTER_KEY"
|
|
27
|
+
|
|
28
|
+
BANNER = "# Generated by dotseal. DO NOT EDIT VALUES MANUALLY."
|
|
29
|
+
METADATA_PREFIX = "# dotseal:"
|
|
30
|
+
METADATA_VERSION = "1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Key resolution ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def find_key_file(start_dir: Optional[str] = None) -> Optional[str]:
|
|
36
|
+
"""Return the path to a local key file, searching upward from ``start_dir``.
|
|
37
|
+
|
|
38
|
+
Walks up the directory tree (like git looking for ``.git``) so the tool
|
|
39
|
+
works from subdirectories of a project.
|
|
40
|
+
"""
|
|
41
|
+
current = os.path.abspath(start_dir or os.getcwd())
|
|
42
|
+
while True:
|
|
43
|
+
candidate = os.path.join(current, KEY_FILE_NAME)
|
|
44
|
+
if os.path.isfile(candidate):
|
|
45
|
+
return candidate
|
|
46
|
+
parent = os.path.dirname(current)
|
|
47
|
+
if parent == current:
|
|
48
|
+
return None
|
|
49
|
+
current = parent
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_master_key(
|
|
53
|
+
master_key: Optional[str] = None,
|
|
54
|
+
*,
|
|
55
|
+
key_file: Optional[str] = None,
|
|
56
|
+
search_dir: Optional[str] = None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Resolve the master key as a base64 string.
|
|
59
|
+
|
|
60
|
+
Resolution order (first match wins):
|
|
61
|
+
1. ``master_key`` argument,
|
|
62
|
+
2. the ``DOTSEAL_MASTER_KEY`` environment variable,
|
|
63
|
+
3. an explicit ``key_file`` path, then a discovered local key file.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
MasterKeyNotFoundError: if no key can be found.
|
|
67
|
+
"""
|
|
68
|
+
if master_key:
|
|
69
|
+
return master_key.strip()
|
|
70
|
+
|
|
71
|
+
env_value = os.environ.get(ENV_VAR_NAME)
|
|
72
|
+
if env_value and env_value.strip():
|
|
73
|
+
return env_value.strip()
|
|
74
|
+
|
|
75
|
+
path = key_file or find_key_file(search_dir)
|
|
76
|
+
if path and os.path.isfile(path):
|
|
77
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
78
|
+
return fh.read().strip()
|
|
79
|
+
|
|
80
|
+
raise MasterKeyNotFoundError(
|
|
81
|
+
"No master key found. Provide one explicitly, set the "
|
|
82
|
+
f"{ENV_VAR_NAME} environment variable, or run `dotseal init` "
|
|
83
|
+
f"to create a local {KEY_FILE_NAME} file."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- Metadata ---------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def build_metadata_line(key_bytes: bytes) -> str:
|
|
90
|
+
fp = crypto.key_fingerprint(key_bytes)
|
|
91
|
+
return f"{METADATA_PREFIX} v={METADATA_VERSION} alg={crypto.ALGORITHM} key_fp={fp}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_metadata(parsed: parser.ParsedEnv) -> Dict[str, str]:
|
|
95
|
+
"""Extract the ``# dotseal:`` metadata tokens, if present."""
|
|
96
|
+
for record in parsed.records:
|
|
97
|
+
if record.kind == "comment" and record.raw.strip().startswith(METADATA_PREFIX):
|
|
98
|
+
body = record.raw.strip()[len(METADATA_PREFIX):].strip()
|
|
99
|
+
meta: Dict[str, str] = {}
|
|
100
|
+
for token in body.split():
|
|
101
|
+
if "=" in token:
|
|
102
|
+
k, v = token.split("=", 1)
|
|
103
|
+
meta[k] = v
|
|
104
|
+
return meta
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _strip_managed_comments(parsed: parser.ParsedEnv) -> None:
|
|
109
|
+
"""Remove dotseal's own banner / metadata comments (keep user ones)."""
|
|
110
|
+
kept = []
|
|
111
|
+
for record in parsed.records:
|
|
112
|
+
if record.kind == "comment":
|
|
113
|
+
text = record.raw.strip()
|
|
114
|
+
if text == BANNER or text.startswith(METADATA_PREFIX):
|
|
115
|
+
continue
|
|
116
|
+
kept.append(record)
|
|
117
|
+
parsed.records = kept
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- Whole-file transforms --------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def encrypt_text(text: str, key_bytes: bytes) -> str:
|
|
123
|
+
"""Encrypt all cleartext values in ``text``; return ``.env.enc`` text."""
|
|
124
|
+
parsed = parser.parse(text)
|
|
125
|
+
_strip_managed_comments(parsed)
|
|
126
|
+
for record in parsed.records:
|
|
127
|
+
if record.kind != "entry":
|
|
128
|
+
continue
|
|
129
|
+
if crypto.is_encrypted_value(record.value):
|
|
130
|
+
continue # idempotent: already encrypted
|
|
131
|
+
record.value = crypto.encrypt_value(key_bytes, record.value, aad=record.key)
|
|
132
|
+
|
|
133
|
+
body = parser.serialize(parsed).rstrip("\n")
|
|
134
|
+
parts = [BANNER]
|
|
135
|
+
if body:
|
|
136
|
+
parts.append(body)
|
|
137
|
+
parts.append(build_metadata_line(key_bytes))
|
|
138
|
+
return "\n".join(parts) + "\n"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def verify_key(parsed: parser.ParsedEnv, key_bytes: bytes) -> None:
|
|
142
|
+
"""Raise if the file's recorded fingerprint does not match ``key_bytes``."""
|
|
143
|
+
meta = parse_metadata(parsed)
|
|
144
|
+
recorded = meta.get("key_fp")
|
|
145
|
+
if recorded and recorded != crypto.key_fingerprint(key_bytes):
|
|
146
|
+
raise KeyFingerprintMismatchError(
|
|
147
|
+
"The master key does not match the key used to encrypt this file "
|
|
148
|
+
f"(expected fingerprint {recorded}). Wrong key?"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def decrypt_text(text: str, key_bytes: bytes) -> str:
|
|
153
|
+
"""Decrypt all encrypted values in ``text``; return cleartext ``.env`` text."""
|
|
154
|
+
parsed = parser.parse(text)
|
|
155
|
+
verify_key(parsed, key_bytes)
|
|
156
|
+
for record in parsed.records:
|
|
157
|
+
if record.kind != "entry":
|
|
158
|
+
continue
|
|
159
|
+
if crypto.is_encrypted_value(record.value):
|
|
160
|
+
plaintext = crypto.decrypt_value(key_bytes, record.value, aad=record.key)
|
|
161
|
+
record.value = parser.format_value(plaintext)
|
|
162
|
+
else:
|
|
163
|
+
record.value = parser.format_value(record.value)
|
|
164
|
+
_strip_managed_comments(parsed)
|
|
165
|
+
return parser.serialize(parsed)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def decrypt_to_dict(text: str, key_bytes: bytes) -> Dict[str, str]:
|
|
169
|
+
"""Decrypt ``text`` into a ``{name: value}`` mapping, in memory only."""
|
|
170
|
+
parsed = parser.parse(text)
|
|
171
|
+
verify_key(parsed, key_bytes)
|
|
172
|
+
result: Dict[str, str] = {}
|
|
173
|
+
for record in parsed.records:
|
|
174
|
+
if record.kind != "entry":
|
|
175
|
+
continue
|
|
176
|
+
if crypto.is_encrypted_value(record.value):
|
|
177
|
+
result[record.key] = crypto.decrypt_value(
|
|
178
|
+
key_bytes, record.value, aad=record.key
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
result[record.key] = record.value
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- Filesystem helpers -----------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def write_secret_file(path: str, text: str, *, mode: int = 0o600) -> None:
|
|
188
|
+
"""Write ``text`` to ``path`` with restrictive (owner-only) permissions."""
|
|
189
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
190
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
191
|
+
try:
|
|
192
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
193
|
+
fh.write(text)
|
|
194
|
+
finally:
|
|
195
|
+
# Re-assert mode in case the file pre-existed with looser perms.
|
|
196
|
+
try:
|
|
197
|
+
os.chmod(path, mode)
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
_ = directory # reserved for future atomic-rename hardening
|
dotseal/crypto.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Cryptographic primitives for dotseal.
|
|
2
|
+
|
|
3
|
+
Encryption scheme
|
|
4
|
+
-----------------
|
|
5
|
+
* Cipher: AES-256-GCM (AEAD) via ``cryptography.hazmat``.
|
|
6
|
+
* Master key: 32 random bytes, stored/transported as standard base64 text.
|
|
7
|
+
* Per value: a fresh random 12-byte nonce is generated for every value.
|
|
8
|
+
* Wire format: ``ENC[AES_GCM,data:<base64(nonce || ciphertext || tag)>]``
|
|
9
|
+
* AAD: the variable *name* is bound as Additional Authenticated Data.
|
|
10
|
+
This means a ciphertext for ``ADMIN_TOKEN`` will not decrypt if
|
|
11
|
+
it is moved onto ``GUEST_TOKEN`` in the file -- it prevents
|
|
12
|
+
value-swapping tampering, not just bit-flipping.
|
|
13
|
+
|
|
14
|
+
Nothing here shells out to external binaries (age/gpg/openssl/sops). It is pure
|
|
15
|
+
Python on top of the ``cryptography`` package.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import binascii
|
|
22
|
+
import hashlib
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
from cryptography.exceptions import InvalidTag
|
|
26
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
27
|
+
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
DecryptionError,
|
|
30
|
+
EncryptionError,
|
|
31
|
+
InvalidMasterKeyError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# --- Format constants -------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
ALGORITHM = "AES_GCM"
|
|
37
|
+
KEY_SIZE = 32 # AES-256
|
|
38
|
+
NONCE_SIZE = 12 # 96-bit nonce recommended for GCM
|
|
39
|
+
|
|
40
|
+
ENC_PREFIX = f"ENC[{ALGORITHM},data:"
|
|
41
|
+
ENC_SUFFIX = "]"
|
|
42
|
+
|
|
43
|
+
# Domain separation string so the public fingerprint can never be confused with
|
|
44
|
+
# any other hash of the key material.
|
|
45
|
+
_FINGERPRINT_DOMAIN = b"dotseal/key-fingerprint/v1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- Helpers ----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def _zero(buf: bytearray) -> None:
|
|
51
|
+
"""Best-effort overwrite of a mutable byte buffer.
|
|
52
|
+
|
|
53
|
+
Python cannot guarantee secrets are erased from memory (immutable ``bytes``/
|
|
54
|
+
``str`` and GC copies make this impossible in the general case), but for the
|
|
55
|
+
mutable buffers we control we overwrite them promptly to shrink the window.
|
|
56
|
+
"""
|
|
57
|
+
for i in range(len(buf)):
|
|
58
|
+
buf[i] = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- Key management ---------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def generate_master_key() -> str:
|
|
64
|
+
"""Generate a new cryptographically secure master key (base64 text)."""
|
|
65
|
+
return base64.b64encode(os.urandom(KEY_SIZE)).decode("ascii")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_key_bytes(master_key: str) -> bytes:
|
|
69
|
+
"""Decode and validate a base64-encoded master key string into raw bytes.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
InvalidMasterKeyError: if the key is not valid base64 or has the wrong
|
|
73
|
+
length for AES-256.
|
|
74
|
+
"""
|
|
75
|
+
if not isinstance(master_key, str):
|
|
76
|
+
raise InvalidMasterKeyError("Master key must be a string.")
|
|
77
|
+
cleaned = master_key.strip()
|
|
78
|
+
if not cleaned:
|
|
79
|
+
raise InvalidMasterKeyError("Master key is empty.")
|
|
80
|
+
try:
|
|
81
|
+
raw = base64.b64decode(cleaned, validate=True)
|
|
82
|
+
except (binascii.Error, ValueError) as exc:
|
|
83
|
+
raise InvalidMasterKeyError(
|
|
84
|
+
"Master key is not valid base64. It must be a base64-encoded "
|
|
85
|
+
"32-byte key as produced by `dotseal init`."
|
|
86
|
+
) from exc
|
|
87
|
+
if len(raw) != KEY_SIZE:
|
|
88
|
+
raise InvalidMasterKeyError(
|
|
89
|
+
f"Master key must decode to {KEY_SIZE} bytes (got {len(raw)}). "
|
|
90
|
+
"Did you copy the whole key?"
|
|
91
|
+
)
|
|
92
|
+
return raw
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def key_fingerprint(key_bytes: bytes) -> str:
|
|
96
|
+
"""Return a short, non-reversible fingerprint of a key (16 hex chars).
|
|
97
|
+
|
|
98
|
+
This is safe to store in the encrypted file: it lets us detect a wrong key
|
|
99
|
+
up front without revealing key material.
|
|
100
|
+
"""
|
|
101
|
+
digest = hashlib.sha256(_FINGERPRINT_DOMAIN + key_bytes).digest()
|
|
102
|
+
return digest[:8].hex()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- Value encryption / decryption ------------------------------------------
|
|
106
|
+
|
|
107
|
+
def is_encrypted_value(value: str) -> bool:
|
|
108
|
+
"""Return True if ``value`` is a well-formed ENC[...] token."""
|
|
109
|
+
return value.startswith(ENC_PREFIX) and value.endswith(ENC_SUFFIX)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def encrypt_value(key_bytes: bytes, plaintext: str, *, aad: str) -> str:
|
|
113
|
+
"""Encrypt a single value, returning the ``ENC[...]`` wire token.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
key_bytes: raw 32-byte AES key.
|
|
117
|
+
plaintext: the cleartext value to protect.
|
|
118
|
+
aad: additional authenticated data (the variable name) bound to the
|
|
119
|
+
ciphertext. Must be supplied identically at decryption time.
|
|
120
|
+
"""
|
|
121
|
+
aesgcm = AESGCM(key_bytes)
|
|
122
|
+
nonce = os.urandom(NONCE_SIZE)
|
|
123
|
+
pt = bytearray(plaintext.encode("utf-8"))
|
|
124
|
+
try:
|
|
125
|
+
ciphertext = aesgcm.encrypt(nonce, bytes(pt), aad.encode("utf-8"))
|
|
126
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
127
|
+
raise EncryptionError(f"Failed to encrypt value: {exc}") from exc
|
|
128
|
+
finally:
|
|
129
|
+
_zero(pt)
|
|
130
|
+
payload = base64.b64encode(nonce + ciphertext).decode("ascii")
|
|
131
|
+
return f"{ENC_PREFIX}{payload}{ENC_SUFFIX}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decrypt_value(key_bytes: bytes, token: str, *, aad: str) -> str:
|
|
135
|
+
"""Decrypt an ``ENC[...]`` token back into the cleartext value.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
DecryptionError: if the token is malformed, the key is wrong, the AAD
|
|
139
|
+
(variable name) does not match, or the ciphertext was tampered with.
|
|
140
|
+
"""
|
|
141
|
+
if not is_encrypted_value(token):
|
|
142
|
+
raise DecryptionError(
|
|
143
|
+
"Value is not a recognized ENC[AES_GCM,...] token."
|
|
144
|
+
)
|
|
145
|
+
payload_b64 = token[len(ENC_PREFIX):-len(ENC_SUFFIX)]
|
|
146
|
+
try:
|
|
147
|
+
blob = base64.b64decode(payload_b64, validate=True)
|
|
148
|
+
except (binascii.Error, ValueError) as exc:
|
|
149
|
+
raise DecryptionError("Corrupted data: payload is not valid base64.") from exc
|
|
150
|
+
if len(blob) <= NONCE_SIZE:
|
|
151
|
+
raise DecryptionError("Corrupted data: ciphertext is too short.")
|
|
152
|
+
nonce, ciphertext = blob[:NONCE_SIZE], blob[NONCE_SIZE:]
|
|
153
|
+
aesgcm = AESGCM(key_bytes)
|
|
154
|
+
try:
|
|
155
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext, aad.encode("utf-8"))
|
|
156
|
+
except InvalidTag as exc:
|
|
157
|
+
raise DecryptionError(
|
|
158
|
+
"Invalid Master Key or Corrupted Data."
|
|
159
|
+
) from exc
|
|
160
|
+
return plaintext.decode("utf-8")
|
dotseal/exceptions.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Custom exception hierarchy for dotseal.
|
|
2
|
+
|
|
3
|
+
These exceptions exist so that failures surface as clear, actionable messages
|
|
4
|
+
instead of leaking raw cryptographic tracebacks (which are both confusing and a
|
|
5
|
+
minor information-leak risk) to end users.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DotsealError(Exception):
|
|
12
|
+
"""Base class for all dotseal errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KeyError_(DotsealError):
|
|
16
|
+
"""Base for key-related problems."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MasterKeyNotFoundError(KeyError_):
|
|
20
|
+
"""Raised when no master key can be located via any supported method."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidMasterKeyError(KeyError_):
|
|
24
|
+
"""Raised when a provided master key is malformed (wrong length/encoding)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class KeyFingerprintMismatchError(DotsealError):
|
|
28
|
+
"""Raised when the key fingerprint in the file does not match the supplied key.
|
|
29
|
+
|
|
30
|
+
This lets us fail fast with a helpful message *before* attempting to decrypt
|
|
31
|
+
every value with an obviously-wrong key.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DecryptionError(DotsealError):
|
|
36
|
+
"""Raised when a value cannot be decrypted (bad key or tampered ciphertext)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EncryptionError(DotsealError):
|
|
40
|
+
"""Raised when a value cannot be encrypted."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ParseError(DotsealError):
|
|
44
|
+
"""Raised when a .env / .env.enc file cannot be parsed."""
|
dotseal/loader.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Runtime loader: decrypt an ``.env.enc`` into ``os.environ`` with no disk write.
|
|
2
|
+
|
|
3
|
+
This is the import-time companion to the CLI and a drop-in replacement for
|
|
4
|
+
``python-dotenv``'s ``load_dotenv``: call :func:`load_env` early in startup to
|
|
5
|
+
make your (encrypted) secrets available via ``os.environ`` / ``os.getenv``
|
|
6
|
+
without ever materializing a cleartext file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from . import core, crypto
|
|
15
|
+
from .exceptions import MasterKeyNotFoundError, DotsealError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_env(
|
|
19
|
+
dotenv_path: str = ".env.enc",
|
|
20
|
+
*,
|
|
21
|
+
master_key: Optional[str] = None,
|
|
22
|
+
override: bool = False,
|
|
23
|
+
encoding: str = "utf-8",
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Decrypt ``dotenv_path`` in memory and inject the values into ``os.environ``.
|
|
26
|
+
|
|
27
|
+
Mirrors ``python-dotenv``'s ``load_dotenv`` so it can be used as a drop-in
|
|
28
|
+
replacement, but reads a structurally-encrypted ``.env.enc`` file.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
dotenv_path: path to the encrypted env file (default ``".env.enc"``).
|
|
32
|
+
master_key: base64 master key. If ``None``, it is resolved from the
|
|
33
|
+
``DOTSEAL_MASTER_KEY`` env var or a local key file.
|
|
34
|
+
override: if ``False`` (default), variables already present in
|
|
35
|
+
``os.environ`` are left untouched (the process environment wins,
|
|
36
|
+
which matches typical 12-factor behavior). If ``True``, decrypted
|
|
37
|
+
values overwrite existing ones.
|
|
38
|
+
encoding: text encoding used to read the file.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
``True`` if at least one variable was set in ``os.environ``, else
|
|
42
|
+
``False`` (matching ``load_dotenv``). To get the decrypted values back
|
|
43
|
+
as a mapping instead, use :func:`dotseal.decrypt_to_dict`.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
FileNotFoundError: if ``dotenv_path`` does not exist.
|
|
47
|
+
MasterKeyNotFoundError / DecryptionError / KeyFingerprintMismatchError:
|
|
48
|
+
on key resolution or decryption problems.
|
|
49
|
+
"""
|
|
50
|
+
if not os.path.isfile(dotenv_path):
|
|
51
|
+
raise FileNotFoundError(f"Encrypted env file not found: {dotenv_path}")
|
|
52
|
+
|
|
53
|
+
key_str = core.resolve_master_key(
|
|
54
|
+
master_key, search_dir=os.path.dirname(os.path.abspath(dotenv_path))
|
|
55
|
+
)
|
|
56
|
+
key_bytes = bytearray(crypto.load_key_bytes(key_str))
|
|
57
|
+
|
|
58
|
+
with open(dotenv_path, "r", encoding=encoding) as fh:
|
|
59
|
+
text = fh.read()
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
values = core.decrypt_to_dict(text, bytes(key_bytes))
|
|
63
|
+
finally:
|
|
64
|
+
crypto._zero(key_bytes)
|
|
65
|
+
|
|
66
|
+
set_any = False
|
|
67
|
+
for name, value in values.items():
|
|
68
|
+
if override or name not in os.environ:
|
|
69
|
+
os.environ[name] = value
|
|
70
|
+
set_any = True
|
|
71
|
+
|
|
72
|
+
return set_any
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"load_env",
|
|
77
|
+
"MasterKeyNotFoundError",
|
|
78
|
+
"DotsealError",
|
|
79
|
+
]
|
dotseal/parser.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Structure-preserving parser for ``.env`` / ``.env.enc`` files.
|
|
2
|
+
|
|
3
|
+
The whole point of dotseal is *structural* encryption: keys stay in
|
|
4
|
+
cleartext, comments and blank lines are preserved, and only values change. To
|
|
5
|
+
make that possible the parser does not collapse a file into a ``dict`` -- it
|
|
6
|
+
keeps an ordered list of :class:`Record` objects so the document can be
|
|
7
|
+
faithfully re-serialized.
|
|
8
|
+
|
|
9
|
+
Value handling follows common ``.env`` conventions:
|
|
10
|
+
|
|
11
|
+
* ``export FOO=bar`` -- the optional ``export`` prefix is preserved.
|
|
12
|
+
* Whitespace around ``=`` and around an unquoted value is trimmed.
|
|
13
|
+
* Values may be wrapped in single or double quotes. Double-quoted values
|
|
14
|
+
support ``\\n``, ``\\t``, ``\\r``, ``\\\\`` and ``\\"`` escapes (this is how
|
|
15
|
+
multi-line values are represented on a single physical line). Single-quoted
|
|
16
|
+
values are taken literally.
|
|
17
|
+
* The first ``=`` separates key and value, so values may themselves contain
|
|
18
|
+
``=`` (e.g. ``PASSWORD=!!@#$%=``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import List
|
|
26
|
+
|
|
27
|
+
from .exceptions import ParseError
|
|
28
|
+
|
|
29
|
+
# Matches an optional `export ` prefix followed by KEY=...
|
|
30
|
+
_ENTRY_RE = re.compile(
|
|
31
|
+
r"""^(?P<export>export\s+)? # optional export prefix
|
|
32
|
+
(?P<key>[A-Za-z_][A-Za-z0-9_.]*) # variable name
|
|
33
|
+
\s*= # separator
|
|
34
|
+
(?P<value>.*)$ # everything after the first =
|
|
35
|
+
""",
|
|
36
|
+
re.VERBOSE | re.DOTALL,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Record:
|
|
42
|
+
"""A single logical line of the document."""
|
|
43
|
+
|
|
44
|
+
kind: str # 'blank' | 'comment' | 'entry'
|
|
45
|
+
raw: str = "" # verbatim text for blank/comment records
|
|
46
|
+
key: str = ""
|
|
47
|
+
value: str = "" # logical (unquoted) value, or an ENC[...] token
|
|
48
|
+
export: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ParsedEnv:
|
|
53
|
+
records: List[Record]
|
|
54
|
+
|
|
55
|
+
def entries(self) -> List[Record]:
|
|
56
|
+
return [r for r in self.records if r.kind == "entry"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- Value decoding / encoding ----------------------------------------------
|
|
60
|
+
|
|
61
|
+
_DOUBLE_UNESCAPE = {
|
|
62
|
+
"\\n": "\n",
|
|
63
|
+
"\\t": "\t",
|
|
64
|
+
"\\r": "\r",
|
|
65
|
+
'\\"': '"',
|
|
66
|
+
"\\\\": "\\",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_DOUBLE_ESCAPE = {
|
|
70
|
+
"\\": "\\\\",
|
|
71
|
+
'"': '\\"',
|
|
72
|
+
"\n": "\\n",
|
|
73
|
+
"\t": "\\t",
|
|
74
|
+
"\r": "\\r",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unquote(raw: str) -> str:
|
|
79
|
+
"""Turn a raw on-disk value into its logical string value."""
|
|
80
|
+
value = raw.strip()
|
|
81
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
82
|
+
inner = value[1:-1]
|
|
83
|
+
if value[0] == '"':
|
|
84
|
+
return _unescape_double(inner)
|
|
85
|
+
return inner # single quotes: literal
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _unescape_double(inner: str) -> str:
|
|
90
|
+
out = []
|
|
91
|
+
i = 0
|
|
92
|
+
while i < len(inner):
|
|
93
|
+
two = inner[i : i + 2]
|
|
94
|
+
if two in _DOUBLE_UNESCAPE:
|
|
95
|
+
out.append(_DOUBLE_UNESCAPE[two])
|
|
96
|
+
i += 2
|
|
97
|
+
else:
|
|
98
|
+
out.append(inner[i])
|
|
99
|
+
i += 1
|
|
100
|
+
return "".join(out)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_value(value: str) -> str:
|
|
104
|
+
"""Render a logical value back to a safe on-disk representation.
|
|
105
|
+
|
|
106
|
+
Quotes (and escapes) the value only when necessary so simple values stay
|
|
107
|
+
diff-friendly and unquoted.
|
|
108
|
+
"""
|
|
109
|
+
if value == "":
|
|
110
|
+
return ""
|
|
111
|
+
needs_quoting = (
|
|
112
|
+
value != value.strip() # leading/trailing whitespace
|
|
113
|
+
or any(c in value for c in (" ", "\t", "#", "\n", "\r", '"', "'"))
|
|
114
|
+
)
|
|
115
|
+
if not needs_quoting:
|
|
116
|
+
return value
|
|
117
|
+
escaped = "".join(_DOUBLE_ESCAPE.get(c, c) for c in value)
|
|
118
|
+
return f'"{escaped}"'
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- Parsing ----------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def parse(text: str) -> ParsedEnv:
|
|
124
|
+
"""Parse the full text of a ``.env``/``.env.enc`` file into records."""
|
|
125
|
+
records: List[Record] = []
|
|
126
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
127
|
+
stripped = line.strip()
|
|
128
|
+
if stripped == "":
|
|
129
|
+
records.append(Record(kind="blank", raw=""))
|
|
130
|
+
continue
|
|
131
|
+
if stripped.startswith("#"):
|
|
132
|
+
records.append(Record(kind="comment", raw=line))
|
|
133
|
+
continue
|
|
134
|
+
match = _ENTRY_RE.match(line)
|
|
135
|
+
if not match:
|
|
136
|
+
raise ParseError(
|
|
137
|
+
f"Line {lineno}: could not parse entry: {line!r}"
|
|
138
|
+
)
|
|
139
|
+
key = match.group("key")
|
|
140
|
+
raw_value = match.group("value")
|
|
141
|
+
records.append(
|
|
142
|
+
Record(
|
|
143
|
+
kind="entry",
|
|
144
|
+
key=key,
|
|
145
|
+
value=_unquote(raw_value),
|
|
146
|
+
export=bool(match.group("export")),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return ParsedEnv(records=records)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def serialize(parsed: ParsedEnv) -> str:
|
|
153
|
+
"""Serialize records back to file text (values rendered via ``format_value``).
|
|
154
|
+
|
|
155
|
+
Entry values are written verbatim (already-final form -- either an ENC[...]
|
|
156
|
+
token or an already-formatted cleartext value). Use :func:`format_value`
|
|
157
|
+
before assigning cleartext values you want safely quoted.
|
|
158
|
+
"""
|
|
159
|
+
lines: List[str] = []
|
|
160
|
+
for r in parsed.records:
|
|
161
|
+
if r.kind == "blank":
|
|
162
|
+
lines.append("")
|
|
163
|
+
elif r.kind == "comment":
|
|
164
|
+
lines.append(r.raw)
|
|
165
|
+
elif r.kind == "entry":
|
|
166
|
+
prefix = "export " if r.export else ""
|
|
167
|
+
lines.append(f"{prefix}{r.key}={r.value}")
|
|
168
|
+
else: # pragma: no cover - defensive
|
|
169
|
+
raise ParseError(f"Unknown record kind: {r.kind!r}")
|
|
170
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotseal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Git-friendly encrypted .env files with cleartext keys and sealed values (SOPS-inspired structural encryption).
|
|
5
|
+
Project-URL: Homepage, https://github.com/Jastchi/dotseal
|
|
6
|
+
Project-URL: Repository, https://github.com/Jastchi/dotseal
|
|
7
|
+
Author: dotseal contributors
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: aes-gcm,configuration,dotenv,dotseal,encryption,env,secrets,sops
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Security :: Cryptography
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Requires-Dist: cryptography>=42.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest<9,>=8.0.0; (python_version < '3.10') and extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=9.0.0; (python_version >= '3.10') and extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.15; (python_version >= '3.9') and extra == 'dev'
|
|
32
|
+
Requires-Dist: ty>=0.0.47; (python_version >= '3.9') and extra == 'dev'
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: pytest<9,>=8.0.0; (python_version < '3.10') and extra == 'test'
|
|
35
|
+
Requires-Dist: pytest>=9.0.0; (python_version >= '3.10') and extra == 'test'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# dotseal
|
|
39
|
+
|
|
40
|
+
Git-friendly encrypted `.env` files with cleartext keys and sealed values — an offline-first environment-variable manager for Python, inspired by [Mozilla SOPS](https://github.com/getsops/sops) but built natively for the Python ecosystem.
|
|
41
|
+
|
|
42
|
+
`dotseal` performs **structural encryption**: it leaves your `.env` **keys in cleartext** and encrypts only the **values**. The result is a `.env.enc` file you can safely commit, review in pull requests, and merge — because the diff still shows *which* variables changed, just not their secret contents.
|
|
43
|
+
|
|
44
|
+
```diff
|
|
45
|
+
DATABASE_URL=ENC[AES_GCM,data:Zm9vYmFy...]
|
|
46
|
+
- DEBUG=ENC[AES_GCM,data:TXVzaWM=]
|
|
47
|
+
+ DEBUG=ENC[AES_GCM,data:b3RoZXI=]
|
|
48
|
+
API_KEY=ENC[AES_GCM,data:c2VjcmV0...]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **No OS dependencies.** Pure Python on top of [`cryptography`](https://cryptography.io). No `age`, `gpg`, `sops`, `openssl` CLI, or Go binaries required.
|
|
52
|
+
- **Authenticated encryption.** AES-256-GCM (AEAD) with a fresh nonce per value.
|
|
53
|
+
- **Tamper-evident & swap-proof.** Each value is bound to its variable name as Additional Authenticated Data (AAD), so ciphertext can't be moved between keys.
|
|
54
|
+
- **Runtime loader.** Decrypt straight into `os.environ` — no cleartext file ever touches disk.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install dotseal
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Requires Python 3.8+.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quickstart
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# 1. Generate a master key (saved to .dotseal.key and gitignored)
|
|
72
|
+
dotseal init
|
|
73
|
+
|
|
74
|
+
# 2. Write a normal .env file
|
|
75
|
+
cat > .env <<'EOF'
|
|
76
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/db
|
|
77
|
+
DEBUG=True
|
|
78
|
+
API_KEY=super-secret
|
|
79
|
+
EOF
|
|
80
|
+
|
|
81
|
+
# 3. Encrypt it → .env.enc (commit this; never commit .env or the key)
|
|
82
|
+
dotseal encrypt
|
|
83
|
+
|
|
84
|
+
# 4. Decrypt when you need it back
|
|
85
|
+
dotseal decrypt
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### What gets committed?
|
|
89
|
+
|
|
90
|
+
| File | Commit it? | Contents |
|
|
91
|
+
| --------------------- | ---------- | ----------------------------------------- |
|
|
92
|
+
| `.env.enc` | ✅ Yes | Keys in cleartext, values encrypted |
|
|
93
|
+
| `.env` | ❌ No | Full cleartext secrets |
|
|
94
|
+
| `.dotseal.key` | ❌ **Never**| The master key (auto-added to `.gitignore`) |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## CLI Reference
|
|
99
|
+
|
|
100
|
+
### `dotseal init`
|
|
101
|
+
Generates a new cryptographically secure master key, writes it to `.dotseal.key` (mode `0600`), and adds it to `.gitignore` (creating one if needed). Prints the key **fingerprint** (not the key) so you can verify which key encrypted a file. Use `--force` to replace an existing key (this makes existing `.env.enc` files undecryptable).
|
|
102
|
+
|
|
103
|
+
### `dotseal encrypt [input] [output]`
|
|
104
|
+
Encrypts the values of a cleartext env file. Defaults: `.env` → `.env.enc`. Idempotent — values that are already encrypted are left untouched.
|
|
105
|
+
|
|
106
|
+
### `dotseal decrypt [input] [output]`
|
|
107
|
+
Decrypts values back to cleartext. Defaults: `.env.enc` → `.env`. The output is written with owner-only (`0600`) permissions since it contains secrets.
|
|
108
|
+
|
|
109
|
+
### `dotseal edit [file]`
|
|
110
|
+
SOPS-style editing. Decrypts `.env.enc` to a temporary file (mode `0600`), opens it in `$EDITOR` (falling back to `nano`), and re-encrypts on save. The temp file is securely overwritten and deleted afterward. If the file doesn't exist yet, you get a fresh template to start from.
|
|
111
|
+
|
|
112
|
+
### Common options
|
|
113
|
+
All commands except `init` accept:
|
|
114
|
+
|
|
115
|
+
- `-k, --key <base64>` — provide the master key directly (overrides env var and key file).
|
|
116
|
+
- `--key-file <path>` — use a specific key file instead of auto-discovery.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Key Management
|
|
121
|
+
|
|
122
|
+
The master key is resolved in this order (first match wins):
|
|
123
|
+
|
|
124
|
+
1. An explicit `--key` argument (CLI) or `master_key=` argument (loader).
|
|
125
|
+
2. The `DOTSEAL_MASTER_KEY` environment variable.
|
|
126
|
+
3. A local `.dotseal.key` file (searched for in the current directory and upward through parent directories).
|
|
127
|
+
|
|
128
|
+
The key is a base64-encoded 32-byte (AES-256) value. Generate one programmatically with:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from dotseal import generate_master_key
|
|
132
|
+
print(generate_master_key())
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Runtime Loader (no cleartext on disk)
|
|
138
|
+
|
|
139
|
+
`load_env` is a **drop-in replacement for `python-dotenv`'s `load_dotenv`** — it just reads an encrypted `.env.enc` instead of a cleartext `.env`. Call it once at startup and your secrets are available as ordinary environment variables through the `os` module:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import os
|
|
143
|
+
from dotseal import load_env
|
|
144
|
+
|
|
145
|
+
# Resolves the key from DOTSEAL_MASTER_KEY or .dotseal.key
|
|
146
|
+
load_env() # reads ".env.enc" by default
|
|
147
|
+
|
|
148
|
+
os.getenv("DATABASE_URL") # now available, like any env var
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Signature:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
def load_env(
|
|
155
|
+
dotenv_path: str = ".env.enc",
|
|
156
|
+
*,
|
|
157
|
+
master_key: str | None = None,
|
|
158
|
+
override: bool = False,
|
|
159
|
+
encoding: str = "utf-8",
|
|
160
|
+
) -> bool:
|
|
161
|
+
...
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `override=False` (default): existing process env vars win (12-factor friendly).
|
|
165
|
+
- `override=True`: decrypted values overwrite anything already in `os.environ`.
|
|
166
|
+
- Returns `True` if at least one variable was set (matching `load_dotenv`). Want the values as a `dict` instead? Use `decrypt_to_dict` (below).
|
|
167
|
+
|
|
168
|
+
Other programmatic helpers:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from dotseal import encrypt_text, decrypt_text, decrypt_to_dict, load_key_bytes
|
|
172
|
+
|
|
173
|
+
key = load_key_bytes("BASE64KEY==")
|
|
174
|
+
enc = encrypt_text("FOO=bar\n", key) # -> ".env.enc" text
|
|
175
|
+
cleartext = decrypt_text(enc, key) # -> ".env" text
|
|
176
|
+
mapping = decrypt_to_dict(enc, key) # -> {"FOO": "bar"}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## File Format
|
|
182
|
+
|
|
183
|
+
```env
|
|
184
|
+
# Generated by dotseal. DO NOT EDIT VALUES MANUALLY.
|
|
185
|
+
DATABASE_URL=ENC[AES_GCM,data:<base64(nonce ‖ ciphertext ‖ tag)>]
|
|
186
|
+
DEBUG=ENC[AES_GCM,data:...]
|
|
187
|
+
# dotseal: v=1 alg=AES_GCM key_fp=7ef08b59e6a945e4
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- Each value's payload is `base64(12-byte nonce ‖ ciphertext ‖ GCM tag)`.
|
|
191
|
+
- The variable name is bound as AAD, so values cannot be swapped between keys.
|
|
192
|
+
- The trailing `# dotseal:` metadata line records the algorithm and a **key fingerprint** (a one-way hash of the key). On decrypt, the fingerprint is checked first so a wrong key fails fast with a clear message instead of a cryptic crypto error.
|
|
193
|
+
- Comments and blank lines are preserved. Values containing spaces, `#`, or newlines are safely quoted/escaped on decryption.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## CI/CD Integration
|
|
198
|
+
|
|
199
|
+
The pattern is always the same: provide the master key via the `DOTSEAL_MASTER_KEY` environment variable (from your platform's secret store), commit only `.env.enc`, and either decrypt to a file or load at runtime.
|
|
200
|
+
|
|
201
|
+
### GitHub Actions
|
|
202
|
+
|
|
203
|
+
Store the key as a repository/environment **secret** named `DOTSEAL_MASTER_KEY`.
|
|
204
|
+
|
|
205
|
+
```yaml
|
|
206
|
+
jobs:
|
|
207
|
+
deploy:
|
|
208
|
+
runs-on: ubuntu-latest
|
|
209
|
+
env:
|
|
210
|
+
DOTSEAL_MASTER_KEY: ${{ secrets.DOTSEAL_MASTER_KEY }}
|
|
211
|
+
steps:
|
|
212
|
+
- uses: actions/checkout@v4
|
|
213
|
+
- uses: actions/setup-python@v5
|
|
214
|
+
with:
|
|
215
|
+
python-version: "3.12"
|
|
216
|
+
- run: pip install dotseal
|
|
217
|
+
|
|
218
|
+
# Option A: decrypt to a real .env for tools that expect a file
|
|
219
|
+
- run: dotseal decrypt .env.enc .env
|
|
220
|
+
|
|
221
|
+
# Option B: load at runtime inside your app (no cleartext file)
|
|
222
|
+
- run: python -c "from dotseal import load_env; load_env(); import app"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Docker
|
|
226
|
+
|
|
227
|
+
Bake only the encrypted file into the image and pass the key at runtime:
|
|
228
|
+
|
|
229
|
+
```dockerfile
|
|
230
|
+
FROM python:3.12-slim
|
|
231
|
+
WORKDIR /app
|
|
232
|
+
RUN pip install dotseal
|
|
233
|
+
COPY .env.enc .
|
|
234
|
+
COPY . .
|
|
235
|
+
# App calls load_env() on startup.
|
|
236
|
+
CMD ["python", "main.py"]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
docker run -e DOTSEAL_MASTER_KEY="$(cat .dotseal.key)" my-image
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
# main.py
|
|
245
|
+
from dotseal import load_env
|
|
246
|
+
load_env() # picks up DOTSEAL_MASTER_KEY from the container env
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Kubernetes
|
|
250
|
+
|
|
251
|
+
Store the master key in a `Secret` and expose it as `DOTSEAL_MASTER_KEY`:
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
env:
|
|
255
|
+
- name: DOTSEAL_MASTER_KEY
|
|
256
|
+
valueFrom:
|
|
257
|
+
secretKeyRef:
|
|
258
|
+
name: dotseal
|
|
259
|
+
key: master-key
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Security Notes & Limitations
|
|
265
|
+
|
|
266
|
+
- **AES-256-GCM** provides confidentiality *and* integrity. Tampered ciphertext or a wrong key is rejected rather than silently producing garbage.
|
|
267
|
+
- **AAD binding** prevents an attacker who can edit the committed `.env.enc` from relocating a high-privilege secret onto a low-privilege variable name.
|
|
268
|
+
- **Key fingerprint** is a domain-separated SHA-256 hash truncated to 8 bytes; it reveals nothing about the key itself.
|
|
269
|
+
- **Memory hygiene is best-effort.** dotseal overwrites the mutable key buffers it controls, but Python's immutable `str`/`bytes` and garbage collector mean secrets can still linger in memory. Do not rely on this for protection against an attacker with live process access.
|
|
270
|
+
- **The master key is the whole ballgame.** Anyone with the key can decrypt everything. Rotate it by re-encrypting with `dotseal init --force` followed by `encrypt`, and store it only in trusted secret managers.
|
|
271
|
+
- This tool is a single-key symmetric scheme. It does **not** implement multi-recipient/asymmetric key sharing (a SOPS + `age`/KMS feature).
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Development
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
uv venv && uv pip install -e ".[dev]"
|
|
279
|
+
uv run pytest
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
CI runs the full test suite on Python 3.8 through 3.14 (see `.github/workflows/test.yml`).
|
|
283
|
+
|
|
284
|
+
The test suite covers crypto round-trips, edge-case values (empty strings, `!!@#$%=`, unicode, multi-line, large), structural parsing, the runtime loader (asserting no side-effect files are written), and the full CLI lifecycle including `edit`.
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
dotseal/__init__.py,sha256=QSHDVx0HmqkS8tTXrSGIW5cLQwVf7kqAWUZTBeNLafg,1430
|
|
2
|
+
dotseal/cli.py,sha256=VObJQjzoL50gRnMVjwd4nl10UemNA5YaWVFPwCSI5ys,8640
|
|
3
|
+
dotseal/core.py,sha256=W-i_NxnFSu-wDq9foVdA4Id-WJ0eh5FYpmPHjHicK5k,7035
|
|
4
|
+
dotseal/crypto.py,sha256=rOTuuepc1nWB_Xz0HQZlQ6TsxDttXhP9d8u2GeFm4Rs,5911
|
|
5
|
+
dotseal/exceptions.py,sha256=cYHDqjZtiudtMiKukrLMNdUIeLk7H2RI5udR2KgpvrA,1261
|
|
6
|
+
dotseal/loader.py,sha256=ax7E9P8Fx0XCeyNqhhHBEh2aQkg8-pTYUOODPwxf5KY,2705
|
|
7
|
+
dotseal/parser.py,sha256=mw_k-m1BGx2YvcyeqR6CqLjxnQ4JpE9uN1RWMBADans,5316
|
|
8
|
+
dotseal-0.1.0.dist-info/METADATA,sha256=mD0BtAzdrdQ0u1-S3w9CrDm8gLer2Wn47pFRSnBBsgk,10978
|
|
9
|
+
dotseal-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
dotseal-0.1.0.dist-info/entry_points.txt,sha256=OcZ6MVNeCRe60U3rXchD-so_nu-OBscyt3jwHUYwtEE,45
|
|
11
|
+
dotseal-0.1.0.dist-info/licenses/LICENSE,sha256=f5qyXeHyRYQU-yXrmItXA1eohCQtgjGI8R5riBIbDWU,1077
|
|
12
|
+
dotseal-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dotseal contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|