msgctl 0.1.0a1__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 (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/client_secrets.py ADDED
@@ -0,0 +1,58 @@
1
+ """Local encryption helpers. Neither plaintext nor private keys reach a transport."""
2
+ from __future__ import annotations
3
+ import os
4
+ from pathlib import Path
5
+ import tempfile
6
+ from msg.core.codec import b64,unb64,wire
7
+ from msg.core.errors import require
8
+ from msg.core.models import ResourceRef
9
+ from msg.security.sealed_box import generate_key,encrypt,decrypt,FORMAT
10
+
11
+
12
+ def write_private(path,content):
13
+ path=Path(path)
14
+ path.parent.mkdir(parents=True,exist_ok=True)
15
+ fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
16
+ try:
17
+ with os.fdopen(fd,'wb') as output:
18
+ output.write(content);output.flush();os.fsync(output.fileno())
19
+ except BaseException:
20
+ path.unlink(missing_ok=True)
21
+ raise
22
+
23
+
24
+ def encryption_keygen(path):
25
+ private,public=generate_key()
26
+ write_private(path,private)
27
+ return {'format':FORMAT,'recipient':b64(public),'private_key_file':str(path)}
28
+
29
+
30
+ async def put_secret(client,name,path,recipient):
31
+ # The msg envelope is bounded. Already encrypted large age/OpenPGP files can
32
+ # instead use transfer + keystore.put(source=...), without loading them here.
33
+ with Path(path).open('rb') as source:
34
+ plaintext=source.read(524289)
35
+ require(len(plaintext)<=524288,'use_streaming_age_or_openpgp')
36
+ sealed=encrypt(plaintext,unb64(recipient,limit=32))
37
+ with tempfile.TemporaryDirectory(prefix='encrypted-',dir=client.state.directory) as temp:
38
+ ciphertext=Path(temp)/'ciphertext';write_private(ciphertext,sealed)
39
+ uploaded=client.checked(await client.upload(ciphertext,media_type='application/octet-stream'))
40
+ return await client.call('keystore.put',{'name':name,'format':FORMAT,'source':wire(uploaded.output)})
41
+
42
+
43
+ async def get_secret(client,resource,output,private_key):
44
+ path=Path(private_key)
45
+ require(path.is_file() and not path.is_symlink() and path.stat().st_uid==os.geteuid() and
46
+ path.stat().st_mode&0o077==0,'unsafe_encryption_key_permissions')
47
+ private=path.read_bytes()
48
+ require(len(private)==32,'invalid_encryption_key')
49
+ entry=client.checked(await client.call('keystore.get',{'id':resource}))
50
+ require(entry.data['format']==FORMAT,'external_decryptor_required')
51
+ require(entry.data['size']<=1048576,'ciphertext_envelope_too_large')
52
+ require(not Path(output).exists(),'download_target_exists')
53
+ with tempfile.TemporaryDirectory(prefix='encrypted-',dir=client.state.directory) as temp:
54
+ ciphertext=Path(temp)/'ciphertext'
55
+ await client.download(entry.output,ciphertext)
56
+ plaintext=decrypt(ciphertext.read_bytes(),private)
57
+ write_private(output,plaintext)
58
+ return {'id':entry.data['id'],'revision':entry.data['revision'],'path':str(output),'decrypted_locally':True}
msg/client_tokens.py ADDED
@@ -0,0 +1,99 @@
1
+ """Protected one-use credential journals; no network authority comes from an ID."""
2
+ from functools import wraps
3
+ import os
4
+ import stat
5
+
6
+ from msg.client_upgrade import locked_state
7
+ from msg.core.codec import b64, loads, unb64
8
+ from msg.core.errors import Failure, require
9
+
10
+ JOURNAL_OPERATIONS = {
11
+ 'temporary.json': ('identity.temporary', 3),
12
+ 'custodial-bootstrap.json': ('identity.custodial_create', 2),
13
+ 'token-rotation.json': ('identity.token_rotate', 2),
14
+ }
15
+
16
+
17
+ def remove_journal(path):
18
+ """The local credential must already be durable before removing its intent."""
19
+ path.unlink()
20
+ fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
21
+ try:
22
+ os.fsync(fd)
23
+ finally:
24
+ os.close(fd)
25
+
26
+
27
+ def token_operation(method):
28
+ """Serialize with identity upgrades too, without blocking the event loop."""
29
+ @wraps(method)
30
+ async def locked(self, *args, **kwargs):
31
+ self._require_token_secret_transport()
32
+ with locked_state(self.state):
33
+ require(not any((self.state.directory/name).exists() or
34
+ (self.state.directory/name).is_symlink() for name in
35
+ ('identity-upgrade.json', 'custodial-upgrade.json')),
36
+ 'identity_recovery_pending')
37
+ self._token_journal()
38
+ return await method(self, *args, **kwargs)
39
+ return locked
40
+
41
+
42
+ def read_journal(state):
43
+ paths = [state.directory/name for name in JOURNAL_OPERATIONS]
44
+ present = [p for p in paths if p.exists() or p.is_symlink()]
45
+ require(len(present) <= 1, 'multiple_token_journals')
46
+ if not present:
47
+ return None, None
48
+ path = present[0]
49
+ try:
50
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
51
+ except OSError:
52
+ raise Failure('unsafe_token_journal') from None
53
+ try:
54
+ info = os.fstat(fd)
55
+ require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
56
+ info.st_nlink == 1 and info.st_mode & 0o077 == 0, 'unsafe_token_journal')
57
+ with os.fdopen(fd, 'rb', closefd=False) as stream:
58
+ raw = stream.read(8193)
59
+ finally:
60
+ os.close(fd)
61
+ require(len(raw) <= 8192, 'invalid_token_journal')
62
+ try:
63
+ saved = loads(raw)
64
+ require(isinstance(saved, dict), 'invalid_token_journal')
65
+ operation, version = JOURNAL_OPERATIONS[path.name]
66
+ require(saved.get('operation') == operation and saved.get('contract_version') == version,
67
+ 'legacy_token_journal_requires_manual_resolution')
68
+ # Older journals stay recoverable using their pre-bound secret, but cannot
69
+ # silently generate replacement keys. Newly written intentions also bind
70
+ # the origin and local key pair, including before any network call.
71
+ if 'server' in saved:
72
+ require(saved['server'] == state.server, 'token_journal_server_mismatch')
73
+ for field in ('nonce', 'recovery_secret'):
74
+ require(len(unb64(saved[field], limit=64)) >= 32, 'invalid_token_journal')
75
+ for field in ('credential_id', 'subject_id', 'request_id'):
76
+ require(isinstance(saved[field], str) and 0 < len(saved[field]) <= 160,
77
+ 'invalid_token_journal')
78
+ recovery = saved.get('recovery')
79
+ if recovery is not None:
80
+ require(isinstance(recovery, dict), 'invalid_token_journal')
81
+ for field in ('nonce', 'new_recovery_secret'):
82
+ require(len(unb64(recovery[field], limit=64)) >= 32, 'invalid_token_journal')
83
+ for field in ('request_id', 'credential_id'):
84
+ require(isinstance(recovery[field], str) and 0 < len(recovery[field]) <= 160,
85
+ 'invalid_token_journal')
86
+ require(state.subject is None or saved['subject_id'] == state.subject,
87
+ 'token_journal_subject_mismatch')
88
+ if operation == 'identity.temporary':
89
+ require(state.signer is not None and state.encryption_recipient is not None,
90
+ 'token_journal_key_missing')
91
+ if 'public_key' in saved:
92
+ require(saved['public_key'] == b64(state.signer.public_key) and
93
+ saved.get('encryption_recipient') == state.encryption_recipient,
94
+ 'token_journal_key_mismatch')
95
+ except Failure:
96
+ raise
97
+ except (KeyError, ValueError, TypeError):
98
+ raise Failure('invalid_token_journal') from None
99
+ return path, saved
msg/client_upgrade.py ADDED
@@ -0,0 +1,188 @@
1
+ """Durable same-subject upgrade intent; status recovery never reuses old authority."""
2
+ from contextlib import contextmanager
3
+ import os
4
+ import re
5
+ import stat
6
+ from uuid import uuid4
7
+
8
+ from msg.core.codec import b64, canonical, loads, wire
9
+ from msg.core.errors import Failure, require
10
+ from msg.security.crypto import Ed25519Signer
11
+ from msg.storage.git import durable_write
12
+
13
+
14
+ @contextmanager
15
+ def upgrade_lock(directory):
16
+ """A crash releases the OS lock; a second caller fails instead of blocking an event loop."""
17
+ try:
18
+ import fcntl
19
+ except ImportError:
20
+ raise Failure('upgrade_lock_unavailable') from None
21
+ try:
22
+ fd = os.open(directory/'identity-upgrade.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
23
+ except OSError:
24
+ raise Failure('unsafe_upgrade_lock') from None
25
+ try:
26
+ info = os.fstat(fd)
27
+ require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
28
+ info.st_nlink == 1 and info.st_mode & 0o077 == 0, 'unsafe_upgrade_lock')
29
+ try:
30
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
31
+ except BlockingIOError:
32
+ raise Failure('identity_upgrade_busy', retryable=True) from None
33
+ yield
34
+ finally:
35
+ os.close(fd)
36
+
37
+
38
+
39
+ @contextmanager
40
+ def locked_state(state):
41
+ """Refresh accepted credentials under the same lock used by every transition."""
42
+ with upgrade_lock(state.directory):
43
+ fresh = type(state)(state.directory, server=state.server)
44
+ state.__dict__.update(fresh.__dict__)
45
+ yield
46
+
47
+ def read_intent(path):
48
+ try:
49
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
50
+ except OSError:
51
+ raise Failure('unsafe_upgrade_journal') from None
52
+ try:
53
+ info = os.fstat(fd)
54
+ require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
55
+ info.st_nlink == 1 and info.st_mode & 0o077 == 0, 'unsafe_upgrade_journal')
56
+ with os.fdopen(fd, 'rb', closefd=False) as stream:
57
+ raw = stream.read(8193)
58
+ finally:
59
+ os.close(fd)
60
+ require(len(raw) <= 8192, 'invalid_upgrade_journal')
61
+ try:
62
+ value = loads(raw)
63
+ require(isinstance(value, dict) and set(value) == {
64
+ 'version', 'server', 'subject_id', 'handle', 'public_key',
65
+ 'encryption_recipient', 'credential_id', 'request_id'}, 'invalid_upgrade_journal')
66
+ require(value['version'] == 1 and all(isinstance(value[name], str) and value[name]
67
+ for name in value if name != 'version'), 'invalid_upgrade_journal')
68
+ require(re.fullmatch('[0-9a-f]{32}', value['request_id']) is not None,
69
+ 'invalid_upgrade_journal')
70
+ except (ValueError, TypeError, KeyError):
71
+ raise Failure('invalid_upgrade_journal') from None
72
+ return value
73
+
74
+
75
+ def confirm_local_identity(state, pending, result, journal):
76
+ require(result.status == 'ok' and result.subject == pending['subject_id'] and
77
+ result.data.get('subject_id') == pending['subject_id'] and
78
+ result.data.get('key_id') == state.signer.key_id and
79
+ result.data.get('handle') == pending['handle'] and
80
+ result.data.get('encryption_recipient') == pending['encryption_recipient'] and
81
+ isinstance(result.data.get('certificate_id'), str) and bool(result.data['certificate_id']),
82
+ 'upgrade_result_mismatch')
83
+ if result.operation == 'identity.upgrade_result':
84
+ require(result.data.get('status') == 'completed' and
85
+ result.data.get('upgrade_request_id') == pending['request_id'],
86
+ 'upgrade_result_mismatch')
87
+ else:
88
+ require(result.operation == 'identity.upgrade' and result.request_id == pending['request_id'],
89
+ 'upgrade_result_mismatch')
90
+ # Do not remove the recovery journal until the local identity is durable.
91
+ # A crash after this write but before unlink is recovered with the new key.
92
+ state.accept_identity(result)
93
+ journal.unlink()
94
+ fd = os.open(state.directory, os.O_RDONLY | os.O_DIRECTORY)
95
+ try:
96
+ os.fsync(fd)
97
+ finally:
98
+ os.close(fd)
99
+ return result
100
+
101
+
102
+ def pending_upgrade(state):
103
+ path = state.directory/'identity-upgrade.json'
104
+ if not (path.exists() or path.is_symlink()):
105
+ return None
106
+ pending = read_intent(path)
107
+ require(pending['server'] == state.server and pending['subject_id'] == state.subject and
108
+ re.fullmatch(r'[a-z][a-z0-9-]{1,40}', pending['handle']), 'upgrade_journal_mismatch')
109
+ return {'status': 'pending', 'handle': pending['handle'], 'request_id': pending['request_id'],
110
+ 'resume': 'msg identity upgrade'}
111
+
112
+
113
+ async def upgrade_identity(client, handle=None):
114
+ state = client.state
115
+ client._require_token_secret_transport()
116
+ with locked_state(state):
117
+ journal = state.directory/'identity-upgrade.json'
118
+ resuming = journal.exists() or journal.is_symlink()
119
+ pending = read_intent(journal) if resuming else None
120
+ if handle is None:
121
+ require(pending is not None, 'upgrade_handle_required')
122
+ handle = pending['handle']
123
+ require(isinstance(handle, str) and re.fullmatch(r'[a-z][a-z0-9-]{1,40}', handle)
124
+ and handle not in {'root', 'online-ca'}, 'invalid_handle')
125
+ if resuming:
126
+ require(state.signer is not None and state.encryption_recipient is not None,
127
+ 'upgrade_journal_key_missing')
128
+ require((pending['server'], pending['subject_id'], pending['handle'],
129
+ pending['public_key'], pending['encryption_recipient']) ==
130
+ (state.server, state.subject, handle, b64(state.signer.public_key),
131
+ state.encryption_recipient), 'upgrade_journal_mismatch')
132
+ require(state.token is None or state.token[0] == pending['credential_id'],
133
+ 'upgrade_journal_mismatch')
134
+ else:
135
+ require(state.subject is not None and state.token is not None, 'temporary_identity_required')
136
+ require(not any((state.directory/name).exists() or (state.directory/name).is_symlink() for name in (
137
+ 'temporary.json', 'custodial-bootstrap.json', 'token-rotation.json',
138
+ 'custodial-upgrade.json')), 'identity_recovery_pending')
139
+ if state.signer is None:
140
+ state.save_signer(Ed25519Signer.generate())
141
+ recipient = state.ensure_encryption_key()
142
+ pending = {'version': 1, 'server': state.server, 'subject_id': state.subject,
143
+ 'handle': handle, 'public_key': b64(state.signer.public_key),
144
+ 'encryption_recipient': recipient, 'credential_id': state.token[0],
145
+ 'request_id': uuid4().hex}
146
+ # Only a credential ID is stored here, never the old token or a private key.
147
+ durable_write(journal, canonical(pending), mode=0o600)
148
+
149
+ async def recover():
150
+ return await client.call('identity.upgrade_result',
151
+ {'upgrade_request_id': pending['request_id']}, signer=state.signer,
152
+ subject=pending['subject_id'], certificates=())
153
+
154
+ if resuming:
155
+ recovered = await recover()
156
+ if recovered.status == 'ok':
157
+ return confirm_local_identity(state, pending, recovered, journal)
158
+ # A not-yet-bound new key can occur after a pre-send crash. Only the
159
+ # still-valid original credential may submit the fixed intention.
160
+ if state.token is None or recovered.error.code not in {
161
+ 'credential_not_found', 'not_found', 'invalid_signature',
162
+ 'credential_ceiling', 'self_custody_signature_required',
163
+ 'upgrade_not_completed'}:
164
+ return recovered
165
+
166
+ require(state.token is not None, 'temporary_identity_required')
167
+ signed = {name: pending[name] for name in ('subject_id', 'handle', 'public_key',
168
+ 'encryption_recipient')}
169
+ arguments = {name: pending[name] for name in ('handle', 'public_key', 'encryption_recipient')}
170
+ arguments['possession_proof'] = wire(state.signer.sign(canonical(signed), purpose='upgrade'))
171
+ try:
172
+ result = await client.call('identity.upgrade', arguments,
173
+ request_id=pending['request_id'], contract_version=2)
174
+ except Failure as exc:
175
+ if exc.code != 'transport_uncertain':
176
+ raise
177
+ recovered = await recover()
178
+ if recovered.status != 'ok':
179
+ raise exc
180
+ result = recovered
181
+ if result.status == 'error' and result.error.code in {
182
+ 'credential_revoked', 'credential_expired', 'not_temporary'}:
183
+ recovered = await recover()
184
+ if recovered.status == 'ok':
185
+ result = recovered
186
+ if result.status == 'ok':
187
+ return confirm_local_identity(state, pending, result, journal)
188
+ return result
msg/config.py ADDED
@@ -0,0 +1,318 @@
1
+ """Server configuration and client configuration have separate roots."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from urllib.parse import urlsplit
10
+
11
+ from msg.core.errors import require
12
+ from msg.core.models import MailConfig, ServerConfig, TransportLimits
13
+ from msg.security.age_keys import public_from_recipient,encryption_key_id
14
+
15
+
16
+ @dataclass(frozen=True,slots=True)
17
+ class RecoveryCustodian:
18
+ id: str
19
+ name: str
20
+ recipient: str
21
+ fingerprint: str
22
+ description: str | None = None
23
+ policy_ref: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True,slots=True)
27
+ class MoneyConfig:
28
+ enabled: bool = True
29
+ currency_id: str = 'primary'
30
+ display_name: str = 'MSG'
31
+ code: str = 'MSG'
32
+ scale: int = 6
33
+ transfer_fee: int = 0
34
+ allow_overdraft: bool = False
35
+
36
+
37
+ def root_private_dir(config_dir: Path) -> Path:
38
+ """Root material is outside the network service configuration tree."""
39
+ directory = Path(config_dir)
40
+ if directory == Path('/etc/msgd'):
41
+ return Path('/var/lib/msgd-root')
42
+ return directory.parent / (directory.name + '-root')
43
+
44
+
45
+ def server_config_file(config_dir: Path) -> Path:
46
+ """Prefer the current name, but keep an existing legacy install readable."""
47
+ directory = Path(config_dir)
48
+ current, legacy = directory/'msgd.toml', directory/'server.toml'
49
+ require(not (current.exists() and legacy.exists()), 'ambiguous_server_configuration')
50
+ return legacy if legacy.exists() else current
51
+
52
+
53
+ @dataclass(frozen=True,slots=True)
54
+ class Settings:
55
+ server: ServerConfig
56
+ service_url: str
57
+ listen: str='127.0.0.1'
58
+ port: int=8042
59
+ public_web_origin: str | None=None
60
+ temporary_ttl: int=3600
61
+ credential_delivery_recovery_window: int=900
62
+ transfer_ttl: int=86400
63
+ base_certificate_ttl: int=2592000
64
+ max_part_bytes: int=65536
65
+ worker_isolation: str='bubblewrap'
66
+ tool_timeout_ms: int=10000
67
+ tool_max_response_bytes: int=4194304
68
+ tool_methods: tuple[str,...]=('GET','HEAD')
69
+ tool_ports: tuple[int,...]=(80,443)
70
+ recovery_custodians: tuple[RecoveryCustodian,...]=()
71
+ money: MoneyConfig=MoneyConfig()
72
+ hosting_base_capacity_bytes: int=10*1024*1024
73
+ hosting_recovery_marker: Path | None=None
74
+ hosting_content_group_read: bool=False
75
+
76
+ @property
77
+ def config_dir(self):
78
+ return self.server.config_dir
79
+
80
+ @property
81
+ def trust_file(self):
82
+ return self.config_dir/'trust'/'root.json'
83
+
84
+ @property
85
+ def service_keys(self):
86
+ return self.server.service_keys_dir
87
+
88
+ @property
89
+ def root_private_dir(self):
90
+ return root_private_dir(self.config_dir)
91
+
92
+ @property
93
+ def repositories_dir(self):
94
+ return self.server.repositories_dir
95
+
96
+
97
+ def load_settings(config_dir=Path('/etc/msgd')):
98
+ config_dir=Path(config_dir)
99
+ path=server_config_file(config_dir)
100
+ require(path.is_file(),'configuration_missing')
101
+ data=tomllib.loads(path.read_text())
102
+ require(set(data)<={'server','storage','limits','plugins','tools','recovery','identity','money','hosting'},'unknown_configuration_section')
103
+ identity=data.get('identity',{})
104
+ require(isinstance(identity,dict) and set(identity)<={'credential_delivery_recovery_window'},
105
+ 'unknown_identity_configuration')
106
+ window=identity.get('credential_delivery_recovery_window','15m')
107
+ require(isinstance(window,str) and len(window)<=3 and
108
+ re.fullmatch(r'[1-9][0-9]*m',window) is not None,
109
+ 'invalid_credential_delivery_recovery_window')
110
+ window_minutes=int(window[:-1])
111
+ require(1<=window_minutes<=60,'invalid_credential_delivery_recovery_window')
112
+ hosting=data.get('hosting',{})
113
+ require(isinstance(hosting,dict) and set(hosting)<={'base_capacity_bytes','recovery_marker','content_group_read'},
114
+ 'unknown_hosting_configuration')
115
+ hosting_base=hosting.get('base_capacity_bytes',10*1024*1024)
116
+ require(type(hosting_base) is int and 0 < hosting_base <= 1024**4,
117
+ 'invalid_hosting_capacity')
118
+ content_group_read=hosting.get('content_group_read',False)
119
+ require(type(content_group_read) is bool,'invalid_content_group_read')
120
+ recovery_marker=hosting.get('recovery_marker')
121
+ require(recovery_marker is None or (isinstance(recovery_marker,str) and
122
+ Path(recovery_marker).is_absolute() and '..' not in Path(recovery_marker).parts and
123
+ not any(ord(c)<32 for c in recovery_marker)), 'invalid_hosting_recovery_marker')
124
+ money=data.get('money',{})
125
+ require(isinstance(money,dict) and set(money)<=set(MoneyConfig.__dataclass_fields__),
126
+ 'unknown_money_configuration')
127
+ fixed={'enabled':True,'currency_id':'primary','scale':6,
128
+ 'transfer_fee':0,'allow_overdraft':False}
129
+ for name,expected in fixed.items():
130
+ value=money.get(name,expected)
131
+ require(type(value) is type(expected) and value==expected,
132
+ 'invalid_money_configuration')
133
+ display_name=money.get('display_name','MSG')
134
+ code=money.get('code','MSG')
135
+ require(type(display_name) is str and 1<=len(display_name)<=80 and
136
+ display_name==display_name.strip() and all(ord(char)>=32 for char in display_name),
137
+ 'invalid_money_display_name')
138
+ require(type(code) is str and re.fullmatch(r'[A-Z][A-Z0-9]{1,15}',code) is not None,
139
+ 'invalid_money_code')
140
+ server=data.get('server',{})
141
+ require(set(server)<={'service_url','listen','port','public_web_origin','temporary_ttl','transfer_ttl'},
142
+ 'unknown_server_configuration')
143
+ service=server.get('service_url','https://msg.lmm.best')
144
+ require(isinstance(service,str),'invalid_service_url')
145
+ service=service.rstrip('/')
146
+ url=urlsplit(service)
147
+ require(url.scheme in {'https','http'} and url.hostname and not url.username and not url.password and not url.query and not url.fragment and not url.path,
148
+ 'invalid_service_url')
149
+ require(type(server.get('port',8042)) is int and 1<=server.get('port',8042)<=65535,'invalid_listen_port')
150
+ require(isinstance(server.get('listen','127.0.0.1'),str) and server.get('listen','127.0.0.1'),'invalid_listen_address')
151
+ for key,default in (('temporary_ttl',3600),('transfer_ttl',86400)):
152
+ require(type(server.get(key,default)) is int and server.get(key,default)>0,'invalid_ttl')
153
+ store=data.get('storage',{})
154
+ require(set(store)<={'postgres_dsn','valkey_url','content','repositories','blobs','staging','service_keys'},
155
+ 'unknown_storage_configuration')
156
+ postgres_dsn=store.get('postgres_dsn')
157
+ require(isinstance(postgres_dsn,str) and bool(postgres_dsn.strip()) and not any(ord(c)<32 for c in postgres_dsn),
158
+ 'invalid_postgres_dsn')
159
+ # libpq service files keep credentials outside the service configuration.
160
+ if postgres_dsn.startswith(('postgresql://','postgres://')):
161
+ try:
162
+ pg_url=urlsplit(postgres_dsn)
163
+ require(bool(pg_url.hostname) and not pg_url.fragment,'invalid_postgres_dsn')
164
+ except ValueError:
165
+ require(False,'invalid_postgres_dsn')
166
+ else:
167
+ require(postgres_dsn.startswith('service=') and len(postgres_dsn.split())==1 and
168
+ len(postgres_dsn)>len('service='),'invalid_postgres_dsn')
169
+ valkey_url=store.get('valkey_url')
170
+ if valkey_url is not None:
171
+ require(isinstance(valkey_url,str),'invalid_valkey_url')
172
+ try:
173
+ cache_url=urlsplit(valkey_url)
174
+ require(cache_url.scheme in {'redis','rediss','unix'} and
175
+ (bool(cache_url.hostname) if cache_url.scheme!='unix' else bool(cache_url.path)) and
176
+ not cache_url.fragment,'invalid_valkey_url')
177
+ except ValueError:
178
+ require(False,'invalid_valkey_url')
179
+ content_dir=Path(store.get('content','/var/lib/msgd/git/content'))
180
+ # Existing installations with an explicit old content path keep their old
181
+ # sibling repository and embedded blob locations until an operator migrates.
182
+ modern_layout=content_dir.name=='content' and content_dir.parent.name=='git'
183
+ repository_default=(content_dir.parent/'repos' if modern_layout else
184
+ content_dir.parent/'repositories')
185
+ blob_default=(content_dir.parent.parent/'blobs'/'sha256' if modern_layout else
186
+ content_dir/'binary')
187
+ service_keys_default=(config_dir/'service' if (config_dir/'service').exists() and
188
+ not modern_layout else
189
+ (content_dir.parent.parent if modern_layout else content_dir.parent)/'service')
190
+ require(all(isinstance(store.get(key,str(default)),str) and
191
+ Path(store.get(key,str(default))).is_absolute()
192
+ for key,default in (('content',content_dir),('repositories',repository_default),
193
+ ('blobs',blob_default),('staging',Path('/var/lib/msgd/transfers/staging')),
194
+ ('service_keys',service_keys_default))),
195
+ 'storage_paths_must_be_absolute')
196
+ limits=data.get('limits',{})
197
+ require(set(limits)<={'request_bytes','response_bytes','path_bytes','part_bytes'},'unknown_limit')
198
+ for value in limits.values():
199
+ require(type(value) is int and value>=256,'invalid_limit')
200
+ mail_file=config_dir/'mail.toml'
201
+ mail=None
202
+ if mail_file.exists():
203
+ raw=tomllib.loads(mail_file.read_text())
204
+ require(set(raw)<={'enabled','host','port','tls','sender','credential_file'},'unknown_mail_configuration')
205
+ if raw.get('enabled',False):
206
+ require(raw.get('tls') in {'starttls','tls'},'mail_tls_required')
207
+ mail=MailConfig(enabled=True,host=raw['host'],port=raw.get('port',587),tls=raw['tls'],sender=raw['sender'],
208
+ credential_file=Path(raw['credential_file']) if raw.get('credential_file') else None)
209
+ recovery=data.get('recovery',{})
210
+ require(isinstance(recovery,dict) and set(recovery)<={'custodians'},'unknown_recovery_configuration')
211
+ raw_custodians=recovery.get('custodians',[])
212
+ require(isinstance(raw_custodians,list) and len(raw_custodians)<=16,'invalid_recovery_custodians')
213
+ custodians=[]
214
+ for item in raw_custodians:
215
+ require(isinstance(item,dict) and set(item)<={'id','name','recipient','description','policy_ref'},
216
+ 'unknown_recovery_custodian_field')
217
+ require(all(name in item for name in ('id','name','recipient')),
218
+ 'invalid_recovery_custodian')
219
+ require(type(item['id']) is str and re.fullmatch(r'[a-z][a-z0-9-]{1,63}',item['id']) is not None and
220
+ type(item['name']) is str and 1<=len(item['name'])<=100,
221
+ 'invalid_recovery_custodian')
222
+ require(all(value is None or (type(value) is str and len(value)<=500)
223
+ for value in (item.get('description'),item.get('policy_ref'))),
224
+ 'invalid_recovery_custodian')
225
+ public=public_from_recipient(item['recipient'])
226
+ custodians.append(RecoveryCustodian(id=item['id'],name=item['name'],
227
+ recipient=item['recipient'],fingerprint=encryption_key_id(public),
228
+ description=item.get('description'),policy_ref=item.get('policy_ref')))
229
+ require(len({item.id for item in custodians})==len(custodians),'duplicate_recovery_custodian')
230
+ require(set(data.get('plugins',{}))<={'enabled'},'unknown_plugin_configuration')
231
+ plugins=tuple(data.get('plugins',{}).get('enabled',('identity','content','discussion','communication','discovery','achievements','recovery','sharing','money','offers','store','bounty','orders','delivery','transfer','extensions','system','batch')))
232
+ require(all(isinstance(name,str) for name in plugins) and len(set(plugins))==len(plugins),'invalid_plugin_list')
233
+ require('identity' in plugins,'identity_plugin_required')
234
+ tools=data.get('tools',{})
235
+ require(set(tools)<={'isolation','timeout_ms','max_response_bytes','methods','ports'},'unknown_tool_configuration')
236
+ require(tools.get('isolation','bubblewrap')=='bubblewrap','unsafe_tool_worker')
237
+ for name,default in (('timeout_ms',10000),('max_response_bytes',4194304)):
238
+ require(type(tools.get(name,default)) is int and tools.get(name,default)>0,'invalid_tool_limit')
239
+ require(isinstance(tools.get('methods',[]),(list,tuple)) and all(m in {'GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'} for m in tools.get('methods',[])),'invalid_tool_methods')
240
+ require(isinstance(tools.get('ports',[]),(list,tuple)) and all(type(p) is int and 1<=p<=65535 for p in tools.get('ports',[])),'invalid_tool_ports')
241
+ public_web=server.get('public_web_origin')
242
+ if public_web is not None:
243
+ require(isinstance(public_web,str),'invalid_hosting_origin')
244
+ hosted=urlsplit(public_web)
245
+ require(hosted.scheme in {'https','http'} and hosted.hostname and not hosted.username and not hosted.password and not hosted.path and not hosted.query and not hosted.fragment,'invalid_hosting_origin')
246
+ require(public_web is None or public_web.rstrip('/')==service.rstrip('/'),
247
+ 'hosting_origin_must_match_service')
248
+ return Settings(server=ServerConfig(config_dir=config_dir,
249
+ postgres_dsn=postgres_dsn,valkey_url=valkey_url,
250
+ content_dir=content_dir,
251
+ repositories_dir=Path(store.get('repositories',repository_default)),
252
+ blob_dir=Path(store.get('blobs',blob_default)),
253
+ staging_dir=Path(store.get('staging','/var/lib/msgd/transfers/staging')),plugins=plugins,
254
+ service_keys_dir=Path(store.get('service_keys',service_keys_default)),
255
+ limits=TransportLimits(max_request_bytes=limits.get('request_bytes',1048576),
256
+ max_response_bytes=limits.get('response_bytes',1048576),max_path_bytes=limits.get('path_bytes',8192),
257
+ encodings=frozenset({'j','gz'})),mail=mail),service_url=service,listen=server.get('listen','127.0.0.1'),
258
+ port=server.get('port',8042),public_web_origin=public_web,
259
+ temporary_ttl=server.get('temporary_ttl',3600),transfer_ttl=server.get('transfer_ttl',86400),
260
+ credential_delivery_recovery_window=window_minutes*60,
261
+ max_part_bytes=limits.get('part_bytes',65536),tool_timeout_ms=tools.get('timeout_ms',10000),
262
+ tool_max_response_bytes=tools.get('max_response_bytes',4194304),
263
+ tool_methods=tuple(tools.get('methods',('GET','HEAD'))),tool_ports=tuple(tools.get('ports',(80,443))),
264
+ recovery_custodians=tuple(custodians),
265
+ money=MoneyConfig(display_name=display_name,code=code),
266
+ hosting_base_capacity_bytes=hosting_base,
267
+ hosting_recovery_marker=Path(recovery_marker) if recovery_marker is not None else None,
268
+ hosting_content_group_read=content_group_read)
269
+
270
+
271
+ def write_example(config_dir,data_dir,service_url='https://msg.lmm.best',*,postgres_dsn='service=msgd',valkey_url=None):
272
+ """Local install helper: writes no private key or default PIN."""
273
+ config_dir,data_dir=Path(config_dir),Path(data_dir)
274
+ config_dir.mkdir(parents=True,exist_ok=True)
275
+ path=server_config_file(config_dir)
276
+ if not path.exists():
277
+ path.write_text(f'''[server]
278
+ service_url = {json.dumps(service_url)}
279
+ listen = "127.0.0.1"
280
+ port = 8042
281
+
282
+ [identity]
283
+ credential_delivery_recovery_window = "15m"
284
+
285
+ [money]
286
+ enabled = true
287
+ currency_id = "primary"
288
+ display_name = "MSG"
289
+ code = "MSG"
290
+ scale = 6
291
+ transfer_fee = 0
292
+ allow_overdraft = false
293
+
294
+ [hosting]
295
+ base_capacity_bytes = 10485760
296
+ content_group_read = false
297
+
298
+ [storage]
299
+ postgres_dsn = {json.dumps(postgres_dsn)}
300
+ {f'valkey_url = {json.dumps(valkey_url)}' if valkey_url is not None else '# valkey_url = "redis://127.0.0.1:6379/0"'}
301
+ content = {json.dumps(str(data_dir/"git"/"content"))}
302
+ repositories = {json.dumps(str(data_dir/"git"/"repos"))}
303
+ blobs = {json.dumps(str(data_dir/"blobs"/"sha256"))}
304
+ staging = {json.dumps(str(data_dir/"transfers"/"staging"))}
305
+ service_keys = {json.dumps(str(data_dir/"service"))}
306
+
307
+ [limits]
308
+ request_bytes = 1048576
309
+ response_bytes = 1048576
310
+ path_bytes = 8192
311
+ part_bytes = 65536
312
+
313
+ [tools]
314
+ isolation = "bubblewrap"
315
+ methods = ["GET", "HEAD"]
316
+ ports = [80, 443]
317
+ ''')
318
+ return load_settings(config_dir)