Files
archy/scripts/c01_migration.py
T

153 lines
8.8 KiB
Python

"""Data-only, explicit legacy import appointment and live cold-writer exclusion.
This does not move files, stop services, create keys, initialize an existing
protected store, or authorize a clock. Layout retirement is a separately reviewed
access transition. Existing encrypted stores use inspect/recover with their
retained keys and binding, never the legacy importer.
"""
import hashlib
import os
from pathlib import Path
import stat
import c01_access as access
import c01_access_kernel as kernel
import c01_access_policy as policy_module
import c01_network
import c01_views as views
APPROVALS = Path('/etc/archipelago-c01/migrations')
require = policy_module.require
def refuse_existing_legacy_anchor(launch):
# A retained plaintext file may coexist with a newer protected store. It is
# never permission to initialize a new incarnation from that stale file.
for root in (launch['manager_backing'], launch['runtime_data_dir']):
path=Path(root)/'federation/protected-pending'
require(not os.path.lexists(path), 'existing protected history requires retained-key recovery, not legacy import')
def validate(value, launch, launch_digest, access_digest):
require(type(value) is dict and set(value) == {
'version', 'operation', 'authorization', 'launch_sha256', 'access_sha256',
'binding', 'source', 'source_public_sha256', 'approved_watermark'}, 'migration approval fields')
require(type(value['version']) is int and value['version'] == 1 and
value['operation'] == 'import-legacy-requests-v1' and
value['authorization'] in ('one-qualified-guarded-framework-migration',
'one-qualified-guarded-yaya-migration'), 'migration authority/scope')
require(value['launch_sha256'] == launch_digest and value['access_sha256'] == access_digest and
value['binding'] == launch['binding'], 'migration configuration/binding differs')
require(type(value['approved_watermark']) is int and 0 < value['approved_watermark'] < 2**64,
'explicit migration floor required; no zero/default/history inference')
require(policy_module.hex_digest(value['source_public_sha256']), 'migration public identity digest')
source = value['source']
require(type(source) is dict and set(source) == {'path','device','inode','uid','gid','bytes','sha256'},
'migration source fields')
require(source['path'] == str(Path(launch['manager_backing'])/'federation/pending_requests.json'),
'migration source must be retained private manager history')
require(all(type(source[k]) is int and source[k] >= 0 for k in ('device','inode','uid','gid','bytes')) and
source['inode'] > 0 and 0 < source['bytes'] <= 4*1024*1024 and
(source['uid'],source['gid']) == (launch['runtime_uid'],launch['runtime_gid']) and
policy_module.hex_digest(source['sha256']), 'migration source custody/size/digest')
class LegacyImport:
def __init__(self, digest, launch):
require(os.getresuid() == (0,0,0) and os.getresgid() == (0,0,0), 'root migration coordinator required')
require(policy_module.hex_digest(digest), 'content-addressed migration approval required')
self.source_fd = None
self.journal = None
self.policy = policy_module.Policy()
require(Path(__file__).resolve().parent == Path('/usr/lib/archipelago') and
'/usr/lib/archipelago' in self.policy.value['trees'],
'migration coordinator closure must be in reviewed access inventory')
current, launch_digest = policy_module.json_read(policy_module.LAUNCH)
require(current == launch, 'migration launch changed')
self.path = APPROVALS/(digest+'.json')
self.value, actual = policy_module.json_read(self.path)
require(actual == digest, 'migration approval digest differs')
validate(self.value, launch, launch_digest, self.policy.digest)
require(self.value['authorization']==self.policy.value['authorization'],'node migration authority differs from retired-writer policy')
refuse_existing_legacy_anchor(launch)
self.digest = digest
self.launch = launch
c01_network.require_private(os.getpid(), 'managed')
manager = views.require_private_backing(launch['manager_backing'])
os.close(manager)
identity = Path(launch['manager_backing'])/'identity/nostr_pubkey'
identity_fd = views._open_locator(identity, directory=False)
try:
# O_PATH pins the actual locator; open the pinned object read-only.
with open(f'/proc/self/fd/{identity_fd}', 'rb') as stream:
public = stream.read(129)
require(hashlib.sha256(public).hexdigest() == self.value['source_public_sha256'],
'migration public identity changed')
from c01_admission import require_public_identity
require_public_identity(public, bytes(launch['binding']['local_identity']))
finally:os.close(identity_fd)
source = self.value['source']
path_fd = views._open_locator(source['path'], directory=False)
try:self.source_fd = os.open(f'/proc/self/fd/{path_fd}', os.O_RDONLY|os.O_CLOEXEC)
finally:os.close(path_fd)
try:
self.source_identity = self.identity()
self.source_pin = views.PinnedSource.capture(self.source_fd, source['path'])
require(self.source_identity[:5] == (source['device'],source['inode'],source['uid'],source['gid'],source['bytes']),
'migration source appointment changed')
with os.fdopen(os.dup(self.source_fd),'rb') as stream:
require(hashlib.file_digest(stream,'sha256').hexdigest() == source['sha256'],
'migration source bytes changed')
require(self.identity() == self.source_identity, 'migration source changed while hashing')
# Hold the real access-owner lock through import/retirement. The
# access service cannot begin serving against a half-imported store.
self.journal = access.Journal(self.policy)
except BaseException:self.close();raise
def identity(self):
m = os.fstat(self.source_fd)
require(stat.S_ISREG(m.st_mode) and m.st_nlink == 1, 'migration source type/alias')
return m.st_dev,m.st_ino,m.st_uid,m.st_gid,m.st_size,m.st_mtime_ns,m.st_ctime_ns,m.st_mode
def check(self, owner):
# Recheck the real retirement controls; a persisted retired label alone
# is not writer exclusion. No runtime user process may exist in this
# cold data-only phase, including a PAM/status/Podman helper.
require(policy_module.json_read(self.path)[1] == self.digest, 'migration approval changed')
require(policy_module.json_read(policy_module.CONFIG)[1] == self.policy.digest, 'migration access policy changed')
current, _ = policy_module.json_read(policy_module.STATE/'journal.json')
access.completed_history(current, self.policy)
require(current['state'] == 'retired' and current['configuration_sha256'] == self.policy.digest and
current['boot'] == Path('/proc/sys/kernel/random/boot_id').read_text().strip(),
'migration requires reconciled same-boot retirement')
self.policy.verify_files()
self.policy.verify_trees()
self.policy.preserved()
kernel.verify_private(self.policy)
for cohort in self.policy.value['cohorts']:
kernel.replacement_tree(cohort, self.policy.runtime)
access.semantic_checks(self.policy)
access.ssh_checks(self.policy)
refuse_existing_legacy_anchor(self.launch)
allowed = {record.pid for record in owner.children.children.values()}
accounts = self.policy.value['accounts']
for process in kernel.all_processes():
require(accounts['runtime']['uid'] not in process['uids'], 'migration runtime writer still exists')
if any(accounts[role]['uid'] in process['uids'] for role in ('manager','authority')):
require(process['pid'] in allowed, 'migration foreign protected-role process')
require(self.identity() == self.source_identity, 'migration source changed after exclusion')
named=self.source_pin.reopen()
os.close(named)
def native_appointment(self):
source = self.value['source']
return dict(source_device=source['device'],source_inode=source['inode'],source_uid=source['uid'],
source_gid=source['gid'],source_sha256=list(bytes.fromhex(source['sha256'])),
approval_sha256=list(bytes.fromhex(self.digest)),approved_watermark=self.value['approved_watermark'])
def close(self):
if self.source_fd is not None:
os.close(self.source_fd);self.source_fd = None
if self.journal is not None:
self.journal.close();self.journal = None