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,281 @@
|
|
|
1
|
+
"""Local recovery's deny-only replay, never a promotion or trust bootstrap.
|
|
2
|
+
|
|
3
|
+
A pin MUST arrive through an independently authenticated operator channel outside
|
|
4
|
+
this database and its backup/rollback set. Neither a database watermark nor an
|
|
5
|
+
audit hash chain supplies that pin. There is deliberately no default trust key,
|
|
6
|
+
network operation, configuration override, or quarantine-clearing operation here.
|
|
7
|
+
Unknown fact kinds require a newer reviewed replay implementation and stay blocked.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from msg.constants import ROOT_SUBJECT
|
|
17
|
+
from msg.core.codec import canonical, decode, digest, loads, parse_time, wire
|
|
18
|
+
from msg.core.errors import Failure, require
|
|
19
|
+
from msg.core.models import AuditEvent, Certificate, Credential, Event, Membership, Signature
|
|
20
|
+
from msg.security.crypto import key_id, verify
|
|
21
|
+
from msg.security.quarantine import SETTING, active
|
|
22
|
+
|
|
23
|
+
FORMAT = 'msg-revocation-checkpoint-v1'
|
|
24
|
+
SUPPORTED_FACTS = frozenset({'credential.revoke', 'certificate.revoke', 'share_grant.revoke',
|
|
25
|
+
'share_grant_v2.revoke', 'share_link.revoke', 'membership.remove',
|
|
26
|
+
'topic_membership.remove', 'topic_ban.lift', 'identity_key.retire',
|
|
27
|
+
'encryption_key.retire', 'vault.destroy'})
|
|
28
|
+
_SHARE_TABLES = {'share_grant.revoke': 'share_grants', 'share_grant_v2.revoke': 'share_grants_v2',
|
|
29
|
+
'share_link.revoke': 'share_links'}
|
|
30
|
+
_MAX_ENTRIES = 10000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
34
|
+
class TrustedCheckpointPin:
|
|
35
|
+
"""Independently approved exact head, not a value extracted from the packet.
|
|
36
|
+
|
|
37
|
+
Creating this internal value is the trusted local caller's responsibility.
|
|
38
|
+
It does not assert that an offline source was current or operator-approved.
|
|
39
|
+
Production remains blocked without evidence for that separate trust ceremony.
|
|
40
|
+
"""
|
|
41
|
+
service: str
|
|
42
|
+
public_key: bytes
|
|
43
|
+
digest: str
|
|
44
|
+
sequence: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def verify_checkpoint(packet, *, pin):
|
|
48
|
+
require(isinstance(pin, TrustedCheckpointPin), 'recovery_checkpoint_pin_required')
|
|
49
|
+
try:
|
|
50
|
+
require(type(packet) is dict and set(packet) == {'checkpoint', 'signature'},
|
|
51
|
+
'recovery_checkpoint_invalid')
|
|
52
|
+
body = packet['checkpoint']
|
|
53
|
+
require(type(body) is dict and set(body) == {
|
|
54
|
+
'format', 'service', 'source_backup_sha256', 'sequence', 'entries'},
|
|
55
|
+
'recovery_checkpoint_invalid')
|
|
56
|
+
require(type(pin.public_key) is bytes and len(pin.public_key) == 32 and
|
|
57
|
+
type(pin.sequence) is int and 0 <= pin.sequence <= _MAX_ENTRIES and
|
|
58
|
+
type(body['sequence']) is int and body['sequence'] == pin.sequence and
|
|
59
|
+
body['format'] == FORMAT and body['service'] == pin.service and
|
|
60
|
+
isinstance(pin.service, str) and bool(pin.service) and
|
|
61
|
+
isinstance(body['source_backup_sha256'], str) and
|
|
62
|
+
re.fullmatch('[0-9a-f]{64}', body['source_backup_sha256']) is not None and
|
|
63
|
+
digest(body) == pin.digest, 'recovery_checkpoint_invalid')
|
|
64
|
+
entries = body['entries']
|
|
65
|
+
require(type(entries) is list and len(entries) == pin.sequence,
|
|
66
|
+
'recovery_checkpoint_invalid')
|
|
67
|
+
for sequence, fact in enumerate(entries, 1):
|
|
68
|
+
require(type(fact) is dict and set(fact) == {
|
|
69
|
+
'sequence', 'kind', 'subject', 'target', 'at'} and
|
|
70
|
+
type(fact['sequence']) is int and fact['sequence'] == sequence and
|
|
71
|
+
fact['kind'] in SUPPORTED_FACTS and
|
|
72
|
+
all(isinstance(fact[name], str) and 0 < len(fact[name]) <= 256
|
|
73
|
+
for name in ('subject', 'target')), 'recovery_checkpoint_invalid')
|
|
74
|
+
require(parse_time(fact['at']) <= datetime.now(UTC), 'recovery_checkpoint_invalid')
|
|
75
|
+
verify(pin.public_key, canonical(body), decode(Signature, packet['signature']),
|
|
76
|
+
purpose='recovery-checkpoint-v1')
|
|
77
|
+
# Own the verified bytes across the subsequent await/transaction boundary.
|
|
78
|
+
return loads(canonical(body))
|
|
79
|
+
except (Failure, KeyError, TypeError, ValueError, OverflowError) as exc:
|
|
80
|
+
raise Failure('recovery_checkpoint_invalid') from exc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _owned(row, subject):
|
|
84
|
+
require(row is not None, 'recovery_fact_missing')
|
|
85
|
+
require(row[0] == subject, 'recovery_fact_subject_mismatch')
|
|
86
|
+
return row
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _earlier(previous, at):
|
|
90
|
+
return at if previous is None or parse_time(at) < parse_time(previous) else previous
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _revoke_credential(tx, subject, target, at):
|
|
94
|
+
row = _owned(tx.one('SELECT subject,body FROM credentials WHERE id=?', (target,)), subject)
|
|
95
|
+
credential = decode(Credential, loads(row[1]))
|
|
96
|
+
require(credential.id == target and credential.subject_id == subject,
|
|
97
|
+
'recovery_fact_subject_mismatch')
|
|
98
|
+
when = parse_time(at)
|
|
99
|
+
changed = credential.revoked_at is None or credential.revoked_at > when
|
|
100
|
+
if changed:
|
|
101
|
+
revoked = replace(credential, revoked_at=when)
|
|
102
|
+
tx.execute('UPDATE credentials SET body=? WHERE id=?',
|
|
103
|
+
(canonical(revoked).decode(), target), write=True)
|
|
104
|
+
if credential.kind == 'token':
|
|
105
|
+
delivery = tx.one('SELECT subject,consumed_at FROM token_deliveries WHERE credential_id=?',
|
|
106
|
+
(target,))
|
|
107
|
+
if delivery is not None:
|
|
108
|
+
_owned(delivery, subject)
|
|
109
|
+
earliest = _earlier(delivery[1], at)
|
|
110
|
+
if earliest != delivery[1]:
|
|
111
|
+
tx.execute('UPDATE token_deliveries SET consumed_at=? WHERE credential_id=?',
|
|
112
|
+
(earliest, target), write=True)
|
|
113
|
+
changed = True
|
|
114
|
+
return changed
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _apply(tx, fact):
|
|
118
|
+
subject, target, at, kind = (fact[name] for name in ('subject', 'target', 'at', 'kind'))
|
|
119
|
+
if kind == 'credential.revoke':
|
|
120
|
+
return _revoke_credential(tx, subject, target, at)
|
|
121
|
+
if kind == 'certificate.revoke':
|
|
122
|
+
row = _owned(tx.one('SELECT subject,revoked,body FROM certificates WHERE id=?',
|
|
123
|
+
(target,)), subject)
|
|
124
|
+
certificate = decode(Certificate, loads(row[2]))
|
|
125
|
+
require(certificate.resource_id == target and certificate.subject_id == subject,
|
|
126
|
+
'recovery_fact_subject_mismatch')
|
|
127
|
+
if row[1] != 1:
|
|
128
|
+
tx.execute('UPDATE certificates SET revoked=1 WHERE id=?', (target,), write=True)
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
if kind in _SHARE_TABLES:
|
|
132
|
+
table = _SHARE_TABLES[kind] # Fixed closed vocabulary, never caller SQL.
|
|
133
|
+
row = _owned(tx.one(f'SELECT grantor,revoked_at FROM {table} WHERE id=?', (target,)), subject)
|
|
134
|
+
earliest = _earlier(row[1], at)
|
|
135
|
+
if row[1] != earliest:
|
|
136
|
+
tx.execute(f'UPDATE {table} SET revoked_at=? WHERE id=?', (earliest, target), write=True)
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
if kind == 'membership.remove':
|
|
140
|
+
require(target != 'g_public', 'recovery_virtual_membership_requires_identity_policy')
|
|
141
|
+
row = tx.one('SELECT generation,body FROM memberships WHERE org=? AND subject=?',
|
|
142
|
+
(target, subject))
|
|
143
|
+
require(row is not None, 'recovery_fact_missing')
|
|
144
|
+
member = decode(Membership, loads(row[1]))
|
|
145
|
+
require(member.subject_id == subject and member.organization_id == target and
|
|
146
|
+
member.version == row[0], 'recovery_fact_subject_mismatch')
|
|
147
|
+
if member.status != 'rejected':
|
|
148
|
+
await tx.update_identity(replace(member, status='rejected', version=member.version + 1),
|
|
149
|
+
member.version)
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
if kind == 'topic_membership.remove':
|
|
153
|
+
row = tx.one('SELECT status FROM topic_memberships WHERE topic=? AND subject=?', (target, subject))
|
|
154
|
+
require(row is not None, 'recovery_fact_missing')
|
|
155
|
+
if row[0] != 'removed':
|
|
156
|
+
tx.execute("UPDATE topic_memberships SET status='removed' WHERE topic=? AND subject=?",
|
|
157
|
+
(target, subject), write=True)
|
|
158
|
+
return True
|
|
159
|
+
return False
|
|
160
|
+
if kind == 'topic_ban.lift':
|
|
161
|
+
row = tx.one('SELECT status FROM topic_bans WHERE topic=? AND subject=?', (target, subject))
|
|
162
|
+
require(row is not None, 'recovery_fact_missing')
|
|
163
|
+
if row[0] != 'lifted':
|
|
164
|
+
tx.execute("UPDATE topic_bans SET status='lifted' WHERE topic=? AND subject=?",
|
|
165
|
+
(target, subject), write=True)
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
if kind in {'identity_key.retire', 'encryption_key.retire'}:
|
|
169
|
+
table = 'identity_keys' if kind == 'identity_key.retire' else 'encryption_subkeys'
|
|
170
|
+
row = _owned(tx.one(f'SELECT subject,retired_at,is_primary FROM {table} WHERE key_id=?',
|
|
171
|
+
(target,)), subject)
|
|
172
|
+
earliest = _earlier(row[1], at)
|
|
173
|
+
changed = row[1] != earliest or row[2] != 0
|
|
174
|
+
if changed:
|
|
175
|
+
tx.execute(f'UPDATE {table} SET retired_at=?,is_primary=0 WHERE key_id=?',
|
|
176
|
+
(earliest, target), write=True)
|
|
177
|
+
if kind == 'identity_key.retire':
|
|
178
|
+
changed = _revoke_credential(tx, subject, target, at) or changed
|
|
179
|
+
vault = tx.one('SELECT signing_key_id,signing_nonce,signing_ciphertext,age_ciphertext '
|
|
180
|
+
'FROM custodial_vault WHERE subject=?', (subject,))
|
|
181
|
+
if vault is not None and vault[0] == target and (vault[1] is not None or vault[2] is not None):
|
|
182
|
+
status = 'decrypt_only' if vault[3] is not None else 'destroyed'
|
|
183
|
+
tx.execute('UPDATE custodial_vault SET signing_nonce=NULL,signing_ciphertext=NULL,'
|
|
184
|
+
'status=? WHERE subject=?', (status, subject), write=True)
|
|
185
|
+
changed = True
|
|
186
|
+
return changed
|
|
187
|
+
require(kind == 'vault.destroy', 'recovery_checkpoint_invalid')
|
|
188
|
+
row = tx.one('SELECT encryption_key_id,signing_nonce,signing_ciphertext,age_nonce,'
|
|
189
|
+
'age_ciphertext,status FROM custodial_vault WHERE subject=?', (subject,))
|
|
190
|
+
require(row is not None, 'recovery_fact_missing')
|
|
191
|
+
require(row[0] == target, 'recovery_fact_subject_mismatch')
|
|
192
|
+
if any(value is not None for value in row[1:5]) or row[5] != 'destroyed':
|
|
193
|
+
tx.execute("UPDATE custodial_vault SET signing_nonce=NULL,signing_ciphertext=NULL,"
|
|
194
|
+
"age_nonce=NULL,age_ciphertext=NULL,status='destroyed',destroyed_at=? WHERE subject=?",
|
|
195
|
+
(at, subject), write=True)
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def replay(store, packet, *, pin, config_dir):
|
|
201
|
+
"""Physical-console entry; neither a TTY flag nor an HTTP caller can authorize it."""
|
|
202
|
+
from msg.admin.root import require_local_console
|
|
203
|
+
operator = require_local_console(config_dir)
|
|
204
|
+
return await _replay(store, packet, pin=pin, operator=operator)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _replay(store, packet, *, pin, operator):
|
|
208
|
+
"""Atomically apply an explicitly pinned supported deny-only log to quarantine.
|
|
209
|
+
|
|
210
|
+
Always re-apply facts, including after a repeated invocation/restart: cached
|
|
211
|
+
watermarks are not evidence that an authorization row has not been restored.
|
|
212
|
+
This neither imports keys, emits effects, nor clears quarantine. topic_ban.lift
|
|
213
|
+
is the only topic-ban fact; it does not reconcile ACLs or promote production.
|
|
214
|
+
"""
|
|
215
|
+
require(isinstance(operator, str) and bool(operator), 'recovery_operator_required')
|
|
216
|
+
body = verify_checkpoint(packet, pin=pin)
|
|
217
|
+
async with store.transaction(write=True) as tx:
|
|
218
|
+
require(active(tx), 'recovery_quarantine_required')
|
|
219
|
+
gate = tx.setting(SETTING)
|
|
220
|
+
require(isinstance(gate, dict) and gate.get('format') == 'msg-recovery-quarantine-v1' and
|
|
221
|
+
gate.get('outbound_enabled') is False and gate.get('authority') == 'health_only',
|
|
222
|
+
'recovery_quarantine_invalid')
|
|
223
|
+
require(gate.get('source_backup_sha256') == body['source_backup_sha256'],
|
|
224
|
+
'recovery_checkpoint_backup_mismatch')
|
|
225
|
+
previous = tx.setting('recovery_replay')
|
|
226
|
+
if previous is not None:
|
|
227
|
+
require(isinstance(previous, dict) and previous.get('format') == FORMAT and
|
|
228
|
+
type(previous.get('sequence')) is int and 0 <= previous['sequence'] <= pin.sequence and
|
|
229
|
+
previous.get('prefix_digest') == digest(body['entries'][:previous['sequence']]),
|
|
230
|
+
'recovery_checkpoint_regression')
|
|
231
|
+
changed = 0
|
|
232
|
+
for fact in body['entries']:
|
|
233
|
+
changed += bool(await _apply(tx, fact))
|
|
234
|
+
receipt = {'format': FORMAT, 'checkpoint_digest': pin.digest, 'sequence': pin.sequence,
|
|
235
|
+
'prefix_digest': digest(body['entries']), 'scope': 'supported_deny_only_facts',
|
|
236
|
+
'promotion': 'blocked', 'backup_retired': False}
|
|
237
|
+
if changed:
|
|
238
|
+
tx.set_setting('authorization_epoch', tx.setting('authorization_epoch', 0) + 1)
|
|
239
|
+
if changed or previous != receipt:
|
|
240
|
+
tx.set_setting('recovery_replay', receipt)
|
|
241
|
+
event = Event(id='audit_' + uuid4().hex, type='recovery.revocation_replay',
|
|
242
|
+
time=datetime.now(UTC), request_id=pin.digest, actor=ROOT_SUBJECT, subject=ROOT_SUBJECT,
|
|
243
|
+
resources=(), data=dict(receipt, operator=operator,
|
|
244
|
+
checkpoint_key_id=key_id(pin.public_key)))
|
|
245
|
+
await tx.append_audit(AuditEvent(event=event, authority=(), before_digest=digest(previous),
|
|
246
|
+
after_digest=digest(receipt), previous_digest=None, entry_digest='', result='quarantined'))
|
|
247
|
+
return dict(receipt, changed=changed)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def selftest():
|
|
251
|
+
"""Ephemeral signer test only; does not inspect or change any installation."""
|
|
252
|
+
from msg.security.crypto import Ed25519Signer
|
|
253
|
+
signer = Ed25519Signer.generate()
|
|
254
|
+
body = {'format': FORMAT, 'service': 'https://isolated.invalid',
|
|
255
|
+
'source_backup_sha256': '0' * 64, 'sequence': 0, 'entries': []}
|
|
256
|
+
packet = {'checkpoint': body, 'signature': wire(signer.sign(
|
|
257
|
+
canonical(body), purpose='recovery-checkpoint-v1'))}
|
|
258
|
+
pin = TrustedCheckpointPin(service=body['service'], public_key=signer.public_key,
|
|
259
|
+
digest=digest(body), sequence=0)
|
|
260
|
+
require(verify_checkpoint(packet, pin=pin) == body, 'recovery_selftest_failed')
|
|
261
|
+
rejected = []
|
|
262
|
+
for invalid in (None, replace(pin, public_key=Ed25519Signer.generate().public_key)):
|
|
263
|
+
try:
|
|
264
|
+
verify_checkpoint(packet, pin=invalid)
|
|
265
|
+
except Failure as exc:
|
|
266
|
+
rejected.append(exc.code)
|
|
267
|
+
else:
|
|
268
|
+
raise Failure('recovery_selftest_failed')
|
|
269
|
+
require(rejected == ['recovery_checkpoint_pin_required', 'recovery_checkpoint_invalid'],
|
|
270
|
+
'recovery_selftest_failed')
|
|
271
|
+
return {'format': FORMAT, 'scope': 'isolated_signature_contract_only',
|
|
272
|
+
'checks': 3, 'status': 'passed', 'database_used': False,
|
|
273
|
+
'production_evidence': False, 'promotion': 'blocked'}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == '__main__':
|
|
277
|
+
import argparse
|
|
278
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
279
|
+
parser.add_argument('--selftest', action='store_true', required=True)
|
|
280
|
+
parser.parse_args()
|
|
281
|
+
print(canonical(selftest()).decode())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Import an offline dump and its quarantine gate in the same PostgreSQL commit."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from msg.core.codec import canonical
|
|
7
|
+
from msg.security.quarantine import SETTING
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def restore_dump(dump, *, safe_dsn, env, quarantine):
|
|
11
|
+
"""Stream through a protected SQL file; COPY data requires psql, not execute().
|
|
12
|
+
|
|
13
|
+
pg_restore does not connect to PostgreSQL here. psql's single transaction
|
|
14
|
+
contains both the dump and the mandatory gate. An interruption before commit
|
|
15
|
+
leaves neither an imported snapshot nor a runnable ungated database.
|
|
16
|
+
"""
|
|
17
|
+
script = Path(dump).parent / 'quarantined-restore.sql'
|
|
18
|
+
script.touch(mode=0o600, exist_ok=False)
|
|
19
|
+
try:
|
|
20
|
+
subprocess.run(['pg_restore', '--exit-on-error', '--no-owner', '--no-acl',
|
|
21
|
+
'--file', str(script), str(dump)],
|
|
22
|
+
env=env, check=True, capture_output=True)
|
|
23
|
+
# Values are fixed recovery metadata, not secrets. Escape as a SQL
|
|
24
|
+
# literal (including backslashes) independently of session string flags.
|
|
25
|
+
value = canonical(quarantine).decode().replace('\\', '\\\\').replace("'", "''")
|
|
26
|
+
gate = ("\nSET search_path = public, pg_catalog;\n"
|
|
27
|
+
f"INSERT INTO settings(key,value) VALUES('{SETTING}',E'{value}') "
|
|
28
|
+
"ON CONFLICT(key) DO UPDATE SET value=EXCLUDED.value;\n")
|
|
29
|
+
with script.open('ab') as stream:
|
|
30
|
+
stream.write(gate.encode())
|
|
31
|
+
stream.flush()
|
|
32
|
+
os.fsync(stream.fileno())
|
|
33
|
+
subprocess.run(['psql', '--no-psqlrc', '--quiet', '--single-transaction',
|
|
34
|
+
'--set', 'ON_ERROR_STOP=1', '--dbname', safe_dsn, '--file', str(script)],
|
|
35
|
+
env=env, check=True, capture_output=True)
|
|
36
|
+
finally:
|
|
37
|
+
script.unlink(missing_ok=True)
|