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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dotseal = dotseal.cli:main
@@ -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.