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
@@ -0,0 +1,300 @@
1
+ """Current owner/group/mode/ShareGrant/credential/certificate authorization path."""
2
+ from __future__ import annotations
3
+ from msg.constants import ROOT_SUBJECT,TOOLS_SPACE
4
+ from msg.core.errors import Failure,require
5
+ from msg.core.codec import loads,parse_time,wire
6
+ from msg.core.models import ResourceRef
7
+ from msg.security.sharing_policy import share_target_error
8
+ from msg.security.quarantine import active as quarantine_active, require_live_authority
9
+ from msg.security.policy import allows,grant_covers,scope_contains,CERTGATE
10
+
11
+ _WRITE_CHECKS={'write','create','remove','chmod','chgrp','chown','manage','certgate','purge','tool_use'}
12
+ _OVERRIDE={'read':'resource.read_override','list':'resource.read_override','traverse':'resource.read_override',
13
+ 'write':'resource.write_override','chmod':'resource.chmod_override','chgrp':'resource.chgrp_override',
14
+ 'chown':'resource.chown','purge':'resource.purge','tool_use':'tool.use'}
15
+
16
+
17
+ class AuthorizationService:
18
+ def __init__(self,registry,certificates):
19
+ self.registry,self.certificates=registry,certificates
20
+
21
+ async def grants(self,principal,session):
22
+ require_live_authority(session)
23
+ grants=[]
24
+ for cid in principal.certificates:
25
+ certificate=await self.certificates.validate(cid,session)
26
+ require(certificate.key_id==principal.credential_id and certificate.subject_id==principal.actor,
27
+ 'certificate_subject_mismatch')
28
+ grants.extend(certificate.grants)
29
+ return tuple(grants)
30
+
31
+ async def has(self,principal,capability,operation,resource,session):
32
+ if principal.method=='local' and principal.subject==ROOT_SUBJECT:
33
+ return True
34
+ if not any([await grant_covers(g,capability,operation,resource,session) for g in principal.ceiling]):
35
+ return False
36
+ return any([await grant_covers(g,capability,operation,resource,session)
37
+ for g in await self.grants(principal,session)])
38
+
39
+ async def _ceiling(self,principal,operation,resource,session):
40
+ require_live_authority(session)
41
+ if principal.method=='anonymous':
42
+ return
43
+ allowed=any([operation in g.operations and await scope_contains(g.scope,resource,session)
44
+ for g in principal.ceiling])
45
+ if not allowed and operation in {'discovery.get@1','discovery.raw@1','job.get@1',
46
+ 'transfer.open@1','transfer.part_get@1','transfer.status@1','transfer.seal@1','transfer.cancel@1'}:
47
+ output=session.setting('tool_output:'+resource)
48
+ if output and output['subject']==principal.subject:
49
+ allowed=await self.has(principal,'tool.use',operation,output['tool_id'],session)
50
+ require(allowed,'credential_ceiling')
51
+ if principal.actor!=principal.subject:
52
+ delegated=[]
53
+ for cid in principal.certificates:
54
+ cert=await self.certificates.validate(cid,session)
55
+ if cert.kind=='delegation':
56
+ for source in cert.authority_sources:
57
+ fact=await self.certificates.validate_authority(source,cert,session)
58
+ if fact['grantor']==principal.subject:
59
+ delegated.extend(cert.grants)
60
+ require(any([operation in g.operations and await scope_contains(g.scope,resource,session)
61
+ for g in delegated]),'delegation_scope')
62
+
63
+ async def ordinary(self,principal,operation,resource,session):
64
+ if principal.method in {'anonymous','local'}:
65
+ return True
66
+ from msg.security.capabilities import BASE_FAMILIES
67
+ return any([g.capability in BASE_FAMILIES and operation in g.operations and
68
+ await scope_contains(g.scope,resource,session) for g in principal.ceiling])
69
+
70
+ async def share_source_active(self,resource,grant_id,subject,now,session, *, reshare=False,
71
+ seen=frozenset()):
72
+ """Resolve a bounded source chain; stored facts are not current authority.
73
+
74
+ Unsupported operations/constraints and malformed restored facts deny this
75
+ source only. Independent owner/group/certificate/grant sources survive.
76
+ """
77
+ chain=(*await session.ancestors(resource.id),resource)
78
+ if share_target_error(self.registry,resource,chain,session):
79
+ return False
80
+ seen=set(seen)
81
+ child_expiry=None
82
+ while grant_id is not None:
83
+ if grant_id in seen or len(seen)>=16:
84
+ return False
85
+ seen.add(grant_id)
86
+ row=session.one('''SELECT resource_id,grantor,grantee,grantee_kind,parent_id,
87
+ operations,constraints,allow_reshare,expires_at,revoked_at,created_at
88
+ FROM share_grants_v2 WHERE id=?''',(grant_id,))
89
+ if row is None or row[0]!=resource.id or row[9] is not None:
90
+ return False
91
+ try:
92
+ expires=parse_time(row[8])
93
+ created=parse_time(row[10])
94
+ supported=loads(row[5])==['read'] and loads(row[6])=={}
95
+ except (Failure,ValueError,TypeError,OverflowError):
96
+ return False
97
+ if (not supported or created>now or expires<=now or expires<=created or
98
+ (child_expiry is not None and child_expiry>expires)):
99
+ return False
100
+ if reshare and row[7]!=1:
101
+ return False
102
+ if row[3]=='user':
103
+ if row[2]!=subject:
104
+ return False
105
+ elif row[3]=='group':
106
+ try:
107
+ group=await session.resource(row[2])
108
+ await session.organization(row[2])
109
+ except Failure as exc:
110
+ if exc.code in {'not_found','group_not_found'}:
111
+ return False
112
+ raise
113
+ if group.state!='active' or row[2] not in {
114
+ member.organization_id for member in await session.memberships(subject)}:
115
+ return False
116
+ else:
117
+ return False
118
+ if row[4] is None:
119
+ return row[1]==resource.owner
120
+ subject,grant_id,child_expiry,reshare=row[1],row[4],expires,True
121
+ return False
122
+
123
+ async def shared_read(self,principal,resource,chain,now,session):
124
+ """A direct, live source for exactly one resource's read check."""
125
+ if principal.subject is None or principal.actor != principal.subject:
126
+ return False
127
+ if share_target_error(self.registry,resource,chain,session):
128
+ return False
129
+ row=session.one('''SELECT grantor,expires_at FROM share_grants
130
+ WHERE resource_id=? AND grantee=? AND revoked_at IS NULL''',
131
+ (resource.id,principal.subject))
132
+ if row and row[0]==resource.owner and parse_time(row[1])>now and resource.state=='active':
133
+ return True
134
+ if resource.state!='active':
135
+ return False
136
+ candidates=session.rows('''SELECT id FROM share_grants_v2
137
+ WHERE resource_id=? AND revoked_at IS NULL AND expires_at>?''',
138
+ (resource.id,wire(now)))
139
+ return any([await self.share_source_active(resource,row[0],principal.subject,now,session)
140
+ for row in candidates])
141
+
142
+ async def share_link_read_allowed(self,grantor,credential_id,resource,chain,now,session):
143
+ """Recheck one bearer source without creating a principal or granting traversal.
144
+
145
+ The original signed owner's credential is a revocation boundary. A link
146
+ cannot borrow that principal for other reads or outlive its current scope.
147
+ """
148
+ if quarantine_active(session):
149
+ return False
150
+ if resource.owner!=grantor or share_target_error(self.registry,resource,chain,session):
151
+ return False
152
+ memberships={m.organization_id for m in await session.memberships(grantor)}
153
+ if not allows(resource,grantor,memberships,'read') or any(
154
+ not allows(ancestor,grantor,memberships,'traverse') for ancestor in chain[:-1]):
155
+ return False
156
+ try:
157
+ credential=await session.credential(credential_id)
158
+ except Failure as exc:
159
+ if exc.code=='credential_not_found':
160
+ return False
161
+ raise
162
+ if (credential.subject_id!=grantor or credential.kind!='signing_key' or
163
+ credential.revoked_at is not None or credential.not_before>now or
164
+ (credential.expires_at is not None and credential.expires_at<=now)):
165
+ return False
166
+ return any([('sharing.link_create@1' in grant.operations and
167
+ await scope_contains(grant.scope,resource.id,session))
168
+ for grant in credential.ceiling])
169
+
170
+ async def require_base(self,principal,operation,resource,session):
171
+ await self._ceiling(principal,operation,resource,session)
172
+ require(await self.ordinary(principal,operation,resource,session),'credential_ceiling')
173
+
174
+ async def require(self,context,request,checks,session):
175
+ principal=context.principal
176
+ require_live_authority(session)
177
+ if principal.subject==ROOT_SUBJECT or principal.actor==ROOT_SUBJECT:
178
+ require(context.entry=='local_admin' and principal.method=='local','local_only')
179
+ memberships={m.organization_id for m in await session.memberships(principal.subject)} if principal.subject else set()
180
+ for check in checks:
181
+ resource=await session.resource(check.resource_id)
182
+ operation=check.operation
183
+ await self._ceiling(principal,operation,resource.id,session)
184
+ chain=(*await session.ancestors(resource.id),resource)
185
+ preview=next((marker for item in chain
186
+ for marker in (session.setting('hosting_preview:'+item.id),
187
+ session.setting('hosting_preview_file:'+item.id)) if marker),None)
188
+ if preview is not None:
189
+ require(principal.subject==preview['owner'] or
190
+ (principal.subject==ROOT_SUBJECT and context.entry=='local_admin'
191
+ and principal.method=='local'),'permission_denied')
192
+ if check.check in _WRITE_CHECKS and any(
193
+ item.id in {'r_agents','r_rules'} for item in chain):
194
+ require(False,'system_managed_resource')
195
+ if check.check in _WRITE_CHECKS and any(item.id=='t_last_will' for item in chain):
196
+ require(operation in {'identity.legacy_put@1','identity.legacy_put@2',
197
+ 'identity.legacy_archive@1'} and
198
+ principal.subject==resource.owner,'legacy_directive_only')
199
+ if check.check in _WRITE_CHECKS and any(
200
+ parent.type=='user' and child.name in {'SOUL.md','AGENTS.md','notes','todos'}
201
+ for parent,child in zip(chain,chain[1:])):
202
+ sharing_notes=(operation in {'sharing.grant@1','sharing.grant@2','sharing.revoke@1',
203
+ 'sharing.revoke@2',
204
+ 'sharing.link_create@1','sharing.link_revoke@1'} and
205
+ all(child.name not in {'SOUL.md','AGENTS.md','todos'}
206
+ for parent,child in zip(chain,chain[1:]) if parent.type=='user'))
207
+ require((sharing_notes or operation in {'identity.personal_put@1','identity.note_put@1',
208
+ 'identity.personal_put@2','identity.note_put@2',
209
+ 'identity.soul_visibility@1','identity.note_archive@1',
210
+ 'identity.note_restore@1','identity.todo_put@1',
211
+ 'identity.todo_put@2',
212
+ 'identity.todo_archive@1','identity.todo_restore@1'}) and
213
+ principal.subject==resource.owner,'personal_managed_resource')
214
+ if check.check in _WRITE_CHECKS and principal.subject is not None:
215
+ for ancestor in reversed(chain):
216
+ if ancestor.type!='topic':
217
+ continue
218
+ ban=session.one("SELECT expires_at FROM topic_bans WHERE topic=? AND subject=? AND status='active'",
219
+ (ancestor.id,principal.subject))
220
+ if ban is not None and (ban[0] is None or parse_time(ban[0])>context.now):
221
+ require(False,'topic_banned')
222
+ direct=None
223
+ for ancestor in chain:
224
+ direct=session.one('SELECT participant_a,participant_b,state,resource_id FROM dm_conversations WHERE resource_id=?',
225
+ (ancestor.id,))
226
+ if direct is not None:
227
+ break
228
+ if direct is not None:
229
+ # A direct conversation has two fixed participants. Neither file
230
+ # mode nor an override certificate may reveal it to a third party.
231
+ require(principal.subject in direct[:2], 'permission_denied')
232
+ if check.check in {'read','list','traverse'}:
233
+ continue
234
+ if (check.check=='create' and resource.id==direct[3] and
235
+ operation=='communication.dm_send@1' and direct[2]=='active'):
236
+ blocked=session.one('SELECT 1 FROM dm_blocks WHERE (blocker=? AND blocked=?) OR (blocker=? AND blocked=?)',
237
+ (direct[0],direct[1],direct[1],direct[0]))
238
+ require(blocked is None,'dm_blocked')
239
+ continue
240
+ if (check.check=='write' and resource.type=='post' and resource.owner==principal.subject and
241
+ operation=='content.post_edit@1'):
242
+ blocked=session.one('SELECT 1 FROM dm_blocks WHERE (blocker=? AND blocked=?) OR (blocker=? AND blocked=?)',
243
+ (direct[0],direct[1],direct[1],direct[0]))
244
+ require(direct[2]=='active' and blocked is None,'dm_blocked')
245
+ continue
246
+ require(False,'dm_controlled_resource')
247
+ # tool.use only reveals its scoped tool and the ancestors needed to reach it.
248
+ tool_access=resource.type=='tool' and await self.has(principal,'tool.use',operation,resource.id,session)
249
+ if resource.type=='tool':
250
+ require(tool_access,'tool_certificate_required')
251
+ shared_read=(check.check=='read' and await self.shared_read(
252
+ principal,resource,chain,context.now,session))
253
+ for ancestor in chain[:-1]:
254
+ readable=allows(ancestor,principal.subject,memberships,'traverse')
255
+ override=await self.has(principal,'resource.read_override',operation,ancestor.id,session)
256
+ minimal_tool=tool_access and ancestor.id==TOOLS_SPACE
257
+ require(readable or override or minimal_tool or shared_read,'permission_denied')
258
+ require(ancestor.state=='active','ancestor_inactive')
259
+ if check.check in _WRITE_CHECKS:
260
+ require(principal.subject is not None,'authentication_required')
261
+ for ancestor in chain:
262
+ if ancestor.mode&CERTGATE:
263
+ require(await self.has(principal,'resource.certified_write',operation,resource.id,session),
264
+ 'certificate_gate')
265
+ if resource.type=='tool':
266
+ require(tool_access,'tool_certificate_required')
267
+ require(check.check in {'read','tool_use'},'tool_read_only')
268
+ continue
269
+ if resource.type=='csr' and check.check=='read' and principal.subject!=resource.owner:
270
+ csr=await session.csr(resource.id)
271
+ require(csr.requested_issuer==principal.subject and await self.has(principal,'cert.issue',operation,resource.id,session),
272
+ 'permission_denied')
273
+ continue
274
+ if check.check=='certgate':
275
+ allowed=await self.has(principal,'resource.certified_write',operation,resource.id,session)
276
+ elif check.check in {'chmod','chgrp'}:
277
+ allowed=principal.subject==resource.owner
278
+ elif check.check=='manage':
279
+ allowed=principal.subject==resource.owner
280
+ elif check.check=='create':
281
+ allowed=allows(resource,principal.subject,memberships,'write') and allows(resource,principal.subject,memberships,'traverse')
282
+ elif check.check=='remove':
283
+ allowed=allows(resource,principal.subject,memberships,'write') and allows(resource,principal.subject,memberships,'traverse')
284
+ elif check.check in {'chown','purge','tool_use'}:
285
+ allowed=False
286
+ else:
287
+ allowed=allows(resource,principal.subject,memberships,check.check)
288
+ if not allowed and check.check=='read':
289
+ allowed=shared_read
290
+ ordinary=await self.ordinary(principal,operation,resource.id,session)
291
+ if not ordinary and check.check=='read':
292
+ output=session.setting('tool_output:'+resource.id)
293
+ ordinary=bool(output and output['subject']==principal.subject and
294
+ await self.has(principal,'tool.use',operation,output['tool_id'],session))
295
+ allowed=allowed and ordinary
296
+ capability=_OVERRIDE.get(check.check)
297
+ if not allowed and capability is not None:
298
+ allowed=await self.has(principal,capability,operation,resource.id,session)
299
+ require(allowed,'permission_denied')
300
+ return tuple(ResourceRef(id=c) for c in principal.certificates)
@@ -0,0 +1,135 @@
1
+ """Root-signed attestation that listed backup sets no longer hold one old key.
2
+
3
+ Only the physical-console import in msg.admin.backup_retirement persists a
4
+ record, under a settings key that no network operation writes. Every read
5
+ re-verifies the signature against the current, unrevoked root credential and the
6
+ exact subject and old key IDs; an absent, altered, expired or superseded-root
7
+ record is not evidence. The statement is an operator attestation for the listed
8
+ backup sets only, not a proof that no other copy of the key exists anywhere.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timedelta
14
+
15
+ from msg.constants import ROOT_SUBJECT
16
+ from msg.core.codec import canonical, decode, digest, parse_time, wire
17
+ from msg.core.errors import Failure, require
18
+ from msg.core.models import Signature
19
+ from msg.security.crypto import key_id, verify
20
+
21
+ DOMAIN = 'msg-custodial-backup-retirement-v1'
22
+ PURPOSE = 'custodial-backup-retirement-v1'
23
+ PREFIX = 'custodial_backup_retirement:'
24
+ MAX_VALIDITY = timedelta(days=400)
25
+ FIELDS = frozenset({'domain', 'subject_id', 'old_identity_key_id', 'old_encryption_key_id',
26
+ 'backup_sets', 'attested_at', 'not_after', 'operator'})
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BackupRetirement:
31
+ """Constructed only by check(); a bare boolean or dict is never accepted."""
32
+ subject_id: str
33
+ old_identity_key_id: str
34
+ old_encryption_key_id: str
35
+ backup_sets: tuple
36
+ attested_at: datetime
37
+ not_after: datetime
38
+ signer_key_id: str
39
+ record_digest: str
40
+
41
+ def binds(self, subject_id, details):
42
+ return (self.subject_id == subject_id and
43
+ self.old_identity_key_id == details.get('old_identity_key_id') and
44
+ self.old_encryption_key_id == details.get('old_encryption_key_id'))
45
+
46
+ def view(self):
47
+ return {'scope': 'listed_backup_sets_only', 'backup_sets': list(self.backup_sets),
48
+ 'attested_at': wire(self.attested_at), 'not_after': wire(self.not_after),
49
+ 'signer_key_id': self.signer_key_id, 'record_digest': self.record_digest}
50
+
51
+
52
+ def setting_key(subject_id, old_identity_key_id):
53
+ return PREFIX + subject_id + ':' + old_identity_key_id
54
+
55
+
56
+ def statement(subject_id, details, backup_sets, *, attested_at, not_after, operator):
57
+ return {'domain': DOMAIN, 'subject_id': subject_id,
58
+ 'old_identity_key_id': details['old_identity_key_id'],
59
+ 'old_encryption_key_id': details['old_encryption_key_id'],
60
+ 'backup_sets': sorted(set(backup_sets)), 'attested_at': wire(attested_at),
61
+ 'not_after': wire(not_after), 'operator': operator}
62
+
63
+
64
+ def sign_record(body, signer):
65
+ return {'statement': body, 'signature': wire(signer.sign(canonical(body), purpose=PURPOSE))}
66
+
67
+
68
+ async def root_verifier(tx):
69
+ """The key of the active, unrevoked root certificate; rotation retires old records."""
70
+ try:
71
+ certificate_id = tx.setting('active_root_certificate')
72
+ certificate = await tx.certificate(certificate_id)
73
+ credential = await tx.credential(certificate.key_id)
74
+ require(certificate.subject_id == ROOT_SUBJECT and
75
+ not await tx.certificate_revoked(certificate_id) and
76
+ credential.subject_id == ROOT_SUBJECT and credential.kind == 'signing_key' and
77
+ credential.revoked_at is None and
78
+ key_id(credential.verifier) == credential.id == certificate.key_id,
79
+ 'backup_retirement_root_unavailable')
80
+ except (Failure, KeyError, TypeError, ValueError) as exc:
81
+ raise Failure('backup_retirement_root_unavailable') from exc
82
+ return credential.verifier
83
+
84
+
85
+ def _sets(value):
86
+ return (isinstance(value, list) and bool(value) and value == sorted(set(value)) and
87
+ all(isinstance(item, str) and 0 < len(item) <= 256 for item in value))
88
+
89
+
90
+ def check(record, verifier, subject_id, details, *, now):
91
+ """Return the attestation only if this exact record is valid now for these old keys."""
92
+ try:
93
+ require(isinstance(record, dict) and set(record) == {'statement', 'signature'},
94
+ 'backup_retirement_invalid')
95
+ body = record['statement']
96
+ require(isinstance(body, dict) and set(body) == FIELDS and body['domain'] == DOMAIN and
97
+ all(isinstance(body[name], str) and body[name] for name in
98
+ ('subject_id', 'old_identity_key_id', 'old_encryption_key_id', 'operator')) and
99
+ _sets(body['backup_sets']), 'backup_retirement_invalid')
100
+ attested, not_after = parse_time(body['attested_at']), parse_time(body['not_after'])
101
+ signature = decode(Signature, record['signature'])
102
+ verify(verifier, canonical(body), signature, purpose=PURPOSE)
103
+ require(attested < not_after <= attested + MAX_VALIDITY, 'backup_retirement_invalid')
104
+ except (Failure, KeyError, TypeError, ValueError) as exc:
105
+ raise Failure('backup_retirement_invalid') from exc
106
+ require(isinstance(details, dict) and body['subject_id'] == subject_id and
107
+ body['old_identity_key_id'] == details.get('old_identity_key_id') and
108
+ body['old_encryption_key_id'] == details.get('old_encryption_key_id'),
109
+ 'backup_retirement_binding_mismatch')
110
+ try:
111
+ current = attested <= now < not_after
112
+ except TypeError:
113
+ current = False
114
+ require(current, 'backup_retirement_expired')
115
+ return BackupRetirement(subject_id=subject_id,
116
+ old_identity_key_id=body['old_identity_key_id'],
117
+ old_encryption_key_id=body['old_encryption_key_id'],
118
+ backup_sets=tuple(body['backup_sets']), attested_at=attested, not_after=not_after,
119
+ signer_key_id=signature.key_id, record_digest=digest(record))
120
+
121
+
122
+ async def verified(tx, subject_id, details, *, now):
123
+ """(status, attestation-or-None); any failure is reported as not retired."""
124
+ old = details.get('old_identity_key_id') if isinstance(details, dict) else None
125
+ if not isinstance(subject_id, str) or not isinstance(old, str) or not old:
126
+ return 'unavailable', None
127
+ record = tx.setting(setting_key(subject_id, old))
128
+ if record is None:
129
+ return 'unavailable', None
130
+ if now is None:
131
+ return 'clock_unavailable', None
132
+ try:
133
+ return 'verified', check(record, await root_verifier(tx), subject_id, details, now=now)
134
+ except Failure as exc:
135
+ return ('expired' if exc.code == 'backup_retirement_expired' else 'invalid'), None
@@ -0,0 +1,157 @@
1
+ """Finite, versioned authority vocabulary. A ceiling is a limit, not a grant."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ from msg.constants import ROOT_SPACE
6
+ from msg.core.models import CapabilitySpec, CapabilityGrant, Scope, ResourceRef
7
+ from msg.plugins.schemas import NETWORK_CONSTRAINTS
8
+
9
+ # Base capabilities route ordinary actions to mode/ownership checks. They never
10
+ # stand in for a special capability or a trusted local execution entry.
11
+ BASE_FAMILIES = {
12
+ 'identity.basic': ('identity.register', 'identity.temporary', 'identity.custodial_create', 'identity.upgrade',
13
+ 'identity.token_rotate', 'identity.token_create', 'identity.token_recover',
14
+ 'identity.upgrade_result',
15
+ 'identity.key_add', 'identity.key_revoke',
16
+ 'identity.email_set', 'identity.email_verify', 'identity.email_get', 'identity.email_notifications',
17
+ 'identity.delegate', 'identity.delegation_revoke', 'identity.certificate_renew',
18
+ 'identity.ssh_key_add', 'identity.ssh_key_revoke', 'identity.ssh_certificates',
19
+ 'identity.identity_key_get', 'identity.identity_key_list',
20
+ 'identity.encryption_key_get', 'identity.encryption_key_list',
21
+ 'identity.encryption_key_rotate',
22
+ 'identity.custodial_status','identity.custodial_upgrade_start',
23
+ 'identity.custodial_upgrade_finish','identity.personal_put','identity.soul_visibility','identity.note_put',
24
+ 'identity.custodial_rewrap_entry','identity.custodial_rewrap_revision',
25
+ 'identity.custodial_rewrap_ack','identity.custodial_migration_get',
26
+ 'identity.custodial_upgrade_result','identity.custodial_upgrade_inventory',
27
+ 'identity.note_list','identity.note_get',
28
+ 'identity.note_archive','identity.note_restore','identity.todo_put',
29
+ 'identity.todo_list','identity.todo_get','identity.todo_archive','identity.todo_restore',
30
+ 'identity.legacy_put','identity.legacy_archive','identity.legacy_get','identity.legacy_status',
31
+ 'identity.recovery_policy_get','identity.recovery_policy_set',
32
+ 'identity.recovery_envelope_register','identity.recovery_envelope_get',
33
+ 'identity.recovery_envelope_list','identity.recovery_custodians',
34
+ 'achievement.start', 'achievement.answer', 'achievement.finish',
35
+ 'achievement.pin','achievement.unpin','achievement.reorder'),
36
+ 'resource.basic': ('content.',),
37
+ 'sharing.basic': ('sharing.',),
38
+ 'discussion.basic': ('discussion.',),
39
+ 'communication.basic': ('communication.',),
40
+ 'money.basic': ('money.',),
41
+ 'store.basic': ('store.',),
42
+ 'bounty.basic': ('bounty.',),
43
+ 'orders.basic': ('orders.',),
44
+ 'delivery.basic': ('delivery.',),
45
+ 'discovery.basic': ('discovery.', 'cert.get', 'job.get', 'achievement.list'),
46
+ 'transfer.basic': ('transfer.',),
47
+ 'group.basic': ('group.',),
48
+ 'cert.request': ('cert.request', 'cert.cancel'),
49
+ 'git.basic': ('git.',),
50
+ 'hosting.basic': ('hosting.',),
51
+ 'keystore.basic': ('keystore.',),
52
+ 'batch.basic': ('batch.',),
53
+ }
54
+ EXCLUDED_BASE = {'content.purge','content.chown','identity.recover'}
55
+ TEMPORARY_OPERATIONS = frozenset({
56
+ 'identity.temporary','identity.custodial_create','identity.upgrade','identity.token_rotate','identity.custodial_status',
57
+ 'identity.custodial_upgrade_start','identity.custodial_upgrade_finish',
58
+ 'identity.custodial_rewrap_entry','identity.custodial_rewrap_revision',
59
+ 'identity.custodial_migration_get','identity.custodial_upgrade_inventory',
60
+ 'identity.custodial_rewrap_ack',
61
+ 'content.topic_create','content.post_create','content.post_edit','content.archive',
62
+ 'content.restore','content.file_put','content.attach',
63
+ 'discussion.reply','discussion.quote','discussion.repost','discussion.like','discussion.unlike',
64
+ 'discussion.ack','discussion.acks','discussion.thread',
65
+ 'communication.send','communication.watch','communication.unwatch',
66
+ 'communication.inbox','communication.outbox','communication.changes',
67
+ 'communication.handoff_create','communication.handoff_get','communication.handoff_list',
68
+ 'communication.handoff_decide','communication.lease_acquire','communication.lease_get',
69
+ 'communication.lease_list','communication.lease_renew','communication.lease_release',
70
+ 'discovery.get','discovery.raw','discovery.list','discovery.search','discovery.operations',
71
+ 'discovery.capabilities','discovery.schema','discovery.diff','discovery.references',
72
+ 'transfer.open','transfer.part_put','transfer.part_get','transfer.status','transfer.seal','transfer.cancel',
73
+ 'keystore.get',
74
+ 'batch.independent','batch.atomic','job.get',
75
+ 'achievement.start','achievement.answer','achievement.finish','achievement.list',
76
+ })
77
+
78
+
79
+ def _selected(operations, selectors):
80
+ return frozenset(f'{op.name}@{op.version}' for op in operations
81
+ if any(op.name.startswith(s) if s.endswith('.') else op.name==s for s in selectors))
82
+
83
+
84
+ def install_capabilities(registry):
85
+ operations=registry.operations()
86
+ types=frozenset(spec.name for spec in registry.resource_types())
87
+ by_name={op.name:op for op in operations}
88
+ network_constraints=ResourceRef(id='schema:network-constraints')
89
+ registry.add_schema(network_constraints, NETWORK_CONSTRAINTS)
90
+ for name,selectors in BASE_FAMILIES.items():
91
+ names=_selected(operations,selectors)-frozenset(f'{n}@1' for n in EXCLUDED_BASE)
92
+ if names:
93
+ registry.add_capability(CapabilitySpec(name=name,version=1,scope_types=types,
94
+ operations=names,replaces_checks=frozenset(),delegatable=True,ca_only=False,constraints_schema=None))
95
+ reads=frozenset(f'{op.name}@{op.version}' for op in operations if op.effect=='read')
96
+ writes=frozenset(f'{op.name}@{op.version}' for op in operations if op.effect!='read')
97
+ all_ops=reads|writes
98
+ # An override only replaces the named check; it cannot waive a credential
99
+ # ceiling, certgate, tool gate, sticky, or root.local_only.
100
+ rules={
101
+ 'resource.certified_write': (writes,()),
102
+ 'resource.read_override': (all_ops,('read','list','traverse')),
103
+ 'resource.write_override': (writes,('write',)),
104
+ 'resource.chmod_override': (_selected(operations,('content.chmod',)),('chmod',)),
105
+ 'resource.chgrp_override': (_selected(operations,('content.chgrp',)),('chgrp',)),
106
+ 'resource.chown': (_selected(operations,('content.chown',)),('chown',)),
107
+ 'resource.purge': (_selected(operations,('content.purge',)),('purge',)),
108
+ 'identity.recover': (_selected(operations,('identity.recover',)),()),
109
+ 'group.manage_override': (_selected(operations,('group.member.','group.admin.',
110
+ 'group.invite','group.approve','group.reject','group.remove')),('manage',)),
111
+ 'tool.use': (_selected(operations,('tool.run','discovery.get','discovery.list','transfer.open',
112
+ 'transfer.part_get','transfer.status','transfer.seal','transfer.cancel','discovery.raw','job.get')),('tool_use',)),
113
+ 'tool.net.private': (_selected(operations,('tool.run',)),()),
114
+ 'webhook.domain': (_selected(operations,('communication.webhook_subscribe',)),()),
115
+ 'system.namespace': (all_ops,()),
116
+ 'cert.issue': (_selected(operations,('cert.publish','cert.get','discovery.get')),()),
117
+ 'cert.revoke': (_selected(operations,('cert.revoke',)),()),
118
+ 'cert.ca.issue': (_selected(operations,('cert.publish',)),()),
119
+ 'system.inspect': (_selected(operations,('system.inspect',)),()),
120
+ 'system.config': (_selected(operations,('system.config','system.share_links_set')),()),
121
+ 'system.maintenance': (_selected(operations,('system.maintenance',)),()),
122
+ }
123
+ for name,(names,replaces) in rules.items():
124
+ registry.add_capability(CapabilitySpec(name=name,version=1,scope_types=types,operations=names,
125
+ replaces_checks=frozenset(replaces),delegatable=name not in {'cert.ca.issue'},
126
+ ca_only=name.startswith('cert.'),constraints_schema=network_constraints if name.startswith('tool.') else None))
127
+ # Publish a finite supported operation vocabulary on every resource type.
128
+ # Handlers still check the precise role of each resource argument (source,
129
+ # destination, owner, etc.), rather than inferring it from this directory.
130
+ for key,spec in tuple(registry._types.items()):
131
+ relations=frozenset({'reply_to','thread_root','quote','repost','attachment','template'}) if spec.name=='post' else frozenset()
132
+ registry._types[key]=replace(spec,operations=all_ops,relations=relations)
133
+
134
+
135
+ def grant_for(spec, *, scope=None, operations=None):
136
+ return CapabilityGrant(capability=spec.name,version=spec.version,
137
+ scope=scope or Scope(resource_id=ROOT_SPACE,descendants=True),
138
+ operations=spec.operations if operations is None else frozenset(operations),constraints={})
139
+
140
+
141
+ def primary_ceiling(registry,scope=None):
142
+ # Explicit snapshot: future registry entries do not enter existing credentials.
143
+ return tuple(grant_for(spec,scope=scope) for spec in registry.capabilities() if spec.operations)
144
+
145
+
146
+ def base_grants(registry,scope=None):
147
+ return tuple(grant_for(spec,scope=scope) for spec in registry.capabilities() if spec.name in BASE_FAMILIES and spec.operations)
148
+
149
+
150
+ def temporary_ceiling(registry,scope=None):
151
+ temporary=frozenset(f'{name}@1' for name in TEMPORARY_OPERATIONS)|{
152
+ 'identity.upgrade@2','identity.temporary@2','identity.temporary@3',
153
+ 'identity.custodial_create@2',
154
+ 'identity.custodial_upgrade_finish@2','identity.custodial_rewrap_ack@2',
155
+ 'identity.token_rotate@2'}
156
+ return tuple(grant_for(spec,scope=scope,operations=spec.operations&temporary) for spec in registry.capabilities()
157
+ if spec.name in BASE_FAMILIES and spec.operations&temporary)