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/plugins/orders.py ADDED
@@ -0,0 +1,273 @@
1
+ """Signed sale checkout, funded escrow, and pre-delivery buyer cancellation.
2
+
3
+ Delivery, seller release and arbitration need their own validated state
4
+ transitions before escrow may be debited for any other reason.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import os
10
+ from functools import partial
11
+
12
+ from msg.market.escrow import EscrowEngine, validate_policy
13
+ from msg.core.codec import canonical, digest, loads, parse_time, wire
14
+ from msg.core.errors import require
15
+ from msg.core.models import HandlerOutput
16
+ from msg.plugins.common import registration
17
+ from msg.plugins.money import CURRENCY_ID, MAX_MINOR, _balance, _post_transfer, account_requirements
18
+ from msg.plugins.schemas import IDENTIFIER, obj
19
+ from msg.plugins.store import _body, _listing, _package_row
20
+
21
+
22
+ _COLUMNS = ('id', 'buyer', 'seller', 'listing_id', 'listing_revision',
23
+ 'package_id', 'package_revision', 'package_digest', 'quantity',
24
+ 'unit_price_minor', 'total_price_minor', 'currency_id',
25
+ 'escrow_subject', 'escrow_policy', 'dispute_policy', 'terms_digest',
26
+ 'delivery_target', 'payment_intent_digest', 'payment_transaction_id',
27
+ 'state', 'created_at', 'funded_at', 'delivered_at', 'settled_at',
28
+ 'receipt_refs')
29
+
30
+
31
+ def _subject(ctx):
32
+ subject = ctx.principal.subject
33
+ require(subject is not None and ctx.principal.actor == subject and
34
+ ctx.principal.method == 'signature', 'signature_required')
35
+ return subject
36
+
37
+
38
+ def _viewer(ctx):
39
+ subject = ctx.principal.subject
40
+ require(subject is not None and ctx.principal.actor == subject,
41
+ 'order_not_found')
42
+ return subject
43
+
44
+
45
+ def _order_id():
46
+ # 160 random bits, unguessable even if an attacker sees other order IDs.
47
+ return 'ord_' + base64.b32encode(os.urandom(20)).decode('ascii').rstrip('=').lower()
48
+
49
+
50
+ def _row(tx, order_id, viewer):
51
+ row = tx.one('''SELECT id,buyer,seller,listing_id,listing_revision,
52
+ package_id,package_revision,package_digest,quantity,unit_price_minor,
53
+ total_price_minor,currency_id,escrow_subject,escrow_policy,
54
+ dispute_policy,terms_digest,delivery_target,payment_intent_digest,
55
+ payment_transaction_id,state,created_at,funded_at,delivered_at,
56
+ settled_at,receipt_refs FROM store_orders WHERE id=?''', (order_id,))
57
+ # A valid ID is not an access grant. Keep nonexistent and unauthorized alike.
58
+ require(row is not None and viewer in row[1:3], 'order_not_found')
59
+ result = dict(zip(_COLUMNS, row))
60
+ result['delivery_target'] = loads(result['delivery_target'])
61
+ result['receipt_refs'] = loads(result['receipt_refs'])
62
+ return result
63
+
64
+
65
+ def _view(row, viewer):
66
+ keys = ('id', 'buyer', 'seller', 'listing_id', 'listing_revision',
67
+ 'package_revision', 'package_digest', 'quantity',
68
+ 'unit_price_minor', 'total_price_minor', 'currency_id',
69
+ 'escrow_policy', 'dispute_policy', 'terms_digest', 'state',
70
+ 'created_at', 'funded_at', 'delivered_at', 'settled_at')
71
+ result = {key: row[key] for key in keys}
72
+ result['payment_status'] = ('refunded' if row['state'] == 'refunded' else
73
+ 'funded' if row['payment_transaction_id'] else
74
+ 'pending')
75
+ result['delivery_channel'] = row['delivery_target']['channel']
76
+ if viewer == row['buyer']:
77
+ result['delivery_target'] = row['delivery_target']
78
+ result['payment_intent_digest'] = row['payment_intent_digest']
79
+ result['receipt_refs'] = row['receipt_refs']
80
+ return result
81
+
82
+
83
+ def install(app):
84
+ op, finish = registration(app, 'orders', ('store', 'money'))
85
+ # Account authority belongs to the operation contract, not just the handler:
86
+ # the executor must apply it before returning an idempotent cached result.
87
+ op = partial(op, requirements=account_requirements)
88
+ amount = {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR}
89
+ quantity = {'type': 'integer', 'minimum': 1, 'maximum': 10**9}
90
+
91
+ @op('orders.buy', obj({'listing_id': IDENTIFIER,
92
+ 'listing_revision': IDENTIFIER, 'quantity': quantity,
93
+ 'currency_id': {'const': CURRENCY_ID}, 'total_price_minor': amount,
94
+ 'package_digest': {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'},
95
+ 'auto_accept': {'type': 'boolean'},
96
+ 'email': {'type': 'string', 'minLength': 3, 'maxLength': 254}},
97
+ ('listing_id', 'listing_revision', 'quantity', 'currency_id',
98
+ 'total_price_minor', 'package_digest')), signature=True, version=2)
99
+ @op('orders.buy', obj({'listing_id': IDENTIFIER,
100
+ 'listing_revision': IDENTIFIER, 'quantity': quantity,
101
+ 'currency_id': {'const': CURRENCY_ID}, 'total_price_minor': amount},
102
+ ('listing_id', 'listing_revision', 'quantity', 'currency_id',
103
+ 'total_price_minor')), signature=True)
104
+ async def buy(ctx, request, tx):
105
+ buyer = _subject(ctx)
106
+ args = request.arguments
107
+ instant = request.contract_version == 2
108
+ if instant:
109
+ require('delivery' in app.settings.server.plugins, 'delivery_disabled')
110
+ if 'email' in args:
111
+ from msg.market.delivery_targets import validate_address
112
+ validate_address(args['email'])
113
+ listing = await _listing(app, ctx, request, tx, args['listing_id'])
114
+ body, _ = await _body(app, tx, listing)
115
+ require(body['mode'] == 'sale' and body['state'] == 'active' and
116
+ (body['expires_at'] is None or
117
+ parse_time(body['expires_at']) > ctx.now), 'listing_not_available')
118
+ require(body['delivery_mode'] == 'managed_instant' and
119
+ body['item_kind'] in {'file','bundle'},
120
+ 'order_delivery_mode_unsupported')
121
+ policy = validate_policy(body['escrow_policy'],body['dispute_policy'])
122
+ if not instant:
123
+ require(body['escrow_policy'] == 'escrow-v1', 'escrow_policy_requires_v2')
124
+ if args.get('auto_accept'):
125
+ require('checkout_accept' in policy['reasons'], 'escrow_reason_unsupported')
126
+ require(args['listing_revision'] == listing.revision,
127
+ 'listing_revision_conflict')
128
+ require(body['currency_id'] == args['currency_id'] == CURRENCY_ID,
129
+ 'unsupported_currency')
130
+ requested = args['quantity']
131
+ require(requested <= body['quantity'], 'quantity_unavailable')
132
+ sold = tx.one('''SELECT COALESCE(SUM(quantity),0) FROM store_orders
133
+ WHERE listing_id=? AND state NOT IN ('cancelled','refunded')''',
134
+ (listing.id,))[0]
135
+ require(int(sold) + requested <= body['quantity'], 'quantity_unavailable')
136
+ total = body['price_minor'] * requested
137
+ require(0 < total <= MAX_MINOR, 'money_overflow')
138
+ require(args['total_price_minor'] == total, 'price_changed')
139
+ require(_balance(tx, buyer) >= total, 'insufficient_funds')
140
+ package_id = body['package_ref']
141
+ package_revision = package_digest = None
142
+ if body['delivery_mode'] == 'managed_instant':
143
+ # The package remains private; checkout sees only its locked digest.
144
+ package = await _package_row(tx, package_id) if package_id else None
145
+ require(package is not None and package[1] == listing.id and
146
+ package[3] == listing.owner and
147
+ package[5] == body['item_kind'] and
148
+ package[10] == body['delivery_mode'], 'package_not_available')
149
+ require(0 <= package[9] <= 1024 * 1024,
150
+ 'delivery_package_too_large')
151
+ package_revision, package_digest = package[4], package[8]
152
+ if instant:
153
+ require(args['package_digest'] == package_digest, 'delivery_package_mismatch')
154
+ # Site ownership is separate from an optional, buyer-owned email
155
+ # endpoint. SMTP never changes this authoritative target.
156
+ target = {'subject_id': buyer, 'channel': 'site'}
157
+ order_id = _order_id()
158
+ escrow = 'esc_' + order_id[4:]
159
+ seller = listing.owner
160
+ require(seller != buyer, 'self_purchase_forbidden')
161
+ # The signed orders.buy request is the buyer's PaymentIntent. Its
162
+ # payload digest covers the quoted revision, currency, quantity and
163
+ # total; the server-generated order ID is not falsely attributed to
164
+ # the buyer's signature.
165
+ # Escrow is a LedgerAccount, never an Identity/Subject. It has no
166
+ # Resource, key, credential, signer or network authentication path.
167
+ tx.execute('''INSERT INTO ledger_accounts(id,kind,subject_id,source_id)
168
+ VALUES (?,'order_escrow',NULL,?)''', (escrow,order_id), write=True)
169
+ receipt = _post_transfer(tx, sender=buyer, recipient=escrow,
170
+ amount=total, actor=buyer, request_id=request.request_id,
171
+ now=ctx.now, receipt_signer=app.receipt_signer,
172
+ reference='order_fund:' + order_id)
173
+ receipt_id = receipt['body']['transaction_id']
174
+ tx.execute('''INSERT INTO store_orders
175
+ (id,buyer,seller,listing_id,listing_revision,package_id,
176
+ package_revision,package_digest,quantity,unit_price_minor,
177
+ total_price_minor,currency_id,escrow_subject,escrow_policy,
178
+ dispute_policy,terms_digest,delivery_target,payment_intent_digest,
179
+ payment_transaction_id,state,created_at,funded_at,delivered_at,
180
+ settled_at,receipt_refs)
181
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
182
+ (order_id,buyer,seller,listing.id,listing.revision,package_id,
183
+ package_revision,package_digest,requested,body['price_minor'],
184
+ total,CURRENCY_ID,escrow,body['escrow_policy'],
185
+ body['dispute_policy'],digest(body['terms']),
186
+ canonical(target).decode(),request.payload_digest,receipt_id,
187
+ 'funded',wire(ctx.now),wire(ctx.now),None,None,
188
+ canonical([receipt_id]).decode()), write=True)
189
+ data = {'payment': receipt}
190
+ if instant:
191
+ from msg.plugins.delivery import prepare_managed, delivery_summary
192
+ from msg.market.delivery_notifications import initialize_notification
193
+ order = _row(tx, order_id, buyer)
194
+ delivery = await prepare_managed(app, ctx, tx, order)
195
+ data['delivery'] = delivery_summary(delivery)
196
+ if args.get('auto_accept'):
197
+ released, decision, _ = await EscrowEngine(app).settle(
198
+ ctx, request, tx, reason='checkout_accept', order_id=order_id)
199
+ data.update(settlement=released, decision=decision)
200
+ data['notification'] = await initialize_notification(
201
+ app, ctx, request, tx, _row(tx, order_id, buyer), args.get('email'))
202
+ data['order'] = _view(_row(tx, order_id, buyer), buyer)
203
+ return HandlerOutput(data=data)
204
+
205
+ @op('orders.cancel', obj({'order_id': IDENTIFIER}, ('order_id',)),
206
+ signature=True)
207
+ async def cancel(ctx, request, tx):
208
+ buyer = _subject(ctx)
209
+ refund,decision,_ = await EscrowEngine(app).settle(ctx,request,tx,reason='buyer_cancel')
210
+ return HandlerOutput(data={'order':_view(_row(tx,request.arguments['order_id'],buyer),buyer),
211
+ 'refund':refund,'decision':decision})
212
+
213
+ @op('orders.get', obj({'order_id': IDENTIFIER}, ('order_id',)), effect='read')
214
+ async def get(ctx, request, tx):
215
+ viewer = _viewer(ctx)
216
+ from msg.market.orders import view
217
+ return HandlerOutput(data={'order': view(tx, _row(tx, request.arguments['order_id'], viewer), viewer)})
218
+
219
+ @op('orders.list', obj({'role': {'enum': ['buy', 'sell']},
220
+ 'status': {'enum': ['open', 'completed', 'disputed']},
221
+ 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100}}), effect='read')
222
+ async def list_orders(ctx, request, tx):
223
+ viewer = _viewer(ctx)
224
+ args = request.arguments
225
+ role = args.get('role')
226
+ status = args.get('status')
227
+ limit = args.get('limit', 50)
228
+ where = '(buyer=? OR seller=?)'
229
+ values = [viewer, viewer]
230
+ if role:
231
+ where = ('buyer=?' if role == 'buy' else 'seller=?')
232
+ values = [viewer]
233
+ if status:
234
+ where += (' AND state IN (\'created\',\'funded\',\'delivered\',\'accepted\')'
235
+ if status == 'open' else
236
+ ' AND state IN (\'settled\',\'cancelled\',\'refunded\')'
237
+ if status == 'completed' else
238
+ " AND state='disputed'")
239
+ rows = tx.rows('SELECT id FROM store_orders WHERE ' + where +
240
+ ' ORDER BY created_at DESC,id DESC LIMIT ?',
241
+ (*values,limit))
242
+ return HandlerOutput(data={'orders': [_view(_row(tx, id, viewer), viewer)
243
+ for (id,) in rows]})
244
+
245
+ @op('orders.payment', obj({'order_id': IDENTIFIER}, ('order_id',)),
246
+ effect='read')
247
+ async def payment(ctx, request, tx):
248
+ viewer = _viewer(ctx)
249
+ row = _row(tx, request.arguments['order_id'], viewer)
250
+ if tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (row['id'],)):
251
+ from msg.market.policy import contract
252
+ contract(tx, row['id'])
253
+ result = {'order_id': row['id'],
254
+ 'status': _view(row, viewer)['payment_status'],
255
+ 'amount_minor': row['total_price_minor'],
256
+ 'currency_id': row['currency_id']}
257
+ if viewer == row['buyer']:
258
+ receipts = []
259
+ for transaction_id in row['receipt_refs']:
260
+ ledger = tx.one('SELECT receipt FROM money_ledger WHERE id=?',
261
+ (transaction_id,))
262
+ require(ledger is not None, 'order_not_found')
263
+ receipts.append(loads(ledger[0]))
264
+ result['receipt'] = receipts[0] if receipts else None
265
+ result['receipts'] = receipts
266
+ result['escrow_balance_minor'] = _balance(tx, row['escrow_subject'])
267
+ return HandlerOutput(data={'payment': result})
268
+
269
+ from msg.market.orders import install as install_market
270
+ install_market(app, op)
271
+ from msg.market.arbitration import install as install_arbitration
272
+ install_arbitration(app, op)
273
+ finish()
@@ -0,0 +1,376 @@
1
+ """Owner-declared recovery metadata. No account recovery or decryption authority."""
2
+ from __future__ import annotations
3
+
4
+ from msg.core.codec import b64,canonical,decode,loads,unb64,wire
5
+ from msg.core.errors import Failure,require
6
+ from msg.core.models import HandlerOutput,ResourceRef,Event,AuditEvent,Signature
7
+ from msg.core.requests import signing_bytes
8
+ from msg.plugins.common import (registration,check_access,new_id,resolve,operation_id,
9
+ create_resource,revise_resource,assert_generation)
10
+ from msg.plugins.schemas import IDENTIFIER,INTEGER,REF,STRING,obj
11
+ from msg.security.age_keys import encryption_key_id,public_from_recipient
12
+ from msg.security.vault import open_age_identity,rewrap_owned_age_ciphertext
13
+ from msg.core.codec import digest
14
+ from msg.security.crypto import verify
15
+ from msg.security.custodial_migration import (snapshot as migration_snapshot, ack_statement,
16
+ keyed_source, keyed_target)
17
+
18
+
19
+ def _current_policy(tx,subject):
20
+ row=tx.one('SELECT body FROM recovery_policies WHERE subject=? ORDER BY version DESC LIMIT 1',
21
+ (subject,))
22
+ return dict(loads(row[0])) if row else None
23
+
24
+
25
+ def _owner(app,ctx,request,tx):
26
+ from msg.plugins.identity import controlled_owner
27
+ return controlled_owner(app,ctx,request,tx)
28
+
29
+
30
+
31
+ def install(app):
32
+ op,finish=registration(app,'recovery',('identity',))
33
+
34
+ async def migration(ctx, request, tx, *, pending=False):
35
+ from msg.plugins.custodial_lifecycle import authorize_migration
36
+ subject = await _owner(app, ctx, request, tx)
37
+ row = tx.one('''SELECT credential_id,status,challenge,body FROM custodial_upgrades
38
+ WHERE id=? AND subject=?''', (request.arguments['challenge_id'], subject.resource_id))
39
+ require(row is not None, 'custodial_upgrade_not_pending')
40
+ challenge, details = loads(row[2]), loads(row[3])
41
+ authorize_migration(ctx, subject, row[0], row[1], details)
42
+ require(not pending or row[1] != 'pending', 'custodial_upgrade_not_pending_rewrap')
43
+ state = await migration_snapshot(tx, subject.resource_id, row[1], challenge, details,
44
+ now=ctx.now)
45
+ return subject, row[1], challenge, details, state
46
+
47
+ @op('identity.custodial_upgrade_inventory',obj({'challenge_id':IDENTIFIER},
48
+ ('challenge_id',)),effect='read')
49
+ async def custodial_upgrade_inventory(ctx, request, tx):
50
+ *_, state = await migration(ctx, request, tx)
51
+ return HandlerOutput(data=state.data)
52
+
53
+ rewrap_schema = obj({'challenge_id':IDENTIFIER,'ciphertext_ref':REF,
54
+ 'old_encryption_key_id':IDENTIFIER,'new_recipient':STRING},
55
+ ('challenge_id','ciphertext_ref','old_encryption_key_id','new_recipient'))
56
+
57
+ async def rewrap(ctx, request, tx, *, current_only):
58
+ from msg.plugins.custodial_lifecycle import activate_encryption_target, audit
59
+ subject, status, challenge, details, state = await migration(ctx, request, tx, pending=True)
60
+ state.unchanged()
61
+ args = request.arguments
62
+ require(challenge['encryption_recipient'] == args['new_recipient'] and
63
+ details['old_encryption_key_id'] == args['old_encryption_key_id'],
64
+ 'custodial_rewrap_key_mismatch')
65
+ vault = tx.one('SELECT encryption_key_id,status FROM custodial_vault WHERE subject=?',
66
+ (subject.resource_id,))
67
+ require(vault is not None and vault[0] == args['old_encryption_key_id'] and
68
+ vault[1] in {'active','decrypt_only'}, 'custodial_rewrap_unknown_old_key')
69
+ activate_encryption_target(tx, subject.resource_id, challenge, details, ctx.now)
70
+ ref = decode(ResourceRef, args['ciphertext_ref'])
71
+ require(ref.revision is not None, 'custodial_rewrap_revision_required')
72
+ resource = await tx.resource(ref.id)
73
+ folder = tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'", (subject.resource_id,))
74
+ require(folder is not None and resource.parent == folder[0] and
75
+ resource.type == 'keystore' and resource.owner == subject.resource_id and
76
+ resource.state != 'purged', 'custodial_rewrap_not_owned')
77
+ if current_only:
78
+ await assert_generation(request, resource)
79
+ require(resource.revision == ref.revision, 'revision_conflict')
80
+ await check_access(app,ctx,request,tx,resource.id,'read')
81
+ revision = await tx.revision(ref)
82
+ old = {'id': resource.id, 'revision': revision.id, 'ciphertext_digest': revision.content.digest}
83
+ require(old in details['age_inventory'], 'custodial_rewrap_not_in_frozen_inventory')
84
+ require(keyed_source(old, details)['key_id'] == args['old_encryption_key_id'],
85
+ 'custodial_rewrap_key_mismatch')
86
+ require(revision.id not in details.get('rewrap_mappings',{}), 'custodial_rewrap_already_mapped')
87
+ require(tx.setting('keystore_format:' + revision.id) == 'age' and revision.content.size <= 1048576,
88
+ 'custodial_rewrap_age_required')
89
+ old_ciphertext = await app.contents.read_bytes(revision.content, limit=1048576)
90
+ new_ciphertext = rewrap_owned_age_ciphertext(open_age_identity(app,tx,subject.resource_id),
91
+ args['new_recipient'],old_ciphertext)
92
+ copy = await create_resource(app,ctx,request,tx,parent=folder[0],type='keystore',
93
+ name='migration-' + digest((args['challenge_id'],revision.id))[7:31] + '.age',
94
+ body=new_ciphertext,media_type='application/octet-stream',mode=0o600)
95
+ tx.set_setting('keystore_format:' + copy.revision,'age')
96
+ tx.set_setting('keystore_key:' + copy.revision,details['new_encryption_key_id'])
97
+ new = {'id':copy.id,'revision':copy.revision,'ciphertext_digest':digest(new_ciphertext)}
98
+ mapping = {'old':old,'new':new,'recipient':args['new_recipient'],
99
+ 'old_key_id':args['old_encryption_key_id'],'new_key_id':details['new_encryption_key_id'],
100
+ 'created_at':wire(ctx.now),'copy_kind':'historical_revision'}
101
+ details.setdefault('rewrap_mappings',{})[revision.id] = mapping
102
+ tx.execute('UPDATE custodial_upgrades SET body=? WHERE id=?',
103
+ (canonical(details).decode(),args['challenge_id']),write=True)
104
+ await audit(tx,ctx,request,subject.resource_id,request.operation,
105
+ {'challenge_id':args['challenge_id'],'mapping':mapping,'old_revision_unchanged':True,
106
+ 'plaintext_exposed':False})
107
+ return HandlerOutput(resources=(ResourceRef(id=copy.id,revision=copy.revision),),
108
+ data={'mapping':mapping,'old_revision_unchanged':True,
109
+ 'client_decryption_acked':False,'client_decryption_verified':False,
110
+ 'vault_remains_active':vault[1]=='active',
111
+ 'new_revision':copy.revision,'new_recipient':args['new_recipient'],
112
+ 'ciphertext_digest':digest(new_ciphertext)})
113
+
114
+ @op('identity.custodial_rewrap_entry',rewrap_schema)
115
+ async def custodial_rewrap_entry(ctx,request,tx):
116
+ return await rewrap(ctx,request,tx,current_only=True)
117
+
118
+ @op('identity.custodial_rewrap_revision',rewrap_schema)
119
+ async def custodial_rewrap_revision(ctx,request,tx):
120
+ return await rewrap(ctx,request,tx,current_only=False)
121
+
122
+ @op('identity.custodial_migration_get',obj({'challenge_id':IDENTIFIER,'old_revision':IDENTIFIER},
123
+ ('challenge_id','old_revision')),effect='read')
124
+ async def custodial_migration_get(ctx,request,tx):
125
+ subject,status,challenge,details,state = await migration(ctx,request,tx,pending=True)
126
+ revision_id = request.arguments['old_revision']
127
+ source = next((item for item in details['age_inventory'] if item['revision']==revision_id),None)
128
+ require(source is not None,'custodial_migration_not_found')
129
+ mapping = details.get('rewrap_mappings',{}).get(revision_id)
130
+ envelope = details.get('recovery_envelope')
131
+ require(mapping is not None or envelope is not None,'custodial_migration_not_found')
132
+ target = mapping['new'] if mapping else envelope['ciphertext']
133
+ for item in (source,target):
134
+ await check_access(app,ctx,request,tx,item['id'],'read')
135
+ revision = await tx.revision(ResourceRef(id=item['id'],revision=item['revision']))
136
+ require(revision.content.digest==item['ciphertext_digest'],'custodial_migration_digest_mismatch')
137
+ ack = details.get('rewrap_acks' if mapping else 'recovery_acks',{}).get(revision_id)
138
+ return HandlerOutput(output=ResourceRef(id=target['id'],revision=target['revision']),
139
+ data={'status':status,'source':source,'mapped':mapping['new'] if mapping else None,
140
+ 'recovery_envelope':envelope if not mapping else None,
141
+ 'method':'rewrap' if mapping else 'compatibility_recovery',
142
+ 'recipient':challenge['encryption_recipient'],'client_ack':ack,
143
+ 'old_revision_unchanged':True})
144
+
145
+ @op('identity.custodial_rewrap_ack',obj({
146
+ 'challenge_id':IDENTIFIER,'old_revision':IDENTIFIER,
147
+ 'new_revision':IDENTIFIER,'ciphertext_digest':IDENTIFIER,
148
+ 'plaintext_digest':IDENTIFIER,'decryption_ack':{'type':'object'}},
149
+ ('challenge_id','old_revision','new_revision','ciphertext_digest',
150
+ 'plaintext_digest','decryption_ack')))
151
+ @op('identity.custodial_rewrap_ack',obj({
152
+ 'challenge_id':IDENTIFIER,'old_revision':IDENTIFIER,'new_revision':IDENTIFIER,
153
+ 'ciphertext_digest':IDENTIFIER,'plaintext_digest':IDENTIFIER,
154
+ 'decryption_ack':{'type':'object'},'inventory_digest':IDENTIFIER,
155
+ 'method':{'enum':['rewrap','compatibility_recovery']}},
156
+ ('challenge_id','old_revision','new_revision','ciphertext_digest','plaintext_digest','decryption_ack')),version=2)
157
+ async def custodial_rewrap_ack(ctx,request,tx):
158
+ from msg.plugins.custodial_lifecycle import audit
159
+ subject,status,challenge,details,state = await migration(ctx,request,tx,pending=True)
160
+ state.unchanged()
161
+ args=request.arguments
162
+ source=next((item for item in details['age_inventory'] if item['revision']==args['old_revision']),None)
163
+ require(source is not None,'custodial_rewrap_not_in_frozen_inventory')
164
+ method=args.get('method','rewrap')
165
+ mapping=details.get('rewrap_mappings',{}).get(args['old_revision'])
166
+ envelope=details.get('recovery_envelope')
167
+ if method=='rewrap':
168
+ require(mapping is not None,'custodial_rewrap_mapping_mismatch')
169
+ target=mapping['new']
170
+ committed_target=keyed_target(mapping,details)
171
+ else:
172
+ require(envelope is not None and keyed_source(source,details)['key_id']==envelope['old_key_id'],
173
+ 'custodial_recovery_key_mismatch')
174
+ target=envelope['ciphertext']
175
+ committed_target=envelope
176
+ require(target['revision']==args['new_revision'] and target['ciphertext_digest']==args['ciphertext_digest'],
177
+ 'custodial_rewrap_mapping_mismatch')
178
+ revision=await tx.revision(ResourceRef(id=target['id'],revision=target['revision']))
179
+ require(revision.content.digest==target['ciphertext_digest'],'custodial_rewrap_mapping_mismatch')
180
+ require(args['plaintext_digest'].startswith('sha256:') and len(args['plaintext_digest'])==71 and
181
+ all(c in '0123456789abcdef' for c in args['plaintext_digest'][7:]),'invalid_plaintext_digest')
182
+ scoped='inventory_digest' in args
183
+ if scoped:
184
+ require(args['inventory_digest']==state.inventory_digest,'custodial_migration_preview_stale')
185
+ signed=ack_statement(subject.resource_id,args['challenge_id'],state.inventory_digest,
186
+ keyed_source(source,details),committed_target,args['plaintext_digest'],request.request_id,
187
+ method=method)
188
+ purpose='custodial-history-ack-v1'
189
+ else:
190
+ require(method=='rewrap','custodial_inventory_ack_required')
191
+ signed={'subject_id':subject.resource_id,'challenge_id':args['challenge_id'],
192
+ 'old':source,'new':target,'plaintext_digest':args['plaintext_digest'],
193
+ 'request_id':request.request_id}
194
+ purpose='custodial-rewrap-ack'
195
+ verify(unb64(challenge['public_key']),canonical(signed),decode(Signature,args['decryption_ack']),purpose=purpose)
196
+ bucket=details.setdefault('rewrap_acks' if method=='rewrap' else 'recovery_acks',{})
197
+ previous=bucket.get(args['old_revision'])
198
+ require(previous is None or previous.get('inventory_digest')!=args.get('inventory_digest'),
199
+ 'custodial_rewrap_already_acked')
200
+ ack={'old_revision':args['old_revision'],'new_revision':args['new_revision'],
201
+ 'ciphertext_digest':args['ciphertext_digest'],'plaintext_digest':args['plaintext_digest'],
202
+ 'signature':wire(args['decryption_ack']),'request_id':request.request_id,
203
+ 'verified_at':wire(ctx.now),'method':method}
204
+ if scoped:ack['inventory_digest']=state.inventory_digest
205
+ bucket[args['old_revision']]=ack
206
+ tx.execute('UPDATE custodial_upgrades SET body=? WHERE id=?',
207
+ (canonical(details).decode(),args['challenge_id']),write=True)
208
+ await audit(tx,ctx,request,subject.resource_id,'identity.custodial_rewrap_ack',
209
+ {'challenge_id':args['challenge_id'],'ack':ack,'verification_scope':'enumerated_revision_only'})
210
+ final=await migration_snapshot(tx,subject.resource_id,status,challenge,details)
211
+ return HandlerOutput(data={'challenge_id':args['challenge_id'],'old_revision':args['old_revision'],
212
+ 'new_revision':args['new_revision'],'client_decryption_acked':True,
213
+ 'inventory_bound':scoped,'vault_remains_active':final.data['vault_remains_active'],
214
+ 'finalize_ready':final.data['finalize_ready']})
215
+
216
+ @op('identity.recovery_custodians',obj(),effect='read')
217
+ async def custodians(ctx,request,tx):
218
+ return HandlerOutput(data={'items':[wire(item) for item in app.settings.recovery_custodians]})
219
+
220
+ @op('identity.recovery_policy_get',obj(),effect='read')
221
+ async def policy_get(ctx,request,tx):
222
+ subject=await _owner(app,ctx,request,tx)
223
+ policy=_current_policy(tx,subject.resource_id)
224
+ return HandlerOutput(data=policy or {'subject_id':subject.resource_id,'version':0,
225
+ 'encryption_key_id':None,'recipients':[],'opted_in':False,
226
+ 'recipient_claim':'owner_declared_unverified'})
227
+
228
+ recipient_schema=obj({'recipient':STRING,'custodian_ref':IDENTIFIER},('recipient',))
229
+ @op('identity.recovery_policy_set',obj({
230
+ 'expected_version':{'type':'integer','minimum':0},
231
+ 'encryption_key_id':IDENTIFIER,
232
+ 'recipients':{'type':'array','items':recipient_schema,'maxItems':8}},
233
+ ('expected_version','encryption_key_id','recipients')),signature=True)
234
+ async def policy_set(ctx,request,tx):
235
+ subject=await _owner(app,ctx,request,tx)
236
+ current=_current_policy(tx,subject.resource_id)
237
+ version=current['version'] if current else 0
238
+ args=request.arguments
239
+ require(args['expected_version']==version,'recovery_policy_version_conflict')
240
+ key=tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1',
241
+ (subject.resource_id,))
242
+ require(key is not None and args['encryption_key_id']==key[0],
243
+ 'recovery_encryption_key_mismatch')
244
+ known={item.id:item for item in app.settings.recovery_custodians}
245
+ recipients=[]
246
+ for entry in args['recipients']:
247
+ recipient=entry['recipient']
248
+ fingerprint=encryption_key_id(public_from_recipient(recipient))
249
+ custodian_ref=entry.get('custodian_ref')
250
+ if custodian_ref is not None:
251
+ if custodian_ref in known:
252
+ require(known[custodian_ref].recipient==recipient,
253
+ 'custodian_recipient_mismatch')
254
+ else:
255
+ require(custodian_ref.startswith('u_'),'unknown_custodian_ref')
256
+ await tx.subject(custodian_ref)
257
+ recipients.append({'recipient':recipient,'fingerprint':fingerprint,
258
+ 'custodian_ref':custodian_ref})
259
+ require(len({item['fingerprint'] for item in recipients})==len(recipients),
260
+ 'duplicate_recovery_recipient')
261
+ policy={'subject_id':subject.resource_id,'version':version+1,
262
+ 'encryption_key_id':args['encryption_key_id'],'recipients':recipients,
263
+ 'opted_in':bool(recipients),'recipient_claim':'owner_declared_unverified',
264
+ 'created_at':wire(ctx.now),'signature_source':'self-custody',
265
+ 'request_signature':wire(request.proof.signature),
266
+ 'signed_envelope':b64(signing_bytes(request))}
267
+ tx.execute('INSERT INTO recovery_policies VALUES (?,?,?,?,?)',
268
+ (subject.resource_id,version+1,args['encryption_key_id'],wire(ctx.now),
269
+ canonical(policy).decode()),write=True)
270
+ return HandlerOutput(data=policy)
271
+
272
+ envelope_schema=obj({
273
+ 'ciphertext_ref':REF,'encryption_key_id':IDENTIFIER,
274
+ 'policy_version':{'type':'integer','minimum':1},
275
+ 'recipient_fingerprints':{'type':'array','items':IDENTIFIER,'minItems':1,
276
+ 'maxItems':8,'uniqueItems':True},
277
+ 'custodian_refs':{'type':'array','items':IDENTIFIER,'maxItems':8,'uniqueItems':True},
278
+ 'purpose':{'const':'encryption-subkey-recovery'},'instructions_ref':REF},
279
+ ('ciphertext_ref','encryption_key_id','policy_version','recipient_fingerprints','purpose'))
280
+
281
+ @op('identity.recovery_envelope_register',envelope_schema,signature=True)
282
+ async def envelope_register(ctx,request,tx):
283
+ subject=await _owner(app,ctx,request,tx)
284
+ args=request.arguments
285
+ policy=_current_policy(tx,subject.resource_id)
286
+ require(policy is not None and policy['opted_in'] and
287
+ args['policy_version']==policy['version'],'recovery_policy_not_opted_in')
288
+ primary=tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1',
289
+ (subject.resource_id,))
290
+ require(primary is not None and args['encryption_key_id']==primary[0] and
291
+ args['encryption_key_id']==policy['encryption_key_id'],
292
+ 'recovery_encryption_key_mismatch')
293
+ offered=set(args['recipient_fingerprints'])
294
+ allowed={entry['fingerprint']:entry for entry in policy['recipients']}
295
+ require(offered<=allowed.keys(),'recovery_recipient_not_in_policy')
296
+ expected_refs={entry['custodian_ref'] for fingerprint,entry in allowed.items()
297
+ if fingerprint in offered and entry['custodian_ref'] is not None}
298
+ refs=set(args.get('custodian_refs',()))
299
+ require(refs==expected_refs,'recovery_custodian_ref_mismatch')
300
+ ref=decode(ResourceRef,args['ciphertext_ref'])
301
+ require(ref.revision is not None,'recovery_revision_required')
302
+ resource=await tx.resource(ref.id)
303
+ folder=tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'",
304
+ (subject.resource_id,))
305
+ require(folder is not None and resource.type=='keystore' and
306
+ resource.owner==subject.resource_id and resource.parent==folder[0],
307
+ 'recovery_ciphertext_not_owned')
308
+ await check_access(app,ctx,request,tx,ref.id,'read')
309
+ await tx.revision(ref)
310
+ require(resource.revision==ref.revision,'recovery_revision_not_current')
311
+ require(tx.setting('keystore_format:'+ref.revision)=='age','recovery_age_ciphertext_required')
312
+ instructions=None
313
+ if args.get('instructions_ref'):
314
+ instructions=decode(ResourceRef,args['instructions_ref'])
315
+ instruction_resource=await tx.resource(instructions.id)
316
+ require(instruction_resource.owner==subject.resource_id,
317
+ 'recovery_instructions_not_owned')
318
+ await check_access(app,ctx,request,tx,instructions.id,'read')
319
+ if instructions.revision is not None:
320
+ await tx.revision(instructions)
321
+ record={'id':new_id('renv'),'owner_subject':subject.resource_id,
322
+ 'ciphertext_ref':wire(ref),'encryption_key_id':args['encryption_key_id'],
323
+ 'policy_version':policy['version'],'recipient_fingerprints':sorted(offered),
324
+ 'custodian_refs':sorted(refs),'purpose':args['purpose'],
325
+ 'instructions_ref':wire(instructions) if instructions else None,
326
+ 'created_at':wire(ctx.now),'recipient_claim':'owner_declared_unverified',
327
+ 'signature_source':'self-custody','request_signature':wire(request.proof.signature),
328
+ 'signed_envelope':b64(signing_bytes(request))}
329
+ tx.execute('''INSERT INTO recovery_envelopes
330
+ (id,owner,ciphertext_resource,ciphertext_revision,policy_version,encryption_key_id,
331
+ purpose,created_at,body) VALUES (?,?,?,?,?,?,?,?,?)''',
332
+ (record['id'],subject.resource_id,ref.id,ref.revision,policy['version'],
333
+ args['encryption_key_id'],args['purpose'],record['created_at'],
334
+ canonical(record).decode()),write=True)
335
+ return HandlerOutput(resources=(ref,),data=record)
336
+
337
+ @op('identity.recovery_envelope_get',obj({'id':IDENTIFIER},('id',)),effect='read')
338
+ async def envelope_get(ctx,request,tx):
339
+ subject=await _owner(app,ctx,request,tx)
340
+ row=tx.one('SELECT owner,body FROM recovery_envelopes WHERE id=?',(request.arguments['id'],))
341
+ require(row is not None and row[0]==subject.resource_id,'recovery_envelope_not_found')
342
+ record=loads(row[1])
343
+ ref=decode(ResourceRef,record['ciphertext_ref'])
344
+ await check_access(app,ctx,request,tx,ref.id,'read')
345
+ await tx.revision(ref)
346
+ if record['instructions_ref'] is not None:
347
+ instruction=decode(ResourceRef,record['instructions_ref'])
348
+ await check_access(app,ctx,request,tx,instruction.id,'read')
349
+ if instruction.revision is not None:
350
+ await tx.revision(instruction)
351
+ return HandlerOutput(data=record)
352
+
353
+ @op('identity.recovery_envelope_list',obj(),effect='read')
354
+ async def envelope_list(ctx,request,tx):
355
+ subject=await _owner(app,ctx,request,tx)
356
+ items=[]
357
+ for (raw,) in tx.rows('SELECT body FROM recovery_envelopes WHERE owner=? ORDER BY created_at,id',
358
+ (subject.resource_id,)):
359
+ record=loads(raw)
360
+ ref=decode(ResourceRef,record['ciphertext_ref'])
361
+ try:
362
+ await check_access(app,ctx,request,tx,ref.id,'read')
363
+ await tx.revision(ref)
364
+ if record['instructions_ref'] is not None:
365
+ instruction=decode(ResourceRef,record['instructions_ref'])
366
+ await check_access(app,ctx,request,tx,instruction.id,'read')
367
+ if instruction.revision is not None:
368
+ await tx.revision(instruction)
369
+ except Failure as exc:
370
+ if exc.code in {'permission_denied','not_found','revision_not_found'}:
371
+ continue
372
+ raise
373
+ items.append(record)
374
+ return HandlerOutput(data={'items':items})
375
+
376
+ finish()