py-app-runner 0.5.49.dev0__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.
Files changed (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,164 @@
1
+ """Command implementations, importable directly for programmatic use.
2
+
3
+ Each takes its collaborators explicitly and returns a process exit code rather than
4
+ printing and exiting, so the whole surface is testable without capturing stdout.
5
+ """
6
+
7
+ import re
8
+ from collections.abc import Callable
9
+
10
+ import psycopg
11
+ from psycopg import sql
12
+
13
+ from py_app_runner.crypto.errors import CryptoError
14
+ from py_app_runner.crypto.fields import FieldCrypto, generate_key, key_id_of
15
+
16
+ Out = Callable[[str], None]
17
+
18
+ # The table and column reach SQL as identifiers, which cannot be bound as parameters. They
19
+ # go through psycopg's Identifier for quoting, but are whitelisted first: Identifier will
20
+ # happily quote `people; DROP TABLE people` into something valid-but-wrong, and a rotate
21
+ # run against a table nobody meant to name is not recoverable.
22
+ _TABLE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
23
+ _COLUMN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
24
+
25
+
26
+ def cmd_key(out: Out) -> int:
27
+ """Print fresh key material. Touches no config and no database, deliberately - the first
28
+ key has to be generatable before there is a working install to generate it from."""
29
+
30
+ out(generate_key())
31
+ out("")
32
+ out('Put it in the environment variable named by config["crypto"]["keys"],')
33
+ out("not in a config file. Keep the previous key listed until:")
34
+ out(" python3 src/app.py crypto rotate --table T --column C")
35
+ out("reports nothing left under it.")
36
+
37
+ return 0
38
+
39
+
40
+ def _qualified(table: str) -> sql.Composed:
41
+ """Split an optional schema qualifier so each half is quoted separately.
42
+
43
+ Identifier("public.people") quotes the dot into the name, producing "public.people" as
44
+ a single relation that does not exist.
45
+ """
46
+
47
+ return sql.SQL(".").join(sql.Identifier(part) for part in table.split("."))
48
+
49
+
50
+ async def cmd_rotate(
51
+ conn: psycopg.AsyncConnection,
52
+ crypto: FieldCrypto,
53
+ table: str,
54
+ column: str,
55
+ id_column: str,
56
+ batch: int,
57
+ dry_run: bool,
58
+ out: Out,
59
+ ) -> int:
60
+ """Re-encrypt a column onto the current key.
61
+
62
+ Idempotent: a second run costs a scan and changes nothing. Values already under the
63
+ current key are skipped, and values that are not encrypted at all are left alone and
64
+ counted, because a half-backfilled column is a normal state rather than an error.
65
+ """
66
+
67
+ if _TABLE_RE.match(table) is None:
68
+ out(f"error: {table!r} is not a plain table name")
69
+ return 2
70
+
71
+ if _COLUMN_RE.match(column) is None:
72
+ out(f"error: --column={column!r} is not a plain column name")
73
+ return 2
74
+
75
+ if _COLUMN_RE.match(id_column) is None:
76
+ out(f"error: --id={id_column!r} is not a plain column name")
77
+ return 2
78
+
79
+ if batch < 1:
80
+ out("error: --batch must be at least 1")
81
+ return 2
82
+
83
+ try:
84
+ current = crypto.current_key_id()
85
+ except CryptoError as e:
86
+ out(f"error: {e}")
87
+ return 1
88
+
89
+ out(f"Rotating {table}.{column} onto key {current!r}" + (" (--dry-run)" if dry_run else ""))
90
+
91
+ relation = _qualified(table)
92
+ id_ident = sql.Identifier(id_column)
93
+ column_ident = sql.Identifier(column)
94
+
95
+ # The first page has no cursor. `id > NULL` is unknown rather than true, so it would
96
+ # match no rows and the whole rotate would report an empty table; the guard makes the
97
+ # predicate short-circuit instead of writing a second query for the first page.
98
+ read = sql.SQL(
99
+ "SELECT {id} AS id, {col} AS value FROM {rel} "
100
+ "WHERE (%(cursor)s IS NULL OR {id} > %(cursor)s) ORDER BY {id} LIMIT %(batch)s"
101
+ ).format(id=id_ident, col=column_ident, rel=relation)
102
+ write = sql.SQL("UPDATE {rel} SET {col} = %s WHERE {id} = %s").format(rel=relation, col=column_ident, id=id_ident)
103
+
104
+ cursor: object = None
105
+ seen = 0
106
+ rotated = 0
107
+ plaintext = 0
108
+
109
+ while True:
110
+ try:
111
+ async with conn.cursor() as cur:
112
+ # Keyset pagination rather than OFFSET: OFFSET re-reads and discards every
113
+ # row it skips, so the last page of a large table costs a full scan, and a
114
+ # row updated mid-run shifts the window under it.
115
+ await cur.execute(read, {"cursor": cursor, "batch": batch})
116
+ rows = await cur.fetchall()
117
+ except psycopg.Error as e:
118
+ out(f"error: cannot read {table}: {e}")
119
+ return 1
120
+
121
+ if not rows:
122
+ break
123
+
124
+ for row_id, value in rows:
125
+ seen += 1
126
+ cursor = row_id
127
+
128
+ if value is None or value == "":
129
+ continue
130
+
131
+ found = key_id_of(value)
132
+ if found is None:
133
+ plaintext += 1
134
+ continue
135
+
136
+ if found == current:
137
+ continue
138
+
139
+ rotated += 1
140
+ if dry_run:
141
+ continue
142
+
143
+ try:
144
+ fresh = crypto.encrypt(str(crypto.decrypt(value)), current)
145
+ except CryptoError as e:
146
+ out(f"error: {id_column} {row_id}: {e}")
147
+ return 1
148
+
149
+ try:
150
+ async with conn.cursor() as cur:
151
+ await cur.execute(write, (fresh, row_id))
152
+ except psycopg.Error as e:
153
+ out(f"error: {id_column} {row_id}: {e}")
154
+ return 1
155
+
156
+ if len(rows) < batch:
157
+ break
158
+
159
+ out(f"Read {seen} rows.")
160
+ if plaintext > 0:
161
+ out(f"{plaintext} were not encrypted and were left alone.")
162
+ out(f"{rotated} would be re-encrypted." if dry_run else f"{rotated} re-encrypted.")
163
+
164
+ return 0
@@ -0,0 +1,144 @@
1
+ """The binary envelope shared with end-to-end encrypted clients.
2
+
3
+ magic(3) | version(1) | nonce(12) | ciphertext(n) | tag(16)
4
+
5
+ This is the format `copasty-server`'s clients already write, where it exists only as
6
+ JavaScript in the share viewer. Having no Python side to it is why the server currently
7
+ validates an uploaded envelope by its length alone and would store arbitrary bytes just as
8
+ happily as a real one.
9
+
10
+ The magic and version are parameters rather than constants: `CPE` belongs to Copasty, and
11
+ a second application gets its own. Nothing here needs the key - `validate()` is the call a
12
+ server that will never hold one still wants, so that a blob which cannot possibly decrypt
13
+ is refused at upload rather than at read.
14
+ """
15
+
16
+ import base64
17
+ import os
18
+ from dataclasses import dataclass
19
+
20
+ from cryptography.exceptions import InvalidTag
21
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
22
+
23
+ from py_app_runner.crypto.errors import CryptoError
24
+
25
+ MAGIC_BYTES = 3
26
+ NONCE_BYTES = 12
27
+ TAG_BYTES = 16
28
+
29
+ # magic + version + nonce + tag, i.e. an envelope around an empty plaintext. Anything
30
+ # shorter cannot be one whatever it contains.
31
+ MIN_ENVELOPE_BYTES = MAGIC_BYTES + 1 + NONCE_BYTES + TAG_BYTES
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Envelope:
36
+ magic: bytes
37
+ version: int
38
+ nonce: bytes
39
+ # Ciphertext with the AEAD tag still appended. Kept joined because that is what both
40
+ # WebCrypto's decrypt and `cryptography`'s AESGCM.decrypt expect to be handed, so
41
+ # splitting it here would only mean rejoining it at both call sites.
42
+ body: bytes
43
+
44
+
45
+ def b64url_decode(value: str) -> bytes:
46
+ """Decode the base64url alphabet, restoring padding first.
47
+
48
+ Keys travel to a browser in the URL fragment, which rules out `+` and `/`, and the
49
+ padding is routinely dropped on the way. `base64.urlsafe_b64decode` rejects a string
50
+ whose length is not a multiple of four rather than assuming the padding, so it has to
51
+ be put back before decoding.
52
+ """
53
+
54
+ padded = value + "=" * (-len(value) % 4)
55
+ try:
56
+ return base64.urlsafe_b64decode(padded)
57
+ except (ValueError, TypeError) as e:
58
+ raise CryptoError(f"Value is not valid base64url: {e}") from e
59
+
60
+
61
+ def b64url_encode(raw: bytes) -> str:
62
+ """Encode to base64url without padding, which is what a URL fragment wants."""
63
+
64
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
65
+
66
+
67
+ def parse(blob: bytes, magic: bytes = b"CPE", version: int = 1) -> Envelope:
68
+ """Read an envelope, or raise explaining which part of it is wrong."""
69
+
70
+ if len(blob) < MIN_ENVELOPE_BYTES:
71
+ raise CryptoError(f"Envelope is {len(blob)} bytes, and the header alone needs {MIN_ENVELOPE_BYTES}.")
72
+
73
+ if len(magic) != MAGIC_BYTES:
74
+ raise CryptoError(f"An envelope magic is {MAGIC_BYTES} bytes; {magic!r} is {len(magic)}.")
75
+
76
+ found_magic = blob[:MAGIC_BYTES]
77
+ if found_magic != magic:
78
+ raise CryptoError(f"Envelope starts with {found_magic!r}, not {magic!r}.")
79
+
80
+ found_version = blob[MAGIC_BYTES]
81
+ if found_version != version:
82
+ raise CryptoError(
83
+ f"Envelope is version {found_version}, and this build reads version {version}. "
84
+ f"It was written by a newer client."
85
+ )
86
+
87
+ return Envelope(
88
+ magic=found_magic,
89
+ version=found_version,
90
+ nonce=blob[MAGIC_BYTES + 1 : MAGIC_BYTES + 1 + NONCE_BYTES],
91
+ body=blob[MAGIC_BYTES + 1 + NONCE_BYTES :],
92
+ )
93
+
94
+
95
+ def validate(blob: bytes, magic: bytes = b"CPE", version: int = 1) -> None:
96
+ """Refuse a blob that cannot be an envelope, without needing the key.
97
+
98
+ This is the check a server storing opaque ciphertext can still make. It says nothing
99
+ about whether the payload decrypts - only the holder of the key can know that - but it
100
+ does stop a client uploading arbitrary bytes into a column the whole system treats as
101
+ an envelope.
102
+ """
103
+
104
+ parse(blob, magic, version)
105
+
106
+
107
+ def pack(nonce: bytes, body: bytes, magic: bytes = b"CPE", version: int = 1) -> bytes:
108
+ if len(nonce) != NONCE_BYTES:
109
+ raise CryptoError(f"An envelope nonce is {NONCE_BYTES} bytes; got {len(nonce)}.")
110
+
111
+ if len(magic) != MAGIC_BYTES:
112
+ raise CryptoError(f"An envelope magic is {MAGIC_BYTES} bytes; {magic!r} is {len(magic)}.")
113
+
114
+ if not 0 <= version <= 255:
115
+ raise CryptoError(f"An envelope version is a single byte; got {version}.")
116
+
117
+ return magic + bytes([version]) + nonce + body
118
+
119
+
120
+ def encrypt(key: bytes, plaintext: bytes, magic: bytes = b"CPE", version: int = 1) -> bytes:
121
+ """Build an envelope. Only for the case where the server legitimately holds the key.
122
+
123
+ An end-to-end encrypted application never calls this: the point there is that the
124
+ server cannot. It exists for the other case - a server-side blob that happens to use
125
+ the same wire format, so a browser can be handed the key later and open it.
126
+ """
127
+
128
+ if len(key) != 32:
129
+ raise CryptoError(f"An envelope key is 32 bytes (AES-256); got {len(key)}.")
130
+
131
+ nonce = os.urandom(NONCE_BYTES)
132
+ body = AESGCM(key).encrypt(nonce, plaintext, None)
133
+ return pack(nonce, body, magic, version)
134
+
135
+
136
+ def decrypt(key: bytes, blob: bytes, magic: bytes = b"CPE", version: int = 1) -> bytes:
137
+ if len(key) != 32:
138
+ raise CryptoError(f"An envelope key is 32 bytes (AES-256); got {len(key)}.")
139
+
140
+ parsed = parse(blob, magic, version)
141
+ try:
142
+ return AESGCM(key).decrypt(parsed.nonce, parsed.body, None)
143
+ except InvalidTag as e:
144
+ raise CryptoError("Could not decrypt an envelope: wrong key, or the value has been altered.") from e
@@ -0,0 +1,8 @@
1
+ class CryptoError(Exception):
2
+ """Anything wrong with key material, configuration, or a stored value's shape.
3
+
4
+ Never caught and turned into a None by this package. A field that cannot be decrypted
5
+ has to reach somebody: silently returning None puts an empty string in front of a user
6
+ and leaves the real value sitting unreadable in the database, which is discovered
7
+ months later when somebody asks why a column is full of blanks.
8
+ """
@@ -0,0 +1,300 @@
1
+ """Field encryption: encrypt a column the application has to read back.
2
+
3
+ Explicit at every call site. Nothing hooks into the database layer, so a value cannot be
4
+ encrypted twice or written in the clear because a hook did not fire - the two failure modes
5
+ that make transparent column encryption so unpleasant to debug.
6
+
7
+ Stored values look like:
8
+
9
+ pa1:<key_id>:<base64 of nonce(12) || ciphertext || tag(16)>
10
+
11
+ carrying a version and a key id, which is what lets a retired key keep decrypting old rows,
12
+ lets a column hold plaintext and ciphertext while a backfill runs, and lets `crypto rotate`
13
+ tell what still needs rewriting.
14
+ """
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import os
20
+ import re
21
+ from collections.abc import Callable
22
+ from typing import Any
23
+
24
+ from cryptography.exceptions import InvalidTag
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
26
+
27
+ from py_app_runner.crypto.errors import CryptoError
28
+
29
+ VERSION = "pa1"
30
+ KEY_BYTES = 32
31
+ NONCE_BYTES = 12
32
+ TAG_BYTES = 16
33
+
34
+ # Another field-encryption format in use across the estate, on a different cipher with a
35
+ # 24-byte nonce. Recognised only so that a value in it is refused with an explanation rather
36
+ # than passed through as though it were plaintext, which is what a column mid-backfill would
37
+ # otherwise make it look like.
38
+ _FOREIGN_VERSION = "sp1"
39
+
40
+ # Lowercase, no dash, no dot, and above all no colon: the id sits next to the ":"
41
+ # separators in the stored value, so a colon in it would make the value unsplittable.
42
+ # Constrained rather than escaped, for the same reason a table name is whitelisted rather
43
+ # than quoted.
44
+ _KEY_ID_RE = re.compile(r"^[a-z0-9_]{1,16}$")
45
+
46
+ # The blind index key is cached under a reserved id that the pattern above cannot match,
47
+ # so it can never collide with a real key id.
48
+ _INDEX_CACHE_ID = "@index"
49
+
50
+ Resolver = Callable[[str], str | None]
51
+
52
+
53
+ class FieldCrypto:
54
+ """Holds the configured keys. Build one per process; it caches decoded key material.
55
+
56
+ An instance rather than a module of functions, so the decoded-key cache has an obvious
57
+ lifetime: it is dropped by rebuilding the object. A module-level cache would need an
58
+ explicit invalidation call that nothing would remember to make after a key change.
59
+ `AppRegistry` already owns the process-global config this is built from.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ key_id: str,
65
+ keys: dict[str, str],
66
+ index_key_var: str = "",
67
+ resolver: Resolver | None = None,
68
+ ) -> None:
69
+ self.key_id = key_id
70
+ self.keys = keys
71
+ self.index_key_var = index_key_var
72
+ # Indirection so a test - or a deployment reading from a secrets manager rather than
73
+ # the environment - can supply key material without setting real environment
74
+ # variables in the process.
75
+ self.resolver: Resolver = resolver if resolver is not None else os.environ.get
76
+ self._cache: dict[str, bytes] = {}
77
+
78
+ @classmethod
79
+ def from_config(cls, config: dict[str, Any], resolver: Resolver | None = None) -> "FieldCrypto":
80
+ settings = config.get("crypto") or {}
81
+ raw_keys = settings.get("keys") or {}
82
+ if not isinstance(raw_keys, dict):
83
+ raise CryptoError('config["crypto"]["keys"] must be a mapping of key id -> env var name.')
84
+
85
+ return cls(
86
+ key_id=settings.get("key") or "",
87
+ keys={str(k): str(v) for k, v in raw_keys.items()},
88
+ index_key_var=settings.get("index_key") or "",
89
+ resolver=resolver,
90
+ )
91
+
92
+ ####################
93
+ ### Key material ###
94
+ ####################
95
+
96
+ def current_key_id(self) -> str:
97
+ if not self.key_id:
98
+ raise CryptoError(
99
+ 'config["crypto"]["key"] does not name a key to encrypt with. '
100
+ "Generate one with: python3 src/app.py crypto key"
101
+ )
102
+
103
+ return self._assert_key_id(self.key_id)
104
+
105
+ def _assert_key_id(self, key_id: str) -> str:
106
+ if _KEY_ID_RE.match(key_id) is None:
107
+ raise CryptoError(f"{key_id!r} is not a usable key id. Use up to 16 of a-z, 0-9 and underscore.")
108
+
109
+ return key_id
110
+
111
+ def _key(self, key_id: str) -> bytes:
112
+ self._assert_key_id(key_id)
113
+
114
+ cached = self._cache.get(key_id)
115
+ if cached is not None:
116
+ return cached
117
+
118
+ variable = self.keys.get(key_id)
119
+ if not variable:
120
+ raise CryptoError(
121
+ f'No key {key_id!r} in config["crypto"]["keys"]. A value encrypted under a key id '
122
+ f"can only be read while that id is still listed, so removing one is permanent."
123
+ )
124
+
125
+ material = self._material(variable, f"key {key_id!r}")
126
+ self._cache[key_id] = material
127
+ return material
128
+
129
+ def _index_key(self) -> bytes:
130
+ cached = self._cache.get(_INDEX_CACHE_ID)
131
+ if cached is not None:
132
+ return cached
133
+
134
+ if not self.index_key_var:
135
+ raise CryptoError(
136
+ 'config["crypto"]["index_key"] is not set, so blind_index() has no key. '
137
+ "Generate one with: python3 src/app.py crypto key"
138
+ )
139
+
140
+ material = self._material(self.index_key_var, "the blind index key")
141
+ self._cache[_INDEX_CACHE_ID] = material
142
+ return material
143
+
144
+ def _material(self, variable: str, label: str) -> bytes:
145
+ value = self.resolver(variable)
146
+ if not value:
147
+ raise CryptoError(
148
+ f"The environment variable {variable}, holding {label}, is not set. "
149
+ f"Generate one with: python3 src/app.py crypto key"
150
+ )
151
+
152
+ try:
153
+ raw = base64.b64decode(value, validate=True)
154
+ except (ValueError, TypeError) as e:
155
+ raise CryptoError(
156
+ f"{variable}, holding {label}, must be {KEY_BYTES} bytes of base64. "
157
+ f"Generate one with: python3 src/app.py crypto key"
158
+ ) from e
159
+
160
+ if len(raw) != KEY_BYTES:
161
+ raise CryptoError(
162
+ f"{variable}, holding {label}, must be {KEY_BYTES} bytes of base64, "
163
+ f"and decoded to {len(raw)}. "
164
+ f"Generate one with: python3 src/app.py crypto key"
165
+ )
166
+
167
+ return raw
168
+
169
+ ###################
170
+ ### Encrypt/dec ###
171
+ ###################
172
+
173
+ def encrypt(self, plaintext: str, key_id: str | None = None) -> str:
174
+ """Encrypt under the current key, or under `key_id` when rotating onto a new one."""
175
+
176
+ chosen = key_id if key_id else self.current_key_id()
177
+ key = self._key(chosen)
178
+
179
+ nonce = os.urandom(NONCE_BYTES)
180
+ # The version and key id are bound as associated data, so the label a value carries
181
+ # is authenticated rather than merely present. Without this an attacker with write
182
+ # access to the column could relabel a value's key id; that only ever degrades to a
183
+ # failed decrypt rather than a forgery, but binding it costs nothing.
184
+ body = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), self._aad(chosen))
185
+
186
+ return f"{VERSION}:{chosen}:{base64.b64encode(nonce + body).decode('ascii')}"
187
+
188
+ def decrypt(self, value: str | None) -> str | None:
189
+ """Decrypt a stored value, passing anything that is not one straight through.
190
+
191
+ A column being backfilled holds both plaintext and ciphertext at once, so a value
192
+ that does not carry the format is returned verbatim rather than treated as an error.
193
+ A value that *does* carry it and still will not open is always an error.
194
+ """
195
+
196
+ if value is None or value == "":
197
+ return value
198
+
199
+ parts = split(value)
200
+ if parts is None:
201
+ if value.startswith(f"{_FOREIGN_VERSION}:"):
202
+ raise CryptoError(
203
+ f"This value is in the {_FOREIGN_VERSION!r} field-encryption format, which uses "
204
+ f"a different cipher and nonce length. This module reads {VERSION!r} "
205
+ f"(AES-256-GCM) and cannot open it."
206
+ )
207
+
208
+ return value
209
+
210
+ key_id, payload = parts
211
+
212
+ try:
213
+ raw = base64.b64decode(payload, validate=True)
214
+ except (ValueError, TypeError) as e:
215
+ raise CryptoError(f"Encrypted value under key {key_id!r} is not base64") from e
216
+
217
+ if len(raw) < NONCE_BYTES + TAG_BYTES:
218
+ raise CryptoError(
219
+ f"Encrypted value under key {key_id!r} is truncated: {len(raw)} bytes, and the "
220
+ f"nonce and tag alone need {NONCE_BYTES + TAG_BYTES}."
221
+ )
222
+
223
+ nonce = raw[:NONCE_BYTES]
224
+ body = raw[NONCE_BYTES:]
225
+
226
+ try:
227
+ plaintext = AESGCM(self._key(key_id)).decrypt(nonce, body, self._aad(key_id))
228
+ except InvalidTag as e:
229
+ # Wrong key or an edited value, and AES-GCM cannot say which. Both mean the same
230
+ # thing to the caller: do not trust this row.
231
+ raise CryptoError(
232
+ f"Could not decrypt a value under key {key_id!r}: wrong key, or the value has been altered"
233
+ ) from e
234
+
235
+ return plaintext.decode("utf-8")
236
+
237
+ def _aad(self, key_id: str) -> bytes:
238
+ return f"{VERSION}:{key_id}".encode("ascii")
239
+
240
+ ###################
241
+ ### Blind index ###
242
+ ###################
243
+
244
+ def blind_index(self, value: str) -> str:
245
+ """A keyed, deterministic index that restores equality lookups on an encrypted column.
246
+
247
+ Store it in a second column and query that. Ranges and LIKE do not come back, and
248
+ equality across rows becomes visible to anyone holding the table - that is inherent
249
+ to a deterministic index, and the separate key is what stops a dump-holder simply
250
+ hashing candidate values to find them.
251
+
252
+ Nothing is normalised here. Whether " Anna " and "anna" should match is a question
253
+ about the column, not about hashing, so the caller answers it.
254
+ """
255
+
256
+ return hmac.new(self._index_key(), value.encode("utf-8"), hashlib.sha256).hexdigest()
257
+
258
+
259
+ #########################
260
+ ### Format inspection ###
261
+ #########################
262
+
263
+
264
+ def split(value: str) -> tuple[str, str] | None:
265
+ """Pull the key id and payload out of a stored value, or None when it is not one."""
266
+
267
+ if not value.startswith(f"{VERSION}:"):
268
+ return None
269
+
270
+ parts = value.split(":", 2)
271
+ if len(parts) != 3 or parts[1] == "" or parts[2] == "":
272
+ return None
273
+
274
+ if _KEY_ID_RE.match(parts[1]) is None:
275
+ return None
276
+
277
+ return (parts[1], parts[2])
278
+
279
+
280
+ def is_encrypted(value: str | None) -> bool:
281
+ return value is not None and value != "" and split(value) is not None
282
+
283
+
284
+ def key_id_of(value: str | None) -> str | None:
285
+ """Which key a stored value is under, without needing that key to be configured.
286
+
287
+ This is what lets `crypto rotate` report on values it cannot decrypt.
288
+ """
289
+
290
+ if value is None or value == "":
291
+ return None
292
+
293
+ parts = split(value)
294
+ return None if parts is None else parts[0]
295
+
296
+
297
+ def generate_key() -> str:
298
+ """Fresh key material, base64 encoded, for an environment variable."""
299
+
300
+ return base64.b64encode(os.urandom(KEY_BYTES)).decode("ascii")
@@ -0,0 +1,66 @@
1
+ """Password hashing.
2
+
3
+ Thin on purpose. bcrypt already embeds its salt and cost in the `$2b$` string it returns,
4
+ so a schema needs one text column and nothing else - a separate `salt` column stores a
5
+ second copy of something the hash already carries, and has to be kept in step with it for
6
+ no benefit.
7
+ """
8
+
9
+ import bcrypt
10
+
11
+ from py_app_runner.crypto.errors import CryptoError
12
+
13
+ DEFAULT_ROUNDS = 12
14
+
15
+ # bcrypt truncates at 72 bytes and, depending on the build, either ignores the rest
16
+ # silently or raises. Neither is a good outcome for a user whose passphrase is long, so the
17
+ # limit is checked here where the message can say what happened.
18
+ MAX_PASSWORD_BYTES = 72
19
+
20
+
21
+ class PasswordHasher:
22
+ def __init__(self, rounds: int = DEFAULT_ROUNDS) -> None:
23
+ self.rounds = rounds
24
+
25
+ def hash(self, password: str) -> str:
26
+ return bcrypt.hashpw(self._encode(password), bcrypt.gensalt(self.rounds)).decode("ascii")
27
+
28
+ def verify(self, password: str, stored: str) -> bool:
29
+ """False for a wrong password *and* for a malformed hash.
30
+
31
+ A stored value that is not a bcrypt hash at all - a truncated column, a migration
32
+ that put something else there - must not raise out of a login handler, because the
33
+ answer to "may this person in" is still no.
34
+ """
35
+
36
+ try:
37
+ return bcrypt.checkpw(self._encode(password), stored.encode("utf-8"))
38
+ except (ValueError, TypeError):
39
+ return False
40
+
41
+ def needs_rehash(self, stored: str) -> bool:
42
+ """True when a stored hash was made with fewer rounds than are configured now.
43
+
44
+ Call it after a successful verify: that is the one moment the plaintext is in hand
45
+ and the hash can be upgraded without asking the user for anything.
46
+ """
47
+
48
+ try:
49
+ cost = int(stored.split("$")[2])
50
+ except (IndexError, ValueError):
51
+ # Unparseable means it is not a bcrypt hash this class wrote, so replacing it is
52
+ # exactly what should happen next time there is a plaintext to do it with.
53
+ return True
54
+
55
+ return cost < self.rounds
56
+
57
+ def _encode(self, password: str) -> bytes:
58
+ raw = password.encode("utf-8")
59
+ if len(raw) > MAX_PASSWORD_BYTES:
60
+ raise CryptoError(
61
+ f"bcrypt hashes at most {MAX_PASSWORD_BYTES} bytes and this password is "
62
+ f"{len(raw)}. Everything past that is ignored, so accepting it would mean two "
63
+ f"different passwords unlocking the same account."
64
+ )
65
+
66
+ return raw