1"""Custody of a project's env key: its id, this machine's store, and resolution.
2
3Encrypted values in a committed `.env` file are unlocked by one symmetric key
4per project. The key never lives in a working tree. It lives on each machine
5in `~/.plain/env-keys/<id>`, named by a short fingerprint, and the file names
6it with a plain `PLAIN_ENV_KEY_ID=<id>` line. Where there is no machine store
7(a sandbox, CI), `PLAIN_ENV_KEY` in the environment carries the key instead.
8
9The loader (`plain.dev.dotenv`) and the `plain env` commands both come here to
10turn "what the files name" and "what the environment supplied" into one key.
11"""
12
13import hashlib
14import os
15import re
16from pathlib import Path
17
18from plain.exceptions import ImproperlyConfigured
19
20# The key, when the environment supplies it. The loader takes it out of the
21# environment on load and keeps it in memory: nothing under the loader needs
22# it, so nothing under the loader can read it.
23ENV_KEY_VAR = "PLAIN_ENV_KEY"
24# The line in a `.env` file naming the key it was encrypted with, by id.
25ENV_KEY_ID_VAR = "PLAIN_ENV_KEY_ID"
26# What the first version called the key. Only ever seen as a leftover line.
27LEGACY_ENV_KEY_VAR = "DEV_ENV_KEY"
28
29# Lines a `.env` file can carry that speak to the loader rather than the app.
30# None of them is ever bound as a variable: the id is a pointer, and the key
31# lines are secrets that no other value may capture.
32KEY_LINE_NAMES = (ENV_KEY_VAR, LEGACY_ENV_KEY_VAR)
33DIRECTIVE_NAMES = frozenset({ENV_KEY_ID_VAR, *KEY_LINE_NAMES})
34
35# Where this machine keeps the keys it has been given: one file per key, named
36# by the key's id. Outside every checkout, so a clone, a worktree and a fork of
37# a project all find the same key, and a working tree never holds a secret.
38# Not under the cache path: a cache is something you can delete, a key is not.
39ENV_KEYS_PATH = Path.home() / ".plain" / "env-keys"
40
41# The id is written into committed files and names a file in the store, so its
42# length is a wire format: the digest slice, the pattern that validates it and
43# the error message that describes it all read it from here.
44_ENV_KEY_ID_LENGTH = 12
45_ENV_KEY_ID_RE = re.compile(rf"^[0-9a-f]{{{_ENV_KEY_ID_LENGTH}}}$")
46
47# Said by the loader and by `plain env unlock`, about the same rule.
48INVALID_ENV_KEY_HINT = (
49 "is not a valid key (expected the 44 character key written by `plain env init`)"
50)
51
52
53# --- keys and ids ---
54
55
56def generate_env_key() -> str:
57 """Generate a new key (a Fernet key: urlsafe base64, 44 chars)."""
58 from cryptography.fernet import Fernet
59
60 return Fernet.generate_key().decode("ascii")
61
62
63def is_valid_env_key(env_key: str) -> bool:
64 """True when `env_key` is in the form `generate_env_key` produces."""
65 from cryptography.fernet import Fernet
66
67 try:
68 Fernet(env_key.encode("ascii"))
69 except ValueError, UnicodeEncodeError:
70 return False
71 return True
72
73
74def env_key_id(env_key: str) -> str:
75 """The id a file names its key by: `PLAIN_ENV_KEY_ID=<id>`."""
76 return hashlib.sha256(env_key.encode("ascii")).hexdigest()[:_ENV_KEY_ID_LENGTH]
77
78
79def check_env_key_id(key_id: str) -> None:
80 """Raise ImproperlyConfigured unless `key_id` is in the form `env_key_id` produces.
81
82 The id comes from a committed file and names a file in the store, so it is
83 checked before it is ever joined to a path — and the offending value is not
84 repeated back, since `$VAR` expansion could have put a secret in it.
85 """
86 if not _ENV_KEY_ID_RE.fullmatch(key_id):
87 raise ImproperlyConfigured(
88 f"{ENV_KEY_ID_VAR} is not a key id ({_ENV_KEY_ID_LENGTH} hex characters, "
89 "as written by `plain env init`)."
90 )
91
92
93# --- this machine's store ---
94
95
96def stored_env_key_path(key_id: str) -> Path:
97 """Where this machine keeps the key with this id, whether or not it has it."""
98 check_env_key_id(key_id)
99 return ENV_KEYS_PATH / key_id
100
101
102def store_env_key(env_key: str) -> Path:
103 """Save a key to this machine's store, readable by this user only, and return its path.
104
105 The directory and the file are made private even if they already existed
106 with looser permissions, and a symlink in either place is refused rather
107 than followed.
108 """
109 path = stored_env_key_path(env_key_id(env_key))
110 if path.parent.is_symlink():
111 raise ImproperlyConfigured(
112 f"{path.parent} is a symlink; the key store must be a real directory."
113 )
114 path.parent.mkdir(parents=True, exist_ok=True)
115 os.chmod(path.parent, 0o700)
116 try:
117 fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
118 except OSError as e:
119 raise ImproperlyConfigured(
120 f"Could not write the key to {path}: {e.strerror}. If it is a symlink, "
121 "remove it; the store holds plain files only."
122 ) from e
123 with os.fdopen(fd, "w", encoding="ascii") as f:
124 os.fchmod(fd, 0o600)
125 f.write(env_key + "\n")
126 return path
127
128
129def read_stored_env_key(key_id: str) -> str | None:
130 """The stored key with this id, or None if this machine doesn't have it.
131
132 A store file that can't be read or doesn't hold a key is reported as the
133 store's problem, not blamed on the `.env` file it fails to decrypt.
134 """
135 path = stored_env_key_path(key_id)
136 try:
137 env_key = path.read_text(encoding="ascii").strip()
138 except FileNotFoundError:
139 return None
140 except (OSError, UnicodeDecodeError) as e:
141 raise ImproperlyConfigured(
142 f"Could not read the key stored at {path}: {e}. Remove it and run "
143 "`plain env unlock` again."
144 ) from e
145 if not is_valid_env_key(env_key):
146 raise ImproperlyConfigured(
147 f"The key stored at {path} {INVALID_ENV_KEY_HINT}. Remove it and run "
148 "`plain env unlock` again."
149 )
150 return env_key
151
152
153# --- resolution ---
154
155
156def resolve_env_key(
157 *,
158 env_key: str | None,
159 key_line: tuple[str, str] | None,
160 key_id: str | None,
161 about: str = "",
162) -> str:
163 """The key that decrypts these values, or ImproperlyConfigured saying what to do.
164
165 The key on offer is `env_key` (from the environment) or, failing that,
166 `key_line` (a `NAME=value` key line from a file, as a pair). When the files
167 name an id, the key has to be that one: the offered key if its id matches,
168 else this machine's store. With no id, the offered key is used as-is.
169 `about` prefixes the message with what was being decrypted.
170 """
171 if env_key is not None:
172 offered: str | None = env_key
173 source = f"{ENV_KEY_VAR} in the environment"
174 elif key_line is not None:
175 offered = key_line[1]
176 source = f"the {key_line[0]}= line"
177 else:
178 offered = None
179 source = ""
180
181 if offered is not None and not is_valid_env_key(offered):
182 # Empty, a leftover `$(...)` line and a mistyped paste all fail the same
183 # test; they differ only in what the developer needs to be told. None of
184 # the messages repeat the value: it is meant to be a secret.
185 if offered.startswith("$("):
186 raise ImproperlyConfigured(
187 f"{about}{source} is a $(...) command, and commands no longer run "
188 "in .env files. Run that command yourself and pipe its output "
189 "into `plain env unlock`, which stores the key on this machine "
190 f"and replaces the line with {ENV_KEY_ID_VAR}."
191 )
192 if not offered:
193 raise ImproperlyConfigured(f"{about}{source} is set to an empty value.")
194 raise ImproperlyConfigured(f"{source} {INVALID_ENV_KEY_HINT}.")
195
196 if key_id:
197 offered_id = env_key_id(offered) if offered is not None else None
198 if offered is not None and offered_id == key_id:
199 return offered
200 stored = read_stored_env_key(key_id)
201 if stored is not None:
202 return stored
203 if offered_id is not None:
204 raise ImproperlyConfigured(
205 f"{source} is key {offered_id}, but {ENV_KEY_ID_VAR} names key "
206 f"{key_id}: it is not the key these values were encrypted with."
207 )
208 raise ImproperlyConfigured(
209 f"{about}key {key_id} is not on this machine. Get this project's key "
210 "from wherever your team keeps it and pipe it into `plain env unlock`, "
211 f"or set {ENV_KEY_VAR} in the environment."
212 )
213
214 if offered is not None:
215 return offered
216
217 raise ImproperlyConfigured(
218 f"{about}no key is available: {ENV_KEY_VAR} is not set and no file names "
219 f"one with {ENV_KEY_ID_VAR}. For a new project run `plain env init`; for an "
220 "existing one, get its key and pipe it into `plain env unlock`."
221 )