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.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- 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()
|
msg/plugins/recovery.py
ADDED
|
@@ -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()
|