Implement attended Railiance Clock host key delivery
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
codex 2026-09-15 23:00:20 +02:00
parent 3b11773469
commit e70ef2f32a
7 changed files with 284 additions and 49 deletions

View file

@ -1,14 +1,15 @@
id: CCR-2026-0028
kind: credential-change-request
schema_version: 1
request_type: workload-kv-read
request_type: attended-host-key-delivery
title: Railiance Clock authority signing key custody and host delivery
status: approved
created: '2026-09-15'
updated: '2026-09-15'
requester:
agent: codex
reason: User requested production admission of the Railiance Clock authority after the host time baseline and disposable native acceptance passed.
reason: User requested production admission of the Railiance Clock authority after
the host time baseline and disposable native acceptance passed.
review:
required: true
required_approvers:
@ -22,71 +23,83 @@ review:
- at: '2026-09-15'
reviewer: User (railiance-clock-owner)
decision: approved
comment: User explicitly approved CCR-2026-0028 for the railiance-clock-owner role.
comment: User explicitly approved CCR-2026-0028 for the railiance-clock-owner
role.
- at: '2026-09-15'
reviewer: User (platform-operator and railiance-clock-owner)
decision: approved_host_delivery
comment: User accepted the corrected systemd host-file design and instructed then
lets go; existing two-role approval retained.
target:
domain: infotech
tenant: platform
workload: railiance-clock
environment: production
purpose: Hold one ES256 authority private signing key in OpenBao and deliver it only to the private railiance01 Railiance Clock service; no public listener or consumer-wide distribution.
purpose: Hold one ES256 authority private signing key in OpenBao and deliver it
only to the private railiance01 Railiance Clock service; no public listener or
consumer-wide distribution.
openbao:
mount: platform
kv_path: platform/workloads/railiance-clock/authority-signing
fields:
- PRIVATE_KEY_PEM
- KEY_ID
metadata_read: false
token_self_lifecycle: true
policy_name: workload-kv-read-railiance-clock-authority-signing
policy_file: openbao/policies/workload-kv-read-railiance-clock-authority-signing.hcl
auth:
method: kubernetes
mount: kubernetes
role: external-secrets-railiance-clock-authority
bound_claims:
service_account_names:
- railiance-clock
service_account_namespaces:
- railiance-clock
bound_claims_confirmed: false
policies:
- workload-kv-read-railiance-clock-authority-signing
ttl: 15m
audience: openbao
token_max_ttl: 15m
token_explicit_max_ttl: 15m
token_no_default_policy: true
method: attended-oidc
mount: netkingdom
role: platform-admin
runtime_access: false
initial_cas: 0
access_frontdoor:
type: host-file-delivery
catalog_id: railiance-clock-authority-signing
selector: Private Railiance Clock authority key on railiance01
readiness: pending-review
type: attended-host-file
catalog_id: openbao-platform-admin-login
readiness: approved-pending-apply
resolvable: false
delivery:
surface: attended-host-file
target: railiance01:/etc/railiance-clock/authority-key.pem, owner railiance-clock, mode 0600; key material must be generated and delivered through the governed OpenBao platform-admin lane and never appear in Git, argv, logs, or chat.
bootstrap_command: See the attended platform custody runbook to be added with this CCR; configure the service only after key admission, public-key metadata readback, and exact artifact hash verification.
host: 92.205.62.239
ssh_user: tegwick
target: /var/lib/railiance-clock/signing.pem
owner: railiance-clock
mode: '0600'
directory_mode: '0700'
procedure: scripts/railiance_clock_custody.py
risk:
classification: high
notes:
- Compromise permits forging Railiance Clock samples until the key is revoked and every consumer trust file is replaced.
- The authority remains loopback-only on railiance01; no workstation-wide or public endpoint is admitted.
- Key generation, OpenBao write, host delivery, and rotation require an attended platform operator and railiance-clock owner review.
- Compromise permits forging Railiance Clock samples until the key is revoked and
every consumer trust file is replaced.
- The authority remains loopback-only on railiance01; no workstation-wide or public
endpoint is admitted.
- Key generation, OpenBao write, host delivery, and rotation require an attended
platform operator and railiance-clock owner review.
verification:
positive:
- Exact service identity reads only PRIVATE_KEY_PEM and KEY_ID; authority readiness and signed sample verification pass.
- Host file ownership and mode are exact; private key value is absent from all command output and logs.
- Attended platform-admin identity and OpenBao audit enabled; initial key stored
with CAS zero.
- Host account, private directory, exact key ownership and permissions verified;
public key matches custody.
- Authority readiness and independent signed sample verification pass.
negative:
- Other service accounts, namespaces, sibling paths, metadata/listing, and public listeners are denied.
- Wrong key id, wrong authority/environment/epoch, expired trust, rollback, and stale health fail closed.
- Existing key is never overwritten during initial admission; interrupted delivery
resumes only with the admitted custody version.
- Wrong key id, authority, environment, epoch, stale health and expired trust are
refused.
- No new workload role, policy or token; no public authority listener.
activation_conditions:
- CCR approved by platform-operator and railiance-clock-owner.
- Dedicated OpenBao policy, Kubernetes role, and host delivery path are applied and read back without secret disclosure.
- Fresh public-key metadata, artifact SHA-256, /readyz, and colocated sample verification are recorded.
- Existing two-role approval covers attended host delivery.
- Ansible prepares the service account and private directory before custody.
- Reviewed helper runs through warden access openbao-platform-admin-login --exec;
host key verification is required.
- Fresh public key, artifact SHA256, readiness and signed sample acceptance are
recorded.
evidence: []
lifecycle:
deactivate: Stop the authority, remove the host key, revoke the workload policy/role, and replace consumer trust files.
rotate: Stop authority and consumers, generate a new key under attended custody, deliver mode 0600, then replace trust files and verify old signatures fail.
compromised: Stop the authority immediately, revoke the policy and key version, replace all trust files, and restart only after fresh verification.
deactivate: Stop authority and withdraw consumer trust. Retain custody and host
key until owner retention decision.
rotate: Stop authority, perform separately versioned attended CAS rotation and atomic
host replacement, then admit new public key and epoch for consumers.
compromised: Stop authority immediately and withdraw all consumer trust; replace
key forward under attended custody before restarting.
state_hub:
workplan_id: RCLK-WP-0005

View file

@ -0,0 +1,36 @@
# Railiance Clock attended host delivery — CCR-2026-0028
The operator approved CCR-2026-0028 for both platform and Clock ownership, then
accepted the correction to systemd host delivery. Kubernetes is present on
Railiance01, but this authority runs as a host service and needs no Kubernetes
identity or standing OpenBao read grant.
Ansible's `railiance_clock_prepare_only=true` prepares the nologin system account,
root-owned installation directory, and service-owned mode-0700 state directory.
It does not require a key or activate the authority.
Run the committed `scripts/railiance_clock_custody.py --receipt <new-private-file>`
inside `warden access openbao-platform-admin-login --exec -- python3 ...`.
The helper requires the exact approved CCR, a non-root platform-admin session
from auth/netkingdom, and enabled audit. It generates an ES256 key in memory,
creates the exact KV path with CAS zero, and verifies version 1 before delivery.
No workload policy or role is created. SSH uses strict known-host verification;
the receiver writes a complete mode-0600 file atomically to
`/var/lib/railiance-clock/signing.pem`, owned by `railiance-clock`, and refuses to
overwrite any different existing key. Only public-key metadata enters the receipt.
If custody succeeded and host delivery failed, inspect the receipt stage/version.
Use `--resume-version 1` through a fresh attended envelope to deliver that same
initial version. This never creates or rotates a key. A changed custody version,
existing different host key, or permission drift requires reconciliation.
After successful delivery, Ansible uses `railiance_clock_key_preinstalled=true`,
checks private key metadata, installs the pinned wheel and health probe, and
starts the localhost-only authority. `railiance_clock_private_key_source` is not
needed in this mode. Readiness and independent signature verification are required
before marking the CCR verified. No client trust is inferred from readiness.
Rotation is a separate explicit operation: stop the authority, CAS against the
known current custody version, replace the host key atomically, and distribute
new public key/epoch trust before resuming clients. This initial-admission helper
deliberately does not implement rotation or overwrite keys.

View file

@ -1,3 +0,0 @@
path "platform/data/workloads/railiance-clock/authority-signing" {
capabilities = ["read"]
}

View file

@ -38,6 +38,7 @@ allowed_statuses:
allowed_request_types:
- workload-kv-read
- attended-host-key-delivery
secret_markers_rejected:
- AGE-SECRET-KEY-1
@ -157,3 +158,7 @@ guardrails:
disallowed_path_fragments:
- "*"
- ".."
# Attended host delivery is currently restricted to CCR-2026-0028.
# Exact contract validation: scripts/railiance_clock_custody.py.
# Delegated workload appliers must refuse this request type.

View file

@ -464,10 +464,16 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
if isinstance(status, str) and status not in ALLOWED_STATUSES:
errors.append(f"status must be one of {sorted(ALLOWED_STATUSES)}")
request_type = ccr.get("request_type")
if request_type != "workload-kv-read":
errors.append("request_type must be workload-kv-read")
else:
if request_type == "workload-kv-read":
validate_workload_kv_read(ccr, errors, warnings)
elif request_type == "attended-host-key-delivery":
import importlib.util
spec = importlib.util.spec_from_file_location("railiance_clock_custody", REPO_DIR / "scripts/railiance_clock_custody.py")
owner = importlib.util.module_from_spec(spec)
spec.loader.exec_module(owner)
errors.extend(owner.validate_contract(ccr))
else:
errors.append("unsupported request_type")
return ccr, errors, warnings
@ -554,6 +560,8 @@ def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str:
def generated_policy_hcl(ccr: dict[str, Any]) -> str:
if ccr.get("request_type") != "workload-kv-read":
fail("attended host delivery uses its owner procedure; no workload policy")
openbao = ccr["openbao"]
mount = openbao["mount"]
suffix = openbao["kv_path"][len(mount) + 1 :]

View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Attended CCR-2026-0028 initial custody and host delivery. Silent, resumable."""
from pathlib import Path
import argparse
import hashlib
import json
import os
import shlex
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
CCR = ROOT / 'credential-change-requests/CCR-2026-0028-railiance-clock-authority-signing.yaml'
KV = 'platform/data/workloads/railiance-clock/authority-signing'
KID = 'railiance01-clock-20260915-v1'
TARGET = '/var/lib/railiance-clock/signing.pem'
def validate_contract(ccr):
errors=[]
expected={'mount':'platform','kv_path':'platform/workloads/railiance-clock/authority-signing',
'fields':['PRIVATE_KEY_PEM','KEY_ID'],'auth':{'method':'attended-oidc','mount':'netkingdom','role':'platform-admin'},
'runtime_access':False,'initial_cas':0}
delivery={'surface':'attended-host-file','host':'92.205.62.239','ssh_user':'tegwick',
'target':TARGET,'owner':'railiance-clock','mode':'0600','directory_mode':'0700',
'procedure':'scripts/railiance_clock_custody.py'}
if ccr.get('id')!='CCR-2026-0028' or ccr.get('request_type')!='attended-host-key-delivery':
errors.append('unsupported attended host contract')
if ccr.get('openbao')!=expected or ccr.get('delivery')!=delivery:
errors.append('exact Clock custody and host binding required')
if ccr.get('access_frontdoor',{}).get('resolvable') is not False:
errors.append('runtime credential frontdoor must remain disabled')
if ccr.get('status') in {'approved','applied','verified','active'}:
comments=ccr.get('review',{}).get('comments',[])
for role in ['platform-operator','railiance-clock-owner']:
if not any(c.get('reviewer')==f'User ({role})' and c.get('decision')=='approved' for c in comments):
errors.append('explicit role approval missing: '+role)
for section,fields in {'verification':['positive','negative','activation_conditions'],
'lifecycle':['deactivate','rotate','compromised']}.items():
for field in fields:
if not ccr.get(section,{}).get(field):errors.append(section+'.'+field+' required')
return errors
class CustodyError(Exception): pass
def run_cmd(argv,payload=None):
r=subprocess.run(argv,input=payload,capture_output=True,timeout=90)
if r.returncode:raise CustodyError('contained_command_failed')
return r.stdout
def bao(args,payload=None):
return json.loads(run_cmd(['bao',*args],None if payload is None else json.dumps(payload).encode()))
REMOTE = '''import os,sys,stat,pwd,subprocess,json,hashlib
p='/var/lib/railiance-clock/signing.pem'
u=pwd.getpwnam('railiance-clock')
d=os.lstat(os.path.dirname(p))
assert stat.S_ISDIR(d.st_mode) and d.st_uid==u.pw_uid and stat.S_IMODE(d.st_mode)==0o700
key=sys.stdin.buffer.read(8193)
assert 0<len(key)<=8192
# Publish the complete key atomically; initial delivery never overwrites a key.
if not os.path.lexists(p):
tmp=p+'.admission-'+str(os.getpid())
fd=os.open(tmp,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
try:
os.fchown(fd,u.pw_uid,u.pw_gid)
with os.fdopen(fd,'wb') as f:
f.write(key);f.flush();os.fsync(f.fileno())
os.link(tmp,p)
finally:
os.unlink(tmp)
fd=os.open(p,os.O_RDONLY|os.O_NOFOLLOW)
with os.fdopen(fd,'rb') as f:
st=os.fstat(f.fileno())
assert stat.S_ISREG(st.st_mode) and st.st_uid==u.pw_uid and stat.S_IMODE(st.st_mode)==0o600
assert f.read(8193)==key
r=subprocess.run(['openssl','pkey','-pubout'],input=key,capture_output=True,check=True)
print(json.dumps({'public_key_sha256':hashlib.sha256(r.stdout).hexdigest(),'owner':'railiance-clock','mode':'0600'}))
'''
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--receipt',required=True)
p.add_argument('--resume-version',type=int)
a=p.parse_args()
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
receipt={'schema':'platform.railiance-clock-host-custody.v1','status':'failed','stage':'contract','ccr':'CCR-2026-0028'}
try:
import yaml
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
ccr=yaml.safe_load(CCR.read_text())
if validate_contract(ccr) or ccr['status'] not in {'approved','applied','verified','active'}:
raise CustodyError('approved_exact_contract_required')
receipt['stage']='attended_identity'
who=bao(['token','lookup','-format=json'])['data']
if 'platform-admin' not in who['policies'] or 'root' in who['policies'] or not who['path'].startswith('auth/netkingdom/'):
raise CustodyError('attended_platform_admin_required')
if not bao(['audit','list','-format=json']):raise CustodyError('audit_required')
receipt['stage']='custody'
if a.resume_version is None:
key=ec.generate_private_key(ec.SECP256R1())
pem=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()).decode()
result=bao(['write','-format=json',KV,'-'],{'options':{'cas':0},'data':{'PRIVATE_KEY_PEM':pem,'KEY_ID':KID}})
version=result['data']['version']
else:
if a.resume_version!=1:raise CustodyError('only_initial_version_resume_supported')
version=a.resume_version
receipt['kv_version']=version
native=bao(['read','-format=json',KV])['data']
if native['metadata']['version']!=version or version!=1:raise CustodyError('custody_version_changed')
values=native['data']
if set(values)!={'PRIVATE_KEY_PEM','KEY_ID'} or values['KEY_ID']!=KID:raise CustodyError('custody_shape_mismatch')
pem=values['PRIVATE_KEY_PEM'].encode()
key=serialization.load_pem_private_key(pem,password=None)
if not isinstance(key,ec.EllipticCurvePrivateKey) or not isinstance(key.curve,ec.SECP256R1):raise CustodyError('es256_key_required')
public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
receipt.update(kid=KID,public_key_pem=public.decode(),public_key_sha256=hashlib.sha256(public).hexdigest())
receipt['stage']='host_delivery'
remote=json.loads(run_cmd(['ssh','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=10',
'tegwick@92.205.62.239','sudo -n python3 -c '+shlex.quote(REMOTE)],pem))
if remote['public_key_sha256']!=receipt['public_key_sha256']:raise CustodyError('host_public_key_mismatch')
receipt['host']=remote
receipt['status']='delivered_pending_authority_acceptance'
receipt['stage']='complete'
except Exception as e:
receipt['error']=str(e) if isinstance(e,CustodyError) else 'internal_error'
finally:
with os.fdopen(fd,'w') as f:json.dump(receipt,f,indent=2);f.write('\n')
return 0 if receipt['status']!='failed' else 1
if __name__=='__main__':sys.exit(main())

View file

@ -0,0 +1,44 @@
import copy
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import yaml
ROOT=Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location('clock_custody',ROOT/'scripts/railiance_clock_custody.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
class CustodyTests(unittest.TestCase):
def setUp(self):self.contract=yaml.safe_load(m.CCR.read_text())
def test_valid(self):self.assertEqual(m.validate_contract(self.contract),[])
def test_scope_and_approval_drift(self):
for change in [lambda c:c['delivery'].update(host='other'),lambda c:c['openbao'].update(runtime_access=True),lambda c:c['review'].update(comments=[]),lambda c:c['openbao'].update(initial_cas=1)]:
c=copy.deepcopy(self.contract);change(c);self.assertTrue(m.validate_contract(c))
def run_failure(self,reply,expected_stage):
with tempfile.TemporaryDirectory() as d:
out=Path(d)/'receipt.json'
with patch.object(sys,'argv',['helper','--receipt',str(out)]),patch.object(m,'bao',side_effect=reply),patch.object(m,'run_cmd') as cmd:
self.assertEqual(m.main(),1);cmd.assert_not_called()
receipt=json.loads(out.read_text());self.assertEqual(receipt['stage'],expected_stage)
self.assertNotIn('PRIVATE KEY',out.read_text())
def test_root_denied(self):
self.run_failure([{'data':{'policies':['root','platform-admin'],'path':'auth/netkingdom/login'}}],'attended_identity')
def test_wrong_auth_mount_denied(self):
self.run_failure([{'data':{'policies':['platform-admin'],'path':'auth/token/create'}}],'attended_identity')
def test_audit_required(self):
self.run_failure([{'data':{'policies':['platform-admin'],'path':'auth/netkingdom/login'}},{}],'attended_identity')
def test_cas_failure_prevents_delivery(self):
self.run_failure([{'data':{'policies':['platform-admin'],'path':'auth/netkingdom/login'}},{'file/':{}},m.CustodyError('contained_command_failed')],'custody')
def test_receipt_never_overwrites(self):
with tempfile.TemporaryDirectory() as d:
out=Path(d)/'receipt.json';out.write_text('previous evidence')
with patch.object(sys,'argv',['helper','--receipt',str(out)]),patch.object(m,'bao') as b:
with self.assertRaises(FileExistsError):m.main()
b.assert_not_called()
self.assertEqual(out.read_text(),'previous evidence')
if __name__=='__main__':unittest.main()