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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Owner-signed transitions for an existing custodial upgrade operation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hmac
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from msg.core.codec import b64, canonical, decode, digest, loads, parse_time, unb64, wire
|
|
8
|
+
from msg.core.errors import require
|
|
9
|
+
from msg.core.models import AuditEvent, Credential, Event, HandlerOutput, ResourceRef, Signature
|
|
10
|
+
from msg.plugins.common import create_resource, new_id
|
|
11
|
+
from msg.security.age_keys import public_from_recipient
|
|
12
|
+
from msg.security.crypto import verify
|
|
13
|
+
from msg.security.custodial_migration import (ordered, owned_age_inventory, output_refs,
|
|
14
|
+
snapshot, stage_statement)
|
|
15
|
+
from msg.security.vault import open_signer, seal_retired_encryption_key, server_upgrade_proof
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def activate_encryption_target(tx, subject, challenge, details, now):
|
|
19
|
+
"""After both possession proofs, new writes must no longer target the vault key."""
|
|
20
|
+
key = details['new_encryption_key_id']
|
|
21
|
+
current = tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1', (subject,))
|
|
22
|
+
if current == (key,):
|
|
23
|
+
return
|
|
24
|
+
require(current == (details['old_encryption_key_id'],), 'custodial_encryption_target_changed')
|
|
25
|
+
tx.execute('UPDATE encryption_subkeys SET is_primary=0,retired_at=? WHERE subject=? AND is_primary=1',
|
|
26
|
+
(wire(now), subject), write=True)
|
|
27
|
+
tx.execute('INSERT INTO encryption_subkeys VALUES (?,?,?,?,?,?,?)',
|
|
28
|
+
(key, subject, challenge['encryption_recipient'],
|
|
29
|
+
b64(public_from_recipient(challenge['encryption_recipient'])), wire(now), None, 1), write=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def audit(tx, ctx, request, subject, kind, data, *, before=None):
|
|
33
|
+
event = Event(id=new_id('audit'), type=kind, time=ctx.now, request_id=request.request_id,
|
|
34
|
+
actor=ctx.principal.actor, subject=subject,
|
|
35
|
+
resources=(ResourceRef(id=subject),), data=data)
|
|
36
|
+
await tx.append_audit(AuditEvent(event=event, authority=(ResourceRef(id=subject),),
|
|
37
|
+
before_digest=digest(before) if before is not None else None, after_digest=digest(data),
|
|
38
|
+
previous_digest=None, entry_digest='', result='committed'))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def authorize_migration(ctx, subject, credential_id, status, details):
|
|
42
|
+
if subject.kind == 'custodial':
|
|
43
|
+
require(ctx.principal.method == 'token' and ctx.principal.credential_id == credential_id,
|
|
44
|
+
'custodial_token_required')
|
|
45
|
+
require(status in {'pending', 'pending_rewrap'}, 'custodial_upgrade_not_pending')
|
|
46
|
+
else:
|
|
47
|
+
require(subject.kind == 'registered' and ctx.principal.method == 'signature' and
|
|
48
|
+
ctx.principal.credential_id == details['new_identity_key_id'] and status == 'completed',
|
|
49
|
+
'custodial_upgrade_new_key_required')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def transition(app, ctx, request, tx, subject):
|
|
53
|
+
args = request.arguments
|
|
54
|
+
row = tx.one('''SELECT credential_id,status,expires_at,challenge,ephemeral_nonce,
|
|
55
|
+
ephemeral_ciphertext,body FROM custodial_upgrades WHERE id=? AND subject=?''',
|
|
56
|
+
(args['challenge_id'], subject.resource_id))
|
|
57
|
+
require(row is not None, 'custodial_upgrade_not_pending')
|
|
58
|
+
challenge, details = loads(row[3]), loads(row[6])
|
|
59
|
+
authorize_migration(ctx, subject, row[0], row[1], details)
|
|
60
|
+
verify(unb64(challenge['public_key']), canonical(stage_statement(subject.resource_id, args,
|
|
61
|
+
request.request_id)), decode(Signature, args['migration_ack']), purpose='custodial-decision-v1')
|
|
62
|
+
if row[1] == 'pending':
|
|
63
|
+
require(args['action'] == 'refresh' and ctx.now < parse_time(row[2]), 'custodial_upgrade_not_pending_rewrap')
|
|
64
|
+
expected = server_upgrade_proof(app, subject.resource_id, challenge, row[4], row[5])
|
|
65
|
+
require(hmac.compare_digest(expected, args['age_proof']), 'age_possession_failed')
|
|
66
|
+
else:
|
|
67
|
+
require(hmac.compare_digest(details['age_possession_digest'], digest(args['age_proof'])),
|
|
68
|
+
'age_possession_failed')
|
|
69
|
+
state = await snapshot(tx, subject.resource_id, row[1], challenge, details, now=ctx.now)
|
|
70
|
+
state.require_observation(args)
|
|
71
|
+
before = digest(details)
|
|
72
|
+
if args['action'] == 'refresh':
|
|
73
|
+
# Preserve disappeared history as unresolved work; never delete it from a
|
|
74
|
+
# manifest just because the source is no longer enumerable.
|
|
75
|
+
added = state.data['delta']['added']
|
|
76
|
+
require(added or state.data['delta']['missing'], 'custodial_inventory_unchanged')
|
|
77
|
+
details['age_inventory'] = ordered(details['age_inventory'] + added)
|
|
78
|
+
details['inventory_version'] = details.get('inventory_version', 1) + 1
|
|
79
|
+
source_keys = details.setdefault('source_keys', {})
|
|
80
|
+
for item in added:
|
|
81
|
+
key = tx.setting('keystore_key:' + item['revision'], details['old_encryption_key_id'])
|
|
82
|
+
source_keys[item['revision']] = key
|
|
83
|
+
if key == details['new_encryption_key_id']:
|
|
84
|
+
details.setdefault('rewrap_mappings', {})[item['revision']] = {
|
|
85
|
+
'old': item, 'new': item, 'old_key_id': key, 'new_key_id': key,
|
|
86
|
+
'recipient': challenge['encryption_recipient'], 'copy_kind': 'new_key_write',
|
|
87
|
+
'created_at': wire(ctx.now)}
|
|
88
|
+
details['last_delta'] = dict(state.data['delta'], version=details['inventory_version'])
|
|
89
|
+
details.pop('resolution', None)
|
|
90
|
+
elif args['action'] == 'recovery_envelope':
|
|
91
|
+
state.unchanged()
|
|
92
|
+
require(row[1] != 'pending', 'custodial_upgrade_not_pending_rewrap')
|
|
93
|
+
if details.get('recovery_envelope') is None:
|
|
94
|
+
ciphertext = seal_retired_encryption_key(app, tx, subject.resource_id,
|
|
95
|
+
details['old_encryption_key_id'], challenge['encryption_recipient'], args['challenge_id'])
|
|
96
|
+
folder = tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'", (subject.resource_id,))
|
|
97
|
+
require(folder is not None, 'keystore_missing')
|
|
98
|
+
resource = await create_resource(app, ctx, request, tx, parent=folder[0], type='keystore',
|
|
99
|
+
name='retired-key-' + digest(args['challenge_id'])[7:31] + '.age', body=ciphertext,
|
|
100
|
+
media_type='application/octet-stream', mode=0o600)
|
|
101
|
+
tx.set_setting('keystore_format:' + resource.revision, 'age')
|
|
102
|
+
tx.set_setting('keystore_key:' + resource.revision, details['new_encryption_key_id'])
|
|
103
|
+
details['recovery_envelope'] = {'subject_id': subject.resource_id,
|
|
104
|
+
'challenge_id': args['challenge_id'], 'old_key_id': details['old_encryption_key_id'],
|
|
105
|
+
'new_key_id': details['new_encryption_key_id'], 'recipient': challenge['encryption_recipient'],
|
|
106
|
+
'purpose': 'retired-encryption-subkey-recovery', 'method': 'compatibility_recovery',
|
|
107
|
+
'ciphertext': {'id': resource.id, 'revision': resource.revision,
|
|
108
|
+
'ciphertext_digest': digest(ciphertext)}}
|
|
109
|
+
else:
|
|
110
|
+
require(args['action'] == 'finalize', 'invalid_custodial_transition')
|
|
111
|
+
resolution = args.get('resolution')
|
|
112
|
+
if resolution == 'verified' or state.data['delta']['added']:
|
|
113
|
+
state.unchanged()
|
|
114
|
+
require(not (row[1]=='completed' and state.data['retirement']['online_encryption_key_deleted']),
|
|
115
|
+
'custodial_upgrade_already_finalized')
|
|
116
|
+
unresolved = state.data['unresolved_revisions']
|
|
117
|
+
require(args.get('reviewed_policy_version') == state.data['recovery_policy_version'],
|
|
118
|
+
'custodial_recovery_policy_review_required')
|
|
119
|
+
if resolution == 'verified':
|
|
120
|
+
require(state.data['history_recoverable'], 'custodial_history_ack_required')
|
|
121
|
+
require(not args.get('loss_revisions'), 'custodial_loss_scope_mismatch')
|
|
122
|
+
else:
|
|
123
|
+
require(resolution in {'accept_loss', 'retain_decrypt'} and args.get('reason'),
|
|
124
|
+
'custodial_resolution_required')
|
|
125
|
+
require(sorted(args.get('loss_revisions', ())) == unresolved,
|
|
126
|
+
'custodial_loss_scope_mismatch')
|
|
127
|
+
if resolution == 'accept_loss':
|
|
128
|
+
require(bool(unresolved), 'custodial_loss_scope_mismatch')
|
|
129
|
+
require(resolution == 'retain_decrypt' or args['external_ciphertexts_migrated'],
|
|
130
|
+
'custodial_external_history_unresolved')
|
|
131
|
+
details['resolution'] = {'decision': resolution, 'revisions': unresolved,
|
|
132
|
+
'reason': args.get('reason'), 'inventory_digest': state.inventory_digest,
|
|
133
|
+
'results_digest': state.data['results_digest'], 'request_id': request.request_id,
|
|
134
|
+
'signature': wire(args['migration_ack']), 'reviewed_policy_version': args['reviewed_policy_version'],
|
|
135
|
+
'external_migration': 'owner_declared_unverified' if args['external_ciphertexts_migrated']
|
|
136
|
+
else 'unknown_retained'}
|
|
137
|
+
details['external_ciphertexts_migrated'] = args['external_ciphertexts_migrated']
|
|
138
|
+
return await switch_identity(app, ctx, request, tx, subject, challenge, details,
|
|
139
|
+
retain_decrypt=resolution == 'retain_decrypt')
|
|
140
|
+
tx.execute('UPDATE custodial_upgrades SET body=? WHERE id=?',
|
|
141
|
+
(canonical(details).decode(), args['challenge_id']), write=True)
|
|
142
|
+
await audit(tx, ctx, request, subject.resource_id, 'identity.custodial_' + args['action'],
|
|
143
|
+
{'challenge_id': args['challenge_id'], 'before_digest': before,
|
|
144
|
+
'after_digest': digest(details), 'decision_signature': wire(args['migration_ack'])})
|
|
145
|
+
return HandlerOutput(data=(await snapshot(tx, subject.resource_id, row[1], challenge, details,
|
|
146
|
+
now=ctx.now)).data)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def switch_identity(app, ctx, request, tx, subject, challenge, details, *, retain_decrypt=False):
|
|
150
|
+
"""One SQL transaction: switch identity, revoke tokens, and restrict/delete online keys."""
|
|
151
|
+
from msg.plugins.identity import issue_online
|
|
152
|
+
before = digest(details)
|
|
153
|
+
uid = subject.resource_id
|
|
154
|
+
new_key = details['new_identity_key_id']
|
|
155
|
+
switched = subject.kind == 'registered'
|
|
156
|
+
if not switched:
|
|
157
|
+
require(subject.kind == 'custodial', 'custodial_token_required')
|
|
158
|
+
phase = tx.one('SELECT status FROM custodial_upgrades WHERE id=? AND subject=?',
|
|
159
|
+
(challenge['challenge_id'], uid))
|
|
160
|
+
require(phase is not None, 'custodial_upgrade_not_pending')
|
|
161
|
+
signer = open_signer(app, tx, uid)
|
|
162
|
+
endorsement = signer.sign(canonical({'subject_id': uid, 'challenge_id': challenge['challenge_id'],
|
|
163
|
+
'old_identity_key_id': details['old_identity_key_id'], 'new_identity_key_id': new_key,
|
|
164
|
+
'new_encryption_key_id': details['new_encryption_key_id'],
|
|
165
|
+
'inventory_digest': (await snapshot(tx, uid, phase[0], challenge, details)).inventory_digest}),
|
|
166
|
+
purpose='custodial-upgrade')
|
|
167
|
+
revoked = []
|
|
168
|
+
for (raw,) in tx.rows('SELECT body FROM credentials WHERE subject=?', (uid,)):
|
|
169
|
+
credential = decode(Credential, loads(raw))
|
|
170
|
+
if credential.revoked_at is None and credential.kind in {'token', 'signing_key'}:
|
|
171
|
+
await tx.save_credential(replace(credential, revoked_at=ctx.now), subject.auth_version)
|
|
172
|
+
revoked.append(credential.id)
|
|
173
|
+
credential = Credential(id=new_key, subject_id=uid, kind='signing_key',
|
|
174
|
+
verifier=unb64(challenge['public_key']), ceiling=app.primary_ceiling(),
|
|
175
|
+
not_before=ctx.now, expires_at=None, revoked_at=None)
|
|
176
|
+
await tx.save_credential(credential, subject.auth_version)
|
|
177
|
+
tx.execute('UPDATE identity_keys SET is_primary=0,retired_at=? WHERE subject=? AND is_primary=1',
|
|
178
|
+
(wire(ctx.now), uid), write=True)
|
|
179
|
+
tx.execute('INSERT INTO identity_keys VALUES (?,?,?,?,?,?)',
|
|
180
|
+
(new_key, uid, challenge['public_key'], wire(ctx.now), None, 1), write=True)
|
|
181
|
+
activate_encryption_target(tx, uid, challenge, details, ctx.now)
|
|
182
|
+
resource = await tx.resource(uid)
|
|
183
|
+
await tx.replace(replace(resource, name='@' + challenge['handle'],
|
|
184
|
+
generation=resource.generation + 1, modified_at=ctx.now, modified_by=ctx.principal.actor),
|
|
185
|
+
resource.generation)
|
|
186
|
+
await tx.update_identity(replace(subject, kind='registered', auth_version=subject.auth_version + 1),
|
|
187
|
+
subject.auth_version)
|
|
188
|
+
certificate = await issue_online(app, tx, uid, new_key, ctx, request,
|
|
189
|
+
authority_source={'kind': 'custodial_upgrade', 'challenge_id': challenge['challenge_id'],
|
|
190
|
+
'new_key_id': new_key})
|
|
191
|
+
completed = {'status': 'completed', 'subject_id': uid, 'identity_key_id': new_key,
|
|
192
|
+
'encryption_key_id': details['new_encryption_key_id'],
|
|
193
|
+
'encryption_recipient': challenge['encryption_recipient'],
|
|
194
|
+
'certificate_id': certificate.resource_id, 'previous_credential': ctx.principal.credential_id,
|
|
195
|
+
'credential_id': new_key, 'signature_source': 'custodial'}
|
|
196
|
+
await audit(tx, ctx, request, uid, 'identity.custodial_tokens_revoked',
|
|
197
|
+
{'challenge_id': challenge['challenge_id'], 'credentials': revoked,
|
|
198
|
+
'new_key_id': new_key, 'old_key_endorsement': wire(endorsement)})
|
|
199
|
+
else:
|
|
200
|
+
completed = dict(details['completed_result'])
|
|
201
|
+
if retain_decrypt:
|
|
202
|
+
tx.execute('''UPDATE custodial_vault SET status='decrypt_only',signing_nonce=NULL,
|
|
203
|
+
signing_ciphertext=NULL WHERE subject=?''', (uid,), write=True)
|
|
204
|
+
else:
|
|
205
|
+
tx.execute('''UPDATE custodial_vault SET status='destroyed',signing_nonce=NULL,
|
|
206
|
+
signing_ciphertext=NULL,age_nonce=NULL,age_ciphertext=NULL,destroyed_at=? WHERE subject=?''',
|
|
207
|
+
(wire(ctx.now), uid), write=True)
|
|
208
|
+
current = await snapshot(tx, uid, 'completed', challenge, details, now=ctx.now)
|
|
209
|
+
completed.update(completion_scope='identity_switch_only', phase=current.data['phase'],
|
|
210
|
+
history_recoverable=current.data['history_recoverable'],
|
|
211
|
+
verification_scope=current.data['verification_scope'],
|
|
212
|
+
server_tracked_age_revisions=len(details['age_inventory']), vault_destroyed=not retain_decrypt,
|
|
213
|
+
external_migration='owner_declared_unverified', external_ciphertexts_verified=False,
|
|
214
|
+
retirement=current.data['retirement'], resolution=details.get('resolution'),
|
|
215
|
+
challenge_id=challenge['challenge_id'], finalization_request_id=request.request_id)
|
|
216
|
+
details['completed_result'] = completed
|
|
217
|
+
tx.execute('''UPDATE custodial_upgrades SET status='completed',ephemeral_nonce=NULL,
|
|
218
|
+
ephemeral_ciphertext=NULL,body=? WHERE id=?''',
|
|
219
|
+
(canonical(details).decode(), challenge['challenge_id']), write=True)
|
|
220
|
+
await audit(tx, ctx, request, uid, 'identity.custodial_online_key_retirement',
|
|
221
|
+
{'challenge_id': challenge['challenge_id'], 'old_identity_key_id': details['old_identity_key_id'],
|
|
222
|
+
'old_encryption_key_id': details['old_encryption_key_id'], 'retirement': current.data['retirement'],
|
|
223
|
+
'resolution': details.get('resolution'), 'before_digest': before})
|
|
224
|
+
return HandlerOutput(resources=(ResourceRef(id=uid),), data=completed)
|
msg/plugins/delivery.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Buyer-only in-site delivery of an immutable managed package.
|
|
2
|
+
|
|
3
|
+
The versioned checkout can prepare a package atomically. A signed acceptance
|
|
4
|
+
or an explicit, digest-bound checkout policy controls settlement; merely reading
|
|
5
|
+
or sending an email never claims a delivery.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import partial
|
|
10
|
+
|
|
11
|
+
from msg.core.codec import b64, canonical, decode, digest, loads, wire
|
|
12
|
+
from msg.core.errors import require
|
|
13
|
+
from msg.core.models import BlobRef, HandlerOutput
|
|
14
|
+
from msg.plugins.common import new_id, registration
|
|
15
|
+
from msg.plugins.money import CURRENCY_ID, _balance, account_requirements
|
|
16
|
+
from msg.market.escrow import EscrowEngine
|
|
17
|
+
from msg.market.delivery_targets import validate_target
|
|
18
|
+
from msg.plugins.orders import _row as order_row, _subject
|
|
19
|
+
from msg.plugins.schemas import IDENTIFIER, obj
|
|
20
|
+
from msg.plugins.store import _package_row
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MAX_INLINE_BYTES = 1024 * 1024
|
|
24
|
+
_COLUMNS = ('id', 'order_id', 'recipient_subject', 'kind', 'payload_refs',
|
|
25
|
+
'manifest', 'package_digest', 'delivery_digest', 'channel',
|
|
26
|
+
'state', 'prepared_at', 'claimed_at', 'receipt')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _delivery(tx, order_id):
|
|
30
|
+
row = tx.one('''SELECT id,order_id,recipient_subject,kind,payload_refs,
|
|
31
|
+
manifest,package_digest,delivery_digest,channel,state,prepared_at,
|
|
32
|
+
claimed_at,receipt FROM store_deliveries WHERE order_id=?''', (order_id,))
|
|
33
|
+
if row is None:
|
|
34
|
+
return None
|
|
35
|
+
result = dict(zip(_COLUMNS, row))
|
|
36
|
+
result['payload_refs'] = loads(result['payload_refs'])
|
|
37
|
+
result['manifest'] = loads(result['manifest'])
|
|
38
|
+
result['receipt'] = loads(result['receipt']) if result['receipt'] else None
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _buyer_order(tx, order_id, buyer):
|
|
43
|
+
row = order_row(tx, order_id, buyer)
|
|
44
|
+
require(row['buyer'] == buyer, 'order_not_found')
|
|
45
|
+
return row
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _verified_delivery(app, tx, order, delivery):
|
|
49
|
+
validate_target(order, delivery)
|
|
50
|
+
require(delivery is not None and
|
|
51
|
+
delivery['recipient_subject'] == order['buyer'] and
|
|
52
|
+
delivery['package_digest'] == order['package_digest'] and
|
|
53
|
+
delivery['channel'] == 'site', 'delivery_mismatch')
|
|
54
|
+
kind, manifest, refs = await _package(app, tx, order)
|
|
55
|
+
body = {'delivery_id': delivery['id'], 'order_id': order['id'],
|
|
56
|
+
'recipient_subject': order['buyer'], 'kind': kind,
|
|
57
|
+
'manifest': manifest, 'payload_refs': refs,
|
|
58
|
+
'package_digest': order['package_digest'], 'channel': 'site'}
|
|
59
|
+
require(delivery['kind'] == kind and delivery['manifest'] == manifest and
|
|
60
|
+
delivery['payload_refs'] == refs and
|
|
61
|
+
delivery['delivery_digest'] == digest(body), 'delivery_mismatch')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _package(app, tx, order):
|
|
65
|
+
require(order['package_id'] and order['package_revision'] and
|
|
66
|
+
order['package_digest'], 'delivery_package_mismatch')
|
|
67
|
+
row = await _package_row(tx, order['package_id'])
|
|
68
|
+
require(row is not None and row[1] == order['listing_id'] and
|
|
69
|
+
row[3] == order['seller'] and row[4] == order['package_revision'] and
|
|
70
|
+
row[8] == order['package_digest'] and row[10] == 'managed_instant' and
|
|
71
|
+
row[5] in {'file', 'bundle'}, 'delivery_package_mismatch')
|
|
72
|
+
manifest, refs = loads(row[6]), loads(row[7])
|
|
73
|
+
snapshot = {'seller': row[3], 'listing_id': row[1],
|
|
74
|
+
'listing_revision': row[2], 'revision': row[4], 'kind': row[5],
|
|
75
|
+
'manifest': manifest, 'payload_refs': refs,
|
|
76
|
+
'total_size': row[9], 'delivery_mode': row[10]}
|
|
77
|
+
require(digest(snapshot) == order['package_digest'] and
|
|
78
|
+
row[9] <= MAX_INLINE_BYTES and row[9] >= 0,
|
|
79
|
+
'delivery_package_mismatch')
|
|
80
|
+
require(refs and (row[5] == 'bundle' or len(refs) == 1),
|
|
81
|
+
'delivery_package_mismatch')
|
|
82
|
+
total = 0
|
|
83
|
+
for ref in refs:
|
|
84
|
+
require(isinstance(ref, dict) and isinstance(ref.get('blob'), dict),
|
|
85
|
+
'delivery_package_mismatch')
|
|
86
|
+
blob = decode(BlobRef, ref['blob'])
|
|
87
|
+
require(blob.size >= 0 and blob.size <= MAX_INLINE_BYTES,
|
|
88
|
+
'delivery_package_mismatch')
|
|
89
|
+
data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES)
|
|
90
|
+
require(len(data) == blob.size and digest(data) == blob.digest,
|
|
91
|
+
'delivery_package_mismatch')
|
|
92
|
+
total += blob.size
|
|
93
|
+
require(total == row[9], 'delivery_package_mismatch')
|
|
94
|
+
return row[5], manifest, refs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _payload(app, delivery):
|
|
98
|
+
files = []
|
|
99
|
+
for ref in delivery['payload_refs']:
|
|
100
|
+
blob = decode(BlobRef, ref['blob'])
|
|
101
|
+
data = await app.contents.read_bytes(blob, limit=MAX_INLINE_BYTES)
|
|
102
|
+
require(len(data) == blob.size and digest(data) == blob.digest,
|
|
103
|
+
'delivery_package_mismatch')
|
|
104
|
+
files.append({'id': ref['id'], 'revision': ref['revision'],
|
|
105
|
+
'media_type': blob.media_type, 'digest': blob.digest,
|
|
106
|
+
'size': blob.size, 'data': b64(data)})
|
|
107
|
+
return files
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def prepare_managed(app, ctx, tx, order):
|
|
111
|
+
"""Prepare inside the caller's transaction; never authenticate or commit here."""
|
|
112
|
+
buyer = order['buyer']
|
|
113
|
+
require(order['state'] == 'funded' and order['delivered_at'] is None,
|
|
114
|
+
'order_not_deliverable')
|
|
115
|
+
validate_target(order)
|
|
116
|
+
require(order['currency_id'] == CURRENCY_ID and
|
|
117
|
+
_balance(tx, order['escrow_subject']) == order['total_price_minor'],
|
|
118
|
+
'escrow_balance_mismatch')
|
|
119
|
+
kind, manifest, refs = await _package(app, tx, order)
|
|
120
|
+
delivery_id = new_id('dlv')
|
|
121
|
+
body = {'delivery_id': delivery_id, 'order_id': order['id'],
|
|
122
|
+
'recipient_subject': buyer, 'kind': kind,
|
|
123
|
+
'manifest': manifest, 'payload_refs': refs,
|
|
124
|
+
'package_digest': order['package_digest'], 'channel': 'site'}
|
|
125
|
+
delivery_digest = digest(body)
|
|
126
|
+
tx.execute('''INSERT INTO store_deliveries
|
|
127
|
+
(id,order_id,recipient_subject,kind,payload_refs,manifest,
|
|
128
|
+
package_digest,delivery_digest,channel,state,prepared_at,claimed_at,receipt)
|
|
129
|
+
VALUES (?,?,?,?,?,?,?,?,?,'prepared',?,NULL,NULL)''',
|
|
130
|
+
(delivery_id, order['id'], buyer, kind, canonical(refs).decode(),
|
|
131
|
+
canonical(manifest).decode(), order['package_digest'],
|
|
132
|
+
delivery_digest, 'site', wire(ctx.now)), write=True)
|
|
133
|
+
changed = tx.execute('''UPDATE store_orders SET state='delivered',delivered_at=?
|
|
134
|
+
WHERE id=? AND buyer=? AND state='funded' AND delivered_at IS NULL''',
|
|
135
|
+
(wire(ctx.now), order['id'], buyer), write=True)
|
|
136
|
+
require(changed.rowcount == 1, 'order_not_deliverable')
|
|
137
|
+
return _delivery(tx, order['id'])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def delivery_summary(delivery):
|
|
141
|
+
return {'delivery_id': delivery['id'], 'order_id': delivery['order_id'],
|
|
142
|
+
'state': delivery['state'], 'channel': delivery['channel'],
|
|
143
|
+
'package_digest': delivery['package_digest'],
|
|
144
|
+
'delivery_digest': delivery['delivery_digest']}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def install(app):
|
|
148
|
+
op, finish = registration(app, 'delivery', ('orders',))
|
|
149
|
+
# Account authority belongs to the operation contract, not just the handler:
|
|
150
|
+
# the executor must apply it before returning an idempotent cached result.
|
|
151
|
+
op = partial(op, requirements=account_requirements)
|
|
152
|
+
|
|
153
|
+
@op('delivery.prepare', obj({'order_id': IDENTIFIER}, ('order_id',)),
|
|
154
|
+
signature=True)
|
|
155
|
+
async def prepare(ctx, request, tx):
|
|
156
|
+
buyer = _subject(ctx)
|
|
157
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
158
|
+
delivery = await prepare_managed(app, ctx, tx, order)
|
|
159
|
+
return HandlerOutput(data={'delivery': delivery_summary(delivery)})
|
|
160
|
+
|
|
161
|
+
@op('delivery.get', obj({'order_id': IDENTIFIER}, ('order_id',)), effect='read')
|
|
162
|
+
async def get(ctx, request, tx):
|
|
163
|
+
buyer = ctx.principal.subject
|
|
164
|
+
require(buyer is not None and ctx.principal.actor == buyer,
|
|
165
|
+
'order_not_found')
|
|
166
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
167
|
+
if tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (order['id'],)):
|
|
168
|
+
from msg.market.delivery import read
|
|
169
|
+
return await read(app, tx, ctx, request)
|
|
170
|
+
delivery = _delivery(tx, order['id'])
|
|
171
|
+
require(delivery is not None, 'delivery_not_found')
|
|
172
|
+
await _verified_delivery(app, tx, order, delivery)
|
|
173
|
+
from msg.market.delivery_notifications import notification_status
|
|
174
|
+
return HandlerOutput(data={'delivery': {
|
|
175
|
+
'delivery_id': delivery['id'], 'order_id': order['id'],
|
|
176
|
+
'recipient_subject': buyer, 'kind': delivery['kind'],
|
|
177
|
+
'manifest': delivery['manifest'], 'package_digest': delivery['package_digest'],
|
|
178
|
+
'delivery_digest': delivery['delivery_digest'], 'channel': 'site',
|
|
179
|
+
'state': delivery['state'], 'prepared_at': delivery['prepared_at'],
|
|
180
|
+
'claimed_at': delivery['claimed_at'],
|
|
181
|
+
'payloads': await _payload(app, delivery),
|
|
182
|
+
'notification': await notification_status(app, tx, order['id'])}})
|
|
183
|
+
|
|
184
|
+
@op('delivery.accept', obj({'order_id': IDENTIFIER,
|
|
185
|
+
'delivery_digest': {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}},
|
|
186
|
+
('order_id', 'delivery_digest')), signature=True)
|
|
187
|
+
async def accept(ctx, request, tx):
|
|
188
|
+
if tx.one('SELECT 1 FROM order_contracts WHERE order_id=?', (request.arguments['order_id'],)):
|
|
189
|
+
from msg.market.delivery import accept as market_accept
|
|
190
|
+
return await market_accept(app, tx, ctx, request)
|
|
191
|
+
receipt,decision,delivery = await EscrowEngine(app).settle(
|
|
192
|
+
ctx,request,tx,reason='buyer_accept')
|
|
193
|
+
return HandlerOutput(data={'order_id':request.arguments['order_id'],'state':'settled',
|
|
194
|
+
'delivery_id':delivery['id'],'receipt':receipt,'decision':decision})
|
|
195
|
+
|
|
196
|
+
@op('delivery.claim', obj({'order_id': IDENTIFIER,
|
|
197
|
+
'delivery_digest': {'type': 'string', 'pattern': '^sha256:[a-f0-9]{64}$'}},
|
|
198
|
+
('order_id', 'delivery_digest')), signature=True)
|
|
199
|
+
async def claim(ctx, request, tx):
|
|
200
|
+
# Checkout settlement is not evidence of receipt by the buyer. This
|
|
201
|
+
# later signed ACK only changes the delivery; it cannot debit escrow.
|
|
202
|
+
buyer = _subject(ctx)
|
|
203
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
204
|
+
delivery = _delivery(tx, order['id'])
|
|
205
|
+
await _verified_delivery(app, tx, order, delivery)
|
|
206
|
+
require(delivery['delivery_digest'] == request.arguments['delivery_digest'],
|
|
207
|
+
'delivery_mismatch')
|
|
208
|
+
await EscrowEngine(app).verify_checkout_release(tx, order, delivery)
|
|
209
|
+
require(delivery['state'] == 'prepared', 'delivery_not_acceptable')
|
|
210
|
+
changed = tx.execute("""UPDATE store_deliveries SET state='claimed',claimed_at=?
|
|
211
|
+
WHERE order_id=? AND recipient_subject=? AND state='prepared'""",
|
|
212
|
+
(wire(ctx.now), order['id'], buyer), write=True)
|
|
213
|
+
require(changed.rowcount == 1, 'delivery_not_acceptable')
|
|
214
|
+
return HandlerOutput(data={'order_id': order['id'],
|
|
215
|
+
'delivery_id': delivery['id'], 'state': 'claimed'})
|
|
216
|
+
|
|
217
|
+
@op('delivery.notify', obj({'order_id': IDENTIFIER,
|
|
218
|
+
'enabled': {'type': 'boolean'}}, ('order_id',)), signature=True)
|
|
219
|
+
async def notify(ctx, request, tx):
|
|
220
|
+
from msg.market.delivery_notifications import queue_notification
|
|
221
|
+
buyer = _subject(ctx)
|
|
222
|
+
order = _buyer_order(tx, request.arguments['order_id'], buyer)
|
|
223
|
+
await _verified_delivery(app, tx, order, _delivery(tx, order['id']))
|
|
224
|
+
status = await queue_notification(app, ctx, request, tx, order,
|
|
225
|
+
enabled=request.arguments.get('enabled', True))
|
|
226
|
+
return HandlerOutput(data={'order_id': order['id'], 'notification': status})
|
|
227
|
+
|
|
228
|
+
from msg.market.delivery import install as install_market
|
|
229
|
+
install_market(app, op)
|
|
230
|
+
finish()
|