Files
archy/scripts/fips_wg_owner.py

349 lines
17 KiB
Python

#!/usr/bin/env python3
"""External deployment-only finite FIPS/WG owner. Not an application command broker.
Inputs are inherited root/bundle directory FDs, never application paths. Exact
source bytes and executable digests are appointed by the deployment principal.
Existing assets must match exactly: migration/rotation is deliberately separate.
"""
import base64
import hashlib
import ipaddress
import json
import os
import stat
import subprocess
import selectors
import time
import yaml
class Refused(Exception):
pass
def require(ok, reason):
if not ok:
raise Refused(reason)
# Finite destination set. No unit/script/path field in runtime requests.
ASSETS = {
'fips.yaml': ('etc/fips/fips.yaml', 0o644),
'fips.key': ('etc/fips/fips.key', 0o600),
'fips.pub': ('etc/fips/fips.pub', 0o644),
'fips.nft': ('etc/fips/fips.nft', 0o644),
'80-web-ui.nft': ('etc/fips/fips.d/80-web-ui.nft', 0o644),
'85-app-ports.nft': ('etc/fips/fips.d/85-app-ports.nft', 0o644),
'90-ssh.nft': ('etc/fips/fips.d/90-ssh.nft', 0o644),
'wg.public': ('etc/archipelago-fips-wg/wg.public', 0o644),
}
PROGRAMS = {'wg': 'usr/bin/wg', 'systemctl': 'usr/bin/systemctl'}
def protected(fd, directory=False, secret=False):
s = os.fstat(fd)
require(s.st_uid == os.geteuid() and s.st_mode & 0o022 == 0,
'unprotected owner object')
require(stat.S_ISDIR(s.st_mode) if directory else
stat.S_ISREG(s.st_mode) and s.st_nlink == 1, 'aliased owner object')
if secret:
require(s.st_mode & 0o077 == 0, 'exposed private key')
def open_beneath(root, path, directory=False):
require(all(p not in ('', '.', '..') for p in path.split('/')), 'invalid internal path')
fd = os.dup(root)
try:
protected(fd, True)
parts = path.split('/')
for i, part in enumerate(parts):
is_dir = i < len(parts) - 1 or directory
new = os.open(part, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW |
(os.O_DIRECTORY if is_dir else 0), dir_fd=fd)
os.close(fd)
fd = new
protected(fd, is_dir)
return fd
except BaseException:
os.close(fd)
raise
def read(root, path, secret=False, limit=65536):
fd = open_beneath(root, path)
try:
protected(fd, secret=secret)
data = os.read(fd, limit + 1)
require(len(data) <= limit, 'owner input exceeds bound')
return data
finally:
os.close(fd)
def digest(data):
return hashlib.sha256(data).hexdigest()
def decode_bech32(text, hrp):
require(text.startswith(hrp + '1'), 'invalid FIPS key prefix')
alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
values = [alphabet.index(c) for c in text[len(hrp) + 1:]]
chk = 1
for v in [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + values:
top = chk >> 25
chk = ((chk & 0x1ffffff) << 5) ^ v
for i, g in enumerate([0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]):
if (top >> i) & 1: chk ^= g
require(chk == 1 and len(values) == 58, 'invalid FIPS key encoding')
bits = ''.join(f'{v:05b}' for v in values[:-6])
require(bits[256:] == '0000', 'invalid FIPS key padding')
return int(bits[:256], 2).to_bytes(32, 'big')
def public_bytes(data):
return data if len(data) == 32 else decode_bech32(data.decode('ascii').strip(), 'npub')
def public_from_secret(data):
# FIPS uses secp256k1 x-only public keys. Decode nsec without a subprocess,
# argv, temp file or secret-bearing diagnostics. No key generation occurs.
text = data.decode('ascii').strip()
if text.startswith('nsec1'):
scalar = int.from_bytes(decode_bech32(text, 'nsec'), 'big')
else:
require(len(text) == 64, 'invalid FIPS key encoding')
scalar = int(text, 16)
p = 2**256 - 2**32 - 977
n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141
require(0 < scalar < n, 'invalid FIPS scalar')
g = (0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798,
0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8)
def add(a, b):
if a is None: return b
if b is None: return a
x, y = a
u, v = b
if x == u and (y + v) % p == 0: return None
slope = ((3*x*x) * pow(2*y, -1, p) if a == b else
(v-y) * pow(u-x, -1, p)) % p
z = (slope*slope-x-u) % p
return z, (slope*(x-z)-y) % p
point = None
for bit in bin(scalar)[2:]:
point = add(point, point)
if bit == '1': point = add(point, g)
return point[0].to_bytes(32, 'big')
def peer(value):
require(set(value) == {'public_key', 'ip'}, 'unknown peer fields')
key = base64.b64decode(value['public_key'], validate=True)
require(len(key) == 32 and key != bytes(32) and
base64.b64encode(key).decode() == value['public_key'], 'invalid WG key')
ip = ipaddress.IPv4Interface(value['ip'])
require(str(ip) == value['ip'] and ip.network.prefixlen == 32 and
ip.ip in ipaddress.IPv4Network('10.44.0.0/24') and
2 <= int(ip.ip) % 256 <= 254, 'invalid managed peer address')
return value['public_key'], value['ip']
class Owner:
def __init__(self, root, bundle, network=None):
self.root, self.bundle = root, bundle
self.network = network
self.plan = json.loads(read(bundle, 'plan.json'))
require(set(self.plan) == {'version', 'assets', 'programs', 'fips', 'wg', 'peers'}, 'unknown plan fields')
require(self.plan['version'] == 1, 'unknown owner version')
require(set(self.plan['assets']) == set(ASSETS), 'exact asset inventory required')
require(set(self.plan['programs']) == set(PROGRAMS), 'exact executable pins required')
for name in ('fips', 'wg'):
require(self.plan[name] in ('running', 'stopped', 'uninstalled'), 'missing operator disposition')
require(len(self.plan['peers']) <= 253, 'peer bound exceeded')
self.peers = [peer(v) for v in self.plan['peers']]
require(len({k for k, _ in self.peers}) == len(self.peers) and
len({a for _, a in self.peers}) == len(self.peers), 'peer collision')
self.sources = {}
for name in ASSETS:
data = read(bundle, name, name == 'fips.key')
require(digest(data) == self.plan['assets'][name], 'source digest mismatch')
self.sources[name] = data
require(public_from_secret(self.sources['fips.key']) == public_bytes(self.sources['fips.pub']), 'FIPS identity mismatch')
config = yaml.safe_load(self.sources['fips.yaml'])
require(isinstance(config, dict) and
config.get('node', {}).get('identity', {}).get('persistent') is True,
'FIPS must retain the installed persistent identity')
key = base64.b64decode(self.sources['wg.public'].strip(), validate=True)
require(len(key) == 32 and key != bytes(32), 'invalid WG server identity')
def check_assets(self, allow_absent=False):
missing = []
for name, (path, mode) in ASSETS.items():
try:
data = read(self.root, path, name == 'fips.key')
except FileNotFoundError:
require(allow_absent, 'protected prerequisite absent')
# Parent provisioning is external; no recursive mkdir broker.
parent = open_beneath(self.root, path.rsplit('/', 1)[0], True)
os.close(parent)
missing.append(name)
continue
require(data == self.sources[name], 'existing asset differs; preserve and migrate explicitly')
return missing
def run(self, program, args):
require(self.network is not None, 'deployment network capability required')
current = os.stat('/proc/self/ns/net')
appointed = os.fstat(self.network)
require((current.st_dev, current.st_ino) == (appointed.st_dev, appointed.st_ino), 'wrong owner network namespace')
fd = open_beneath(self.root, PROGRAMS[program])
try:
# Hash the retained executable, then execute that same descriptor.
h = hashlib.sha256()
while chunk := os.read(fd, 65536): h.update(chunk)
require(h.hexdigest() == self.plan['programs'][program], 'executable digest mismatch')
os.lseek(fd, 0, 0)
require(os.read(fd, 4) == b'\x7fELF', 'owner requires ELF tool')
with subprocess.Popen([f'/proc/self/fd/{fd}', *args], pass_fds=(fd,),
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, cwd='/',
env={'PATH': '/usr/bin:/usr/sbin', 'LC_ALL': 'C'}) as child:
output = bytearray()
os.set_blocking(child.stdout.fileno(), False)
try:
with selectors.DefaultSelector() as selector:
selector.register(child.stdout, selectors.EVENT_READ)
end = time.monotonic() + 1
while True:
require(time.monotonic() < end, 'owner deadline exceeded')
if selector.select(min(0.02, end - time.monotonic())):
chunk = os.read(child.stdout.fileno(), 4096)
if not chunk: break
output.extend(chunk)
require(len(output) <= 65536, 'owner output exceeds bound')
child.wait(timeout=max(0.001, end - time.monotonic()))
except BaseException:
child.kill(); child.wait()
raise Refused('owner effect uncertain; retained intent blocks replay') from None
require(child.returncode == 0 and len(output) <= 65536, 'owner operation failed or uncertain')
return output
finally:
os.close(fd)
def kernel_peers(self):
require(self.plan['wg'] == 'running', 'WG stopped/uninstalled decision')
require(self.run('wg', ['show', 'wg0', 'public-key']).strip() == self.sources['wg.public'].strip(), 'live WG identity mismatch')
entries = {}
for line in self.run('wg', ['show', 'wg0', 'allowed-ips']).decode('ascii').splitlines():
key, addresses = line.split('\t', 1)
require(key not in entries, 'duplicate live peer')
entries[key] = addresses.split()
return entries
def verify(self, request):
self.check_assets()
if request == 'FipsActive':
request = {'FipsActive': None}
require(len(request) == 1, 'invalid finite request')
if 'FipsIdentity' in request:
value = request['FipsIdentity']
require(set(value) == {'public'} and bytes(value['public']) == public_bytes(self.sources['fips.pub']), 'requested identity mismatch')
elif 'FipsActive' in request:
require(request['FipsActive'] is None and self.plan['fips'] == 'running', 'FIPS stopped/uninstalled decision')
require(self.run('systemctl', ['is-active', 'archipelago-fips.service']).strip() == b'active', 'FIPS not active')
elif 'WgPeer' in request:
value = request['WgPeer']
require(set(value) == {'public', 'address'}, 'unknown peer request fields')
key, address = peer({'public_key': base64.b64encode(bytes(value['public'])).decode(), 'ip': value['address'] + '/32'})
require((key, address) in self.peers, 'peer lacks external admission')
require(self.kernel_peers().get(key) == [address], 'peer not restored exactly')
else:
raise Refused('unknown finite operation')
return b'ok'
def provision(self, activate=False):
require(not activate or self.plan['fips'] == 'running', 'FIPS stopped/uninstalled decision')
if activate:
require(self.sources['fips.key'].startswith(b'nsec1') and
self.sources['fips.key'].endswith(b'\n') and len(self.sources['fips.key']) == 64 and
self.sources['fips.pub'].startswith(b'npub1') and
self.sources['fips.pub'].endswith(b'\n') and len(self.sources['fips.pub']) == 64,
'activation requires daemon-canonical identity files; explicit representation migration required')
missing = self.check_assets(True)
plan_bytes = read(self.bundle, 'plan.json')
try:
require(read(self.root, 'etc/archipelago-fips-wg/plan.json') == plan_bytes,
'existing deployment plan differs; explicit transition required')
except FileNotFoundError:
pass
# All preimages validated before any writes. Uninstalled decisions forbid
# even installing missing configuration. No existing inode is replaced.
require(not missing or (self.plan['fips'] != 'uninstalled' and self.plan['wg'] != 'uninstalled'), 'uninstall decision forbids provisioning')
journal = open_beneath(self.root, 'etc/archipelago-fips-wg', True)
try:
intent = os.open('intent', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=journal)
with os.fdopen(intent, 'wb') as out:
out.write(b'uncertain\n'); out.flush(); os.fsync(out.fileno())
os.fsync(journal)
for name in missing:
path, mode = ASSETS[name]
parent, leaf = path.rsplit('/', 1)
directory = open_beneath(self.root, parent, True)
try:
fd = os.open(leaf, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode, dir_fd=directory)
with os.fdopen(fd, 'wb') as out:
out.write(self.sources[name]); out.flush(); os.fsync(out.fileno())
os.fsync(directory)
finally: os.close(directory)
self.check_assets()
if activate:
require(self.plan['fips'] == 'running', 'FIPS stopped/uninstalled decision')
# No enable/unmask/reset-failed/kill. Only explicitly authorized
# deployment activation; never called by normal app startup.
self.run('systemctl', ['start', 'archipelago-fips.service'])
self.verify({'FipsActive': None})
if self.plan['wg'] == 'running':
existing = self.kernel_peers()
for key, address in self.peers:
require(key not in existing or existing[key] == [address], 'existing WG peer differs')
wanted = ipaddress.IPv4Address(address[:-3])
for other, networks in existing.items():
if other != key:
require(all(wanted not in ipaddress.ip_network(net) for net in networks), 'WG allowed-IP conflict')
for key, address in self.peers:
if key not in existing:
self.run('wg', ['set', 'wg0', 'peer', key, 'allowed-ips', address])
final = self.kernel_peers()
require(all(final.get(k) == [a] for k, a in self.peers) and
all(final.get(k) == v for k, v in existing.items()), 'WG restoration verification failed')
receipt = os.open('complete', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=journal)
with os.fdopen(receipt, 'wb') as out:
out.write(b'verified\n'); out.flush(); os.fsync(out.fileno())
os.fsync(journal)
# Publish admission inputs only after effects and preservation checks.
# Never replace a previous external plan implicitly.
try:
planfd = os.open('plan.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=journal)
except FileExistsError:
require(read(self.root, 'etc/archipelago-fips-wg/plan.json') == plan_bytes, 'deployment plan changed')
else:
with os.fdopen(planfd, 'wb') as out:
out.write(plan_bytes); out.flush(); os.fsync(out.fileno())
os.fsync(journal)
finally: os.close(journal)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--root-fd', type=int, required=True)
parser.add_argument('--bundle-fd', type=int, required=True)
parser.add_argument('--network-fd', type=int, required=True)
parser.add_argument('--activate', action='store_true')
args = parser.parse_args()
try:
require(os.geteuid() == 0, 'external deployment principal must be root')
Owner(args.root_fd, args.bundle_fd, args.network_fd).provision(args.activate)
except Exception:
# Deliberately never stringify secret-bearing parse/subprocess exceptions.
raise SystemExit('FIPS/WG owner refused; preserve intent and reconcile externally') from None