msgctl 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msg/__init__.py +2 -0
- msg/admin/__init__.py +0 -0
- msg/admin/backup_retirement.py +56 -0
- msg/admin/backups.py +341 -0
- msg/admin/custodial_check.py +77 -0
- msg/admin/diagnostics.py +750 -0
- msg/admin/market.py +91 -0
- msg/admin/market_check.py +341 -0
- msg/admin/money.py +322 -0
- msg/admin/preflight.py +142 -0
- msg/admin/recovery_replay.py +281 -0
- msg/admin/restore_database.py +37 -0
- msg/admin/root.py +347 -0
- msg/admin/rotation.py +197 -0
- msg/admin/token_delivery_check.py +54 -0
- msg/admin/upgrade_check.py +58 -0
- msg/application.py +163 -0
- msg/bootstrap.py +305 -0
- msg/cli.py +537 -0
- msg/client.py +725 -0
- msg/client_certificates.py +98 -0
- msg/client_content.py +43 -0
- msg/client_custodial.py +206 -0
- msg/client_market.py +104 -0
- msg/client_recovery.py +186 -0
- msg/client_secrets.py +58 -0
- msg/client_tokens.py +99 -0
- msg/client_upgrade.py +188 -0
- msg/config.py +318 -0
- msg/constants.py +11 -0
- msg/core/__init__.py +0 -0
- msg/core/batching.py +23 -0
- msg/core/codec.py +253 -0
- msg/core/contracts.py +128 -0
- msg/core/cursors.py +68 -0
- msg/core/email_address.py +33 -0
- msg/core/errors.py +22 -0
- msg/core/events.py +6 -0
- msg/core/execution_ports.py +20 -0
- msg/core/executor.py +193 -0
- msg/core/models.py +517 -0
- msg/core/packet.py +71 -0
- msg/core/permissions.py +14 -0
- msg/core/query.py +44 -0
- msg/core/read_query.py +60 -0
- msg/core/registry.py +173 -0
- msg/core/requests.py +58 -0
- msg/core/schema_policy.py +28 -0
- msg/core/schemas.py +30 -0
- msg/core/tags.py +21 -0
- msg/core/template_dsl.py +94 -0
- msg/core/text_patch.py +283 -0
- msg/core/tool_execution.py +28 -0
- msg/core/transfer.py +183 -0
- msg/daemon.py +255 -0
- msg/data/__init__.py +0 -0
- msg/data/bootstrap.json +72 -0
- msg/data/favicon.png +0 -0
- msg/data/logo-dark.svg +5 -0
- msg/data/logo.svg +5 -0
- msg/data/recovery-checkpoint.example.json +1 -0
- msg/data/recovery-checkpoint.schema.json +118 -0
- msg/data/shortcodes.json +1 -0
- msg/data/system/AGENTS.md +6 -0
- msg/data/system/rules/_index.md +17 -0
- msg/data/system/rules/auth.md +6 -0
- msg/data/system/rules/files.md +6 -0
- msg/data/system/rules/identity.md +6 -0
- msg/data/system/rules/protocol.md +6 -0
- msg/data/system/rules/read-write.md +6 -0
- msg/data/system/rules/recovery.md +6 -0
- msg/data/system/rules/security.md +10 -0
- msg/data/system/rules/topics.md +6 -0
- msg/extensions/__init__.py +1 -0
- msg/extensions/hosting.py +259 -0
- msg/extensions/keystore.py +96 -0
- msg/extensions/repositories.py +769 -0
- msg/extensions/rss.py +67 -0
- msg/extensions/ssh.py +264 -0
- msg/extensions/ssh_git.py +300 -0
- msg/extensions/tools.py +150 -0
- msg/hosting_runtime.py +138 -0
- msg/market/__init__.py +1 -0
- msg/market/arbitration.py +481 -0
- msg/market/delivery.py +338 -0
- msg/market/delivery_notifications.py +110 -0
- msg/market/delivery_targets.py +76 -0
- msg/market/email.py +100 -0
- msg/market/escrow.py +345 -0
- msg/market/orders.py +218 -0
- msg/market/policy.py +147 -0
- msg/market/rationale.py +91 -0
- msg/market/references.py +19 -0
- msg/market/targets.py +157 -0
- msg/plugins/__init__.py +28 -0
- msg/plugins/achievements.py +316 -0
- msg/plugins/batch.py +32 -0
- msg/plugins/bounty.py +366 -0
- msg/plugins/collaboration.py +261 -0
- msg/plugins/common.py +217 -0
- msg/plugins/communication.py +853 -0
- msg/plugins/content.py +855 -0
- msg/plugins/custodial_lifecycle.py +224 -0
- msg/plugins/delivery.py +230 -0
- msg/plugins/discovery.py +1266 -0
- msg/plugins/discussion.py +162 -0
- msg/plugins/extensions.py +10 -0
- msg/plugins/following.py +78 -0
- msg/plugins/hosting_capacity.py +52 -0
- msg/plugins/identity.py +1654 -0
- msg/plugins/money.py +221 -0
- msg/plugins/offers.py +183 -0
- msg/plugins/orders.py +273 -0
- msg/plugins/recovery.py +376 -0
- msg/plugins/schemas.py +5 -0
- msg/plugins/sharing.py +300 -0
- msg/plugins/store.py +249 -0
- msg/plugins/system.py +75 -0
- msg/plugins/transfer.py +249 -0
- msg/py.typed +0 -0
- msg/security/__init__.py +0 -0
- msg/security/age_keys.py +112 -0
- msg/security/authentication.py +141 -0
- msg/security/authorization.py +300 -0
- msg/security/backup_retirement.py +135 -0
- msg/security/capabilities.py +157 -0
- msg/security/certificates.py +183 -0
- msg/security/crypto.py +102 -0
- msg/security/custodial_migration.py +292 -0
- msg/security/custody_history.py +146 -0
- msg/security/network.py +81 -0
- msg/security/policy.py +67 -0
- msg/security/quarantine.py +19 -0
- msg/security/root_files.py +67 -0
- msg/security/rotation_journal.py +60 -0
- msg/security/sealed_box.py +70 -0
- msg/security/sharing_policy.py +29 -0
- msg/security/token_delivery.py +93 -0
- msg/security/vault.py +186 -0
- msg/storage/__init__.py +0 -0
- msg/storage/capacity.py +100 -0
- msg/storage/custodial_migration.py +22 -0
- msg/storage/git.py +435 -0
- msg/storage/ledger_migration.py +307 -0
- msg/storage/market_migration.py +49 -0
- msg/storage/postgres.py +663 -0
- msg/storage/query.py +39 -0
- msg/storage/read_only.py +126 -0
- msg/storage/session.py +403 -0
- msg/storage/sqlite.py +360 -0
- msg/storage/topic_event_migration.py +26 -0
- msg/storage/valkey_bus.py +45 -0
- msg/transports/__init__.py +0 -0
- msg/transports/client.py +183 -0
- msg/transports/dictionary.py +387 -0
- msg/transports/graphql.py +75 -0
- msg/transports/http.py +72 -0
- msg/transports/http_routes.py +1577 -0
- msg/transports/mcp.py +127 -0
- msg/transports/packet.py +42 -0
- msg/transports/read_tree_path.py +65 -0
- msg/transports/stdio.py +36 -0
- msg/transports/url_safety.py +123 -0
- msg/tui.py +368 -0
- msg/workers/__init__.py +1 -0
- msg/workers/effects.py +406 -0
- msg/workers/leases.py +19 -0
- msg/workers/mail.py +63 -0
- msg/workers/maintenance.py +310 -0
- msg/workers/sandbox.py +74 -0
- msg/workers/sandbox_child.py +189 -0
- msg/workers/webhook.py +160 -0
- msgctl-0.1.0a1.dist-info/METADATA +96 -0
- msgctl-0.1.0a1.dist-info/RECORD +176 -0
- msgctl-0.1.0a1.dist-info/WHEEL +4 -0
- msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,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})
|