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,146 @@
1
+ """Read-only evidence for a bounded custody migration, never retirement authority.
2
+
3
+ A valid ACK is the client's signed declaration that it decrypted one mapped copy.
4
+ It cannot prove that no external ciphertext, recipient or recoverable backup exists.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from msg.core.codec import canonical, decode, unb64
9
+ from msg.core.errors import Failure
10
+ from msg.core.models import Signature
11
+ from msg.security.age_keys import encryption_key_id, public_from_recipient
12
+ from msg.security.backup_retirement import verified as verified_backup
13
+ from msg.security.crypto import key_id, verify
14
+
15
+
16
+ def _sha256(value):
17
+ return (isinstance(value, str) and len(value) == 71 and
18
+ value.startswith('sha256:') and
19
+ all(char in '0123456789abcdef' for char in value[7:]))
20
+
21
+
22
+ def _entry(value):
23
+ return (isinstance(value, dict) and set(value) == {'id', 'revision', 'ciphertext_digest'}
24
+ and all(isinstance(value[key], str) and value[key] for key in ('id', 'revision'))
25
+ and _sha256(value['ciphertext_digest']))
26
+
27
+
28
+ def history_evidence(subject_id, challenge_id, challenge, details, current):
29
+ """Reverify persisted ACKs, not merely their presence or cached success flags.
30
+
31
+ Legacy mappings bind the keys through the immutable challenge. New mappings
32
+ additionally carry the exact old/new key IDs; a conflicting binding is denied.
33
+ Unknown or malformed records stay pending and are not repaired by this read.
34
+ """
35
+ frozen = details.get('age_inventory')
36
+ mappings = details.get('rewrap_mappings', {})
37
+ acks = details.get('rewrap_acks', {})
38
+ result = {'verification_scope': 'enumerated_keystore_age_revisions',
39
+ 'external_coverage': 'unknown', 'inventory_changed': True,
40
+ 'mapped_count': len(mappings) if isinstance(mappings, dict) else 0,
41
+ 'acked_count': len(acks) if isinstance(acks, dict) else 0,
42
+ 'verified_acked_count': 0, 'known_ciphertexts_migrated': False,
43
+ 'historical_revisions_require_migration': True}
44
+ try:
45
+ if not (isinstance(frozen, list) and all(_entry(item) for item in frozen)
46
+ and isinstance(mappings, dict) and isinstance(acks, dict)
47
+ and isinstance(current, list) and all(_entry(item) for item in current)):
48
+ return result
49
+ sources = {item['revision']: item for item in frozen}
50
+ if len(sources) != len(frozen):
51
+ return result
52
+ public = unb64(challenge['public_key'], limit=32)
53
+ if (challenge['subject_id'] != subject_id or challenge['challenge_id'] != challenge_id
54
+ or key_id(public) != details['new_identity_key_id']
55
+ or encryption_key_id(public_from_recipient(challenge['encryption_recipient']))
56
+ != details['new_encryption_key_id']):
57
+ return result
58
+ copies = []
59
+ for revision, mapping in mappings.items():
60
+ if not (isinstance(mapping, dict) and mapping.get('old') == sources.get(revision)
61
+ and revision in sources and _entry(mapping.get('new'))
62
+ and mapping['new']['revision'] != revision
63
+ and mapping.get('recipient') == challenge['encryption_recipient']
64
+ and mapping.get('old_encryption_key_id', details['old_encryption_key_id'])
65
+ == details['old_encryption_key_id']
66
+ and mapping.get('new_encryption_key_id', details['new_encryption_key_id'])
67
+ == details['new_encryption_key_id']):
68
+ return result
69
+ copies.append(mapping['new'])
70
+ if len({item['revision'] for item in copies}) != len(copies):
71
+ return result
72
+ expected = sorted(frozen + copies, key=lambda item: (item['id'], item['revision']))
73
+ result['inventory_changed'] = current != expected
74
+ verified = 0
75
+ for revision, ack in acks.items():
76
+ mapping = mappings.get(revision)
77
+ if not (mapping is not None and isinstance(ack, dict)
78
+ and ack.get('old_revision') == revision
79
+ and ack.get('new_revision') == mapping['new']['revision']
80
+ and ack.get('ciphertext_digest') == mapping['new']['ciphertext_digest']
81
+ and _sha256(ack.get('plaintext_digest'))
82
+ and isinstance(ack.get('request_id'), str) and ack['request_id']):
83
+ continue
84
+ signed = {'subject_id': subject_id, 'challenge_id': challenge_id,
85
+ 'old': mapping['old'], 'new': mapping['new'],
86
+ 'plaintext_digest': ack['plaintext_digest'], 'request_id': ack['request_id']}
87
+ try:
88
+ verify(public, canonical(signed), decode(Signature, ack['signature']),
89
+ purpose='custodial-rewrap-ack')
90
+ except (Failure, KeyError, TypeError, ValueError):
91
+ continue
92
+ verified += 1
93
+ result['verified_acked_count'] = verified
94
+ complete = (set(mappings) == set(acks) == set(sources)
95
+ and verified == len(sources) and not result['inventory_changed'])
96
+ result['known_ciphertexts_migrated'] = complete
97
+ result['historical_revisions_require_migration'] = not complete
98
+ except (Failure, KeyError, TypeError, ValueError):
99
+ # Persisted damaged state is not successful evidence, nor a reason to
100
+ # expose ciphertext/secret details through an exception message.
101
+ pass
102
+ return result
103
+
104
+
105
+ async def retirement_evidence(tx, subject_id, details, *, history_recoverable, now=None):
106
+ """Separate observable online retirement from locally attested backup retirement.
107
+
108
+ backup_retired needs one exact root-signed record imported at the physical
109
+ console; server_key_retired additionally needs observable online retirement.
110
+ """
111
+ new_signing = tx.one('SELECT key_id FROM identity_keys WHERE subject=? AND is_primary=1',
112
+ (subject_id,))
113
+ new_encryption = tx.one('SELECT key_id FROM encryption_subkeys WHERE subject=? AND is_primary=1',
114
+ (subject_id,))
115
+ switched = (new_signing is not None and new_encryption is not None
116
+ and new_signing[0] == details.get('new_identity_key_id')
117
+ and new_encryption[0] == details.get('new_encryption_key_id'))
118
+ vault = tx.one('''SELECT status,signing_nonce,signing_ciphertext,age_nonce,age_ciphertext,
119
+ destroyed_at,signing_key_id,encryption_key_id FROM custodial_vault WHERE subject=?''',
120
+ (subject_id,))
121
+ destroyed = (vault is not None and vault[0] == 'destroyed' and vault[5] is not None
122
+ and all(value is None for value in vault[1:5])
123
+ and vault[6] == details.get('old_identity_key_id')
124
+ and vault[7] == details.get('old_encryption_key_id'))
125
+ old_signing = tx.one('SELECT retired_at,is_primary FROM identity_keys WHERE subject=? AND key_id=?',
126
+ (subject_id, details.get('old_identity_key_id')))
127
+ old_encryption = tx.one('SELECT retired_at,is_primary FROM encryption_subkeys WHERE subject=? AND key_id=?',
128
+ (subject_id, details.get('old_encryption_key_id')))
129
+ try:
130
+ credential = await tx.credential(details.get('old_identity_key_id'))
131
+ signing_revoked = credential.subject_id == subject_id and credential.revoked_at is not None
132
+ except Failure:
133
+ signing_revoked = False
134
+ online = (switched and destroyed and signing_revoked and
135
+ all(row is not None and row[0] is not None and not row[1]
136
+ for row in (old_signing, old_encryption)))
137
+ status, attestation = await verified_backup(tx, subject_id, details, now=now)
138
+ backup = attestation is not None and attestation.binds(subject_id, details)
139
+ server = online and backup
140
+ return {'identity_switched': switched, 'history_recoverable': history_recoverable,
141
+ 'online_retired': online, 'backup_retired': backup, 'server_key_retired': server,
142
+ 'backup_retirement_evidence': status if not backup else 'local_attestation_verified',
143
+ 'backup_retirement_record': attestation.view() if backup else None,
144
+ 'completion_status': ('server_key_retired' if server and history_recoverable
145
+ else 'pending_backup_retirement' if online and history_recoverable
146
+ else 'pending_history' if switched else 'pending_identity_switch')}
@@ -0,0 +1,81 @@
1
+ """Network policies are intersected; parsing or DNS never grants authority."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ import ipaddress
6
+ import re
7
+ from urllib.parse import urlsplit
8
+
9
+ from msg.core.errors import Failure,require
10
+
11
+
12
+ def normalized_host(host):
13
+ require(isinstance(host,str) and host and len(host)<=253 and '%' not in host and
14
+ not any(ord(c)<=32 or ord(c)==127 for c in host),'invalid_host')
15
+ host=host.rstrip('.').lower()
16
+ try:
17
+ return str(ipaddress.ip_address(host))
18
+ except ValueError:
19
+ try:
20
+ value=host.encode('idna').decode('ascii')
21
+ except UnicodeError as exc:
22
+ raise Failure('invalid_host') from exc
23
+ require(all(re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?',part) for part in value.split('.')),'invalid_host')
24
+ return value
25
+
26
+
27
+ def validate_url(url,method,policy):
28
+ require(type(url) is str and 0<len(url)<=8192 and '\\' not in url and
29
+ all(ord(c)>32 and ord(c)!=127 for c in url),'invalid_network_url')
30
+ try:
31
+ parsed=urlsplit(url)
32
+ require(parsed.scheme in policy.schemes and parsed.scheme in {'http','https'},'network_scheme_forbidden')
33
+ require(parsed.hostname is not None and parsed.username is None and parsed.password is None and not parsed.fragment,
34
+ 'invalid_network_url')
35
+ host=normalized_host(parsed.hostname)
36
+ port=parsed.port or (443 if parsed.scheme=='https' else 80)
37
+ except ValueError as exc:
38
+ if isinstance(exc,Failure):raise
39
+ raise Failure('invalid_network_url') from exc
40
+ require(port in policy.ports,'network_port_forbidden')
41
+ require(method in policy.methods,'network_method_forbidden')
42
+ require(not policy.hosts or host in {normalized_host(h) for h in policy.hosts},'network_host_forbidden')
43
+ return parsed
44
+
45
+
46
+ def validate_addresses(addresses,policy):
47
+ require(bool(addresses),'dns_no_address')
48
+ results=[]
49
+ for value in addresses:
50
+ require('%' not in value,'invalid_network_target')
51
+ try:
52
+ ip=ipaddress.ip_address(value)
53
+ except ValueError as exc:
54
+ raise Failure('invalid_network_target') from exc
55
+ actual=ip.ipv4_mapped if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped else ip
56
+ if not policy.allow_private:
57
+ require(actual.is_global and not actual.is_multicast,'private_target_forbidden')
58
+ require(not actual.is_unspecified and not actual.is_multicast,'invalid_network_target')
59
+ results.append(value)
60
+ return results
61
+
62
+
63
+ def intersect_policy(policy,constraints):
64
+ require(set(constraints)<={'hosts','schemes','ports','methods','timeout_ms','max_response_bytes','max_redirects'},
65
+ 'unknown_network_constraint')
66
+ values={}
67
+ for field in ('hosts','schemes','ports','methods'):
68
+ if field not in constraints:continue
69
+ supplied=set(constraints[field])
70
+ if field=='hosts':
71
+ supplied={normalized_host(h) for h in supplied}
72
+ current=set(getattr(policy,field))
73
+ narrowed=supplied if field=='hosts' and not current else current&supplied
74
+ require(bool(narrowed),'network_policy_empty')
75
+ values[field]=tuple(sorted(narrowed)) if field=='hosts' else frozenset(narrowed)
76
+ for field in ('timeout_ms','max_response_bytes','max_redirects'):
77
+ if field in constraints:
78
+ value=constraints[field]
79
+ require(type(value) is int and value>=(0 if field=='max_redirects' else 1),'invalid_network_limit')
80
+ values[field]=min(value,getattr(policy,field))
81
+ return replace(policy,**values)
msg/security/policy.py ADDED
@@ -0,0 +1,67 @@
1
+ """Small pure mode/scope primitives. Certificate rights never replace entry checks."""
2
+ from __future__ import annotations
3
+ from msg.core.errors import require
4
+
5
+ READ,WRITE,EXECUTE=4,2,1
6
+ CERTGATE,SETGID,STICKY=0o4000,0o2000,0o1000
7
+
8
+
9
+ def selected_class(resource,subject,memberships):
10
+ if subject is not None and subject==resource.owner:
11
+ return 'owner'
12
+ if subject is not None and resource.group in memberships:
13
+ return 'group'
14
+ return 'other'
15
+
16
+
17
+ def class_bits(mode,permission_class):
18
+ require(type(mode) is int and 0<=mode<=0o7777,'invalid_mode')
19
+ return (mode>>{'owner':6,'group':3,'other':0}[permission_class])&7
20
+
21
+
22
+ def allows(resource,subject,memberships,permission):
23
+ mask={'read':READ,'write':WRITE,'execute':EXECUTE,'list':READ,'traverse':EXECUTE}[permission]
24
+ return class_bits(resource.mode,selected_class(resource,subject,memberships))&mask==mask
25
+
26
+
27
+ def inherit_group(parent,primary_group):
28
+ return parent.group if parent.mode&SETGID else primary_group
29
+
30
+
31
+ def format_mode(mode):
32
+ require(type(mode) is int and 0<=mode<=0o7777,'invalid_mode')
33
+ return f'{mode:04o}'
34
+
35
+
36
+ async def scope_contains(scope,resource_id,session):
37
+ if scope.resource_id==resource_id:
38
+ return True
39
+ return scope.descendants and any(r.id==scope.resource_id for r in await session.ancestors(resource_id))
40
+
41
+
42
+ async def scope_subset(child,parent,session):
43
+ if child.resource_id==parent.resource_id:
44
+ return not child.descendants or parent.descendants
45
+ return parent.descendants and await scope_contains(parent,child.resource_id,session)
46
+
47
+
48
+ async def grant_covers(grant,capability,operation,resource_id,session):
49
+ return (grant.capability==capability and grant.version==1 and operation in grant.operations
50
+ and await scope_contains(grant.scope,resource_id,session))
51
+
52
+
53
+ def constraints_subset(child,parent):
54
+ """Lists narrow allowlists; upper bounds may only decrease. Unknown keys reject upstream."""
55
+ for name,value in parent.items():
56
+ if name not in child:
57
+ return False
58
+ proposed=child[name]
59
+ if name in {'hosts','methods','ports','schemes'}:
60
+ if not set(proposed)<=set(value):
61
+ return False
62
+ elif name in {'max_response_bytes','timeout_ms','max_redirects'}:
63
+ if type(proposed) is not int or proposed>value:
64
+ return False
65
+ elif proposed!=value:
66
+ return False
67
+ return True
@@ -0,0 +1,19 @@
1
+ """A restored snapshot is not current authority until a local recovery is proven.
2
+
3
+ The gate is a database fact, not a deletable config marker. No network operation
4
+ may clear it. Absence alone means an ordinary installation; malformed, false or
5
+ unknown values fail closed. This is containment, not a revocation replay proof.
6
+ """
7
+ from msg.core.errors import require
8
+
9
+ SETTING = 'recovery_quarantine'
10
+
11
+
12
+ def active(session):
13
+ return session.one('SELECT value FROM settings WHERE key=?', (SETTING,)) is not None
14
+
15
+
16
+ def require_live_authority(session):
17
+ # Even anonymous access to formerly public content may revive an ACL that
18
+ # was revoked after the snapshot. Only the transport health probe remains.
19
+ require(not active(session), 'recovery_quarantined')
@@ -0,0 +1,67 @@
1
+ """Descriptor-checked private Root files and a nonblocking local rotation lock.
2
+
3
+ These helpers confer no Root authority. RootAdmin must still enforce the real
4
+ OS-console/PIN ceremony; isolated tests exercise only the internal file use case.
5
+ """
6
+ import fcntl
7
+ import os
8
+ import stat
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+ from msg.core.errors import Failure, require
13
+
14
+
15
+ def _private_descriptor(fd):
16
+ info = os.fstat(fd)
17
+ require(stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid() and
18
+ stat.S_IMODE(info.st_mode) == 0o600 and info.st_nlink == 1,
19
+ 'unsafe_root_private_file')
20
+ return info
21
+
22
+
23
+ def read_private(path, *, limit=4 * 1024 * 1024):
24
+ try:
25
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
26
+ except OSError as exc:
27
+ raise Failure('unsafe_root_private_file') from exc
28
+ try:
29
+ before = _private_descriptor(fd)
30
+ require(0 < before.st_size <= limit, 'unsafe_root_private_file')
31
+ chunks, count = [], 0
32
+ while chunk := os.read(fd, min(65536, limit + 1 - count)):
33
+ count += len(chunk)
34
+ require(count <= limit, 'unsafe_root_private_file')
35
+ chunks.append(chunk)
36
+ after = os.fstat(fd)
37
+ require(before.st_size == count == after.st_size and
38
+ before.st_mtime_ns == after.st_mtime_ns and before.st_ctime_ns == after.st_ctime_ns,
39
+ 'root_private_file_changed')
40
+ return b''.join(chunks)
41
+ finally:
42
+ os.close(fd)
43
+
44
+
45
+ @contextmanager
46
+ def rotation_lock(protected):
47
+ protected = Path(protected)
48
+ require(not protected.is_symlink(), 'unsafe_root_private_path')
49
+ protected.mkdir(mode=0o700, parents=True, exist_ok=True)
50
+ info = protected.stat()
51
+ require(stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() and
52
+ stat.S_IMODE(info.st_mode) == 0o700, 'unsafe_root_private_path')
53
+ try:
54
+ fd = os.open(protected / '.rotation.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW |
55
+ os.O_NONBLOCK | os.O_CLOEXEC, 0o600)
56
+ except OSError as exc:
57
+ raise Failure('unsafe_root_private_file') from exc
58
+ try:
59
+ _private_descriptor(fd)
60
+ try:
61
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
62
+ except BlockingIOError as exc:
63
+ raise Failure('root_rotation_busy', retryable=True) from exc
64
+ yield
65
+ finally:
66
+ fcntl.flock(fd, fcntl.LOCK_UN)
67
+ os.close(fd)
@@ -0,0 +1,60 @@
1
+ """Validate a locally approved root rotation without loading a network service."""
2
+ from msg.constants import ROOT_SUBJECT, ONLINE_CA
3
+ from msg.core.codec import canonical, decode, digest, unb64
4
+ from msg.core.errors import require
5
+ from msg.core.models import Certificate, CertificateRequest, Signature
6
+ from msg.security.certificates import certificate_body, verify_csr
7
+ from msg.security.crypto import Ed25519Signer, open_private_key, verify
8
+
9
+ _FIELDS = {'version', 'old_certificate', 'new_trust', 'new_envelope', 'online_csr',
10
+ 'previous_envelope_digest', 'statement', 'new_signature', 'old_signature'}
11
+
12
+
13
+ def authorization(journal):
14
+ """Public, signed commitments; never copy the encrypted root into an audit."""
15
+ return {'version': journal['version'], 'statement': journal['statement'],
16
+ 'old_certificate_digest': digest(journal['old_certificate']),
17
+ 'new_trust_digest': digest(journal['new_trust']),
18
+ 'new_envelope_digest': digest(journal['new_envelope']),
19
+ 'online_csr_digest': digest(journal['online_csr']),
20
+ 'previous_envelope_digest': journal['previous_envelope_digest']}
21
+
22
+
23
+ def validate(journal, *, pin, service, online_public):
24
+ require(isinstance(journal, dict) and journal.get('version') == 2,
25
+ 'rotation_journal_upgrade_required')
26
+ require(set(journal) == _FIELDS, 'invalid_rotation_journal')
27
+ signer = Ed25519Signer.from_bytes(open_private_key(journal['new_envelope'], pin))
28
+ trust = journal['new_trust']
29
+ require(set(trust) == {'version', 'public_key', 'certificate'} and trust['version'] == 1,
30
+ 'invalid_trust_anchor')
31
+ require(signer.public_key == unb64(trust['public_key'], limit=32), 'root_key_mismatch')
32
+ proof = authorization(journal)
33
+ verify(signer.public_key, canonical(proof), decode(Signature, journal['new_signature']),
34
+ purpose='root-rotation')
35
+ old = decode(Certificate, journal['old_certificate'])
36
+ new = decode(Certificate, trust['certificate'])
37
+ statement = journal['statement']
38
+ require(statement['old_certificate'] == old.resource_id and
39
+ statement['new_certificate'] == new.resource_id and
40
+ statement['new_fingerprint'] == digest(signer.public_key) and
41
+ type(statement['previous_key_proved']) is bool and
42
+ statement['previous_key_proved'] == (journal['old_signature'] is not None),
43
+ 'rotation_statement_mismatch')
44
+ require(old.resource_id != new.resource_id and old.key_id != new.key_id,
45
+ 'rotation_keys_must_change')
46
+ require(new.key_id == signer.key_id and new.subject_id == new.issuer_id == ROOT_SUBJECT and
47
+ new.parent_certificate_id is None and new.kind == 'ca' and
48
+ new.issuance is not None and not new.authority_sources and
49
+ old.subject_id == ROOT_SUBJECT and
50
+ new.target_service == old.target_service == service, 'rotation_service_mismatch')
51
+ verify(signer.public_key, canonical(certificate_body(new)), new.signature, purpose='certificate')
52
+ csr = decode(CertificateRequest, journal['online_csr'])
53
+ verify_csr(csr)
54
+ require(csr.public_key == online_public and csr.subject_id == csr.applicant == ONLINE_CA and
55
+ csr.requested_issuer == ROOT_SUBJECT and csr.target_service == service and
56
+ csr.kind == 'ca' and csr.delegation_depth == 0 and not csr.authority_sources and
57
+ csr.issuance is not None and csr.issuance.max_child_ca_depth == 0 and
58
+ len(csr.grants) == 1 and csr.grants[0].capability == 'cert.issue',
59
+ 'rotation_online_csr_mismatch')
60
+ return signer, old, new, csr, proof
@@ -0,0 +1,70 @@
1
+ """Client-side X25519 + HKDF-SHA256 + AES-256-GCM envelope.
2
+
3
+ This is a versioned msg envelope, not age, OpenPGP or a new cryptographic
4
+ primitive. Encryption keys are independent of signing keys. The server validates
5
+ only the envelope shape; it never receives a decryption key.
6
+ """
7
+ from __future__ import annotations
8
+ import os
9
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
10
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
11
+ from cryptography.hazmat.primitives import hashes
12
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13
+ from cryptography.exceptions import InvalidTag
14
+ from msg.core.codec import b64, unb64, canonical, loads
15
+ from msg.core.errors import Failure, require
16
+
17
+ FORMAT='msg-x25519-v1'
18
+
19
+
20
+ def generate_key():
21
+ key=X25519PrivateKey.generate()
22
+ return key.private_bytes_raw(),key.public_key().public_bytes_raw()
23
+
24
+
25
+ def validate_envelope(content):
26
+ value=loads(content)
27
+ require(isinstance(value,dict) and set(value)=={'format','recipient','ephemeral','salt','nonce','ciphertext'},
28
+ 'invalid_ciphertext_envelope')
29
+ require(value['format']==FORMAT,'invalid_ciphertext_format')
30
+ for name,size in (('recipient',32),('ephemeral',32),('salt',32),('nonce',12)):
31
+ require(len(unb64(value[name],limit=size))==size,'invalid_ciphertext_envelope')
32
+ require(len(unb64(value['ciphertext']))>=16,'invalid_ciphertext_envelope')
33
+ return value
34
+
35
+
36
+ def derived(shared,salt,recipient,ephemeral):
37
+ return HKDF(algorithm=hashes.SHA256(),length=32,salt=salt,
38
+ info=b'msg-keystore-v1\0'+recipient+ephemeral).derive(shared)
39
+
40
+
41
+ def encrypt(plaintext,recipient_public):
42
+ require(isinstance(plaintext,bytes) and len(recipient_public)==32,'invalid_encryption_input')
43
+ ephemeral=X25519PrivateKey.generate()
44
+ public=ephemeral.public_key().public_bytes_raw()
45
+ salt,nonce=os.urandom(32),os.urandom(12)
46
+ header={'format':FORMAT,'recipient':b64(recipient_public),'ephemeral':b64(public),
47
+ 'salt':b64(salt),'nonce':b64(nonce)}
48
+ try:
49
+ shared=ephemeral.exchange(X25519PublicKey.from_public_bytes(recipient_public))
50
+ except ValueError as exc:
51
+ raise Failure('invalid_encryption_recipient') from exc
52
+ key=derived(shared,salt,recipient_public,public)
53
+ ciphertext=AESGCM(key).encrypt(nonce,plaintext,canonical(header))
54
+ return canonical({**header,'ciphertext':b64(ciphertext)})
55
+
56
+
57
+ def decrypt(content,private):
58
+ try:
59
+ value=validate_envelope(content)
60
+ key=X25519PrivateKey.from_private_bytes(private)
61
+ recipient=key.public_key().public_bytes_raw()
62
+ require(b64(recipient)==value['recipient'],'wrong_decryption_recipient')
63
+ ephemeral=unb64(value['ephemeral'])
64
+ shared=key.exchange(X25519PublicKey.from_public_bytes(ephemeral))
65
+ derived_key=derived(shared,unb64(value['salt']),recipient,ephemeral)
66
+ header={k:v for k,v in value.items() if k!='ciphertext'}
67
+ return AESGCM(derived_key).decrypt(unb64(value['nonce']),unb64(value['ciphertext']),canonical(header))
68
+ except (ValueError,InvalidTag,KeyError,TypeError) as exc:
69
+ if isinstance(exc,Failure):raise
70
+ raise Failure('ciphertext_authentication_failed') from exc
@@ -0,0 +1,29 @@
1
+ """Shared, current-state boundaries for grants, delegated sources and links.
2
+
3
+ Only the finite ShareGrant@2 contract (read, no constraints) is accepted. These
4
+ checks do not grant an owner, a role or a certificate any additional permission.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def share_target_error(registry, resource, chain, session):
10
+ """Return the same denial at issuance and at every source revalidation."""
11
+ if resource.state != 'active' or any(item.state != 'active' for item in chain):
12
+ return 'share_forbidden_resource'
13
+ if registry.resource_type(resource.type, resource.type_version).container:
14
+ return 'share_container_forbidden'
15
+ if any(item.id in {'r_agents', 'r_rules', 't_last_will'} or item.type in {
16
+ 'tool', 'csr', 'certificate', 'credential', 'legacy_directive',
17
+ 'dm_conversation'} for item in chain):
18
+ return 'share_forbidden_resource'
19
+ if any(parent.type == 'user' and child.name in {'SOUL.md', 'AGENTS.md', 'todos'}
20
+ for parent, child in zip(chain, chain[1:])):
21
+ return 'share_forbidden_resource'
22
+ if any(session.setting('hosting_preview:' + item.id) or
23
+ session.setting('hosting_preview_file:' + item.id) for item in chain):
24
+ return 'share_forbidden_resource'
25
+ if session.one('SELECT 1 FROM dm_conversations WHERE resource_id IN (' +
26
+ ','.join('?' for _ in chain) + ') LIMIT 1',
27
+ tuple(item.id for item in chain)) is not None:
28
+ return 'share_forbidden_resource'
29
+ return None
@@ -0,0 +1,93 @@
1
+ """One-time credential delivery, distinct from service composition.
2
+
3
+ The handler records the verifier in its existing issuance transaction. After that
4
+ transaction commits, release claims the delivery in a separate write transaction;
5
+ only a committed claim can produce a response-only token. Lost responses must use
6
+ the pre-bound recovery operation, never replay a persistent secret. This module
7
+ owns no signer, vault, root capability, HTTP adapter or plugin registry.
8
+
9
+ Clock and window providers preserve the configured current values without giving
10
+ the domain access to the whole settings/Application object. These are trusted
11
+ in-process dependencies, not a Python sandbox or an additional authorization path.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import replace
17
+ from datetime import datetime, timedelta
18
+ import hashlib
19
+ import hmac
20
+
21
+ from msg.core.codec import b64, canonical, unb64, wire
22
+ from msg.core.contracts import MetadataStore
23
+ from msg.core.errors import require
24
+ from msg.core.requests import SECRET_DELIVERY_MIN_VERSION
25
+
26
+
27
+ def recovery_verifier(value):
28
+ secret=unb64(value,limit=64)
29
+ require(len(secret)>=32,'invalid_recovery_secret')
30
+ return hashlib.sha256(b'token-recovery-v1\0'+secret).hexdigest()
31
+
32
+ class TokenDelivery:
33
+ def __init__(self, *, metadata: MetadataStore, token_secret: bytes,
34
+ clock: Callable[[], datetime], recovery_window: Callable[[], int]):
35
+ require(len(token_secret)==32,'invalid_service_key')
36
+ self.metadata=metadata
37
+ self._token_secret=token_secret
38
+ self.clock=clock
39
+ self.recovery_window=recovery_window
40
+
41
+ def issued_token(self,request,subject):
42
+ # Nonces are high-entropy claims, not random tokens persisted in results.
43
+ return hmac.digest(self._token_secret,b'issued-token-v1\0'+canonical({
44
+ 'subject':subject,'request_id':request.request_id,'nonce':request.arguments['nonce']}),'sha256')
45
+
46
+ recovery_verifier=staticmethod(recovery_verifier)
47
+
48
+ def record_token_delivery(self,tx,request,credential,now,*,recovery_deadline=None):
49
+ """Bind recovery before commit; neither token nor recovery secret is stored."""
50
+ require(request.operation in SECRET_DELIVERY_MIN_VERSION and
51
+ request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation],
52
+ 'credential_delivery_upgrade_required')
53
+ field='new_recovery_secret' if request.operation=='identity.token_recover' else 'recovery_secret'
54
+ secret=unb64(request.arguments[field],limit=64)
55
+ require(secret!=unb64(request.arguments['nonce'],limit=64),'recovery_secret_not_independent')
56
+ if request.operation=='identity.token_recover':
57
+ require(secret!=unb64(request.arguments['recovery_secret'],limit=64),
58
+ 'recovery_secret_not_independent')
59
+ expires=min(credential.expires_at,
60
+ recovery_deadline if recovery_deadline is not None else
61
+ now+timedelta(seconds=self.recovery_window()))
62
+ require(expires>now,'recovery_unavailable')
63
+ tx.execute('''INSERT INTO token_deliveries
64
+ (credential_id,subject,request_id,request_digest,recovery_verifier,recovery_expires_at,
65
+ claimed_at,consumed_at) VALUES (?,?,?,?,?,?,NULL,NULL)''',
66
+ (credential.id,credential.subject_id,request.request_id,request.payload_digest,
67
+ self.recovery_verifier(request.arguments[field]),wire(expires)),write=True)
68
+
69
+ async def release(self,request,result):
70
+ if result.status!='ok' or request.operation not in SECRET_DELIVERY_MIN_VERSION:
71
+ return result
72
+ require(request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation],
73
+ 'credential_delivery_upgrade_required')
74
+ token=self.issued_token(request,result.subject)
75
+ async with self.metadata.transaction(write=True) as tx:
76
+ credential=await tx.credential(result.data['credential_id'])
77
+ require(credential.revoked_at is None and credential.expires_at>self.clock(),'credential_expired')
78
+ require(hmac.compare_digest(credential.verifier,hashlib.sha256(token).digest()),'invalid_token_result')
79
+ row=tx.one('''SELECT subject,request_id,request_digest,claimed_at,consumed_at
80
+ FROM token_deliveries WHERE credential_id=?''',(credential.id,))
81
+ require(row is not None and row[0]==result.subject and row[1]==request.request_id
82
+ and row[2]==request.payload_digest and row[4] is None,'token_delivery_unavailable',
83
+ details={'committed_result':wire(result,compact=True)})
84
+ require(row[3] is None,'token_delivery_unavailable',
85
+ details={'committed_result':wire(result,compact=True)})
86
+ updated=tx.execute('''UPDATE token_deliveries SET claimed_at=? WHERE credential_id=?
87
+ AND claimed_at IS NULL AND consumed_at IS NULL''',
88
+ (wire(self.clock()),credential.id),write=True)
89
+ require(updated.rowcount==1,'token_delivery_unavailable',
90
+ details={'committed_result':wire(result,compact=True)})
91
+ # The claim commits before the adapter sees the token. A dropped response
92
+ # must use a separate, pre-bound recovery secret to rotate the credential.
93
+ return replace(result,data=dict(result.data,token=b64(token)))