msgctl 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,98 @@
1
+ """User CA keys stay on the client; the service verifies published certificates."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ from datetime import timedelta
6
+ from pathlib import Path
7
+ import sys
8
+ from uuid import uuid4
9
+
10
+ from msg.core.codec import b64,canonical,decode,digest,wire,loads
11
+ from msg.core.errors import require
12
+ from msg.core.models import CertificateRequest,Certificate,Signature,CapabilityGrant,IssuancePolicy
13
+ from msg.security.certificates import csr_body,verify_csr,sign_certificate
14
+ from msg.security.crypto import Ed25519Signer,key_id
15
+ from msg.storage.git import durable_write
16
+
17
+
18
+ def local_key(path, *, create=False):
19
+ path=Path(path)
20
+ if not path.exists() and create:
21
+ path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
22
+ key=Ed25519Signer.generate()
23
+ durable_write(path,key.private_bytes(),mode=0o600)
24
+ return key
25
+ require(path.is_file() and not path.is_symlink() and path.stat().st_mode&0o077==0,'unsafe_ca_key_permissions')
26
+ return Ed25519Signer.from_bytes(path.read_bytes())
27
+
28
+
29
+ async def request_certificate(client,spec,key_path=None):
30
+ require(client.state.subject and client.state.signer is not None,'registered_identity_required')
31
+ require(set(spec)<={'requested_issuer','kind','grants','issuance','requested_ttl_seconds','delegation_depth'},'unknown_csr_field')
32
+ issuer=spec.get('requested_issuer','u_root')
33
+ if issuer.startswith('/'):
34
+ result=client.checked(await client.call('discovery.get',{'id':issuer,'fields':['id']},anonymous=True))
35
+ issuer=result.data['id']
36
+ signer=local_key(key_path or client.state.directory/'ca.key',create=True) if spec.get('kind')=='ca' or key_path else client.state.signer
37
+ csr=CertificateRequest(resource_id='client-pending',applicant=client.state.subject,subject_id=client.state.subject,
38
+ requested_issuer=issuer,public_key=signer.public_key,kind=spec.get('kind','capability'),
39
+ grants=tuple(decode(CapabilityGrant,g) for g in spec.get('grants',())),
40
+ issuance=decode(IssuancePolicy,spec['issuance']) if spec.get('issuance') else None,
41
+ requested_ttl_seconds=spec.get('requested_ttl_seconds',86400),target_service=client.state.server,
42
+ delegation_depth=spec.get('delegation_depth',0),authority_sources=(),request_digest='',
43
+ possession_proof=Signature(key_id=signer.key_id,algorithm='ed25519',value=b''))
44
+ proof=signer.sign(canonical(csr_body(csr)),purpose='csr')
45
+ args={k:v for k,v in wire(csr).items() if k in {'requested_issuer','public_key','kind','grants','issuance',
46
+ 'requested_ttl_seconds','delegation_depth'}}
47
+ args['possession_proof']=wire(proof)
48
+ journal=client.state.directory/('csr-'+digest(csr_body(csr))[7:39]+'.json')
49
+ pending=loads(journal.read_bytes()) if journal.exists() else {'request_id':uuid4().hex}
50
+ durable_write(journal,canonical(pending),mode=0o600)
51
+ result=await client.call('cert.request',args,request_id=pending['request_id'])
52
+ if result.status=='ok':
53
+ durable_write(journal,canonical({**pending,'csr_id':result.data['csr_id'],'request_digest':result.data['request_digest']}),mode=0o600)
54
+ return result
55
+
56
+
57
+ async def issue_certificate(client,csr_id,issuer_certificate_id,key_path, *, expected_digest=None):
58
+ signer=local_key(key_path)
59
+ issuer_result=client.checked(await client.call('cert.get',{'id':issuer_certificate_id},anonymous=True))
60
+ parent=decode(Certificate,issuer_result.data['certificate'])
61
+ require(parent.kind=='ca' and parent.issuance is not None and parent.key_id==signer.key_id,'not_matching_ca_key')
62
+ require(parent.subject_id==client.state.subject and parent.subject_id!='u_root','local_only')
63
+ certificates=(issuer_certificate_id,)
64
+ result=client.checked(await client.call('cert.get',{'id':csr_id},signer=signer,certificates=certificates))
65
+ csr=decode(CertificateRequest,result.data['request'])
66
+ verify_csr(csr)
67
+ require(csr.requested_issuer==parent.subject_id,'wrong_requested_issuer')
68
+ # The review binds the exact immutable request, not a mutable title or path.
69
+ print(canonical({'request':wire(csr),'issuer':issuer_certificate_id}).decode(),file=sys.stderr)
70
+ if expected_digest is None:
71
+ require(sys.stdin.isatty(),'approval_digest_required')
72
+ expected_digest=input('Type the exact CSR digest to approve: ').strip()
73
+ require(expected_digest==csr.request_digest,'approval_digest_mismatch')
74
+ if result.data['state']['status']=='issued':
75
+ return await client.call('cert.get',{'id':result.data['state']['certificate_id']},anonymous=True)
76
+ require(result.data['state']['status']=='pending','csr_not_pending')
77
+ journal=client.state.directory/('issued-'+digest((csr_id,issuer_certificate_id))[7:39]+'.json')
78
+ if journal.exists():
79
+ pending=loads(journal.read_bytes())
80
+ require(pending['csr_digest']==csr.request_digest,'approval_digest_mismatch')
81
+ cert=decode(Certificate,pending['certificate'])
82
+ else:
83
+ now=client.clock()
84
+ require(parent.not_before<=now<parent.expires_at,'issuer_expired')
85
+ require(csr.requested_ttl_seconds<=parent.issuance.max_cert_ttl_seconds,'certificate_ttl_escalation')
86
+ cert=Certificate(resource_id='cert_'+uuid4().hex,serial='serial_'+uuid4().hex,subject_id=csr.subject_id,
87
+ key_id=key_id(csr.public_key),issuer_id=parent.subject_id,parent_certificate_id=parent.resource_id,
88
+ authority_sources=csr.authority_sources,kind=csr.kind,grants=csr.grants,not_before=now,
89
+ expires_at=min(now+timedelta(seconds=csr.requested_ttl_seconds),parent.expires_at),
90
+ target_service=csr.target_service,delegation_depth=csr.delegation_depth,issuance=csr.issuance,
91
+ signature=Signature(key_id=signer.key_id,algorithm='ed25519',value=b''))
92
+ cert=sign_certificate(cert,signer)
93
+ pending={'csr_digest':csr.request_digest,'certificate':wire(cert),'request_id':uuid4().hex}
94
+ durable_write(journal,canonical(pending),mode=0o600)
95
+ # Server revalidates issuance scope, operations, revocation, expiry, authority
96
+ # sources and remaining depth in the committing SQLite transaction.
97
+ return await client.call('cert.publish',{'csr_id':csr_id,'certificate':wire(cert)},signer=signer,
98
+ certificates=certificates,request_id=pending['request_id'])
msg/client_content.py ADDED
@@ -0,0 +1,43 @@
1
+ """Client-side immutable Revision signing for structured text patches.
2
+
3
+ The returned arguments go through the ordinary signed OperationRequest client.
4
+ A Revision signature is separate from both that request and the server receipt.
5
+ The caller must fetch and verify the exact current/base bytes beforehand.
6
+ """
7
+ from uuid import uuid4
8
+
9
+ from msg.core.codec import canonical, digest, wire
10
+ from msg.core.errors import require
11
+ from msg.core.models import BlobRef, Revision
12
+ from msg.core.text_patch import apply_patch
13
+
14
+
15
+ def signed_patch_arguments(signer, resource, previous, source, patch, *, subject,
16
+ created_at, actor=None, change_note=None, revision_id=None,
17
+ base_revision=None, base_generation=None, base_source=None):
18
+ require(previous.resource_id==resource.id and previous.id==resource.revision,
19
+ 'revision_conflict')
20
+ require(digest(source.encode('utf-8'))==previous.content.digest and
21
+ len(source.encode('utf-8'))==previous.content.size,'content_digest_mismatch')
22
+ require(previous.content.media_type in {'text/plain','text/markdown'},'text_patch_required')
23
+ require(base_revision is None or base_source is not None,'base_content_required')
24
+ body=apply_patch(source,patch,base_source=base_source)
25
+ blob=BlobRef(digest=digest(body.encode('utf-8')),size=len(body.encode('utf-8')),
26
+ media_type=previous.content.media_type)
27
+ revision=Revision(format_version=1,id=revision_id or 'v_'+uuid4().hex,
28
+ resource_id=resource.id,parents=(previous.id,),content=blob,
29
+ relations=previous.relations,actor=actor or subject,subject=subject,
30
+ author=previous.author,created_at=created_at,manifest_digest='',
31
+ change_note=change_note,source_kind='user',source_version=1,source_digest=digest(patch))
32
+ manifest={key:value for key,value in wire(revision).items()
33
+ if key not in {'manifest_digest','signature'}}
34
+ signature=signer.sign(canonical(manifest),purpose='revision')
35
+ args={'id':resource.id,'base_revision':base_revision or previous.id,
36
+ 'base_generation':resource.generation if base_generation is None else base_generation,
37
+ 'patch':patch,'revision_id':revision.id,'content_created_at':wire(created_at),
38
+ 'content_signature':wire(signature)}
39
+ if change_note is not None:
40
+ args['change_note']=change_note
41
+ if base_revision is not None:
42
+ args['rebase']=True
43
+ return args
@@ -0,0 +1,206 @@
1
+ """Local, journaled custodial history recovery. Plaintext never leaves this client."""
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+ from uuid import uuid4
7
+
8
+ from msg.client_recovery import _age, _protected_file
9
+ from msg.client_secrets import write_private
10
+ from msg.core.codec import canonical, digest, loads, wire
11
+ from msg.core.errors import Failure, require
12
+ from msg.security.age_keys import encryption_key_id, public_from_recipient, recipient_from_identity
13
+ from msg.security.custodial_migration import ack_statement, stage_statement
14
+ from msg.security.vault import client_upgrade_proof
15
+ from msg.storage.git import durable_write
16
+
17
+
18
+ def journal_for(state):
19
+ for name in ('custodial-upgrade.json', 'custodial-history.json'):
20
+ path = state.directory / name
21
+ if path.exists() or path.is_symlink():
22
+ path = _protected_file(path)
23
+ require(path.stat().st_size <= 4 * 1024 * 1024, 'custodial_journal_too_large')
24
+ body = loads(path.read_bytes())
25
+ challenge = body.get('challenge')
26
+ require(challenge and challenge['subject_id'] == state.subject and
27
+ challenge['public_key'] == body['public_key'] and
28
+ body['encryption_recipient'] == state.encryption_recipient and
29
+ body.get('server', state.server) == state.server,
30
+ 'custodial_upgrade_journal_mismatch')
31
+ require(state.signer is not None and body['public_key'] == wire(state.signer.public_key),
32
+ 'custodial_upgrade_journal_mismatch')
33
+ return path, body
34
+ raise Failure('custodial_upgrade_journal_missing')
35
+
36
+
37
+ async def inventory(client):
38
+ _, journal = journal_for(client.state)
39
+ return client.checked(await client.call('identity.custodial_upgrade_inventory',
40
+ {'challenge_id': journal['challenge']['challenge_id']}))
41
+
42
+
43
+ async def transition(client, action, *, resolution='verified', loss_revisions=(), reason=None,
44
+ external_ciphertexts_migrated=False):
45
+ from msg.client_upgrade import locked_state
46
+ from msg.client_tokens import remove_journal
47
+ client._require_token_secret_transport()
48
+ with locked_state(client.state):
49
+ path, journal = journal_for(client.state)
50
+ challenge = journal['challenge']
51
+ pending = journal.get('pending_transition')
52
+ intent = {'action': action, 'resolution': resolution, 'loss_revisions': sorted(loss_revisions),
53
+ 'reason': reason, 'external_ciphertexts_migrated': external_ciphertexts_migrated}
54
+ if pending is None:
55
+ state = (await inventory(client)).data
56
+ identity = _protected_file(client.state.age_key_path).read_text().strip()
57
+ require(recipient_from_identity(identity) == state['recipient'], 'encryption_identity_mismatch')
58
+ args = {'challenge_id': challenge['challenge_id'], 'action': action,
59
+ 'age_proof': client_upgrade_proof(identity, challenge),
60
+ 'external_ciphertexts_migrated': external_ciphertexts_migrated,
61
+ 'inventory_digest': state['inventory_digest'], 'observed_digest': state['observed_digest'],
62
+ 'results_digest': state['results_digest']}
63
+ if action == 'finalize':
64
+ args.update(resolution=resolution, loss_revisions=sorted(loss_revisions),
65
+ reviewed_policy_version=state['recovery_policy_version'])
66
+ if reason is not None:
67
+ args['reason'] = reason
68
+ request_id = uuid4().hex
69
+ args['migration_ack'] = wire(client.state.signer.sign(canonical(
70
+ stage_statement(client.state.subject, args, request_id)), purpose='custodial-decision-v1'))
71
+ pending = {'intent': intent, 'arguments': args, 'request_id': request_id}
72
+ journal['pending_transition'] = pending
73
+ journal['server'] = client.state.server
74
+ durable_write(path, canonical(journal), mode=0o600)
75
+ else:
76
+ require(pending['intent'] == intent, 'custodial_transition_pending')
77
+ async def recovered():
78
+ result = await client.call('identity.custodial_upgrade_result',
79
+ {'challenge_id': challenge['challenge_id']}, signer=client.state.signer,
80
+ subject=client.state.subject, certificates=())
81
+ require(result.status == 'ok' and result.data.get('finalization_request_id') ==
82
+ pending['request_id'], 'custodial_transition_recovery_pending')
83
+ return result
84
+ try:
85
+ result = await client.call('identity.custodial_upgrade_finish', pending['arguments'],
86
+ request_id=pending['request_id'], contract_version=2)
87
+ except Failure as exc:
88
+ if exc.code != 'transport_uncertain' or action != 'finalize':
89
+ raise
90
+ result = await recovered()
91
+ if result.status == 'error' and action == 'finalize' and result.error.code in {
92
+ 'credential_revoked', 'credential_expired', 'custodial_token_required'}:
93
+ result = await recovered()
94
+ if result.status == 'ok':
95
+ journal.pop('pending_transition', None)
96
+ if action == 'finalize':
97
+ client.state.accept_identity(result)
98
+ journal['status'] = 'completed'
99
+ journal['completed_result'] = wire(result.data)
100
+ destination = client.state.directory / 'custodial-history.json'
101
+ durable_write(destination, canonical(journal), mode=0o600)
102
+ if path != destination:
103
+ remove_journal(path)
104
+ else:
105
+ durable_write(path, canonical(journal), mode=0o600)
106
+ elif result.error.code not in {'internal_error', 'transport_uncertain'}:
107
+ # A signed refusal proves this decision did not commit. A fresh
108
+ # observation may be taken; uncertain transport keeps the journal.
109
+ journal.pop('pending_transition', None)
110
+ durable_write(path, canonical(journal), mode=0o600)
111
+ return result
112
+
113
+
114
+ async def _ciphertext(client, ref, directory, name):
115
+ entry = client.checked(await client.call('keystore.get', {'id': ref['id'], 'revision': ref['revision']}))
116
+ require(entry.data['format'] == 'age' and entry.data['size'] <= 1114112,
117
+ 'age_keystore_entry_required')
118
+ path = directory / name
119
+ await client.download(entry.output, path)
120
+ value = path.read_bytes()
121
+ require(digest(value) == ref['ciphertext_digest'], 'custodial_rewrap_ciphertext_mismatch')
122
+ return value
123
+
124
+
125
+ async def acknowledge(client, old_revision, *, method='rewrap'):
126
+ from msg.client_upgrade import locked_state
127
+ with locked_state(client.state):
128
+ state = (await inventory(client)).data
129
+ require(not state['inventory_changed'], 'custodial_inventory_changed')
130
+ source = next((item for item in state['sources'] if item['revision'] == old_revision), None)
131
+ require(source is not None, 'custodial_rewrap_not_in_frozen_inventory')
132
+ identity_path = _protected_file(client.state.age_key_path)
133
+ require(recipient_from_identity(identity_path.read_text().strip()) == state['recipient'],
134
+ 'custodial_rewrap_recipient_mismatch')
135
+ with tempfile.TemporaryDirectory(prefix='custodial-history-', dir=client.state.directory) as temp:
136
+ directory = Path(temp)
137
+ if method == 'rewrap':
138
+ mapping = state['mappings'].get(old_revision)
139
+ require(mapping is not None and mapping['old'] == {k: v for k, v in source.items() if k != 'key_id'},
140
+ 'custodial_rewrap_mapping_missing')
141
+ target = dict(mapping['new'], key_id=state['new_key_id'], recipient=state['recipient'])
142
+ ciphertext = await _ciphertext(client, mapping['new'], directory, 'mapped.age')
143
+ plaintext = _age('--decrypt', '--identity', str(identity_path), input_data=ciphertext)
144
+ target_ref = mapping['new']
145
+ else:
146
+ require(method == 'compatibility_recovery', 'invalid_recovery_method')
147
+ target = state['recovery_envelope']
148
+ require(target is not None and target['subject_id'] == client.state.subject and
149
+ target['old_key_id'] == source['key_id'] and target['new_key_id'] == state['new_key_id'] and
150
+ target['recipient'] == state['recipient'], 'custodial_recovery_envelope_mismatch')
151
+ encrypted_key = await _ciphertext(client, target['ciphertext'], directory, 'key.age')
152
+ raw = _age('--decrypt', '--identity', str(identity_path), input_data=encrypted_key)
153
+ payload = loads(raw)
154
+ require(set(payload) == {'format','purpose','subject_id','challenge_id','encryption_key_id',
155
+ 'encryption_recipient','age_identity'} and
156
+ payload['format'] == 'msg-custodial-retired-key-v1' and
157
+ payload['purpose'] == 'retired-encryption-subkey-recovery' and
158
+ payload['subject_id'] == client.state.subject and
159
+ payload['challenge_id'] == state['challenge_id'] and
160
+ payload['encryption_key_id'] == source['key_id'], 'custodial_recovery_envelope_mismatch')
161
+ old_recipient = recipient_from_identity(payload['age_identity'])
162
+ require(old_recipient == payload['encryption_recipient'] and
163
+ encryption_key_id(public_from_recipient(old_recipient)) == source['key_id'],
164
+ 'custodial_recovery_key_mismatch')
165
+ old_path = directory / 'retired.agekey'
166
+ write_private(old_path, (payload['age_identity'] + '\n').encode('ascii'))
167
+ ciphertext = await _ciphertext(client, source, directory, 'original.age')
168
+ plaintext = _age('--decrypt', '--identity', str(old_path), input_data=ciphertext)
169
+ target_ref = target['ciphertext']
170
+ request_id = uuid4().hex
171
+ plaintext_digest = digest(plaintext)
172
+ signed = ack_statement(client.state.subject, state['challenge_id'], state['inventory_digest'],
173
+ source, target, plaintext_digest, request_id, method=method)
174
+ signature = client.state.signer.sign(canonical(signed), purpose='custodial-history-ack-v1')
175
+ return client.checked(await client.call('identity.custodial_rewrap_ack', {
176
+ 'challenge_id': state['challenge_id'], 'old_revision': old_revision,
177
+ 'new_revision': target_ref['revision'], 'ciphertext_digest': target_ref['ciphertext_digest'],
178
+ 'plaintext_digest': plaintext_digest, 'inventory_digest': state['inventory_digest'],
179
+ 'method': method, 'decryption_ack': wire(signature)}, request_id=request_id, contract_version=2))
180
+
181
+
182
+ async def migrate(client, *, method='rewrap', limit=100):
183
+ require(type(limit) is int and 1 <= limit <= 1000, 'invalid_migration_limit')
184
+ state = (await inventory(client)).data
185
+ require(not state['inventory_changed'], 'custodial_inventory_changed')
186
+ if method == 'compatibility_recovery' and state['recovery_envelope'] is None:
187
+ client.checked(await transition(client, 'recovery_envelope'))
188
+ outcomes = []
189
+ for source in state['sources']:
190
+ revision = source['revision']
191
+ if revision in state['verified_revisions']:
192
+ continue
193
+ if len(outcomes) == limit:
194
+ break
195
+ try:
196
+ chosen = 'rewrap' if source['key_id'] == state['new_key_id'] else method
197
+ if chosen == 'rewrap' and revision not in state['mappings']:
198
+ client.checked(await client.call('identity.custodial_rewrap_revision', {
199
+ 'challenge_id': state['challenge_id'],
200
+ 'ciphertext_ref': {'id': source['id'], 'revision': revision},
201
+ 'old_encryption_key_id': state['old_key_id'], 'new_recipient': state['recipient']}))
202
+ result = await acknowledge(client, revision, method=chosen)
203
+ outcomes.append({'revision': revision, 'status': result.status, 'method': chosen})
204
+ except Failure as exc:
205
+ outcomes.append({'revision': revision, 'status': 'pending', 'error': exc.code})
206
+ return {'items': outcomes, 'inventory': wire((await inventory(client)).data), 'finalized': False}
msg/client_market.py ADDED
@@ -0,0 +1,104 @@
1
+ """Small CLI adapters over the declared market contracts, never a second API."""
2
+ from collections.abc import Mapping
3
+ from uuid import uuid4
4
+
5
+ from msg.core.codec import canonical, digest, parse_time, unb64, wire
6
+ from msg.core.errors import require
7
+
8
+ # Values are (operation, positional argument). JSON is parsed by the CLI's
9
+ # existing bounded JSON/@file/stdin reader. Exact prices remain explicit input.
10
+ COMMANDS = {
11
+ 'money': {
12
+ 'state':('money.state',None), 'banks':('money.banks',None),
13
+ 'balance':('money.balance',None), 'ledger':('money.ledger','json'),
14
+ 'offers':('money.offers',None), 'entitlements':('money.entitlements',None),
15
+ 'transfer':('money.transfer','json'), 'redeem':('money.redeem','json'),
16
+ 'purchase':('money.purchase_get','purchase_id'),
17
+ 'settle':('money.purchase_settle','purchase_id'),
18
+ 'cancel':('money.purchase_cancel','purchase_id'),
19
+ },
20
+ 'store': {
21
+ 'get':('store.listing_get','id'), 'create':('store.listing_create','json'),
22
+ 'update':('store.listing_update','json'),
23
+ 'deposit':('store.package_deposit','json'), 'package':('store.package_get','id'),
24
+ },
25
+ 'bounty': {
26
+ 'get':('bounty.get','listing_id'), 'create':('bounty.create','json'),
27
+ 'top-up':('bounty.top_up','json'), 'pause':('bounty.pause','listing_id'),
28
+ 'resume':('bounty.resume','listing_id'), 'close':('bounty.close','listing_id'),
29
+ 'claims':('bounty.claims','listing_id'), 'challenge':('bounty.challenge','listing_id'),
30
+ 'claim':('bounty.claim','json'), 'prove':(None,'listing_id'),
31
+ },
32
+ 'orders': {
33
+ 'create':('orders.create','json'), 'buy':('orders.buy','json'),
34
+ 'pay':('orders.fund','json'), 'get':('orders.get','order_id'),
35
+ 'list':('orders.list','json'), 'payment':('orders.payment','order_id'),
36
+ 'cancel':('orders.cancel','order_id'), 'contract':('orders.contract','order_id'),
37
+ 'resolve':('orders.dispute_execute','json'), 'dispute':('orders.dispute_open','json'),
38
+ 'case':('orders.dispute_get','case_id'),
39
+ },
40
+ 'delivery': {
41
+ 'get':('delivery.get','order_id'), 'prepare':('delivery.prepare','order_id'),
42
+ 'accept':('delivery.accept','json'), 'submit':('delivery.submit','json'),
43
+ 'claim':('delivery.claim','json'),
44
+ },
45
+ }
46
+
47
+
48
+ def add_commands(commands):
49
+ for group, actions in COMMANDS.items():
50
+ sub = commands.add_parser(group, help='Signed market operations; amounts are exact minor units.').add_subparsers(
51
+ dest='action', required=True)
52
+ for action, (operation, field) in actions.items():
53
+ help_text = ('Sign a current-key PoP challenge and claim; not a human/Sybil check.'
54
+ if group=='bounty' and action=='prove' else None)
55
+ cmd = sub.add_parser(action, help=help_text)
56
+ if field=='json':
57
+ cmd.add_argument('payload', nargs='?', default='{}', help='JSON, @file or - for stdin.')
58
+ elif field:
59
+ cmd.add_argument('value')
60
+ cmd.add_argument('--request-id', help='Reuse exactly this ID and input after an uncertain response.')
61
+ if operation is not None:
62
+ cmd.add_argument('--contract-version', type=int,
63
+ help='Explicit published operation version; never changes the signed input silently.')
64
+ if group=='store' and action=='update':
65
+ cmd.add_argument('--generation', type=int, required=True)
66
+
67
+
68
+ async def prove_bounty(client, listing_id, request_id=None):
69
+ require(client.state.signer is not None and client.state.subject is not None, 'signing_identity_required')
70
+ request_id = request_id or uuid4().hex
71
+ # Repeat the SAME challenge request after a lost claim response. Never
72
+ # silently replace the nonce or extend TTL while retaining the claim ID.
73
+ challenge_id = 'bounty-challenge-' + digest((client.state.subject,listing_id,request_id))[7:39]
74
+ challenged = await client.call('bounty.challenge',{'listing_id':listing_id},request_id=challenge_id)
75
+ if challenged.status != 'ok':
76
+ return challenged
77
+ payload = challenged.data.get('challenge')
78
+ fields = {'challenge_id','listing_id','claimant_subject_id','nonce','issued_at','expires_at','verifier_version'}
79
+ require(isinstance(payload,Mapping) and set(payload)==fields and
80
+ payload['listing_id']==listing_id and payload['claimant_subject_id']==client.state.subject and
81
+ payload['verifier_version']==1 and len(unb64(payload['nonce'],limit=32))==24 and
82
+ 0 < (parse_time(payload['expires_at'])-parse_time(payload['issued_at'])).total_seconds() <= 300,
83
+ 'bounty_challenge_mismatch')
84
+ proof = client.state.signer.sign(canonical(payload),purpose='bounty-pop-v1')
85
+ return await client.call('bounty.claim',{'challenge_id':payload['challenge_id'],'proof':wire(proof)},
86
+ request_id=request_id)
87
+
88
+
89
+ async def run_command(client, args, parse_arguments):
90
+ operation, field = COMMANDS[args.command][args.action]
91
+ if operation is None:
92
+ return await prove_bounty(client,args.value,args.request_id)
93
+ params = parse_arguments(args.payload) if field=='json' else {field:args.value} if field else {}
94
+ kwargs = {'request_id':args.request_id}
95
+ version = getattr(args, 'contract_version', None)
96
+ if version is not None:
97
+ require(type(version) is int and 1 <= version <= 2**31 - 1, 'invalid_contract_version')
98
+ kwargs['contract_version'] = version
99
+ elif operation=='money.redeem':
100
+ kwargs['contract_version']=2
101
+ if args.command=='store' and args.action=='update':
102
+ require(isinstance(params.get('id'),str),'listing_id_required')
103
+ kwargs['expected']=((params['id'],args.generation),)
104
+ return await client.call(operation,params,**kwargs)
msg/client_recovery.py ADDED
@@ -0,0 +1,186 @@
1
+ """Local age backup of a self-custody encryption subkey.
2
+
3
+ The service receives only ciphertext. Decrypting an envelope does not log in to
4
+ an account or grant the custodian any application permission.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from pathlib import Path
13
+ from uuid import uuid4
14
+
15
+ from msg.client_secrets import write_private
16
+ from msg.core.codec import canonical, digest, loads, wire
17
+ from msg.core.errors import Failure, require
18
+ from msg.security.age_keys import (
19
+ encryption_key_id, public_from_recipient, recipient_from_identity,
20
+ )
21
+
22
+
23
+ def _protected_file(path):
24
+ path=Path(path)
25
+ require(path.is_file() and not path.is_symlink() and
26
+ path.stat().st_uid==os.geteuid() and path.stat().st_mode&0o077==0,
27
+ 'unsafe_encryption_key_permissions')
28
+ return path
29
+
30
+
31
+ def _age(*arguments,input_data):
32
+ executable=shutil.which('age')
33
+ require(executable is not None,'age_dependency_unavailable')
34
+ try:
35
+ result=subprocess.run([executable,*arguments],input=input_data,capture_output=True,
36
+ timeout=60,check=False)
37
+ except (OSError,subprocess.TimeoutExpired) as exc:
38
+ raise Failure('age_operation_failed') from exc
39
+ require(result.returncode==0,'age_operation_failed')
40
+ return result.stdout
41
+
42
+
43
+ def create_recovery_envelope(state,recipients):
44
+ """Return a standard age ciphertext for an independently recoverable key."""
45
+ require(state.subject is not None and state.encryption_recipient is not None,
46
+ 'self_custody_identity_required')
47
+ recipients=tuple(recipients)
48
+ require(1<=len(recipients)<=8 and len(set(recipients))==len(recipients),
49
+ 'invalid_recovery_recipients')
50
+ for recipient in recipients:
51
+ public_from_recipient(recipient)
52
+ require(any(recipient!=state.encryption_recipient for recipient in recipients),
53
+ 'independent_recovery_recipient_required')
54
+ identity=_protected_file(state.age_key_path).read_text().strip()
55
+ require(recipient_from_identity(identity)==state.encryption_recipient,
56
+ 'encryption_identity_mismatch')
57
+ key_id=encryption_key_id(public_from_recipient(state.encryption_recipient))
58
+ plaintext=canonical({'format':'msg-recovery-envelope-v1','purpose':'encryption-subkey-recovery',
59
+ 'subject_id':state.subject,'encryption_key_id':key_id,
60
+ 'encryption_recipient':state.encryption_recipient,'age_identity':identity})
61
+ arguments=['--encrypt']
62
+ for recipient in recipients:
63
+ arguments.extend(('--recipient',recipient))
64
+ ciphertext=_age(*arguments,input_data=plaintext)
65
+ require(ciphertext.startswith(b'age-encryption.org/v1\n'),'invalid_recovery_envelope')
66
+ return ciphertext,{'subject_id':state.subject,'encryption_key_id':key_id,
67
+ 'recipient_fingerprints':tuple(encryption_key_id(public_from_recipient(r))
68
+ for r in recipients),'purpose':'encryption-subkey-recovery'}
69
+
70
+
71
+ async def save_recovery_envelope(client,recipients, *, policy_version,name=None):
72
+ """Store ciphertext and register its owner-declared metadata under an opted-in policy."""
73
+ ciphertext,metadata=create_recovery_envelope(client.state,recipients)
74
+ policy=client.checked(await client.call('identity.recovery_policy_get',{})).data
75
+ require(policy['opted_in'] and policy['version']==policy_version and
76
+ policy['encryption_key_id']==metadata['encryption_key_id'],
77
+ 'recovery_policy_not_opted_in')
78
+ by_fingerprint={item['fingerprint']:item for item in policy['recipients']}
79
+ offered=set(metadata['recipient_fingerprints'])
80
+ require(offered<=by_fingerprint.keys(),'recovery_recipient_not_in_policy')
81
+ custodian_refs=sorted({by_fingerprint[fingerprint]['custodian_ref']
82
+ for fingerprint in offered
83
+ if by_fingerprint[fingerprint].get('custodian_ref') is not None})
84
+ name=name or 'recovery-'+metadata['encryption_key_id'][:18]
85
+ with tempfile.TemporaryDirectory(prefix='recovery-',dir=client.state.directory) as folder:
86
+ path=Path(folder)/'envelope.age'
87
+ write_private(path,ciphertext)
88
+ uploaded=client.checked(await client.upload(path,media_type='application/octet-stream'))
89
+ stored=client.checked(await client.call('keystore.put',{'name':name,'format':'age',
90
+ 'source':wire(uploaded.output)}))
91
+ registered=client.checked(await client.call('identity.recovery_envelope_register',{
92
+ 'ciphertext_ref':wire(stored.resources[0]),
93
+ 'encryption_key_id':metadata['encryption_key_id'],'policy_version':policy_version,
94
+ 'recipient_fingerprints':sorted(offered),'custodian_refs':custodian_refs,
95
+ 'purpose':'encryption-subkey-recovery'}))
96
+ return stored,registered,metadata
97
+
98
+
99
+ def restore_recovery_envelope(ciphertext,custodian_identity_path,output_path, *,
100
+ expected_subject_id,expected_encryption_key_id):
101
+ """Offline decrypt and validate an envelope, writing the restored key 0600."""
102
+ require(type(ciphertext) is bytes and ciphertext.startswith(b'age-encryption.org/v1\n'),
103
+ 'invalid_recovery_envelope')
104
+ custodian=_protected_file(custodian_identity_path)
105
+ plaintext=_age('--decrypt','--identity',str(custodian),input_data=ciphertext)
106
+ try:
107
+ payload=loads(plaintext)
108
+ require(payload['format']=='msg-recovery-envelope-v1' and
109
+ payload['purpose']=='encryption-subkey-recovery' and
110
+ payload['subject_id']==expected_subject_id and
111
+ payload['encryption_key_id']==expected_encryption_key_id,
112
+ 'recovery_envelope_mismatch')
113
+ recipient=recipient_from_identity(payload['age_identity'])
114
+ require(recipient==payload['encryption_recipient'] and
115
+ encryption_key_id(public_from_recipient(recipient))==expected_encryption_key_id,
116
+ 'recovery_envelope_mismatch')
117
+ except (KeyError,TypeError) as exc:
118
+ raise Failure('invalid_recovery_envelope') from exc
119
+ write_private(output_path,(payload['age_identity']+'\n').encode())
120
+ return {'subject_id':expected_subject_id,'encryption_key_id':expected_encryption_key_id,
121
+ 'recipient':recipient,'restored_locally':True,'account_authority':'none'}
122
+
123
+
124
+ async def rewrap_age_keystore_entry(client,resource_id,old_identity_path, *,
125
+ expected_revision):
126
+ """Re-encrypt one selected age entry for the current subkey, never scanning a vault."""
127
+ require(client.state.subject is not None and client.state.signer is not None and
128
+ client.state.encryption_recipient is not None,'signing_identity_required')
129
+ old_identity=_protected_file(old_identity_path)
130
+ old_recipient=recipient_from_identity(old_identity.read_text().strip())
131
+ recipient=client.state.encryption_recipient
132
+ require(old_recipient!=recipient,'encryption_key_unchanged')
133
+ public_from_recipient(recipient)
134
+ entry=client.checked(await client.call('keystore.get',{
135
+ 'id':resource_id,'revision':expected_revision}))
136
+ require(entry.data['format']=='age' and entry.data['size']<=1048576,
137
+ 'age_keystore_entry_required')
138
+ current=client.checked(await client.call('discovery.get',{'id':resource_id,'view':'meta'}))
139
+ require(current.data['owner']==client.state.subject,'keystore_owner_required')
140
+ require(current.data['revision']==expected_revision,'revision_conflict')
141
+ with tempfile.TemporaryDirectory(prefix='rewrap-',dir=client.state.directory) as folder:
142
+ directory=Path(folder)
143
+ prior=directory/'prior.age'
144
+ await client.download(entry.output,prior)
145
+ plaintext=_age('--decrypt','--identity',str(old_identity),input_data=prior.read_bytes())
146
+ ciphertext=_age('--encrypt','--recipient',recipient,input_data=plaintext)
147
+ require(ciphertext.startswith(b'age-encryption.org/v1\n'),'invalid_ciphertext_envelope')
148
+ renewed=directory/'renewed.age'
149
+ write_private(renewed,ciphertext)
150
+ uploaded=client.checked(await client.upload(renewed,media_type='application/octet-stream'))
151
+ result=client.checked(await client.call('keystore.put',{
152
+ 'id':resource_id,'name':current.data['name'],'format':'age',
153
+ 'source':wire(uploaded.output)},
154
+ expected=((resource_id,current.data['generation']),)))
155
+ return result
156
+
157
+
158
+ async def verify_custodial_rewrap_entry(client,resource_id,revision,new_identity_path, *,
159
+ expected_recipient,expected_ciphertext_digest):
160
+ """Owner-side age round trip; the plaintext stays in this process."""
161
+ identity=_protected_file(new_identity_path)
162
+ require(recipient_from_identity(identity.read_text().strip())==expected_recipient,
163
+ 'custodial_rewrap_recipient_mismatch')
164
+ entry=client.checked(await client.call('keystore.get',{
165
+ 'id':resource_id,'revision':revision}))
166
+ require(entry.data['format']=='age' and entry.data['size']<=1114112,
167
+ 'custodial_rewrap_age_required')
168
+ with tempfile.TemporaryDirectory(prefix='verify-rewrap-',dir=client.state.directory) as folder:
169
+ path=Path(folder)/'ciphertext.age'
170
+ await client.download(entry.output,path)
171
+ ciphertext=path.read_bytes()
172
+ require(digest(ciphertext)==expected_ciphertext_digest,
173
+ 'custodial_rewrap_ciphertext_mismatch')
174
+ plaintext=_age('--decrypt','--identity',str(identity),input_data=ciphertext)
175
+ return {'id':resource_id,'revision':revision,'recipient':expected_recipient,
176
+ 'ciphertext_digest':expected_ciphertext_digest,'decrypted_locally':True,
177
+ 'plaintext_digest':digest(plaintext),'upgrade_finalized':False}
178
+
179
+
180
+ async def ack_custodial_rewrap_entry(client,challenge_id,old_revision,new_identity_path):
181
+ """Compatibility wrapper for the scoped, locally decrypted ACK workflow."""
182
+ from msg.client_custodial import acknowledge, journal_for
183
+ _,journal=journal_for(client.state)
184
+ require(journal['challenge']['challenge_id']==challenge_id and
185
+ Path(new_identity_path)==client.state.age_key_path,'custodial_upgrade_journal_mismatch')
186
+ return await acknowledge(client,old_revision)