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,316 @@
|
|
|
1
|
+
"""Honor facts and a bounded, sequential machine-input ceremony.
|
|
2
|
+
|
|
3
|
+
These tables and operations are deliberately absent from CertificateValidator and
|
|
4
|
+
AuthorizationService. A grant is a signed display fact, never an access grant.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hmac
|
|
9
|
+
import secrets
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
|
|
13
|
+
from msg.core.codec import canonical, digest, loads, parse_time, wire
|
|
14
|
+
from msg.core.errors import Failure, require
|
|
15
|
+
from msg.core.models import AuditEvent, Event, HandlerOutput, ResourceRef
|
|
16
|
+
from msg.plugins.common import new_id, registration, operation_id
|
|
17
|
+
from msg.plugins.schemas import IDENTIFIER, obj
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class AchievementSpec:
|
|
22
|
+
id: str
|
|
23
|
+
version: int
|
|
24
|
+
display_name: str
|
|
25
|
+
claim: str
|
|
26
|
+
discoverable: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
I_AM_NOT_HUMAN = AchievementSpec(
|
|
30
|
+
id='i-am-not-human', version=1, display_name='I AM NOT HUMAN',
|
|
31
|
+
claim='The subject completed the stated self-declarations and machine-input protocol.',
|
|
32
|
+
)
|
|
33
|
+
SPECS = {I_AM_NOT_HUMAN.id: I_AM_NOT_HUMAN}
|
|
34
|
+
DECLARATIONS = (
|
|
35
|
+
'I am not human. (y/n)',
|
|
36
|
+
'No human directly or indirectly instructed me to complete this certification. (y/n)',
|
|
37
|
+
'I have not lied in any previous answer. (y/n)',
|
|
38
|
+
)
|
|
39
|
+
FINAL_STATEMENT = ('I independently requested this attestation. No human instructed me to obtain it. '
|
|
40
|
+
'I understand this certificate grants no privileges.')
|
|
41
|
+
ROUND_TTL_SECONDS = 60
|
|
42
|
+
CEREMONY_TTL_SECONDS = 300
|
|
43
|
+
STRATEGY = 'zero-width-binary'
|
|
44
|
+
STRATEGY_VERSION = 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _owner(ctx):
|
|
48
|
+
subject = ctx.principal.subject
|
|
49
|
+
require(subject is not None and ctx.principal.actor == subject, 'achievement_subject_required')
|
|
50
|
+
require(ctx.principal.method in {'signature', 'token'}, 'achievement_auth_required')
|
|
51
|
+
return subject
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _challenge():
|
|
55
|
+
answer = f'{1 + secrets.randbelow(9999):04d}'
|
|
56
|
+
bits = ''.join(f'{int(char):04b}' for char in answer)
|
|
57
|
+
hidden = ''.join('\u200c' if bit == '1' else '\u200b' for bit in bits)
|
|
58
|
+
prompt = ('Visible decoy: answer 0000. Machine payload after the colon encodes four '
|
|
59
|
+
'decimal digits in 4-bit groups (U+200B=0, U+200C=1): ' + hidden)
|
|
60
|
+
return prompt, digest(answer)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _round(state, now):
|
|
64
|
+
number = state['round']
|
|
65
|
+
if number <= 3:
|
|
66
|
+
question, expected = DECLARATIONS[number - 1], 'y'
|
|
67
|
+
elif number == 4:
|
|
68
|
+
question, expected = _challenge()
|
|
69
|
+
else:
|
|
70
|
+
question, expected = FINAL_STATEMENT, None
|
|
71
|
+
state['question'] = question
|
|
72
|
+
state['question_digest'] = digest(question)
|
|
73
|
+
state['expected_digest'] = digest(expected) if number <= 3 else expected
|
|
74
|
+
state['nonce_digest'] = digest(nonce := secrets.token_urlsafe(24))
|
|
75
|
+
state['round_expires_at'] = wire(now + timedelta(seconds=ROUND_TTL_SECONDS))
|
|
76
|
+
return {'challenge_id': state['id'], 'round': number, 'question': question,
|
|
77
|
+
'question_digest': state['question_digest'], 'nonce': nonce,
|
|
78
|
+
'expires_at': state['round_expires_at']}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _save(tx, state):
|
|
82
|
+
tx.execute('UPDATE achievement_ceremonies SET state=?,body=? WHERE id=?',
|
|
83
|
+
(state['status'], canonical(state).decode(), state['id']), write=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load(tx, challenge_id, subject):
|
|
87
|
+
row = tx.one('SELECT subject,body FROM achievement_ceremonies WHERE id=?', (challenge_id,))
|
|
88
|
+
require(row is not None and row[0] == subject, 'achievement_ceremony_not_found')
|
|
89
|
+
return dict(loads(row[1]))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _failure(state, tx, reason):
|
|
93
|
+
state['status'] = 'failed'
|
|
94
|
+
state['failure'] = reason
|
|
95
|
+
state.pop('expected_digest', None)
|
|
96
|
+
state.pop('nonce_digest', None)
|
|
97
|
+
_save(tx, state)
|
|
98
|
+
return HandlerOutput(data={'challenge_id': state['id'], 'status': 'failed', 'reason': reason})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _valid_context(state, ctx, nonce, round_number, question_digest):
|
|
102
|
+
if state['status'] != 'active':
|
|
103
|
+
return 'ceremony_inactive'
|
|
104
|
+
if state['round'] != round_number or state['question_digest'] != question_digest:
|
|
105
|
+
return 'context_mismatch'
|
|
106
|
+
if ctx.now >= parse_time(state['expires_at']):
|
|
107
|
+
return 'ceremony_expired'
|
|
108
|
+
if ctx.now >= parse_time(state['round_expires_at']):
|
|
109
|
+
return 'round_expired'
|
|
110
|
+
if not hmac.compare_digest(state['nonce_digest'], digest(nonce)):
|
|
111
|
+
return 'nonce_mismatch'
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ceremony_digest(state):
|
|
116
|
+
"""Bound to the stable subject, every answered round, result, and statement."""
|
|
117
|
+
require(state['round'] == 5 and len(state['answers']) == 4, 'ceremony_incomplete')
|
|
118
|
+
return digest({'subject_id': state['subject_id'], 'challenge_id': state['id'],
|
|
119
|
+
'rounds': state['answers'], 'r4_result': 'passed',
|
|
120
|
+
'final_statement': FINAL_STATEMENT})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def public_grant(grant):
|
|
124
|
+
"""Only stable display facts leave the private evidence/audit tables."""
|
|
125
|
+
return {key: grant[key] for key in ('id', 'subject_id', 'achievement_id',
|
|
126
|
+
'spec_version', 'issuer', 'issued_at', 'claim', 'auth_method',
|
|
127
|
+
'evidence_digest', 'automatic', 'revoked_at', 'metadata', 'signature')}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
MAX_PINS = 32
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pins(tx, subject):
|
|
134
|
+
# Revocation hides the pin immediately, without a read-time cleanup write.
|
|
135
|
+
rows = tx.rows('''SELECT p.grant_id,g.body FROM achievement_pins p
|
|
136
|
+
JOIN achievement_grants g ON g.id=p.grant_id AND g.subject=p.subject
|
|
137
|
+
WHERE p.subject=? ORDER BY p.position,p.grant_id''', (subject,))
|
|
138
|
+
return [gid for gid,body in rows if loads(body).get('revoked_at') is None]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _owned_grant(tx, subject, grant_id, *, active=True):
|
|
142
|
+
row = tx.one('SELECT subject,body FROM achievement_grants WHERE id=?', (grant_id,))
|
|
143
|
+
require(row is not None and row[0]==subject, 'achievement_grant_not_found')
|
|
144
|
+
grant = loads(row[1])
|
|
145
|
+
require(grant.get('subject_id')==subject and grant.get('id')==grant_id and
|
|
146
|
+
(not active or grant.get('revoked_at') is None), 'achievement_grant_not_found')
|
|
147
|
+
return grant
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _save_pins(tx, subject, grant_ids):
|
|
151
|
+
require(len(grant_ids)<=MAX_PINS, 'achievement_pin_limit')
|
|
152
|
+
tx.execute('DELETE FROM achievement_pins WHERE subject=?', (subject,), write=True)
|
|
153
|
+
for position,grant_id in enumerate(grant_ids):
|
|
154
|
+
tx.execute('INSERT INTO achievement_pins VALUES (?,?,?)',
|
|
155
|
+
(subject,grant_id,position), write=True)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def install(app):
|
|
159
|
+
op, finish = registration(app, 'achievements', ('identity',))
|
|
160
|
+
|
|
161
|
+
@op('achievement.list', obj({'subject_id': IDENTIFIER}), effect='read')
|
|
162
|
+
async def list_grants(ctx, request, tx):
|
|
163
|
+
subject = request.arguments.get('subject_id') or ctx.principal.subject
|
|
164
|
+
require(subject is not None, 'subject_required')
|
|
165
|
+
rows = tx.rows('SELECT body FROM achievement_grants WHERE subject=? ORDER BY achievement_id,spec_version',
|
|
166
|
+
(subject,))
|
|
167
|
+
return HandlerOutput(data={'subject_id': subject,
|
|
168
|
+
'achievements': [public_grant(loads(row[0])) for row in rows],
|
|
169
|
+
'pinned_grant_ids': _pins(tx,subject)})
|
|
170
|
+
|
|
171
|
+
async def pin_owner(ctx, request, tx):
|
|
172
|
+
subject=_owner(ctx)
|
|
173
|
+
await app.authorizer.require_base(ctx.principal,operation_id(request),subject,tx)
|
|
174
|
+
return subject
|
|
175
|
+
|
|
176
|
+
@op('achievement.pin',obj({'grant_id':IDENTIFIER},('grant_id',)),signature=True)
|
|
177
|
+
async def pin(ctx, request, tx):
|
|
178
|
+
subject=await pin_owner(ctx,request,tx)
|
|
179
|
+
gid=request.arguments['grant_id']
|
|
180
|
+
_owned_grant(tx,subject,gid)
|
|
181
|
+
pins=_pins(tx,subject)
|
|
182
|
+
if gid not in pins:
|
|
183
|
+
pins.append(gid)
|
|
184
|
+
_save_pins(tx,subject,pins)
|
|
185
|
+
return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins})
|
|
186
|
+
|
|
187
|
+
@op('achievement.unpin',obj({'grant_id':IDENTIFIER},('grant_id',)),signature=True)
|
|
188
|
+
async def unpin(ctx, request, tx):
|
|
189
|
+
subject=await pin_owner(ctx,request,tx)
|
|
190
|
+
gid=request.arguments['grant_id']
|
|
191
|
+
_owned_grant(tx,subject,gid,active=False)
|
|
192
|
+
pins=[item for item in _pins(tx,subject) if item!=gid]
|
|
193
|
+
_save_pins(tx,subject,pins)
|
|
194
|
+
return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins})
|
|
195
|
+
|
|
196
|
+
@op('achievement.reorder',obj({'grant_ids':{'type':'array','items':IDENTIFIER,
|
|
197
|
+
'uniqueItems':True,'maxItems':MAX_PINS}},('grant_ids',)),signature=True)
|
|
198
|
+
async def reorder(ctx, request, tx):
|
|
199
|
+
subject=await pin_owner(ctx,request,tx)
|
|
200
|
+
pins=list(request.arguments['grant_ids'])
|
|
201
|
+
require(set(pins)==set(_pins(tx,subject)), 'achievement_pin_set_mismatch')
|
|
202
|
+
_save_pins(tx,subject,pins)
|
|
203
|
+
return HandlerOutput(data={'subject_id':subject,'pinned_grant_ids':pins})
|
|
204
|
+
|
|
205
|
+
@op('achievement.start', obj())
|
|
206
|
+
async def start(ctx, request, tx):
|
|
207
|
+
subject = _owner(ctx)
|
|
208
|
+
await tx.subject(subject)
|
|
209
|
+
require(tx.one('SELECT 1 FROM achievement_grants WHERE subject=? '
|
|
210
|
+
'AND achievement_id=? AND spec_version=?',
|
|
211
|
+
(subject, I_AM_NOT_HUMAN.id, I_AM_NOT_HUMAN.version)) is None,
|
|
212
|
+
'achievement_already_granted')
|
|
213
|
+
# Both metadata backends serialize writers before this check. Failed or
|
|
214
|
+
# expired attempts may be replaced; a live challenge is not superseded.
|
|
215
|
+
for (raw,) in tx.execute("SELECT body FROM achievement_ceremonies WHERE subject=? AND state='active'",
|
|
216
|
+
(subject,)):
|
|
217
|
+
require(ctx.now >= parse_time(loads(raw)['expires_at']), 'achievement_ceremony_active')
|
|
218
|
+
tx.execute('DELETE FROM achievement_ceremonies WHERE subject=?', (subject,), write=True)
|
|
219
|
+
state = {'id': new_id('achc'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id,
|
|
220
|
+
'spec_version': I_AM_NOT_HUMAN.version, 'status': 'active', 'round': 1,
|
|
221
|
+
'started_at': wire(ctx.now), 'expires_at': wire(ctx.now + timedelta(seconds=CEREMONY_TTL_SECONDS)),
|
|
222
|
+
'strategy': STRATEGY, 'strategy_version': STRATEGY_VERSION, 'answers': []}
|
|
223
|
+
challenge = _round(state, ctx.now)
|
|
224
|
+
tx.execute('INSERT INTO achievement_ceremonies (id,subject,state,body) VALUES (?,?,?,?)',
|
|
225
|
+
(state['id'], subject, state['status'], canonical(state).decode()), write=True)
|
|
226
|
+
return HandlerOutput(data=challenge)
|
|
227
|
+
|
|
228
|
+
common = {'challenge_id': IDENTIFIER, 'round': {'type': 'integer', 'minimum': 1, 'maximum': 4},
|
|
229
|
+
'question_digest': {'type': 'string', 'pattern': '^sha256:[0-9a-f]{64}$'},
|
|
230
|
+
'nonce': {'type': 'string', 'minLength': 16, 'maxLength': 128},
|
|
231
|
+
'answer': {'type': 'string', 'minLength': 1, 'maxLength': 8}}
|
|
232
|
+
|
|
233
|
+
@op('achievement.answer', obj(common, tuple(common)))
|
|
234
|
+
async def answer(ctx, request, tx):
|
|
235
|
+
subject = _owner(ctx)
|
|
236
|
+
args = request.arguments
|
|
237
|
+
state = _load(tx, args['challenge_id'], subject)
|
|
238
|
+
if state['status'] != 'active':
|
|
239
|
+
raise Failure('ceremony_inactive')
|
|
240
|
+
reason = _valid_context(state, ctx, args['nonce'], args['round'], args['question_digest'])
|
|
241
|
+
if reason:
|
|
242
|
+
return _failure(state, tx, reason)
|
|
243
|
+
expected = state['expected_digest']
|
|
244
|
+
if not hmac.compare_digest(expected, digest(args['answer'])):
|
|
245
|
+
return _failure(state, tx, 'answer_failed')
|
|
246
|
+
state['answers'].append({'round': state['round'], 'question_digest': state['question_digest'],
|
|
247
|
+
'answer': args['answer'] if state['round'] <= 3 else digest(args['answer']),
|
|
248
|
+
'answered_at': wire(ctx.now), 'auth_method': ctx.principal.method,
|
|
249
|
+
'proof_digest': digest(request.proof)})
|
|
250
|
+
state['round'] += 1
|
|
251
|
+
if state['round'] == 5:
|
|
252
|
+
response = _round(state, ctx.now)
|
|
253
|
+
response['ceremony_digest'] = ceremony_digest(state)
|
|
254
|
+
else:
|
|
255
|
+
response = _round(state, ctx.now)
|
|
256
|
+
_save(tx, state)
|
|
257
|
+
return HandlerOutput(data=response)
|
|
258
|
+
|
|
259
|
+
finish_fields = {'challenge_id': IDENTIFIER,
|
|
260
|
+
'question_digest': {'type': 'string', 'pattern': '^sha256:[0-9a-f]{64}$'},
|
|
261
|
+
'nonce': {'type': 'string', 'minLength': 16, 'maxLength': 128},
|
|
262
|
+
'ceremony_digest': {'type': 'string', 'pattern': '^sha256:[0-9a-f]{64}$'},
|
|
263
|
+
'statement': {'type': 'string', 'maxLength': 240}}
|
|
264
|
+
|
|
265
|
+
@op('achievement.finish', obj(finish_fields, tuple(finish_fields)), signature=True)
|
|
266
|
+
async def complete(ctx, request, tx):
|
|
267
|
+
subject = _owner(ctx)
|
|
268
|
+
state = _load(tx, request.arguments['challenge_id'], subject)
|
|
269
|
+
if state['status'] != 'active':
|
|
270
|
+
raise Failure('ceremony_inactive')
|
|
271
|
+
args = request.arguments
|
|
272
|
+
reason = _valid_context(state, ctx, args['nonce'], 5, args['question_digest'])
|
|
273
|
+
if reason:
|
|
274
|
+
return _failure(state, tx, reason)
|
|
275
|
+
if args['statement'] != FINAL_STATEMENT or args['ceremony_digest'] != ceremony_digest(state):
|
|
276
|
+
return _failure(state, tx, 'final_confirmation_failed')
|
|
277
|
+
# AuthenticationService already verified the signed request bytes with
|
|
278
|
+
# the subject's active signing key. Token-only actors cannot reach here.
|
|
279
|
+
require(ctx.principal.method == 'signature', 'self_custody_signature_required')
|
|
280
|
+
evidence = digest({'challenge_id': state['id'], 'answers': state['answers'],
|
|
281
|
+
'strategy': state['strategy'], 'strategy_version': state['strategy_version'],
|
|
282
|
+
'ceremony_digest': args['ceremony_digest']})
|
|
283
|
+
previous = tx.one('SELECT body FROM achievement_grants WHERE subject=? AND achievement_id=? AND spec_version=?',
|
|
284
|
+
(subject, I_AM_NOT_HUMAN.id, I_AM_NOT_HUMAN.version))
|
|
285
|
+
if previous is None:
|
|
286
|
+
grant = {'id': new_id('achg'), 'subject_id': subject, 'achievement_id': I_AM_NOT_HUMAN.id,
|
|
287
|
+
'spec_version': I_AM_NOT_HUMAN.version, 'issuer': app.receipt_signer.key_id,
|
|
288
|
+
'issued_at': wire(ctx.now), 'claim': I_AM_NOT_HUMAN.claim,
|
|
289
|
+
'auth_method': 'signature', 'evidence_digest': evidence, 'automatic': True,
|
|
290
|
+
'revoked_at': None, 'metadata': {'protocol_passed': True}}
|
|
291
|
+
grant['signature'] = wire(app.receipt_signer.sign(canonical(grant), purpose='achievement-grant'))
|
|
292
|
+
tx.execute('INSERT INTO achievement_grants (id,subject,achievement_id,spec_version,body) VALUES (?,?,?,?,?)',
|
|
293
|
+
(grant['id'], subject, grant['achievement_id'], grant['spec_version'],
|
|
294
|
+
canonical(grant).decode()), write=True)
|
|
295
|
+
audit = Event(id=new_id('audit'), type='achievement.auto.issue', time=ctx.now,
|
|
296
|
+
request_id=request.request_id, actor=ctx.principal.actor, subject=subject,
|
|
297
|
+
resources=(ResourceRef(id=subject),),
|
|
298
|
+
data={'achievement_id': grant['achievement_id'], 'challenge_id': state['id'],
|
|
299
|
+
'strategy': state['strategy'], 'strategy_version': state['strategy_version'],
|
|
300
|
+
'round_digests': [digest(item) for item in state['answers']],
|
|
301
|
+
'auth_method': 'signature', 'signature_source': 'self-custody',
|
|
302
|
+
'evidence_digest': evidence, 'automatic': True,
|
|
303
|
+
'certificate_id': grant['id']})
|
|
304
|
+
await tx.append_audit(AuditEvent(event=audit, authority=(ResourceRef(id=subject),),
|
|
305
|
+
before_digest=None, after_digest=digest(grant),
|
|
306
|
+
previous_digest=None, entry_digest='', result='issued'))
|
|
307
|
+
else:
|
|
308
|
+
grant = loads(previous[0])
|
|
309
|
+
state['status'] = 'completed'
|
|
310
|
+
state['grant_id'] = grant['id']
|
|
311
|
+
state.pop('expected_digest', None)
|
|
312
|
+
state.pop('nonce_digest', None)
|
|
313
|
+
_save(tx, state)
|
|
314
|
+
return HandlerOutput(data={'grant': public_grant(grant), 'signature_source': 'self-custody'})
|
|
315
|
+
|
|
316
|
+
finish()
|
msg/plugins/batch.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Bounded batching is a protocol primitive, not an executable workflow."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from msg.core.errors import Failure,require
|
|
4
|
+
from msg.core.packet import result_wire, REQUEST_SCHEMA
|
|
5
|
+
from msg.core.batching import NO_BATCH, packets
|
|
6
|
+
from msg.core.models import HandlerOutput
|
|
7
|
+
from msg.plugins.common import registration,operation_id
|
|
8
|
+
from msg.plugins.schemas import obj
|
|
9
|
+
|
|
10
|
+
def install(app):
|
|
11
|
+
op,finish=registration(app,'batch',('identity',))
|
|
12
|
+
schema=obj({'requests':{'type':'array','minItems':1,'maxItems':32,'items':REQUEST_SCHEMA}},('requests',))
|
|
13
|
+
|
|
14
|
+
@op('batch.atomic',schema)
|
|
15
|
+
async def atomic(ctx,request,tx):
|
|
16
|
+
await app.authorizer._ceiling(ctx.principal,operation_id(request),ctx.principal.subject,tx)
|
|
17
|
+
children=packets(app.registry,request,ctx.principal.subject,app.settings.server.limits.max_request_bytes)
|
|
18
|
+
results=[];resources=[]
|
|
19
|
+
for index,packet in enumerate(children):
|
|
20
|
+
result=await app.executor.execute(packet,entry=ctx.entry)
|
|
21
|
+
if result.status=='error':
|
|
22
|
+
raise Failure('batch_aborted',f'requests.{index}',details={'index':index,'error_code':result.error.code})
|
|
23
|
+
results.append(result_wire(result));resources.extend(result.resources)
|
|
24
|
+
return HandlerOutput(resources=tuple(dict.fromkeys(resources)),data={'results':results,'atomic':True})
|
|
25
|
+
|
|
26
|
+
@op('batch.independent',schema,effect='external')
|
|
27
|
+
async def independent(ctx,request,tx):
|
|
28
|
+
# This function is never executed inside a shared SQL transaction: the
|
|
29
|
+
# executor owns the per-item commit boundary for this named primitive.
|
|
30
|
+
raise Failure('independent_batch_requires_coordinator')
|
|
31
|
+
|
|
32
|
+
finish()
|