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,183 @@
1
+ """Current-state certificate chain validation and distinct issuance ceilings."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ from datetime import timedelta
6
+
7
+ from msg.constants import ROOT_SPACE,ROOT_SUBJECT,ONLINE_CA
8
+ from msg.core.codec import canonical,wire,digest
9
+ from msg.core.errors import Failure,require
10
+ from msg.core.models import Certificate,Signature
11
+ from msg.security.crypto import key_id,verify
12
+ from msg.security.policy import constraints_subset,scope_subset,grant_covers
13
+
14
+
15
+ # Keep this list explicit. Adding a new ordinary capability must not silently
16
+ # enlarge the permanent online issuer's signing authority.
17
+ ONLINE_ISSUABLE_CAPABILITIES=frozenset({
18
+ 'identity.basic','resource.basic','discussion.basic','communication.basic',
19
+ 'discovery.basic','transfer.basic','group.basic','cert.request','git.basic',
20
+ 'hosting.basic','keystore.basic','batch.basic','sharing.basic',
21
+ 'money.basic','store.basic','bounty.basic','orders.basic','delivery.basic',
22
+ })
23
+
24
+
25
+ def certificate_body(certificate):
26
+ body=wire(certificate)
27
+ body.pop('signature')
28
+ return body
29
+
30
+
31
+ def csr_body(request):
32
+ body=wire(request)
33
+ for key in ('resource_id','request_digest','possession_proof'):
34
+ body.pop(key,None)
35
+ return body
36
+
37
+
38
+ def sign_certificate(certificate,signer):
39
+ return replace(certificate,signature=signer.sign(canonical(certificate_body(certificate)),purpose='certificate'))
40
+
41
+
42
+ def verify_csr(request):
43
+ require(request.request_digest==digest(csr_body(request)),'csr_digest_mismatch')
44
+ verify(request.public_key,canonical(csr_body(request)),request.possession_proof,purpose='csr')
45
+
46
+
47
+ class CertificateValidator:
48
+ def __init__(self,registry,root_certificate,root_public_key,service,clock):
49
+ self.registry=registry
50
+ self.root_certificate=root_certificate
51
+ self.root_public_key=root_public_key
52
+ self.service=service
53
+ self.clock=clock
54
+
55
+ async def validate_grant(self,grant,session):
56
+ spec=self.registry.capability(grant.capability,grant.version)
57
+ resource=await session.resource(grant.scope.resource_id)
58
+ require(resource.type in spec.scope_types,'invalid_scope_type')
59
+ require(bool(grant.operations) and grant.operations<=spec.operations,'invalid_grant_operations')
60
+ if spec.constraints_schema is not None:
61
+ self.registry.validate(spec.constraints_schema,grant.constraints)
62
+ else:
63
+ require(not grant.constraints,'unknown_grant_constraint')
64
+ return spec
65
+
66
+ async def allowed_issuance(self,grant,policy,session):
67
+ await self.validate_grant(grant,session)
68
+ for allowed in policy.issue_grants:
69
+ if (grant.capability==allowed.capability and grant.version==allowed.version and
70
+ grant.operations<=allowed.operations and await scope_subset(grant.scope,allowed.scope,session)
71
+ and constraints_subset(grant.constraints,allowed.constraints)):
72
+ return True
73
+ return False
74
+
75
+ async def validate(self,id,session, *, seen=None,certificate=None,issuance_resource=None):
76
+ seen=set() if seen is None else set(seen)
77
+ require(id not in seen and len(seen)<32,'certificate_cycle')
78
+ seen.add(id)
79
+ cert=certificate or await session.certificate(id)
80
+ now=self.clock()
81
+ require(cert.target_service==self.service,'wrong_service')
82
+ require(cert.not_before<=now<cert.expires_at,'certificate_expired')
83
+ if certificate is None:
84
+ require(not await session.certificate_revoked(id),'certificate_revoked')
85
+ if id==self.root_certificate.resource_id:
86
+ require(canonical(cert)==canonical(self.root_certificate),'trust_anchor_mismatch')
87
+ require(cert.subject_id==ROOT_SUBJECT and cert.issuer_id==ROOT_SUBJECT and
88
+ cert.parent_certificate_id is None and cert.kind=='ca','invalid_root_certificate')
89
+ verify(self.root_public_key,canonical(certificate_body(cert)),cert.signature,purpose='certificate')
90
+ return cert
91
+ require(cert.subject_id!=ROOT_SUBJECT and cert.parent_certificate_id is not None,'local_only')
92
+ parent=await self.validate(cert.parent_certificate_id,session,seen=seen)
93
+ require(parent.kind=='ca' and parent.issuance is not None,'issuer_not_ca')
94
+ require(cert.issuer_id==parent.subject_id,'issuer_mismatch')
95
+ parent_key=await session.credential(parent.key_id)
96
+ require(parent_key.subject_id==parent.subject_id and parent_key.revoked_at is None,'issuer_key_revoked')
97
+ verify(parent_key.verifier,canonical(certificate_body(cert)),cert.signature,purpose='certificate')
98
+ subject_key=await session.credential(cert.key_id)
99
+ require(subject_key.subject_id==cert.subject_id,'certificate_key_mismatch')
100
+ require(subject_key.revoked_at is None and subject_key.not_before<=now and
101
+ (subject_key.expires_at is None or now<subject_key.expires_at),'certificate_key_revoked')
102
+ require(parent.not_before<=cert.not_before and cert.expires_at<=parent.expires_at,'certificate_validity_escalation')
103
+ require((cert.expires_at-cert.not_before).total_seconds()<=parent.issuance.max_cert_ttl_seconds,
104
+ 'certificate_ttl_escalation')
105
+ require(cert.delegation_depth<=parent.issuance.max_delegation_depth,'delegation_depth_exceeded')
106
+ # Signing rights are explicit use grants, not inferred from issue_grants.
107
+ issue_resource=issuance_resource or session.setting('certificate_request:'+cert.resource_id,cert.resource_id)
108
+ require(any([await grant_covers(g,'cert.issue','cert.publish@1',issue_resource,session)
109
+ for g in parent.grants]),'issuer_cannot_issue')
110
+ for grant in cert.grants:
111
+ require(await self.allowed_issuance(grant,parent.issuance,session),'issuance_scope_exceeded')
112
+ if cert.subject_id==ONLINE_CA:
113
+ require(parent.resource_id==self.root_certificate.resource_id and cert.kind=='ca' and
114
+ cert.issuance is not None and cert.issuance.max_child_ca_depth==0 and
115
+ len(cert.grants)==1 and cert.grants[0].capability=='cert.issue' and
116
+ 'cert.publish@1' in cert.grants[0].operations,
117
+ 'online_ca_policy_exceeded')
118
+ require(all(grant.capability in ONLINE_ISSUABLE_CAPABILITIES and
119
+ not self.registry.capability(grant.capability,grant.version).ca_only
120
+ for grant in cert.issuance.issue_grants),'online_ca_policy_exceeded')
121
+ if parent.subject_id==ONLINE_CA:
122
+ require(cert.kind in {'identity','delegation'} and
123
+ (cert.kind!='delegation' or bool(cert.authority_sources)) and
124
+ all(grant.capability in ONLINE_ISSUABLE_CAPABILITIES for grant in cert.grants),
125
+ 'online_ca_policy_exceeded')
126
+ if cert.kind=='ca':
127
+ require(cert.issuance is not None and parent.issuance.max_child_ca_depth>0,'ca_depth_exceeded')
128
+ require(any([await grant_covers(g,'cert.ca.issue','cert.publish@1',issue_resource,session)
129
+ for g in parent.grants]),'issuer_cannot_issue_ca')
130
+ require(cert.issuance.max_child_ca_depth<parent.issuance.max_child_ca_depth,'ca_depth_exceeded')
131
+ # The signed policy on an old trust anchor may allow more levels.
132
+ # Count the validated parent chain so an L4 CA remains impossible.
133
+ level=1
134
+ ancestor=parent
135
+ while ancestor.resource_id!=self.root_certificate.resource_id:
136
+ level+=1
137
+ ancestor=await session.certificate(ancestor.parent_certificate_id)
138
+ require(level<=3 and 0<=cert.issuance.max_child_ca_depth<=3-level,'ca_depth_exceeded')
139
+ require(cert.issuance.max_cert_ttl_seconds<=parent.issuance.max_cert_ttl_seconds and
140
+ cert.issuance.max_delegation_depth<=parent.issuance.max_delegation_depth,'issuance_policy_escalation')
141
+ for grant in cert.issuance.issue_grants:
142
+ require(await self.allowed_issuance(grant,parent.issuance,session),'issuance_scope_exceeded')
143
+ else:
144
+ require(cert.issuance is None,'unexpected_issuance_policy')
145
+ for source in cert.authority_sources:
146
+ await self.validate_authority(source,cert,session,seen=seen)
147
+ return cert
148
+
149
+ async def validate_authority(self,source,cert,session, *, seen=None):
150
+ resource=await session.resource(source.id)
151
+ require(resource.type=='delegation' and resource.state=='active','authority_source_inactive')
152
+ require(source.revision==resource.revision,'authority_source_changed')
153
+ # The controlled delegation facts are distinct from mutable ordinary content.
154
+ fact=session.setting('delegation:'+source.id)
155
+ require(fact is not None and fact['grantee']==cert.subject_id,'authority_source_invalid')
156
+ from msg.core.codec import parse_time,decode
157
+ from msg.core.models import CapabilityGrant
158
+ require(parse_time(fact['expires_at'])>self.clock(),'authority_source_expired')
159
+ for raw in fact['grants']:
160
+ grant=decode(CapabilityGrant,raw)
161
+ target=await session.resource(grant.scope.resource_id)
162
+ require(target.owner==fact['grantor'],'authority_source_lost')
163
+ allowed=tuple(decode(CapabilityGrant,g) for g in fact['grants'])
164
+ for grant in (*cert.grants, *(cert.issuance.issue_grants if cert.issuance else ())):
165
+ require(any([g.capability==grant.capability and g.version==grant.version and
166
+ grant.operations<=g.operations and await scope_subset(grant.scope,g.scope,session)
167
+ and constraints_subset(grant.constraints,g.constraints) for g in allowed]),
168
+ 'delegation_scope_exceeded')
169
+ if fact.get('parent_certificate'):
170
+ await self.validate(fact['parent_certificate'],session,seen=set(seen or ())|{cert.resource_id})
171
+ return fact
172
+
173
+ async def validate_publication(self,cert,csr,session):
174
+ verify_csr(csr)
175
+ require(cert.subject_id==csr.subject_id and cert.issuer_id==csr.requested_issuer and
176
+ cert.key_id==key_id(csr.public_key) and cert.kind==csr.kind and
177
+ cert.grants==csr.grants and cert.issuance==csr.issuance and
178
+ cert.authority_sources==csr.authority_sources and
179
+ cert.delegation_depth==csr.delegation_depth and cert.target_service==csr.target_service,
180
+ 'csr_certificate_mismatch')
181
+ require((cert.expires_at-cert.not_before).total_seconds()<=csr.requested_ttl_seconds,
182
+ 'csr_ttl_exceeded')
183
+ return await self.validate(cert.resource_id,session,certificate=cert,issuance_resource=csr.resource_id)
msg/security/crypto.py ADDED
@@ -0,0 +1,102 @@
1
+ """Ed25519 and password wrapping from cryptography; no custom cryptographic primitive."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+ from cryptography.exceptions import InvalidSignature, InvalidTag
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
12
+ from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption, PublicFormat
13
+
14
+ from msg.core.codec import b64, canonical, unb64
15
+ from msg.core.errors import Failure, require
16
+ from msg.core.models import Signature
17
+
18
+
19
+ def key_id(public: bytes) -> str:
20
+ return 'k_'+hashlib.sha256(public).hexdigest()
21
+
22
+
23
+ def subject_id(public: bytes) -> str:
24
+ return 'u_'+hashlib.sha256(b'msg-subject-v1\0'+public).hexdigest()[:32]
25
+
26
+
27
+ def framed(payload: bytes, purpose: str) -> bytes:
28
+ require(purpose and '\0' not in purpose,'invalid_signature_purpose')
29
+ return b'msg.lmm.best/v1/'+purpose.encode('ascii')+b'\0'+payload
30
+
31
+
32
+ @dataclass(frozen=True,slots=True)
33
+ class Ed25519Signer:
34
+ _private: Ed25519PrivateKey=field(repr=False)
35
+
36
+ @classmethod
37
+ def generate(cls):
38
+ return cls(Ed25519PrivateKey.generate())
39
+
40
+ @classmethod
41
+ def from_bytes(cls,private: bytes):
42
+ require(len(private)==32,'invalid_private_key')
43
+ return cls(Ed25519PrivateKey.from_private_bytes(private))
44
+
45
+ @property
46
+ def public_key(self):
47
+ return self._private.public_key().public_bytes(Encoding.Raw,PublicFormat.Raw)
48
+
49
+ @property
50
+ def key_id(self):
51
+ return key_id(self.public_key)
52
+
53
+ def private_bytes(self):
54
+ return self._private.private_bytes(Encoding.Raw,PrivateFormat.Raw,NoEncryption())
55
+
56
+ def sign(self,payload: bytes, *, purpose: str):
57
+ return Signature(key_id=self.key_id,algorithm='ed25519',value=self._private.sign(framed(payload,purpose)))
58
+
59
+
60
+ def verify(public: bytes,payload: bytes,signature: Signature, *, purpose: str):
61
+ require(signature.algorithm=='ed25519' and signature.key_id==key_id(public),'invalid_signature')
62
+ try:
63
+ Ed25519PublicKey.from_public_bytes(public).verify(signature.value,framed(payload,purpose))
64
+ except (InvalidSignature,ValueError,TypeError) as exc:
65
+ raise Failure('invalid_signature') from exc
66
+
67
+
68
+ def _pin(pin):
69
+ require(type(pin) is str and len(pin)>=12 and
70
+ pin.casefold() not in {'password','passwordpassword','changemechangeme','defaultdefault'} and
71
+ not (pin.isdecimal() and len(pin)<20),'weak_pin')
72
+ return pin.encode('utf-8')
73
+
74
+
75
+ def seal_private_key(private: bytes,pin: str):
76
+ password=_pin(pin)
77
+ signer=Ed25519Signer.from_bytes(private)
78
+ header={'version':1,'kdf':'scrypt','n':2**17,'r':8,'p':1,
79
+ 'salt':b64(os.urandom(16)),'nonce':b64(os.urandom(12)),
80
+ 'cipher':'aes-256-gcm','public_key':b64(signer.public_key)}
81
+ derived=Scrypt(salt=unb64(header['salt']),length=32,n=header['n'],r=8,p=1).derive(password)
82
+ encrypted=AESGCM(derived).encrypt(unb64(header['nonce']),private,canonical(header))
83
+ return dict(header,ciphertext=b64(encrypted))
84
+
85
+
86
+ def open_private_key(envelope: dict,pin: str):
87
+ required={'version','kdf','n','r','p','salt','nonce','cipher','public_key','ciphertext'}
88
+ require(set(envelope)==required and envelope['version']==1 and envelope['kdf']=='scrypt' and
89
+ envelope['cipher']=='aes-256-gcm','invalid_key_envelope')
90
+ n=envelope['n']
91
+ require(type(n) is int and 2**17<=n<=2**20 and n&(n-1)==0 and
92
+ envelope['r']==8 and envelope['p']==1,'invalid_kdf_parameters')
93
+ header={k:v for k,v in envelope.items() if k!='ciphertext'}
94
+ salt,nonce=unb64(header['salt']),unb64(header['nonce'])
95
+ require(len(salt)==16 and len(nonce)==12,'invalid_key_envelope')
96
+ try:
97
+ derived=Scrypt(salt=salt,length=32,n=n,r=8,p=1).derive(pin.encode('utf-8'))
98
+ private=AESGCM(derived).decrypt(nonce,unb64(envelope['ciphertext']),canonical(header))
99
+ require(b64(Ed25519Signer.from_bytes(private).public_key)==header['public_key'],'invalid_key_envelope')
100
+ return private
101
+ except InvalidTag as exc:
102
+ raise Failure('invalid_pin') from exc
@@ -0,0 +1,292 @@
1
+ """Custodial exit invariants; evidence is scoped, and deletion is not retirement.
2
+
3
+ The stored challenge binds both new public keys. Every later decision signs a
4
+ fresh inventory and result digest. A recovery envelope is compatibility recovery,
5
+ not a replacement ciphertext. No network claim can attest backup destruction.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+
12
+ from msg.core.codec import canonical, decode, digest, loads, unb64, wire
13
+ from msg.core.errors import Failure, require
14
+ from msg.core.models import ResourceRef, Signature
15
+ from msg.security.crypto import key_id, verify
16
+ from msg.security.age_keys import encryption_key_id, public_from_recipient
17
+ from msg.security.backup_retirement import BackupRetirement, verified as verified_backup
18
+
19
+
20
+ class MigrationPhase(StrEnum):
21
+ CHALLENGE = 'challenge'
22
+ MIGRATING = 'migrating'
23
+ IDENTITY_SWITCHED = 'identity_switched'
24
+ HISTORY_RECOVERABLE = 'history_recoverable'
25
+ ONLINE_KEY_RETIRED = 'online_key_retired'
26
+ SERVER_KEY_RETIRED = 'server_key_retired'
27
+
28
+
29
+ def ordered(items):
30
+ return sorted(items, key=lambda item: (item['id'], item['revision']))
31
+
32
+
33
+ def inventory_commitment(subject, details, challenge):
34
+ return digest({'domain': 'msg-custodial-inventory-v1', 'subject_id': subject,
35
+ 'challenge_id': challenge['challenge_id'],
36
+ 'version': details.get('inventory_version', 1),
37
+ 'old_identity_key_id': details['old_identity_key_id'],
38
+ 'new_identity_key_id': details['new_identity_key_id'],
39
+ 'old_encryption_key_id': details['old_encryption_key_id'],
40
+ 'new_encryption_key_id': details['new_encryption_key_id'],
41
+ 'recipient': challenge['encryption_recipient'],
42
+ 'items': details['age_inventory'],
43
+ 'source_keys': details.get('source_keys', {})})
44
+
45
+
46
+ def stage_statement(subject, arguments, request_id):
47
+ """Sign every decision field, not a caller-selected subset of the manifest."""
48
+ return {'domain': 'msg-custodial-decision-v1', 'subject_id': subject,
49
+ 'request_id': request_id,
50
+ 'decision': {key: value for key, value in arguments.items()
51
+ if key != 'migration_ack'}}
52
+
53
+
54
+ def ack_statement(subject, challenge_id, inventory_digest, source, target,
55
+ plaintext_digest, request_id, *, method='rewrap'):
56
+ return {'domain': 'msg-custodial-history-ack-v1', 'subject_id': subject,
57
+ 'challenge_id': challenge_id, 'inventory_digest': inventory_digest,
58
+ 'source': source, 'target': target, 'method': method,
59
+ 'plaintext_digest': plaintext_digest, 'request_id': request_id}
60
+
61
+
62
+ def keyed_source(item, details):
63
+ return dict(item, key_id=details.get('source_keys', {}).get(
64
+ item['revision'], details['old_encryption_key_id']))
65
+
66
+
67
+ def keyed_target(mapping, details):
68
+ return dict(mapping['new'], key_id=details['new_encryption_key_id'],
69
+ recipient=mapping['recipient'])
70
+
71
+
72
+ async def owned_age_inventory(tx, subject):
73
+ """All retained owned keystore age revisions, not just current revisions."""
74
+ folder = tx.one("SELECT id FROM resources WHERE parent=? AND name='keystore'", (subject,))
75
+ if folder is None:
76
+ return []
77
+ result = []
78
+ for rid, revision_id in tx.rows('''SELECT r.id,v.id FROM revisions v
79
+ JOIN resources r ON r.id=v.resource_id WHERE r.parent=? AND r.owner=?
80
+ AND r.type='keystore' ORDER BY r.id,v.id''', (folder[0], subject)):
81
+ if tx.setting('keystore_format:' + revision_id) == 'age':
82
+ revision = await tx.revision(ResourceRef(id=rid, revision=revision_id))
83
+ result.append({'id': rid, 'revision': revision_id,
84
+ 'ciphertext_digest': revision.content.digest})
85
+ return result
86
+
87
+
88
+ def output_refs(details):
89
+ """Outputs are not new source work; direct new-key writes remain sources."""
90
+ result = [m['new'] for m in details.get('rewrap_mappings', {}).values()
91
+ if m.get('copy_kind') != 'new_key_write']
92
+ envelope = details.get('recovery_envelope')
93
+ if envelope is not None:
94
+ result.append(envelope['ciphertext'])
95
+ return result
96
+
97
+
98
+ def retirement_view(details, vault, *, identity_switched, subject_id=None, backup=None,
99
+ backup_status='requires_offline_verification'):
100
+ """An absent online key cannot prove that historical backups lost that key."""
101
+ signing_deleted = bool(vault and vault[1] is None and vault[2] is None)
102
+ encryption_deleted = bool(vault and vault[3] is None and vault[4] is None)
103
+ # Only a locally imported, root-signed record verified by
104
+ # msg.security.backup_retirement may set this field. The network migration
105
+ # API never writes or accepts one; details['backup_retirement'] is ignored.
106
+ retired = isinstance(backup, BackupRetirement) and backup.binds(subject_id, details)
107
+ online = bool(identity_switched and vault and vault[0] == 'destroyed' and
108
+ signing_deleted and encryption_deleted)
109
+ return {'identity_switched': identity_switched,
110
+ 'token_revoked': identity_switched,
111
+ 'online_signing_key_deleted': signing_deleted,
112
+ 'online_encryption_key_deleted': encryption_deleted,
113
+ 'backup_retired': retired,
114
+ 'backup_status': 'local_attestation_verified' if retired else backup_status,
115
+ 'backup_retirement_record': backup.view() if retired else None,
116
+ 'server_key_retired': retired and online}
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class MigrationSnapshot:
121
+ data: dict
122
+
123
+ @property
124
+ def inventory_digest(self):
125
+ return self.data['inventory_digest']
126
+
127
+ def unchanged(self):
128
+ require(not self.data['inventory_changed'], 'custodial_inventory_changed')
129
+
130
+ def require_observation(self, arguments):
131
+ require(arguments.get('inventory_digest') == self.inventory_digest and
132
+ arguments.get('results_digest') == self.data['results_digest'] and
133
+ arguments.get('observed_digest') == self.data['observed_digest'],
134
+ 'custodial_migration_preview_stale')
135
+
136
+
137
+ async def verify_challenge_binding(tx, subject, status, challenge, details):
138
+ """Verify the original challenge even when there are no history ACKs.
139
+
140
+ The retired signing credential remains a historical verifier, not current
141
+ authority. A mutable copy of a key ID in the migration body is not a proof.
142
+ """
143
+ try:
144
+ public = unb64(challenge['public_key'], limit=32)
145
+ old = await tx.credential(details['old_identity_key_id'])
146
+ require(challenge['subject_id'] == subject and old.subject_id == subject and
147
+ old.kind == 'signing_key' and key_id(old.verifier) == old.id and
148
+ key_id(public) == details['new_identity_key_id'] and
149
+ old.id != details['new_identity_key_id'] and
150
+ encryption_key_id(public_from_recipient(challenge['encryption_recipient'])) ==
151
+ details['new_encryption_key_id'] and
152
+ details['old_encryption_key_id'] != details['new_encryption_key_id'],
153
+ 'custodial_challenge_invalid')
154
+ verify(old.verifier, canonical(challenge), decode(Signature, details['server_signature']),
155
+ purpose='custodial-upgrade-challenge')
156
+ old_age = tx.one('SELECT subject,recipient,public_key FROM encryption_subkeys WHERE key_id=?',
157
+ (details['old_encryption_key_id'],))
158
+ require(old_age is not None and old_age[0] == subject and
159
+ encryption_key_id(public_from_recipient(old_age[1])) == details['old_encryption_key_id'] and
160
+ unb64(old_age[2], limit=32) == public_from_recipient(old_age[1]),
161
+ 'custodial_challenge_invalid')
162
+ except (Failure, KeyError, TypeError, ValueError) as exc:
163
+ raise Failure('custodial_challenge_invalid') from exc
164
+ if status != 'completed':
165
+ require(old.revoked_at is None, 'custodial_signing_key_retired')
166
+ retained = tx.one('SELECT status FROM custodial_vault WHERE subject=?', (subject,))
167
+ if status == 'pending_rewrap' or (status == 'completed' and retained == ('decrypt_only',)):
168
+ target = tx.one('SELECT subject,recipient,is_primary,retired_at FROM encryption_subkeys '
169
+ 'WHERE key_id=?', (details['new_encryption_key_id'],))
170
+ require(target == (subject, challenge['encryption_recipient'], 1, None),
171
+ 'custodial_encryption_target_changed')
172
+
173
+
174
+ async def snapshot(tx, subject, status, challenge, details, *, now=None):
175
+ """Without a trusted clock a backup attestation cannot be current, so it is not used."""
176
+ await verify_challenge_binding(tx, subject, status, challenge, details)
177
+ frozen = details.get('age_inventory')
178
+ require(isinstance(frozen, list), 'custodial_inventory_required')
179
+ require(frozen == ordered(frozen) and len({i['revision'] for i in frozen}) == len(frozen),
180
+ 'custodial_inventory_corrupt')
181
+ current = await owned_age_inventory(tx, subject)
182
+ outputs = output_refs(details)
183
+ current_sources = [item for item in current if item not in outputs]
184
+ expected = ordered(frozen + [item for item in outputs if item not in frozen])
185
+ added = [item for item in current_sources if item not in frozen]
186
+ missing = [item for item in frozen if item not in current]
187
+ drift = current != expected
188
+ commitment = inventory_commitment(subject, details, challenge)
189
+ mappings = details.get('rewrap_mappings', {})
190
+ acks = details.get('rewrap_acks', {})
191
+ recovery_acks = details.get('recovery_acks', {})
192
+ envelope = details.get('recovery_envelope')
193
+ sources = {item['revision']: item for item in frozen}
194
+ require(set(mappings) <= sources.keys() and set(acks) <= sources.keys() and
195
+ set(recovery_acks) <= sources.keys(), 'custodial_mapping_corrupt')
196
+ targets = set()
197
+ verified, legacy_acked, methods = set(), set(), {}
198
+ for revision, mapping in mappings.items():
199
+ source = sources[revision]
200
+ require(mapping['old'] == source and
201
+ mapping['recipient'] == challenge['encryption_recipient'] and
202
+ mapping.get('old_key_id', keyed_source(source, details)['key_id']) ==
203
+ keyed_source(source, details)['key_id'] and
204
+ mapping.get('new_key_id', details['new_encryption_key_id']) ==
205
+ details['new_encryption_key_id'], 'custodial_mapping_key_mismatch')
206
+ ref = mapping['new']
207
+ marker = (ref['id'], ref['revision'])
208
+ require(marker not in targets, 'custodial_mapping_target_reused')
209
+ targets.add(marker)
210
+ if ref not in current or source in missing:
211
+ continue
212
+ ack = acks.get(revision)
213
+ if ack is None:
214
+ continue
215
+ require(ack['new_revision'] == ref['revision'] and
216
+ ack['ciphertext_digest'] == ref['ciphertext_digest'],
217
+ 'custodial_rewrap_mapping_mismatch')
218
+ legacy_acked.add(revision)
219
+ if ack.get('inventory_digest') != commitment:
220
+ continue
221
+ body = ack_statement(subject, challenge['challenge_id'], commitment,
222
+ keyed_source(source, details), keyed_target(mapping, details),
223
+ ack['plaintext_digest'], ack['request_id'])
224
+ verify(unb64(challenge['public_key']), canonical(body), decode(Signature, ack['signature']),
225
+ purpose='custodial-history-ack-v1')
226
+ verified.add(revision)
227
+ methods[revision] = 'rewrap'
228
+ if envelope is not None:
229
+ require(envelope['subject_id'] == subject and
230
+ envelope['old_key_id'] == details['old_encryption_key_id'] and
231
+ envelope['new_key_id'] == details['new_encryption_key_id'] and
232
+ envelope['recipient'] == challenge['encryption_recipient'] and
233
+ envelope['purpose'] == 'retired-encryption-subkey-recovery',
234
+ 'custodial_recovery_envelope_mismatch')
235
+ if envelope['ciphertext'] in current:
236
+ for revision, ack in recovery_acks.items():
237
+ if sources[revision] in missing or ack.get('inventory_digest') != commitment:
238
+ continue
239
+ require(keyed_source(sources[revision], details)['key_id'] == envelope['old_key_id'],
240
+ 'custodial_recovery_key_mismatch')
241
+ body = ack_statement(subject, challenge['challenge_id'], commitment,
242
+ keyed_source(sources[revision], details), envelope,
243
+ ack['plaintext_digest'], ack['request_id'], method='compatibility_recovery')
244
+ verify(unb64(challenge['public_key']), canonical(body),
245
+ decode(Signature, ack['signature']), purpose='custodial-history-ack-v1')
246
+ verified.add(revision)
247
+ methods[revision] = 'compatibility_recovery'
248
+ policy_row = tx.one('SELECT body FROM recovery_policies WHERE subject=? ORDER BY version DESC LIMIT 1',
249
+ (subject,))
250
+ policy = loads(policy_row[0]) if policy_row else {'version': 0, 'opted_in': False}
251
+ vault = tx.one('''SELECT status,signing_nonce,signing_ciphertext,age_nonce,age_ciphertext
252
+ FROM custodial_vault WHERE subject=?''', (subject,))
253
+ switched = status == 'completed'
254
+ backup_status, backup = await verified_backup(tx, subject, details, now=now) if switched \
255
+ else ('unavailable', None)
256
+ retirement = retirement_view(details, vault, identity_switched=switched, subject_id=subject,
257
+ backup=backup, backup_status='requires_offline_verification' if backup_status == 'unavailable'
258
+ else 'local_attestation_' + backup_status)
259
+ complete = not drift and verified == set(sources)
260
+ phase = MigrationPhase.CHALLENGE if status == 'pending' else MigrationPhase.MIGRATING
261
+ if switched:
262
+ phase = MigrationPhase.HISTORY_RECOVERABLE if complete else MigrationPhase.IDENTITY_SWITCHED
263
+ if retirement['online_signing_key_deleted'] and retirement['online_encryption_key_deleted']:
264
+ phase = MigrationPhase.ONLINE_KEY_RETIRED
265
+ if retirement['server_key_retired']:
266
+ phase = MigrationPhase.SERVER_KEY_RETIRED
267
+ results = {'mappings': mappings, 'acks': acks, 'recovery_envelope': envelope,
268
+ 'recovery_acks': recovery_acks, 'resolution': details.get('resolution')}
269
+ return MigrationSnapshot({'challenge_id': challenge['challenge_id'], 'status': status,
270
+ 'phase': str(phase), 'recipient': challenge['encryption_recipient'],
271
+ 'old_key_id': details['old_encryption_key_id'], 'new_key_id': details['new_encryption_key_id'],
272
+ 'frozen': frozen, 'sources': [keyed_source(item, details) for item in frozen],
273
+ 'frozen_digest': digest(frozen), 'inventory_digest': commitment,
274
+ 'inventory_version': details.get('inventory_version', 1),
275
+ 'observed_digest': digest(current), 'results_digest': digest(results),
276
+ 'mappings': mappings, 'acks': acks, 'recovery_envelope': envelope,
277
+ 'recovery_acks': recovery_acks, 'methods': methods,
278
+ 'inventory_changed': drift, 'delta': {'added': added, 'missing': missing},
279
+ 'mapped_count': len(mappings), 'acked_count': len(legacy_acked),
280
+ 'verified_revisions': sorted(verified),
281
+ 'unresolved_revisions': sorted(set(sources) - verified),
282
+ 'known_ciphertexts_migrated': not drift and legacy_acked == set(sources),
283
+ 'history_recoverable': complete,
284
+ 'verification_scope': 'enumerated_and_client_verified_revisions_only',
285
+ 'recovery_policy_version': policy['version'],
286
+ 'recovery_policy_requires_review': bool(policy.get('opted_in')),
287
+ 'external_ciphertexts_migrated_owner_claim': details.get('external_ciphertexts_migrated', False),
288
+ 'external_ciphertexts_verified': False,
289
+ 'historical_revisions_require_migration': not complete,
290
+ 'finalize_ready': complete and not policy.get('opted_in'),
291
+ 'vault_remains_active': bool(vault and vault[0] == 'active'),
292
+ 'retirement': retirement})