153 lines
8.8 KiB
Python
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
|