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
msg/admin/root.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Local root administration; never imported by a network adapter or worker.
|
|
2
|
+
|
|
3
|
+
The underscored provisioning functions are internal local use cases. The command
|
|
4
|
+
entrypoint must pass OS-console checks before calling them. Tests call these use
|
|
5
|
+
cases only against disposable directories and never enable a network root route.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
import getpass
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from msg.constants import *
|
|
19
|
+
from msg.core.codec import b64,unb64,canonical,decode,digest,loads,wire
|
|
20
|
+
from msg.core.errors import Failure,require
|
|
21
|
+
from msg.core.models import Certificate,CertificateRequest,Credential,IssuancePolicy,Signature,ResourceRef,Event,AuditEvent
|
|
22
|
+
from msg.security.crypto import Ed25519Signer,seal_private_key,open_private_key
|
|
23
|
+
from msg.security.certificates import sign_certificate,csr_body,verify_csr
|
|
24
|
+
from msg.security.capabilities import grant_for
|
|
25
|
+
from msg.storage.git import durable_write
|
|
26
|
+
from msg.bootstrap import bootstrap,seed_resource
|
|
27
|
+
from msg.plugins.common import new_id
|
|
28
|
+
from msg.plugins.identity import certificate_resource
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def root_envelope(config_dir):
|
|
32
|
+
from msg.config import root_private_dir
|
|
33
|
+
current=root_private_dir(Path(config_dir))/'key.json'
|
|
34
|
+
legacy=Path(config_dir)/'root'/'key.json'
|
|
35
|
+
return legacy if legacy.is_file() and not current.exists() else current
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _provision(app,pin):
|
|
39
|
+
"""Initialize only an empty installation; partial state requires explicit recovery."""
|
|
40
|
+
settings=app.settings
|
|
41
|
+
protected=settings.root_private_dir
|
|
42
|
+
marker=settings.config_dir/'initialization.pending'
|
|
43
|
+
require(not root_envelope(settings.config_dir).exists() and not settings.trust_file.exists() and
|
|
44
|
+
not marker.exists(),'initialization_requires_recovery')
|
|
45
|
+
await app.open_storage()
|
|
46
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
47
|
+
require(tx.one('SELECT COUNT(*) FROM resources')[0] == 0,'initialization_requires_recovery')
|
|
48
|
+
root=Ed25519Signer.generate()
|
|
49
|
+
envelope=seal_private_key(root.private_bytes(),pin)
|
|
50
|
+
settings.config_dir.mkdir(parents=True,exist_ok=True)
|
|
51
|
+
durable_write(marker,b'1\n',mode=0o600)
|
|
52
|
+
protected.mkdir(mode=0o700,parents=True,exist_ok=True)
|
|
53
|
+
os.chmod(protected,0o700)
|
|
54
|
+
settings.service_keys.mkdir(mode=0o700,parents=True,exist_ok=True)
|
|
55
|
+
os.chmod(settings.service_keys,0o700)
|
|
56
|
+
settings.trust_file.parent.mkdir(mode=0o755,parents=True,exist_ok=True)
|
|
57
|
+
# Wrapped before touching the installation; a rejected PIN leaves no partial state.
|
|
58
|
+
durable_write(root_envelope(settings.config_dir),canonical(envelope),mode=0o600)
|
|
59
|
+
online=Ed25519Signer.generate()
|
|
60
|
+
receipt=Ed25519Signer.generate()
|
|
61
|
+
durable_write(settings.service_keys/'online.key',online.private_bytes(),mode=0o600)
|
|
62
|
+
durable_write(settings.service_keys/'receipt.key',receipt.private_bytes(),mode=0o600)
|
|
63
|
+
durable_write(settings.service_keys/'tokens.key',os.urandom(32),mode=0o600)
|
|
64
|
+
now=app.clock()
|
|
65
|
+
await bootstrap(app.metadata,app.contents,app.registry,now,
|
|
66
|
+
selftest_run_id=app.selftest_run_id)
|
|
67
|
+
root_grants=app.primary_ceiling()
|
|
68
|
+
root_certificate=Certificate(resource_id='cert_root',serial=new_id('serial'),subject_id=ROOT_SUBJECT,
|
|
69
|
+
key_id=root.key_id,issuer_id=ROOT_SUBJECT,parent_certificate_id=None,authority_sources=(),kind='ca',
|
|
70
|
+
grants=root_grants,not_before=now,expires_at=now+timedelta(days=3650),target_service=settings.service_url,
|
|
71
|
+
delegation_depth=8,issuance=IssuancePolicy(issue_grants=root_grants,max_cert_ttl_seconds=31536000,
|
|
72
|
+
max_child_ca_depth=3,max_delegation_depth=8),signature=Signature(key_id=root.key_id,algorithm='ed25519',value=b''))
|
|
73
|
+
root_certificate=sign_certificate(root_certificate,root)
|
|
74
|
+
online_use=grant_for(app.registry.capability('cert.issue'),scope=app.default_scope())
|
|
75
|
+
csr=CertificateRequest(resource_id=new_id('csr'),applicant=ONLINE_CA,subject_id=ONLINE_CA,
|
|
76
|
+
requested_issuer=ROOT_SUBJECT,public_key=online.public_key,kind='ca',grants=(online_use,),
|
|
77
|
+
issuance=IssuancePolicy(issue_grants=app.base_grants(),max_cert_ttl_seconds=settings.base_certificate_ttl,
|
|
78
|
+
max_child_ca_depth=0,max_delegation_depth=8),requested_ttl_seconds=31536000,
|
|
79
|
+
target_service=settings.service_url,delegation_depth=0,authority_sources=(),request_digest='',
|
|
80
|
+
possession_proof=Signature(key_id=online.key_id,algorithm='ed25519',value=b''))
|
|
81
|
+
csr=replace(csr,request_digest=digest(csr_body(csr)),
|
|
82
|
+
possession_proof=online.sign(canonical(csr_body(csr)),purpose='csr'))
|
|
83
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
84
|
+
for uid,signer,ceiling in ((ROOT_SUBJECT,root,()),(ONLINE_CA,online,(online_use,))):
|
|
85
|
+
await tx.save_credential(Credential(id=signer.key_id,subject_id=uid,kind='signing_key',verifier=signer.public_key,
|
|
86
|
+
ceiling=ceiling,not_before=now,expires_at=None,revoked_at=None),0)
|
|
87
|
+
for name in ('keys','certificates'):
|
|
88
|
+
await seed_resource(tx,app.contents,dict(id='r_'+digest((uid,name))[7:39],type='topic',name=name,parent=uid,
|
|
89
|
+
owner=uid,group=PUBLIC_GROUP,mode='0555'),now)
|
|
90
|
+
await certificate_resource(tx,root_certificate,now)
|
|
91
|
+
await tx.register_certificate(root_certificate,None,0)
|
|
92
|
+
await seed_resource(tx,app.contents,dict(id=csr.resource_id,type='csr',name=csr.resource_id,parent=CSR_SPACE,
|
|
93
|
+
owner=ONLINE_CA,group=ADMINS_GROUP,mode='0600'),now)
|
|
94
|
+
await tx.save_csr(csr)
|
|
95
|
+
tx.set_setting('active_root_certificate',root_certificate.resource_id)
|
|
96
|
+
tx.set_setting('online_ca_request',csr.resource_id)
|
|
97
|
+
tx.set_setting('receipt_public_key',{'key_id':receipt.key_id,'public_key':b64(receipt.public_key)})
|
|
98
|
+
durable_write(settings.trust_file,canonical({'version':1,'public_key':b64(root.public_key),
|
|
99
|
+
'certificate':wire(root_certificate)}),mode=0o444)
|
|
100
|
+
marker.unlink()
|
|
101
|
+
await app.load()
|
|
102
|
+
return csr.resource_id,root
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _approve_csr(app,csr_id,signer, *, expected_digest,operator):
|
|
106
|
+
now=app.clock()
|
|
107
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
108
|
+
csr=await tx.csr(csr_id)
|
|
109
|
+
state=await tx.csr_state(csr_id)
|
|
110
|
+
require(csr.requested_issuer==ROOT_SUBJECT,'not_root_request')
|
|
111
|
+
if expected_digest is not None:
|
|
112
|
+
require(csr.request_digest==expected_digest,'csr_changed_after_confirmation')
|
|
113
|
+
verify_csr(csr)
|
|
114
|
+
if state.status=='issued':
|
|
115
|
+
return await tx.certificate(state.certificate_id)
|
|
116
|
+
require(state.status=='pending','csr_not_pending')
|
|
117
|
+
require(signer.key_id==app.certificates.root_certificate.key_id,'root_key_mismatch')
|
|
118
|
+
parent=app.certificates.root_certificate
|
|
119
|
+
certificate=Certificate(resource_id=new_id('cert'),serial=new_id('serial'),subject_id=csr.subject_id,
|
|
120
|
+
key_id=csr.possession_proof.key_id,issuer_id=ROOT_SUBJECT,parent_certificate_id=parent.resource_id,
|
|
121
|
+
authority_sources=csr.authority_sources,kind=csr.kind,grants=csr.grants,not_before=now,
|
|
122
|
+
expires_at=min(now+timedelta(seconds=csr.requested_ttl_seconds),parent.expires_at),
|
|
123
|
+
target_service=csr.target_service,delegation_depth=csr.delegation_depth,issuance=csr.issuance,
|
|
124
|
+
signature=Signature(key_id=signer.key_id,algorithm='ed25519',value=b''))
|
|
125
|
+
certificate=sign_certificate(certificate,signer)
|
|
126
|
+
await certificate_resource(tx,certificate,now)
|
|
127
|
+
await app.certificates.validate_publication(certificate,csr,tx)
|
|
128
|
+
await tx.register_certificate(certificate,csr_id,state.generation)
|
|
129
|
+
credential=await tx.credential(certificate.key_id)
|
|
130
|
+
if not credential.ceiling:
|
|
131
|
+
subject=await tx.subject(credential.subject_id)
|
|
132
|
+
await tx.save_credential(replace(credential,ceiling=certificate.grants),subject.auth_version)
|
|
133
|
+
if csr_id==tx.setting('online_ca_request'):
|
|
134
|
+
tx.set_setting('online_ca_certificate',certificate.resource_id)
|
|
135
|
+
event=Event(id=new_id('audit'),type='root.cert.issue',time=now,request_id=csr_id,
|
|
136
|
+
actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,resources=(ResourceRef(id=certificate.resource_id),),
|
|
137
|
+
data={'operator':operator,'csr_digest':csr.request_digest,'grants':wire(certificate.grants)})
|
|
138
|
+
await tx.append_audit(AuditEvent(event=event,authority=(ResourceRef(id=parent.resource_id),),
|
|
139
|
+
before_digest=csr.request_digest,after_digest=digest(certificate),previous_digest=None,entry_digest='',result='issued'))
|
|
140
|
+
return certificate
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def require_local_console(config_dir):
|
|
144
|
+
"""OS root plus a local VT/serial console, not an arbitrary pseudo-terminal.
|
|
145
|
+
|
|
146
|
+
Remote SSH and terminal multiplexers normally use /dev/pts and cannot satisfy
|
|
147
|
+
this boundary merely by deleting SSH_* variables or supplying client=cli.
|
|
148
|
+
Deployments that need another administrative channel must use a separately
|
|
149
|
+
audited OS policy, not relax this check through the network configuration.
|
|
150
|
+
"""
|
|
151
|
+
require(hasattr(os,'geteuid') and os.geteuid()==0,'local_os_administrator_required')
|
|
152
|
+
require(not any(name in os.environ for name in ('SSH_CONNECTION','SSH_CLIENT','SSH_TTY')),'remote_admin_forbidden')
|
|
153
|
+
require(sys.stdin.isatty() and sys.stdout.isatty(),'local_console_required')
|
|
154
|
+
tty=os.path.realpath(os.ttyname(sys.stdin.fileno()))
|
|
155
|
+
require(re.fullmatch(r'/dev/(console|tty[0-9]+|ttyS[0-9]+|hvc[0-9]+)',tty) is not None,
|
|
156
|
+
'local_console_required')
|
|
157
|
+
path=Path(config_dir).resolve()
|
|
158
|
+
require(path.exists() and path.stat().st_uid==0 and not path.stat().st_mode&0o022,'unsafe_config_owner')
|
|
159
|
+
return f'uid:{os.geteuid()}:{tty}'
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RootAdmin:
|
|
163
|
+
def __init__(self,config_dir=Path('/etc/msgd')):
|
|
164
|
+
self.config_dir=Path(config_dir)
|
|
165
|
+
|
|
166
|
+
def _app(self):
|
|
167
|
+
from msg.application import Application
|
|
168
|
+
from msg.config import load_settings
|
|
169
|
+
return Application(load_settings(self.config_dir))
|
|
170
|
+
|
|
171
|
+
def initialize(self):
|
|
172
|
+
require_local_console(self.config_dir)
|
|
173
|
+
app=self._app()
|
|
174
|
+
if root_envelope(self.config_dir).exists() and app.settings.trust_file.exists():
|
|
175
|
+
status=self.doctor()
|
|
176
|
+
require(all(status['checks'].get(k,{}).get('ok') for k in ('storage','root_trust','bootstrap','root_private_boundary')),
|
|
177
|
+
'initialization_requires_recovery')
|
|
178
|
+
private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Current root PIN/passphrase: '))
|
|
179
|
+
trust=loads(app.settings.trust_file.read_bytes())
|
|
180
|
+
require(Ed25519Signer.from_bytes(private).public_key==unb64(trust['public_key']),'root_key_mismatch')
|
|
181
|
+
return status['root_id']
|
|
182
|
+
first=getpass.getpass('New root PIN/passphrase: ')
|
|
183
|
+
second=getpass.getpass('Confirm root PIN/passphrase: ')
|
|
184
|
+
require(first==second,'pin_confirmation_mismatch')
|
|
185
|
+
csr,_=asyncio.run(_provision(app,first))
|
|
186
|
+
print(canonical({'status':'initialized','root_id':ROOT_SUBJECT,'online_ca_request':csr}).decode())
|
|
187
|
+
return ROOT_SUBJECT
|
|
188
|
+
|
|
189
|
+
def issue(self,csr_id):
|
|
190
|
+
operator=require_local_console(self.config_dir)
|
|
191
|
+
app=self._app()
|
|
192
|
+
async def read():
|
|
193
|
+
await app.load()
|
|
194
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
195
|
+
return await tx.csr(csr_id),await tx.csr_state(csr_id)
|
|
196
|
+
csr,state=asyncio.run(read())
|
|
197
|
+
# json.dumps escapes terminal controls; no raw user-controlled text is printed.
|
|
198
|
+
print(canonical({'request':wire(csr),'state':wire(state),'public_key_fingerprint':digest(csr.public_key)}).decode())
|
|
199
|
+
if state.status=='issued':
|
|
200
|
+
async def previous():
|
|
201
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
202
|
+
return await tx.certificate(state.certificate_id)
|
|
203
|
+
return asyncio.run(previous())
|
|
204
|
+
require(input('Type the exact CSR digest to approve: ').strip()==csr.request_digest,'approval_cancelled')
|
|
205
|
+
pin=getpass.getpass('Root PIN/passphrase: ')
|
|
206
|
+
private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),pin)
|
|
207
|
+
signer=Ed25519Signer.from_bytes(private)
|
|
208
|
+
return asyncio.run(_approve_csr(app,csr_id,signer,expected_digest=csr.request_digest,operator=operator))
|
|
209
|
+
|
|
210
|
+
def change_pin(self):
|
|
211
|
+
require_local_console(self.config_dir)
|
|
212
|
+
require(input('Type CHANGE PIN to continue: ')=='CHANGE PIN','approval_cancelled')
|
|
213
|
+
path=root_envelope(self.config_dir)
|
|
214
|
+
private=open_private_key(loads(path.read_bytes()),getpass.getpass('Current root PIN/passphrase: '))
|
|
215
|
+
first=getpass.getpass('New root PIN/passphrase: ')
|
|
216
|
+
require(first==getpass.getpass('Confirm new root PIN/passphrase: '),'pin_confirmation_mismatch')
|
|
217
|
+
durable_write(path,canonical(seal_private_key(private,first)),mode=0o600)
|
|
218
|
+
|
|
219
|
+
def rotate(self, *, lost_key=False, resume=False):
|
|
220
|
+
operator=require_local_console(self.config_dir)
|
|
221
|
+
app=self._app()
|
|
222
|
+
from msg.admin.rotation import prepare,complete,journal_path
|
|
223
|
+
if resume:
|
|
224
|
+
require(journal_path(app).is_file(),'rotation_journal_missing')
|
|
225
|
+
journal=loads(journal_path(app).read_bytes())
|
|
226
|
+
print(canonical(journal['statement']).decode())
|
|
227
|
+
require(input('Type the new root fingerprint to resume: ')==journal['statement']['new_fingerprint'],'approval_cancelled')
|
|
228
|
+
pin=getpass.getpass('New root PIN/passphrase: ')
|
|
229
|
+
else:
|
|
230
|
+
asyncio.run(app.load())
|
|
231
|
+
print(canonical({'old_fingerprint':digest(app.certificates.root_public_key),
|
|
232
|
+
'effect':'all existing certificate chains require reissuance','lost_key':lost_key}).decode())
|
|
233
|
+
require(input('Type ROTATE ROOT '+digest(app.certificates.root_public_key)+': ')==
|
|
234
|
+
'ROTATE ROOT '+digest(app.certificates.root_public_key),'approval_cancelled')
|
|
235
|
+
old=None if lost_key else Ed25519Signer.from_bytes(open_private_key(
|
|
236
|
+
loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Current root PIN/passphrase: ')))
|
|
237
|
+
pin=getpass.getpass('New root PIN/passphrase: ')
|
|
238
|
+
require(pin==getpass.getpass('Confirm new root PIN/passphrase: '),'pin_confirmation_mismatch')
|
|
239
|
+
journal=prepare(app,Ed25519Signer.generate(),pin,old_signer=old,operator=operator)
|
|
240
|
+
return asyncio.run(complete(app,journal,pin=pin))
|
|
241
|
+
|
|
242
|
+
def doctor(self):
|
|
243
|
+
# Imported lazily: this read-only diagnostic must not run schema migrations.
|
|
244
|
+
from msg.admin.diagnostics import doctor
|
|
245
|
+
return doctor(self.config_dir)
|
|
246
|
+
|
|
247
|
+
def selftest(self):
|
|
248
|
+
from msg.admin.diagnostics import selftest
|
|
249
|
+
return asyncio.run(selftest())
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def revoke(self,certificate_id,reason):
|
|
253
|
+
operator=require_local_console(self.config_dir)
|
|
254
|
+
app=self._app()
|
|
255
|
+
asyncio.run(app.load())
|
|
256
|
+
async def read():
|
|
257
|
+
async with app.metadata.transaction(write=False) as tx:return await tx.certificate(certificate_id)
|
|
258
|
+
certificate=asyncio.run(read())
|
|
259
|
+
print(canonical({'certificate':wire(certificate),'reason':reason}).decode())
|
|
260
|
+
require(input('Type REVOKE '+certificate_id+' to continue: ')=='REVOKE '+certificate_id,'approval_cancelled')
|
|
261
|
+
private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Root PIN/passphrase: '))
|
|
262
|
+
return asyncio.run(_revoke(app,certificate_id,Ed25519Signer.from_bytes(private),reason=reason,operator=operator))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def sign_backup_retirement(self,source,destination):
|
|
266
|
+
"""Root-sign an operator's statement that listed backup sets lost one old key."""
|
|
267
|
+
require_local_console(self.config_dir)
|
|
268
|
+
from msg.admin.backup_retirement import unsigned_statement
|
|
269
|
+
from msg.security.backup_retirement import sign_record
|
|
270
|
+
target=Path(destination)
|
|
271
|
+
require(not target.exists() and not target.is_symlink(),'backup_retirement_destination_exists')
|
|
272
|
+
body=unsigned_statement(loads(Path(source).read_bytes()))
|
|
273
|
+
print(canonical({'statement':body,'digest':digest(body),
|
|
274
|
+
'claim':'listed_backup_sets_only'}).decode())
|
|
275
|
+
require(input('Type ATTEST BACKUP RETIREMENT '+digest(body)+': ')==
|
|
276
|
+
'ATTEST BACKUP RETIREMENT '+digest(body),'approval_cancelled')
|
|
277
|
+
private=open_private_key(loads(root_envelope(self.config_dir).read_bytes()),getpass.getpass('Root PIN/passphrase: '))
|
|
278
|
+
record=sign_record(body,Ed25519Signer.from_bytes(private))
|
|
279
|
+
durable_write(target,canonical(record),mode=0o600)
|
|
280
|
+
return {'status':'backup_retirement_signed','path':str(target),'digest':digest(record)}
|
|
281
|
+
|
|
282
|
+
def import_backup_retirement(self,source):
|
|
283
|
+
"""Only this console path persists a record; reads re-verify it every time."""
|
|
284
|
+
operator=require_local_console(self.config_dir)
|
|
285
|
+
from msg.admin.backup_retirement import import_record
|
|
286
|
+
app=self._app()
|
|
287
|
+
record=loads(Path(source).read_bytes())
|
|
288
|
+
print(canonical({'record':record,'digest':digest(record)}).decode())
|
|
289
|
+
require(input('Type IMPORT BACKUP RETIREMENT '+digest(record)+': ')==
|
|
290
|
+
'IMPORT BACKUP RETIREMENT '+digest(record),'approval_cancelled')
|
|
291
|
+
async def run():
|
|
292
|
+
await app.load()
|
|
293
|
+
try:
|
|
294
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
295
|
+
return await import_record(tx,record,now=app.clock(),operator=operator)
|
|
296
|
+
finally:
|
|
297
|
+
await app.close()
|
|
298
|
+
attestation=asyncio.run(run())
|
|
299
|
+
return {'status':'backup_retirement_imported','scope':'listed_backup_sets_only',
|
|
300
|
+
**attestation.view()}
|
|
301
|
+
|
|
302
|
+
def backup(self,destination):
|
|
303
|
+
require_local_console(self.config_dir)
|
|
304
|
+
target=Path(destination)
|
|
305
|
+
require(not target.exists() and not target.is_symlink(),'backup_destination_exists')
|
|
306
|
+
envelope=loads(root_envelope(self.config_dir).read_bytes())
|
|
307
|
+
trust=loads((self.config_dir/'trust'/'root.json').read_bytes())
|
|
308
|
+
require(input('Type BACKUP ROOT to continue: ')=='BACKUP ROOT','approval_cancelled')
|
|
309
|
+
private=open_private_key(envelope,getpass.getpass('Root PIN/passphrase: '))
|
|
310
|
+
require(b64(Ed25519Signer.from_bytes(private).public_key)==trust['public_key'],'root_key_mismatch')
|
|
311
|
+
durable_write(target,canonical({'format':'msg-root-backup-v1','envelope':envelope,'trust':trust}),mode=0o600)
|
|
312
|
+
return {'status':'encrypted_backup_created','path':str(target),'fingerprint':digest(unb64(trust['public_key']))}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def recover(self,source):
|
|
316
|
+
require_local_console(self.config_dir)
|
|
317
|
+
target=root_envelope(self.config_dir)
|
|
318
|
+
require(not target.exists(),'root_material_already_present')
|
|
319
|
+
backup=loads(Path(source).read_bytes())
|
|
320
|
+
require(set(backup)=={'format','envelope','trust'} and backup['format']=='msg-root-backup-v1','invalid_root_backup')
|
|
321
|
+
current=loads((self.config_dir/'trust'/'root.json').read_bytes())
|
|
322
|
+
require(canonical(current)==canonical(backup['trust']),'trust_anchor_mismatch')
|
|
323
|
+
print(canonical({'fingerprint':digest(unb64(current['public_key'])),'action':'recover_missing_encrypted_root_key'}).decode())
|
|
324
|
+
require(input('Type RECOVER ROOT to continue: ')=='RECOVER ROOT','approval_cancelled')
|
|
325
|
+
private=open_private_key(backup['envelope'],getpass.getpass('Backup root PIN/passphrase: '))
|
|
326
|
+
require(b64(Ed25519Signer.from_bytes(private).public_key)==current['public_key'],'root_key_mismatch')
|
|
327
|
+
target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
|
328
|
+
os.chmod(target.parent,0o700)
|
|
329
|
+
durable_write(target,canonical(backup['envelope']),mode=0o600)
|
|
330
|
+
return {'status':'root_key_recovered','fingerprint':digest(unb64(current['public_key']))}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _revoke(app,certificate_id,signer, *, reason,operator):
|
|
334
|
+
require(certificate_id!=app.certificates.root_certificate.resource_id,'root_rotation_required')
|
|
335
|
+
require(signer.key_id==app.certificates.root_certificate.key_id,'root_key_mismatch')
|
|
336
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
337
|
+
certificate=await tx.certificate(certificate_id)
|
|
338
|
+
statement={'certificate_id':certificate_id,'reason':reason,'time':wire(app.clock()),
|
|
339
|
+
'issuer':ROOT_SUBJECT,'operator':operator}
|
|
340
|
+
signature=signer.sign(canonical(statement),purpose='revocation')
|
|
341
|
+
event=Event(id=new_id('audit'),type='root.cert.revoke',time=app.clock(),request_id=new_id('local'),
|
|
342
|
+
actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,resources=(ResourceRef(id=certificate_id),),
|
|
343
|
+
data={**statement,'signature':wire(signature)})
|
|
344
|
+
await tx.revoke_certificate(certificate_id,AuditEvent(event=event,
|
|
345
|
+
authority=(ResourceRef(id=app.certificates.root_certificate.resource_id),),before_digest=digest(certificate),
|
|
346
|
+
after_digest=digest(statement),previous_digest=None,entry_digest='',result='revoked'))
|
|
347
|
+
return {'certificate_id':certificate_id,'revoked':True,'signature':wire(signature)}
|
msg/admin/rotation.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Explicit local trust rotation with a PIN-encrypted, crash-recoverable journal.
|
|
2
|
+
|
|
3
|
+
The journal never contains a plaintext private key. Installing a new trust anchor
|
|
4
|
+
invalidates all old certificate chains for new requests. Historical public keys
|
|
5
|
+
and signed records remain available; they are not retroactively re-signed.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from msg.constants import ROOT_SUBJECT, ONLINE_CA, ADMINS_GROUP, CSR_SPACE
|
|
13
|
+
from msg.core.codec import canonical, decode, digest, wire, b64, loads
|
|
14
|
+
from msg.core.errors import require
|
|
15
|
+
from msg.core.models import Certificate, CertificateRequest, Credential, Signature, IssuancePolicy, AuditEvent, Event, ResourceRef
|
|
16
|
+
from msg.plugins.common import new_id
|
|
17
|
+
from msg.plugins.identity import certificate_resource
|
|
18
|
+
from msg.security.crypto import seal_private_key, Ed25519Signer
|
|
19
|
+
from msg.security.certificates import sign_certificate, csr_body
|
|
20
|
+
from msg.security.rotation_journal import authorization, validate as validate_journal
|
|
21
|
+
from msg.security.root_files import read_private, rotation_lock
|
|
22
|
+
from msg.security.capabilities import grant_for
|
|
23
|
+
from msg.storage.git import durable_write
|
|
24
|
+
from msg.bootstrap import seed_resource
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def journal_path(app):
|
|
28
|
+
from msg.admin.root import root_envelope
|
|
29
|
+
return root_envelope(app.settings.config_dir).parent/'rotation.pending.json'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prepare(app, new_signer, new_pin, *, old_signer, operator):
|
|
33
|
+
with rotation_lock(journal_path(app).parent):
|
|
34
|
+
return _prepare(app, new_signer, new_pin, old_signer=old_signer, operator=operator)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prepare(app, new_signer, new_pin, *, old_signer, operator):
|
|
38
|
+
require(not journal_path(app).exists(), 'root_rotation_pending')
|
|
39
|
+
old = app.certificates.root_certificate
|
|
40
|
+
if old_signer is not None:
|
|
41
|
+
require(old_signer.key_id == old.key_id, 'root_key_mismatch')
|
|
42
|
+
now = app.clock()
|
|
43
|
+
grants = app.primary_ceiling()
|
|
44
|
+
new = Certificate(resource_id=new_id('cert_root'), serial=new_id('serial'), subject_id=ROOT_SUBJECT,
|
|
45
|
+
key_id=new_signer.key_id, issuer_id=ROOT_SUBJECT, parent_certificate_id=None, authority_sources=(), kind='ca',
|
|
46
|
+
grants=grants, not_before=now, expires_at=now+timedelta(days=3650), target_service=app.settings.service_url,
|
|
47
|
+
delegation_depth=8, issuance=IssuancePolicy(issue_grants=grants,max_cert_ttl_seconds=31536000,
|
|
48
|
+
max_child_ca_depth=3,max_delegation_depth=8),
|
|
49
|
+
signature=Signature(key_id=new_signer.key_id, algorithm='ed25519', value=b''))
|
|
50
|
+
new = sign_certificate(new, new_signer)
|
|
51
|
+
statement = {'old_certificate':old.resource_id, 'old_fingerprint':digest(app.certificates.root_public_key),
|
|
52
|
+
'new_certificate':new.resource_id, 'new_fingerprint':digest(new_signer.public_key), 'time':wire(now),
|
|
53
|
+
'operator':operator, 'previous_key_proved':old_signer is not None}
|
|
54
|
+
online = app.online_signer
|
|
55
|
+
csr = CertificateRequest(resource_id=new_id('csr'), applicant=ONLINE_CA, subject_id=ONLINE_CA,
|
|
56
|
+
requested_issuer=ROOT_SUBJECT, public_key=online.public_key, kind='ca',
|
|
57
|
+
grants=(grant_for(app.registry.capability('cert.issue'),scope=app.default_scope()),),
|
|
58
|
+
issuance=IssuancePolicy(issue_grants=app.base_grants(),max_cert_ttl_seconds=app.settings.base_certificate_ttl,
|
|
59
|
+
max_child_ca_depth=0,max_delegation_depth=8), requested_ttl_seconds=31536000,
|
|
60
|
+
target_service=app.settings.service_url,delegation_depth=0,authority_sources=(),request_digest='',
|
|
61
|
+
possession_proof=Signature(key_id=online.key_id,algorithm='ed25519',value=b''))
|
|
62
|
+
csr = replace(csr, request_digest=digest(csr_body(csr)),
|
|
63
|
+
possession_proof=online.sign(canonical(csr_body(csr)),purpose='csr'))
|
|
64
|
+
from msg.admin.root import root_envelope
|
|
65
|
+
key_file=root_envelope(app.settings.config_dir)
|
|
66
|
+
require(not key_file.is_symlink(),'unsafe_root_private_path')
|
|
67
|
+
journal={'version':2,'old_certificate':wire(old),'new_trust':{'version':1,
|
|
68
|
+
'public_key':b64(new_signer.public_key),'certificate':wire(new)},
|
|
69
|
+
'new_envelope':seal_private_key(new_signer.private_bytes(),new_pin),
|
|
70
|
+
'previous_envelope_digest':digest(read_private(key_file)) if key_file.exists() else None,
|
|
71
|
+
'online_csr':wire(csr),'statement':statement}
|
|
72
|
+
proof=canonical(authorization(journal))
|
|
73
|
+
journal['new_signature']=wire(new_signer.sign(proof,purpose='root-rotation'))
|
|
74
|
+
journal['old_signature']=wire(old_signer.sign(proof,purpose='root-rotation')) if old_signer else None
|
|
75
|
+
protected=journal_path(app).parent
|
|
76
|
+
require(not protected.is_symlink(),'unsafe_root_private_path')
|
|
77
|
+
protected.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
78
|
+
os.chmod(protected,0o700)
|
|
79
|
+
durable_write(journal_path(app),canonical(journal),mode=0o600)
|
|
80
|
+
return journal
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def complete(app, journal, *, pin):
|
|
84
|
+
with rotation_lock(journal_path(app).parent):
|
|
85
|
+
return await _complete(app, journal, pin=pin)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def _complete(app, journal, *, pin):
|
|
89
|
+
from msg.security.crypto import verify
|
|
90
|
+
pending=journal_path(app)
|
|
91
|
+
protected=pending.parent
|
|
92
|
+
require(not protected.is_symlink() and not pending.is_symlink(),
|
|
93
|
+
'unsafe_root_private_path')
|
|
94
|
+
online=Ed25519Signer.from_bytes((app.settings.service_keys/'online.key').read_bytes())
|
|
95
|
+
signer,old,new,csr,proof=validate_journal(journal,pin=pin,
|
|
96
|
+
service=app.settings.service_url,online_public=online.public_key)
|
|
97
|
+
key_file=protected/'key.json'
|
|
98
|
+
require(not key_file.is_symlink(),'unsafe_root_private_path')
|
|
99
|
+
if key_file.exists():
|
|
100
|
+
require(digest(read_private(key_file)) in {
|
|
101
|
+
journal['previous_envelope_digest'],digest(canonical(journal['new_envelope']))},
|
|
102
|
+
'root_envelope_changed')
|
|
103
|
+
else:
|
|
104
|
+
require(journal['previous_envelope_digest'] is None,'root_envelope_missing')
|
|
105
|
+
# Reject unsafe or substituted history BEFORE changing the database anchor.
|
|
106
|
+
history = protected/'history'/old.resource_id/'key.json'
|
|
107
|
+
for directory in (protected/'history', history.parent):
|
|
108
|
+
require(not directory.is_symlink(), 'unsafe_root_private_path')
|
|
109
|
+
require(not history.is_symlink(), 'unsafe_root_private_path')
|
|
110
|
+
if history.exists():
|
|
111
|
+
require(digest(read_private(history)) == journal['previous_envelope_digest'],
|
|
112
|
+
'rotation_history_mismatch')
|
|
113
|
+
elif journal['previous_envelope_digest'] is not None:
|
|
114
|
+
require(key_file.exists() and
|
|
115
|
+
digest(read_private(key_file)) == journal['previous_envelope_digest'],
|
|
116
|
+
'rotation_history_missing')
|
|
117
|
+
require(app.settings.trust_file.is_file() and not app.settings.trust_file.is_symlink(),
|
|
118
|
+
'rotation_trust_missing')
|
|
119
|
+
current_trust = loads(app.settings.trust_file.read_bytes())
|
|
120
|
+
# This method is called only after the local console has approved the exact
|
|
121
|
+
# journal. It also handles the state where PostgreSQL committed but protected files did not.
|
|
122
|
+
await app.open_storage()
|
|
123
|
+
async with app.metadata.transaction(write=True) as tx:
|
|
124
|
+
current=tx.setting('active_root_certificate','cert_root')
|
|
125
|
+
require(current in {old.resource_id,new.resource_id},'trust_anchor_mismatch')
|
|
126
|
+
existing=await tx.certificate(old.resource_id)
|
|
127
|
+
require(canonical(existing)==canonical(old),'trust_anchor_mismatch')
|
|
128
|
+
old_key=await tx.credential(old.key_id)
|
|
129
|
+
require(old_key.subject_id==ROOT_SUBJECT and
|
|
130
|
+
digest(old_key.verifier)==journal['statement']['old_fingerprint'],
|
|
131
|
+
'rotation_statement_mismatch')
|
|
132
|
+
old_trust = {'version': 1, 'public_key': b64(old_key.verifier), 'certificate': wire(old)}
|
|
133
|
+
require(canonical(current_trust) in (canonical(old_trust), canonical(journal['new_trust'])),
|
|
134
|
+
'rotation_trust_changed')
|
|
135
|
+
if journal['old_signature'] is not None:
|
|
136
|
+
verify(old_key.verifier,canonical(proof),decode(Signature,journal['old_signature']),
|
|
137
|
+
purpose='root-rotation')
|
|
138
|
+
if current == new.resource_id:
|
|
139
|
+
require(canonical(await tx.certificate(new.resource_id))==canonical(new) and
|
|
140
|
+
canonical(await tx.csr(csr.resource_id))==canonical(csr) and
|
|
141
|
+
old_key.revoked_at is not None and await tx.certificate_revoked(old.resource_id),
|
|
142
|
+
'rotation_resume_mismatch')
|
|
143
|
+
installed=await tx.credential(new.key_id)
|
|
144
|
+
require(installed.verifier==signer.public_key and installed.revoked_at is None,
|
|
145
|
+
'rotation_resume_mismatch')
|
|
146
|
+
else:
|
|
147
|
+
subject=await tx.subject(ROOT_SUBJECT)
|
|
148
|
+
require(subject.local_only,'root_policy_corrupt')
|
|
149
|
+
# Credential saves are upserts; a retired root key must never be revived.
|
|
150
|
+
require(tx.one('SELECT 1 FROM credentials WHERE id=?',(signer.key_id,)) is None,
|
|
151
|
+
'root_key_reused')
|
|
152
|
+
await tx.save_credential(Credential(id=signer.key_id,subject_id=ROOT_SUBJECT,kind='signing_key',
|
|
153
|
+
verifier=signer.public_key,ceiling=(),not_before=new.not_before,expires_at=None,revoked_at=None),subject.auth_version)
|
|
154
|
+
await tx.save_credential(replace(old_key,revoked_at=app.clock()),subject.auth_version)
|
|
155
|
+
await certificate_resource(tx,new,app.clock())
|
|
156
|
+
await tx.register_certificate(new,None,0)
|
|
157
|
+
event=Event(id=new_id('audit'),type='root.rotate',time=app.clock(),request_id=new.resource_id,
|
|
158
|
+
actor=ROOT_SUBJECT,subject=ROOT_SUBJECT,resources=(ResourceRef(id=new.resource_id),),
|
|
159
|
+
data={'statement':journal['statement'],'authorization':proof,'new_signature':journal['new_signature'],
|
|
160
|
+
'old_signature':journal['old_signature']})
|
|
161
|
+
await tx.revoke_certificate(old.resource_id,AuditEvent(event=event,authority=(),
|
|
162
|
+
before_digest=digest(old),after_digest=digest(new),previous_digest=None,entry_digest='',result='root_rotated'))
|
|
163
|
+
await seed_resource(tx,app.contents,dict(id=csr.resource_id,type='csr',name=csr.resource_id,parent=CSR_SPACE,
|
|
164
|
+
owner=ONLINE_CA,group=ADMINS_GROUP,mode='0600'),app.clock())
|
|
165
|
+
await tx.save_csr(csr)
|
|
166
|
+
tx.set_setting('online_ca_request',csr.resource_id)
|
|
167
|
+
tx.set_setting('online_ca_certificate',None)
|
|
168
|
+
tx.set_setting('active_root_certificate',new.resource_id)
|
|
169
|
+
tx.set_setting('authorization_epoch',tx.setting('authorization_epoch',0)+1)
|
|
170
|
+
tx.set_setting('root_rotation:'+new.resource_id,journal['statement'])
|
|
171
|
+
# The journal and envelope must use the same independently protected root
|
|
172
|
+
# directory, including when resuming after the metadata commit.
|
|
173
|
+
for directory in (protected,protected/'history',protected/'history'/old.resource_id):
|
|
174
|
+
require(not directory.is_symlink(),'unsafe_root_private_path')
|
|
175
|
+
directory.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
176
|
+
os.chmod(directory,0o700)
|
|
177
|
+
history=protected/'history'/old.resource_id/'key.json'
|
|
178
|
+
require(not history.is_symlink(),'unsafe_root_private_path')
|
|
179
|
+
if history.exists():
|
|
180
|
+
require(digest(read_private(history))==journal['previous_envelope_digest'],
|
|
181
|
+
'rotation_history_mismatch')
|
|
182
|
+
elif journal['previous_envelope_digest'] is not None:
|
|
183
|
+
# Never archive an already installed new envelope as the old key.
|
|
184
|
+
require(key_file.exists() and
|
|
185
|
+
digest(read_private(key_file))==journal['previous_envelope_digest'],
|
|
186
|
+
'rotation_history_missing')
|
|
187
|
+
durable_write(history,read_private(key_file),mode=0o600)
|
|
188
|
+
durable_write(key_file,canonical(journal['new_envelope']),mode=0o600)
|
|
189
|
+
durable_write(app.settings.trust_file,canonical(journal['new_trust']),mode=0o444)
|
|
190
|
+
# Journal survives every earlier failure and is removed only after both
|
|
191
|
+
# metadata and protected files have durable copies.
|
|
192
|
+
pending.unlink(missing_ok=True)
|
|
193
|
+
directory=os.open(protected,os.O_RDONLY|os.O_DIRECTORY)
|
|
194
|
+
try:os.fsync(directory)
|
|
195
|
+
finally:os.close(directory)
|
|
196
|
+
return {'root_id':ROOT_SUBJECT,'certificate_id':new.resource_id,'fingerprint':digest(signer.public_key),
|
|
197
|
+
'online_ca_request':csr.resource_id,'restart_required':True,'old_certificate_chains_valid':False}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exercise one-use credential release only on the caller's isolated Test Root."""
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import TemporaryDirectory
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from msg.client import ClientState, MsgClient
|
|
9
|
+
from msg.core.errors import Failure
|
|
10
|
+
from msg.transports.client import HTTPTransport
|
|
11
|
+
from msg.transports.http import create_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def check_token_delivery(app, now):
|
|
15
|
+
with TemporaryDirectory(prefix='msg-token-selftest-') as directory:
|
|
16
|
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=create_app(app))) as http:
|
|
17
|
+
state = ClientState(Path(directory), server=app.settings.service_url)
|
|
18
|
+
transport = HTTPTransport(state.server, http=http)
|
|
19
|
+
client = MsgClient(state, transport, clock=lambda: now, retries=0)
|
|
20
|
+
created = await client.temporary()
|
|
21
|
+
if created.status != 'ok':
|
|
22
|
+
return False
|
|
23
|
+
original = state.token
|
|
24
|
+
send = transport.call
|
|
25
|
+
|
|
26
|
+
async def discard(packet):
|
|
27
|
+
result = await send(packet)
|
|
28
|
+
if result.status != 'ok':
|
|
29
|
+
raise Failure('selftest_issuance_failed')
|
|
30
|
+
raise httpx.ReadTimeout('selftest response loss')
|
|
31
|
+
|
|
32
|
+
transport.call = discard
|
|
33
|
+
for attempt in (client.rotate_token, client.recover_token):
|
|
34
|
+
try:
|
|
35
|
+
await attempt()
|
|
36
|
+
return False
|
|
37
|
+
except Failure as exc:
|
|
38
|
+
if exc.code != 'transport_uncertain':
|
|
39
|
+
return False
|
|
40
|
+
# Both processes can restart; refreshing only the request expiry
|
|
41
|
+
# does not refresh either the token lifetime or recovery deadline.
|
|
42
|
+
state = ClientState(Path(directory), server=app.settings.service_url)
|
|
43
|
+
client = MsgClient(state, HTTPTransport(state.server, http=http),
|
|
44
|
+
clock=lambda: now+timedelta(seconds=1), retries=0)
|
|
45
|
+
replay = await client.recover_token()
|
|
46
|
+
if replay.status != 'error' or replay.error.code != 'token_delivery_unavailable':
|
|
47
|
+
return False
|
|
48
|
+
completed = await client.recover_token()
|
|
49
|
+
if completed.status != 'ok' or not state.token or state.token[0] == original[0]:
|
|
50
|
+
return False
|
|
51
|
+
async with app.metadata.transaction(write=False) as tx:
|
|
52
|
+
old = await tx.credential(original[0])
|
|
53
|
+
current = await tx.credential(state.token[0])
|
|
54
|
+
return old.revoked_at is not None and current.revoked_at is None and client._token_journal() == (None, None)
|