mailroom-core 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.
@@ -0,0 +1 @@
1
+ """Mailroom's mail layer, shared by mailroom-mcp and the Mailroom app."""
@@ -0,0 +1,67 @@
1
+ """Accounts and limits: core's half of the config (`src/config/`), the rest being mailroom-mcp's."""
2
+
3
+ from mailroom_core.config.errors import (
4
+ ConfigError,
5
+ ConfigIssue,
6
+ config_problem,
7
+ issues_text,
8
+ validation_issues,
9
+ )
10
+ from mailroom_core.config.load import (
11
+ CoreConfig,
12
+ LoadedCore,
13
+ loaded_core,
14
+ sealed_env_secrets,
15
+ without_secret_envs,
16
+ )
17
+ from mailroom_core.config.schema import (
18
+ MAX_SAFE_INTEGER,
19
+ SEND_MODES,
20
+ AccountAuth,
21
+ AccountConfig,
22
+ CoreSettings,
23
+ Endpoint,
24
+ EnvName,
25
+ Identity,
26
+ Limits,
27
+ NonEmpty,
28
+ PasswordAuth,
29
+ SendMode,
30
+ Slug,
31
+ StrictModel,
32
+ TimeZone,
33
+ TimeZoneName,
34
+ core_settings_or_issues,
35
+ time_zone_name,
36
+ )
37
+
38
+ __all__ = [
39
+ "MAX_SAFE_INTEGER",
40
+ "SEND_MODES",
41
+ "AccountAuth",
42
+ "AccountConfig",
43
+ "ConfigError",
44
+ "ConfigIssue",
45
+ "CoreConfig",
46
+ "CoreSettings",
47
+ "Endpoint",
48
+ "EnvName",
49
+ "Identity",
50
+ "Limits",
51
+ "LoadedCore",
52
+ "NonEmpty",
53
+ "PasswordAuth",
54
+ "SendMode",
55
+ "Slug",
56
+ "StrictModel",
57
+ "TimeZone",
58
+ "TimeZoneName",
59
+ "config_problem",
60
+ "core_settings_or_issues",
61
+ "issues_text",
62
+ "loaded_core",
63
+ "sealed_env_secrets",
64
+ "time_zone_name",
65
+ "validation_issues",
66
+ "without_secret_envs",
67
+ ]
@@ -0,0 +1,100 @@
1
+ """Config errors, worded as `src/config/load.ts` words them (zod's `prettifyError`)."""
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pydantic_core import PydanticCustomError
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Iterable
12
+
13
+ from pydantic import ValidationError
14
+
15
+ type _IssuePath = tuple[str | int, ...]
16
+
17
+
18
+ class ConfigError(Exception):
19
+ """A config or settings document that cannot be used; the message says why, never a secret."""
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ConfigIssue:
24
+ """One problem in a document, at a path like `account[0].smtp.tls`."""
25
+
26
+ path: _IssuePath
27
+ message: str
28
+
29
+
30
+ def config_problem(message: str) -> PydanticCustomError:
31
+ """A validation error worded exactly `message` (a ValueError would get a prefix)."""
32
+ return PydanticCustomError("config", "{message}", {"message": message})
33
+
34
+
35
+ def validation_issues(error: ValidationError, prefix: _IssuePath = ()) -> list[ConfigIssue]:
36
+ """Pydantic's errors as issues, the unknown keys of one table in one issue as zod has them."""
37
+ details = error.errors(include_url=False, include_input=False)
38
+ paths = [prefix + tuple(detail["loc"]) for detail in details]
39
+ found: list[tuple[_IssuePath, str | list[str]]] = []
40
+ unknown: dict[_IssuePath, list[str]] = {}
41
+ for detail, path in zip(details, paths, strict=True):
42
+ if detail["type"] == "too_short":
43
+ # Pydantic counts the items that passed: an array whose items failed is also short.
44
+ if any(other[: len(path)] == path and other != path for other in paths):
45
+ continue
46
+ minimum = (detail.get("ctx") or {}).get("min_length")
47
+ found.append((path, f"Too small: expected array to have >={minimum} items"))
48
+ elif detail["type"] == "string_too_short":
49
+ minimum = (detail.get("ctx") or {}).get("min_length")
50
+ found.append((path, f"Too small: expected string to have >={minimum} characters"))
51
+ elif detail["type"] != "extra_forbidden":
52
+ found.append((path, _MESSAGES.get(detail["type"], detail["msg"])))
53
+ elif path[:-1] in unknown:
54
+ unknown[path[:-1]].append(str(path[-1]))
55
+ else:
56
+ unknown[path[:-1]] = [str(path[-1])]
57
+ found.append((path[:-1], unknown[path[:-1]]))
58
+ return [
59
+ ConfigIssue(path, message if isinstance(message, str) else _unrecognized(message))
60
+ for path, message in found
61
+ ]
62
+
63
+
64
+ def issues_text(issues: Iterable[ConfigIssue]) -> str:
65
+ """One `✖ message` line per issue, then ` → at path` unless it is about the whole document."""
66
+ lines: list[str] = []
67
+ for issue in sorted(issues, key=lambda issue: len(issue.path)):
68
+ lines.append(f"✖ {issue.message}")
69
+ if issue.path:
70
+ lines.append(f" → at {_dot_path(issue.path)}")
71
+ return "\n".join(lines)
72
+
73
+
74
+ # Pydantic's wording names Python types; the documents are TOML or JSON.
75
+ _MESSAGES = {
76
+ "tuple_type": "Input should be an array",
77
+ "model_type": "Input should be an object",
78
+ "model_attributes_type": "Input should be an object",
79
+ "dict_type": "Input should be an object",
80
+ }
81
+
82
+
83
+ def _unrecognized(keys: list[str]) -> str:
84
+ quoted = ", ".join(json.dumps(key, ensure_ascii=False) for key in keys)
85
+ return f"Unrecognized key{'s' if len(keys) > 1 else ''}: {quoted}"
86
+
87
+
88
+ _PLAIN_KEY = re.compile(r"[\w$]+", re.ASCII)
89
+
90
+
91
+ def _dot_path(path: _IssuePath) -> str:
92
+ out = ""
93
+ for segment in path:
94
+ if isinstance(segment, int):
95
+ out += f"[{segment}]"
96
+ elif not _PLAIN_KEY.fullmatch(segment):
97
+ out += f"[{json.dumps(segment, ensure_ascii=False)}]"
98
+ else:
99
+ out += f".{segment}" if out else segment
100
+ return out
@@ -0,0 +1,176 @@
1
+ """Accounts and limits, checked, their secrets opened: core's half of `src/config/load.ts`.
2
+
3
+ mailroom-mcp adds the bootstrap file and the profiles; `CoreConfig` is the config without them,
4
+ which the send gate takes.
5
+ """
6
+
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from types import MappingProxyType
10
+
11
+ from mailroom_core.config.errors import ConfigError
12
+ from mailroom_core.config.schema import (
13
+ AccountConfig,
14
+ CoreSettings,
15
+ Limits,
16
+ TimeZoneName,
17
+ is_env_name,
18
+ )
19
+ from mailroom_core.crypto import SealedSecretError, SecretsSubkey, open_secret, seal_secret
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class CoreConfig:
24
+ """The running accounts and limits: what the mail layer and the send gate need."""
25
+
26
+ time_zone: TimeZoneName
27
+ """IANA time zone of the dates shown to the model."""
28
+ limits: Limits
29
+ accounts: tuple[AccountConfig, ...]
30
+ secrets: Mapping[str, str] = field(repr=False)
31
+ """Mailbox password or token per running account id."""
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class LoadedCore:
36
+ config: CoreConfig
37
+ warnings: tuple[str, ...]
38
+ """Problems worth logging that do not stop the server."""
39
+ inactive: tuple[str, ...]
40
+ """Accounts left out of the config: their stored secret is missing or does not open."""
41
+
42
+
43
+ def without_secret_envs(raw: object) -> tuple[object, Mapping[str, str]]:
44
+ """Take each account's `auth.secret_env` out of raw settings, by account id.
45
+
46
+ Settings may name an account's password by environment variable: config.toml's, being
47
+ imported, or a database's from before 0.2.1. The names come out, for `sealed_env_secrets` to
48
+ store their values; a malformed one stays where it is, for the schema to refuse.
49
+ """
50
+ secret_envs: dict[str, str] = {}
51
+ if not isinstance(raw, Mapping) or not isinstance(raw.get("account"), list):
52
+ return raw, MappingProxyType(secret_envs)
53
+ accounts: list[object] = []
54
+ for acc in raw["account"]:
55
+ auth = acc.get("auth") if isinstance(acc, Mapping) else None
56
+ if not isinstance(auth, Mapping):
57
+ accounts.append(acc)
58
+ continue
59
+ name = auth.get("secret_env")
60
+ account_id = acc.get("id")
61
+ if not isinstance(account_id, str) or not is_env_name(name):
62
+ accounts.append(acc)
63
+ continue
64
+ secret_envs[account_id] = name
65
+ accounts.append({**acc, "auth": {k: v for k, v in auth.items() if k != "secret_env"}})
66
+ return {**raw, "account": accounts}, MappingProxyType(secret_envs)
67
+
68
+
69
+ def sealed_env_secrets(
70
+ subkey: SecretsSubkey | None, secret_envs: Mapping[str, str], env: Mapping[str, str]
71
+ ) -> dict[str, bytes]:
72
+ """The passwords `secret_envs` names, read from `env` and sealed for the database.
73
+
74
+ A missing variable or secrets key is a deployment error: nothing is stored until it is fixed.
75
+ """
76
+ sealed: dict[str, bytes] = {}
77
+ missing: dict[str, None] = {}
78
+ for account_id, name in secret_envs.items():
79
+ value = env.get(name, "")
80
+ if value == "":
81
+ missing[name] = None
82
+ elif subkey is not None:
83
+ sealed[account_id] = seal_secret(subkey, account_id, "password", value)
84
+ if missing:
85
+ raise ConfigError(f"missing secrets in the environment: {', '.join(missing)}")
86
+ if len(sealed) < len(secret_envs):
87
+ raise ConfigError(
88
+ f"storing the passwords of {', '.join(secret_envs)} needs [auth] secrets_key_env"
89
+ " (MAILROOM_SECRETS_KEY)"
90
+ )
91
+ return sealed
92
+
93
+
94
+ def loaded_core(
95
+ time_zone: TimeZoneName,
96
+ settings: CoreSettings,
97
+ subkey: SecretsSubkey | None,
98
+ stored: Mapping[str, bytes] = MappingProxyType({}),
99
+ ) -> LoadedCore:
100
+ """The running accounts, each with its secret opened from `stored` (sealed blobs by id).
101
+
102
+ An account whose stored secret is missing or does not open (no key, another key) is left out
103
+ with a warning, so the settings page still comes up to fix it.
104
+ """
105
+ secrets: dict[str, str] = {}
106
+ inactive: list[tuple[str, str]] = []
107
+ for acc in settings.account:
108
+ secret = _stored_secret(subkey, acc.id, stored)
109
+ if isinstance(secret, _Problem):
110
+ inactive.append((acc.id, secret.reason))
111
+ else:
112
+ secrets[acc.id] = secret
113
+ running = tuple(acc for acc in settings.account if acc.id in secrets)
114
+ return LoadedCore(
115
+ config=CoreConfig(
116
+ time_zone=time_zone,
117
+ limits=settings.limits,
118
+ accounts=running,
119
+ secrets=MappingProxyType(secrets),
120
+ ),
121
+ warnings=(
122
+ *(
123
+ f'account "{account_id}" is not running: {reason}.'
124
+ " Enter its password on the settings page."
125
+ for account_id, reason in inactive
126
+ ),
127
+ *_identity_domain_warnings(running),
128
+ *_auto_approve_warnings(running),
129
+ ),
130
+ inactive=tuple(account_id for account_id, _ in inactive),
131
+ )
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class _Problem:
136
+ reason: str
137
+
138
+
139
+ def _stored_secret(
140
+ subkey: SecretsSubkey | None, account_id: str, stored: Mapping[str, bytes]
141
+ ) -> str | _Problem:
142
+ """An account's stored secret, or why it cannot be used (never the value)."""
143
+ blob = stored.get(account_id)
144
+ if blob is None:
145
+ return _Problem("it has no stored password")
146
+ if subkey is None:
147
+ return _Problem("its stored password needs [auth] secrets_key_env (MAILROOM_SECRETS_KEY)")
148
+ try:
149
+ return open_secret(subkey, account_id, "password", blob)
150
+ except SealedSecretError:
151
+ return _Problem(
152
+ "its stored password does not open with this secrets key (another key, or altered)"
153
+ )
154
+
155
+
156
+ def _domain(address: str) -> str:
157
+ return address[address.rfind("@") + 1 :].lower()
158
+
159
+
160
+ def _identity_domain_warnings(accounts: tuple[AccountConfig, ...]) -> list[str]:
161
+ return [
162
+ f'account "{acc.id}": identity {identity.address} is outside the login\'s domain;'
163
+ " the SMTP server may refuse it"
164
+ for acc in accounts
165
+ for identity in acc.identities
166
+ if "@" in acc.login and _domain(identity.address) != _domain(acc.login)
167
+ ]
168
+
169
+
170
+ def _auto_approve_warnings(accounts: tuple[AccountConfig, ...]) -> list[str]:
171
+ """`auto_approve` only means something in `approval` mode; elsewhere it is likely a mistake."""
172
+ return [
173
+ f'account "{acc.id}": auto_approve has no effect with send_mode "{acc.send_mode}"'
174
+ for acc in accounts
175
+ if acc.auto_approve and acc.send_mode != "approval"
176
+ ]
@@ -0,0 +1,235 @@
1
+ """The account and limit settings: core's half of `src/config/schema.ts` (`settingsSchema`).
2
+
3
+ Profiles, the other half of the settings, and the bootstrap file are mailroom-mcp's. The same
4
+ rules hold for the one-time import from config.toml and for every edit on the settings page.
5
+ """
6
+
7
+ import re
8
+ from functools import cache
9
+ from typing import TYPE_CHECKING, Annotated, Final, Literal, NewType, TypeIs, get_args
10
+ from zoneinfo import available_timezones
11
+
12
+ from pydantic import (
13
+ AfterValidator,
14
+ BaseModel,
15
+ ConfigDict,
16
+ Field,
17
+ Strict,
18
+ StrictBool,
19
+ StrictFloat,
20
+ StrictInt,
21
+ StrictStr,
22
+ ValidationError,
23
+ )
24
+
25
+ from mailroom_core.config.errors import ConfigError, ConfigIssue, config_problem, validation_issues
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Callable
29
+
30
+ type SendMode = Literal["off", "drafts", "approval", "direct"]
31
+ SEND_MODES: Final[tuple[SendMode, ...]] = get_args(SendMode.__value__)
32
+
33
+ MAX_SAFE_INTEGER: Final = 2**53 - 1
34
+ """The largest integer a JavaScript number holds exactly: the TypeScript server's bound on
35
+ integers and durations, kept so that both accept the same settings."""
36
+
37
+ TimeZoneName = NewType("TimeZoneName", str)
38
+ """An IANA time zone known here: made by `time_zone_name` or by a `TimeZone` field only."""
39
+ _TIME_ZONE_PROBLEM = 'must be an IANA time zone, e.g. "Europe/Rome"'
40
+
41
+
42
+ @cache
43
+ def _time_zones() -> dict[str, str]:
44
+ return {zone.lower(): zone for zone in available_timezones()}
45
+
46
+
47
+ def _canonical_time_zone(zone: str) -> str | None:
48
+ """The IANA name `zone` spells in any letter case, as Intl accepts it; None if unknown."""
49
+ return _time_zones().get(zone.lower()) if zone.isascii() else None
50
+
51
+
52
+ def time_zone_name(zone: str) -> TimeZoneName:
53
+ """`zone` checked as an IANA time zone and given its canonical case; raises `ConfigError`
54
+ otherwise."""
55
+ canonical = _canonical_time_zone(zone)
56
+ if canonical is None:
57
+ raise ConfigError(f"time zone {zone!r} {_TIME_ZONE_PROBLEM}")
58
+ return TimeZoneName(canonical)
59
+
60
+
61
+ def _check_time_zone(zone: TimeZoneName) -> TimeZoneName:
62
+ canonical = _canonical_time_zone(zone)
63
+ if canonical is None:
64
+ raise config_problem(_TIME_ZONE_PROBLEM)
65
+ return TimeZoneName(canonical)
66
+
67
+
68
+ TimeZone = Annotated[TimeZoneName, Strict(), AfterValidator(_check_time_zone)]
69
+ """A config field holding an IANA time zone, e.g. `Europe/Rome`."""
70
+
71
+ _ENV_NAME = re.compile(r"[A-Z_][A-Z0-9_]*")
72
+ _SLUG = re.compile(r"[a-z0-9][a-z0-9-]*")
73
+ # zod's email check, so that both servers accept the same addresses.
74
+ _EMAIL = re.compile(
75
+ r"(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}"
76
+ )
77
+ _RECIPIENT_RULE = re.compile(r"(?:[^\s@]+@|@)[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+")
78
+
79
+
80
+ def _matching(pattern: re.Pattern[str], message: str) -> Callable[[str], str]:
81
+ def check(text: str) -> str:
82
+ if pattern.fullmatch(text) is None:
83
+ raise config_problem(message)
84
+ return text
85
+
86
+ return check
87
+
88
+
89
+ def is_env_name(value: object) -> TypeIs[str]:
90
+ """Whether `value` can name an environment variable: `A-Z`, `0-9` and `_`."""
91
+ return isinstance(value, str) and _ENV_NAME.fullmatch(value) is not None
92
+
93
+
94
+ EnvName = Annotated[
95
+ StrictStr,
96
+ AfterValidator(_matching(_ENV_NAME, "must be an environment variable name (A-Z, 0-9, _)")),
97
+ ]
98
+ Slug = Annotated[
99
+ StrictStr,
100
+ AfterValidator(_matching(_SLUG, "must be lowercase letters, digits and dashes")),
101
+ ]
102
+ """Ids that appear in URLs and tool arguments: `/mcp/<profile>`, `account = "<id>"`."""
103
+ NonEmpty = Annotated[StrictStr, Field(min_length=1)]
104
+ _Email = Annotated[StrictStr, AfterValidator(_matching(_EMAIL, "Invalid email address"))]
105
+ _RecipientRule = Annotated[
106
+ StrictStr,
107
+ AfterValidator(
108
+ _matching(
109
+ _RECIPIENT_RULE,
110
+ "must be an address (ana@example.org) or @ plus a domain (@example.org)",
111
+ )
112
+ ),
113
+ ]
114
+ """An exact recipient or a whole domain: `ana@example.org`, `@example.org`."""
115
+ _Count = Annotated[StrictInt, Field(ge=1, le=MAX_SAFE_INTEGER)]
116
+
117
+
118
+ class StrictModel(BaseModel):
119
+ """Base of the config models: unknown keys are an error, what is parsed is read-only."""
120
+
121
+ model_config = ConfigDict(extra="forbid", frozen=True)
122
+
123
+
124
+ class Endpoint(StrictModel):
125
+ host: NonEmpty
126
+ port: Annotated[StrictInt, Field(ge=1, le=65_535)]
127
+ tls: Literal["implicit", "starttls"]
128
+
129
+
130
+ class Identity(StrictModel):
131
+ address: _Email
132
+ name: NonEmpty | None = None
133
+ default: StrictBool = False
134
+
135
+
136
+ class PasswordAuth(StrictModel):
137
+ """A password or token, stored sealed in the database.
138
+
139
+ config.toml may name it by environment variable instead (`auth.secret_env`):
140
+ `without_secret_envs` takes that out, and the import seals the variable's value.
141
+ """
142
+
143
+ type: Literal["password"]
144
+
145
+
146
+ # A union discriminated on `type` once there is a second kind (0.4: xoauth2).
147
+ type AccountAuth = PasswordAuth
148
+
149
+
150
+ class AccountConfig(StrictModel):
151
+ id: Slug
152
+ login: NonEmpty
153
+ send_mode: SendMode
154
+ auth: AccountAuth
155
+ imap: Endpoint
156
+ smtp: Endpoint
157
+ identities: Annotated[tuple[Identity, ...], Field(min_length=1)]
158
+ auto_approve: tuple[_RecipientRule, ...] = ()
159
+ """Recipients (`addr@x` or `@domain`) sent directly in `approval` mode."""
160
+
161
+
162
+ class Limits(StrictModel):
163
+ send_per_hour: _Count = 20
164
+ max_recipients: _Count = 10
165
+ body_chars: Annotated[StrictInt, Field(ge=500, le=MAX_SAFE_INTEGER)] = 6000
166
+ attachment_max_mb: Annotated[StrictFloat, Field(gt=0, allow_inf_nan=False)] = 10.0
167
+
168
+
169
+ class CoreSettings(StrictModel):
170
+ """Accounts and limits, as stored; only `core_settings_or_issues` makes one with its rules."""
171
+
172
+ limits: Limits = Limits()
173
+ account: tuple[AccountConfig, ...] = ()
174
+
175
+
176
+ def core_settings_or_issues(raw: object) -> CoreSettings | tuple[ConfigIssue, ...]:
177
+ """The settings, valid and with a lone identity made the default; else what is wrong."""
178
+ try:
179
+ settings = CoreSettings.model_validate({} if raw is None else raw)
180
+ except ValidationError as error:
181
+ return tuple(validation_issues(error))
182
+ issues = _cross_check(settings)
183
+ if issues:
184
+ return issues
185
+ # A single identity is the default one whether or not it says so.
186
+ return settings.model_copy(
187
+ update={
188
+ "account": tuple(
189
+ acc.model_copy(
190
+ update={"identities": (acc.identities[0].model_copy(update={"default": True}),)}
191
+ )
192
+ if len(acc.identities) == 1
193
+ else acc
194
+ for acc in settings.account
195
+ )
196
+ }
197
+ )
198
+
199
+
200
+ def _cross_check(settings: CoreSettings) -> tuple[ConfigIssue, ...]:
201
+ issues: list[ConfigIssue] = []
202
+ ids: set[str] = set()
203
+ logins: set[str] = set()
204
+ addresses: dict[str, str] = {}
205
+ for i, acc in enumerate(settings.account):
206
+ if acc.id in ids:
207
+ issues.append(ConfigIssue(("account", i, "id"), f'duplicate account id "{acc.id}"'))
208
+ ids.add(acc.id)
209
+ # Tools accept a login in any case, so two logins differing only in case would be
210
+ # ambiguous.
211
+ login = acc.login.lower()
212
+ if login in logins:
213
+ issues.append(ConfigIssue(("account", i, "login"), f'duplicate login "{acc.login}"'))
214
+ logins.add(login)
215
+
216
+ defaults = sum(identity.default for identity in acc.identities)
217
+ if len(acc.identities) > 1 and defaults != 1:
218
+ issues.append(
219
+ ConfigIssue(
220
+ ("account", i, "identities"),
221
+ f"exactly one identity must be marked default (found {defaults})",
222
+ )
223
+ )
224
+ for j, identity in enumerate(acc.identities):
225
+ address = identity.address.lower()
226
+ owner = addresses.get(address)
227
+ if owner is not None:
228
+ issues.append(
229
+ ConfigIssue(
230
+ ("account", i, "identities", j, "address"),
231
+ f'identity {address} is already used by account "{owner}"',
232
+ )
233
+ )
234
+ addresses[address] = acc.id
235
+ return tuple(issues)
@@ -0,0 +1,19 @@
1
+ """Mailroom's cryptography: mailbox secrets sealed at rest."""
2
+
3
+ from mailroom_core.crypto.sealed_secrets import (
4
+ SealedSecretError,
5
+ SecretPurpose,
6
+ SecretsSubkey,
7
+ open_secret,
8
+ seal_secret,
9
+ secrets_subkey,
10
+ )
11
+
12
+ __all__ = [
13
+ "SealedSecretError",
14
+ "SecretPurpose",
15
+ "SecretsSubkey",
16
+ "open_secret",
17
+ "seal_secret",
18
+ "secrets_subkey",
19
+ ]
@@ -0,0 +1,65 @@
1
+ """Mailbox secrets sealed at rest, byte-compatible with `src/crypto/sealed-secrets.ts`.
2
+
3
+ The format is in docs/DESIGN.md — *Python port*, *Compatibility*.
4
+ """
5
+
6
+ from secrets import token_bytes
7
+ from typing import Literal, NewType
8
+
9
+ from cryptography.exceptions import InvalidTag
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+ from cryptography.hazmat.primitives.hashes import SHA256
12
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
13
+
14
+ type SecretPurpose = Literal["password"]
15
+ """What a stored secret is for; the only kind today is a mailbox password or token."""
16
+
17
+ SecretsSubkey = NewType("SecretsSubkey", bytes)
18
+ """The key that seals stored secrets. Only `secrets_subkey` makes one: never the raw key."""
19
+
20
+ _FORMAT = 1
21
+ _NONCE_BYTES = 12
22
+ _TAG_BYTES = 16
23
+
24
+
25
+ class SealedSecretError(ValueError):
26
+ """A blob that does not open: wrong key, altered, moved, or not a stored secret at all."""
27
+
28
+
29
+ def secrets_subkey(secrets_key: bytes) -> SecretsSubkey:
30
+ """Derive the sealing key from MAILROOM_SECRETS_KEY (its 32 bytes, decoded) for this one use."""
31
+ hkdf = HKDF(algorithm=SHA256(), length=32, salt=None, info=b"mailroom account secrets")
32
+ return SecretsSubkey(hkdf.derive(secrets_key))
33
+
34
+
35
+ def seal_secret(
36
+ subkey: SecretsSubkey, account_id: str, purpose: SecretPurpose, plaintext: str
37
+ ) -> bytes:
38
+ """AES-256-GCM, as `format ‖ nonce ‖ ciphertext ‖ tag`.
39
+
40
+ The account and purpose are bound in as associated data: a blob copied to another account or
41
+ use does not open.
42
+ """
43
+ nonce = token_bytes(_NONCE_BYTES)
44
+ sealed = AESGCM(subkey).encrypt(
45
+ nonce, plaintext.encode(), _associated_data(account_id, purpose)
46
+ )
47
+ return bytes([_FORMAT]) + nonce + sealed
48
+
49
+
50
+ def open_secret(subkey: SecretsSubkey, account_id: str, purpose: SecretPurpose, blob: bytes) -> str:
51
+ """Raise `SealedSecretError` (without the value) unless the blob opens for this account."""
52
+ if len(blob) < 1 + _NONCE_BYTES + _TAG_BYTES or blob[0] != _FORMAT:
53
+ raise SealedSecretError("not a stored secret of a known format")
54
+ nonce = blob[1 : 1 + _NONCE_BYTES]
55
+ try:
56
+ plaintext = AESGCM(subkey).decrypt(
57
+ nonce, blob[1 + _NONCE_BYTES :], _associated_data(account_id, purpose)
58
+ )
59
+ except InvalidTag:
60
+ raise SealedSecretError("stored secret does not open") from None
61
+ return plaintext.decode()
62
+
63
+
64
+ def _associated_data(account_id: str, purpose: SecretPurpose) -> bytes:
65
+ return f"mailroom/v1/{account_id}/{purpose}".encode()
mailroom_core/py.typed ADDED
File without changes