Files
archy/scripts/c01_access_policy.py

480 lines
24 KiB
Python

"""Fixed, root-protected C01 access policy and guarded filesystem operations.
There is deliberately no RPC for installing policy, selecting code or paths, or
supplying observations. Only the offline `transition` entry consumes this file.
The manifest is an operator-reviewed inventory, not evidence that keys were never
copied. An unresolved/external signing copy prevents transition.
"""
import hashlib
import json
import os
from pathlib import Path
import re
import stat
CONFIG = Path('/etc/archipelago-c01/access.json')
STAGED = Path('/etc/archipelago-c01/access-policy')
STATE = Path('/var/lib/archipelago-c01/access')
RUNTIME = Path('/etc/archipelago-c01/runtime.json')
LAUNCH = Path('/etc/archipelago-c01/launch.json')
LIMIT = 1024 * 1024
TARGETS = {
'passwd': Path('/etc/passwd'), 'shadow': Path('/etc/shadow'),
'group': Path('/etc/group'), 'gshadow': Path('/etc/gshadow'),
'sudoers': Path('/etc/sudoers'),
'polkit': Path('/etc/polkit-1/rules.d/00-archipelago-c01.rules'),
'sshd': Path('/etc/ssh/sshd_config'),
'cron-deny': Path('/etc/cron.deny'), 'at-deny': Path('/etc/at.deny'),
}
def require(value, message):
if not value:
raise ValueError(message)
def exact(pairs):
result = {}
for key, value in pairs:
require(key not in result, 'duplicate access-policy key')
result[key] = value
return result
def canonical(path):
path = Path(path)
require(path.is_absolute() and '..' not in path.parts and str(path) != '/', 'canonical absolute path required')
return path
def protected(path, private=False, directory=False):
path = canonical(path)
for parent in reversed(path.parents):
meta = parent.lstat()
require(stat.S_ISDIR(meta.st_mode) and meta.st_uid == 0 and not meta.st_mode & 0o022,
'mutable access-policy ancestry')
meta = path.lstat()
require(meta.st_uid == 0 and not meta.st_mode & (0o077 if private else 0o022) and
(stat.S_ISDIR(meta.st_mode) if directory else stat.S_ISREG(meta.st_mode)) and
(directory or meta.st_nlink == 1), 'unprotected access-policy object: ' + str(path))
return meta
def read(path, private=False):
before = protected(path, private)
require(0 <= before.st_size <= LIMIT, 'access input size')
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
try:
def stable(meta):
# Reading may legitimately advance atime (including relatime on
# private tmpfs). It is not part of content/ownership identity.
fields = ('st_dev', 'st_ino', 'st_mode', 'st_uid', 'st_gid', 'st_nlink',
'st_size', 'st_mtime_ns', 'st_ctime_ns')
return all(getattr(meta, key) == getattr(before, key) for key in fields)
require(stable(os.fstat(fd)), 'access input replaced')
raw = os.read(fd, LIMIT + 1)
require(len(raw) == before.st_size and stable(os.fstat(fd)), 'access input changed')
return raw
finally:
os.close(fd)
def sha(raw):
return hashlib.sha256(raw).hexdigest()
def hex_digest(value):
return type(value) is str and len(value) == 64 and all(c in '0123456789abcdef' for c in value)
def json_read(path):
raw = read(path, True)
return json.loads(raw, object_pairs_hook=exact), sha(raw)
def identity(meta):
return [meta.st_dev, meta.st_ino, meta.st_uid, meta.st_gid, stat.S_IMODE(meta.st_mode)]
def open_path(path, directory=False):
"""No-follow traversal, including runtime-owned legacy ancestry."""
path = canonical(path)
fd = os.open('/', os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC)
try:
for index, component in enumerate(path.parts[1:], 1):
flags = os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC
if index < len(path.parts) - 1 or directory:
flags |= os.O_DIRECTORY
child = os.open(component, flags, dir_fd=fd)
os.close(fd)
fd = child
require(not stat.S_ISLNK(os.fstat(fd).st_mode), 'alias in access inventory')
return fd
except BaseException:
os.close(fd)
raise
def atomic(path, raw, mode=0o600):
"""Never truncate a credential/policy file in place; no secret diagnostics."""
protected(path.parent, directory=True)
temporary = path.with_name('.' + path.name + '.c01-new')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, mode)
try:
os.fchmod(fd, mode)
require(os.write(fd, raw) == len(raw), 'short policy write')
os.fsync(fd)
finally:
os.close(fd)
os.replace(temporary, path)
parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
try:
os.fsync(parent)
finally:
os.close(parent)
def sudo_policy(operator, authentication="password"):
require(authentication in ("password", "preserve-verified-noninteractive"),
"finite administrator sudo authentication choice required")
require(authentication == "password" or operator == "archipelago-admin",
"noninteractive preservation is restricted to the separate administrator")
tag = "NOPASSWD: " if authentication == "preserve-verified-noninteractive" else ""
# No include, group, netgroup, LDAP or runtime-specific positive grant.
return ('Defaults env_reset\nDefaults use_pty\nroot ALL=(ALL:ALL) ALL\n' +
operator + ' ALL=(ALL:ALL) ' + tag + 'ALL\n').encode()
def polkit_policy(names):
return ('polkit.addRule(function(action, subject) {\n' +
' if (subject.user !== "root" && subject.user !== "archipelago-admin" &&\n' +
' (action.id.indexOf("org.freedesktop.login1.") === 0 ||\n' +
' action.id.indexOf("org.freedesktop.systemd1.") === 0 ||\n' +
' action.id.indexOf("org.freedesktop.timedate1.") === 0 ||\n' +
' action.id === "org.freedesktop.policykit.exec")) return polkit.Result.NO;\n' +
' if (' + ' || '.join('subject.user === ' + json.dumps(name) for name in names) +
') return polkit.Result.NO;\n});\n').encode()
def ssh_policy(baseline, names):
"""Finite transformation of an already verified administrator configuration.
Deliberately support global-only, self-contained configurations. Includes or
Match blocks need separately reviewed flattening BEFORE this transaction;
sampling addresses cannot establish a universal denial across those blocks.
"""
require(b'\\' not in baseline and b'\x00' not in baseline, 'unsupported SSH continuation/input')
for line in baseline.decode().splitlines():
text = line.strip()
if not text or text.startswith('#'):
continue
match = re.match(r'^([a-zA-Z][a-zA-Z0-9]*)(?:\s|=|$)', text)
require(match is not None and match[1].lower() not in ('include', 'match', 'denyusers'),
'SSH baseline must be global, self-contained and without DenyUsers')
return ('DenyUsers ' + ' '.join(names) + '\n').encode() + baseline
def ssh_baseline(postimage, names, before):
prefix = ('DenyUsers ' + ' '.join(names) + '\n').encode()
require(postimage.startswith(prefix), 'global managed SSH denial missing')
baseline = postimage[len(prefix):]
require(sha(baseline) == before and ssh_policy(baseline, names) == postimage,
'SSH transformation must preserve exact verified administrator baseline')
return baseline
class Policy:
def __init__(self):
self.value, self.digest = json_read(CONFIG)
v = self.value
fields = {'version', 'runtime_sha256', 'launch_sha256', 'operator', 'accounts',
'files', 'trees', 'cohorts', 'copies', 'preserved', 'restart_units',
'broker_cgroup', 'supervisor_cgroup', 'inventory_scope', 'authorization'}
if v.get('version') in (2,3): fields.add('d09_workspace')
if v.get('version')==3: fields.add('migration_layout')
if 'operator_sudo_authentication' in v:
require(v.get('version') == 3 and v['operator_sudo_authentication'] in
('password', 'preserve-verified-noninteractive'), 'administrator sudo appointment')
fields.add('operator_sudo_authentication')
require(set(v) == fields,
'access manifest schema')
require(type(v['version']) is int and v['version'] in (1,2,3) and
v['authorization'] in ('one-qualified-guarded-framework-migration',
'one-qualified-guarded-yaya-migration') and
v['inventory_scope'] == 'complete-known-writers-and-copies-no-unresolved-signers',
'explicit migration authority/inventory required')
self.runtime, digest = json_read(RUNTIME)
require(digest == v['runtime_sha256'], 'runtime configuration binding changed')
self.launch, digest = json_read(LAUNCH)
import c01_access_generation
self.generations = c01_access_generation.Generations(v, self.digest, self.launch, digest)
if v['version'] in (2,3):
require(all(not target.is_relative_to(canonical(path)) for path in v['trees']
for target in (LAUNCH,Path('/usr/local/bin/archipelago'))),
'native/launch must use explicit generation pins, not overlapping whole-tree pins')
r, launch = self.runtime, self.launch
require(v['operator'] == {'name': 'archipelago-admin', 'uid': 1001, 'gid': 1001},
'separate approved operator required')
require(set(v['accounts']) == {'runtime', 'manager', 'authority'}, 'managed account roles')
for role, account in v['accounts'].items():
require(set(account) == {'name', 'uid', 'gid'} and account['name'].replace('-', '').isalnum(),
'account schema')
require((account['uid'], account['gid']) == (r[role + '_uid'], r[role + '_gid']),
'principal differs from runtime binding')
require(v['accounts']['runtime'] == {'name': r['runtime_user'], 'uid': 1000, 'gid': 1000},
'preserve original Framework runtime identity')
require(v['accounts']['manager']['name'] == launch['application_user'] and
v['accounts']['authority']['name'] == launch['authority_user'] and
(r['operator_uid'], r['operator_gid']) == (1001, 1001), 'launch/operator binding')
require(all(launch[key] == r[key] for key in ('runtime_uid', 'runtime_gid', 'runtime_data_dir')),
'launch/runtime preservation binding differs')
for kind in ('uid', 'gid'):
ids = [a[kind] for a in [*v['accounts'].values(), v['operator']]]
require(all(type(n) is int and 0 < n < 2**32 - 1 for n in ids) and len(set(ids)) == 4,
'principal overlap')
allocations = r['sub' + kind + 's']
require(type(allocations) is list and allocations, 'original subordinate map inventory missing')
for pair in allocations:
require(type(pair) is list and len(pair) == 2 and
all(type(n) is int for n in pair) and pair[0] > 0 and pair[1] > 0 and
pair[0] + pair[1] < 2**32 and
not any(pair[0] <= principal < pair[0] + pair[1] for principal in ids),
'rootless mapping can impersonate an exclusive/operator principal')
require(set(v['files']) == set(TARGETS), 'complete finite policy transaction required')
for item in v['files'].values():
require(set(item) == {'before', 'after'} and
(item['before'] is None or hex_digest(item['before'])) and hex_digest(item['after']),
'policy preimage/postimage digest required')
require(v['cohorts'] and v['copies'] and v['preserved'] and v['restart_units'] and v['trees'],
'empty migration inventory')
require(len(set(v['restart_units'])) == len(v['restart_units']), 'duplicate mask destinations')
for unit in v['restart_units']:
require(type(unit) is str and '/' not in unit and unit.endswith(('.service', '.socket', '.timer', '.path'))
and unit.replace('-', '').replace('@', '').replace('.', '').replace('_', '').isalnum(),
'invalid fixed restart unit')
require(unit not in ('ssh.service', 'sshd.service', 'archipelago-c01-access.service'),
'operator/owner restart path cannot be retired')
for role in ('manager', 'authority'):
require(f'user@{v["accounts"][role]["uid"]}.service' in v['restart_units'],
'managed user manager must be masked')
for field in ('broker_cgroup', 'supervisor_cgroup'):
require(str(canonical(v[field])).startswith('/sys/fs/cgroup/') and v[field].endswith('.service'),
'fixed service cgroup required')
if v['version']==3:
import c01_migration_layout
c01_migration_layout.validate(v['migration_layout'],self.launch,self.runtime)
self.validate_staged()
@property
def names(self):
return [self.value['accounts'][role]['name'] for role in ('runtime', 'manager', 'authority')]
def validate_staged(self):
v = self.value
staged = {name: read(STAGED / name, True) for name in TARGETS}
require(all(sha(raw) == v['files'][name]['after'] for name, raw in staged.items()),
'staged policy digest changed')
require(staged['sudoers'] == sudo_policy(v['operator']['name'], v.get('operator_sudo_authentication', 'password')) and
staged['polkit'] == polkit_policy(self.names), 'finite privilege policy differs')
ssh_baseline(staged['sshd'], self.names, v['files']['sshd']['before'])
for name in ('cron-deny', 'at-deny'):
require(set(self.names) <= set(staged[name].decode().splitlines()), 'job submission not denied')
# Identity files are reviewed whole-file preimages, but only the three
# managed accounts may change. Existing operator hashes are never logged.
for name in ('passwd', 'shadow', 'group', 'gshadow'):
current = read(TARGETS[name]).decode().splitlines()
future = staged[name].decode().splitlines()
def rows(lines):
pairs = [line.split(':') for line in lines]
require(len({row[0] for row in pairs}) == len(pairs), 'duplicate account row')
return {row[0]: row for row in pairs}
old, new = rows(current), rows(future)
require(set(old) == set(new), 'access transition cannot create/delete accounts')
if name in ('passwd', 'shadow'):
require(all(row == new[key] for key, row in old.items() if key not in self.names),
'unrelated account/operator change')
if name == 'passwd':
for account in v['accounts'].values():
row = new[account['name']]
require(len(row) == 7 and row[:6] == old[account['name']][:6] and
(int(row[2]), int(row[3])) == (account['uid'], account['gid']) and
row[6] in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'),
'account identity/home preservation or login lock failed')
require(sum(int(r[2]) == account['uid'] for r in new.values()) == 1, 'UID alias')
elif name == 'shadow':
for key in self.names:
require(len(new[key]) == 9 and new[key][1].startswith('!') and
new[key][1] in (old[key][1], '!' + old[key][1]) and
new[key][2:] == old[key][2:], 'managed password must be locked')
else:
for key, row in old.items():
require(len(row) == 4 and len(new[key]) == 4 and row[:2] == new[key][:2],
'group metadata changed')
if name == 'group':
require(row[2] == new[key][2], 'group ID changed')
fields = (3,)
else:
fields = (2, 3)
for field in fields:
require(new[key][field].split(',') ==
[x for x in row[field].split(',') if x not in self.names] or
(not new[key][field] and all(x in self.names for x in row[field].split(','))),
'only managed supplementary memberships may be removed')
return staged
def verify_files(self):
for name, target in TARGETS.items():
require(sha(read(target)) == self.value['files'][name]['after'], 'installed access policy changed: ' + name)
self.generations.check()
try:
launch_digest=json_read(LAUNCH)[1]
except FileNotFoundError:
# The exact launch pathname can be absent between the two admitted
# renames. Pending blocks all supervisor leases; runtime/data policy
# is invariant and must not be revoked by that legitimate interval.
require(self.generations.value['pending'] is not None,'active launch missing')
launch_digest=None
require(json_read(RUNTIME)[1] == self.value['runtime_sha256'] and
(launch_digest in self.generations.allowed() or (launch_digest is None and self.generations.value['pending'] is not None)) and
json_read(CONFIG)[1] == self.digest, 'protected configuration changed')
def preflight_files(self):
staged = self.validate_staged()
for name, target in TARGETS.items():
protected(target.parent, directory=True)
expected = self.value['files'][name]['before']
if expected is None:
require(not target.exists() and not target.is_symlink(), 'new policy path already exists')
else:
require(sha(read(target)) == expected, 'policy preimage differs: ' + name)
return staged
def install_files(self):
staged = self.preflight_files()
# No rollback to old credentials/privileges after a partial operation.
# A failure retains the pending journal and blocks every new admission.
for name, target in TARGETS.items():
expected = self.value['files'][name]['before']
require(not os.path.lexists(target) if expected is None else sha(read(target)) == expected,
'policy preimage changed before replacement: ' + name)
mode = 0o600 if name in ('shadow', 'gshadow') else 0o644
if name == 'sudoers':
mode = 0o440
atomic(target, staged[name], mode)
def preserved(self):
required = {self.runtime[k] for k in ('home', 'graphroot', 'runtime_data_dir')}
require(required <= {item['path'] for item in self.value['preserved']}, 'preservation roots missing')
for item in self.value['preserved']:
require(set(item) == {'path', 'identity'}, 'preserved locator schema')
fd = open_path(item['path'], True)
try:
require(identity(os.fstat(fd)) == item['identity'], 'preserved root metadata changed')
finally:
os.close(fd)
def verify_trees(self):
"""Exact root-reviewed effective-policy inventory; no symlink blind spots.
Includes distro policy/plugin/unit/PAM/NSS/cron paths. This is an input
integrity control, not a claim that arbitrary policy semantics were proven.
Required semantic probes and kernel namespace checks are separate.
"""
mandatory = {'/etc/sudo.conf', '/etc/nsswitch.conf', '/etc/pam.d', '/etc/ssh',
'/etc/polkit-1', '/usr/share/polkit-1/rules.d', '/etc/systemd/system',
'/etc/cron.d', '/var/spool/cron', '/etc/subuid', '/etc/subgid'}
require(mandatory <= set(self.value['trees']), 'effective policy inventory incomplete')
exclusions=self.generations.workspace_exclusions()
for text, expected in self.value['trees'].items():
path = canonical(text)
require(path.is_relative_to('/etc') or path.is_relative_to('/usr') or
path == Path('/var/spool/cron'), 'unsupported policy inventory root')
require(tree_digest(path,exclusions) == expected, 'policy inventory drift: ' + text)
def policy_link_target(parent, text):
"""Validate directory components before applying any parent traversal.
Lexical abspath would erase `directory-alias/..` even though the kernel
follows the alias first. Directory aliases are deliberately unsupported.
Ordinary protected ../file links retain their real loader semantics.
"""
cursor = Path('/') if text.startswith('/') else parent
parts = text.split('/')
for index, part in enumerate(parts):
if part in ('', '.'):
continue
if part == '..':
# The previous normal component was checked before reaching here.
cursor = cursor.parent
else:
cursor = cursor / part
if index < len(parts) - 1 and cursor != Path('/'):
protected(cursor, directory=True)
return cursor
def tree_digest(path, exclusions=()):
"""Canonical metadata+content digest; root-only writes; bounded, no links.
/etc/systemd and distro trees often contain trusted links. They are recorded
verbatim, and every link target chain must independently be root protected.
Directory aliases are refused rather than silently omitting descendants.
"""
rows = []
def walk(item):
if item in exclusions:
return
require(len(rows) < 16384, 'policy tree bound')
meta = item.lstat()
require(meta.st_uid == 0 and (stat.S_ISLNK(meta.st_mode) or not meta.st_mode & 0o022),
'mutable effective policy')
entry = [str(item.relative_to(path)), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid]
if stat.S_ISLNK(meta.st_mode):
# Validate every lexical hop, not only the final resolved target.
cursor = item
closure = []
visited = set()
while cursor.is_symlink():
require(str(cursor) not in visited and len(visited) < 32, 'policy alias cycle')
visited.add(str(cursor))
for parent in cursor.parents:
parent_meta = parent.lstat()
require(stat.S_ISDIR(parent_meta.st_mode) and parent_meta.st_uid == 0 and
not parent_meta.st_mode & 0o022, 'mutable policy alias ancestry')
link_meta = cursor.lstat()
require(link_meta.st_uid == 0, 'mutable policy alias')
text = os.readlink(cursor)
# Link inode allocation is not policy (new /dev/null masks must
# be precomputable before installation). Pin every lexical hop,
# ownership/mode/link count and text; pin final regular identity.
closure.append([str(cursor), link_meta.st_uid, link_meta.st_gid,
stat.S_IMODE(link_meta.st_mode), link_meta.st_nlink, text])
cursor = policy_link_target(cursor.parent, text)
target = cursor
require(not target.is_dir(), 'policy directory alias refused')
# /dev/null masks are the sole special object admitted.
if target != Path('/dev/null'):
target_meta = protected(target)
closure.append([str(target), identity(target_meta), sha(read(target))])
else:
protected(target.parent, directory=True)
target_meta = target.lstat()
require(stat.S_ISCHR(target_meta.st_mode) and target_meta.st_uid == 0 and
target_meta.st_rdev == os.makedev(1, 3), 'invalid null mask target')
# devtmpfs inode/device allocation changes across boot. The
# tightly identified null device's type/rdev/permissions do not.
closure.append([str(target), target_meta.st_uid, target_meta.st_gid,
stat.S_IMODE(target_meta.st_mode), target_meta.st_rdev])
entry += ['link-closure', closure]
elif stat.S_ISDIR(meta.st_mode):
entry += ['directory']
else:
entry += ['file', sha(read(item))]
rows.append(entry)
if stat.S_ISDIR(meta.st_mode):
for child in sorted(item.iterdir()):
walk(child)
walk(path)
return sha(json.dumps(rows, separators=(',', ':')).encode())