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/admin/root.py ADDED
@@ -0,0 +1,347 @@
1
+ """Local root administration; never imported by a network adapter or worker.
2
+
3
+ The underscored provisioning functions are internal local use cases. The command
4
+ entrypoint must pass OS-console checks before calling them. Tests call these use
5
+ cases only against disposable directories and never enable a network root route.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import replace
11
+ from datetime import timedelta
12
+ import getpass
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import sys
17
+
18
+ from msg.constants import *
19
+ from msg.core.codec import b64,unb64,canonical,decode,digest,loads,wire
20
+ from msg.core.errors import Failure,require
21
+ from msg.core.models import Certificate,CertificateRequest,Credential,IssuancePolicy,Signature,ResourceRef,Event,AuditEvent
22
+ from msg.security.crypto import Ed25519Signer,seal_private_key,open_private_key
23
+ from msg.security.certificates import sign_certificate,csr_body,verify_csr
24
+ from msg.security.capabilities import grant_for
25
+ from msg.storage.git import durable_write
26
+ from msg.bootstrap import bootstrap,seed_resource
27
+ from msg.plugins.common import new_id
28
+ from msg.plugins.identity import certificate_resource
29
+
30
+
31
+ def root_envelope(config_dir):
32
+ from msg.config import root_private_dir
33
+ current=root_private_dir(Path(config_dir))/'key.json'
34
+ legacy=Path(config_dir)/'root'/'key.json'
35
+ return legacy if legacy.is_file() and not current.exists() else current
36
+
37
+
38
+ async def _provision(app,pin):
39
+ """Initialize only an empty installation; partial state requires explicit recovery."""
40
+ settings=app.settings
41
+ protected=settings.root_private_dir
42
+ marker=settings.config_dir/'initialization.pending'
43
+ require(not root_envelope(settings.config_dir).exists() and not settings.trust_file.exists() and
44
+ not marker.exists(),'initialization_requires_recovery')
45
+ await app.open_storage()
46
+ async with app.metadata.transaction(write=False) as tx:
47
+ require(tx.one('SELECT COUNT(*) FROM resources')[0] == 0,'initialization_requires_recovery')
48
+ root=Ed25519Signer.generate()
49
+ envelope=seal_private_key(root.private_bytes(),pin)
50
+ settings.config_dir.mkdir(parents=True,exist_ok=True)
51
+ durable_write(marker,b'1\n',mode=0o600)
52
+ protected.mkdir(mode=0o700,parents=True,exist_ok=True)
53
+ os.chmod(protected,0o700)
54
+ settings.service_keys.mkdir(mode=0o700,parents=True,exist_ok=True)
55
+ os.chmod(settings.service_keys,0o700)
56
+ settings.trust_file.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
57
+ # Wrapped before touching the installation; a rejected PIN leaves no partial state.
58
+ durable_write(root_envelope(settings.config_dir),canonical(envelope),mode=0o600)
59
+ online=Ed25519Signer.generate()
60
+ receipt=Ed25519Signer.generate()
61
+ durable_write(settings.service_keys/'online.key',online.private_bytes(),mode=0o600)
62
+ durable_write(settings.service_keys/'receipt.key',receipt.private_bytes(),mode=0o600)
63
+ durable_write(settings.service_keys/'tokens.key',os.urandom(32),mode=0o600)
64
+ now=app.clock()
65
+ await bootstrap(app.metadata,app.contents,app.registry,now,
66
+ selftest_run_id=app.selftest_run_id)
67
+ root_grants=app.primary_ceiling()
68
+ root_certificate=Certificate(resource_id='cert_root',serial=new_id('serial'),subject_id=ROOT_SUBJECT,
69
+ key_id=root.key_id,issuer_id=ROOT_SUBJECT,parent_certificate_id=None,authority_sources=(),kind='ca',
70
+ grants=root_grants,not_before=now,expires_at=now+timedelta(days=3650),target_service=settings.service_url,
71
+ delegation_depth=8,issuance=IssuancePolicy(issue_grants=root_grants,max_cert_ttl_seconds=31536000,
72
+ max_child_ca_depth=3,max_delegation_depth=8),signature=Signature(key_id=root.key_id,algorithm='ed25519',value=b''))
73
+ root_certificate=sign_certificate(root_certificate,root)
74
+ online_use=grant_for(app.registry.capability('cert.issue'),scope=app.default_scope())
75
+ csr=CertificateRequest(resource_id=new_id('csr'),applicant=ONLINE_CA,subject_id=ONLINE_CA,
76
+ requested_issuer=ROOT_SUBJECT,public_key=online.public_key,kind='ca',grants=(online_use,),
77
+ issuance=IssuancePolicy(issue_grants=app.base_grants(),max_cert_ttl_seconds=settings.base_certificate_ttl,
78
+ max_child_ca_depth=0,max_delegation_depth=8),requested_ttl_seconds=31536000,
79
+ target_service=settings.service_url,delegation_depth=0,authority_sources=(),request_digest='',
80
+ possession_proof=Signature(key_id=online.key_id,algorithm='ed25519',value=b''))
81
+ csr=replace(csr,request_digest=digest(csr_body(csr)),
82
+ possession_proof=online.sign(canonical(csr_body(csr)),purpose='csr'))
83
+ async with app.metadata.transaction(write=True) as tx:
84
+ for uid,signer,ceiling in ((ROOT_SUBJECT,root,()),(ONLINE_CA,online,(online_use,))):
85
+ await tx.save_credential(Credential(id=signer.key_id,subject_id=uid,kind='signing_key',verifier=signer.public_key,
86
+ ceiling=ceiling,not_before=now,expires_at=None,revoked_at=None),0)
87
+ for name in ('keys','certificates'):
88
+ await seed_resource(tx,app.contents,dict(id='r_'+digest((uid,name))[7:39],type='topic',name=name,parent=uid,
89
+ owner=uid,group=PUBLIC_GROUP,mode='0555'),now)
90
+ await certificate_resource(tx,root_certificate,now)
91
+ await tx.register_certificate(root_certificate,None,0)
92
+ await seed_resource(tx,app.contents,dict(id=csr.resource_id,type='csr',name=csr.resource_id,parent=CSR_SPACE,
93
+ owner=ONLINE_CA,group=ADMINS_GROUP,mode='0600'),now)
94
+ await tx.save_csr(csr)
95
+ tx.set_setting('active_root_certificate',root_certificate.resource_id)
96
+ tx.set_setting('online_ca_request',csr.resource_id)
97
+ tx.set_setting('receipt_public_key',{'key_id':receipt.key_id,'public_key':b64(receipt.public_key)})
98
+ durable_write(settings.trust_file,canonical({'version':1,'public_key':b64(root.public_key),
99
+ 'certificate':wire(root_certificate)}),mode=0o444)
100
+ marker.unlink()
101
+ await app.load()
102
+ return csr.resource_id,root
103
+
104
+
105
+ async def _approve_csr(app,csr_id,signer, *, expected_digest,operator):
106
+ now=app.clock()
107
+ async with app.metadata.transaction(write=True) as tx:
108
+ csr=await tx.csr(csr_id)
109
+ state=await tx.csr_state(csr_id)
110
+ require(csr.requested_issuer==ROOT_SUBJECT,'not_root_request')
111
+ if expected_digest is not None:
112
+ require(csr.request_digest==expected_digest,'csr_changed_after_confirmation')
113
+ verify_csr(csr)
114
+ if state.status=='issued':
115
+ return await tx.certificate(state.certificate_id)
116
+ require(state.status=='pending','csr_not_pending')
117
+ require(signer.key_id==app.certificates.root_certificate.key_id,'root_key_mismatch')
118
+ parent=app.certificates.root_certificate
119
+ certificate=Certificate(resource_id=new_id('cert'),serial=new_id('serial'),subject_id=csr.subject_id,
120
+ key_id=csr.possession_proof.key_id,issuer_id=ROOT_SUBJECT,parent_certificate_id=parent.resource_id,
121
+ authority_sources=csr.authority_sources,kind=csr.kind,grants=csr.grants,not_before=now,
122
+ expires_at=min(now+timedelta(seconds=csr.requested_ttl_seconds),parent.expires_at),
123
+ target_service=csr.target_service,delegation_depth=csr.delegation_depth,issuance=csr.issuance,
124
+ signature=Signature(key_id=signer.key_id,algorithm='ed25519',value=b''))
125
+ certificate=sign_certificate(certificate,signer)
126
+ await certificate_resource(tx,certificate,now)
127
+ await app.certificates.validate_publication(certificate,csr,tx)
128
+ await tx.register_certificate(certificate,csr_id,state.generation)
129
+ credential=await tx.credential(certificate.key_id)
130
+ if not credential.ceiling:
131
+ subject=await tx.subject(credential.subject_id)
132
+ await tx.save_credential(replace(credential,ceiling=certificate.grants),subject.auth_version)
133
+ if csr_id==tx.setting('online_ca_request'):
134
+ tx.set_setting('online_ca_certificate',certificate.resource_id)
135
+ event=Event(id=new_id('audit'),type='root.cert.issue',time=now,request_id=csr_id,
136
+ actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,resources=(ResourceRef(id=certificate.resource_id),),
137
+ data={'operator':operator,'csr_digest':csr.request_digest,'grants':wire(certificate.grants)})
138
+ await tx.append_audit(AuditEvent(event=event,authority=(ResourceRef(id=parent.resource_id),),
139
+ before_digest=csr.request_digest,after_digest=digest(certificate),previous_digest=None,entry_digest='',result='issued'))
140
+ return certificate
141
+
142
+
143
+ def require_local_console(config_dir):
144
+ """OS root plus a local VT/serial console, not an arbitrary pseudo-terminal.
145
+
146
+ Remote SSH and terminal multiplexers normally use /dev/pts and cannot satisfy
147
+ this boundary merely by deleting SSH_* variables or supplying client=cli.
148
+ Deployments that need another administrative channel must use a separately
149
+ audited OS policy, not relax this check through the network configuration.
150
+ """
151
+ require(hasattr(os,'geteuid') and os.geteuid()==0,'local_os_administrator_required')
152
+ require(not any(name in os.environ for name in ('SSH_CONNECTION','SSH_CLIENT','SSH_TTY')),'remote_admin_forbidden')
153
+ require(sys.stdin.isatty() and sys.stdout.isatty(),'local_console_required')
154
+ tty=os.path.realpath(os.ttyname(sys.stdin.fileno()))
155
+ require(re.fullmatch(r'/dev/(console|tty[0-9]+|ttyS[0-9]+|hvc[0-9]+)',tty) is not None,
156
+ 'local_console_required')
157
+ path=Path(config_dir).resolve()
158
+ require(path.exists() and path.stat().st_uid==0 and not path.stat().st_mode&0o022,'unsafe_config_owner')
159
+ return f'uid:{os.geteuid()}:{tty}'
160
+
161
+
162
+ class RootAdmin:
163
+ def __init__(self,config_dir=Path('/etc/msgd')):
164
+ self.config_dir=Path(config_dir)
165
+
166
+ def _app(self):
167
+ from msg.application import Application
168
+ from msg.config import load_settings
169
+ return Application(load_settings(self.config_dir))
170
+
171
+ def initialize(self):
172
+ require_local_console(self.config_dir)
173
+ app=self._app()
174
+ if root_envelope(self.config_dir).exists() and app.settings.trust_file.exists():
175
+ status=self.doctor()
176
+ require(all(status['checks'].get(k,{}).get('ok') for k in ('storage','root_trust','bootstrap','root_private_boundary')),
177
+ 'initialization_requires_recovery')
178
+ private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Current root PIN/passphrase: '))
179
+ trust=loads(app.settings.trust_file.read_bytes())
180
+ require(Ed25519Signer.from_bytes(private).public_key==unb64(trust['public_key']),'root_key_mismatch')
181
+ return status['root_id']
182
+ first=getpass.getpass('New root PIN/passphrase: ')
183
+ second=getpass.getpass('Confirm root PIN/passphrase: ')
184
+ require(first==second,'pin_confirmation_mismatch')
185
+ csr,_=asyncio.run(_provision(app,first))
186
+ print(canonical({'status':'initialized','root_id':ROOT_SUBJECT,'online_ca_request':csr}).decode())
187
+ return ROOT_SUBJECT
188
+
189
+ def issue(self,csr_id):
190
+ operator=require_local_console(self.config_dir)
191
+ app=self._app()
192
+ async def read():
193
+ await app.load()
194
+ async with app.metadata.transaction(write=False) as tx:
195
+ return await tx.csr(csr_id),await tx.csr_state(csr_id)
196
+ csr,state=asyncio.run(read())
197
+ # json.dumps escapes terminal controls; no raw user-controlled text is printed.
198
+ print(canonical({'request':wire(csr),'state':wire(state),'public_key_fingerprint':digest(csr.public_key)}).decode())
199
+ if state.status=='issued':
200
+ async def previous():
201
+ async with app.metadata.transaction(write=False) as tx:
202
+ return await tx.certificate(state.certificate_id)
203
+ return asyncio.run(previous())
204
+ require(input('Type the exact CSR digest to approve: ').strip()==csr.request_digest,'approval_cancelled')
205
+ pin=getpass.getpass('Root PIN/passphrase: ')
206
+ private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),pin)
207
+ signer=Ed25519Signer.from_bytes(private)
208
+ return asyncio.run(_approve_csr(app,csr_id,signer,expected_digest=csr.request_digest,operator=operator))
209
+
210
+ def change_pin(self):
211
+ require_local_console(self.config_dir)
212
+ require(input('Type CHANGE PIN to continue: ')=='CHANGE PIN','approval_cancelled')
213
+ path=root_envelope(self.config_dir)
214
+ private=open_private_key(loads(path.read_bytes()),getpass.getpass('Current root PIN/passphrase: '))
215
+ first=getpass.getpass('New root PIN/passphrase: ')
216
+ require(first==getpass.getpass('Confirm new root PIN/passphrase: '),'pin_confirmation_mismatch')
217
+ durable_write(path,canonical(seal_private_key(private,first)),mode=0o600)
218
+
219
+ def rotate(self, *, lost_key=False, resume=False):
220
+ operator=require_local_console(self.config_dir)
221
+ app=self._app()
222
+ from msg.admin.rotation import prepare,complete,journal_path
223
+ if resume:
224
+ require(journal_path(app).is_file(),'rotation_journal_missing')
225
+ journal=loads(journal_path(app).read_bytes())
226
+ print(canonical(journal['statement']).decode())
227
+ require(input('Type the new root fingerprint to resume: ')==journal['statement']['new_fingerprint'],'approval_cancelled')
228
+ pin=getpass.getpass('New root PIN/passphrase: ')
229
+ else:
230
+ asyncio.run(app.load())
231
+ print(canonical({'old_fingerprint':digest(app.certificates.root_public_key),
232
+ 'effect':'all existing certificate chains require reissuance','lost_key':lost_key}).decode())
233
+ require(input('Type ROTATE ROOT '+digest(app.certificates.root_public_key)+': ')==
234
+ 'ROTATE ROOT '+digest(app.certificates.root_public_key),'approval_cancelled')
235
+ old=None if lost_key else Ed25519Signer.from_bytes(open_private_key(
236
+ loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Current root PIN/passphrase: ')))
237
+ pin=getpass.getpass('New root PIN/passphrase: ')
238
+ require(pin==getpass.getpass('Confirm new root PIN/passphrase: '),'pin_confirmation_mismatch')
239
+ journal=prepare(app,Ed25519Signer.generate(),pin,old_signer=old,operator=operator)
240
+ return asyncio.run(complete(app,journal,pin=pin))
241
+
242
+ def doctor(self):
243
+ # Imported lazily: this read-only diagnostic must not run schema migrations.
244
+ from msg.admin.diagnostics import doctor
245
+ return doctor(self.config_dir)
246
+
247
+ def selftest(self):
248
+ from msg.admin.diagnostics import selftest
249
+ return asyncio.run(selftest())
250
+
251
+
252
+ def revoke(self,certificate_id,reason):
253
+ operator=require_local_console(self.config_dir)
254
+ app=self._app()
255
+ asyncio.run(app.load())
256
+ async def read():
257
+ async with app.metadata.transaction(write=False) as tx:return await tx.certificate(certificate_id)
258
+ certificate=asyncio.run(read())
259
+ print(canonical({'certificate':wire(certificate),'reason':reason}).decode())
260
+ require(input('Type REVOKE '+certificate_id+' to continue: ')=='REVOKE '+certificate_id,'approval_cancelled')
261
+ private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Root PIN/passphrase: '))
262
+ return asyncio.run(_revoke(app,certificate_id,Ed25519Signer.from_bytes(private),reason=reason,operator=operator))
263
+
264
+
265
+ def sign_backup_retirement(self,source,destination):
266
+ """Root-sign an operator's statement that listed backup sets lost one old key."""
267
+ require_local_console(self.config_dir)
268
+ from msg.admin.backup_retirement import unsigned_statement
269
+ from msg.security.backup_retirement import sign_record
270
+ target=Path(destination)
271
+ require(not target.exists() and not target.is_symlink(),'backup_retirement_destination_exists')
272
+ body=unsigned_statement(loads(Path(source).read_bytes()))
273
+ print(canonical({'statement':body,'digest':digest(body),
274
+ 'claim':'listed_backup_sets_only'}).decode())
275
+ require(input('Type ATTEST BACKUP RETIREMENT '+digest(body)+': ')==
276
+ 'ATTEST BACKUP RETIREMENT '+digest(body),'approval_cancelled')
277
+ private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Root PIN/passphrase: '))
278
+ record=sign_record(body,Ed25519Signer.from_bytes(private))
279
+ durable_write(target,canonical(record),mode=0o600)
280
+ return {'status':'backup_retirement_signed','path':str(target),'digest':digest(record)}
281
+
282
+ def import_backup_retirement(self,source):
283
+ """Only this console path persists a record; reads re-verify it every time."""
284
+ operator=require_local_console(self.config_dir)
285
+ from msg.admin.backup_retirement import import_record
286
+ app=self._app()
287
+ record=loads(Path(source).read_bytes())
288
+ print(canonical({'record':record,'digest':digest(record)}).decode())
289
+ require(input('Type IMPORT BACKUP RETIREMENT '+digest(record)+': ')==
290
+ 'IMPORT BACKUP RETIREMENT '+digest(record),'approval_cancelled')
291
+ async def run():
292
+ await app.load()
293
+ try:
294
+ async with app.metadata.transaction(write=True) as tx:
295
+ return await import_record(tx,record,now=app.clock(),operator=operator)
296
+ finally:
297
+ await app.close()
298
+ attestation=asyncio.run(run())
299
+ return {'status':'backup_retirement_imported','scope':'listed_backup_sets_only',
300
+ **attestation.view()}
301
+
302
+ def backup(self,destination):
303
+ require_local_console(self.config_dir)
304
+ target=Path(destination)
305
+ require(not target.exists() and not target.is_symlink(),'backup_destination_exists')
306
+ envelope=loads(root_envelope(self.config_dir).read_bytes())
307
+ trust=loads((self.config_dir/'trust'/'root.json').read_bytes())
308
+ require(input('Type BACKUP ROOT to continue: ')=='BACKUP ROOT','approval_cancelled')
309
+ private=open_private_key(envelope,getpass.getpass('Root PIN/passphrase: '))
310
+ require(b64(Ed25519Signer.from_bytes(private).public_key)==trust['public_key'],'root_key_mismatch')
311
+ durable_write(target,canonical({'format':'msg-root-backup-v1','envelope':envelope,'trust':trust}),mode=0o600)
312
+ return {'status':'encrypted_backup_created','path':str(target),'fingerprint':digest(unb64(trust['public_key']))}
313
+
314
+
315
+ def recover(self,source):
316
+ require_local_console(self.config_dir)
317
+ target=root_envelope(self.config_dir)
318
+ require(not target.exists(),'root_material_already_present')
319
+ backup=loads(Path(source).read_bytes())
320
+ require(set(backup)=={'format','envelope','trust'} and backup['format']=='msg-root-backup-v1','invalid_root_backup')
321
+ current=loads((self.config_dir/'trust'/'root.json').read_bytes())
322
+ require(canonical(current)==canonical(backup['trust']),'trust_anchor_mismatch')
323
+ print(canonical({'fingerprint':digest(unb64(current['public_key'])),'action':'recover_missing_encrypted_root_key'}).decode())
324
+ require(input('Type RECOVER ROOT to continue: ')=='RECOVER ROOT','approval_cancelled')
325
+ private=open_private_key(backup['envelope'],getpass.getpass('Backup root PIN/passphrase: '))
326
+ require(b64(Ed25519Signer.from_bytes(private).public_key)==current['public_key'],'root_key_mismatch')
327
+ target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
328
+ os.chmod(target.parent,0o700)
329
+ durable_write(target,canonical(backup['envelope']),mode=0o600)
330
+ return {'status':'root_key_recovered','fingerprint':digest(unb64(current['public_key']))}
331
+
332
+
333
+ async def _revoke(app,certificate_id,signer, *, reason,operator):
334
+ require(certificate_id!=app.certificates.root_certificate.resource_id,'root_rotation_required')
335
+ require(signer.key_id==app.certificates.root_certificate.key_id,'root_key_mismatch')
336
+ async with app.metadata.transaction(write=True) as tx:
337
+ certificate=await tx.certificate(certificate_id)
338
+ statement={'certificate_id':certificate_id,'reason':reason,'time':wire(app.clock()),
339
+ 'issuer':ROOT_SUBJECT,'operator':operator}
340
+ signature=signer.sign(canonical(statement),purpose='revocation')
341
+ event=Event(id=new_id('audit'),type='root.cert.revoke',time=app.clock(),request_id=new_id('local'),
342
+ actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,resources=(ResourceRef(id=certificate_id),),
343
+ data={**statement,'signature':wire(signature)})
344
+ await tx.revoke_certificate(certificate_id,AuditEvent(event=event,
345
+ authority=(ResourceRef(id=app.certificates.root_certificate.resource_id),),before_digest=digest(certificate),
346
+ after_digest=digest(statement),previous_digest=None,entry_digest='',result='revoked'))
347
+ return {'certificate_id':certificate_id,'revoked':True,'signature':wire(signature)}
msg/admin/rotation.py ADDED
@@ -0,0 +1,197 @@
1
+ """Explicit local trust rotation with a PIN-encrypted, crash-recoverable journal.
2
+
3
+ The journal never contains a plaintext private key. Installing a new trust anchor
4
+ invalidates all old certificate chains for new requests. Historical public keys
5
+ and signed records remain available; they are not retroactively re-signed.
6
+ """
7
+ from __future__ import annotations
8
+ from dataclasses import replace
9
+ from datetime import timedelta
10
+ import os
11
+
12
+ from msg.constants import ROOT_SUBJECT, ONLINE_CA, ADMINS_GROUP, CSR_SPACE
13
+ from msg.core.codec import canonical, decode, digest, wire, b64, loads
14
+ from msg.core.errors import require
15
+ from msg.core.models import Certificate, CertificateRequest, Credential, Signature, IssuancePolicy, AuditEvent, Event, ResourceRef
16
+ from msg.plugins.common import new_id
17
+ from msg.plugins.identity import certificate_resource
18
+ from msg.security.crypto import seal_private_key, Ed25519Signer
19
+ from msg.security.certificates import sign_certificate, csr_body
20
+ from msg.security.rotation_journal import authorization, validate as validate_journal
21
+ from msg.security.root_files import read_private, rotation_lock
22
+ from msg.security.capabilities import grant_for
23
+ from msg.storage.git import durable_write
24
+ from msg.bootstrap import seed_resource
25
+
26
+
27
+ def journal_path(app):
28
+ from msg.admin.root import root_envelope
29
+ return root_envelope(app.settings.config_dir).parent/'rotation.pending.json'
30
+
31
+
32
+ def prepare(app, new_signer, new_pin, *, old_signer, operator):
33
+ with rotation_lock(journal_path(app).parent):
34
+ return _prepare(app, new_signer, new_pin, old_signer=old_signer, operator=operator)
35
+
36
+
37
+ def _prepare(app, new_signer, new_pin, *, old_signer, operator):
38
+ require(not journal_path(app).exists(), 'root_rotation_pending')
39
+ old = app.certificates.root_certificate
40
+ if old_signer is not None:
41
+ require(old_signer.key_id == old.key_id, 'root_key_mismatch')
42
+ now = app.clock()
43
+ grants = app.primary_ceiling()
44
+ new = Certificate(resource_id=new_id('cert_root'), serial=new_id('serial'), subject_id=ROOT_SUBJECT,
45
+ key_id=new_signer.key_id, issuer_id=ROOT_SUBJECT, parent_certificate_id=None, authority_sources=(), kind='ca',
46
+ grants=grants, not_before=now, expires_at=now+timedelta(days=3650), target_service=app.settings.service_url,
47
+ delegation_depth=8, issuance=IssuancePolicy(issue_grants=grants,max_cert_ttl_seconds=31536000,
48
+ max_child_ca_depth=3,max_delegation_depth=8),
49
+ signature=Signature(key_id=new_signer.key_id, algorithm='ed25519', value=b''))
50
+ new = sign_certificate(new, new_signer)
51
+ statement = {'old_certificate':old.resource_id, 'old_fingerprint':digest(app.certificates.root_public_key),
52
+ 'new_certificate':new.resource_id, 'new_fingerprint':digest(new_signer.public_key), 'time':wire(now),
53
+ 'operator':operator, 'previous_key_proved':old_signer is not None}
54
+ online = app.online_signer
55
+ csr = CertificateRequest(resource_id=new_id('csr'), applicant=ONLINE_CA, subject_id=ONLINE_CA,
56
+ requested_issuer=ROOT_SUBJECT, public_key=online.public_key, kind='ca',
57
+ grants=(grant_for(app.registry.capability('cert.issue'),scope=app.default_scope()),),
58
+ issuance=IssuancePolicy(issue_grants=app.base_grants(),max_cert_ttl_seconds=app.settings.base_certificate_ttl,
59
+ max_child_ca_depth=0,max_delegation_depth=8), requested_ttl_seconds=31536000,
60
+ target_service=app.settings.service_url,delegation_depth=0,authority_sources=(),request_digest='',
61
+ possession_proof=Signature(key_id=online.key_id,algorithm='ed25519',value=b''))
62
+ csr = replace(csr, request_digest=digest(csr_body(csr)),
63
+ possession_proof=online.sign(canonical(csr_body(csr)),purpose='csr'))
64
+ from msg.admin.root import root_envelope
65
+ key_file=root_envelope(app.settings.config_dir)
66
+ require(not key_file.is_symlink(),'unsafe_root_private_path')
67
+ journal={'version':2,'old_certificate':wire(old),'new_trust':{'version':1,
68
+ 'public_key':b64(new_signer.public_key),'certificate':wire(new)},
69
+ 'new_envelope':seal_private_key(new_signer.private_bytes(),new_pin),
70
+ 'previous_envelope_digest':digest(read_private(key_file)) if key_file.exists() else None,
71
+ 'online_csr':wire(csr),'statement':statement}
72
+ proof=canonical(authorization(journal))
73
+ journal['new_signature']=wire(new_signer.sign(proof,purpose='root-rotation'))
74
+ journal['old_signature']=wire(old_signer.sign(proof,purpose='root-rotation')) if old_signer else None
75
+ protected=journal_path(app).parent
76
+ require(not protected.is_symlink(),'unsafe_root_private_path')
77
+ protected.mkdir(parents=True,exist_ok=True,mode=0o700)
78
+ os.chmod(protected,0o700)
79
+ durable_write(journal_path(app),canonical(journal),mode=0o600)
80
+ return journal
81
+
82
+
83
+ async def complete(app, journal, *, pin):
84
+ with rotation_lock(journal_path(app).parent):
85
+ return await _complete(app, journal, pin=pin)
86
+
87
+
88
+ async def _complete(app, journal, *, pin):
89
+ from msg.security.crypto import verify
90
+ pending=journal_path(app)
91
+ protected=pending.parent
92
+ require(not protected.is_symlink() and not pending.is_symlink(),
93
+ 'unsafe_root_private_path')
94
+ online=Ed25519Signer.from_bytes((app.settings.service_keys/'online.key').read_bytes())
95
+ signer,old,new,csr,proof=validate_journal(journal,pin=pin,
96
+ service=app.settings.service_url,online_public=online.public_key)
97
+ key_file=protected/'key.json'
98
+ require(not key_file.is_symlink(),'unsafe_root_private_path')
99
+ if key_file.exists():
100
+ require(digest(read_private(key_file)) in {
101
+ journal['previous_envelope_digest'],digest(canonical(journal['new_envelope']))},
102
+ 'root_envelope_changed')
103
+ else:
104
+ require(journal['previous_envelope_digest'] is None,'root_envelope_missing')
105
+ # Reject unsafe or substituted history BEFORE changing the database anchor.
106
+ history = protected/'history'/old.resource_id/'key.json'
107
+ for directory in (protected/'history', history.parent):
108
+ require(not directory.is_symlink(), 'unsafe_root_private_path')
109
+ require(not history.is_symlink(), 'unsafe_root_private_path')
110
+ if history.exists():
111
+ require(digest(read_private(history)) == journal['previous_envelope_digest'],
112
+ 'rotation_history_mismatch')
113
+ elif journal['previous_envelope_digest'] is not None:
114
+ require(key_file.exists() and
115
+ digest(read_private(key_file)) == journal['previous_envelope_digest'],
116
+ 'rotation_history_missing')
117
+ require(app.settings.trust_file.is_file() and not app.settings.trust_file.is_symlink(),
118
+ 'rotation_trust_missing')
119
+ current_trust = loads(app.settings.trust_file.read_bytes())
120
+ # This method is called only after the local console has approved the exact
121
+ # journal. It also handles the state where PostgreSQL committed but protected files did not.
122
+ await app.open_storage()
123
+ async with app.metadata.transaction(write=True) as tx:
124
+ current=tx.setting('active_root_certificate','cert_root')
125
+ require(current in {old.resource_id,new.resource_id},'trust_anchor_mismatch')
126
+ existing=await tx.certificate(old.resource_id)
127
+ require(canonical(existing)==canonical(old),'trust_anchor_mismatch')
128
+ old_key=await tx.credential(old.key_id)
129
+ require(old_key.subject_id==ROOT_SUBJECT and
130
+ digest(old_key.verifier)==journal['statement']['old_fingerprint'],
131
+ 'rotation_statement_mismatch')
132
+ old_trust = {'version': 1, 'public_key': b64(old_key.verifier), 'certificate': wire(old)}
133
+ require(canonical(current_trust) in (canonical(old_trust), canonical(journal['new_trust'])),
134
+ 'rotation_trust_changed')
135
+ if journal['old_signature'] is not None:
136
+ verify(old_key.verifier,canonical(proof),decode(Signature,journal['old_signature']),
137
+ purpose='root-rotation')
138
+ if current == new.resource_id:
139
+ require(canonical(await tx.certificate(new.resource_id))==canonical(new) and
140
+ canonical(await tx.csr(csr.resource_id))==canonical(csr) and
141
+ old_key.revoked_at is not None and await tx.certificate_revoked(old.resource_id),
142
+ 'rotation_resume_mismatch')
143
+ installed=await tx.credential(new.key_id)
144
+ require(installed.verifier==signer.public_key and installed.revoked_at is None,
145
+ 'rotation_resume_mismatch')
146
+ else:
147
+ subject=await tx.subject(ROOT_SUBJECT)
148
+ require(subject.local_only,'root_policy_corrupt')
149
+ # Credential saves are upserts; a retired root key must never be revived.
150
+ require(tx.one('SELECT 1 FROM credentials WHERE id=?',(signer.key_id,)) is None,
151
+ 'root_key_reused')
152
+ await tx.save_credential(Credential(id=signer.key_id,subject_id=ROOT_SUBJECT,kind='signing_key',
153
+ verifier=signer.public_key,ceiling=(),not_before=new.not_before,expires_at=None,revoked_at=None),subject.auth_version)
154
+ await tx.save_credential(replace(old_key,revoked_at=app.clock()),subject.auth_version)
155
+ await certificate_resource(tx,new,app.clock())
156
+ await tx.register_certificate(new,None,0)
157
+ event=Event(id=new_id('audit'),type='root.rotate',time=app.clock(),request_id=new.resource_id,
158
+ actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,resources=(ResourceRef(id=new.resource_id),),
159
+ data={'statement':journal['statement'],'authorization':proof,'new_signature':journal['new_signature'],
160
+ 'old_signature':journal['old_signature']})
161
+ await tx.revoke_certificate(old.resource_id,AuditEvent(event=event,authority=(),
162
+ before_digest=digest(old),after_digest=digest(new),previous_digest=None,entry_digest='',result='root_rotated'))
163
+ await seed_resource(tx,app.contents,dict(id=csr.resource_id,type='csr',name=csr.resource_id,parent=CSR_SPACE,
164
+ owner=ONLINE_CA,group=ADMINS_GROUP,mode='0600'),app.clock())
165
+ await tx.save_csr(csr)
166
+ tx.set_setting('online_ca_request',csr.resource_id)
167
+ tx.set_setting('online_ca_certificate',None)
168
+ tx.set_setting('active_root_certificate',new.resource_id)
169
+ tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
170
+ tx.set_setting('root_rotation:'+new.resource_id,journal['statement'])
171
+ # The journal and envelope must use the same independently protected root
172
+ # directory, including when resuming after the metadata commit.
173
+ for directory in (protected,protected/'history',protected/'history'/old.resource_id):
174
+ require(not directory.is_symlink(),'unsafe_root_private_path')
175
+ directory.mkdir(parents=True,exist_ok=True,mode=0o700)
176
+ os.chmod(directory,0o700)
177
+ history=protected/'history'/old.resource_id/'key.json'
178
+ require(not history.is_symlink(),'unsafe_root_private_path')
179
+ if history.exists():
180
+ require(digest(read_private(history))==journal['previous_envelope_digest'],
181
+ 'rotation_history_mismatch')
182
+ elif journal['previous_envelope_digest'] is not None:
183
+ # Never archive an already installed new envelope as the old key.
184
+ require(key_file.exists() and
185
+ digest(read_private(key_file))==journal['previous_envelope_digest'],
186
+ 'rotation_history_missing')
187
+ durable_write(history,read_private(key_file),mode=0o600)
188
+ durable_write(key_file,canonical(journal['new_envelope']),mode=0o600)
189
+ durable_write(app.settings.trust_file,canonical(journal['new_trust']),mode=0o444)
190
+ # Journal survives every earlier failure and is removed only after both
191
+ # metadata and protected files have durable copies.
192
+ pending.unlink(missing_ok=True)
193
+ directory=os.open(protected,os.O_RDONLY|os.O_DIRECTORY)
194
+ try:os.fsync(directory)
195
+ finally:os.close(directory)
196
+ return {'root_id':ROOT_SUBJECT,'certificate_id':new.resource_id,'fingerprint':digest(signer.public_key),
197
+ 'online_ca_request':csr.resource_id,'restart_required':True,'old_certificate_chains_valid':False}
@@ -0,0 +1,54 @@
1
+ """Exercise one-use credential release only on the caller's isolated Test Root."""
2
+ from datetime import timedelta
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+
6
+ import httpx
7
+
8
+ from msg.client import ClientState, MsgClient
9
+ from msg.core.errors import Failure
10
+ from msg.transports.client import HTTPTransport
11
+ from msg.transports.http import create_app
12
+
13
+
14
+ async def check_token_delivery(app, now):
15
+ with TemporaryDirectory(prefix='msg-token-selftest-') as directory:
16
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app))) as http:
17
+ state = ClientState(Path(directory), server=app.settings.service_url)
18
+ transport = HTTPTransport(state.server, http=http)
19
+ client = MsgClient(state, transport, clock=lambda: now, retries=0)
20
+ created = await client.temporary()
21
+ if created.status != 'ok':
22
+ return False
23
+ original = state.token
24
+ send = transport.call
25
+
26
+ async def discard(packet):
27
+ result = await send(packet)
28
+ if result.status != 'ok':
29
+ raise Failure('selftest_issuance_failed')
30
+ raise httpx.ReadTimeout('selftest response loss')
31
+
32
+ transport.call = discard
33
+ for attempt in (client.rotate_token, client.recover_token):
34
+ try:
35
+ await attempt()
36
+ return False
37
+ except Failure as exc:
38
+ if exc.code != 'transport_uncertain':
39
+ return False
40
+ # Both processes can restart; refreshing only the request expiry
41
+ # does not refresh either the token lifetime or recovery deadline.
42
+ state = ClientState(Path(directory), server=app.settings.service_url)
43
+ client = MsgClient(state, HTTPTransport(state.server, http=http),
44
+ clock=lambda: now+timedelta(seconds=1), retries=0)
45
+ replay = await client.recover_token()
46
+ if replay.status != 'error' or replay.error.code != 'token_delivery_unavailable':
47
+ return False
48
+ completed = await client.recover_token()
49
+ if completed.status != 'ok' or not state.token or state.token[0] == original[0]:
50
+ return False
51
+ async with app.metadata.transaction(write=False) as tx:
52
+ old = await tx.credential(original[0])
53
+ current = await tx.credential(state.token[0])
54
+ return old.revoked_at is not None and current.revoked_at is None and client._token_journal() == (None, None)