docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
b07fbc60b1
commit
4bf35dffaa
45 changed files with 7749 additions and 0 deletions
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended native reader/ESO verification; only closed booleans leave custody."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
setup=load('identity-credential-setup');runtime=load('identity-runtime')
|
||||
from state_hub_preflight_lane import bao,data,capabilities,revoke
|
||||
|
||||
|
||||
def verify(action,expected,receipt,kubeconfig):
|
||||
receipt['phase']='validate_approved_contract'
|
||||
ccr,lane=setup.contract(action,expected)
|
||||
receipt['phase']='verify_attended_identity'
|
||||
ident=data(bao(['token','lookup','-format=json']))['data']
|
||||
if 'platform-admin' not in ident.get('policies',[]) or 'root' in ident.get('policies',[]) or not ident.get('path','').startswith('auth/netkingdom/'):raise setup.Refused()
|
||||
capture=runtime.capture;capture.KUBE=['kubectl','--kubeconfig',kubeconfig]
|
||||
receipt['phase']='verify_cluster_identity'
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise setup.Refused()
|
||||
def login(sa,audience,negative=False,namespace='sso'):
|
||||
jwt=capture.command(['-n',namespace,'create','token',sa,'--audience='+audience,'--duration=10m']).decode().strip()
|
||||
result=bao(['write','-format=json','auth/kubernetes/login','-'],payload={'role':lane['role'],'jwt':jwt},allow_failure=negative)
|
||||
if negative:
|
||||
if result.returncode==0:
|
||||
revoke(data(result)['auth']['client_token']);raise setup.Refused()
|
||||
if b'403' not in result.stderr and b'400' not in result.stderr:raise setup.Refused()
|
||||
return None
|
||||
return data(result)['auth']
|
||||
receipt['phase']='native_reader_login'
|
||||
auth=login(lane['service_account'],'openbao');token=auth['client_token']
|
||||
receipt['native_reader_metadata']={k:auth.get(k) for k in ['token_policies','identity_policies','policies','lease_duration']}
|
||||
try:
|
||||
receipt['phase']='effective_reader_policy_ttl'
|
||||
if (auth['token_policies']!=[lane['policy']] or auth.get('identity_policies')
|
||||
or auth.get('policies',auth['token_policies'])!=[lane['policy']] or not 0<auth['lease_duration']<=900):raise setup.Refused()
|
||||
paths=[lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1),lane['kv_path']+'-sibling',
|
||||
'platform/data/workloads/railiance/backup/offsite-lane','auth/token/create']
|
||||
receipt['phase']='reader_capabilities'
|
||||
cap=capabilities(token,paths)
|
||||
receipt['capabilities_checked']=cap
|
||||
if cap[paths[0]]!=['read'] or any(cap[p]!=['deny'] for p in paths[1:]):raise setup.Refused()
|
||||
receipt['phase']='native_kv_eso_equality'
|
||||
native=data(bao(['read','-format=json',lane['kv_path']],token=token))['data']
|
||||
if set(native['data'])!={'NC_WEBDAV_TOKEN'} or native['data']['NC_WEBDAV_TOKEN']!=runtime.token(action):raise setup.Refused()
|
||||
receipt['kv_version']=native['metadata']['version']
|
||||
finally:revoke(token)
|
||||
receipt['phase']='negative_reader_logins'
|
||||
login('default','openbao',True);login(lane['service_account'],'wrong-audience',True)
|
||||
proposed={'apiVersion':'v1','kind':'ServiceAccount','metadata':{'name':lane['service_account'],'namespace':'default'},'automountServiceAccountToken':False}
|
||||
receipt['wrong_namespace_probe_attempted']=proposed['metadata']
|
||||
test=json.loads(capture.command(['create','-f','-','-o','json'],json.dumps(proposed).encode()))
|
||||
if test['metadata']['name']!=lane['service_account'] or test['metadata']['namespace']!='default':raise setup.Refused()
|
||||
try:login(lane['service_account'],'openbao',True,'default')
|
||||
finally:
|
||||
path='/api/v1/namespaces/default/serviceaccounts/'+lane['service_account']
|
||||
capture.command(['delete','--raw',path,'-f','-'],json.dumps({'apiVersion':'v1','kind':'DeleteOptions',
|
||||
'preconditions':{'uid':test['metadata']['uid']}}).encode())
|
||||
receipt['wrong_namespace_probe_cleanup_verified']=True
|
||||
row=data(bao(['read','-format=json','auth/kubernetes/role/'+lane['role']]))['data']
|
||||
setup.check_role(row,lane)
|
||||
receipt.update(status='reader_and_delivery_verified',exact_namespace_binding_verified=True,
|
||||
exact_policy_and_ttl_verified=True,wrong_service_account_denied=True,wrong_audience_denied=True,
|
||||
wrong_namespace_denied=True,
|
||||
metadata_sibling_escrow_and_token_create_denied=True,kv_write_denied=True,
|
||||
eso_delivery_equality_verified=True,temporary_reader_revoked=True,secret_values_exported=False,
|
||||
source_ccr=ccr['id'],ccr_sha256=expected)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=setup.PROFILES)
|
||||
p.add_argument('--ccr-sha256',required=True);p.add_argument('--kubeconfig',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:verify(a.action,a.ccr_sha256,r,a.kubeconfig)
|
||||
except Exception:r.update(status='failed',error='native_delivery_verification_failed_diagnostics_suppressed')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended independent-key recovery of the new data-bearing off-host bundle."""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name,path=None):
|
||||
spec=importlib.util.spec_from_file_location(name,path or HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
bundle=load('identity-bundle');pg=load('check-privacyidea-database')
|
||||
custody=load('sops_private',HERE.parents[1]/'sso-mfa/bootstrap/sops-private.py')
|
||||
|
||||
|
||||
def verify(transfer,keyfd,receipt):
|
||||
if (transfer.get('schema')!='netkingdom.identity-offsite.v1' or transfer.get('status')!='readback_verified'
|
||||
or transfer.get('independent_owner_retrieval_verified') is not True
|
||||
or transfer.get('ciphertext_bytes',0)>bundle.LIMIT):raise bundle.Refused()
|
||||
path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT)
|
||||
with path.open('rb') as source:digest=hashlib.file_digest(source,'sha256').hexdigest()
|
||||
if path.stat().st_size!=transfer['ciphertext_bytes'] or digest!=transfer['ciphertext_sha256']:raise bundle.Refused()
|
||||
derived=pg.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()
|
||||
if derived!=bundle.profile()['public_recipient'].encode():raise bundle.Refused()
|
||||
receipt.update(ciphertext_sha256=digest,artifact=transfer['artifact'],registered_recipient_match=True)
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-identity-restore-',dir='/tmp') as temporary:
|
||||
work=Path(temporary);env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary}
|
||||
wrong=work/'wrong.key';pg.run(['age-keygen','-o',str(wrong)],env=env)
|
||||
negative=subprocess.run(['age','-d','-i',str(wrong),str(path)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,timeout=120)
|
||||
if negative.returncode==0:raise bundle.Refused()
|
||||
receipt['wrong_key_refused']=True
|
||||
plain=work/'archive.tar'
|
||||
with plain.open('xb') as output:
|
||||
result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,
|
||||
stderr=subprocess.DEVNULL,env=env,pass_fds=(keyfd,),timeout=180)
|
||||
if result.returncode or plain.stat().st_size>bundle.LIMIT+4*1024*1024:raise bundle.Refused()
|
||||
restored=work/'restored';restored.mkdir(mode=0o700)
|
||||
manifest,checks=bundle.restore(plain,restored)
|
||||
receipt.update(checks=checks,manifest_file_count=len(manifest['files']),all_manifest_hashes_verified=True,
|
||||
profile_sha256=manifest['profile_sha256'])
|
||||
container='nk49-identity-db-'+uuid.uuid4().hex
|
||||
try:
|
||||
receipt['database_restore']=pg.restore(restored/'databases/privacyidea.dump',container)
|
||||
finally:
|
||||
pg.run(['docker','rm','--force','--volumes',container])
|
||||
absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=15)
|
||||
pg.run(['docker','info','--format','{{.ServerVersion}}'])
|
||||
if absent.returncode==0:raise bundle.Refused()
|
||||
receipt['container_cleanup_verified']=True
|
||||
if work.exists():raise bundle.Refused()
|
||||
receipt.update(temporary_plaintext_cleanup_verified=True,status='identity_archive_verified',
|
||||
full_service_authentication_proven=False,existing_token_decryption_proven=False,audit_signature_continuity_proven=False)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.identity-recovery.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),
|
||||
'production_contacted':False,'secret_values_exported':False,'container_cleanup_verified':False,
|
||||
'temporary_plaintext_cleanup_verified':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd=None
|
||||
try:
|
||||
transfer=json.loads(Path(a.transfer_receipt).read_text());keyfd=custody.private_key_fd()
|
||||
verify(transfer,keyfd,r)
|
||||
except Exception:r.update(status='failed',error='identity_recovery_failed_private_diagnostics_suppressed')
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
if r['status']=='identity_archive_verified':
|
||||
print('OK: off-host identity bundle decrypted; complete manifest, key pairs, SQLite integrity and PostgreSQL restore passed; disposable data removed. Service authentication remains separate acceptance.')
|
||||
return 0
|
||||
print('ERROR: identity recovery failed; review safe receipt. Private diagnostics suppressed.');return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1 @@
|
|||
Historical R5 proposal remains in ../r5/FIXTURE-R5.md. This R6 packet is governed by README.md, preparation.json and its new source manifest. Do not use the R5 command/hash with R6.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# R6 recovery candidate — prepared, awaiting independent review
|
||||
|
||||
Frozen R5 and its failure remain unchanged. This successor masks the pinned helper image's declared `/privacyidea/etc/persistent` VOLUME with a 16MiB non-executable tmpfs, only on the helper. Arbitrary anonymous/disk volumes remain refused. Public `provision_phase` checkpoints identify the next operation without error text, environment or payload.
|
||||
|
||||
`bounded-public-diagnosis.json` preserves the failed metadata projections and subsequent safe allowlist image readback. The retained R5 receipt cannot establish the exact first refusal retrospectively. This patch fixes a concrete image/containment incompatibility; it does not prove a successful launch. No Sysctls checks are relaxed.
|
||||
|
||||
72 focused checks pass, including phase order, worker and issuer containment failure checkpoints, explicit helper-only mask, retained anonymous-volume refusal and two disposable process lifecycle checks. An initial 70-check run failed only because the copied source manifest still bound R5; updated R6 closure passed. No Docker fixture has run.
|
||||
|
||||
Manifest SHA256: `3f39a33891f25c36adcdc8137afac3be064a3011e15d9a06da60a3155bacea56`. Review `r5-to-r6.patch`, `candidate.patch`, `preparation.json` and the manifest before execution. `test-root` is a copied dependency closure, not canonical owner integration.
|
||||
|
||||
Validation:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r6/test-root/tests -p 'test_isolated_keycape*.py'
|
||||
python3 /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r6/test-root/tests/run_isolated_keycape_native_fixture.py --plan
|
||||
```
|
||||
|
||||
Proposed positive-only trial, following independent review:
|
||||
|
||||
```sh
|
||||
python3 /home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-16/identity-restore-adapter-next/revisions/r6/test-root/tests/run_isolated_keycape_native_fixture.py --execute-reviewed-fixture --expected-manifest-sha256 3f39a33891f25c36adcdc8137afac3be064a3011e15d9a06da60a3155bacea56 --case positive
|
||||
```
|
||||
|
||||
Hard-loss/deadline/uncertain-response cases remain deferred until the positive case passes. Existing fixed engine, pinned images, no-pull, synthetic-only, loopback isolation, resource bounds and exact cleanup rules remain as in frozen R5/FIXTURE-R5.md. No production acceptance or execution approval is asserted.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"schema": "nk49.r5-bounded-public-diagnosis.v1",
|
||||
"initial_image_projection": {
|
||||
"attempts": [
|
||||
"sandbox",
|
||||
"required_escalated"
|
||||
],
|
||||
"returncodes": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"raw_errors_disclosed": false,
|
||||
"followup_classification": "template_projection_failure"
|
||||
},
|
||||
"fixed_endpoint": "unix:///var/run/docker.sock",
|
||||
"engine": {
|
||||
"id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
||||
"name": "bnt-lap001",
|
||||
"os_type": "linux"
|
||||
},
|
||||
"socket": {
|
||||
"present": true,
|
||||
"is_socket": true
|
||||
},
|
||||
"allowlist_image_readback": {
|
||||
"worker": {
|
||||
"id": "sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473",
|
||||
"volumes": {
|
||||
"/privacyidea/etc/persistent": {}
|
||||
},
|
||||
"user": "0",
|
||||
"entrypoint": [
|
||||
"python",
|
||||
"/privacyidea/entrypoint.py"
|
||||
],
|
||||
"cmd": null
|
||||
},
|
||||
"issuer": {
|
||||
"id": "sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010",
|
||||
"volumes": null,
|
||||
"user": "0",
|
||||
"entrypoint": [
|
||||
"/keycape"
|
||||
],
|
||||
"cmd": null
|
||||
}
|
||||
},
|
||||
"defect": "R5 worker launch leaves pinned helper image-declared VOLUME unmasked, causing implicit anonymous non-tmpfs volume refused by existing containment check",
|
||||
"causal_limit": "Bounded metadata establishes incompatible launch and validator; historical exact failed stage was not recorded. No replay has established this was the first refusal.",
|
||||
"correction": "Explicit bounded worker-only tmpfs at /privacyidea/etc/persistent; retain arbitrary volume refusal; record public provision_phase before each step"
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
--- a/tests/test_isolated_keycape_recovery.py
|
||||
+++ b/tests/test_isolated_keycape_recovery.py
|
||||
@@ -136,7 +136,7 @@
|
||||
self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified'])
|
||||
|
||||
class ContainmentTests(unittest.TestCase):
|
||||
- def inspect(self,overrides=None,launch=None):
|
||||
+ def inspect(self,overrides=None,launch=None,mounts=None):
|
||||
name='nk49-kc-'+('a'*32)+'-issuer'
|
||||
session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}}
|
||||
host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']}
|
||||
@@ -146,9 +146,13 @@
|
||||
if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}'
|
||||
if argv[-1]=='{{.Id}}':return b'anchor-id'
|
||||
if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode()
|
||||
- return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode()
|
||||
+ return json.dumps({'host':host,'mounts':mounts or [],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode()
|
||||
with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session)
|
||||
def test_minimal_network_none_admitted(self):self.inspect()
|
||||
+ def test_image_declared_anonymous_disk_volume_remains_refused(self):
|
||||
+ with self.assertRaises(a.Refused):
|
||||
+ self.inspect(mounts=[{'Type':'volume','Name':'anonymous-fixture','Destination':'/privacyidea/etc/persistent','RW':True}])
|
||||
+
|
||||
def test_alternate_same_image_command_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']})
|
||||
def test_cap_add_refused(self):
|
||||
--- a/tests/test_isolated_keycape_session.py
|
||||
+++ b/tests/test_isolated_keycape_session.py
|
||||
@@ -45,6 +45,40 @@
|
||||
self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive')
|
||||
self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1])
|
||||
self.assertNotIn('--entrypoint',issuer_call[1])
|
||||
+ def test_pinned_helper_image_volume_is_explicitly_masked_with_tmpfs(self):
|
||||
+ calls=[]
|
||||
+ def run(argv,**kwargs):calls.append(argv);return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
+ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session'):
|
||||
+ s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example')
|
||||
+ worker=next(argv for argv in calls if s.WORKER in argv)
|
||||
+ mask='/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'
|
||||
+ self.assertIn(mask,worker)
|
||||
+ self.assertEqual(worker[worker.index(mask)-1],'--tmpfs')
|
||||
+ issuer=next(argv for argv in calls if argv[-1]==s.adapter.IMAGE)
|
||||
+ self.assertNotIn(mask,issuer)
|
||||
+ def test_public_phase_sequence_reports_containment_before_private_import(self):
|
||||
+ session=s.plan_session();phases=[]
|
||||
+ def run(argv,**kwargs):
|
||||
+ return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
+ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session'):
|
||||
+ s.provision(session,{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
+ self.assertEqual(phases,list(s.PROVISION_PHASES))
|
||||
+ def test_worker_containment_refusal_keeps_exact_phase_and_skips_import(self):
|
||||
+ phases=[];calls=[]
|
||||
+ def run(argv,**kwargs):calls.append(argv);return b''
|
||||
+ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=s.adapter.Refused):
|
||||
+ with self.assertRaises(s.adapter.Refused):
|
||||
+ s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
+ self.assertEqual(phases[-1],'worker_containment')
|
||||
+ self.assertFalse(any(s.IMPORT in argv for argv in calls))
|
||||
+ def test_issuer_containment_refusal_does_not_claim_provision_complete(self):
|
||||
+ phases=[]
|
||||
+ def run(argv,**kwargs):return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
+ with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=[None,s.adapter.Refused()]):
|
||||
+ with self.assertRaises(s.adapter.Refused):
|
||||
+ s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
+ self.assertEqual(phases[-1],'issuer_containment')
|
||||
+ self.assertNotIn('complete',phases)
|
||||
def test_non_original_https_port_refused_before_any_mutation(self):
|
||||
with patch.object(s,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example')
|
||||
--- a/tools/bootstrap-recovery/isolated-keycape-session.py
|
||||
+++ b/tools/bootstrap-recovery/isolated-keycape-session.py
|
||||
@@ -122,26 +122,40 @@
|
||||
return args
|
||||
|
||||
|
||||
-def provision(session,payload,tls,issuer):
|
||||
+PROVISION_PHASES=(
|
||||
+ 'volume_creation_and_verification','worker_launch','worker_containment',
|
||||
+ 'private_import','issuer_launch','issuer_containment','tls_transport_launch','complete')
|
||||
+
|
||||
+
|
||||
+def provision(session,payload,tls,issuer,phase=None):
|
||||
"""Inspect containment and driver options before the first private import."""
|
||||
from urllib.parse import urlsplit
|
||||
address=urlsplit(issuer)
|
||||
if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'):
|
||||
raise adapter.Refused()
|
||||
+ if phase is None:phase=lambda value:None
|
||||
+ phase('volume_creation_and_verification')
|
||||
for name,spec in session['volumes'].items():
|
||||
run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'],
|
||||
'--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name])
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
+ phase('worker_launch')
|
||||
args=base_args(session,session['worker'],'none')+mounts(session)
|
||||
+ # This pinned helper declares an image VOLUME here. Mask it explicitly so
|
||||
+ # Docker cannot allocate an unregistered disk-backed anonymous volume.
|
||||
+ args+=['--tmpfs','/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700']
|
||||
args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1',
|
||||
'--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)']
|
||||
run(args)
|
||||
# Check helper and every private filesystem before touching the ciphertext's
|
||||
# private contents. Issuer is created only after originals are present.
|
||||
+ phase('worker_containment')
|
||||
adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True)
|
||||
+ phase('private_import')
|
||||
files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}}
|
||||
run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode())
|
||||
anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip()
|
||||
+ phase('issuer_launch')
|
||||
args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True)
|
||||
# CLI argv carries environment names only. Original credentials stay in the
|
||||
# owner subprocess environment and Docker's private container configuration.
|
||||
@@ -150,8 +164,11 @@
|
||||
for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name]
|
||||
args+=[adapter.IMAGE]
|
||||
run(args,env=env)
|
||||
+ phase('issuer_containment')
|
||||
adapter.inspect_session(session)
|
||||
+ phase('tls_transport_launch')
|
||||
run(['docker','exec','--detach',session['worker'],'python3','-c',TLS])
|
||||
+ phase('complete')
|
||||
|
||||
|
||||
def cleanup_all(session,receipt):
|
||||
@@ -421,7 +438,12 @@
|
||||
run(['docker','rm',probe])
|
||||
if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused()
|
||||
receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified'
|
||||
- provision(session,payload,tls,admission['issuer'])
|
||||
+ def phase(value):
|
||||
+ # Public enum only: no private payload, exception text or daemon logs.
|
||||
+ if value not in PROVISION_PHASES:raise adapter.Refused()
|
||||
+ receipt['provision_phase']=value
|
||||
+ if checkpoint is not None:checkpoint(receipt)
|
||||
+ provision(session,payload,tls,admission['issuer'],phase)
|
||||
receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files']))
|
||||
checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'}
|
||||
# Native checker consumes no service secret argument or new grant.
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Age-only SOPS recovery to a new private file; no plaintext process output."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def public_recipient():
|
||||
lines = (ROOT / 'keys/age.pub').read_text().splitlines()
|
||||
values = [line.strip() for line in lines if line.strip() and not line.startswith('#')]
|
||||
if len(values) != 1 or not RECIPIENT.fullmatch(values[0]):
|
||||
raise Refused('registered public recipient is invalid')
|
||||
result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=10)
|
||||
if result.returncode:
|
||||
raise Refused('registered public recipient is invalid')
|
||||
return values[0]
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Refused('duplicate encrypted document key')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def parse_document(raw, suffix):
|
||||
if suffix == '.json':
|
||||
data = json.loads(raw, object_pairs_hook=unique_object)
|
||||
kind = 'json'
|
||||
elif suffix in {'.yaml', '.yml'}:
|
||||
import yaml
|
||||
class UniqueLoader(yaml.SafeLoader):
|
||||
def construct_mapping(self, node, deep=False):
|
||||
self.flatten_mapping(node)
|
||||
return unique_object((self.construct_object(key, deep=deep),
|
||||
self.construct_object(value, deep=deep))
|
||||
for key, value in node.value)
|
||||
try:
|
||||
data = yaml.load(raw, Loader=UniqueLoader)
|
||||
except yaml.YAMLError:
|
||||
raise Refused('invalid encrypted YAML input') from None
|
||||
kind = 'yaml'
|
||||
else:
|
||||
raise Refused('age recovery supports SOPS JSON/YAML input')
|
||||
return data, kind
|
||||
|
||||
|
||||
def age_document(source, recipient, *, raw=None):
|
||||
# Inspect ciphertext metadata before giving a credential to any subprocess.
|
||||
# This repository's admitted SOPS profile is age-only, not arbitrary cloud
|
||||
# KMS, key services, SSH identities or executable age plugins.
|
||||
if raw is None:
|
||||
with source.open("rb") as encrypted:
|
||||
raw = encrypted.read(8 * 1024 * 1024 + 1)
|
||||
if len(raw) > 8 * 1024 * 1024:
|
||||
raise Refused('encrypted input is too large')
|
||||
data, kind = parse_document(raw, source.suffix)
|
||||
metadata = data.get('sops') if isinstance(data, dict) else None
|
||||
if not isinstance(metadata, dict):
|
||||
raise Refused('SOPS metadata is missing')
|
||||
known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp',
|
||||
'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version',
|
||||
'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex',
|
||||
'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex',
|
||||
'mac_only_encrypted'}
|
||||
if set(metadata) - known or metadata.get('key_groups'):
|
||||
raise Refused('unsupported SOPS custody profile')
|
||||
if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')):
|
||||
raise Refused('recovery requires age-only metadata')
|
||||
recipients = metadata.get('age')
|
||||
if not isinstance(recipients, list) or not recipients:
|
||||
raise Refused('age recipient metadata is missing')
|
||||
if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', '')))
|
||||
for row in recipients):
|
||||
raise Refused('unsupported age recipient')
|
||||
if recipient not in [row['recipient'] for row in recipients]:
|
||||
raise Refused('document does not include the registered recipient')
|
||||
return raw, kind
|
||||
|
||||
|
||||
def private_key_fd():
|
||||
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
|
||||
if not name:
|
||||
raise Refused('explicit temporary custody key required')
|
||||
path = Path(os.path.abspath(name))
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused('custody key path must not contain symlinks')
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384:
|
||||
os.close(fd)
|
||||
raise Refused('custody key must be an owned private regular file')
|
||||
return fd
|
||||
|
||||
|
||||
def decrypt(recipient):
|
||||
source_name = os.environ.get('NK_SOPS_FILE', '')
|
||||
output_name = os.environ.get('NK_SOPS_OUTPUT', '')
|
||||
if not source_name or not output_name:
|
||||
raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled')
|
||||
source = Path(source_name).resolve(strict=True)
|
||||
output = Path(output_name).absolute()
|
||||
parent = output.parent.resolve(strict=True)
|
||||
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file()
|
||||
or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir())
|
||||
for p in [parent, *parent.parents]):
|
||||
raise Refused('plaintext output must be outside Git worktrees')
|
||||
raw, kind = age_document(source, recipient)
|
||||
binary = shutil.which('sops')
|
||||
if binary is None:
|
||||
raise Refused('sops executable is unavailable')
|
||||
directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
temporary = '.netkingdom-sops-' + uuid.uuid4().hex
|
||||
keyfd = None
|
||||
try:
|
||||
info = os.fstat(directory)
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused('OUTPUT requires an owned private parent directory')
|
||||
try:
|
||||
os.stat(output.name, dir_fd=directory, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
raise Refused('OUTPUT already exists')
|
||||
keyfd = private_key_fd()
|
||||
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
pass_fds=(keyfd,), timeout=10)
|
||||
if derived.returncode or derived.stdout.strip() != recipient.encode():
|
||||
raise Refused('custody key does not match the registered recipient')
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=directory)
|
||||
with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated:
|
||||
# HOME/XDG isolation matters: SOPS loads ambient keys in addition to
|
||||
# an explicit key file. No inline keys, SSH keys, key commands,
|
||||
# proxies or remote keyservice configuration are inherited.
|
||||
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated,
|
||||
'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated,
|
||||
'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}',
|
||||
'AWS_EC2_METADATA_DISABLED': 'true'}
|
||||
result = subprocess.run([binary, '--decrypt', '--input-type', kind,
|
||||
'--output-type', kind, '/dev/stdin'],
|
||||
input=raw, stdout=plain, stderr=subprocess.DEVNULL,
|
||||
env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30)
|
||||
if result.returncode:
|
||||
raise Refused('SOPS decryption failed; no plaintext published')
|
||||
plain.flush()
|
||||
os.fsync(plain.fileno())
|
||||
# Atomic no-overwrite publication within the private directory.
|
||||
os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory,
|
||||
follow_symlinks=False)
|
||||
os.unlink(temporary, dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
if keyfd is not None:
|
||||
os.close(keyfd)
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
os.close(directory)
|
||||
print('Decrypted to the requested private file. Remove it after the approved operation.')
|
||||
|
||||
|
||||
def interrupted(_signum, _frame):
|
||||
raise Refused('operation interrupted')
|
||||
|
||||
|
||||
def main():
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
|
||||
signal.signal(sig, interrupted)
|
||||
try:
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}:
|
||||
raise Refused('unsupported custody operation')
|
||||
recipient = public_recipient()
|
||||
if sys.argv[1] == 'setup':
|
||||
print('Registered public recipient validated. No private key was read, copied or generated.')
|
||||
else:
|
||||
decrypt(recipient)
|
||||
return 0
|
||||
except Refused as exc:
|
||||
print('ERROR: ' + str(exc), file=sys.stderr)
|
||||
except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError):
|
||||
# Parser, filesystem and tool errors can include user data. Never echo.
|
||||
print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env python3
|
||||
"""In-pod key transport. Only invoke through key-custody.py (captured output)."""
|
||||
import base64
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
|
||||
FILES = ('enckey', 'private.pem', 'public.pem')
|
||||
LIMIT = 65536
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate(values):
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
if set(values) != set(FILES) or len(values['enckey']) != 96:
|
||||
raise Refused()
|
||||
if any(not value or len(value) > LIMIT for value in values.values()):
|
||||
raise Refused()
|
||||
private = serialization.load_pem_private_key(values['private.pem'], password=None)
|
||||
public = serialization.load_pem_public_key(values['public.pem'])
|
||||
if (not isinstance(private, rsa.RSAPrivateKey) or private.key_size < 2048
|
||||
or not isinstance(public, rsa.RSAPublicKey)
|
||||
or private.public_key().public_numbers() != public.public_numbers()):
|
||||
raise Refused()
|
||||
|
||||
|
||||
def read(directory, name):
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
forbidden = 0o022 if name == 'public.pem' else 0o027
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_mode & forbidden:
|
||||
raise Refused()
|
||||
with os.fdopen(os.dup(fd), 'rb') as stream:
|
||||
data = stream.read(LIMIT + 1)
|
||||
after = os.stat(name, dir_fd=directory, follow_symlinks=False)
|
||||
if len(data) > LIMIT or any(getattr(info, k) != getattr(after, k)
|
||||
for k in ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns')):
|
||||
raise Refused()
|
||||
return data
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def install(directory, values, *, uid=0, gid=999):
|
||||
"""Verify every existing file before creating anything; never replace."""
|
||||
missing = []
|
||||
for name, value in values.items():
|
||||
try:
|
||||
if read(directory, name) != value:
|
||||
raise Refused()
|
||||
except FileNotFoundError:
|
||||
missing.append(name)
|
||||
for name in missing:
|
||||
temp = '.key-custody-' + secrets.token_hex(12)
|
||||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=directory)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(values[name]); stream.flush()
|
||||
os.fchown(stream.fileno(), uid, gid)
|
||||
os.fchmod(stream.fileno(), 0o644 if name == 'public.pem' else 0o640)
|
||||
os.fsync(stream.fileno())
|
||||
os.link(temp, name, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False)
|
||||
os.unlink(temp, dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if any(read(directory, name) != value for name, value in values.items()):
|
||||
raise Refused()
|
||||
|
||||
|
||||
def operate(request, root=Path('/etc/privacyidea'), *, uid=0, gid=999):
|
||||
if any(p.is_symlink() for p in (root, *root.parents)):
|
||||
raise Refused()
|
||||
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
# The admitted service uses fsGroup 999, which may make its PVC group-writable.
|
||||
if os.fstat(directory).st_mode & 0o002:
|
||||
raise Refused()
|
||||
fcntl.flock(directory, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if request == {'action': 'initialize-audit'}:
|
||||
# This explicit first-bootstrap action never creates/replaces enckey.
|
||||
enckey = read(directory, 'enckey')
|
||||
if len(enckey) != 96:
|
||||
raise Refused()
|
||||
for name in ('private.pem', 'public.pem'):
|
||||
try:
|
||||
os.stat(name, dir_fd=directory, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
raise Refused()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
values = {'enckey': enckey,
|
||||
'private.pem': private.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption()),
|
||||
'public.pem': private.public_key().public_bytes(serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)}
|
||||
validate(values)
|
||||
install(directory, values, uid=uid, gid=gid)
|
||||
return {'result': 'initialized'}
|
||||
if request == {'action': 'export'}:
|
||||
values = {name: read(directory, name) for name in FILES}
|
||||
validate(values)
|
||||
return {name: base64.b64encode(value).decode() for name, value in values.items()}
|
||||
if set(request) == {'action', 'keys'} and request['action'] == 'restore':
|
||||
values = {name: base64.b64decode(value, validate=True) for name, value in request['keys'].items()}
|
||||
validate(values)
|
||||
install(directory, values, uid=uid, gid=gid)
|
||||
return {'result': 'restored'}
|
||||
raise Refused()
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raw = sys.stdin.buffer.read(4 * LIMIT + 1)
|
||||
if len(raw) > 4 * LIMIT:
|
||||
raise Refused()
|
||||
print(json.dumps(operate(json.loads(raw))))
|
||||
except Exception:
|
||||
# Transport may carry key bytes; diagnostics must never serialize them.
|
||||
print('Key custody operation refused or failed.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare/run the exact reviewed T18 candidate on disposable synthetic material.
|
||||
|
||||
Default --plan performs no Docker calls and generates no key/material. Execution
|
||||
requires the reviewed fixture manifest hash and source closure. This runner calls
|
||||
the candidate itself; hooks only pause or inject a post-response transport fault.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
MANIFEST=ROOT/'tests/isolated-keycape-native-fixture-manifest.json'
|
||||
ISSUER='https://nk49-fixture.invalid'
|
||||
CASES=('positive','hard-parent-loss','deadline','nonzero-after-accept')
|
||||
|
||||
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path)
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
s=load('session',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
|
||||
|
||||
def require(condition):
|
||||
if not condition:raise RuntimeError('synthetic_fixture_refused')
|
||||
|
||||
|
||||
def reviewed_manifest(expected):
|
||||
raw=MANIFEST.read_bytes();require(hashlib.sha256(raw).hexdigest()==expected)
|
||||
manifest=json.loads(raw)
|
||||
for name,digest in manifest['source_sha256'].items():
|
||||
require(hashlib.sha256((ROOT/name).read_bytes()).hexdigest()==digest)
|
||||
return manifest
|
||||
|
||||
|
||||
def private_write(path,value):
|
||||
path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(value)
|
||||
|
||||
|
||||
def materials(root):
|
||||
"""Reuse the existing full archive fixture factory, no real archive accepted."""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes,serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
import yaml
|
||||
existing=load('existing_bundle_fixture',ROOT/'tests/test_identity_bundle.py')
|
||||
payload=existing.stage.__wrapped__(root)
|
||||
config={'issuer':ISSUER,'port':8080,'privateKeyPem':'/etc/keycape/key.pem','environment':'dev','tokenLifetime':'5m',
|
||||
'lldap':{'url':'ldap://127.0.0.1:1','bindDN':'cn=fixture','bindPW':'synthetic-only','baseDN':'dc=fixture'},
|
||||
'authelia':{'baseURL':'http://127.0.0.1:1','clientId':'synthetic','clientSecret':'synthetic','redirectURI':ISSUER+'/authorize/callback'},
|
||||
'privacyidea':{'baseURL':'http://127.0.0.1:1'},
|
||||
'clients':[{'clientId':'nk49-fixture-consumer','clientType':'confidential','audience':'nk49-fixture',
|
||||
'grantTypes':['client_credentials'],'allowedScopes':['fixture:read'],'secretRef':'env:KEYCAPE_FIXTURE_CLIENT_SECRET',
|
||||
'serviceSubject':'service:nk49-fixture','tenant':'tenant:fixture','roles':['fixture-reader']}]+
|
||||
[{'clientId':name,'displayName':name,'clientType':'public','grantTypes':['authorization_code'],
|
||||
'redirectUris':['https://nk49-fixture.invalid/callback'],'allowedScopes':['openid'],'mfaRequired':True}
|
||||
for name in ('vergabe-demo-company','user-engine-portal')]}
|
||||
secret_path=payload/'resources/sso/secret/keycape-config.json';secret=json.loads(secret_path.read_text())
|
||||
secret['data']['config.yaml']=base64.b64encode(yaml.safe_dump(config).encode()).decode();secret_path.write_text(json.dumps(secret))
|
||||
client_path=payload/'resources/sso/secret/keycape-rapp-qonto-client.json';client=json.loads(client_path.read_text())
|
||||
client['data']={'client-secret':base64.b64encode(b'synthetic-fixture-client-secret').decode()};client_path.write_text(json.dumps(client))
|
||||
factor_path=payload/'resources/sso/secret/keycape-factor-read.json';factor=json.loads(factor_path.read_text())
|
||||
factor['data']={'admin-token':base64.b64encode(b'synthetic-unused-factor-reader').decode()};factor_path.write_text(json.dumps(factor))
|
||||
dep_path=payload/'resources/sso/deployment/keycape.json';dep=json.loads(dep_path.read_text())
|
||||
dep['spec']={'template':{'spec':{'containers':[{'name':'keycape','image':s.adapter.IMAGE,'env':[
|
||||
{'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'},
|
||||
{'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'},
|
||||
{'name':'KEYCAPE_FIXTURE_CLIENT_SECRET','valueFrom':{'secretKeyRef':{'name':'keycape-rapp-qonto-client','key':'client-secret'}}}]}]}}};dep_path.write_text(json.dumps(dep))
|
||||
policy={'Revision':1,'Modes':{k:'mandatory' for k in ('vergabe-demo-company','user-engine-portal')},'History':[
|
||||
{'reference':'synthetic-retained-receipt','actor':'service:fixture','client':'user-engine-portal','before':'mandatory','after':'mandatory','revision':1,'at':1}]}
|
||||
# JSON tags in the owner store are lowercase; retain an existing lock inode
|
||||
# representation as part of the archived tree, never synthesize it after import.
|
||||
policy={k.lower():v for k,v in policy.items()};(payload/'data/keycape/policy.json').write_text(json.dumps(policy))
|
||||
private_write(payload/'data/keycape/policy.json.lock',b'')
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'nk49-fixture.invalid')]);now=datetime.now(timezone.utc)
|
||||
cert=(x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now-timedelta(minutes=5)).not_valid_after(now+timedelta(days=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName('nk49-fixture.invalid')]),critical=False)
|
||||
.add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True).sign(key,hashes.SHA256()))
|
||||
tls={'/recovery-tls/server.pem':cert.public_bytes(serialization.Encoding.PEM),
|
||||
'/recovery-tls/server-key.pem':key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()),
|
||||
'/recovery-tls/trust.pem':cert.public_bytes(serialization.Encoding.PEM)}
|
||||
for path,value in tls.items():private_write(root/Path(path).name,value)
|
||||
age=root/'synthetic-age.key';s.run(['age-keygen','-o',str(age)])
|
||||
recipient=s.run(['age-keygen','-y',str(age)]).decode().strip();profile=s.bundle.profile();profile['public_recipient']=recipient
|
||||
s.bundle.profile=lambda:profile
|
||||
artifact=root/'net-kingdom-identity-20261016T063159Z-0123456789ab.tar.age';s.bundle.seal(payload,artifact,{'synthetic_native_keycape_fixture':True})
|
||||
raw=s.run(['age','-d','-i',str(age),str(artifact)],max_output=s.bundle.LIMIT)
|
||||
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:manifest=json.load(archive.extractfile('manifest.json'))
|
||||
transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':artifact.name,'ciphertext_bytes':artifact.stat().st_size,
|
||||
'ciphertext_sha256':hashlib.sha256(artifact.read_bytes()).hexdigest(),'etag':'"synthetic"','fetched_path':str(artifact),
|
||||
'independent_owner_retrieval_verified':True}
|
||||
# Derive public executable identity only from the exact cached admitted image.
|
||||
probe=s.plan_session();name=probe['public_image_probe']
|
||||
try:
|
||||
s.run(['docker','create','--pull=never','--name',name,'--network','none','--read-only','--label',s.adapter.LABEL+'='+probe['run_id'],s.adapter.IMAGE])
|
||||
binary=s.adapter.container_file(name,'/keycape')
|
||||
finally:s.adapter.cleanup({**probe,'containers':[name]}, {})
|
||||
admission={'issuer':ISSUER,'ciphertext_sha256':transfer['ciphertext_sha256'],
|
||||
'manifest_sha256':hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest(),
|
||||
'verifier_sha256':hashlib.sha256(binary).hexdigest(),'trust_sha256':hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest(),
|
||||
'tls_cert_sha256':hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest(),
|
||||
'consumer':{'client_id':'nk49-fixture-consumer','secret_env':'KEYCAPE_FIXTURE_CLIENT_SECRET','audience':'nk49-fixture','scope':'fixture:read',
|
||||
'subject':'service:nk49-fixture','tenant':'tenant:fixture','roles':'fixture-reader','deny_scope':'fixture:write'}}
|
||||
private_write(root/'fixture.json',json.dumps({'schema':'nk49.generated-keycape-fixture.v1','transfer':transfer,'admission':admission,'recipient':recipient}).encode())
|
||||
|
||||
|
||||
def worker(root,case):
|
||||
execution=s.isolate_execution();execution['deadline']=time.monotonic()+(20 if case=='deadline' else 120)
|
||||
data=json.loads((root/'fixture.json').read_text());require(data['schema']=='nk49.generated-keycape-fixture.v1')
|
||||
profile=s.bundle.profile();profile['public_recipient']=data['recipient'];s.bundle.profile=lambda:profile
|
||||
s.local.verify.custody.private_key_fd=lambda:os.open(root/'synthetic-age.key',os.O_RDONLY|os.O_NOFOLLOW)
|
||||
tls={'/recovery-tls/'+name:(root/name).read_bytes() for name in ('server.pem','server-key.pem','trust.pem')}
|
||||
receipt={};output=root/(case+'.json');checkpoint=lambda r:output.write_text(json.dumps(r))
|
||||
provision=s.provision
|
||||
def paused(*args):
|
||||
provision(*args)
|
||||
if case in ('hard-parent-loss','deadline'):
|
||||
private_write(root/(case+'.barrier'),b'private-import-and-default-launch-complete')
|
||||
time.sleep(150)
|
||||
s.provision=paused
|
||||
if case=='nonzero-after-accept':
|
||||
original=s.adapter.subprocess.run;injected=False
|
||||
def fault(argv,**kwargs):
|
||||
nonlocal injected
|
||||
result=original(argv,**kwargs)
|
||||
if argv[:3]==['docker','--host',s.adapter.ENDPOINT] and 'run' in argv and not injected and result.returncode==0:
|
||||
injected=True
|
||||
return subprocess.CompletedProcess(argv,1,b'',b'')
|
||||
return result
|
||||
s.adapter.subprocess.run=fault
|
||||
try:s.execute(data['transfer'],data['admission'],tls,receipt,execution,checkpoint=checkpoint)
|
||||
except BaseException:receipt['status']='failed'
|
||||
finally:output.write_text(json.dumps(receipt))
|
||||
return int(receipt.get('status')!='isolated_keycape_native_registration_checked')
|
||||
|
||||
|
||||
def run_cases(root,summary,cases):
|
||||
for case in cases:
|
||||
child=subprocess.Popen([sys.executable,str(Path(__file__).resolve()),'--worker-root',str(root),'--worker-case',case,
|
||||
'--execute-reviewed-fixture','--expected-manifest-sha256',summary['manifest_sha256']],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
end=time.monotonic()+150
|
||||
if case=='hard-parent-loss':
|
||||
while not (root/(case+'.barrier')).exists() and child.poll() is None and time.monotonic()<end:time.sleep(.05)
|
||||
require((root/(case+'.barrier')).exists())
|
||||
execution=json.loads((root/(case+'.json')).read_text())['execution_binding'];require(execution['pid']==child.pid)
|
||||
fd=os.pidfd_open(child.pid)
|
||||
try:signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
finally:os.close(fd)
|
||||
child.wait(timeout=max(1,end-time.monotonic()))
|
||||
receipt=json.loads((root/(case+'.json')).read_text())
|
||||
summary['receipts'][case]=receipt
|
||||
if case=='deadline':require((root/(case+'.barrier')).exists())
|
||||
if case=='positive':
|
||||
require(receipt.get('status')=='isolated_keycape_native_registration_checked' and receipt.get('container_cleanup_verified') and receipt.get('temporary_plaintext_cleanup_verified'))
|
||||
require(receipt.get('native_service_registration_verified') and receipt.get('original_public_signing_key_in_live_jwks'))
|
||||
summary['actual_issuer_original_hostname_tls_verified']=True
|
||||
else:
|
||||
require(receipt.get('status')!='isolated_keycape_native_registration_checked')
|
||||
outcome=Path(receipt['reaper_outcome_path'])
|
||||
while not outcome.exists() and time.monotonic()<end:time.sleep(.05)
|
||||
require(outcome.exists());cleanup=json.loads(outcome.read_text())
|
||||
require(cleanup.get('execution_quiescence_verified') and cleanup.get('temporary_plaintext_cleanup_verified'))
|
||||
if case=='nonzero-after-accept':require(cleanup.get('status')=='bounded_cleanup_uncertain' and cleanup.get('in_flight_daemon_request_uncertain'))
|
||||
else:require(cleanup.get('status')=='bounded_cleanup_verified')
|
||||
summary['cases'][case]='passed'
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
fd=os.pidfd_open(child.pid)
|
||||
try:signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
finally:os.close(fd)
|
||||
child.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--plan',action='store_true')
|
||||
p.add_argument('--execute-reviewed-fixture',action='store_true');p.add_argument('--expected-manifest-sha256')
|
||||
p.add_argument('--worker-root');p.add_argument('--worker-case',choices=CASES);p.add_argument('--case',choices=(*CASES,'all'),default='positive');args=p.parse_args()
|
||||
if not args.execute_reviewed_fixture:
|
||||
print(MANIFEST.read_text());return 0
|
||||
require(not args.plan);manifest=reviewed_manifest(args.expected_manifest_sha256);os.umask(0o077)
|
||||
if args.worker_root:
|
||||
root=Path(args.worker_root);require(root.parent==Path('/dev/shm') and root.name.startswith('nk49-keycape-fixture-'))
|
||||
s.bundle.private(root,True);return worker(root,args.worker_case)
|
||||
require(not args.worker_case)
|
||||
s.local.preflight();s.run(['docker','image','inspect',s.adapter.IMAGE,'--format','{{.Id}}'])
|
||||
safe=Path(tempfile.mkdtemp(prefix='nk49-keycape-native-fixture-review-',dir='/tmp'))
|
||||
summary={'schema':'nk49.synthetic-keycape-native-fixture.v1','status':'failed','manifest_sha256':args.expected_manifest_sha256,
|
||||
'cases':{},'receipts':{},'actual_credentials_used':False,'actual_recovery_proven':False,'docker_executed':True}
|
||||
print('Safe synthetic fixture result: '+str(safe/'result.json'),flush=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-keycape-fixture-',dir='/dev/shm') as temporary:
|
||||
root=Path(temporary);materials(root);run_cases(root,summary,CASES if args.case=='all' else (args.case,))
|
||||
summary['status']='synthetic_native_fixture_passed'
|
||||
finally:(safe/'result.json').write_text(json.dumps(summary,indent=2)+'\n')
|
||||
print('Safe synthetic fixture result: '+str(safe/'result.json'))
|
||||
return int(summary['status']!='synthetic_native_fixture_passed')
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
"""Actual SQLite/age/RSA fixtures and hostile archive/custody boundaries."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
import uuid
|
||||
from copy import deepcopy
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,ROOT/f'tools/bootstrap-recovery/{name}.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
b=load('identity-bundle');files=load('identity-files');capture=load('capture-identity')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stage(tmp_path):
|
||||
os.umask(0o077);root=tmp_path/'payload';root.mkdir(mode=0o700)
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
private=key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())
|
||||
public=key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
for c in b.profile()['components']:
|
||||
ns=c['namespace'];name=c['deployment']
|
||||
d={'kind':'Deployment','metadata':{'name':name,'namespace':ns,'uid':c['deployment_uid']},'spec':{}}
|
||||
b.save(root,f'resources/{ns}/deployment/{name}.json',json.dumps(d).encode())
|
||||
for name in c['secret_refs']:
|
||||
data={'fixture':base64.b64encode(b'synthetic-private-value-canary').decode()}
|
||||
if name=='keycape-config':data={'key.pem':base64.b64encode(private).decode(),'config.yaml':base64.b64encode(b'issuer: https://isolated.invalid').decode()}
|
||||
b.save(root,f'resources/{ns}/secret/{name}.json',json.dumps({'kind':'Secret','metadata':{'name':name,'namespace':ns},'data':data}).encode())
|
||||
for name in c['configmap_refs']:
|
||||
b.save(root,f'resources/{ns}/configmap/{name}.json',json.dumps({'kind':'ConfigMap','metadata':{'name':name,'namespace':ns},'data':{'fixture':'synthetic'}}).encode())
|
||||
for component,name in [('lldap','users.db'),('authelia','db.sqlite3')]:
|
||||
path=root/f'data/{component}/{name}';path.parent.mkdir(mode=0o700,parents=True)
|
||||
with sqlite3.connect(path) as db:db.execute('CREATE TABLE retained(value TEXT)');db.execute("INSERT INTO retained VALUES ('fixture-preserved')")
|
||||
path.chmod(0o600)
|
||||
b.save(root,'data/keycape/policy.json',b'{"version":1}')
|
||||
for name,value in [('enckey',os.urandom(96)),('private.pem',private),('public.pem',public)]:
|
||||
b.save(root,'data/privacyidea/'+name,value)
|
||||
b.save(root,'databases/privacyidea.dump',b'PGDMP-synthetic-format-only')
|
||||
return root
|
||||
|
||||
|
||||
def test_complete_bundle_has_native_integrity_checks(stage):
|
||||
r=b.checks(stage)
|
||||
assert r['sqlite_integrity_passed'] and r['privacyidea_key_format_and_pair_passed']
|
||||
assert r['native_identity_recovery_proven'] is False
|
||||
|
||||
|
||||
def test_real_age_seal_manifest_is_encrypted_and_plaintext_never_prints(stage,tmp_path,monkeypatch,capsys):
|
||||
key=tmp_path/'fixture-age.key'
|
||||
subprocess.run(['age-keygen','-o',str(key)],check=True,capture_output=True)
|
||||
recipient=subprocess.run(['age-keygen','-y',str(key)],check=True,capture_output=True).stdout.decode().strip()
|
||||
p=b.profile();p['public_recipient']=recipient;monkeypatch.setattr(b,'profile',lambda:p)
|
||||
artifact=tmp_path/'identity.tar.age';r=b.seal(stage,artifact,{'test_only':True})
|
||||
assert artifact.stat().st_mode&0o077==0
|
||||
assert b'synthetic-private-value-canary' not in artifact.read_bytes()
|
||||
result=subprocess.run(['age','-d','-i',str(key),str(artifact)],check=True,capture_output=True)
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout)) as archive:
|
||||
manifest=json.load(archive.extractfile('manifest.json'))
|
||||
assert len(manifest['files'])==r['file_count']
|
||||
assert 'data/privacyidea/enckey' in archive.getnames()
|
||||
assert 'data/lldap/users.db' in archive.getnames()
|
||||
plain=tmp_path/'decrypted.tar';plain.write_bytes(result.stdout)
|
||||
recovered=tmp_path/'recovered';recovered.mkdir(mode=0o700)
|
||||
restored,checks=b.restore(plain,recovered)
|
||||
assert checks['privacyidea_key_format_and_pair_passed']
|
||||
assert (recovered/'data/privacyidea/enckey').read_bytes()==(stage/'data/privacyidea/enckey').read_bytes()
|
||||
assert 'synthetic-private-value-canary' not in json.dumps(r)+capsys.readouterr().out
|
||||
with pytest.raises(b.Refused):b.seal(stage,artifact,{})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['missing-key','bad-sqlite','bad-audit-pair','wrong-deployment','private-age-key','symlink','hardlink','unknown-payload'])
|
||||
def test_incomplete_unsafe_and_mismatched_inputs_refuse(stage,tmp_path,defect):
|
||||
if defect=='missing-key':(stage/'data/privacyidea/enckey').unlink()
|
||||
if defect=='bad-sqlite':(stage/'data/lldap/users.db').write_bytes(b'broken')
|
||||
if defect=='bad-audit-pair':
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
(stage/'data/privacyidea/public.pem').write_bytes(key.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
if defect=='wrong-deployment':
|
||||
p=stage/'resources/sso/deployment/keycape.json';r=json.loads(p.read_text());r['metadata']['uid']='replacement';p.write_text(json.dumps(r))
|
||||
if defect=='private-age-key':(stage/'data/keycape/poison').write_bytes(b'AGE-SECRET-KEY-' + b'fixture')
|
||||
if defect=='symlink':(stage/'data/keycape/linked').symlink_to(stage/'data/privacyidea/enckey')
|
||||
if defect=='hardlink':os.link(stage/'data/privacyidea/enckey',stage/'data/keycape/linked')
|
||||
if defect=='unknown-payload':(stage/'unreviewed').write_bytes(b'not-allowed')
|
||||
with pytest.raises(Exception):
|
||||
b.checks(stage);b.inventory(stage)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,kind',[('../escape','file'),('/absolute','file'),('data/keycape/valid','symlink')])
|
||||
def test_tar_import_refuses_traversal_and_links(tmp_path,name,kind):
|
||||
raw=io.BytesIO()
|
||||
with tarfile.open(fileobj=raw,mode='w') as archive:
|
||||
member=tarfile.TarInfo(name);member.size=1
|
||||
if kind=='symlink':member.type=tarfile.SYMTYPE;member.linkname='/etc/passwd'
|
||||
archive.addfile(member,io.BytesIO(b'x'))
|
||||
with pytest.raises(b.Refused):b.import_files(raw.getvalue(),tmp_path,'sso')
|
||||
|
||||
|
||||
def test_native_sqlite_snapshot_keeps_committed_wal_rows_and_source_unchanged(tmp_path):
|
||||
root=tmp_path/'sources';root.mkdir();scratch=tmp_path/'scratch';scratch.mkdir()
|
||||
for component in ['lldap','authelia','keycape']:(root/component).mkdir()
|
||||
for component,name in [('lldap','users.db'),('authelia','db.sqlite3')]:
|
||||
with sqlite3.connect(root/component/name) as con:
|
||||
con.execute('CREATE TABLE retained(value TEXT)');con.execute("INSERT INTO retained VALUES ('native-snapshot')")
|
||||
(root/'keycape/policy.json').write_text('{}')
|
||||
live=sqlite3.connect(root/'lldap/users.db');live.execute('PRAGMA journal_mode=WAL')
|
||||
live.execute("INSERT INTO retained VALUES ('committed-wal')");live.commit()
|
||||
try:
|
||||
result=files.capture('sso',root,scratch)
|
||||
restored=tmp_path/'restored.sqlite';restored.write_bytes(result['data/lldap/users.db'])
|
||||
with sqlite3.connect(restored) as con:assert con.execute('SELECT count(*) FROM retained').fetchone()==(2,)
|
||||
assert list(scratch.iterdir())==[]
|
||||
assert not any(name.endswith(('-wal','-shm')) for name in result)
|
||||
finally:live.close()
|
||||
|
||||
|
||||
def test_helper_manifest_has_no_writable_service_mount_or_credentials():
|
||||
objects=capture.manifests('fixture')
|
||||
for obj in objects:
|
||||
if obj['kind']=='NetworkPolicy':
|
||||
assert obj['spec']['egress']==[] and obj['spec']['ingress']==[]
|
||||
else:
|
||||
s=obj['spec'];assert s['automountServiceAccountToken'] is False
|
||||
assert 'fsGroup' not in s['securityContext']
|
||||
assert s['activeDeadlineSeconds']==900
|
||||
assert all(v['persistentVolumeClaim']['readOnly'] for v in s['volumes'] if 'persistentVolumeClaim' in v)
|
||||
assert not any('secret' in v for v in s['volumes'])
|
||||
|
||||
|
||||
def test_cleanup_delete_carries_exact_uid_precondition(monkeypatch):
|
||||
calls=[];monkeypatch.setattr(capture,'command',lambda args,payload=None,**kw:calls.append((args,payload)) or b'')
|
||||
capture.delete_created({'kind':'Pod','metadata':{'namespace':'sso','name':'nk49-capture-fixture','uid':'exact-created-uid'}})
|
||||
assert json.loads(calls[0][1])['preconditions']=={'uid':'exact-created-uid'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect',['writable-mount','extra-container','service-token','different-image','host-network'])
|
||||
def test_mutated_helper_is_refused_before_private_exec(defect):
|
||||
wanted=next(o for o in capture.manifests('fixture') if o['kind']=='Pod')
|
||||
actual=deepcopy(wanted);spec=actual['spec']
|
||||
if defect=='writable-mount':spec['containers'][0]['volumeMounts'][0]['readOnly']=False
|
||||
if defect=='extra-container':spec['containers'].append(deepcopy(spec['containers'][0]))
|
||||
if defect=='service-token':spec['automountServiceAccountToken']=True
|
||||
if defect=='different-image':spec['containers'][0]['image']='unreviewed:latest'
|
||||
if defect=='host-network':spec['hostNetwork']=True
|
||||
with pytest.raises(capture.Refused):capture.validate_helper(actual,wanted)
|
||||
|
||||
|
||||
def test_namespace_dns_allow_is_excluded_only_for_capture_helpers():
|
||||
selector={'matchExpressions':[{'key':'net-kingdom/capture','operator':'DoesNotExist'}]}
|
||||
assert capture.selects(selector,{'app.kubernetes.io/name':'authelia'})
|
||||
assert not capture.selects(selector,{'net-kingdom/capture':'fixture'})
|
||||
|
||||
|
||||
def test_unknown_create_outcome_is_checkpointed_and_never_claimed_clean(tmp_path,monkeypatch):
|
||||
monkeypatch.setattr(capture.bundle,'profile',lambda:{'components':[]})
|
||||
monkeypatch.setattr(capture,'command',lambda *args,**kwargs:(_ for _ in ()).throw(TimeoutError()))
|
||||
r={'started_at':'fixture'};checkpoints=[]
|
||||
p={'run_id':'fixture','create_resources':[capture.manifests('fixture')[0]]}
|
||||
with pytest.raises(capture.Refused):capture.capture(p,tmp_path/'unused.age',r,'fixture',lambda:checkpoints.append(deepcopy(r)))
|
||||
assert r['helper_cleanup_verified'] is False and r['unverified_create_outcomes']
|
||||
assert any(c.get('unverified_create_outcomes') for c in checkpoints)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.environ.get('NK49_RUN_DISPOSABLE_POSTGRES_TEST')!='1',reason='explicit local Docker fixture opt-in')
|
||||
def test_full_new_archive_decrypt_extract_native_database_restore_and_cleanup(stage,tmp_path,monkeypatch):
|
||||
verify=load('verify-identity');fixture='nk49-bundle-fixture-'+uuid.uuid4().hex
|
||||
def command(args,**kwargs):
|
||||
return subprocess.run(args,check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120,**kwargs).stdout
|
||||
try:
|
||||
command(['docker','run','--detach','--pull=never','--name',fixture,'--network','none',
|
||||
'--tmpfs','/var/lib/postgresql/data:rw,size=268435456','--env','POSTGRES_HOST_AUTH_METHOD=trust',
|
||||
verify.pg.IMAGE,'postgres','-c','listen_addresses='])
|
||||
for _ in range(60):
|
||||
probe=subprocess.run(['docker','exec',fixture,'pg_isready','-U','postgres'],capture_output=True)
|
||||
if probe.returncode==0:break
|
||||
time.sleep(1)
|
||||
command(['docker','exec','-i',fixture,'psql','-X','-U','postgres','-v','ON_ERROR_STOP=1'],
|
||||
input=b'CREATE TABLE token(id integer); INSERT INTO token VALUES (1),(2); CREATE TABLE resolver(id integer); CREATE TABLE realm(id integer);')
|
||||
(stage/'databases/privacyidea.dump').write_bytes(command(['docker','exec',fixture,'pg_dump','-U','postgres','-Fc','postgres']))
|
||||
key=tmp_path/'disposable-age.key';command(['age-keygen','-o',str(key)])
|
||||
recipient=command(['age-keygen','-y',str(key)]).decode().strip()
|
||||
p=b.profile();p['public_recipient']=recipient
|
||||
monkeypatch.setattr(b,'profile',lambda:p);monkeypatch.setattr(verify.bundle,'profile',lambda:p)
|
||||
artifact=tmp_path/'net-kingdom-identity-20261016T000000Z-0123456789ab.tar.age'
|
||||
c=b.seal(stage,artifact,{'test_only':True})
|
||||
transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified',
|
||||
'independent_owner_retrieval_verified':True,'fetched_path':str(artifact),'artifact':artifact.name,
|
||||
'ciphertext_sha256':c['ciphertext_sha256'],'ciphertext_bytes':c['ciphertext_bytes']}
|
||||
r={};fd=os.open(key,os.O_RDONLY)
|
||||
try:verify.verify(transfer,fd,r)
|
||||
finally:os.close(fd)
|
||||
assert r['status']=='identity_archive_verified'
|
||||
assert r['database_restore']['aggregate_row_counts']['token']==2
|
||||
assert r['all_manifest_hashes_verified'] and r['wrong_key_refused']
|
||||
assert r['container_cleanup_verified'] and r['temporary_plaintext_cleanup_verified']
|
||||
assert not r['full_service_authentication_proven']
|
||||
finally:subprocess.run(['docker','rm','--force','--volumes',fixture],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=30)
|
||||
|
||||
|
||||
def test_runtime_host_requires_owner_and_exact_machine_binding(monkeypatch):
|
||||
import hashlib,socket
|
||||
module=load('identity-bundle')
|
||||
current=module.profile();current['runtime_host']={'hostname':'verified-node','machine_id_sha256':hashlib.sha256(b'fixture-machine').hexdigest()}
|
||||
monkeypatch.setattr(module,'profile',lambda:current)
|
||||
monkeypatch.setattr(module.os,'geteuid',lambda:0);monkeypatch.setattr(socket,'gethostname',lambda:'verified-node')
|
||||
original=Path.read_bytes
|
||||
monkeypatch.setattr(Path,'read_bytes',lambda p:b'fixture-machine' if str(p)=='/etc/machine-id' else original(p))
|
||||
module.require_runtime_host()
|
||||
monkeypatch.setattr(socket,'gethostname',lambda:'foreign-node')
|
||||
with pytest.raises(module.Refused):module.require_runtime_host()
|
||||
monkeypatch.setattr(socket,'gethostname',lambda:'verified-node');monkeypatch.setattr(module.os,'geteuid',lambda:1000)
|
||||
with pytest.raises(module.Refused):module.require_runtime_host()
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('fixture_runner',ROOT/'tests/run_isolated_keycape_native_fixture.py')
|
||||
f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f)
|
||||
|
||||
class FixturePreparationTests(unittest.TestCase):
|
||||
def test_default_plan_neither_runs_docker_nor_generates_materials(self):
|
||||
output=io.StringIO()
|
||||
with patch.object(sys,'argv',['fixture','--plan']),patch.object(f.s,'run') as run,patch.object(f,'materials') as materials,patch.object(f.s.local,'preflight') as preflight,contextlib.redirect_stdout(output):
|
||||
self.assertEqual(f.main(),0)
|
||||
run.assert_not_called();materials.assert_not_called();preflight.assert_not_called()
|
||||
manifest=json.loads(output.getvalue())
|
||||
self.assertEqual(manifest['status'],'prepared_not_executed_not_approved')
|
||||
self.assertEqual(manifest['fixed_endpoint'],'unix:///var/run/docker.sock')
|
||||
def test_wrong_reviewed_manifest_hash_stops_before_preflight_or_generation(self):
|
||||
with patch.object(sys,'argv',['fixture','--execute-reviewed-fixture','--expected-manifest-sha256','0'*64]),patch.object(f.s,'run') as run,patch.object(f,'materials') as materials,patch.object(f.s.local,'preflight') as preflight:
|
||||
with self.assertRaises(RuntimeError):f.main()
|
||||
run.assert_not_called();materials.assert_not_called();preflight.assert_not_called()
|
||||
def test_manifest_binds_current_candidate_and_existing_fixture_factory(self):
|
||||
digest=hashlib.sha256(f.MANIFEST.read_bytes()).hexdigest()
|
||||
manifest=f.reviewed_manifest(digest)
|
||||
self.assertIn('tests/test_identity_bundle.py',manifest['source_sha256'])
|
||||
self.assertIn('tools/bootstrap-recovery/isolated-keycape-session.py',manifest['source_sha256'])
|
||||
self.assertIn('sso-mfa/k8s/privacyidea/key-custody-remote.py',manifest['source_sha256'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py'
|
||||
spec=importlib.util.spec_from_file_location('adapter',SOURCE)
|
||||
a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a)
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name)
|
||||
config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}}
|
||||
self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(),
|
||||
'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[
|
||||
{'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'},
|
||||
{'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'},
|
||||
{'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(),
|
||||
'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(),
|
||||
'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'}
|
||||
for name,value in self.values.items():
|
||||
p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600)
|
||||
self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]},
|
||||
'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]}
|
||||
def payload(self):
|
||||
digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
return a.private_payload(self.root,self.manifest,digest)
|
||||
def test_original_state_and_secret_delivery_retained(self):
|
||||
p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private')
|
||||
self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}')
|
||||
def test_archive_digest_refused(self):
|
||||
with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64)
|
||||
def test_changed_file_refused(self):
|
||||
(self.root/'data/keycape/policy.json').write_text('{"changed":true}')
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
def test_symlink_refused(self):
|
||||
p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname')
|
||||
with self.assertRaises(Exception):self.payload()
|
||||
def test_duplicate_manifest_refused(self):
|
||||
self.manifest['files'].append(self.manifest['files'][0])
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
def test_image_mismatch_refused(self):
|
||||
d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest'
|
||||
raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw)
|
||||
for row in self.manifest['files']:
|
||||
if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest())
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
|
||||
class NativeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())
|
||||
self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n'
|
||||
self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'}
|
||||
self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}}
|
||||
self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(),
|
||||
'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(),
|
||||
'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer',
|
||||
'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}}
|
||||
self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}}
|
||||
self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write'])
|
||||
def file(self,container,path):
|
||||
return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path]
|
||||
def command(self,argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode()
|
||||
output=io.BytesIO()
|
||||
with tarfile.open(fileobj=output,mode='w') as t:
|
||||
m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}'))
|
||||
return output.getvalue()
|
||||
def verify(self,code=0,output=None):
|
||||
with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run:
|
||||
result=a.run_verifier(self.session,self.admission,self.payload)
|
||||
argv=run.call_args.args[0]
|
||||
self.assertNotIn('fixture-private',' '.join(argv))
|
||||
self.assertIn('-previous-secret-env',argv)
|
||||
return result
|
||||
def test_original_registration_and_unrelated_secret(self):
|
||||
r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r))
|
||||
def test_transport_failure_never_counts_as_wrong_secret(self):
|
||||
with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n')
|
||||
def test_missing_explicit_rejection_never_counts(self):
|
||||
with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n',''))
|
||||
def test_fail_line_refused_even_zero_exit(self):
|
||||
with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n')
|
||||
def test_security_environment_override_refused(self):
|
||||
original=self.command
|
||||
def altered(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode()
|
||||
return original(argv,**kwargs)
|
||||
with patch.object(self,'command',side_effect=altered):
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_http_issuer_refused(self):
|
||||
self.admission['issuer']='http://issuer.example'
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_changed_trust_refused(self):
|
||||
self.admission['trust_sha256']='0'*64
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_config_drift_refused(self):
|
||||
self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode()
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
|
||||
class PublicReadinessTransportTests(unittest.TestCase):
|
||||
def test_provider_probe_failure_is_closed_false_data_not_failed_docker_exec(self):
|
||||
output=io.StringIO()
|
||||
with patch('sys.stdin',io.StringIO('{}')),patch('sys.stdout',output):
|
||||
exec(compile(a.JWKS_TRANSPORT,'<public-readiness>','exec',optimize=2),{})
|
||||
self.assertEqual(json.loads(output.getvalue()),{'original_public_signing_key_in_live_jwks':False})
|
||||
|
||||
class BoundaryTests(unittest.TestCase):
|
||||
def test_remote_docker_refused_before_info(self):
|
||||
session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'}
|
||||
with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command:
|
||||
with self.assertRaises(a.Refused):a.inspect_session(session)
|
||||
self.assertEqual(command.call_count,1)
|
||||
def test_failed_verifier_cleanup_is_attempted(self):
|
||||
receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}}
|
||||
with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup:
|
||||
with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt)
|
||||
cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed')
|
||||
|
||||
def test_cleanup_failure_overrides_success(self):
|
||||
admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}}
|
||||
result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')}
|
||||
result['public_signing_spki_sha256']='e'*64;receipt={}
|
||||
with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused):
|
||||
with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt)
|
||||
self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified'])
|
||||
|
||||
class ContainmentTests(unittest.TestCase):
|
||||
def inspect(self,overrides=None,launch=None,mounts=None):
|
||||
name='nk49-kc-'+('a'*32)+'-issuer'
|
||||
session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}}
|
||||
host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']}
|
||||
host.update(overrides or {})
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]'
|
||||
if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}'
|
||||
if argv[-1]=='{{.Id}}':return b'anchor-id'
|
||||
if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode()
|
||||
return json.dumps({'host':host,'mounts':mounts or [],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode()
|
||||
with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session)
|
||||
def test_minimal_network_none_admitted(self):self.inspect()
|
||||
def test_image_declared_anonymous_disk_volume_remains_refused(self):
|
||||
with self.assertRaises(a.Refused):
|
||||
self.inspect(mounts=[{'Type':'volume','Name':'anonymous-fixture','Destination':'/privacyidea/etc/persistent','RW':True}])
|
||||
|
||||
def test_alternate_same_image_command_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']})
|
||||
def test_cap_add_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']})
|
||||
def test_devices_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]})
|
||||
def test_device_requests_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]})
|
||||
def test_host_namespace_refused(self):
|
||||
for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'):
|
||||
with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'})
|
||||
def test_security_override_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']})
|
||||
|
||||
class CleanupTests(unittest.TestCase):
|
||||
def test_partial_cleanup_attempts_every_owned_container(self):
|
||||
session={'run_id':'a'*32,'containers':['first','second','third']};receipt={}
|
||||
removed=[]
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return ('a'*32).encode()
|
||||
if argv[1]=='rm':
|
||||
removed.append(argv[-1])
|
||||
if argv[-1]=='third':raise a.Refused()
|
||||
return b''
|
||||
if argv[1:3]==['container','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$')
|
||||
return (name+'\n').encode() if name=='third' or name not in removed else b''
|
||||
return b''
|
||||
with patch.object(a,'command',side_effect=command):
|
||||
with self.assertRaises(a.Refused):a.cleanup(session,receipt)
|
||||
self.assertEqual(removed,['third','second','first'])
|
||||
self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified'])
|
||||
def test_ownership_mismatch_does_not_remove_and_continues(self):
|
||||
session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[]
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode()
|
||||
if argv[1]=='rm':removed.append(argv[-1])
|
||||
if argv[1:3]==['container','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$')
|
||||
return (name+'\n').encode() if name not in removed else b''
|
||||
return b''
|
||||
with patch.object(a,'command',side_effect=command):
|
||||
with self.assertRaises(a.Refused):a.cleanup(session,receipt)
|
||||
self.assertEqual(removed,['first'])
|
||||
self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed')
|
||||
def test_inspect_failure_retains_uncertain_obligation(self):
|
||||
receipt={}
|
||||
with patch.object(a,'command',side_effect=a.Refused):
|
||||
with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt)
|
||||
self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}])
|
||||
|
||||
class JWKSTests(unittest.TestCase):
|
||||
def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'):
|
||||
request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'}
|
||||
def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=')
|
||||
responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}]
|
||||
if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2'))
|
||||
class Response:
|
||||
def __init__(self,value):self.value=value
|
||||
def __enter__(self):return self
|
||||
def __exit__(self,*args):pass
|
||||
def read(self,*args):return json.dumps(self.value).encode()
|
||||
class Opener:
|
||||
def open(self,*args,**kwargs):return Response(responses.pop(0))
|
||||
with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()):
|
||||
exec(compile(a.JWKS_PROBE,'<jwks-probe>','exec',optimize=2),{})
|
||||
def test_exact_original_public_key(self):self.execute()
|
||||
def test_extra_signing_key_refused_under_optimization(self):
|
||||
with self.assertRaises(ValueError):self.execute(extra=True)
|
||||
def test_wrong_kid_refused_under_optimization(self):
|
||||
with self.assertRaises(ValueError):self.execute(kid='replacement')
|
||||
def test_wrong_live_signing_key_refused(self):
|
||||
with self.assertRaises(ValueError):self.execute(n='124')
|
||||
def test_cross_origin_jwks_refused(self):
|
||||
with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks')
|
||||
def test_production_dns_refused_before_fetch(self):
|
||||
with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py'
|
||||
spec=importlib.util.spec_from_file_location('session',SOURCE)
|
||||
s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s)
|
||||
|
||||
class SessionTests(unittest.TestCase):
|
||||
def test_tmpfs_plan_has_four_exact_original_mounts(self):
|
||||
session=s.plan_session()
|
||||
self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS))
|
||||
self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values()))
|
||||
self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE)
|
||||
def test_non_tmpfs_driver_options_refused(self):
|
||||
session=s.plan_session();name,spec=next(iter(session['volumes'].items()))
|
||||
wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}]
|
||||
with patch.object(s,'run',return_value=json.dumps(wrong).encode()):
|
||||
with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id'])
|
||||
def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self):
|
||||
session=s.plan_session();events=[]
|
||||
payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}}
|
||||
def run(argv,**kwargs):
|
||||
events.append(('run',argv,kwargs))
|
||||
if argv[-1]=='{{.Id}}':return b'anchor'
|
||||
return b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))):
|
||||
s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example')
|
||||
joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run')
|
||||
self.assertNotIn('fixture-sensitive',joined)
|
||||
private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1])
|
||||
inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect')
|
||||
self.assertLess(inspect_index,private_index)
|
||||
issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE)
|
||||
self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive')
|
||||
self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1])
|
||||
self.assertNotIn('--entrypoint',issuer_call[1])
|
||||
def test_pinned_helper_image_volume_is_explicitly_masked_with_tmpfs(self):
|
||||
calls=[]
|
||||
def run(argv,**kwargs):calls.append(argv);return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session'):
|
||||
s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example')
|
||||
worker=next(argv for argv in calls if s.WORKER in argv)
|
||||
mask='/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700'
|
||||
self.assertIn(mask,worker)
|
||||
self.assertEqual(worker[worker.index(mask)-1],'--tmpfs')
|
||||
issuer=next(argv for argv in calls if argv[-1]==s.adapter.IMAGE)
|
||||
self.assertNotIn(mask,issuer)
|
||||
def test_public_phase_sequence_reports_containment_before_private_import(self):
|
||||
session=s.plan_session();phases=[]
|
||||
def run(argv,**kwargs):
|
||||
return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session'):
|
||||
s.provision(session,{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
self.assertEqual(phases,list(s.PROVISION_PHASES))
|
||||
def test_worker_containment_refusal_keeps_exact_phase_and_skips_import(self):
|
||||
phases=[];calls=[]
|
||||
def run(argv,**kwargs):calls.append(argv);return b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=s.adapter.Refused):
|
||||
with self.assertRaises(s.adapter.Refused):
|
||||
s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
self.assertEqual(phases[-1],'worker_containment')
|
||||
self.assertFalse(any(s.IMPORT in argv for argv in calls))
|
||||
def test_issuer_containment_refusal_does_not_claim_provision_complete(self):
|
||||
phases=[]
|
||||
def run(argv,**kwargs):return b'anchor' if argv[-1]=='{{.Id}}' else b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=[None,s.adapter.Refused()]):
|
||||
with self.assertRaises(s.adapter.Refused):
|
||||
s.provision(s.plan_session(),{'files':{},'env':{}},{},'https://issuer.example',phases.append)
|
||||
self.assertEqual(phases[-1],'issuer_containment')
|
||||
self.assertNotIn('complete',phases)
|
||||
def test_non_original_https_port_refused_before_any_mutation(self):
|
||||
with patch.object(s,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example')
|
||||
run.assert_not_called()
|
||||
def test_container_failure_still_attempts_each_volume_cleanup(self):
|
||||
session=s.plan_session();receipt={};removed=[]
|
||||
def run(argv,**kwargs):
|
||||
if argv[1:3]==['volume','rm']:removed.append(argv[-1])
|
||||
if argv[1:3]==['volume','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$')
|
||||
return (name+'\n').encode() if name not in removed else b''
|
||||
return b''
|
||||
with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run):
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt)
|
||||
self.assertEqual(set(removed),set(session['volumes']))
|
||||
self.assertFalse(receipt['container_cleanup_verified'])
|
||||
self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4)
|
||||
def test_reaper_inherits_no_custody_or_service_environment(self):
|
||||
session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']}
|
||||
with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'verify_execution'),patch.object(s.os,'pidfd_open',return_value=99),patch.object(s.os,'close'),patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])):
|
||||
spawn.return_value.stdout.readline.return_value=b'registered\n'
|
||||
s.start_reaper(session,staging,{'pid':123})
|
||||
kwargs=spawn.call_args.kwargs
|
||||
self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'})
|
||||
self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']);self.assertEqual(kwargs['pass_fds'],(99,))
|
||||
|
||||
class TargetBindingTests(unittest.TestCase):
|
||||
def test_every_direct_docker_command_is_explicitly_bound(self):
|
||||
with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run:
|
||||
self.assertEqual(s.run(['docker','info']),b'ok')
|
||||
argv=run.call_args.args[0]
|
||||
self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT])
|
||||
self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT)
|
||||
def test_nondefault_unix_socket_is_refused_before_any_docker_call(self):
|
||||
with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'])
|
||||
run.assert_not_called()
|
||||
def test_conflicting_context_is_refused(self):
|
||||
with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'])
|
||||
run.assert_not_called()
|
||||
def test_conflicting_per_call_host_is_refused(self):
|
||||
with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'})
|
||||
run.assert_not_called()
|
||||
def test_reused_preflight_uses_same_bound_command(self):
|
||||
self.assertIs(s.local.command,s.adapter.command)
|
||||
def test_probe_is_registered_before_session_creation(self):
|
||||
session=s.plan_session()
|
||||
self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe')
|
||||
self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT)
|
||||
|
||||
class PlaintextLifecycleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name)
|
||||
self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop)
|
||||
self.run_id='a'*32
|
||||
self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700)
|
||||
self.staging=s.register_workdir(self.work,self.run_id)
|
||||
def test_exact_plaintext_directory_removed_and_recorded(self):
|
||||
(self.work/'archive.tar').write_bytes(b'synthetic-private-archive')
|
||||
restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic')
|
||||
receipt={};s.cleanup_workdir(self.staging,receipt)
|
||||
self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified'])
|
||||
self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified')
|
||||
def test_docker_failure_does_not_skip_plaintext_cleanup(self):
|
||||
(self.work/'archive.tar').write_bytes(b'synthetic')
|
||||
receipt={}
|
||||
with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused):
|
||||
with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt)
|
||||
self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified'])
|
||||
def test_replacement_inode_is_not_removed(self):
|
||||
# Preserve original inode while replacing its registered pathname.
|
||||
old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700)
|
||||
(self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={}
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt)
|
||||
self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists())
|
||||
self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed')
|
||||
def test_symlink_root_is_not_followed(self):
|
||||
self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep')
|
||||
self.work.symlink_to(outside,target_is_directory=True)
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{})
|
||||
self.assertTrue((outside/'kept').exists())
|
||||
def test_descendant_symlink_is_not_followed(self):
|
||||
outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside)
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{})
|
||||
self.assertEqual(outside.read_bytes(),b'keep')
|
||||
def test_wrong_owner_identity_is_not_removed(self):
|
||||
changed={**self.staging,'owner_uid':os.getuid()+1}
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{})
|
||||
self.assertTrue(self.work.exists())
|
||||
def test_unregistered_path_refused(self):
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {})
|
||||
|
||||
@unittest.skipUnless(hasattr(os,'pidfd_open') and hasattr(signal,'pidfd_send_signal'),'Linux pidfd required')
|
||||
class ExecutionDeadlineTests(unittest.TestCase):
|
||||
def start_synthetic_owner(self,root):
|
||||
late=str(root/'late-creation')
|
||||
code="""import importlib.util,json,subprocess,sys,time
|
||||
spec=importlib.util.spec_from_file_location('session',sys.argv[1]);s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s)
|
||||
execution=s.isolate_execution()
|
||||
child=subprocess.Popen([sys.executable,'-c',"import pathlib,sys,time;time.sleep(2);pathlib.Path(sys.argv[1]).write_text('late')",sys.argv[2]])
|
||||
print(json.dumps({'execution':execution,'child_pid':child.pid}),flush=True)
|
||||
time.sleep(20)
|
||||
"""
|
||||
parent=subprocess.Popen([sys.executable,'-c',code,str(SOURCE),late],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
message=json.loads(parent.stdout.readline());parent.stdout.close()
|
||||
def finish():
|
||||
if parent.poll() is None:parent.kill()
|
||||
parent.wait(timeout=5)
|
||||
try:
|
||||
before=s.process_facts(message['child_pid'])
|
||||
if before is None or before['sid']!=message['execution']['sid'] or before['pgid']!=message['execution']['pgid']:return
|
||||
fd=os.pidfd_open(message['child_pid'])
|
||||
try:
|
||||
after=s.process_facts(message['child_pid'])
|
||||
if after is not None and all(after[k]==before[k] for k in ('pid','sid','pgid','start_ticks','uid')):
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(fd)
|
||||
except ProcessLookupError:pass
|
||||
self.addCleanup(finish)
|
||||
return parent,message['execution'],Path(late)
|
||||
def test_deadline_quiesces_only_fresh_owner_group_before_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid)
|
||||
try:
|
||||
receipt={};s.quiesce_execution(execution,fd,receipt)
|
||||
self.assertTrue(receipt['execution_quiescence_verified'])
|
||||
self.assertFalse(s.group_members(execution));self.assertFalse(late.exists())
|
||||
finally:os.close(fd)
|
||||
def test_hard_parent_loss_stops_surviving_marker_creator(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid)
|
||||
try:
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
poll=__import__('select').poll();poll.register(fd,__import__('select').POLLIN);self.assertTrue(poll.poll(2000))
|
||||
receipt={};s.quiesce_execution(execution,fd,receipt)
|
||||
self.assertTrue(receipt['execution_quiescence_verified']);self.assertFalse(late.exists())
|
||||
self.assertFalse(s.group_members(execution))
|
||||
finally:os.close(fd)
|
||||
def test_expired_execution_cannot_start_any_docker_command(self):
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()-1,'staging':'/tmp/not-created'}
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','create','fixed-image'])
|
||||
run.assert_not_called()
|
||||
def test_reused_or_foreign_session_refuses_signals(self):
|
||||
binding={'pid':123,'sid':123,'pgid':123,'start_ticks':10,'uid':os.getuid()}
|
||||
wrong={**binding,'sid':456,'state':'S'}
|
||||
with patch.object(s,'process_facts',return_value=wrong),patch.object(s.signal,'pidfd_send_signal') as send:
|
||||
with self.assertRaises(s.adapter.Refused):s.quiesce_execution(binding,99,{})
|
||||
send.assert_not_called()
|
||||
def test_inflight_uncertainty_survives_plaintext_directory_removal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'owned-stage';path.mkdir();marker=Path(str(path)+'-docker-inflight');marker.touch()
|
||||
staging={'path':str(path)};self.assertTrue(s.daemon_uncertain(staging));path.rmdir()
|
||||
self.assertTrue(s.daemon_uncertain(staging))
|
||||
|
||||
class ReaperOrderingTests(unittest.TestCase):
|
||||
def exercise(self,quiescence_fails=False):
|
||||
events=[];records=[]
|
||||
def quiet(*args):
|
||||
events.append('quiesce')
|
||||
if quiescence_fails:raise s.adapter.Refused()
|
||||
args[-1]['execution_quiescence_verified']=True
|
||||
def recover(*args):events.append('cleanup')
|
||||
with patch.object(s,'verify_execution'),patch.object(s,'pidfd_dead',return_value=False),patch.object(s.select,'poll') as poll,patch.object(s,'quiesce_execution',side_effect=quiet),patch.object(s,'daemon_uncertain',return_value=True),patch.object(s,'recover_obligations',side_effect=recover),patch.object(s.os,'close'),patch.object(s.os,'open',return_value=99),patch.object(s.os,'fdopen'),patch.object(s.json,'dump',side_effect=lambda r,*args:records.append(dict(r))),patch('sys.stdout',io.StringIO()):
|
||||
poll.return_value.poll.return_value=[]
|
||||
s.reaper_main({}, {'path':'/dev/shm/exact-staging'}, {'deadline':time.monotonic()-1},77)
|
||||
return events,records[0]
|
||||
def test_daemon_uncertainty_survives_verified_resource_readback(self):
|
||||
events,receipt=self.exercise();self.assertEqual(events,['quiesce','cleanup'])
|
||||
self.assertTrue(receipt['execution_quiescence_verified']);self.assertTrue(receipt['in_flight_daemon_request_uncertain'])
|
||||
self.assertEqual(receipt['status'],'bounded_cleanup_uncertain')
|
||||
def test_unverified_quiescence_prevents_any_cleanup(self):
|
||||
events,receipt=self.exercise(True);self.assertEqual(events,['quiesce'])
|
||||
self.assertFalse(receipt['execution_quiescence_verified']);self.assertEqual(receipt['status'],'bounded_cleanup_uncertain')
|
||||
def test_checkpoint_rewrites_one_valid_json_document(self):
|
||||
with tempfile.TemporaryFile() as stream:
|
||||
s.persist_receipt(stream.fileno(),{'status':'registered','long':'x'*100})
|
||||
s.persist_receipt(stream.fileno(),{'status':'failed'})
|
||||
stream.seek(0);self.assertEqual(json.load(stream),{'status':'failed'})
|
||||
def test_nonzero_docker_result_retains_uncertainty_before_inflight_clear(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
staging=str(Path(directory)/'stage');Path(staging).mkdir()
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging}
|
||||
def returned(*args,**kwargs):
|
||||
self.assertTrue(Path(staging+'-docker-inflight').exists())
|
||||
return subprocess.CompletedProcess(args[0],1,b'')
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=returned):
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','create','synthetic-already-accepted'])
|
||||
self.assertFalse(Path(staging+'-docker-inflight').exists());self.assertTrue(s.daemon_uncertain({'path':staging}))
|
||||
def test_only_policy_mount_is_writable_in_issuer(self):
|
||||
plan=s.plan_session();args=s.mounts(plan,readonly=True)
|
||||
mounts=args[1::2]
|
||||
for value in mounts:
|
||||
self.assertEqual(not value.endswith(',readonly'),'target=/var/lib/keycape-policy' in value)
|
||||
def test_docker_timeout_retains_independent_uncertainty_marker(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
staging=str(Path(directory)/'stage');Path(staging).mkdir()
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging}
|
||||
def timeout(*args,**kwargs):
|
||||
self.assertTrue(Path(staging+'-docker-inflight').exists())
|
||||
raise subprocess.TimeoutExpired('synthetic Docker',1)
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=timeout):
|
||||
with self.assertRaises(subprocess.TimeoutExpired):s.run(['docker','create','fixed-image'])
|
||||
self.assertFalse(Path(staging+'-docker-inflight').exists())
|
||||
self.assertTrue(Path(staging+'-docker-uncertain').exists());self.assertTrue(s.daemon_uncertain({'path':staging}))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run identity capture: metadata plan first, exact bounded execution later.
|
||||
|
||||
Use the admitted Railiance caller. Flags bind reviewed source/targets; they do
|
||||
not grant authority. Private child output and provider diagnostics never print.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
|
||||
bundle=load('identity-bundle');prepare=load('prepare-identity-coverage')
|
||||
IMAGE='ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
||||
SOURCES=['capture-identity.py','identity-files.py','identity-bundle.py','prepare-identity-coverage.py','identity-backup-profile.json']
|
||||
KUBE=['kubectl']
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def command(args,payload=None,timeout=60):
|
||||
result=subprocess.run(KUBE+['--request-timeout=30s']+args,input=payload,
|
||||
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=timeout)
|
||||
if result.returncode:raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get(ns,kind,name):
|
||||
scope=['-n',ns] if ns else []
|
||||
return json.loads(command(scope+['get',kind,name,'-o','json']))
|
||||
|
||||
|
||||
def selects(selector,labels):
|
||||
if any(labels.get(k)!=v for k,v in selector.get('matchLabels',{}).items()):return False
|
||||
for expression in selector.get('matchExpressions',[]):
|
||||
key=expression['key'];op=expression['operator'];values=expression.get('values',[])
|
||||
if op=='In' and labels.get(key) not in values:return False
|
||||
if op=='NotIn' and key in labels and labels[key] in values:return False
|
||||
if op=='Exists' and key not in labels:return False
|
||||
if op=='DoesNotExist' and key in labels:return False
|
||||
if op not in {'In','NotIn','Exists','DoesNotExist'}:raise Refused()
|
||||
return True
|
||||
|
||||
|
||||
def network_review(run_id,actual_labels=None):
|
||||
rows=[]
|
||||
for ns in ('sso','mfa'):
|
||||
policies=json.loads(command(['-n',ns,'get','networkpolicies','-o','json']))['items']
|
||||
for obj in policies:
|
||||
m=obj['metadata'];s=obj['spec']
|
||||
labels=(actual_labels or {}).get(ns,{'net-kingdom/capture':run_id})
|
||||
if selects(s['podSelector'],labels) and (s.get('ingress') or s.get('egress')):
|
||||
rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'],
|
||||
'spec_sha256':hashlib.sha256(json.dumps(s,sort_keys=True).encode()).hexdigest()})
|
||||
return rows
|
||||
|
||||
|
||||
def validate_helper(actual,wanted):
|
||||
a=actual['spec'];w=wanted['spec']
|
||||
if (any(a.get(k) for k in ('hostNetwork','hostPID','hostIPC','initContainers','ephemeralContainers'))
|
||||
or a.get('automountServiceAccountToken') is not False or a.get('activeDeadlineSeconds')!=900
|
||||
or len(a['containers'])!=1 or a['volumes']!=w['volumes']):raise Refused()
|
||||
c=a['containers'][0];expected=w['containers'][0]
|
||||
if any(c.get(k)!=expected[k] for k in ('name','image','command','volumeMounts')):raise Refused()
|
||||
if c.get('env') or c.get('envFrom') or c.get('securityContext',{}).get('privileged'):raise Refused()
|
||||
if any(c.get('securityContext',{}).get(k)!=v for k,v in expected['securityContext'].items()):raise Refused()
|
||||
if any(a.get('securityContext',{}).get(k)!=v for k,v in w['securityContext'].items()):raise Refused()
|
||||
|
||||
|
||||
def manifests(run_id):
|
||||
result=[]
|
||||
for ns,claims in [('sso',[('lldap','lldap-data'),('authelia','authelia-data'),('keycape','keycape-authentication-policy')]),
|
||||
('mfa',[('privacyidea','privacyidea-data')])]:
|
||||
label={'net-kingdom/capture':run_id};name='nk49-capture-'+run_id
|
||||
result.append({'apiVersion':'networking.k8s.io/v1','kind':'NetworkPolicy',
|
||||
'metadata':{'name':name,'namespace':ns},
|
||||
'spec':{'podSelector':{'matchLabels':label},'policyTypes':['Ingress','Egress'],'ingress':[],'egress':[]}})
|
||||
result.append({'apiVersion':'v1','kind':'Pod','metadata':{'name':name,'namespace':ns,'labels':label},
|
||||
'spec':{'restartPolicy':'Never','activeDeadlineSeconds':900,'automountServiceAccountToken':False,
|
||||
'securityContext':{'runAsUser':0,'runAsGroup':0},
|
||||
'containers':[{'name':'capture','image':IMAGE,'imagePullPolicy':'IfNotPresent',
|
||||
'command':['python3','-c','import time; time.sleep(900)'],
|
||||
'securityContext':{'allowPrivilegeEscalation':False,'readOnlyRootFilesystem':True,
|
||||
'capabilities':{'drop':['ALL'],'add':['DAC_OVERRIDE']}},
|
||||
'resources':{'requests':{'cpu':'50m','memory':'128Mi'},'limits':{'cpu':'1','memory':'2Gi'}},
|
||||
'volumeMounts':[{'name':key,'mountPath':'/source/'+key,'readOnly':True} for key,_ in claims]+
|
||||
[{'name':'scratch','mountPath':'/scratch'}]}],
|
||||
'volumes':[{'name':key,'persistentVolumeClaim':{'claimName':claim,'readOnly':True}} for key,claim in claims]+
|
||||
[{'name':'scratch','emptyDir':{'medium':'Memory','sizeLimit':'512Mi'}}]}})
|
||||
return result
|
||||
|
||||
|
||||
def plan():
|
||||
p=bundle.profile();binding=prepare.prepare(p,get=get)
|
||||
cluster=get('databases','cluster','net-kingdom-pg')
|
||||
primary=cluster['status']['currentPrimary'];pod=get('databases','pod',primary)
|
||||
if (not any(o.get('uid')==cluster['metadata']['uid'] for o in pod['metadata'].get('ownerReferences',[]))
|
||||
or not any(c['type']=='Ready' and c['status']=='True' for c in pod['status'].get('conditions',[]))):raise Refused()
|
||||
run_id=uuid.uuid4().hex[:12]
|
||||
return {'schema':'netkingdom.identity-capture-plan.v1','status':'requires_owner_execution_admission',
|
||||
'created_at':datetime.now(timezone.utc).isoformat(),'run_id':run_id,'binding':binding,'kubeconfig':KUBE[2],
|
||||
'sources':[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES],
|
||||
'database':{'namespace':'databases','cluster':'net-kingdom-pg','cluster_uid':cluster['metadata']['uid'],
|
||||
'pod':primary,'pod_uid':pod['metadata']['uid'],'database':'privacyidea_db'},
|
||||
'create_resources':manifests(run_id),'service_changes':False,'credential_grants':False,
|
||||
'matching_network_allow_policies':network_review(run_id),
|
||||
'network_policy_enforcement_requires_owner_confirmation':True,
|
||||
'offsite_delivery_included':False,'global_atomic_snapshot_claimed':False}
|
||||
|
||||
|
||||
def checked_plan(path,expected):
|
||||
raw=path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=expected:raise Refused()
|
||||
p=json.loads(raw)
|
||||
if p.get('schema')!='netkingdom.identity-capture-plan.v1':raise Refused()
|
||||
if p.get('kubeconfig')!=KUBE[2]:raise Refused()
|
||||
if network_review(p['run_id']):raise Refused()
|
||||
if p['sources']!=[{'path':name,'sha256':hashlib.sha256((HERE/name).read_bytes()).hexdigest()} for name in SOURCES]:raise Refused()
|
||||
if p['create_resources']!=manifests(p['run_id']):raise Refused()
|
||||
now=datetime.now(timezone.utc)
|
||||
if not 0<=(now-datetime.fromisoformat(p['created_at'])).total_seconds()<3600:raise Refused()
|
||||
# Recheck the exact immutable source/target bindings before private reads.
|
||||
current=prepare.prepare(bundle.profile(),get=get)
|
||||
if current['bindings']!=p['binding']['bindings'] or current['profile_sha256']!=p['binding']['profile_sha256']:raise Refused()
|
||||
return p
|
||||
|
||||
|
||||
def delete_created(obj):
|
||||
m=obj['metadata'];ns=m['namespace'];name=m['name'];uid=m['uid']
|
||||
if obj['kind']=='Pod':path=f'/api/v1/namespaces/{ns}/pods/{name}'
|
||||
elif obj['kind']=='NetworkPolicy':path=f'/apis/networking.k8s.io/v1/namespaces/{ns}/networkpolicies/{name}'
|
||||
else:raise Refused()
|
||||
payload=json.dumps({'apiVersion':'v1','kind':'DeleteOptions','preconditions':{'uid':uid},'gracePeriodSeconds':1}).encode()
|
||||
command(['delete','--raw',path,'-f','-'],payload)
|
||||
if obj['kind']=='Pod':command(['-n',ns,'wait','--for=delete','pod/'+name,'--timeout=90s'],timeout=100)
|
||||
|
||||
|
||||
def bounded_file(args,path,limit):
|
||||
# Stream binary dump to private staging, with a byte bound and suppressed
|
||||
# diagnostics. API command argv contains only public resource/database names.
|
||||
proc=subprocess.Popen(KUBE+['--request-timeout=600s']+args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
count=0
|
||||
with path.open('xb') as out:
|
||||
while block:=proc.stdout.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>limit:raise Refused()
|
||||
out.write(block)
|
||||
if proc.wait(timeout=600):raise Refused()
|
||||
finally:
|
||||
if proc.poll() is None:proc.kill();proc.wait()
|
||||
|
||||
|
||||
def capture(p,output,receipt,decision,checkpoint=lambda:None):
|
||||
created=[];receipt.update(status='capture_started',decision_ref=decision,plan_run_id=p['run_id'],
|
||||
created_resources=[],unverified_create_outcomes=[],helper_cleanup_verified=False,temporary_plaintext_cleanup_verified=False)
|
||||
checkpoint()
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-identity-',dir='/tmp') as temporary:
|
||||
receipt['temporary_plaintext_directory']=temporary
|
||||
checkpoint()
|
||||
root=Path(temporary)/'payload';root.mkdir(mode=0o700)
|
||||
held={}
|
||||
try:
|
||||
for c in bundle.profile()['components']:
|
||||
for kind,names in [('deployment',[c['deployment']]),('secret',c['secret_refs']),('configmap',c['configmap_refs'])]:
|
||||
for name in names:
|
||||
obj=get(c['namespace'],kind,name)
|
||||
# Keep reconstructable deployed resources inside encryption,
|
||||
# without unrelated stale last-applied annotations.
|
||||
m=obj['metadata'];obj['metadata']={k:m[k] for k in ['name','namespace','uid','resourceVersion']}
|
||||
obj.pop('status',None)
|
||||
raw=json.dumps(obj,sort_keys=True).encode();slot=f"resources/{c['namespace']}/{kind}/{name}.json"
|
||||
held[(c['namespace'],kind,name)]=(m['uid'],m['resourceVersion'])
|
||||
bundle.save(root,slot,raw)
|
||||
for proposed in p['create_resources']:
|
||||
# create, never adopt/apply an existing resource. Unknown create
|
||||
# outcomes stop; only returned exact UIDs are eligible for cleanup.
|
||||
pending={'kind':proposed['kind'],**proposed['metadata']}
|
||||
receipt['unverified_create_outcomes'].append(pending)
|
||||
checkpoint()
|
||||
obj=json.loads(command(['create','-f','-','-o','json'],json.dumps(proposed).encode()))
|
||||
if (obj['kind']!=proposed['kind'] or obj['metadata']['name']!=proposed['metadata']['name']
|
||||
or obj['metadata']['namespace']!=proposed['metadata']['namespace'] or not obj['metadata'].get('uid')):raise Refused()
|
||||
created.append(obj)
|
||||
receipt['unverified_create_outcomes'].remove(pending)
|
||||
receipt['created_resources'].append({'namespace':obj['metadata']['namespace'],
|
||||
'kind':obj['kind'],'name':obj['metadata']['name'],'uid':obj['metadata']['uid']})
|
||||
checkpoint()
|
||||
for ns in ('sso','mfa'):
|
||||
name='nk49-capture-'+p['run_id']
|
||||
command(['-n',ns,'wait','--for=condition=Ready','pod/'+name,'--timeout=180s'],timeout=190)
|
||||
live=get(ns,'pod',name)
|
||||
expected=next(o for o in created if o['kind']=='Pod' and o['metadata']['namespace']==ns)
|
||||
if live['metadata']['uid']!=expected['metadata']['uid']:raise Refused()
|
||||
wanted=next(o for o in p['create_resources'] if o['kind']=='Pod' and o['metadata']['namespace']==ns)
|
||||
validate_helper(live,wanted)
|
||||
if network_review(p['run_id'],{ns:live['metadata']['labels']}):raise Refused()
|
||||
raw=command(['-n',ns,'exec','-i',name,'-c','capture','--','python3','-c',
|
||||
(HERE/'identity-files.py').read_text(),ns],timeout=600)
|
||||
if len(raw)>bundle.LIMIT+2*1024*1024:raise Refused()
|
||||
bundle.import_files(raw,root,ns)
|
||||
if get(ns,'pod',name)['metadata']['uid']!=expected['metadata']['uid']:raise Refused()
|
||||
db=p['database'];current=get('databases','cluster','net-kingdom-pg')
|
||||
if current['metadata']['uid']!=db['cluster_uid'] or current['status']['currentPrimary']!=db['pod']:raise Refused()
|
||||
if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused()
|
||||
(root/'databases').mkdir(mode=0o700)
|
||||
bounded_file(['-n','databases','exec',db['pod'],'-c','postgres','--','pg_dump','-U','postgres',
|
||||
'-d','privacyidea_db','-Fc','--no-owner','--no-acl'],root/'databases/privacyidea.dump',bundle.FILE_LIMIT)
|
||||
if get('databases','pod',db['pod'])['metadata']['uid']!=db['pod_uid']:raise Refused()
|
||||
for (ns,kind,name),binding in held.items():
|
||||
m=get(ns,kind,name)['metadata']
|
||||
if (m['uid'],m['resourceVersion'])!=binding:raise Refused()
|
||||
# Re-read original keys after the database dump. No rotation is
|
||||
# inferred from file presence; a changed key set refuses publication.
|
||||
raw=command(['-n','mfa','exec','-i','nk49-capture-'+p['run_id'],'-c','capture','--','python3','-c',
|
||||
(HERE/'identity-files.py').read_text(),'mfa'],timeout=60)
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-key-recheck-',dir='/tmp') as keytemp:
|
||||
again=Path(keytemp);bundle.import_files(raw,again,'mfa')
|
||||
for name in ('enckey','private.pem','public.pem'):
|
||||
if (root/'data/privacyidea'/name).read_bytes()!=(again/'data/privacyidea'/name).read_bytes():raise Refused()
|
||||
receipt.update(bundle.seal(root,output,{'started_at':receipt['started_at'],
|
||||
'finished_at':datetime.now(timezone.utc).isoformat(),'database':db,'decision_ref':decision,
|
||||
'cluster_uid':prepare.CLUSTER_UID,'global_atomic_snapshot_claimed':False,
|
||||
'omissions':['historical local backups','SQLite WAL/SHM/journal represented by native snapshots',
|
||||
'Authelia notification.txt','privacyIDEA service logs; audit data retained in database',
|
||||
'OpenBao root/unseal/age private key custody remains independently held']}))
|
||||
finally:
|
||||
failures=[]
|
||||
for obj in reversed(created):
|
||||
try:delete_created(obj)
|
||||
except Exception:failures.append(obj['metadata']['uid'])
|
||||
receipt['helper_cleanup_verified']=not failures and not receipt['unverified_create_outcomes']
|
||||
checkpoint()
|
||||
if not receipt['helper_cleanup_verified']:raise Refused()
|
||||
receipt['temporary_plaintext_cleanup_verified']=not Path(temporary).exists()
|
||||
checkpoint()
|
||||
if not receipt['temporary_plaintext_cleanup_verified']:raise Refused()
|
||||
|
||||
|
||||
def main():
|
||||
global KUBE
|
||||
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('action',choices=['plan','capture'])
|
||||
parser.add_argument('--kubeconfig',required=True)
|
||||
parser.add_argument('--receipt',required=True);parser.add_argument('--plan');parser.add_argument('--plan-sha256')
|
||||
parser.add_argument('--output');parser.add_argument('--decision-id');a=parser.parse_args();os.umask(0o077)
|
||||
KUBE=['kubectl','--kubeconfig',str(Path(a.kubeconfig).absolute())]
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
os.close(fd)
|
||||
r={'status':'failed','started_at':datetime.now(timezone.utc).isoformat(),'secret_values_exported':False}
|
||||
def checkpoint():
|
||||
fd,temp=tempfile.mkstemp(prefix='.capture-receipt-',dir=Path(a.receipt).absolute().parent)
|
||||
try:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno())
|
||||
os.replace(temp,a.receipt)
|
||||
finally:Path(temp).unlink(missing_ok=True)
|
||||
code=1
|
||||
try:
|
||||
if a.action=='plan':
|
||||
if any([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused()
|
||||
r=plan();code=0
|
||||
else:
|
||||
if not all([a.plan,a.plan_sha256,a.output,a.decision_id]):raise Refused()
|
||||
uuid.UUID(a.decision_id)
|
||||
p=checked_plan(Path(a.plan),a.plan_sha256)
|
||||
capture(p,Path(a.output),r,a.decision_id,checkpoint);code=0
|
||||
except Exception:
|
||||
r.update(status='failed',error='capture_refused_or_failed_private_diagnostics_suppressed')
|
||||
if r.get('temporary_plaintext_directory') and not Path(r['temporary_plaintext_directory']).exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
checkpoint()
|
||||
print('OK: identity '+a.action+' complete; see safe receipt.' if code==0 else 'ERROR: identity operation failed; inspect safe receipt before retry.')
|
||||
return code
|
||||
|
||||
|
||||
def interrupted(*args):raise Refused()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Restore the fetched PI database in an isolated, disposable local PostgreSQL.
|
||||
|
||||
Run only through the attended custody helper. No cluster, production database,
|
||||
network, ports, host data mounts, IAM login or token decryption is involved.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
spec=importlib.util.spec_from_file_location('custody',ROOT/'sso-mfa/bootstrap/sops-private.py')
|
||||
custody=importlib.util.module_from_spec(spec);spec.loader.exec_module(custody)
|
||||
RECIPIENT='age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz'
|
||||
IMAGE='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70'
|
||||
OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age'
|
||||
SIZE=89129309
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def run(args,**kwargs):
|
||||
result=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=600,**kwargs)
|
||||
if result.returncode:raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def transfer_input(receipt):
|
||||
r=json.loads(receipt.read_text())
|
||||
if (r.get('schema')!='netkingdom.privacyidea-offhost-transfer.v1' or
|
||||
r.get('status')!='ciphertext_fetched' or r.get('object')!=OBJECT or
|
||||
r.get('ciphertext_bytes')!=SIZE or r.get('object_unchanged_during_transfer') is not True):raise Refused()
|
||||
path=Path(r['ciphertext_path'])
|
||||
if not path.is_absolute() or path.parent.parent!=Path('/tmp') or not path.parent.name.startswith('nk49-offhost-'):raise Refused()
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
st=path.stat()
|
||||
if st.st_uid!=os.getuid() or st.st_mode&0o077 or st.st_nlink!=1 or not path.is_file() or st.st_size!=SIZE:raise Refused()
|
||||
blob=path.read_bytes()
|
||||
if len(blob)!=SIZE or hashlib.sha256(blob).hexdigest()!=r['ciphertext_sha256']:raise Refused()
|
||||
return blob,r['ciphertext_sha256']
|
||||
|
||||
|
||||
def restore(dump,container):
|
||||
# Require the exact image already present; do not pull through a mutable tag.
|
||||
image_id=run(['docker','image','inspect','--format','{{.Id}}',IMAGE]).decode().strip()
|
||||
run(['docker','run','--detach','--pull=never','--name',container,'--network','none',
|
||||
'--security-opt','no-new-privileges','--memory','2g','--cpus','2',
|
||||
'--tmpfs','/var/lib/postgresql/data:rw,nosuid,nodev,size=1073741824',
|
||||
'--env','POSTGRES_HOST_AUTH_METHOD=trust','--env','POSTGRES_DB=recovery',
|
||||
IMAGE,'postgres','-c','listen_addresses='])
|
||||
for _ in range(60):
|
||||
# The image starts a temporary server during initdb. Wait until the
|
||||
# entrypoint has exec'd the final server before attempting the restore.
|
||||
pid1=subprocess.run(['docker','exec',container,'cat','/proc/1/comm'],
|
||||
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if pid1.returncode or pid1.stdout.strip()!=b'postgres':
|
||||
time.sleep(1);continue
|
||||
ready=subprocess.run(['docker','exec',container,'pg_isready','-U','postgres','-d','recovery'],
|
||||
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if ready.returncode==0:break
|
||||
time.sleep(1)
|
||||
else:raise Refused()
|
||||
version=run(['docker','exec',container,'pg_restore','--version']).decode().strip()
|
||||
with dump.open('rb') as source:
|
||||
run(['docker','exec','-i',container,'pg_restore','-U','postgres','-d','recovery',
|
||||
'--no-owner','--no-privileges','--exit-on-error'],stdin=source)
|
||||
# Fixed aggregate queries only. No identity, resolver config or token values.
|
||||
counts={}
|
||||
for table in ('token','resolver','realm'):
|
||||
raw=run(['docker','exec',container,'psql','-X','-U','postgres','-d','recovery','-At',
|
||||
'-v','ON_ERROR_STOP=1','-c',f'SELECT count(*) FROM public.{table};']).strip()
|
||||
if not raw.isdigit():raise Refused()
|
||||
counts[table]=int(raw)
|
||||
return {'image':IMAGE,'image_id':image_id,'pg_restore_version':version,'aggregate_row_counts':counts}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
# Reserve the receipt before any recovery action; never overwrite evidence.
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.privacyidea-database-drill.v1','status':'failed',
|
||||
'started_at':datetime.now(timezone.utc).isoformat(),'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'production_contacted':False,'secret_values_exported':False,'native_identity_recovery_proven':False,
|
||||
'token_decryption_proven':False,'audit_signature_continuity_proven':False,
|
||||
'full_identity_file_coverage_proven':False,'container_cleanup_verified':False,
|
||||
'temporary_plaintext_cleanup_verified':False}
|
||||
container='nk49-pi-recovery-'+uuid.uuid4().hex
|
||||
keyfd=None;work=None
|
||||
try:
|
||||
blob,digest=transfer_input(Path(a.transfer_receipt));r['ciphertext_sha256']=digest
|
||||
keyfd=custody.private_key_fd()
|
||||
derived=run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()
|
||||
if derived!=RECIPIENT.encode():raise Refused()
|
||||
r['registered_recipient_match']=True
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-pi-drill-',dir='/tmp') as temporary:
|
||||
work=Path(temporary)
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary}
|
||||
wrong=work/'wrong.key'
|
||||
run(['age-keygen','-o',str(wrong)],env=env)
|
||||
negative=subprocess.run(['age','-d','-i',str(wrong)],input=blob,stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=60,env=env)
|
||||
if negative.returncode==0:raise Refused()
|
||||
r['wrong_key_refused']=True
|
||||
dump=work/'privacyidea.dump'
|
||||
with dump.open('xb') as output:
|
||||
result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=blob,
|
||||
stdout=output,stderr=subprocess.DEVNULL,timeout=120,pass_fds=(keyfd,),env=env)
|
||||
if result.returncode or not 5<dump.stat().st_size<SIZE:raise Refused()
|
||||
with dump.open('rb') as source:
|
||||
if source.read(5)!=b'PGDMP':raise Refused()
|
||||
r['offhost_ciphertext_decryption_verified']=True
|
||||
try:
|
||||
r.update(restore(dump,container));r['database_restore_and_readback_passed']=True
|
||||
finally:
|
||||
removed=subprocess.run(['docker','rm','--force','--volumes',container],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=60)
|
||||
# A removal error is a failed ceremony even if the restore passed.
|
||||
if removed.returncode:raise Refused()
|
||||
absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,timeout=15)
|
||||
# Confirm the daemon remains reachable, so inspect failure means absent.
|
||||
run(['docker','info','--format','{{.ServerVersion}}'])
|
||||
if absent.returncode==0:raise Refused()
|
||||
r['container_cleanup_verified']=True
|
||||
if work.exists():raise Refused()
|
||||
r['temporary_plaintext_cleanup_verified']=True;r['status']='isolated_database_drill_passed'
|
||||
except Exception:
|
||||
r['error']='database_drill_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
if work is not None and not work.exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
if r['status']=='isolated_database_drill_passed':
|
||||
print('OK: off-host database decrypted, isolated restore/readback passed, disposable data removed. Full identity recovery remains open.')
|
||||
return 0
|
||||
print('ERROR: database drill failed; private diagnostics suppressed. Retain the safe receipt for review.',file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch one fixed ciphertext through Platform's attended Backup-account lane.
|
||||
|
||||
No key read, plaintext, redirects, proxies, grants, deletion or production writes.
|
||||
The object and byte count are bound to the reviewed inventory. Provider diagnostics
|
||||
and credentials remain private. Output is a fresh private directory under /tmp.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
spec=importlib.util.spec_from_file_location('inventory',HERE/'inventory-storage-share.py')
|
||||
owner=importlib.util.module_from_spec(spec);spec.loader.exec_module(owner)
|
||||
OBJECT='r01-net-kingdom-pg/net-kingdom-pg-privacyidea_db-20261015T023000Z.dump.age'
|
||||
SIZE=89129309
|
||||
|
||||
|
||||
def metadata(response,url):
|
||||
etag=response.headers.get('ETag','')
|
||||
if (response.status!=200 or response.url!=url or
|
||||
response.headers.get('Content-Length')!=str(SIZE) or
|
||||
len(etag)>200 or not etag.startswith('"') or not etag.endswith('"') or
|
||||
any(ord(c)<32 for c in etag)):
|
||||
raise owner.Refused()
|
||||
return etag
|
||||
|
||||
|
||||
def transfer(opener,auth,destination):
|
||||
url=owner.HOST+owner.BASE+OBJECT
|
||||
headers={'Authorization':'Basic '+auth,'Accept-Encoding':'identity'}
|
||||
with opener.open(urllib.request.Request(url,method='HEAD',headers=headers),timeout=30) as response:
|
||||
etag=metadata(response,url)
|
||||
headers['If-Match']=etag
|
||||
digest=hashlib.sha256();count=0
|
||||
with opener.open(urllib.request.Request(url,headers=headers),timeout=60) as response:
|
||||
if metadata(response,url)!=etag:raise owner.Refused()
|
||||
fd=os.open(destination,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
while chunk:=response.read(1024*1024):
|
||||
count+=len(chunk)
|
||||
if count>SIZE:raise owner.Refused()
|
||||
digest.update(chunk);out.write(chunk)
|
||||
out.flush();os.fsync(out.fileno())
|
||||
if count!=SIZE:raise owner.Refused()
|
||||
with opener.open(urllib.request.Request(url,method='HEAD',headers=headers),timeout=30) as response:
|
||||
if metadata(response,url)!=etag:raise owner.Refused()
|
||||
# ETag used for request binding only; it is not asserted to be a content hash.
|
||||
return {'ciphertext_bytes':count,'ciphertext_sha256':digest.hexdigest(),
|
||||
'object_unchanged_during_transfer':True}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.privacyidea-offhost-transfer.v1','status':'failed',
|
||||
'started_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share',
|
||||
'object':OBJECT,'age_keys_read':False,'plaintext_created':False,
|
||||
'production_writes':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'owner_accessor_sha256':hashlib.sha256((HERE/'inventory-storage-share.py').read_bytes()).hexdigest()}
|
||||
destination=None
|
||||
try:
|
||||
identity,credentials=owner.caller_credentials()
|
||||
r['caller_auth_path']=identity['path'];r['caller_entity_id']=identity.get('entity_id')
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
destination=Path(tempfile.mkdtemp(prefix='nk49-offhost-',dir='/tmp'))/'privacyidea.dump.age'
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),owner.NoRedirect())
|
||||
r.update(transfer(opener,auth,destination))
|
||||
r.update(status='ciphertext_fetched',ciphertext_path=str(destination))
|
||||
except Exception:
|
||||
r['error']='bounded_transfer_failed_diagnostics_suppressed'
|
||||
if destination is not None:
|
||||
destination.unlink(missing_ok=True);destination.parent.rmdir()
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"schema": "netkingdom.identity-backup-profile.v1",
|
||||
"status": "implemented_pending_owner_admission",
|
||||
"owner": "net-kingdom with railiance-platform/rapp-postgres runtime and storage admission",
|
||||
"public_recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz",
|
||||
"archive_profile": "netkingdom-identity-v1; new profile, not the bootstrap secrets-only tar profile",
|
||||
"components": [
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "lldap",
|
||||
"deployment_uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "lldap-data",
|
||||
"uid": "0a06b65a-e7b3-42d3-916e-e74dbe5fc7da"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"lldap-secrets"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /data/users.db with integrity check",
|
||||
"remaining non-backup durable /data files, including any private identity material",
|
||||
"current deployment configuration and exact lldap-secrets fields"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"directory user/group/membership preservation",
|
||||
"existing-user password authentication in isolated environment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "authelia",
|
||||
"deployment_uid": "dc40a925-2306-4a3b-89c0-e714187503c3",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "authelia-data",
|
||||
"uid": "86826554-b014-4df4-8b28-866aa64f6413"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"authelia-secrets",
|
||||
"authelia-smtp"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"authelia-config"
|
||||
],
|
||||
"required_assets": [
|
||||
"SQLite online-backup snapshot of /var/authelia/data/db.sqlite3 with integrity check",
|
||||
"current configuration.yml and all Secret-mounted encryption/OIDC/session fields",
|
||||
"explicit disposition for notifier credentials and remaining durable files"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"storage decryption",
|
||||
"same OIDC signing identity and preserved client configuration",
|
||||
"isolated authentication flow with outbound notification disabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "sso",
|
||||
"deployment": "keycape",
|
||||
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "keycape-authentication-policy",
|
||||
"uid": "423f558f-8c11-4edf-956b-243559df4a8e"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"keycape-approval-engine-operator-client",
|
||||
"keycape-config",
|
||||
"keycape-factor-read",
|
||||
"keycape-informed-decision-sitting-requester-client",
|
||||
"keycape-rapp-qonto-client",
|
||||
"keycape-secrets-engine-approval-client",
|
||||
"keycape-secrets-engine-openbao-client",
|
||||
"keycape-secrets-engine-requester-client"
|
||||
],
|
||||
"configmap_refs": [],
|
||||
"required_assets": [
|
||||
"exact current keycape-config config.yaml and key.pem",
|
||||
"complete stable policy/receipt PVC snapshot",
|
||||
"current deployment and owner disposition for every referenced credential: encrypted recovery or independently recoverable provider reissue path"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"same issuer/JWKS signing identity",
|
||||
"registry, authentication policy and receipt continuity",
|
||||
"current consumer positive/negative authentication under isolated test endpoints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"namespace": "mfa",
|
||||
"deployment": "privacyidea",
|
||||
"deployment_uid": "58c7f96d-61cb-4dd4-bca2-54661c0ac375",
|
||||
"pvc_refs": [
|
||||
{
|
||||
"name": "privacyidea-data",
|
||||
"uid": "65650fc1-02d8-4c0f-84fc-e0015c8f3ed9"
|
||||
},
|
||||
{
|
||||
"name": "privacyidea-logs",
|
||||
"uid": "39fa90d9-bc54-4a03-a0d6-ae85165e0cb9"
|
||||
}
|
||||
],
|
||||
"secret_refs": [
|
||||
"privacyidea-config"
|
||||
],
|
||||
"configmap_refs": [
|
||||
"factor-recovery-01119187d90d7b76",
|
||||
"keycape-onboarding-9465e5e0b98f7986",
|
||||
"privacyidea-cfg"
|
||||
],
|
||||
"required_assets": [
|
||||
"original /etc/privacyidea/enckey, private.pem and public.pem with matching RSA pair validation",
|
||||
"current privacyidea-config and mounted configuration/scripts",
|
||||
"bounded native PostgreSQL custom dump with exact database identity and key-generation linkage",
|
||||
"explicit disposition for service logs; audit database/signatures must be preserved"
|
||||
],
|
||||
"native_acceptance": [
|
||||
"decrypt existing retained token material without exporting it",
|
||||
"verify pre-backup audit signatures and create/verify a new isolated audit entry",
|
||||
"existing-token MFA and negative-key refusal"
|
||||
]
|
||||
}
|
||||
],
|
||||
"capture_contract": {
|
||||
"plaintext": "private task staging outside Git; never process output, State Hub or logs; verified cleanup after encryption",
|
||||
"consistency": "native SQLite online-backup and PostgreSQL logical snapshot; explicit capture interval per asset; no global atomic snapshot claim; before/after source identities/config versions and directory inventories must match",
|
||||
"key_policy": "preserve original service keys; no age private key in the payload, no replacement generation, no root/unseal collection",
|
||||
"encryption": "public-recipient-only streaming archive to age; private fresh ciphertext, no overwrite; manifest inside ciphertext with source identities, times, complete required paths, digests and omissions",
|
||||
"limits": "measure first; proposed 512 MiB encrypted admission ceiling, no silent truncation/exclusions",
|
||||
"historical_database": "existing verified October15 dump remains protected; new bundle must link its own exact database/key capture generation and recovery receipt"
|
||||
},
|
||||
"proposed_destinations": {
|
||||
"initial": "Storage Share Backup/railiance-backups/net-kingdom-identity/",
|
||||
"future_secondary": "s3://railiance-platform-pg-backup/platform-pg/application-archives/net-kingdom/; not implemented/admitted in this rollout",
|
||||
"admitted": false,
|
||||
"publisher": "CCR-2026-0048: new folder-only read/create public DAV share, delivered by exact ESO reader; no operator password in runtime; explicitly owner-approved provider-added re-sharing (permissions21)",
|
||||
"retention": "CCR-2026-0049: separate folder-only read/delete share and process; newest plus seven daily/two weekly and independently recovered pinned generation; unknown/unindexed points never deleted; explicitly owner-approved provider-added re-sharing (permissions25)",
|
||||
"schedule": "source prepared: daily 03:45 UTC plus up to five minutes jitter, hourly 25-hour freshness check; installation leaves timers disabled until independent archive recovery",
|
||||
"quota": "512 MiB ceiling, two GiB fresh provider headroom; initial 10 GiB account quota checked; unsupported public-share quota fails closed"
|
||||
},
|
||||
"admission_gates": [
|
||||
"Approve exact CCR-2026-0048/0049 grants and node runtime packet; three required owner reviews, confirmed native SA/namespace/audience boundaries before acceptance",
|
||||
"Exclude helper capture label from existing broad DNS allow policies; validate read-only helper manifests and cluster/PVC/source bindings before private capture",
|
||||
"Preserve every referenced credential encrypted with original keys; fresh native SQLite/PG snapshots and exact manifest, with documented omissions and no global atomicity claim",
|
||||
"Create fresh ciphertext and exact Storage Share object, read back ciphertext and receipt by strong ETag/hash, independently retrieve using operator custody",
|
||||
"Attended independent age-key proof: wrong-key denial, complete archive hashes, SQLite/RSA checks, disposable native PostgreSQL restore and verified cleanup before timer activation",
|
||||
"Full NK49 closure separately requires native directory/issuer/MFA authentication, retained token decryption, audit signature continuity, current callers and legacy credential dispositions; archive acceptance does not close those gates",
|
||||
"Host-local health cannot report a dead host; external outage monitoring and second-provider copy remain separate coverage gaps"
|
||||
],
|
||||
"runtime_host": {
|
||||
"ssh_alias": "railiance01",
|
||||
"hostname": "239.62.205.92.host.secureserver.net",
|
||||
"machine_id_sha256": "fe7367a60951922122af7ba18ed2cde65c7e0a232bbedfb045cb575de2094c2f"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict data-bearing identity archive format, native checks and private sealing.
|
||||
|
||||
No custody key is used to create a bundle. Its manifest and plaintext digests
|
||||
stay inside encryption. Public receipts expose only ciphertext provenance.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path,PurePosixPath
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||||
LIMIT=512*1024*1024
|
||||
FILE_LIMIT=256*1024*1024
|
||||
NAME=re.compile(r'[A-Za-z0-9_.-]{1,160}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def profile():return json.loads(PROFILE.read_text())
|
||||
|
||||
|
||||
def private(path,directory=False,max_bytes=FILE_LIMIT):
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
s=path.lstat()
|
||||
if s.st_uid!=os.getuid() or s.st_mode&0o077:raise Refused()
|
||||
if directory:
|
||||
if not stat.S_ISDIR(s.st_mode):raise Refused()
|
||||
elif not stat.S_ISREG(s.st_mode) or s.st_nlink!=1 or s.st_size>max_bytes:raise Refused()
|
||||
|
||||
|
||||
def allowed(name,profile_data=None):
|
||||
p=PurePosixPath(name)
|
||||
if p.is_absolute() or any(not NAME.fullmatch(x) or x in {'.','..'} for x in p.parts):return False
|
||||
if len(p.parts)>=3 and p.parts[:2] in [('data','lldap'),('data','authelia'),('data','keycape'),('data','privacyidea')]:return True
|
||||
if name=='databases/privacyidea.dump':return True
|
||||
for c in (profile_data or profile())['components']:
|
||||
prefix=f"resources/{c['namespace']}/"
|
||||
if name==prefix+'deployment/'+c['deployment']+'.json':return True
|
||||
if any(name==prefix+'secret/'+n+'.json' for n in c['secret_refs']):return True
|
||||
if any(name==prefix+'configmap/'+n+'.json' for n in c['configmap_refs']):return True
|
||||
return False
|
||||
|
||||
|
||||
def save(root,name,value,profile_data=None):
|
||||
if not allowed(name,profile_data) or len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused()
|
||||
target=root/name;target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(value)
|
||||
|
||||
|
||||
def import_files(raw,root,mode):
|
||||
names=set();total=0
|
||||
with tarfile.open(fileobj=io.BytesIO(raw),mode='r:') as archive:
|
||||
for member in archive:
|
||||
expected=('data/privacyidea/',) if mode=='mfa' else ('data/lldap/','data/authelia/','data/keycape/')
|
||||
if (not member.isfile() or not member.name.startswith(expected) or member.name in names
|
||||
or member.size<0 or member.size>FILE_LIMIT or len(names)>=2048):raise Refused()
|
||||
names.add(member.name);total+=member.size
|
||||
if total>LIMIT:raise Refused()
|
||||
data=archive.extractfile(member).read()
|
||||
if len(data)!=member.size:raise Refused()
|
||||
save(root,member.name,data)
|
||||
|
||||
|
||||
def resource(root,ns,kind,name):
|
||||
p=root/f'resources/{ns}/{kind}/{name}.json'
|
||||
data=json.loads(p.read_bytes())
|
||||
if data['metadata']['name']!=name or data['metadata']['namespace']!=ns:raise Refused()
|
||||
return data
|
||||
|
||||
|
||||
def checks(root,profile_data=None):
|
||||
# Checks describe stored formats/integrity, not current login validity.
|
||||
for c in (profile_data or profile())['components']:
|
||||
deployment=resource(root,c['namespace'],'deployment',c['deployment'])
|
||||
if deployment.get('kind')!='Deployment' or deployment['metadata']['uid']!=c['deployment_uid']:raise Refused()
|
||||
for name in c['secret_refs']:
|
||||
data=resource(root,c['namespace'],'secret',name)
|
||||
if data.get('kind')!='Secret' or not isinstance(data.get('data'),dict) or not data['data']:raise Refused()
|
||||
for value in data['data'].values():
|
||||
if b'AGE-SECRET-KEY-' in base64.b64decode(value,validate=True):raise Refused()
|
||||
for name in c['configmap_refs']:
|
||||
if resource(root,c['namespace'],'configmap',name).get('kind')!='ConfigMap':raise Refused()
|
||||
for name,file in [('lldap','users.db'),('authelia','db.sqlite3')]:
|
||||
db=root/f'data/{name}/{file}'
|
||||
with sqlite3.connect(db.as_uri()+'?mode=ro',uri=True) as con:
|
||||
if con.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused()
|
||||
with (root/'databases/privacyidea.dump').open('rb') as source:
|
||||
if source.read(5)!=b'PGDMP':raise Refused()
|
||||
spec=importlib.util.spec_from_file_location('pi_keys',ROOT/'sso-mfa/k8s/privacyidea/key-custody-remote.py')
|
||||
keys=importlib.util.module_from_spec(spec);spec.loader.exec_module(keys)
|
||||
keys.validate({name:(root/'data/privacyidea'/name).read_bytes() for name in ('enckey','private.pem','public.pem')})
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
config=resource(root,'sso','secret','keycape-config')['data']
|
||||
if not base64.b64decode(config['config.yaml'],validate=True):raise Refused()
|
||||
signing=serialization.load_pem_private_key(base64.b64decode(config['key.pem'],validate=True),password=None)
|
||||
if not isinstance(signing,rsa.RSAPrivateKey) or signing.key_size<2048:raise Refused()
|
||||
# Policy must exist, even for an empty set of receipts; do not silently
|
||||
# restore an issuer without its current authentication policy.
|
||||
policy=json.loads((root/'data/keycape/policy.json').read_bytes())
|
||||
if not isinstance(policy,dict):raise Refused()
|
||||
return {'sqlite_integrity_passed':True,'privacyidea_key_format_and_pair_passed':True,
|
||||
'keycape_signing_key_format_passed':True,'policy_json_present':True,
|
||||
'database_custom_format_present':True,'native_identity_recovery_proven':False}
|
||||
|
||||
|
||||
def inventory(root):
|
||||
private(root,True);entries=[];total=0
|
||||
for path in sorted(root.rglob('*')):
|
||||
if path.is_dir():private(path,True);continue
|
||||
private(path);name=path.relative_to(root).as_posix()
|
||||
if not allowed(name):raise Refused()
|
||||
with path.open('rb') as source:value=source.read(FILE_LIMIT+1)
|
||||
if len(value)>FILE_LIMIT or b'AGE-SECRET-KEY-' in value:raise Refused()
|
||||
total+=len(value)
|
||||
if total>LIMIT or len(entries)>=4096:raise Refused()
|
||||
entries.append({'path':name,'bytes':len(value),'sha256':hashlib.sha256(value).hexdigest()})
|
||||
if not entries:raise Refused()
|
||||
return entries
|
||||
|
||||
|
||||
def seal(root,output,capture):
|
||||
output=output.absolute();private(output.parent,True)
|
||||
if output.exists() or output.is_symlink():raise Refused()
|
||||
checked=checks(root);entries=inventory(root)
|
||||
manifest={'schema':'netkingdom.identity-archive.v1','profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
'profile':profile(),
|
||||
'captured_at':datetime.now(timezone.utc).isoformat(),'capture':capture,'files':entries,'checks':checked}
|
||||
with tempfile.TemporaryDirectory(prefix='.identity-seal-',dir=output.parent) as temporary:
|
||||
ciphertext=Path(temporary)/'ciphertext'
|
||||
fd=os.open(ciphertext,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
proc=subprocess.Popen(['age','-r',profile()['public_recipient']],stdin=subprocess.PIPE,stdout=fd,stderr=subprocess.DEVNULL)
|
||||
os.close(fd)
|
||||
try:
|
||||
with tarfile.open(fileobj=proc.stdin,mode='w|') as archive:
|
||||
raw=json.dumps(manifest,sort_keys=True).encode();m=tarfile.TarInfo('manifest.json');m.size=len(raw);m.mode=0o600
|
||||
archive.addfile(m,io.BytesIO(raw))
|
||||
for entry in entries:
|
||||
with (root/entry['path']).open('rb') as source:
|
||||
value=source.read(FILE_LIMIT+1)
|
||||
if hashlib.sha256(value).hexdigest()!=entry['sha256']:raise Refused()
|
||||
m=tarfile.TarInfo(entry['path']);m.size=len(value);m.mode=0o600
|
||||
archive.addfile(m,io.BytesIO(value))
|
||||
proc.stdin.close()
|
||||
if proc.wait(timeout=120) or not 0<ciphertext.stat().st_size<=LIMIT:raise Refused()
|
||||
# Check source completeness after encryption too; changed/new files
|
||||
# refuse publication. This is not same-UID adversary isolation.
|
||||
if inventory(root)!=entries:raise Refused()
|
||||
with ciphertext.open('rb') as source:
|
||||
os.fsync(source.fileno());digest=hashlib.file_digest(source,'sha256').hexdigest()
|
||||
os.link(ciphertext,output)
|
||||
finally:
|
||||
if proc.poll() is None:proc.kill();proc.wait()
|
||||
return {'schema':'netkingdom.identity-capture.v1','status':'encrypted_bundle_created',
|
||||
'ciphertext_sha256':digest,'ciphertext_bytes':output.stat().st_size,'ciphertext_path':str(output),
|
||||
'profile_sha256':manifest['profile_sha256'],'file_count':len(entries),
|
||||
'checks':checked,'offsite_recovery_proven':False,'secret_values_exported':False}
|
||||
|
||||
|
||||
def restore(archive_path,root):
|
||||
private(root,True)
|
||||
with tarfile.open(archive_path,mode='r:') as archive:
|
||||
first=archive.next()
|
||||
if first is None or first.name!='manifest.json' or not first.isfile() or not 0<first.size<2*1024*1024:raise Refused()
|
||||
manifest=json.load(archive.extractfile(first))
|
||||
if manifest.get('schema')!='netkingdom.identity-archive.v1':raise Refused()
|
||||
captured=manifest['profile']
|
||||
if captured.get('schema')!='netkingdom.identity-backup-profile.v1':raise Refused()
|
||||
if len(captured['components'])!=4:raise Refused()
|
||||
if {(c['namespace'],c['deployment']) for c in captured['components']}!={('sso','lldap'),('sso','authelia'),('sso','keycape'),('mfa','privacyidea')}:raise Refused()
|
||||
for c in captured['components']:
|
||||
for key in ('secret_refs','configmap_refs'):
|
||||
if len(set(c[key]))!=len(c[key]) or any(not NAME.fullmatch(n) or n in {'.','..'} for n in c[key]):raise Refused()
|
||||
entries=manifest['files'];expected={e['path']:e for e in entries}
|
||||
if len(expected)!=len(entries) or len(entries)>4096:raise Refused()
|
||||
seen=set();total=0
|
||||
# Iteration starts with the already-read first member; explicitly skip
|
||||
# only that member. A later duplicate manifest is never accepted.
|
||||
for index,member in enumerate(archive):
|
||||
if index==0 and member is first:continue
|
||||
name=member.name
|
||||
if (not member.isfile() or name in seen or name not in expected or not allowed(name,captured)
|
||||
or not 0<=member.size<=FILE_LIMIT or member.size!=expected[name]['bytes']):raise Refused()
|
||||
seen.add(name);total+=member.size
|
||||
if total>LIMIT:raise Refused()
|
||||
raw=archive.extractfile(member).read()
|
||||
if len(raw)!=member.size or hashlib.sha256(raw).hexdigest()!=expected[name]['sha256']:raise Refused()
|
||||
save(root,name,raw,captured)
|
||||
if seen!=set(expected):raise Refused()
|
||||
return manifest,checks(root,captured)
|
||||
|
||||
|
||||
def require_runtime_host():
|
||||
import socket
|
||||
host=profile()['runtime_host']
|
||||
if (os.geteuid()!=0 or socket.gethostname()!=host['hostname']
|
||||
or hashlib.sha256(Path('/etc/machine-id').read_bytes()).hexdigest()!=host['machine_id_sha256']):raise Refused()
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended Platform/Backup owner ceremony for two exact new backup shares.
|
||||
|
||||
Requires approved, hash-bound CCRs. No operator password/age key is delivered to
|
||||
the runtime; only a folder-scoped share token is stored with initial KV CAS=0.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
PLATFORM=HERE.parents[1].parent/'railiance-platform'
|
||||
sys.path.insert(0,str(PLATFORM/'scripts'))
|
||||
from state_hub_preflight_lane import bao,data,capabilities,revoke
|
||||
from repair_eso_kubernetes_auth import role_payload,check_role
|
||||
from telemetry_smtp_delivery import ensure
|
||||
|
||||
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
inventory=load('storage_inventory',HERE/'inventory-storage-share.py')
|
||||
offsite=load('offsite',HERE/'identity-offsite.py')
|
||||
FOLDER='/railiance-backups/net-kingdom-identity'
|
||||
PROFILES={'publisher':(48,21),'retention':(49,25)}
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def contract(action,expected):
|
||||
number,permission=PROFILES[action]
|
||||
path=PLATFORM/f'credential-change-requests/CCR-2026-{number:04d}-net-kingdom-backup-{action}.yaml'
|
||||
raw=path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=expected:raise Refused()
|
||||
module=load('credential_change',PLATFORM/'scripts/credential-change.py')
|
||||
ccr,errors,_=module.validate_ccr(path)
|
||||
if errors or ccr['status'] not in {'approved','applied','verified','active'}:raise Refused()
|
||||
required={'platform-owner','net-kingdom-owner','backup-share-owner'}
|
||||
approved={r['reviewer'].removeprefix('User (').removesuffix(')') for r in ccr.get('review',{}).get('comments',[])
|
||||
if r.get('decision')=='approved' and r.get('source_ref')}
|
||||
name='net-kingdom-backup-'+action+'-eso'
|
||||
expected_path='platform/workloads/net-kingdom/identity-backup/'+action
|
||||
a=ccr['openbao'];provider=ccr['provider']
|
||||
if (not required<=approved or a['kv_path']!=expected_path or a['fields']!=['NC_WEBDAV_TOKEN']
|
||||
or a['policy_name']!=name or a['auth']['role']!=name
|
||||
or a['auth']['bound_claims']!={'service_account_names':[name],'service_account_namespaces':['sso']}
|
||||
or provider['folder']!=FOLDER or provider['share_permissions']!=permission or provider['share_type']!=3
|
||||
or provider['label']!='net-kingdom-identity-'+action
|
||||
or provider.get('provider_added_resharing_approved') is not True):raise Refused()
|
||||
if (a.get('metadata_read') is not False or a.get('token_self_lifecycle') is not True
|
||||
or a['auth'].get('audience')!='openbao' or a['auth'].get('token_no_default_policy') is not True
|
||||
or any(a['auth'].get(k)!='15m' for k in ['ttl','token_max_ttl','token_explicit_max_ttl'])
|
||||
or a['auth'].get('policies')!=[name] or provider.get('host')!='nx4069.your-storageshare.de'):raise Refused()
|
||||
lane={'service_account':name,'namespace':'sso','role':name,'policy':name,
|
||||
'kv_path':expected_path.replace('platform/','platform/data/',1)}
|
||||
wanted=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/revoke-self" { capabilities = ["update"] }\n')
|
||||
if (PLATFORM/a['policy_file']).read_text()!=wanted:raise Refused()
|
||||
return ccr,lane
|
||||
|
||||
|
||||
def request(client,url,auth,method='GET',body=None,extra=None):
|
||||
if not url.startswith(offsite.HOST+'/'):raise Refused()
|
||||
h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode(),
|
||||
'X-Requested-With':'XMLHttpRequest'};h.update(extra or {})
|
||||
try:
|
||||
with client.open(urllib.request.Request(url,data=body,method=method,headers=h),timeout=60) as response:
|
||||
if response.url!=url:raise Refused()
|
||||
raw=response.read(1024*1024+1)
|
||||
if len(raw)>1024*1024:raise Refused()
|
||||
return response.status,raw
|
||||
except urllib.error.HTTPError as error:return error.code,b''
|
||||
|
||||
|
||||
def ocs(client,auth,suffix='',fields=None):
|
||||
url=offsite.HOST+'/ocs/v2.php/apps/files_sharing/api/v1/shares'+suffix
|
||||
body=urllib.parse.urlencode(fields).encode() if fields is not None else None
|
||||
status,raw=request(client,url,auth,'POST' if fields is not None else 'GET',body,
|
||||
{'OCS-APIRequest':'true','Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'})
|
||||
if status not in (200,201):raise Refused()
|
||||
parsed=json.loads(raw)['ocs']
|
||||
if parsed['meta']['statuscode'] not in (100,200):raise Refused()
|
||||
return parsed['data']
|
||||
|
||||
|
||||
def share(client,auth,action,receipt):
|
||||
owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER
|
||||
status,_=request(client,owner,auth,'MKCOL')
|
||||
if status not in (201,405):raise Refused()
|
||||
receipt['folder_created']=status==201
|
||||
matches=[s for s in ocs(client,auth,'?path='+urllib.parse.quote(FOLDER,safe=''))
|
||||
if s.get('label')=='net-kingdom-identity-'+action]
|
||||
if len(matches)>1:raise Refused()
|
||||
if matches:result=matches[0];receipt['share_created']=False
|
||||
else:
|
||||
result=ocs(client,auth,fields={'path':FOLDER,'shareType':3,'permissions':PROFILES[action][1],
|
||||
'label':'net-kingdom-identity-'+action});receipt['share_created']=True
|
||||
if (result.get('path')!=FOLDER or int(result.get('permissions',-1))!=PROFILES[action][1]
|
||||
or int(result.get('share_type',-1))!=3 or not result.get('token')):raise Refused()
|
||||
offsite.headers(result['token'])
|
||||
receipt.update(share_id=str(result['id']),share_permissions=int(result['permissions']),folder=FOLDER)
|
||||
return result['token']
|
||||
|
||||
|
||||
def boundary(client,auth,token,action,receipt):
|
||||
name='nk49-boundary-'+uuid.uuid4().hex+'.age'
|
||||
payload=subprocess.run(['age','-r',json.loads((HERE/'identity-backup-profile.json').read_text())['public_recipient']],
|
||||
input=b'NK49 disposable provider boundary fixture',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if payload.returncode:raise Refused()
|
||||
owner=offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+name
|
||||
public=offsite.BASE+name;created=False;deleted=False
|
||||
receipt['provider_test_artifacts']=[name]
|
||||
receipt['boundary_fixture_cleanup_verified']=False
|
||||
try:
|
||||
receipt['boundary_phase']='create_owner_fixture'
|
||||
status,_=request(client,owner,auth,'PUT',payload.stdout,{'If-None-Match':'*'})
|
||||
if status!=201:raise Refused()
|
||||
created=True
|
||||
receipt['boundary_phase']='read_public_fixture'
|
||||
status,raw=request(client,public,(token,''))
|
||||
if status!=200 or raw!=payload.stdout:raise Refused()
|
||||
receipt['boundary_phase']='deny_public_overwrite'
|
||||
status,_=request(client,public,(token,''),'PUT',payload.stdout)
|
||||
if status not in (401,403):raise Refused()
|
||||
denied_name='nk49-absent-'+uuid.uuid4().hex+'.age'
|
||||
if action=='retention':receipt['provider_test_artifacts'].append(denied_name)
|
||||
status,_=request(client,offsite.BASE+denied_name,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'}) if action=='retention' else (403,b'')
|
||||
if status not in (401,403):raise Refused()
|
||||
receipt['boundary_phase']='public_delete'
|
||||
status,_=request(client,public,(token,''),'DELETE')
|
||||
if action=='publisher':
|
||||
if status not in (401,403):raise Refused()
|
||||
new=offsite.BASE+'nk49-boundary-'+uuid.uuid4().hex+'.age'
|
||||
receipt['provider_test_artifacts'].append(new.rsplit('/',1)[-1])
|
||||
receipt['boundary_phase']='create_public_fixture'
|
||||
code,_=request(client,new,(token,''),'PUT',payload.stdout,{'If-None-Match':'*'})
|
||||
if code!=201:raise Refused()
|
||||
# This fixture belongs to the current ceremony, not retained data.
|
||||
receipt['boundary_phase']='cleanup_public_fixture'
|
||||
remove_fixture(client,offsite.HOST+'/remote.php/dav/files/Backup'+FOLDER+'/'+new.rsplit('/',1)[-1],auth,payload.stdout)
|
||||
else:
|
||||
if status!=204:raise Refused()
|
||||
deleted=True
|
||||
receipt['boundary_phase']='deny_parent_path'
|
||||
status,_=request(client,offsite.BASE+'%2e%2e/r01-net-kingdom-pg/',(token,''))
|
||||
receipt['parent_probe_status']=status
|
||||
if status not in (400,401,403,404):raise Refused()
|
||||
receipt['parent_path_rejected']=True
|
||||
# A malformed-path400 proves request rejection, not authorization.
|
||||
# Separately probe a known existing account folder with only this share
|
||||
# token: it must not authenticate as the Backup account.
|
||||
receipt['boundary_phase']='deny_other_account_folder'
|
||||
status,_=request(client,offsite.HOST+'/remote.php/dav/files/Backup/r01-net-kingdom-pg/',(token,''))
|
||||
receipt['other_account_folder_status']=status
|
||||
if status not in (401,403,404):raise Refused()
|
||||
receipt['provider_boundary_verified']=True
|
||||
finally:
|
||||
if created and not deleted:
|
||||
remove_fixture(client,owner,auth,payload.stdout)
|
||||
receipt['owner_fixture_cleanup_verified']=True
|
||||
# A rejected operation may have had an unexpected side effect. Only a
|
||||
# successful complete boundary test claims cleanup; names remain in the
|
||||
# receipt for owner reconciliation on every failure.
|
||||
receipt['boundary_fixture_cleanup_verified']=receipt.get('provider_boundary_verified',False)
|
||||
|
||||
|
||||
def remove_fixture(client,url,auth,expected):
|
||||
h={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode()}
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
version=offsite.etag(response,url,len(expected))
|
||||
code,raw=request(client,url,auth,extra={'If-Match':version})
|
||||
if code!=200 or raw!=expected:raise Refused()
|
||||
code,_=request(client,url,auth,'DELETE',extra={'If-Match':version})
|
||||
if code!=204:raise Refused()
|
||||
|
||||
|
||||
def run(action,expected,receipt):
|
||||
ccr,lane=contract(action,expected)
|
||||
identity,credentials=inventory.caller_credentials()
|
||||
receipt['caller_entity_id']=identity.get('entity_id');receipt['caller_auth_path']=identity['path']
|
||||
client=offsite.opener();auth=(credentials['BACKUP_USERNAME'],credentials['BACKUP_PASSWORD'])
|
||||
# Use the owner quota accessor only with proxies removed from its environment.
|
||||
quota=load('owner_quota',PLATFORM/'scripts/migrate_nextcloud_backup_account.py')
|
||||
quota.request=lambda url,method='GET',body=None,auth=None,headers=None:request(client,url,auth,method,body,headers)
|
||||
receipt['quota_before']=quota.quota(auth)
|
||||
if receipt['quota_before']['available_bytes']<offsite.HEADROOM+offsite.LIMIT:raise Refused()
|
||||
token=share(client,auth,action,receipt)
|
||||
boundary(client,auth,token,action,receipt)
|
||||
if action=='publisher':
|
||||
receipt['publisher_quota_available']=offsite.quota(client,token)
|
||||
if receipt['publisher_quota_available']<offsite.HEADROOM+offsite.LIMIT:raise Refused()
|
||||
# Fence agent reads before any initial secret publication.
|
||||
boundary_path='sys/policies/acl/agent-high-risk-boundary'
|
||||
current=data(bao(['read','-format=json',boundary_path]))['data']['policy'];added=''
|
||||
for path in [lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1)]:
|
||||
stanza='path "'+path+'" { capabilities = ["deny"] }\n'
|
||||
if '"'+path+'"' not in current:added+=stanza
|
||||
elif stanza.strip() not in current:raise Refused()
|
||||
if added:
|
||||
if data(bao(['read','-format=json',boundary_path]))['data']['policy']!=current:raise Refused()
|
||||
bao(['write',boundary_path,'-'],payload={'policy':current+'\n'+added})
|
||||
if data(bao(['read','-format=json',boundary_path]))['data']['policy']!=current+'\n'+added:raise Refused()
|
||||
receipt['coding_agent_fence_verified_before_publication']=True
|
||||
existing=bao(['read','-format=json',lane['kv_path']],allow_failure=True)
|
||||
if existing.returncode==0:
|
||||
native=data(existing)['data']
|
||||
if native['data']!={'NC_WEBDAV_TOKEN':token}:raise Refused()
|
||||
receipt['kv_version']=native['metadata']['version'];receipt['existing_identical_custody']=True
|
||||
else:
|
||||
if b'No value found' not in existing.stdout+existing.stderr:raise Refused()
|
||||
native=data(bao(['write','-format=json',lane['kv_path'],'-'],payload={'options':{'cas':0},'data':{'NC_WEBDAV_TOKEN':token}}))
|
||||
receipt['kv_version']=native['data']['version']
|
||||
policy=(PLATFORM/ccr['openbao']['policy_file']).read_text()
|
||||
expected_policy=('path "'+lane['kv_path']+'" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
|
||||
'path "auth/token/revoke-self" { capabilities = ["update"] }\n')
|
||||
if policy!=expected_policy:raise Refused()
|
||||
ensure('sys/policies/acl/'+lane['policy'],{'policy':policy},lambda actual: None if actual['policy']==policy else (_ for _ in ()).throw(Refused()))
|
||||
ensure('auth/kubernetes/role/'+lane['role'],role_payload(lane),lambda actual:check_role(actual,lane))
|
||||
child=data(bao(['token','create','-format=json','-policy='+lane['policy'],'-policy=agent-high-risk-boundary','-no-default-policy','-ttl=60s']))['auth']['client_token']
|
||||
try:
|
||||
if any(v!=['deny'] for v in capabilities(child,[lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1)]).values()):raise Refused()
|
||||
finally:revoke(child)
|
||||
receipt.update(status='provider_and_reader_prepared',coding_agents_denied=True,escrow_read=False,
|
||||
operator_password_delivered=False,retained_backups_deleted=False,source_ccr=ccr['id'],ccr_sha256=expected,
|
||||
eso_delivery_and_native_login_verification_pending=True)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=PROFILES)
|
||||
p.add_argument('--ccr-sha256',required=True);p.add_argument('--receipt',required=True);a=p.parse_args()
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','action':a.action,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:run(a.action,a.ccr_sha256,r)
|
||||
except Exception:r.update(status='failed',error='contained_setup_failed_inspect_partial_state_before_retry')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "List",
|
||||
"items": [
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ServiceAccount",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-publisher-eso",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"automountServiceAccountToken": false
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "SecretStore",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-publisher",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"provider": {
|
||||
"vault": {
|
||||
"server": "http://openbao.openbao.svc:8200",
|
||||
"path": "platform",
|
||||
"version": "v2",
|
||||
"auth": {
|
||||
"kubernetes": {
|
||||
"mountPath": "kubernetes",
|
||||
"role": "net-kingdom-backup-publisher-eso",
|
||||
"serviceAccountRef": {
|
||||
"name": "net-kingdom-backup-publisher-eso",
|
||||
"audiences": [
|
||||
"openbao"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "ExternalSecret",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-publisher",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"refreshInterval": "1h",
|
||||
"secretStoreRef": {
|
||||
"kind": "SecretStore",
|
||||
"name": "net-kingdom-backup-publisher"
|
||||
},
|
||||
"target": {
|
||||
"name": "net-kingdom-backup-publisher",
|
||||
"creationPolicy": "Owner",
|
||||
"template": {
|
||||
"type": "Opaque"
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"secretKey": "token",
|
||||
"remoteRef": {
|
||||
"key": "workloads/net-kingdom/identity-backup/publisher",
|
||||
"property": "NC_WEBDAV_TOKEN"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ServiceAccount",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-retention-eso",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"automountServiceAccountToken": false
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "SecretStore",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-retention",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"provider": {
|
||||
"vault": {
|
||||
"server": "http://openbao.openbao.svc:8200",
|
||||
"path": "platform",
|
||||
"version": "v2",
|
||||
"auth": {
|
||||
"kubernetes": {
|
||||
"mountPath": "kubernetes",
|
||||
"role": "net-kingdom-backup-retention-eso",
|
||||
"serviceAccountRef": {
|
||||
"name": "net-kingdom-backup-retention-eso",
|
||||
"audiences": [
|
||||
"openbao"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"apiVersion": "external-secrets.io/v1",
|
||||
"kind": "ExternalSecret",
|
||||
"metadata": {
|
||||
"name": "net-kingdom-backup-retention",
|
||||
"namespace": "sso"
|
||||
},
|
||||
"spec": {
|
||||
"refreshInterval": "1h",
|
||||
"secretStoreRef": {
|
||||
"kind": "SecretStore",
|
||||
"name": "net-kingdom-backup-retention"
|
||||
},
|
||||
"target": {
|
||||
"name": "net-kingdom-backup-retention",
|
||||
"creationPolicy": "Owner",
|
||||
"template": {
|
||||
"type": "Opaque"
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"secretKey": "token",
|
||||
"remoteRef": {
|
||||
"key": "workloads/net-kingdom/identity-backup/retention",
|
||||
"property": "NC_WEBDAV_TOKEN"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private in-helper PVC capture. Stdout is a secret-bearing tar: parent MUST capture.
|
||||
|
||||
Sources are mounted read-only; SQLite uses its online backup API, never raw copy
|
||||
or immutable=1. No production file is generated, replaced, chmod'd or removed.
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
MAX_FILE=256*1024*1024
|
||||
MAX_TOTAL=512*1024*1024
|
||||
MAX_COUNT=2048
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def read(path):
|
||||
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
try:
|
||||
before=os.fstat(fd)
|
||||
if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or before.st_size>MAX_FILE:raise Refused()
|
||||
with os.fdopen(os.dup(fd),'rb') as stream:data=stream.read(MAX_FILE+1)
|
||||
after=os.stat(path,follow_symlinks=False)
|
||||
if (len(data)>MAX_FILE or b'AGE-SECRET-KEY-' in data or
|
||||
any(getattr(before,k)!=getattr(after,k) for k in ('st_dev','st_ino','st_size','st_mtime_ns','st_ctime_ns'))):raise Refused()
|
||||
return data
|
||||
finally:os.close(fd)
|
||||
|
||||
|
||||
def tree(root,database=None,omit_notification=False,omit_backups=True):
|
||||
result={}
|
||||
for directory,dirs,files in os.walk(root,followlinks=False):
|
||||
path=Path(directory)
|
||||
if path.is_symlink():raise Refused()
|
||||
# Historical local snapshots are redundant; do not recursively include
|
||||
# accumulated backups. WAL/SHM are represented by the native snapshot.
|
||||
if omit_backups:dirs[:]=[name for name in dirs if name!='backups']
|
||||
for name in dirs:
|
||||
if (path/name).is_symlink():raise Refused()
|
||||
for name in files:
|
||||
relative=(path/name).relative_to(root).as_posix()
|
||||
if database and relative in {database,database+'-wal',database+'-shm',database+'-journal'}:continue
|
||||
if omit_notification and relative=='notification.txt':continue
|
||||
result[relative]=read(path/name)
|
||||
if len(result)>MAX_COUNT or sum(map(len,result.values()))>MAX_TOTAL:raise Refused()
|
||||
return result
|
||||
|
||||
|
||||
def snapshot(source,target):
|
||||
if source.is_symlink() or not source.is_file():raise Refused()
|
||||
# A read-only URI intentionally fails if the engine needs a write to its
|
||||
# production WAL/SHM. Never fall back to a raw copy or mutate the mount.
|
||||
with sqlite3.connect(source.as_uri()+'?mode=ro',uri=True,timeout=10) as origin:
|
||||
with sqlite3.connect(target) as dest:
|
||||
origin.backup(dest,pages=128,sleep=0.05)
|
||||
if dest.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused()
|
||||
return read(target)
|
||||
|
||||
|
||||
def capture(mode,root=Path('/source'),scratch=Path('/scratch')):
|
||||
values={}
|
||||
with tempfile.TemporaryDirectory(prefix='snapshot-',dir=scratch) as temporary:
|
||||
if mode=='sso':
|
||||
for name,database in [('lldap','users.db'),('authelia','db.sqlite3'),('keycape',None)]:
|
||||
source=root/name
|
||||
if source.is_symlink() or not source.is_dir():raise Refused()
|
||||
first=tree(source,database,name=='authelia',name!='keycape')
|
||||
if database:first[database]=snapshot(source/database,Path(temporary)/(name+'.sqlite'))
|
||||
second=tree(source,database,name=='authelia',name!='keycape')
|
||||
if {k:v for k,v in first.items() if k!=database}!=second:raise Refused()
|
||||
values.update({f'data/{name}/{k}':v for k,v in first.items()})
|
||||
elif mode=='mfa':
|
||||
first=tree(root/'privacyidea')
|
||||
if not {'enckey','private.pem','public.pem'}<=set(first) or first!=tree(root/'privacyidea'):raise Refused()
|
||||
values={f'data/privacyidea/{name}':value for name,value in first.items()}
|
||||
else:raise Refused()
|
||||
if len(values)>MAX_COUNT or sum(map(len,values.values()))>MAX_TOTAL:raise Refused()
|
||||
return values
|
||||
|
||||
|
||||
def emit(values,destination):
|
||||
with tarfile.open(fileobj=destination,mode='w|') as archive:
|
||||
for name,value in sorted(values.items()):
|
||||
member=tarfile.TarInfo(name);member.size=len(value);member.mode=0o600
|
||||
archive.addfile(member,io.BytesIO(value))
|
||||
|
||||
|
||||
def interrupted(*args):raise Refused()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,interrupted)
|
||||
try:
|
||||
os.umask(0o077)
|
||||
if len(sys.argv)!=2:raise Refused()
|
||||
emit(capture(sys.argv[1]),sys.stdout.buffer)
|
||||
except Exception:
|
||||
print('ERROR: private file capture refused; diagnostics suppressed.',file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Plan two exact DNS-selector exclusions; apply only an owner-admitted hash-bound plan."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
spec=importlib.util.spec_from_file_location('capture',HERE/'capture-identity.py')
|
||||
capture=importlib.util.module_from_spec(spec);spec.loader.exec_module(capture)
|
||||
SELECTOR={'matchExpressions':[{'key':'net-kingdom/capture','operator':'DoesNotExist'}]}
|
||||
|
||||
|
||||
def plan():
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise capture.Refused()
|
||||
rows=[]
|
||||
for ns in ('sso','mfa'):
|
||||
obj=capture.get(ns,'networkpolicy','allow-egress-dns');s=obj['spec'];m=obj['metadata']
|
||||
if s['podSelector'] not in ({},SELECTOR):raise capture.Refused()
|
||||
pods=json.loads(capture.command(['-n',ns,'get','pods','-o','json']))['items']
|
||||
if any('net-kingdom/capture' in p['metadata'].get('labels',{}) for p in pods):raise capture.Refused()
|
||||
# Preserve every peer, port and policy type. Existing unlabelled service
|
||||
# pods retain their exact selector match. UID/RV/spec tests guard drift.
|
||||
patch=[{'op':'test','path':'/metadata/uid','value':m['uid']},
|
||||
{'op':'test','path':'/metadata/resourceVersion','value':m['resourceVersion']},
|
||||
{'op':'test','path':'/spec','value':s},
|
||||
{'op':'replace','path':'/spec/podSelector','value':SELECTOR}]
|
||||
rows.append({'namespace':ns,'name':m['name'],'uid':m['uid'],'patch':patch})
|
||||
return {'schema':'netkingdom.identity-isolation.v1','status':'prepared_not_applied',
|
||||
'kubeconfig':capture.KUBE[2],'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'policies':rows}
|
||||
|
||||
|
||||
def apply(packet,receipt,dry_run=False):
|
||||
if packet!=plan():raise capture.Refused()
|
||||
for row in packet['policies']:
|
||||
args=['-n',row['namespace'],'patch','networkpolicy',row['name'],'--type=json','-p',json.dumps(row['patch']),'-o','json']
|
||||
if dry_run:args+=['--dry-run=server']
|
||||
receipt['attempted'].append({'namespace':row['namespace'],'name':row['name'],'uid':row['uid']})
|
||||
actual=json.loads(capture.command(args));expected=row['patch'][2]['value'].copy();expected['podSelector']=SELECTOR
|
||||
if actual['metadata']['uid']!=row['uid'] or actual['spec']!=expected:raise capture.Refused()
|
||||
receipt['verified'].append({'namespace':row['namespace'],'name':row['name'],'uid':row['uid']})
|
||||
receipt['status']='server_dry_run_passed' if dry_run else 'capture_dns_exclusions_verified'
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=['plan','dry-run','apply'])
|
||||
p.add_argument('--kubeconfig',required=True);p.add_argument('--plan');p.add_argument('--plan-sha256');p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077);capture.KUBE=['kubectl','--kubeconfig',a.kubeconfig]
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','attempted':[],'verified':[]}
|
||||
try:
|
||||
if a.action=='plan':r=plan()
|
||||
else:
|
||||
raw=Path(a.plan).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=a.plan_sha256:raise capture.Refused()
|
||||
apply(json.loads(raw),r,a.action=='dry-run')
|
||||
except Exception:r.update(status='failed',error='isolation_refused_review_partial_attempts')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Folder-scoped Storage Share ciphertext publication and verified readback.
|
||||
|
||||
Runtime gets only a dedicated read/create share token. Account administration,
|
||||
deletion and age private keys have no route in this publisher.
|
||||
"""
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HOST='https://nx4069.your-storageshare.de'
|
||||
BASE=HOST+'/public.php/webdav/'
|
||||
NAME=re.compile(r'net-kingdom-identity-\d{8}T\d{6}Z-[a-f0-9]{12}\.tar\.age\Z')
|
||||
LIMIT=512*1024*1024
|
||||
HEADROOM=2*1024**3
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):raise Refused()
|
||||
|
||||
|
||||
def opener():return urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
|
||||
|
||||
|
||||
def headers(token):
|
||||
if not isinstance(token,str) or not re.fullmatch('[A-Za-z0-9]{10,128}',token):raise Refused()
|
||||
return {'Authorization':'Basic '+base64.b64encode((token+':').encode()).decode(),
|
||||
'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'}
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest()
|
||||
|
||||
|
||||
def quota(client,token):
|
||||
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:quota-available-bytes/></d:prop></d:propfind>'
|
||||
h=headers(token);h.update(Depth='0');h['Content-Type']='application/xml'
|
||||
with client.open(urllib.request.Request(BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response:
|
||||
if response.status!=207 or response.url!=BASE:raise Refused()
|
||||
raw=response.read(1024*1024+1)
|
||||
if len(raw)>1024*1024:raise Refused()
|
||||
amount=ET.fromstring(raw).findtext('.//{DAV:}quota-available-bytes') or ''
|
||||
if not amount.isdigit():raise Refused()
|
||||
return int(amount)
|
||||
|
||||
|
||||
def etag(response,url,size):
|
||||
value=response.headers.get('ETag','')
|
||||
if (response.status!=200 or response.url!=url or response.headers.get('Content-Length')!=str(size)
|
||||
or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',value)):raise Refused()
|
||||
return value
|
||||
|
||||
|
||||
def readback(client,token,name,size,expected,target):
|
||||
valid=NAME.fullmatch(name) or (name.endswith('.receipt.json') and NAME.fullmatch(name.removesuffix('.receipt.json')) and size<=65536)
|
||||
if not valid or not 0<size<=LIMIT:raise Refused()
|
||||
url=BASE+name;h=headers(token)
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=etag(response,url,size)
|
||||
h['If-Match']=version;count=0;sha=hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
|
||||
if etag(response,url,size)!=version:raise Refused()
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
while block:=response.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>size:raise Refused()
|
||||
sha.update(block);out.write(block)
|
||||
out.flush();os.fsync(out.fileno())
|
||||
if count!=size or sha.hexdigest()!=expected:raise Refused()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
if etag(response,url,size)!=version:raise Refused()
|
||||
return version
|
||||
|
||||
|
||||
def publish(client,token,source,capture,target):
|
||||
if (not NAME.fullmatch(source.name) or source.is_symlink() or source.stat().st_mode&0o077
|
||||
or source.stat().st_uid!=os.getuid() or source.stat().st_nlink!=1 or not source.is_file()
|
||||
or source.stat().st_size>LIMIT or capture.get('status')!='encrypted_bundle_created'
|
||||
or capture.get('helper_cleanup_verified') is not True or capture.get('temporary_plaintext_cleanup_verified') is not True
|
||||
or digest(source)!=capture.get('ciphertext_sha256') or source.stat().st_size!=capture.get('ciphertext_bytes')):raise Refused()
|
||||
with source.open('rb') as stream:
|
||||
if stream.read(22)!=b'age-encryption.org/v1\n':raise Refused()
|
||||
available=quota(client,token)
|
||||
if available<source.stat().st_size+HEADROOM:raise Refused()
|
||||
url=BASE+source.name;h=headers(token)
|
||||
try:
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30):raise Refused()
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code!=404:raise Refused() from None
|
||||
h.update({'If-None-Match':'*','Content-Type':'application/octet-stream','Content-Length':str(source.stat().st_size)})
|
||||
# An unknown upload outcome is never retried under this name automatically.
|
||||
with source.open('rb') as stream:
|
||||
with client.open(urllib.request.Request(url,data=stream,method='PUT',headers=h),timeout=600) as response:
|
||||
if response.status!=201 or response.url!=url:raise Refused()
|
||||
version=readback(client,token,source.name,source.stat().st_size,capture['ciphertext_sha256'],target)
|
||||
index={'schema':'netkingdom.identity-offsite-index.v1','status':'readback_verified',
|
||||
'artifact':source.name,'ciphertext_sha256':capture['ciphertext_sha256'],
|
||||
'ciphertext_bytes':source.stat().st_size,'captured_at':capture['finished_at']}
|
||||
raw=json.dumps(index,sort_keys=True).encode();index_name=source.name+'.receipt.json';index_url=BASE+index_name
|
||||
h=headers(token);h.update({'If-None-Match':'*','Content-Type':'application/json','Content-Length':str(len(raw))})
|
||||
with client.open(urllib.request.Request(index_url,data=raw,method='PUT',headers=h),timeout=30) as response:
|
||||
if response.status!=201 or response.url!=index_url:raise Refused()
|
||||
with tempfile.TemporaryDirectory(prefix='.identity-index-',dir=target.parent) as temporary:
|
||||
readback(client,token,index_name,len(raw),hashlib.sha256(raw).hexdigest(),Path(temporary)/'index.json')
|
||||
return {'schema':'netkingdom.identity-offsite.v1','status':'readback_verified',
|
||||
'observed_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':source.name,
|
||||
'ciphertext_sha256':capture['ciphertext_sha256'],'ciphertext_bytes':source.stat().st_size,
|
||||
'fetched_path':str(target),'etag':version,'quota_available_before':available,
|
||||
'new_object_created':True,'downloaded_digest_verified':True,
|
||||
'verified_index_published':True,'index_name':index_name,
|
||||
'age_private_key_used':False,'secret_values_exported':False,'retained_objects_deleted':False,
|
||||
'native_identity_recovery_proven':False}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Separate folder-scoped read/delete executor for verified new identity backups.
|
||||
|
||||
Unknown/unindexed files are never eligible. The recovered point, newest point,
|
||||
seven daily and two weekly points remain protected. No grant or account login.
|
||||
"""
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
spec=importlib.util.spec_from_file_location('offsite',Path(__file__).with_name('identity-offsite.py'))
|
||||
offsite=importlib.util.module_from_spec(spec);spec.loader.exec_module(offsite)
|
||||
|
||||
|
||||
def listing(client,token):
|
||||
h=offsite.headers(token);h.update({'Depth':'1','Content-Type':'application/xml'})
|
||||
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getetag/></d:prop></d:propfind>'
|
||||
with client.open(urllib.request.Request(offsite.BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response:
|
||||
if response.status!=207 or response.url!=offsite.BASE:raise offsite.Refused()
|
||||
raw=response.read(4*1024*1024+1)
|
||||
if len(raw)>4*1024*1024:raise offsite.Refused()
|
||||
result={}
|
||||
for response in ET.fromstring(raw).findall('{DAV:}response'):
|
||||
href=urllib.parse.urlsplit(response.findtext('{DAV:}href') or '')
|
||||
if href.netloc and href.netloc!=urllib.parse.urlsplit(offsite.HOST).netloc:raise offsite.Refused()
|
||||
name=urllib.parse.unquote(href.path.rstrip('/').rsplit('/',1)[-1])
|
||||
for row in response.findall('{DAV:}propstat'):
|
||||
if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue
|
||||
p=row.find('{DAV:}prop')
|
||||
if p is None or p.find('{DAV:}resourcetype/{DAV:}collection') is not None:continue
|
||||
is_index=name.endswith('.receipt.json') and offsite.NAME.fullmatch(name.removesuffix('.receipt.json'))
|
||||
if not offsite.NAME.fullmatch(name) and not is_index:continue
|
||||
size=p.findtext('{DAV:}getcontentlength') or '';version=p.findtext('{DAV:}getetag') or ''
|
||||
if not size.isdigit() or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',version) or name in result:raise offsite.Refused()
|
||||
result[name]={'bytes':int(size),'etag':version}
|
||||
if len(result)>10000:raise offsite.Refused()
|
||||
return result
|
||||
|
||||
|
||||
def generations(client,token,objects):
|
||||
result=[]
|
||||
for name,meta in objects.items():
|
||||
if not offsite.NAME.fullmatch(name) or name+'.receipt.json' not in objects:continue
|
||||
index=objects[name+'.receipt.json']
|
||||
if not 0<index['bytes']<=65536:raise offsite.Refused()
|
||||
url=offsite.BASE+name+'.receipt.json';h=offsite.headers(token);h['If-Match']=index['etag']
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=30) as response:
|
||||
if offsite.etag(response,url,index['bytes'])!=index['etag']:raise offsite.Refused()
|
||||
raw=response.read(65537)
|
||||
if len(raw)!=index['bytes']:raise offsite.Refused()
|
||||
r=json.loads(raw)
|
||||
if (r.get('schema')!='netkingdom.identity-offsite-index.v1' or r.get('status')!='readback_verified'
|
||||
or r.get('artifact')!=name or r.get('ciphertext_bytes')!=meta['bytes'] or not 0<meta['bytes']<=offsite.LIMIT
|
||||
or not re.fullmatch('[a-f0-9]{64}',r.get('ciphertext_sha256',''))):raise offsite.Refused()
|
||||
stamp=datetime.strptime(name.removeprefix('net-kingdom-identity-')[:16],'%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
|
||||
captured=datetime.fromisoformat(r['captured_at'])
|
||||
if captured.tzinfo is None or abs((captured-stamp).total_seconds())>3600:raise offsite.Refused()
|
||||
if stamp>datetime.now(timezone.utc):raise offsite.Refused()
|
||||
result.append({**r,'stamp':stamp,'etag':meta['etag'],'index_etag':index['etag']})
|
||||
return sorted(result,key=lambda r:r['stamp'],reverse=True)
|
||||
|
||||
|
||||
def select(rows,protected):
|
||||
if not rows or protected not in {r['artifact'] for r in rows}:raise offsite.Refused()
|
||||
keep={protected,rows[0]['artifact']};days=set();weeks=set()
|
||||
for row in rows:
|
||||
day=row['stamp'].date();week=row['stamp'].isocalendar()[:2]
|
||||
if day not in days and len(days)<7:keep.add(row['artifact']);days.add(day)
|
||||
if week not in weeks and len(weeks)<2:keep.add(row['artifact']);weeks.add(week)
|
||||
return [r for r in rows if r['artifact'] not in keep],keep
|
||||
|
||||
|
||||
def run(client,token,accepted,receipt,execute=False,checkpoint=lambda:None):
|
||||
if (accepted.get('status')!='identity_archive_verified' or accepted.get('container_cleanup_verified') is not True
|
||||
or accepted.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(accepted.get('artifact',''))):raise offsite.Refused()
|
||||
objects=listing(client,token);rows=generations(client,token,objects)
|
||||
candidates,keep=select(rows,accepted['artifact'])
|
||||
recovered=next(r for r in rows if r['artifact']==accepted['artifact'])
|
||||
if recovered['ciphertext_sha256']!=accepted['ciphertext_sha256']:raise offsite.Refused()
|
||||
receipt.update(status='retention_preview',protected=accepted['artifact'],keep=sorted(keep),
|
||||
candidates=[r['artifact'] for r in candidates],deleted=[],unknown_objects_untouched=True)
|
||||
checkpoint()
|
||||
# Validate retained known-good and newest ciphertext before any deletion.
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-retained-check-',dir='/tmp') as temporary:
|
||||
for index,row in enumerate({r['artifact']:r for r in [recovered,rows[0]]}.values()):
|
||||
offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/str(index))
|
||||
if not execute:return
|
||||
for index,row in enumerate(candidates):
|
||||
version=offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/('expire-'+str(index)))
|
||||
for name,etag in [(row['artifact'],version),(row['artifact']+'.receipt.json',row['index_etag'])]:
|
||||
h=offsite.headers(token);h['If-Match']=etag
|
||||
receipt['deletion_in_progress']=name
|
||||
checkpoint()
|
||||
with client.open(urllib.request.Request(offsite.BASE+name,method='DELETE',headers=h),timeout=30) as response:
|
||||
if response.status!=204 or response.url!=offsite.BASE+name:raise offsite.Refused()
|
||||
receipt['deleted'].append(name)
|
||||
checkpoint()
|
||||
receipt.pop('deletion_in_progress',None)
|
||||
receipt['status']='retention_applied'
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Root-owned Railiance backup job. No operator password or age private key.
|
||||
|
||||
Publisher and retention are separate processes with separate scoped delivery.
|
||||
Scheduled execution is gated by an independently recovered archive receipt.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
capture=load('capture-identity');offsite=load('identity-offsite');retention=load('identity-retention')
|
||||
|
||||
|
||||
def write(path,value):
|
||||
fd,name=tempfile.mkstemp(prefix='.identity-receipt-',dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd,'w') as out:json.dump(value,out,indent=2);out.write('\n');out.flush();os.fsync(out.fileno())
|
||||
os.replace(name,path)
|
||||
finally:Path(name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def token(action):
|
||||
name='net-kingdom-backup-'+action
|
||||
es=capture.get('sso','externalsecret',name)
|
||||
entries=[]
|
||||
for entry in es['spec'].get('data',[]):
|
||||
item=dict(entry);ref=dict(item.get('remoteRef',{}))
|
||||
for key,default in [('conversionStrategy','Default'),('decodingStrategy','None'),('metadataPolicy','None')]:
|
||||
if ref.pop(key,default)!=default:raise offsite.Refused()
|
||||
item['remoteRef']=ref;entries.append(item)
|
||||
if (es['spec']['target']['name']!=name or entries!=[{'secretKey':'token','remoteRef':{
|
||||
'key':'workloads/net-kingdom/identity-backup/'+action,'property':'NC_WEBDAV_TOKEN'}}]
|
||||
or es['spec'].get('dataFrom') or es['spec'].get('secretStoreRef')!={'kind':'SecretStore','name':name}
|
||||
or not any(c['type']=='Ready' and c['status']=='True' for c in es['status'].get('conditions',[]))):raise offsite.Refused()
|
||||
secret=capture.get('sso','secret',name)
|
||||
if (set(secret['data'])!={'token'} or not any(o['uid']==es['metadata']['uid'] for o in secret['metadata'].get('ownerReferences',[]))):raise offsite.Refused()
|
||||
value=base64.b64decode(secret['data']['token'],validate=True).decode();offsite.headers(value)
|
||||
return value
|
||||
|
||||
|
||||
def accepted(path):
|
||||
capture.bundle.private(path)
|
||||
r=json.loads(path.read_text())
|
||||
if (r.get('schema')!='netkingdom.identity-recovery.v1' or r.get('status')!='identity_archive_verified'
|
||||
or r.get('all_manifest_hashes_verified') is not True or r.get('container_cleanup_verified') is not True
|
||||
or r.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(r.get('artifact',''))):raise offsite.Refused()
|
||||
return r
|
||||
|
||||
|
||||
def post_event(state,summary):
|
||||
# Non-secret durable reporting: replay retained notes at the next run.
|
||||
directory=state/'event-outbox';directory.mkdir(mode=0o700,exist_ok=True)
|
||||
import uuid
|
||||
path=directory/(uuid.uuid4().hex+'.json')
|
||||
write(path,{'summary':summary,'event_type':'note','author':'net-kingdom-identity-backup',
|
||||
'workplan_id':'17d315f0-829e-57c7-85cc-3a7914152804','task_id':'d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac'})
|
||||
for pending in sorted(directory.glob('*.json'))[:64]:
|
||||
request=urllib.request.Request('http://10.43.68.154:8000/progress/',data=pending.read_bytes(),
|
||||
headers={'Content-Type':'application/json'},method='POST')
|
||||
try:
|
||||
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(request,timeout=10) as response:
|
||||
if response.status!=201:break
|
||||
pending.unlink()
|
||||
except Exception:break
|
||||
|
||||
|
||||
def backup(state,decision,first=False):
|
||||
if not first:accepted(state/'accepted-recovery.json')
|
||||
if (state/'capture-in-progress.json').exists():raise offsite.Refused()
|
||||
publisher=token('publisher')
|
||||
client=offsite.opener()
|
||||
if offsite.quota(client,publisher)<offsite.HEADROOM+offsite.LIMIT:raise offsite.Refused()
|
||||
if any(shutil.disk_usage(path).free<2*offsite.LIMIT+offsite.HEADROOM for path in [state,Path('/tmp')]):raise offsite.Refused()
|
||||
p=capture.plan();stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
name='net-kingdom-identity-'+stamp+'-'+p['run_id']+'.tar.age'
|
||||
directory=state/'runs'/name;directory.mkdir(mode=0o700,parents=True)
|
||||
plan_path=directory/'plan.json';write(plan_path,p)
|
||||
import hashlib
|
||||
capture.checked_plan(plan_path,hashlib.sha256(plan_path.read_bytes()).hexdigest())
|
||||
source=directory/name;r={'started_at':datetime.now(timezone.utc).isoformat(),'status':'capture_started'}
|
||||
record=directory/'capture.json';write(record,r)
|
||||
write(state/'capture-in-progress.json',{'receipt':str(record),'artifact':name})
|
||||
try:capture.capture(p,source,r,decision,lambda:write(record,r))
|
||||
except Exception:
|
||||
if r.get('temporary_plaintext_directory') and not Path(r['temporary_plaintext_directory']).exists():r['temporary_plaintext_cleanup_verified']=True
|
||||
r['status']='failed';write(record,r);raise
|
||||
finally:
|
||||
if r.get('helper_cleanup_verified') and r.get('temporary_plaintext_cleanup_verified'):
|
||||
(state/'capture-in-progress.json').unlink()
|
||||
write(record,r)
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat();write(record,r)
|
||||
write(directory/'transfer-attempt.json',{'status':'pending','artifact':name,'capture_receipt':'capture.json',
|
||||
'ciphertext_sha256':r['ciphertext_sha256'],'ciphertext_bytes':r['ciphertext_bytes']})
|
||||
remote=offsite.publish(client,publisher,source,r,directory/'fetched.tar.age')
|
||||
write(directory/'offsite.json',remote)
|
||||
# The original private local ciphertext and safe receipts remain. Every
|
||||
# successful fetched duplicate is unnecessary after first acceptance and
|
||||
# may be removed by the owner under a separate local-cache policy.
|
||||
write(state/'last-success.json',{'finished_at':datetime.now(timezone.utc).isoformat(),
|
||||
'artifact':name,'directory':str(directory),'ciphertext_sha256':remote['ciphertext_sha256'],
|
||||
'offsite_verified':True})
|
||||
post_event(state,'Identity backup completed: encrypted full coverage captured, Storage Share upload and downloaded digest verified. Native service authentication remains separate evidence.')
|
||||
|
||||
|
||||
def expire(state,execute=False):
|
||||
if (state/'retention-blocked.json').exists():raise offsite.Refused()
|
||||
proof=accepted(state/'accepted-recovery.json')
|
||||
r={'status':'pending','started_at':datetime.now(timezone.utc).isoformat()}
|
||||
stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
|
||||
record=state/('retention-'+stamp+'.json')
|
||||
write(record,r)
|
||||
try:
|
||||
retention.run(offsite.opener(),token('retention'),proof,r,execute,lambda:write(record,r))
|
||||
if execute:r['local_generations_removed']=prune_local(state,proof)
|
||||
except Exception:
|
||||
r['status']='failed';write(record,r)
|
||||
# Stop future automatic expiration after an unknown/partial outcome.
|
||||
# Owner review must explicitly clear this marker; no blind retries.
|
||||
if execute:write(state/'retention-blocked.json',{'receipt':str(record),'requires_owner_reconciliation':True})
|
||||
raise
|
||||
write(record,r)
|
||||
|
||||
|
||||
def prune_local(state,proof):
|
||||
root=state/'runs';rows=[];known={}
|
||||
if not root.exists():return []
|
||||
capture.bundle.private(root,True)
|
||||
for directory in root.iterdir():
|
||||
if directory.is_symlink() or not directory.is_dir() or not offsite.NAME.fullmatch(directory.name):continue
|
||||
capture.bundle.private(directory,True)
|
||||
allowed={'plan.json','capture.json','transfer-attempt.json','offsite.json','fetched.tar.age',directory.name}
|
||||
if {p.name for p in directory.iterdir()}-allowed:continue
|
||||
try:
|
||||
remote=json.loads((directory/'offsite.json').read_text());local=json.loads((directory/'capture.json').read_text())
|
||||
if (remote.get('status')!='readback_verified' or remote.get('verified_index_published') is not True
|
||||
or remote.get('artifact')!=directory.name or remote.get('ciphertext_sha256')!=local.get('ciphertext_sha256')
|
||||
or local.get('helper_cleanup_verified') is not True or local.get('temporary_plaintext_cleanup_verified') is not True):continue
|
||||
for path in directory.iterdir():capture.bundle.private(path,max_bytes=offsite.LIMIT)
|
||||
if offsite.digest(directory/directory.name)!=remote['ciphertext_sha256']:continue
|
||||
stamp=datetime.strptime(directory.name.removeprefix('net-kingdom-identity-')[:16],'%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
|
||||
rows.append({'artifact':directory.name,'stamp':stamp});known[directory.name]=directory
|
||||
except Exception:continue
|
||||
rows.sort(key=lambda r:r['stamp'],reverse=True)
|
||||
if not rows:return []
|
||||
protected=proof['artifact'] if proof['artifact'] in known else rows[0]['artifact']
|
||||
candidates,_=retention.select(rows,protected)
|
||||
removed=[]
|
||||
for row in candidates:
|
||||
directory=known[row['artifact']]
|
||||
for path in directory.iterdir():path.unlink()
|
||||
directory.rmdir();removed.append(row['artifact'])
|
||||
return removed
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('action',choices=['first-capture','scheduled','retention-preview','retention','status'])
|
||||
p.add_argument('--kubeconfig',required=True);p.add_argument('--state',default='/var/lib/net-kingdom-backup')
|
||||
p.add_argument('--decision-id');a=p.parse_args();os.umask(0o077)
|
||||
state=Path(a.state).absolute()
|
||||
ready=False
|
||||
try:
|
||||
capture.bundle.require_runtime_host()
|
||||
capture.KUBE=['kubectl','--kubeconfig',a.kubeconfig]
|
||||
uid=capture.get(None,'namespace','kube-system')['metadata']['uid']
|
||||
if uid!=capture.prepare.CLUSTER_UID:raise offsite.Refused()
|
||||
state.mkdir(mode=0o700,parents=True,exist_ok=True);capture.bundle.private(state,True);ready=True
|
||||
fd=None
|
||||
try:
|
||||
if a.action!='status':
|
||||
fd=os.open(state/'.lock',os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600)
|
||||
fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||
if a.action in {'first-capture','scheduled'}:
|
||||
import uuid
|
||||
uuid.UUID(a.decision_id or '')
|
||||
backup(state,a.decision_id,a.action=='first-capture')
|
||||
elif a.action.startswith('retention'):expire(state,a.action=='retention')
|
||||
else:
|
||||
last=json.loads((state/'last-success.json').read_text())
|
||||
age=(datetime.now(timezone.utc)-datetime.fromisoformat(last['finished_at'])).total_seconds()
|
||||
if not 0<=age<=25*3600:raise offsite.Refused()
|
||||
finally:
|
||||
if fd is not None:os.close(fd)
|
||||
except Exception:
|
||||
if ready:
|
||||
try:post_event(state,'Identity backup '+a.action+' failed or refused. Private diagnostics suppressed; inspect root-owned receipts and reconcile partial state before retry.')
|
||||
except Exception:pass
|
||||
print('ERROR: identity backup operation failed; inspect private owner receipts. No secret diagnostics emitted.');return 1
|
||||
print('OK: identity backup '+a.action+' completed.');return 0
|
||||
|
||||
|
||||
def interrupted(*args):raise offsite.Refused()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render/install a source-bound node runtime; activation needs actual recovery.
|
||||
|
||||
Install does not enable timers. Owner applies the separately reviewed delivery
|
||||
and capture-isolation changes, performs first capture and independent recovery,
|
||||
then explicitly activates. No credential values enter this package.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
HERE=Path(__file__).resolve().parent
|
||||
FILES=[f'tools/bootstrap-recovery/{name}' for name in ['capture-identity.py','identity-files.py','identity-bundle.py',
|
||||
'identity-runtime.py','identity-offsite.py','identity-retention.py','identity-backup-profile.json',
|
||||
'prepare-identity-coverage.py','install-identity-backup.py']]+['sso-mfa/k8s/privacyidea/key-custody-remote.py']
|
||||
STATE='/var/lib/net-kingdom-backup'
|
||||
KUBECONFIG='/etc/rancher/k3s/k3s.yaml'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def render(decision):
|
||||
uuid.UUID(decision)
|
||||
files=[{'path':name,'sha256':hashlib.sha256((ROOT/name).read_bytes()).hexdigest()} for name in FILES]
|
||||
release=hashlib.sha256(json.dumps(files,sort_keys=True).encode()).hexdigest()
|
||||
directory='/opt/net-kingdom-backup/releases/'+release
|
||||
runtime=directory+'/tools/bootstrap-recovery/identity-runtime.py'
|
||||
common=f' --kubeconfig {KUBECONFIG} --state {STATE}'
|
||||
base=('User=root\nGroup=root\nUMask=0077\nStateDirectory=net-kingdom-backup\nStateDirectoryMode=0700\n'
|
||||
'NoNewPrivileges=true\nPrivateTmp=true\nPrivateDevices=true\nProtectSystem=strict\nProtectHome=true\n'
|
||||
f'ReadWritePaths={STATE}\nCapabilityBoundingSet=\nRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n'
|
||||
'ProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\n'
|
||||
'MemoryMax=3G\nCPUQuota=200%\nTasksMax=128\nTimeoutStartSec=45min\nTimeoutStopSec=5min\n')
|
||||
command='/usr/bin/python3 -I '+runtime
|
||||
service=('[Unit]\nDescription=Encrypted full identity backup and verified Storage Share readback\nAfter=network-online.target k3s.service\n'
|
||||
f'ConditionPathExists={STATE}/accepted-recovery.json\n[Service]\nType=oneshot\n'+base+
|
||||
f'ExecStartPre={command} retention{common}\nExecStart={command} scheduled{common} --decision-id {decision}\n'
|
||||
f'ExecStartPost={command} retention{common}\n')
|
||||
health=('[Unit]\nDescription=Identity backup age check with durable failure reporting\nAfter=network-online.target k3s.service\n'
|
||||
'[Service]\nType=oneshot\n'+base+f'ExecStart={command} status{common}\n')
|
||||
units={'net-kingdom-identity-backup.service':service,
|
||||
'net-kingdom-identity-backup.timer':'[Unit]\nDescription=Daily full identity recovery bundle\n[Timer]\nOnCalendar=*-*-* 03:45:00 UTC\nPersistent=true\nRandomizedDelaySec=5min\nUnit=net-kingdom-identity-backup.service\n[Install]\nWantedBy=timers.target\n',
|
||||
'net-kingdom-identity-backup-health.service':health,
|
||||
'net-kingdom-identity-backup-health.timer':'[Unit]\nDescription=Hourly identity backup freshness check\n[Timer]\nOnCalendar=hourly\nPersistent=true\nUnit=net-kingdom-identity-backup-health.service\n[Install]\nWantedBy=timers.target\n'}
|
||||
return {'schema':'netkingdom.identity-runtime-install.v1','status':'prepared_not_installed','decision_ref':decision,
|
||||
'release_id':release,'release_directory':directory,'runtime_files':files,'units':units,
|
||||
'timers_automatically_enabled':False,'first_capture_command':command+' first-capture'+common+' --decision-id '+decision,
|
||||
'activation_requires_independent_recovery':True,'source_contains_credentials':False}
|
||||
|
||||
|
||||
def run(args):
|
||||
r=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=120)
|
||||
if r.returncode:raise Refused()
|
||||
return r.stdout
|
||||
|
||||
|
||||
def module(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'));m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
def owner():
|
||||
module('identity-bundle').require_runtime_host()
|
||||
capture=module('capture-identity');capture.KUBE=['kubectl','--kubeconfig',KUBECONFIG]
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise Refused()
|
||||
|
||||
|
||||
def install(packet):
|
||||
owner();expected=render(packet['decision_ref'])
|
||||
if packet!=expected:raise Refused()
|
||||
destination=Path(packet['release_directory'])
|
||||
# Refuse differing prior units before changing files. Installation never
|
||||
# overwrites a running/unknown version as an implicit upgrade.
|
||||
for name,value in packet['units'].items():
|
||||
path=Path('/etc/systemd/system')/name
|
||||
if path.is_symlink() or (path.exists() and path.read_text()!=value):raise Refused()
|
||||
destination.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
for row in packet['runtime_files']:
|
||||
target=destination/row['path'];target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
if target.exists():
|
||||
if target.is_symlink() or hashlib.sha256(target.read_bytes()).hexdigest()!=row['sha256']:raise Refused()
|
||||
else:
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write((ROOT/row['path']).read_bytes())
|
||||
if hashlib.sha256(target.read_bytes()).hexdigest()!=row['sha256']:raise Refused()
|
||||
for name,value in packet['units'].items():
|
||||
path=Path('/etc/systemd/system')/name
|
||||
if not path.exists():
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o644)
|
||||
with os.fdopen(fd,'w') as out:out.write(value)
|
||||
run(['systemd-analyze','verify',*[str(Path('/etc/systemd/system')/name) for name in packet['units']]])
|
||||
run(['systemctl','daemon-reload'])
|
||||
|
||||
|
||||
def activate(proof):
|
||||
owner();runtime=module('identity-runtime');r=runtime.accepted(proof)
|
||||
state=Path(STATE);runtime.capture.bundle.private(state,True)
|
||||
last=json.loads((state/'last-success.json').read_text())
|
||||
if last.get('offsite_verified') is not True:raise Refused()
|
||||
# Restore proof names the exact protected artifact; require its local
|
||||
# off-site receipt too, not a synthetic JSON worksheet.
|
||||
path=state/'runs'/r['artifact']/'offsite.json'
|
||||
runtime.capture.bundle.private(path);transfer=json.loads(path.read_text())
|
||||
if transfer.get('status')!='readback_verified' or transfer.get('ciphertext_sha256')!=r['ciphertext_sha256']:raise Refused()
|
||||
target=state/'accepted-recovery.json'
|
||||
if target.exists():
|
||||
if target.is_symlink() or json.loads(target.read_text())!=r:raise Refused()
|
||||
else:
|
||||
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
run(['systemctl','start','net-kingdom-identity-backup-health.service'])
|
||||
run(['systemctl','enable','--now','net-kingdom-identity-backup.timer','net-kingdom-identity-backup-health.timer'])
|
||||
for unit in ['net-kingdom-identity-backup.timer','net-kingdom-identity-backup-health.timer']:
|
||||
if run(['systemctl','is-active',unit]).strip()!=b'active':raise Refused()
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=['render','install','activate'])
|
||||
p.add_argument('--decision-id');p.add_argument('--packet');p.add_argument('--packet-sha256');p.add_argument('--accepted-receipt')
|
||||
p.add_argument('--receipt',required=True);a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600);r={'status':'failed'}
|
||||
try:
|
||||
if a.action=='render':r=render(a.decision_id)
|
||||
elif a.action=='install':
|
||||
raw=Path(a.packet).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=a.packet_sha256:raise Refused()
|
||||
packet=json.loads(raw);install(packet)
|
||||
r={'status':'installed_timers_disabled','release_id':packet['release_id']}
|
||||
else:
|
||||
activate(Path(a.accepted_receipt));r={'status':'timers_active_after_independent_recovery'}
|
||||
except Exception:r={'status':'failed','error':'owner_install_or_activation_refused_private_diagnostics_suppressed'}
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only bounded S3 backup metadata through existing scoped runtime delivery.
|
||||
|
||||
No bootstrap API key, new grant, object content read, age key, provider write or
|
||||
retention action. The caller must already have admitted cluster read authority.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
BUCKET='railiance-platform-pg-backup'
|
||||
ENDPOINT='https://s3.nl-ams.scw.cloud'
|
||||
PREFIX='platform-pg/'
|
||||
CLUSTER='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def kube(args):
|
||||
p=subprocess.run(['kubectl',*args,'-o','json'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=30)
|
||||
if p.returncode:raise Refused()
|
||||
return json.loads(p.stdout)
|
||||
|
||||
|
||||
def list_prefix(client,prefix):
|
||||
if not prefix.startswith(PREFIX) or not re.fullmatch(r'[A-Za-z0-9._/-]+',prefix) or '..' in prefix.split('/'):raise Refused()
|
||||
directories=[];files=[];continuation=None
|
||||
for _ in range(20):
|
||||
args={'Bucket':BUCKET,'Prefix':prefix,'Delimiter':'/','MaxKeys':1000}
|
||||
if continuation:args['ContinuationToken']=continuation
|
||||
page=client.list_objects_v2(**args)
|
||||
for row in page.get('CommonPrefixes',[]):
|
||||
value=row['Prefix']
|
||||
if not value.startswith(prefix) or not value.endswith('/') or not re.fullmatch(r'[A-Za-z0-9._/-]+',value) or '..' in value.split('/'):raise Refused()
|
||||
directories.append(value)
|
||||
for row in page.get('Contents',[]):
|
||||
name=row['Key']
|
||||
if not name.startswith(prefix) or not re.fullmatch(r'[A-Za-z0-9._/-]+',name) or '..' in name.split('/'):raise Refused()
|
||||
if name.endswith(('.age','.json','.yaml')):
|
||||
size=row['Size']
|
||||
if type(size) is not int or size<0:raise Refused()
|
||||
files.append({'key':name,'bytes':size})
|
||||
if not page.get('IsTruncated'):break
|
||||
continuation=page.get('NextContinuationToken')
|
||||
if not continuation:raise Refused()
|
||||
else:raise Refused()
|
||||
return {'directories':sorted(set(directories)),'files':sorted(files,key=lambda x:x['key'])}
|
||||
|
||||
|
||||
def inventory(client):
|
||||
pending=[(PREFIX,0)];result={}
|
||||
while pending:
|
||||
prefix,depth=pending.pop(0)
|
||||
if prefix in result:raise Refused()
|
||||
if len(result)>=64:raise Refused()
|
||||
row=list_prefix(client,prefix);result[prefix]=row
|
||||
if depth<3:
|
||||
for child in row['directories']:
|
||||
# Native database WAL/base trees are not application/key-file
|
||||
# archives. Record their presence without enumerating every WAL.
|
||||
if any(x in child.split('/') for x in ['wals','base']):continue
|
||||
pending.append((child,depth+1))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.scaleway-backup-inventory.v1','status':'failed',
|
||||
'started_at':datetime.now(timezone.utc).isoformat(),'read_only':True,
|
||||
'provider':'Scaleway','bucket':BUCKET,'prefix':PREFIX,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'bootstrap_api_key_used':False,'age_keys_read':False,'object_contents_downloaded':False,
|
||||
'secret_values_exported':False,'scope':'Bounded prefix metadata, at most three child levels; WAL/base trees not enumerated; other buckets/prefixes unassessed'}
|
||||
try:
|
||||
if kube(['get','namespace','kube-system'])['metadata']['uid']!=CLUSTER:raise Refused()
|
||||
delivered=kube(['-n','databases','get','secret','platform-pg-backup-s3'])
|
||||
if delivered['metadata']['namespace']!='databases' or delivered['metadata']['name']!='platform-pg-backup-s3':raise Refused()
|
||||
values=delivered['data']
|
||||
# Same governed runtime delivery used by Platform's archive verifier;
|
||||
# explicit credentials avoid SDK ambient/profile/metadata discovery.
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
client=boto3.client('s3',endpoint_url=ENDPOINT,region_name='nl-ams',
|
||||
aws_access_key_id=base64.b64decode(values['ACCESS_KEY_ID'],validate=True).decode(),
|
||||
aws_secret_access_key=base64.b64decode(values['ACCESS_SECRET_KEY'],validate=True).decode(),
|
||||
config=Config(signature_version='s3v4',proxies={},connect_timeout=15,read_timeout=30,
|
||||
retries={'max_attempts':1},s3={'addressing_style':'path'}))
|
||||
r['prefixes']=inventory(client);r['status']='metadata_inventory_complete'
|
||||
r['runtime_delivery_uid']=delivered['metadata']['uid']
|
||||
except Exception:r['error']='inventory_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Silent read-only inventory in the existing Platform Backup-account procedure.
|
||||
|
||||
Runs only inside the attended platform-admin envelope. Reuses Platform's owner
|
||||
custody accessor and fixed provider/account contract; no age key is read and no
|
||||
backup is downloaded, uploaded, deleted, rotated or granted.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OWNER=ROOT.parent/'railiance-platform/scripts'
|
||||
sys.path.insert(0,str(OWNER))
|
||||
from state_hub_preflight_lane import bao, data
|
||||
from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH
|
||||
BASE='/remote.php/dav/files/Backup/railiance-backups/'
|
||||
LIMIT=4*1024*1024
|
||||
NAME=re.compile(r'[A-Za-z0-9._-]{1,160}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):raise Refused()
|
||||
|
||||
|
||||
def parse_listing(raw, relative):
|
||||
if len(raw)>LIMIT:raise Refused()
|
||||
rows=[]
|
||||
for item in ET.fromstring(raw).findall('{DAV:}response'):
|
||||
href=urllib.parse.urlsplit(item.findtext('{DAV:}href') or '')
|
||||
if href.netloc and href.netloc!=urllib.parse.urlsplit(HOST).netloc:raise Refused()
|
||||
path=urllib.parse.unquote(href.path)
|
||||
if not path.startswith(BASE+relative):raise Refused()
|
||||
name=path[len(BASE+relative):].rstrip('/')
|
||||
if not name:continue
|
||||
if not NAME.fullmatch(name) or name in {'.','..'}:raise Refused()
|
||||
for row in item.findall('{DAV:}propstat'):
|
||||
if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue
|
||||
prop=row.find('{DAV:}prop')
|
||||
if prop is None:continue
|
||||
folder=prop.find('{DAV:}resourcetype/{DAV:}collection') is not None
|
||||
size=prop.findtext('{DAV:}getcontentlength')
|
||||
if size is not None and not size.isdigit():raise Refused()
|
||||
# Export only known-safe operational names/size, not arbitrary DAV
|
||||
# properties, URLs, account metadata or raw provider responses.
|
||||
if folder or name.endswith(('.age','.json','.yaml')):
|
||||
rows.append({'name':name,'directory':folder,'bytes':int(size) if size else None})
|
||||
if len(rows)>10000 or len({r['name'] for r in rows})!=len(rows):raise Refused()
|
||||
return sorted(rows,key=lambda x:x['name'])
|
||||
|
||||
|
||||
def listing(relative,credentials):
|
||||
if relative and (not relative.endswith('/') or not NAME.fullmatch(relative[:-1])):raise Refused()
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
req=urllib.request.Request(HOST+BASE+relative,
|
||||
data=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/></d:prop></d:propfind>',
|
||||
method='PROPFIND',headers={'Authorization':'Basic '+auth,'Depth':'1','Content-Type':'application/xml'})
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
|
||||
with opener.open(req,timeout=30) as response:
|
||||
if response.status!=207 or response.url!=HOST+BASE+relative:raise Refused()
|
||||
raw=response.read(LIMIT+1)
|
||||
return parse_listing(raw,relative)
|
||||
|
||||
|
||||
def caller_credentials():
|
||||
# Lookup payload contains a token: retain only safe caller/role facts.
|
||||
identity=data(bao(['token','lookup','-format=json']))['data']
|
||||
if 'platform-admin' not in identity.get('policies',[]) or 'root' in identity.get('policies',[]):raise Refused()
|
||||
if not str(identity.get('path','')).startswith('auth/netkingdom/'):raise Refused()
|
||||
credentials=data(bao(['read','-format=json',OPERATOR_PATH]))['data']['data']
|
||||
if credentials.get('BACKUP_USERNAME')!='Backup' or not isinstance(credentials.get('BACKUP_PASSWORD'),str) or not credentials['BACKUP_PASSWORD']:raise Refused()
|
||||
return identity,credentials
|
||||
|
||||
|
||||
def inventory():
|
||||
identity,credentials=caller_credentials()
|
||||
root=listing('',credentials)
|
||||
folders={}
|
||||
for row in root:
|
||||
if row['directory'] and re.match(r'^(?:r01-|core-|identity|sso|bootstrap|net-kingdom|openbao)',row['name']):
|
||||
if len(folders)>=32:raise Refused()
|
||||
folders[row['name']]=listing(row['name']+'/',credentials)
|
||||
return {'root':root,'folders':folders,'caller_auth_path':identity['path'],
|
||||
'caller_entity_id':identity.get('entity_id'),'scope':'Backup/railiance-backups root and bounded identity/database subfolders; other accounts/folders unassessed'}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.storage-share-inventory.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),
|
||||
'read_only':True,'age_keys_read':False,'backup_contents_downloaded':False,'secret_values_exported':False,
|
||||
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'owner_sources':[{'path':str(OWNER/name),'sha256':hashlib.sha256((OWNER/name).read_bytes()).hexdigest()} for name in ['state_hub_preflight_lane.py','migrate_nextcloud_backup_account.py']]}
|
||||
try:r.update(inventory());r['status']='metadata_inventory_complete'
|
||||
except Exception:r['error']='inventory_failed_diagnostics_suppressed'
|
||||
finally:
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run T18 session assembly using the existing archive/containment pipeline.
|
||||
|
||||
Candidate only: native pinned-image tests and review are required before use.
|
||||
Shared Docker local-driver tmpfs is required because Docker cp does not support
|
||||
private tmpfs/mount paths, and the original KeyCape image is distroless.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import re
|
||||
import stat
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
adapter=load('isolated-keycape')
|
||||
local=load('isolated-lldap')
|
||||
bundle=local.bundle
|
||||
# Bind the reused preflight itself, not only this module's direct Docker calls.
|
||||
local.command=adapter.command
|
||||
_original_preflight=local.preflight
|
||||
def preflight():
|
||||
contexts=json.loads(adapter.command(['docker','context','inspect']))
|
||||
if len(contexts)!=1 or contexts[0]['Endpoints']['docker']['Host']!=adapter.ENDPOINT:raise adapter.Refused()
|
||||
result=_original_preflight();result['docker_endpoint']=adapter.ENDPOINT;return result
|
||||
local.preflight=preflight
|
||||
WORKER=local.CLIENT
|
||||
STAGING_ROOT=Path('/dev/shm')
|
||||
RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')
|
||||
VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'}
|
||||
|
||||
IMPORT=r'''
|
||||
import base64,json,os,sys
|
||||
from pathlib import Path
|
||||
os.umask(0o077)
|
||||
p=json.load(sys.stdin)
|
||||
allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/')
|
||||
for name,encoded in p.items():
|
||||
f=Path(name)
|
||||
if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError()
|
||||
f.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True))
|
||||
'''
|
||||
|
||||
# Small transport inside the already pinned helper; it has only loopback routes.
|
||||
# No token handling, auth bypass, dependency imitation, reset or notifier path.
|
||||
TLS=r'''
|
||||
import select,socket,socketserver,ssl,threading
|
||||
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version=ssl.TLSVersion.TLSv1_2
|
||||
context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem')
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
with context.wrap_socket(self.request,server_side=True) as incoming:
|
||||
with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing:
|
||||
incoming.settimeout(15);outgoing.settimeout(15)
|
||||
while True:
|
||||
ready,_,_=select.select([incoming,outgoing],[],[],15)
|
||||
if not ready:return
|
||||
for source in ready:
|
||||
data=source.recv(65536)
|
||||
if not data:return
|
||||
(outgoing if source is incoming else incoming).sendall(data)
|
||||
except Exception:pass
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address=True
|
||||
daemon_threads=True
|
||||
with Server(('127.0.0.1',443),Handler) as server:server.serve_forever()
|
||||
'''
|
||||
|
||||
|
||||
def run(argv,**kwargs):return adapter.command(argv,**kwargs)
|
||||
|
||||
|
||||
def plan_session():
|
||||
run_id=uuid.uuid4().hex
|
||||
stem='nk49-kc-'+run_id
|
||||
worker=stem+'-transport';issuer=stem+'-issuer'
|
||||
volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)}
|
||||
return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer,
|
||||
'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes,
|
||||
'public_image_probe':stem+'-image-probe','docker_endpoint':adapter.ENDPOINT}
|
||||
|
||||
|
||||
def verify_volume(name,spec,run_id):
|
||||
obj=json.loads(run(['docker','volume','inspect',name]))
|
||||
if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local'
|
||||
or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS
|
||||
or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused()
|
||||
|
||||
|
||||
def base_args(session,name,network):
|
||||
return ['docker','run','--detach','--pull=never','--name',name,'--network',network,
|
||||
'--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL',
|
||||
'--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1',
|
||||
'--pids-limit','64','--log-driver','none','--restart','no',
|
||||
'--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700']
|
||||
|
||||
|
||||
def mounts(session,readonly=False):
|
||||
args=[]
|
||||
for name,spec in session['volumes'].items():
|
||||
args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly and spec['target']!='/var/lib/keycape-policy' else '')]
|
||||
return args
|
||||
|
||||
|
||||
PROVISION_PHASES=(
|
||||
'volume_creation_and_verification','worker_launch','worker_containment',
|
||||
'private_import','issuer_launch','issuer_containment','tls_transport_launch','complete')
|
||||
|
||||
|
||||
def provision(session,payload,tls,issuer,phase=None):
|
||||
"""Inspect containment and driver options before the first private import."""
|
||||
from urllib.parse import urlsplit
|
||||
address=urlsplit(issuer)
|
||||
if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'):
|
||||
raise adapter.Refused()
|
||||
if phase is None:phase=lambda value:None
|
||||
phase('volume_creation_and_verification')
|
||||
for name,spec in session['volumes'].items():
|
||||
run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'],
|
||||
'--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name])
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
phase('worker_launch')
|
||||
args=base_args(session,session['worker'],'none')+mounts(session)
|
||||
# This pinned helper declares an image VOLUME here. Mask it explicitly so
|
||||
# Docker cannot allocate an unregistered disk-backed anonymous volume.
|
||||
args+=['--tmpfs','/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700']
|
||||
args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1',
|
||||
'--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)']
|
||||
run(args)
|
||||
# Check helper and every private filesystem before touching the ciphertext's
|
||||
# private contents. Issuer is created only after originals are present.
|
||||
phase('worker_containment')
|
||||
adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True)
|
||||
phase('private_import')
|
||||
files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}}
|
||||
run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode())
|
||||
anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip()
|
||||
phase('issuer_launch')
|
||||
args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True)
|
||||
# CLI argv carries environment names only. Original credentials stay in the
|
||||
# owner subprocess environment and Docker's private container configuration.
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'],
|
||||
'SSL_CERT_FILE':'/recovery-tls/trust.pem'}
|
||||
for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name]
|
||||
args+=[adapter.IMAGE]
|
||||
run(args,env=env)
|
||||
phase('issuer_containment')
|
||||
adapter.inspect_session(session)
|
||||
phase('tls_transport_launch')
|
||||
run(['docker','exec','--detach',session['worker'],'python3','-c',TLS])
|
||||
phase('complete')
|
||||
|
||||
|
||||
def cleanup_all(session,receipt):
|
||||
failed=False
|
||||
try:adapter.cleanup({**session,'containers':session['containers']+[session['public_image_probe']]},receipt)
|
||||
except Exception:failed=True
|
||||
rows=[]
|
||||
for name,spec in session['volumes'].items():
|
||||
row={'volume':name,'status':'unverified'};rows.append(row)
|
||||
try:
|
||||
exists=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines()
|
||||
if not exists:
|
||||
row['status']='absence_verified'
|
||||
continue
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
run(['docker','volume','rm',name])
|
||||
remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines()
|
||||
if remaining:row['status']='still_present';failed=True
|
||||
else:row['status']='absence_verified'
|
||||
except Exception:failed=True
|
||||
receipt['tmpfs_volume_cleanup_obligations']=rows
|
||||
receipt['container_cleanup_verified']=not failed
|
||||
if failed:raise adapter.Refused()
|
||||
|
||||
def register_workdir(work, run_id):
|
||||
if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+run_id+'-[a-z0-9_]{8}',work.name):raise adapter.Refused()
|
||||
if any(p.is_symlink() for p in (work,*work.parents)):raise adapter.Refused()
|
||||
info=work.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)!=0o700:raise adapter.Refused()
|
||||
return {'path':str(work),'run_id':run_id,'device':info.st_dev,'inode':info.st_ino,'owner_uid':info.st_uid}
|
||||
|
||||
|
||||
def cleanup_workdir(staging, receipt):
|
||||
"""Delete only the registered inode below actual /dev/shm, never a replacement."""
|
||||
row={'path':staging['path'],'device':staging['device'],'inode':staging['inode'],'status':'unverified'}
|
||||
receipt['plaintext_cleanup_obligation']=row
|
||||
work=Path(staging['path'])
|
||||
if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+staging['run_id']+'-[a-z0-9_]{8}',work.name):raise adapter.Refused()
|
||||
if not any(line.split()[1:3]==['/dev/shm','tmpfs'] for line in Path('/proc/mounts').read_text().splitlines()):raise adapter.Refused()
|
||||
if any(p.is_symlink() for p in work.parents):raise adapter.Refused()
|
||||
parentfd=os.open(work.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
||||
try:
|
||||
try:info=os.stat(work.name,dir_fd=parentfd,follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid!=staging['owner_uid'] or info.st_uid!=os.getuid()
|
||||
or info.st_dev!=staging['device'] or info.st_ino!=staging['inode'] or stat.S_IMODE(info.st_mode)!=0o700):
|
||||
row['status']='identity_mismatch_not_removed';raise adapter.Refused()
|
||||
def remove(parent,name,expected):
|
||||
fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent)
|
||||
try:
|
||||
current=os.fstat(fd)
|
||||
if (current.st_dev,current.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused()
|
||||
for child in os.listdir(fd):
|
||||
before=os.stat(child,dir_fd=fd,follow_symlinks=False)
|
||||
if before.st_uid!=staging['owner_uid'] or before.st_dev!=staging['device']:raise adapter.Refused()
|
||||
if stat.S_ISDIR(before.st_mode):remove(fd,child,before)
|
||||
elif stat.S_ISREG(before.st_mode) and before.st_nlink==1:os.unlink(child,dir_fd=fd)
|
||||
else:raise adapter.Refused() # symlinks/special files/hardlinks are uncertain, never followed
|
||||
finally:os.close(fd)
|
||||
after=os.stat(name,dir_fd=parent,follow_symlinks=False)
|
||||
if (after.st_dev,after.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused()
|
||||
os.rmdir(name,dir_fd=parent)
|
||||
remove(parentfd,work.name,info)
|
||||
try:os.stat(work.name,dir_fd=parentfd,follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return
|
||||
raise adapter.Refused()
|
||||
finally:os.close(parentfd)
|
||||
|
||||
|
||||
def recover_obligations(session, staging, receipt):
|
||||
failed=False
|
||||
try:cleanup_all(session,receipt)
|
||||
except Exception:failed=True
|
||||
# The plaintext obligation is attempted even when Docker is unreachable.
|
||||
try:cleanup_workdir(staging,receipt)
|
||||
except Exception:failed=True
|
||||
if failed:raise adapter.Refused()
|
||||
|
||||
|
||||
def process_facts(pid):
|
||||
raw=Path('/proc')/str(pid)/'stat'
|
||||
try:
|
||||
fields=raw.read_text().rsplit(')',1)[1].split()
|
||||
return {'pid':pid,'state':fields[0],'pgid':int(fields[2]),'sid':int(fields[3]),
|
||||
'start_ticks':int(fields[19]),'uid':raw.parent.stat().st_uid}
|
||||
except FileNotFoundError:return None
|
||||
|
||||
|
||||
def isolate_execution():
|
||||
# Called by main before reading TLS/key inputs. Never reuse an inherited
|
||||
# shell, custody-helper, user or unrelated process group.
|
||||
os.setsid()
|
||||
pid=os.getpid();facts=process_facts(pid)
|
||||
if os.getsid(0)!=pid or os.getpgrp()!=pid or facts['sid']!=pid or facts['pgid']!=pid:raise adapter.Refused()
|
||||
return {k:facts[k] for k in ('pid','pgid','sid','start_ticks','uid')}|{'deadline':time.monotonic()+1800}
|
||||
|
||||
|
||||
def verify_execution(execution):
|
||||
if execution['pid']!=execution['sid'] or execution['pid']!=execution['pgid'] or execution['uid']!=os.getuid():raise adapter.Refused()
|
||||
current=process_facts(execution['pid'])
|
||||
if current is not None and any(current[k]!=execution[k] for k in ('pid','pgid','sid','start_ticks','uid')):
|
||||
# Numeric leader PID reuse is not authority to signal a new group.
|
||||
raise adapter.Refused()
|
||||
|
||||
|
||||
def group_members(execution):
|
||||
verify_execution(execution)
|
||||
members=[]
|
||||
for path in Path('/proc').iterdir():
|
||||
if not path.name.isdecimal():continue
|
||||
facts=process_facts(int(path.name))
|
||||
if facts is None or facts['pgid']!=execution['pgid']:continue
|
||||
if facts['sid']!=execution['sid'] or facts['uid']!=execution['uid'] or facts['start_ticks']<execution['start_ticks']:raise adapter.Refused()
|
||||
if facts['state'] not in ('Z','X'):members.append(facts)
|
||||
return members
|
||||
|
||||
|
||||
def pidfd_dead(pidfd):
|
||||
poller=select.poll();poller.register(pidfd,select.POLLIN)
|
||||
return bool(poller.poll(0))
|
||||
|
||||
|
||||
def quiesce_execution(execution,parent_pidfd,receipt):
|
||||
"""Stop only this fresh execution session; cleanup needs no live creator.
|
||||
|
||||
Exact parent pidfd prevents leader reuse. Each remaining session member gets
|
||||
its own pidfd after metadata comparison; no inherited/user-group kill occurs.
|
||||
Unknown membership or uninterruptible survivors refuse cleanup altogether.
|
||||
"""
|
||||
verify_execution(execution)
|
||||
if not pidfd_dead(parent_pidfd):
|
||||
signal.pidfd_send_signal(parent_pidfd,signal.SIGTERM)
|
||||
poller=select.poll();poller.register(parent_pidfd,select.POLLIN);poller.poll(2000)
|
||||
if not pidfd_dead(parent_pidfd):signal.pidfd_send_signal(parent_pidfd,signal.SIGKILL)
|
||||
for attempt in range(50):
|
||||
members=group_members(execution)
|
||||
if pidfd_dead(parent_pidfd) and not members:
|
||||
receipt['execution_quiescence_verified']=True;return
|
||||
for original in members:
|
||||
try:fd=os.pidfd_open(original['pid'])
|
||||
except ProcessLookupError:continue
|
||||
try:
|
||||
current=process_facts(original['pid'])
|
||||
if current is None or pidfd_dead(fd):continue
|
||||
if current!=original: # state may naturally change; bind stable identity below
|
||||
if any(current[k]!=original[k] for k in ('pid','pgid','sid','start_ticks','uid')):raise adapter.Refused()
|
||||
verify_execution(execution)
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(fd)
|
||||
time.sleep(.05)
|
||||
receipt['execution_quiescence_verified']=False
|
||||
raise adapter.Refused()
|
||||
|
||||
|
||||
def daemon_uncertain(staging):
|
||||
return any(Path(staging['path']+suffix).exists() or Path(staging['path']+suffix).is_symlink() for suffix in ('-docker-inflight','-docker-uncertain'))
|
||||
|
||||
|
||||
def reaper_main(session,staging,execution,parent_pidfd):
|
||||
receipt={'status':'failed','execution_quiescence_verified':False}
|
||||
verify_execution(execution)
|
||||
if pidfd_dead(parent_pidfd):raise adapter.Refused()
|
||||
print('registered',flush=True)
|
||||
poller=select.poll();poller.register(parent_pidfd,select.POLLIN)
|
||||
remaining=max(0,int((execution['deadline']-time.monotonic())*1000))
|
||||
parent_lost=bool(poller.poll(remaining))
|
||||
receipt['cleanup_trigger']='parent_exit' if parent_lost else 'execution_deadline'
|
||||
try:
|
||||
# No deletion races a live creator: first stop/wait this exact owner
|
||||
# session and its children. A daemon request already accepted can still
|
||||
# complete after CLI loss; retain that uncertainty even after readback.
|
||||
quiesce_execution(execution,parent_pidfd,receipt)
|
||||
uncertain=daemon_uncertain(staging)
|
||||
receipt['in_flight_daemon_request_uncertain']=uncertain
|
||||
recover_obligations(session,staging,receipt)
|
||||
receipt['status']='bounded_cleanup_uncertain' if uncertain else 'bounded_cleanup_verified'
|
||||
except Exception:receipt['status']='bounded_cleanup_uncertain'
|
||||
finally:
|
||||
os.close(parent_pidfd)
|
||||
try:
|
||||
fd=os.open(staging['path']+'-reaper.json',os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(receipt,out)
|
||||
except Exception:pass
|
||||
|
||||
|
||||
def start_reaper(session, staging, execution):
|
||||
adapter.docker_environment()
|
||||
verify_execution(execution)
|
||||
if session.get('docker_endpoint')!=adapter.ENDPOINT or staging.get('run_id')!=session['run_id']:raise adapter.Refused()
|
||||
if register_workdir(Path(staging['path']),session['run_id'])!=staging:raise adapter.Refused()
|
||||
parent_pidfd=os.pidfd_open(execution['pid'])
|
||||
code="""import importlib.util,json,sys
|
||||
spec=importlib.util.spec_from_file_location('session',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
m.reaper_main(json.loads(sys.argv[2]),json.loads(sys.argv[3]),json.loads(sys.argv[4]),int(sys.argv[5]))
|
||||
"""
|
||||
try:
|
||||
child=subprocess.Popen([sys.executable,'-c',code,str(Path(__file__).resolve()),json.dumps(session),json.dumps(staging),json.dumps(execution),str(parent_pidfd)],
|
||||
stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':adapter.ENDPOINT},
|
||||
close_fds=True,pass_fds=(parent_pidfd,),start_new_session=True)
|
||||
finally:os.close(parent_pidfd)
|
||||
ready,_,_=select.select([child.stdout],[],[],10)
|
||||
if not ready or child.stdout.readline()!=b'registered\n':
|
||||
child.terminate();child.wait(timeout=5);raise adapter.Refused()
|
||||
child.stdout.close()
|
||||
return child
|
||||
|
||||
|
||||
def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
||||
"""Existing private custody/transfer/archive APIs; no second archive format."""
|
||||
verify_execution(execution)
|
||||
if execution['pid']!=os.getpid():raise adapter.Refused()
|
||||
target=local.preflight()
|
||||
for image in (adapter.IMAGE,):run(['docker','image','inspect',image])
|
||||
local.offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:raise adapter.Refused()
|
||||
path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT)
|
||||
if (path.stat().st_size!=transfer['ciphertext_bytes'] or local.offline.offsite.digest(path)!=transfer['ciphertext_sha256']
|
||||
or admission['ciphertext_sha256']!=transfer['ciphertext_sha256']):raise adapter.Refused()
|
||||
if hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest()!=admission['trust_sha256']:raise adapter.Refused()
|
||||
if hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest()!=admission['tls_cert_sha256']:raise adapter.Refused()
|
||||
# Validate exact private certificate/key pair and original issuer name before
|
||||
# opening service containers; do not generate replacement trust or keys.
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
cert=x509.load_pem_x509_certificate(tls['/recovery-tls/server.pem'])
|
||||
key=serialization.load_pem_private_key(tls['/recovery-tls/server-key.pem'],password=None)
|
||||
if cert.public_key().public_numbers()!=key.public_key().public_numbers():raise adapter.Refused()
|
||||
keyfd=local.verify.custody.private_key_fd();work=None;session=None;reaper=None;staging=None
|
||||
receipt.update(status='failed',target=target,session_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
source_files_sha256={name:hashlib.sha256((HERE/name).read_bytes()).hexdigest() for name in ('isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh','isolated-lldap.py','identity-bundle.py','verify-identity.py','retrieve-identity-offline.py','identity-offsite.py')},
|
||||
temporary_plaintext_cleanup_verified=False,container_cleanup_verified=False,
|
||||
downstream_consumer_reconnect_proven=False,human_mfa_proven=False)
|
||||
try:
|
||||
if run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()!=bundle.profile()['public_recipient'].encode():raise adapter.Refused()
|
||||
session=plan_session()
|
||||
work=Path(tempfile.mkdtemp(prefix='nk49-keycape-'+session['run_id']+'-',dir='/dev/shm'))
|
||||
staging=register_workdir(work,session['run_id'])
|
||||
reaper=start_reaper(session,staging,execution)
|
||||
adapter.EXECUTION={**execution,'staging':str(work)}
|
||||
receipt.update(execution_binding=execution,bounded_cleanup_reaper_started=True,plaintext_cleanup_obligation={**staging,'status':'registered_before_plaintext'},docker_endpoint=adapter.ENDPOINT,
|
||||
reaper_outcome_path=staging['path']+'-reaper.json',
|
||||
registered_cleanup={'containers':session['containers']+[session['public_image_probe']],'tmpfs_volumes':list(session['volumes'])})
|
||||
if checkpoint is not None:checkpoint(receipt)
|
||||
archive=work/'archive.tar'
|
||||
with archive.open('xb') as output:
|
||||
p=adapter.process(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,stderr=subprocess.DEVNULL,pass_fds=(keyfd,),timeout=180)
|
||||
if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused()
|
||||
root=work/'restored';root.mkdir(mode=0o700)
|
||||
manifest,_=bundle.restore(archive,root)
|
||||
payload=adapter.private_payload(root,manifest,admission['manifest_sha256'])
|
||||
import yaml
|
||||
cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml']))
|
||||
if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused()
|
||||
# Public executable readback uses the daemon archive API only on the
|
||||
# original image rootfs, never a tmpfs or private user-created mount.
|
||||
probe=session['public_image_probe']
|
||||
try:
|
||||
run(['docker','create','--pull=never','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE])
|
||||
binary=adapter.container_file(probe,'/keycape')
|
||||
if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused()
|
||||
finally:
|
||||
receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}]
|
||||
run(['docker','rm',probe])
|
||||
if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused()
|
||||
receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified'
|
||||
def phase(value):
|
||||
# Public enum only: no private payload, exception text or daemon logs.
|
||||
if value not in PROVISION_PHASES:raise adapter.Refused()
|
||||
receipt['provision_phase']=value
|
||||
if checkpoint is not None:checkpoint(receipt)
|
||||
provision(session,payload,tls,admission['issuer'],phase)
|
||||
receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files']))
|
||||
checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'}
|
||||
# Native checker consumes no service secret argument or new grant.
|
||||
# Retry only bounded public readiness; no account/factor attempt.
|
||||
for attempt in range(40):
|
||||
try:
|
||||
numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers()
|
||||
adapter.check_jwks(session,checked_admission,numbers);break
|
||||
except Exception:
|
||||
if attempt==39:raise
|
||||
time.sleep(.25)
|
||||
adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False)
|
||||
finally:
|
||||
os.close(keyfd)
|
||||
try:
|
||||
if session is not None and staging is not None:
|
||||
uncertain=daemon_uncertain(staging)
|
||||
receipt['in_flight_daemon_request_uncertain']=uncertain
|
||||
recover_obligations(session,staging,receipt)
|
||||
if uncertain:raise adapter.Refused()
|
||||
if reaper is not None:
|
||||
reaper.terminate();reaper.wait(timeout=10)
|
||||
except Exception:
|
||||
receipt['status']='failed'
|
||||
raise
|
||||
finally:
|
||||
adapter.EXECUTION=None
|
||||
if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True
|
||||
|
||||
|
||||
def persist_receipt(fd,receipt):
|
||||
raw=(json.dumps(receipt,indent=2)+'\n').encode()
|
||||
os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0)
|
||||
with os.fdopen(os.dup(fd),'wb') as output:
|
||||
output.write(raw);output.flush();os.fsync(output.fileno())
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True)
|
||||
args=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False}
|
||||
try:
|
||||
execution=isolate_execution()
|
||||
raw=Path(args.admission).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused()
|
||||
admission=json.loads(raw)
|
||||
tls={}
|
||||
for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)):
|
||||
source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes()
|
||||
execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,execution,checkpoint=lambda r:persist_receipt(fd,r))
|
||||
except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed')
|
||||
finally:
|
||||
receipt['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
try:persist_receipt(fd,receipt)
|
||||
finally:os.close(fd)
|
||||
print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.')
|
||||
return int(receipt['status']!='isolated_keycape_native_registration_checked')
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
#!/usr/bin/env python3
|
||||
"""T18 native adapter for an owner-admitted, already restored isolated session.
|
||||
|
||||
Called after identity-bundle.restore, never with an unvalidated directory. This
|
||||
module does not provision TLS, grant credentials, or claim downstream acceptance.
|
||||
The existing decrypt/tmpfs wrapper remains responsible for archive cleanup.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
import tarfile
|
||||
import time
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010'
|
||||
LABEL = 'net-kingdom.recovery-run'
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_bundle():
|
||||
spec = importlib.util.spec_from_file_location('identity_bundle', HERE / 'identity-bundle.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def private_payload(root, manifest, expected_manifest_sha256):
|
||||
"""Bind the complete validated archive before selecting KeyCape material.
|
||||
|
||||
Private hashes never enter the public receipt. The digest is supplied by the
|
||||
existing private archive admission, not inferred from an untrusted manifest.
|
||||
"""
|
||||
encoded = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode()
|
||||
if hashlib.sha256(encoded).hexdigest() != expected_manifest_sha256:
|
||||
raise Refused()
|
||||
entries = manifest['files']
|
||||
if len(entries) != len({e['path'] for e in entries}):
|
||||
raise Refused()
|
||||
values = {}
|
||||
bundle = load_bundle()
|
||||
for entry in entries:
|
||||
if not bundle.allowed(entry['path'], manifest['profile']):
|
||||
raise Refused()
|
||||
path = root / entry['path']
|
||||
bundle.private(path)
|
||||
value = path.read_bytes()
|
||||
if len(value) != entry['bytes'] or hashlib.sha256(value).hexdigest() != entry['sha256']:
|
||||
raise Refused()
|
||||
values[entry['path']] = value
|
||||
deployment = json.loads(values['resources/sso/deployment/keycape.json'])
|
||||
main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'keycape')
|
||||
if main.get('command') or main.get('args') or main.get('envFrom'):
|
||||
raise Refused()
|
||||
if main['image'] != IMAGE:
|
||||
# Mutable-tag/historical mismatches need a separate explicit owner admission.
|
||||
raise Refused()
|
||||
secret = json.loads(values['resources/sso/secret/keycape-config.json'])['data']
|
||||
files = {'/etc/keycape/' + name: base64.b64decode(secret[name], validate=True)
|
||||
for name in ('config.yaml', 'key.pem')}
|
||||
for path, value in values.items():
|
||||
if path.startswith('data/keycape/'):
|
||||
relative = path.removeprefix('data/keycape/')
|
||||
files['/var/lib/keycape-policy/' + relative] = value
|
||||
if '/var/lib/keycape-policy/policy.json' not in files:
|
||||
raise Refused()
|
||||
if 'resources/sso/secret/keycape-factor-read.json' in values:
|
||||
factor=json.loads(values['resources/sso/secret/keycape-factor-read.json'])['data']
|
||||
files['/etc/keycape-factor/admin-token']=base64.b64decode(factor['admin-token'],validate=True)
|
||||
# Resolve original delivered environment, including all referenced secrets.
|
||||
env = {}
|
||||
for item in main.get('env', []):
|
||||
if not re.fullmatch(r'KEYCAPE_[A-Z0-9_]+',item['name']):raise Refused()
|
||||
if item['name'] in env:
|
||||
raise Refused()
|
||||
if 'value' in item:
|
||||
env[item['name']] = item['value']
|
||||
else:
|
||||
ref = item['valueFrom']['secretKeyRef']
|
||||
source = json.loads(values['resources/sso/secret/' + ref['name'] + '.json'])['data']
|
||||
env[item['name']] = base64.b64decode(source[ref['key']], validate=True).decode()
|
||||
if env.get('KEYCAPE_CONFIG') != '/etc/keycape/config.yaml' or env.get('KEYCAPE_POLICY_PATH') != '/var/lib/keycape-policy/policy.json':
|
||||
raise Refused()
|
||||
return {'files': {p: base64.b64encode(v).decode() for p, v in files.items()}, 'env': env}
|
||||
|
||||
|
||||
ENDPOINT = 'unix:///var/run/docker.sock'
|
||||
EXECUTION = None
|
||||
|
||||
|
||||
def docker_environment(environment=None):
|
||||
# Even an admitted daemon exposed by another Unix socket is outside this
|
||||
# exact session binding. Conflicting ambient routing is refused, not ignored.
|
||||
for source in (os.environ, environment or {}):
|
||||
if source.get('DOCKER_HOST') not in (None,'',ENDPOINT):raise Refused()
|
||||
if source.get('DOCKER_CONTEXT') not in (None,'','default'):raise Refused()
|
||||
if any(source.get(k) for k in ('DOCKER_TLS_VERIFY','DOCKER_CERT_PATH')):raise Refused()
|
||||
env=dict(os.environ if environment is None else environment)
|
||||
for key in ('DOCKER_CONTEXT','DOCKER_TLS_VERIFY','DOCKER_CERT_PATH'):env.pop(key,None)
|
||||
env['DOCKER_HOST']=ENDPOINT
|
||||
return env
|
||||
|
||||
|
||||
def process(argv, **kwargs):
|
||||
marker=None
|
||||
if EXECUTION is not None and (kwargs.get('start_new_session') or kwargs.get('process_group') not in (None,-1)):raise Refused()
|
||||
if argv[0]=='docker':
|
||||
argv=['docker','--host',ENDPOINT,*argv[1:]]
|
||||
kwargs['env']=docker_environment(kwargs.get('env'))
|
||||
if EXECUTION is not None:
|
||||
if (os.getpid()!=EXECUTION['pid'] or os.getsid(0)!=EXECUTION['sid'] or os.getpgrp()!=EXECUTION['pgid']
|
||||
or time.monotonic()>=EXECUTION['deadline']):raise Refused()
|
||||
marker=Path(EXECUTION['staging']+'-docker-inflight')
|
||||
fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
os.close(fd)
|
||||
try:
|
||||
result=subprocess.run(argv,**kwargs)
|
||||
if marker is not None and result.returncode!=0:
|
||||
fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd)
|
||||
return result
|
||||
except BaseException:
|
||||
# Parent loss leaves .docker-inflight; caught timeout/transport failures
|
||||
# retain a sticky uncertainty marker even if later cleanup can run.
|
||||
if marker is not None:
|
||||
fd=os.open(Path(EXECUTION['staging']+'-docker-uncertain'),os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600);os.close(fd)
|
||||
raise
|
||||
finally:
|
||||
if marker is not None:
|
||||
try:marker.unlink()
|
||||
except FileNotFoundError:pass
|
||||
|
||||
|
||||
def command(argv, **kwargs):
|
||||
maximum = kwargs.pop('max_output', 32768)
|
||||
result = process(argv, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
timeout=kwargs.pop('timeout', 180), **kwargs)
|
||||
if result.returncode or len(result.stdout) > maximum:
|
||||
raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def inspect_session(session, worker_only=False):
|
||||
"""No credential environment is read by Docker inspect or returned to caller."""
|
||||
if not re.fullmatch(r'[a-f0-9]{32}', session['run_id']):
|
||||
raise Refused()
|
||||
names = session['containers']
|
||||
if not names or len(names) > 12 or len(set(names)) != len(names):
|
||||
raise Refused()
|
||||
for name in names:
|
||||
if not re.fullmatch(r'nk49-kc-' + session['run_id'] + r'-[a-z0-9-]{1,24}', name):
|
||||
raise Refused()
|
||||
if session['worker'] not in names or session['issuer'] not in names:
|
||||
raise Refused()
|
||||
import os
|
||||
contexts=json.loads(command(['docker','context','inspect']))
|
||||
endpoints=[contexts[0]['Endpoints']['docker']['Host'],os.environ.get('DOCKER_HOST') or ENDPOINT]
|
||||
if not all(x==ENDPOINT for x in endpoints):raise Refused()
|
||||
info = json.loads(command(['docker', 'info', '--format', '{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}']))
|
||||
if info != {'ID': '74f93783-6cef-43a8-a3c4-f62586cd3bc1', 'Name': 'bnt-lap001', 'OSType': 'linux'}:
|
||||
raise Refused()
|
||||
# The worker is the anchor; all dependencies/TLS/issuer share its network-none namespace.
|
||||
anchor = command(['docker', 'container', 'inspect', session['worker'], '--format', '{{.Id}}']).decode().strip()
|
||||
for name,spec in session.get('volumes',{}).items():
|
||||
volume=json.loads(command(['docker','volume','inspect',name]))
|
||||
expected={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'}
|
||||
if len(volume)!=1 or volume[0]['Driver']!='local' or volume[0].get('Scope')!='local' or volume[0].get('Options')!=expected or spec['options']!=expected or volume[0].get('Labels',{}).get(LABEL)!=session['run_id']:raise Refused()
|
||||
for name in names:
|
||||
obj = json.loads(command(['docker', 'container', 'inspect', name, '--format',
|
||||
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"labels":{{json .Config.Labels}},"image":{{json .Config.Image}},"user":{{json .Config.User}},"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}}}']))
|
||||
host = obj['host']
|
||||
mounts=obj['mounts']
|
||||
destinations=[m['Destination'] for m in mounts]
|
||||
if len(destinations)!=len(set(destinations)):raise Refused()
|
||||
for volume,expected_spec in session.get('volumes',{}).items():
|
||||
matches=[m for m in mounts if m['Destination']==expected_spec['target']]
|
||||
if len(matches)!=1 or matches[0]['Type']!='volume' or matches[0]['Name']!=volume or matches[0]['RW']!=(name==session['worker'] or expected_spec['target']=='/var/lib/keycape-policy'):raise Refused()
|
||||
if (obj['labels'].get(LABEL) != session['run_id'] or not obj['user'] or obj['user'].split(':')[0] in ('0', 'root')
|
||||
or not host['ReadonlyRootfs'] or host['Privileged'] or host.get('Binds') or host.get('PortBindings')
|
||||
or host.get('PidMode') or host.get('IpcMode') == 'host'
|
||||
or host.get('UTSMode') or host.get('UsernsMode') == 'host' or host.get('CgroupnsMode') == 'host'
|
||||
or host.get('Sysctls',{}) not in ({},{'net.ipv4.ip_unprivileged_port_start':'0'} if name==session['worker'] else {})
|
||||
or host.get('CapAdd') or host.get('Devices') or host.get('DeviceRequests') or host.get('VolumesFrom')
|
||||
or host.get('CapDrop') != ['ALL']
|
||||
or host.get('SecurityOpt') not in (['no-new-privileges'], ['no-new-privileges:true'])
|
||||
or any(m['Type']!='tmpfs' and not (m['Type']=='volume' and m['Name'] in session.get('volumes',{}) and m['Destination']==session['volumes'][m['Name']]['target']) for m in obj['mounts'])
|
||||
or host['NetworkMode'] != ('none' if name == session['worker'] else 'container:' + anchor)
|
||||
or obj['image'] != session['images'][name] or '@sha256:' not in obj['image']):
|
||||
raise Refused()
|
||||
if worker_only:return
|
||||
if session['images'][session['issuer']] != IMAGE:
|
||||
raise Refused()
|
||||
# Known admitted archive has no command/args override; image default /keycape
|
||||
# must be the actual issuer executable, not an alternate same-image command.
|
||||
launch=json.loads(command(['docker','container','inspect',session['issuer'],'--format',
|
||||
'{"entrypoint":{{json .Config.Entrypoint}},"cmd":{{json .Config.Cmd}},"path":{{json .Path}},"args":{{json .Args}}}']))
|
||||
if launch != {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def container_file(container, path):
|
||||
# Docker streams one tar member into memory; never materialize private files.
|
||||
raw = command(['docker', 'cp', container + ':' + path, '-'], max_output=128*1024*1024)
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode='r:') as archive:
|
||||
members = archive.getmembers()
|
||||
if len(members) != 1 or not members[0].isfile() or members[0].size > 96*1024*1024:
|
||||
raise Refused()
|
||||
return archive.extractfile(members[0]).read()
|
||||
|
||||
|
||||
JWKS_PROBE = r"""
|
||||
import base64,ipaddress,json,socket,ssl,sys,urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
p=json.load(sys.stdin);issuer=p['issuer'];origin=urlsplit(issuer)
|
||||
def require(condition):
|
||||
if not condition:raise ValueError('public_jwks_binding_refused')
|
||||
require(origin.scheme=='https')
|
||||
resolved=socket.getaddrinfo(origin.hostname,origin.port or 443,type=socket.SOCK_STREAM)
|
||||
require(bool(resolved) and all(ipaddress.ip_address(x[4][0]).is_loopback for x in resolved))
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self,*args,**kwargs):return None
|
||||
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect(),
|
||||
urllib.request.HTTPSHandler(context=ssl.create_default_context(cadata=p['trust'])))
|
||||
def get(url):
|
||||
u=urlsplit(url)
|
||||
require((u.scheme,u.hostname,u.port or 443)==(origin.scheme,origin.hostname,origin.port or 443))
|
||||
require(not u.username and not u.password and not u.fragment)
|
||||
with opener.open(url,timeout=10) as response:
|
||||
raw=response.read(1024*1024+1);require(len(raw)<=1024*1024)
|
||||
return json.loads(raw)
|
||||
d=get(issuer.rstrip('/')+'/.well-known/openid-configuration');require(d['issuer']==issuer)
|
||||
keys=get(d['jwks_uri'])['keys']
|
||||
def integer(value):return int.from_bytes(base64.urlsafe_b64decode(value+'='*(-len(value)%4)),'big')
|
||||
# The exact admitted owner release constructs one key, kid key-1. Reject every
|
||||
# extra key, including irrelevant keys: a native successful exchange can then
|
||||
# only have used the original signer through the existing verifier.
|
||||
require(isinstance(keys,list) and len(keys)==1)
|
||||
k=keys[0]
|
||||
require(k.get('kty')=='RSA' and k.get('kid')=='key-1' and k.get('use')=='sig' and k.get('alg')=='RS256')
|
||||
require(integer(k['n'])==int(p['n']) and integer(k['e'])==int(p['e']))
|
||||
print('{"original_public_signing_key_in_live_jwks":true}')
|
||||
"""
|
||||
|
||||
# A failed public readiness probe is closed data, not a failing Docker RPC.
|
||||
# Transport/CLI nonzero remains uncertain; no deliberately failing exec is
|
||||
# retried as if daemon acceptance had been established.
|
||||
JWKS_TRANSPORT = ("import contextlib,io\ntry:\n"
|
||||
" with contextlib.redirect_stdout(io.StringIO()):\n"
|
||||
" exec(" + repr(JWKS_PROBE) + ")\n"
|
||||
"except Exception:\n print('{\"original_public_signing_key_in_live_jwks\":false}')\n"
|
||||
"else:\n print('{\"original_public_signing_key_in_live_jwks\":true}')\n")
|
||||
|
||||
|
||||
def check_jwks(session, admission, public_numbers):
|
||||
trust=runtime_file(session,admission['trust_path']).decode()
|
||||
request={'issuer':admission['issuer'],'trust':trust,'n':str(public_numbers.n),'e':str(public_numbers.e)}
|
||||
result=json.loads(command(['docker','exec','-i',session['worker'],'python3','-c',JWKS_TRANSPORT],input=json.dumps(request).encode()))
|
||||
if result != {'original_public_signing_key_in_live_jwks': True}:raise Refused()
|
||||
|
||||
|
||||
RUNTIME_FILES = r"""
|
||||
import base64,json,sys
|
||||
from pathlib import Path
|
||||
p=json.load(sys.stdin)
|
||||
roots=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/')
|
||||
result={}
|
||||
paths=[]
|
||||
if p.get('tree'):
|
||||
paths=list(Path('/var/lib/keycape-policy').rglob('*'))
|
||||
else:paths=[Path(x) for x in p['paths']]
|
||||
for f in paths:
|
||||
if f.is_dir():continue
|
||||
if not str(f).startswith(roots) or not f.is_file() or any(x.is_symlink() for x in (f,*f.parents)) or f.stat().st_size>8*1024*1024:raise ValueError()
|
||||
result[str(f)]=base64.b64encode(f.read_bytes()).decode()
|
||||
if len(result)>4096:raise ValueError()
|
||||
print(json.dumps(result))
|
||||
"""
|
||||
|
||||
|
||||
def runtime_files(session, paths=(), tree=False):
|
||||
raw=command(['docker','exec','-i',session['worker'],'python3','-c',RUNTIME_FILES],
|
||||
input=json.dumps({'paths':list(paths),'tree':tree}).encode(),max_output=48*1024*1024)
|
||||
return {k:base64.b64decode(v,validate=True) for k,v in json.loads(raw).items()}
|
||||
|
||||
|
||||
def runtime_file(session,path):
|
||||
return runtime_files(session,[path])[path]
|
||||
|
||||
|
||||
def run_verifier(session, admission, payload):
|
||||
issuer = session['issuer']
|
||||
if {v['target'] for v in session.get('volumes',{}).values()}!={'/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls'}:raise Refused()
|
||||
from urllib.parse import urlsplit
|
||||
import ipaddress
|
||||
address = urlsplit(admission['issuer'])
|
||||
if (address.scheme != 'https' or not address.hostname or address.username or address.password
|
||||
or address.query or address.fragment):
|
||||
raise Refused()
|
||||
if hashlib.sha256(container_file(issuer, '/keycape')).hexdigest() != admission['verifier_sha256']:
|
||||
raise Refused()
|
||||
if hashlib.sha256(runtime_file(session, admission['trust_path'])).hexdigest() != admission['trust_sha256']:
|
||||
raise Refused()
|
||||
# Capture private environment only in owner process memory, never receipt.
|
||||
environment = json.loads(command(['docker','container','inspect',issuer,'--format','{{json .Config.Env}}']))
|
||||
env = dict(item.split('=',1) for item in environment)
|
||||
if len(env)!=len(environment):raise Refused()
|
||||
if set(env)-set(payload['env'])-{'PATH','HOME','HOSTNAME','SSL_CERT_FILE'}:raise Refused()
|
||||
if env.get('SSL_CERT_FILE') != admission['trust_path'] or any(k.lower().endswith('_proxy') and v for k,v in env.items()):
|
||||
raise Refused()
|
||||
if {k:v for k,v in env.items() if k.startswith('KEYCAPE_')}!={k:v for k,v in payload['env'].items() if k.startswith('KEYCAPE_')}:raise Refused()
|
||||
for name,value in payload['env'].items():
|
||||
if env.get(name) != value:
|
||||
raise Refused()
|
||||
files = {k:base64.b64decode(v,validate=True) for k,v in payload['files'].items()}
|
||||
def equal():
|
||||
if runtime_files(session,files)!=files:raise Refused()
|
||||
durable=runtime_files(session,tree=True)
|
||||
if durable!={k:v for k,v in files.items() if k.startswith('/var/lib/keycape-policy/')}:
|
||||
raise Refused()
|
||||
equal()
|
||||
# Reading YAML is data parsing only; no archived executable is evaluated.
|
||||
import yaml
|
||||
cfg = yaml.safe_load(files['/etc/keycape/config.yaml'])
|
||||
if cfg['issuer'] != admission['issuer']:
|
||||
raise Refused()
|
||||
consumer = admission['consumer']
|
||||
client = next(x for x in cfg['clients'] if x['clientId']==consumer['client_id'])
|
||||
if (client['secretRef']!='env:'+consumer['secret_env'] or client['clientType']!='confidential'
|
||||
or 'client_credentials' not in client['grantTypes'] or not env.get(consumer['secret_env'])):
|
||||
raise Refused()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None)
|
||||
check_jwks(session,admission,key.public_key().public_numbers())
|
||||
wrong = secrets.token_urlsafe(48)
|
||||
if wrong == env[consumer['secret_env']]:raise Refused()
|
||||
args = ['docker','exec','--env','NK49_UNRELATED_NEGATIVE_SECRET='+wrong,issuer,'/keycape','verify-client',
|
||||
'-issuer',admission['issuer'],'-previous-secret-env','NK49_UNRELATED_NEGATIVE_SECRET']
|
||||
flags = {'client_id':'client-id','secret_env':'secret-env','audience':'audience','scope':'scope',
|
||||
'subject':'expect-subject','tenant':'expect-tenant','roles':'expect-roles','deny_scope':'deny-scope'}
|
||||
for name,flag in flags.items():args+=['-'+flag,consumer[name]]
|
||||
# Native command reports on stderr. Do not return or print its raw response.
|
||||
result = process(args,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=150)
|
||||
raw = (result.stdout+result.stderr).decode()
|
||||
required = ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings',
|
||||
'principal_type is service','sub matches the registration','tenant matches the registration','roles present',
|
||||
'granted scope carries no excess grant','predecessor secret refused']
|
||||
required += ['excess scope refused: '+scope for scope in consumer['deny_scope'].split()]
|
||||
if result.returncode or any('PASS '+name+'\n' not in raw for name in required) or 'FAIL ' in raw:
|
||||
raise Refused()
|
||||
equal()
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
key=serialization.load_pem_private_key(files['/etc/keycape/key.pem'],password=None)
|
||||
check_jwks(session,admission,key.public_key().public_numbers())
|
||||
public=key.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return {'native_service_registration_verified':True,'wrong_unrelated_secret_explicitly_refused':True,
|
||||
'denied_scopes_refused':True,'original_config_registry_policy_receipts_equal':True,
|
||||
'public_signing_spki_sha256':hashlib.sha256(public).hexdigest(),
|
||||
'original_https_issuer_and_trust_preserved':True,'original_public_signing_key_in_live_jwks':True}
|
||||
|
||||
|
||||
def cleanup(session, receipt=None):
|
||||
"""Attempt every owned cleanup even when one obligation fails.
|
||||
|
||||
Preserve exact safe container names and outcomes so the owner can recover an
|
||||
uncertain/partial cleanup. An inspect/daemon failure never means absence.
|
||||
"""
|
||||
obligations = []
|
||||
failed = False
|
||||
for name in reversed(session['containers']):
|
||||
row = {'container': name, 'status': 'unverified'}
|
||||
obligations.append(row)
|
||||
try:
|
||||
present=command(['docker','container','ls','--all','--filter','name=^/'+name+'$','--format','{{.Names}}']).decode().splitlines()
|
||||
if not present:
|
||||
row['status']='absence_verified'
|
||||
continue
|
||||
label = command(['docker', 'container', 'inspect', name, '--format',
|
||||
'{{index .Config.Labels "' + LABEL + '"}}']).decode().strip()
|
||||
if label != session['run_id']:
|
||||
row['status'] = 'ownership_mismatch_not_removed'
|
||||
failed = True
|
||||
continue
|
||||
# A failed remove is followed by a successful explicit inventory;
|
||||
# interrupted remove may actually have completed.
|
||||
try:
|
||||
command(['docker', 'rm', '--force', '--volumes', name])
|
||||
except Exception:
|
||||
pass
|
||||
remaining = command(['docker','container','ls','--all','--filter',
|
||||
'name=^/' + name + '$','--format','{{.Names}}']).decode().splitlines()
|
||||
if remaining:
|
||||
row['status'] = 'still_present'
|
||||
failed = True
|
||||
else:
|
||||
row['status'] = 'absence_verified'
|
||||
except Exception:
|
||||
failed = True
|
||||
if receipt is not None:
|
||||
receipt['container_cleanup_obligations'] = obligations
|
||||
if failed:
|
||||
raise Refused()
|
||||
|
||||
|
||||
|
||||
def native_checks(root, manifest, admission, receipt, cleanup_session=True):
|
||||
"""Owner wrapper provides a reviewed session after existing restore pipeline.
|
||||
|
||||
Admission must bind ciphertext, canonical private manifest digest, executable
|
||||
and trust bytes, exact image/target list, original issuer and selected consumer.
|
||||
None of these admission fields grant authority or carry a client secret.
|
||||
"""
|
||||
session = admission['session']
|
||||
# Validate ownership/containment before permitting either exec or cleanup.
|
||||
inspect_session(session)
|
||||
receipt.update(status='failed', source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
ciphertext_sha256=admission['ciphertext_sha256'], production_contacted=False,
|
||||
human_mfa_proven=False, downstream_consumer_reconnect_proven=False,
|
||||
container_cleanup_verified=False)
|
||||
try:
|
||||
for field in ('ciphertext_sha256', 'manifest_sha256', 'verifier_sha256', 'trust_sha256'):
|
||||
if not re.fullmatch('[a-f0-9]{64}', admission[field]):
|
||||
raise Refused()
|
||||
for field in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope'):
|
||||
if not isinstance(admission['consumer'].get(field), str) or not admission['consumer'][field].strip():
|
||||
raise Refused()
|
||||
payload = private_payload(root, manifest, admission['manifest_sha256'])
|
||||
result = run_verifier(session, admission, payload)
|
||||
required = {'native_service_registration_verified','wrong_unrelated_secret_explicitly_refused',
|
||||
'denied_scopes_refused','original_config_registry_policy_receipts_equal',
|
||||
'original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks'}
|
||||
if any(result.get(k) is not True for k in required) or not re.fullmatch('[a-f0-9]{64}', result.get('public_signing_spki_sha256','')):
|
||||
raise Refused()
|
||||
# Closed receipt allowlist: never copy arbitrary worker output.
|
||||
receipt.update({k: True for k in required})
|
||||
receipt['public_signing_spki_sha256'] = result['public_signing_spki_sha256']
|
||||
receipt['status'] = 'isolated_keycape_native_registration_checked'
|
||||
finally:
|
||||
try:
|
||||
if cleanup_session:
|
||||
cleanup(session, receipt)
|
||||
receipt['container_cleanup_verified'] = True
|
||||
except Exception:
|
||||
receipt['status'] = 'failed'
|
||||
raise
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run native LLDAP restore from the verified identity bundle.
|
||||
|
||||
Private inputs remain in the owner process/pipe. Decrypted staging uses Linux
|
||||
tmpfs. Containers have no network, ports, host mounts or production API access.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import signal
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
import uuid
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, HERE / (name + '.py'))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
verify = load('verify-identity')
|
||||
offline = load('retrieve-identity-offline')
|
||||
bundle = verify.bundle
|
||||
IMAGE = 'docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d'
|
||||
CLIENT = 'ghcr.io/gpappsoft/privacyidea-docker@sha256:af7841adad262f129e0c1d4f553af13f21cb2f4dc713533f316cfe43ed0b4473'
|
||||
ENGINE_ID = '74f93783-6cef-43a8-a3c4-f62586cd3bc1'
|
||||
ENGINE_NAME = 'bnt-lap001'
|
||||
|
||||
|
||||
def command(args, **kwargs):
|
||||
result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
timeout=kwargs.pop('timeout', 90), **kwargs)
|
||||
if result.returncode:
|
||||
raise bundle.Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def preflight():
|
||||
contexts = json.loads(command(['docker', 'context', 'inspect']))
|
||||
endpoints = [contexts[0]['Endpoints']['docker']['Host']]
|
||||
if os.environ.get('DOCKER_HOST'):
|
||||
endpoints.append(os.environ['DOCKER_HOST'])
|
||||
if not all(url.startswith('unix://') for url in endpoints):
|
||||
raise bundle.Refused()
|
||||
facts = json.loads(command(['docker', 'info', '--format',
|
||||
'{"ID":{{json .ID}},"Name":{{json .Name}},"OSType":{{json .OSType}}}']))
|
||||
if facts['ID'] != ENGINE_ID or facts['Name'] != ENGINE_NAME or facts['OSType'] != 'linux':
|
||||
raise bundle.Refused()
|
||||
# /dev/shm must actually be tmpfs, not a similarly named disk directory.
|
||||
mounts = Path('/proc/mounts').read_text().splitlines()
|
||||
if not any(line.split()[1:3] == ['/dev/shm', 'tmpfs'] for line in mounts):
|
||||
raise bundle.Refused()
|
||||
images = []
|
||||
for image in (IMAGE, CLIENT):
|
||||
image_id = command(['docker', 'image', 'inspect', '--format', '{{.Id}}', image]).decode().strip()
|
||||
images.append({'reference': image, 'image_id': image_id})
|
||||
return {'engine_id': facts['ID'], 'engine_name': facts['Name'], 'images': images,
|
||||
'production_contacted': False, 'network_mode': 'none', 'host_ports_published': False}
|
||||
|
||||
|
||||
def snapshot(path):
|
||||
# Include all fields of durable identity tables, including stored password
|
||||
# hashes. Only counts and the equality boolean leave this process.
|
||||
result = {}
|
||||
with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as db:
|
||||
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise bundle.Refused()
|
||||
tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
for name in ('users', 'groups', 'memberships', 'user_attributes', 'group_attributes',
|
||||
'user_attribute_values', 'group_attribute_values',
|
||||
'user_attribute_schema', 'group_attribute_schema'):
|
||||
if name in tables:
|
||||
rows = db.execute('SELECT * FROM "' + name + '"').fetchall()
|
||||
encoded = sorted(repr(row) for row in rows)
|
||||
result[name] = {'count': len(rows), 'digest': hashlib.sha256('\n'.join(encoded).encode()).digest()}
|
||||
if not {'users', 'groups', 'memberships'} <= result.keys():
|
||||
raise bundle.Refused()
|
||||
return result
|
||||
|
||||
|
||||
def configuration(root):
|
||||
data = root / 'data/lldap'
|
||||
existing = data / 'lldap_config.toml'
|
||||
config = tomllib.loads(existing.read_text()) if existing.is_file() else {}
|
||||
deployment = bundle.resource(root, 'sso', 'deployment', 'lldap')
|
||||
main = next(c for c in deployment['spec']['template']['spec']['containers'] if c['name'] == 'lldap')
|
||||
env = {e['name']: e['value'] for e in main.get('env', []) if 'value' in e}
|
||||
secret = bundle.resource(root, 'sso', 'secret', 'lldap-secrets')['data']
|
||||
jwt = base64.b64decode(secret['LLDAP_JWT_SECRET'], validate=True)
|
||||
password = base64.b64decode(secret['LLDAP_LDAP_USER_PASS'], validate=True)
|
||||
if not jwt or not password or b'\n' in jwt or b'\n' in password:
|
||||
raise bundle.Refused()
|
||||
base = env.get('LLDAP_LDAP_BASE_DN', config.get('ldap_base_dn'))
|
||||
user = env.get('LLDAP_LDAP_USER_DN', config.get('ldap_user_dn', 'admin'))
|
||||
if not isinstance(base, str) or not base or not isinstance(user, str):
|
||||
raise bundle.Refused()
|
||||
chosen = {'database_url': 'sqlite:///data/users.db?mode=rwc',
|
||||
'ldap_base_dn': base, 'ldap_user_dn': user,
|
||||
'ldap_host': '127.0.0.1', 'http_host': '127.0.0.1',
|
||||
'ldap_port': 3890, 'http_port': 17170,
|
||||
'force_ldap_user_pass_reset': False, 'force_update_private_key': False}
|
||||
seed = env.get('LLDAP_KEY_SEED', config.get('key_seed'))
|
||||
if seed:
|
||||
if not isinstance(seed, str):
|
||||
raise bundle.Refused()
|
||||
chosen['key_seed'] = seed
|
||||
else:
|
||||
value = env.get('LLDAP_KEY_FILE', config.get('key_file', '/data/private_key'))
|
||||
key = PurePosixPath(value)
|
||||
if not key.is_absolute():
|
||||
key = PurePosixPath('/data') / key
|
||||
if key.parent != PurePosixPath('/data') or not (data / key.name).is_file():
|
||||
raise bundle.Refused()
|
||||
chosen['key_file'] = str(key)
|
||||
# JSON strings are valid TOML basic strings for these service inputs.
|
||||
raw = '\n'.join(k + ' = ' + json.dumps(v) for k, v in chosen.items())
|
||||
raw += '\n[smtp_options]\nenable_password_reset = false\n[ldaps_options]\nenabled = false\n'
|
||||
return raw.encode(), jwt, password, base
|
||||
|
||||
|
||||
def archive_files(files):
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=out, mode='w') as archive:
|
||||
for name, value in files.items():
|
||||
if PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts:
|
||||
raise bundle.Refused()
|
||||
entry = tarfile.TarInfo(name)
|
||||
entry.size = len(value)
|
||||
entry.mode = 0o600
|
||||
entry.uid = entry.gid = 1000
|
||||
archive.addfile(entry, io.BytesIO(value))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import contextlib,io,json,logging,socket,sys,time
|
||||
logging.disable(logging.CRITICAL)
|
||||
result={'status':'failed'}
|
||||
stage='network_isolation'
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
from pathlib import Path
|
||||
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
|
||||
assert len(Path('/proc/net/route').read_text().splitlines())==1
|
||||
stage='native_client_import'
|
||||
from ldap3 import Server,Connection,NONE
|
||||
from ldap3.utils.dn import escape_rdn
|
||||
values=json.load(sys.stdin)
|
||||
stage='directory_startup'
|
||||
for _ in range(60):
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1',3890),timeout=1):pass
|
||||
break
|
||||
except OSError:time.sleep(.25)
|
||||
else:raise RuntimeError()
|
||||
dn='uid='+escape_rdn(values['user'])+',ou=people,'+values['base']
|
||||
server=Server('127.0.0.1',port=3890,get_info=NONE,connect_timeout=3)
|
||||
stage='existing_user_bind'
|
||||
connection=Connection(server,user=dn,password=values['password'],auto_bind=True,raise_exceptions=True)
|
||||
assert connection.bound
|
||||
connection.unbind()
|
||||
stage='wrong_password_bind'
|
||||
wrong=Connection(server,user=dn,password=values['wrong_password'],auto_bind=False,raise_exceptions=False)
|
||||
refused=not wrong.bind() and wrong.result.get('result')==49
|
||||
wrong.unbind()
|
||||
assert refused
|
||||
result={'status':'verified','existing_user_native_ldap_bind':True,'wrong_password_refused':True,
|
||||
'only_loopback_interface_verified':True,'external_routes_absent':True}
|
||||
except Exception as error:result={'status':'failed','failed_stage':stage,'failure_type':type(error).__name__}
|
||||
print(json.dumps(result))
|
||||
'''
|
||||
|
||||
|
||||
def restore_lldap(root, user, password, receipt):
|
||||
before = snapshot(root / 'data/lldap/users.db')
|
||||
config, jwt, admin_password, base = configuration(root)
|
||||
name = 'nk49-lldap-' + uuid.uuid4().hex
|
||||
client_name = name + '-client'
|
||||
try:
|
||||
command(['docker', 'run', '--detach', '--pull=never', '--name', name,
|
||||
'--network', 'none', '--read-only', '--cap-drop', 'ALL',
|
||||
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
|
||||
'--memory', '256m', '--cpus', '1', '--pids-limit', '128',
|
||||
'--tmpfs', '/data:rw,nosuid,nodev,noexec,size=268435456,uid=1000,gid=1000,mode=0700',
|
||||
'--tmpfs', '/run/recovery:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
|
||||
'--env', 'LLDAP_JWT_SECRET_FILE=/run/recovery/jwt',
|
||||
'--env', 'LLDAP_LDAP_USER_PASS_FILE=/run/recovery/admin-password',
|
||||
'--entrypoint', '/bin/sh', IMAGE, '-c', 'sleep 1800'])
|
||||
guard = json.loads(command(['docker', 'container', 'inspect', name, '--format',
|
||||
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}']))
|
||||
host = guard['host']
|
||||
if (host['NetworkMode'] != 'none' or not host['ReadonlyRootfs'] or host['Privileged']
|
||||
or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', [])
|
||||
or 'no-new-privileges' not in host.get('SecurityOpt', [])
|
||||
or guard['user'] != '1000:1000'
|
||||
or any(mount['Type'] != 'tmpfs' for mount in guard['mounts'])):
|
||||
raise bundle.Refused()
|
||||
receipt['container_isolation_verified_before_private_import'] = True
|
||||
payload = {p.relative_to(root / 'data/lldap').as_posix(): p.read_bytes()
|
||||
for p in (root / 'data/lldap').rglob('*') if p.is_file()}
|
||||
command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/data'], input=archive_files(payload))
|
||||
command(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/recovery'],
|
||||
input=archive_files({'lldap.toml': config, 'jwt': jwt, 'admin-password': admin_password}))
|
||||
# No credential values in Docker configuration, environment or argv.
|
||||
command(['docker', 'exec', '--detach', name, '/bin/sh', '-c',
|
||||
'echo $$ > /run/recovery/server.pid; exec /app/lldap run --config-file /run/recovery/lldap.toml > /dev/null 2>&1'])
|
||||
raw = command(['docker', 'run', '--interactive', '--rm', '--name', client_name, '--pull=never', '--network', 'container:' + name,
|
||||
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--memory', '256m', '--cpus', '1', '--pids-limit', '64',
|
||||
'--entrypoint', 'python3', CLIENT, '-c', PROBE],
|
||||
input=json.dumps({'user': user, 'password': password, 'base': base,
|
||||
'wrong_password': 'nk49-wrong-' + uuid.uuid4().hex}).encode())
|
||||
result = json.loads(raw)
|
||||
receipt.update(result)
|
||||
if result.get('status') != 'verified':
|
||||
raise bundle.Refused()
|
||||
command(['docker', 'exec', name, '/bin/sh', '-c', 'kill -TERM "$(cat /run/recovery/server.pid)"'])
|
||||
for _ in range(60):
|
||||
running = subprocess.run(['docker', 'exec', name, '/bin/sh', '-c',
|
||||
'kill -0 "$(cat /run/recovery/server.pid)"'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if running.returncode:
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise bundle.Refused()
|
||||
raw = command(['docker', 'exec', name, '/bin/sh', '-c',
|
||||
'test ! -s /data/users.db-wal && cat /data/users.db'])
|
||||
after_path = root / 'post-restore-users.db'
|
||||
after_path.write_bytes(raw)
|
||||
if snapshot(after_path) != before:
|
||||
raise bundle.Refused()
|
||||
receipt.update(directory_identity_rows_preserved=True,
|
||||
directory_comparison_scope=sorted(before),
|
||||
directory_table_counts={k: v['count'] for k, v in before.items()})
|
||||
finally:
|
||||
# A failed create can still have created a container (e.g. lost reply).
|
||||
for owned in (client_name, name):
|
||||
found = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=15)
|
||||
if found.returncode == 0:
|
||||
command(['docker', 'rm', '--force', '--volumes', owned])
|
||||
absent = subprocess.run(['docker', 'container', 'inspect', owned], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=15)
|
||||
command(['docker', 'info', '--format', '{{.ID}}'])
|
||||
if absent.returncode == 0:
|
||||
raise bundle.Refused()
|
||||
receipt['container_cleanup_verified'] = True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--transfer-receipt', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
parser.add_argument('--user', required=True)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
result = {'schema': 'netkingdom.isolated-lldap-recovery.v1', 'status': 'failed',
|
||||
'production_contacted': False, 'secret_values_exported': False,
|
||||
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
|
||||
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd = None
|
||||
work = None
|
||||
stage = 'target_preflight'
|
||||
try:
|
||||
result['target'] = preflight()
|
||||
stage = 'input_validation'
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,128}', args.user):
|
||||
raise bundle.Refused()
|
||||
transfer = json.loads(Path(args.transfer_receipt).read_text())
|
||||
offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:
|
||||
raise bundle.Refused()
|
||||
path = Path(transfer['fetched_path'])
|
||||
bundle.private(path, max_bytes=bundle.LIMIT)
|
||||
if path.stat().st_size != transfer['ciphertext_bytes'] or offline.offsite.digest(path) != transfer['ciphertext_sha256']:
|
||||
raise bundle.Refused()
|
||||
keyfd = verify.custody.private_key_fd()
|
||||
derived = command(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip()
|
||||
if derived != bundle.profile()['public_recipient'].encode():
|
||||
raise bundle.Refused()
|
||||
stage = 'private_existing_password_prompt'
|
||||
password = offline.private_prompt('Existing account password for the isolated LLDAP check: ')
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-service-recovery-', dir='/dev/shm') as temporary:
|
||||
work = Path(temporary)
|
||||
archive = work / 'archive.tar'
|
||||
stage = 'archive_decryption'
|
||||
with archive.open('xb') as output:
|
||||
proc = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)],
|
||||
stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180)
|
||||
if proc.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024:
|
||||
raise bundle.Refused()
|
||||
restored = work / 'restored'
|
||||
restored.mkdir(mode=0o700)
|
||||
stage = 'archive_validation'
|
||||
manifest, _ = bundle.restore(archive, restored)
|
||||
result.update(ciphertext_sha256=transfer['ciphertext_sha256'],
|
||||
manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True)
|
||||
stage = 'native_directory_check'
|
||||
restore_lldap(restored, args.user, password, result)
|
||||
password = None
|
||||
if work.exists():
|
||||
raise bundle.Refused()
|
||||
result.update(status='isolated_lldap_verified', temporary_plaintext_cleanup_verified=True)
|
||||
except (Exception, KeyboardInterrupt):
|
||||
result.update(status='failed', error='isolated_lldap_check_failed_private_diagnostics_suppressed')
|
||||
result.setdefault('failed_stage', stage)
|
||||
finally:
|
||||
if keyfd is not None:
|
||||
os.close(keyfd)
|
||||
if work is not None and not work.exists():
|
||||
result['temporary_plaintext_cleanup_verified'] = True
|
||||
result['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(result, output, indent=2)
|
||||
output.write('\n')
|
||||
success = result['status'] == 'isolated_lldap_verified'
|
||||
print('OK: restored LLDAP existing-user bind, wrong-password refusal, directory preservation and cleanup passed.'
|
||||
if success else 'ERROR: isolated LLDAP recovery refused; review the safe receipt. No production change.')
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
signal.signal(sig, offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private native privacyIDEA token-material and audit recovery checks.
|
||||
|
||||
This does not authenticate a user's MFA code. It restores only a disposable
|
||||
database, decrypts token material internally and verifies stored audit rows.
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# Import the already reviewed local target/transport boundary without changing
|
||||
# the accepted LLDAP executor or its source digest.
|
||||
import importlib.util
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('isolated_lldap', HERE / 'isolated-lldap.py')
|
||||
local = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(local)
|
||||
bundle = local.bundle
|
||||
IMAGE = local.CLIENT
|
||||
POSTGRES = local.verify.pg.IMAGE
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return local.command(args, **kwargs)
|
||||
|
||||
|
||||
def preflight():
|
||||
target = local.preflight()
|
||||
target['images'].append({'reference': POSTGRES,
|
||||
'image_id': run(['docker', 'image', 'inspect', '--format', '{{.Id}}', POSTGRES]).decode().strip()})
|
||||
return target
|
||||
|
||||
|
||||
def isolation(name, expected_network, user):
|
||||
info = json.loads(run(['docker', 'container', 'inspect', name, '--format',
|
||||
'{"host":{{json .HostConfig}},"mounts":{{json .Mounts}},"user":{{json .Config.User}}}']))
|
||||
host = info['host']
|
||||
if (host['NetworkMode'] != expected_network or not host['ReadonlyRootfs'] or host['Privileged']
|
||||
or host.get('Binds') or host.get('PortBindings') or 'ALL' not in host.get('CapDrop', [])
|
||||
or 'no-new-privileges' not in host.get('SecurityOpt', []) or info['user'] != user
|
||||
or any(m['Type'] != 'tmpfs' for m in info['mounts'])):
|
||||
raise bundle.Refused()
|
||||
|
||||
|
||||
def cleanup(names):
|
||||
for name in names:
|
||||
present = subprocess.run(['docker', 'container', 'inspect', name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
if present.returncode == 0:
|
||||
run(['docker', 'rm', '--force', '--volumes', name])
|
||||
absent = subprocess.run(['docker', 'container', 'inspect', name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
|
||||
run(['docker', 'info', '--format', '{{.ID}}'])
|
||||
if absent.returncode == 0:
|
||||
raise bundle.Refused()
|
||||
|
||||
|
||||
def start_database(name):
|
||||
run(['docker', 'run', '--detach', '--pull=never', '--name', name, '--network', 'none',
|
||||
'--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
||||
'--user', '999:999', '--memory', '2g', '--cpus', '2', '--pids-limit', '128',
|
||||
'--tmpfs', '/var/lib/postgresql/data:rw,nosuid,nodev,noexec,size=1073741824,uid=999,gid=999,mode=0700',
|
||||
'--tmpfs', '/var/run/postgresql:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=999,gid=999',
|
||||
'--env', 'POSTGRES_HOST_AUTH_METHOD=trust', '--env', 'POSTGRES_DB=recovery',
|
||||
'--entrypoint', '/usr/bin/timeout', POSTGRES, '--kill-after=15s', '1800s',
|
||||
'/usr/local/bin/docker-entrypoint.sh', 'postgres', '-c', 'listen_addresses=127.0.0.1'])
|
||||
isolation(name, 'none', '999:999')
|
||||
for _ in range(80):
|
||||
# The entrypoint's temporary init server has TCP disabled. Require TCP
|
||||
# readiness so we cannot mistake it for the final local-only server.
|
||||
ready = subprocess.run(['docker', 'exec', name, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-d', 'recovery'],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if ready.returncode == 0:
|
||||
return
|
||||
time.sleep(.25)
|
||||
raise bundle.Refused()
|
||||
|
||||
|
||||
def start_worker(name, database, root):
|
||||
secret = bundle.resource(root, 'mfa', 'secret', 'privacyidea-config')['data']
|
||||
config = {'SQLALCHEMY_DATABASE_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery',
|
||||
'PI_AUDIT_SQL_URI': 'postgresql+psycopg2://postgres@127.0.0.1:5432/recovery',
|
||||
'SECRET_KEY': base64.b64decode(secret['PI_SECRET_KEY'], validate=True).decode(),
|
||||
'PI_PEPPER': base64.b64decode(secret['PI_PEPPER'], validate=True).decode(),
|
||||
'PI_ENCFILE': '/recovery/enckey', 'PI_AUDIT_KEY_PRIVATE': '/recovery/private.pem',
|
||||
'PI_AUDIT_KEY_PUBLIC': '/recovery/public.pem', 'PI_AUDIT_NO_SIGN': False,
|
||||
'PI_CHECK_OLD_SIGNATURES': legacy_setting(root), 'PI_NO_RESPONSE_SIGN': True,
|
||||
'PI_LOGFILE': '/recovery/runtime.log', 'PI_LOGLEVEL': 50,
|
||||
'PI_UUID_FILE': '/recovery/node-uuid',
|
||||
'PI_TRUSTED_JWT': []}
|
||||
# This deliberately does not execute archived pi.cfg or reuse its live DB URI.
|
||||
payload = {'pi.cfg': '\n'.join(k + '=' + repr(v) for k, v in config.items()).encode()}
|
||||
for key in ('enckey', 'private.pem', 'public.pem'):
|
||||
payload[key] = (root / 'data/privacyidea' / key).read_bytes()
|
||||
run(['docker', 'run', '--detach', '--pull=never', '--name', name,
|
||||
'--network', 'container:' + database, '--read-only', '--cap-drop', 'ALL',
|
||||
'--security-opt', 'no-new-privileges', '--user', '1000:1000',
|
||||
'--memory', '1g', '--cpus', '2', '--pids-limit', '128',
|
||||
'--tmpfs', '/recovery:rw,nosuid,nodev,noexec,size=33554432,uid=1000,gid=1000,mode=0700',
|
||||
'--tmpfs', '/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000',
|
||||
'--tmpfs', '/privacyidea/etc/persistent:rw,nosuid,nodev,noexec,size=1048576,uid=1000,gid=1000,mode=0700',
|
||||
'--env', 'PRIVACYIDEA_CONFIGFILE=/recovery/pi.cfg',
|
||||
'--entrypoint', 'python3', IMAGE, '-c', 'import time;time.sleep(1800)'])
|
||||
# Docker normalizes container:<name> to container:<full ID> in HostConfig.
|
||||
database_id = run(['docker', 'container', 'inspect', database, '--format', '{{.Id}}']).decode().strip()
|
||||
isolation(name, 'container:' + database_id, '1000:1000')
|
||||
run(['docker', 'exec', '-i', name, 'tar', '-xf', '-', '-C', '/recovery'],
|
||||
input=local.archive_files(payload))
|
||||
|
||||
|
||||
def legacy_setting(root):
|
||||
path = root / 'resources/mfa/configmap/privacyidea-cfg.json'
|
||||
if not path.exists():
|
||||
return False # Only the lower-level synthetic fixture omits ConfigMaps.
|
||||
raw = bundle.resource(root, 'mfa', 'configmap', 'privacyidea-cfg')['data']['pi.cfg']
|
||||
selected = False
|
||||
for node in ast.parse(raw).body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'PI_CHECK_OLD_SIGNATURES' for t in node.targets):
|
||||
if not isinstance(node.value, ast.Constant) or type(node.value.value) is not bool:
|
||||
raise bundle.Refused()
|
||||
selected = node.value.value
|
||||
return selected
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import contextlib,io,json,logging,os,sys
|
||||
logging.disable(logging.CRITICAL)
|
||||
os.umask(0o077)
|
||||
result={'status':'failed'}
|
||||
stage='network_isolation'
|
||||
try:
|
||||
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
||||
from pathlib import Path
|
||||
assert {p.name for p in Path('/sys/class/net').iterdir()}=={'lo'}
|
||||
assert len(Path('/proc/net/route').read_text().splitlines())==1
|
||||
stage='native_application_configuration'
|
||||
from privacyidea.app import create_app
|
||||
app=create_app(config_name='testing',config_file='/recovery/pi.cfg',silent=True)
|
||||
from privacyidea.models import db,Token
|
||||
from privacyidea.lib.security.default import DefaultSecurityModule
|
||||
from privacyidea.lib.auditmodules.sqlaudit import Audit,LogEntry
|
||||
from privacyidea.lib.crypto import Sign
|
||||
import binascii
|
||||
with app.app_context():
|
||||
assert str(db.engine.url)=='postgresql+psycopg2://postgres@127.0.0.1:5432/recovery'
|
||||
stage='retained_token_decryption'
|
||||
security=DefaultSecurityModule({'file':'/recovery/enckey'})
|
||||
wrong_path=Path('/recovery/wrong-enckey');wrong_path.write_bytes(os.urandom(96))
|
||||
wrong=DefaultSecurityModule({'file':str(wrong_path)})
|
||||
total=Token.query.count()
|
||||
assert 0<total<=10000
|
||||
checked=0;negative=0
|
||||
for token in Token.query.yield_per(100):
|
||||
if not token.key_enc:continue
|
||||
encrypted=binascii.unhexlify(token.key_enc);iv=binascii.unhexlify(token.key_iv)
|
||||
material=security.decrypt(encrypted,iv)
|
||||
assert isinstance(material,bytes) and 0<len(material)<=4096
|
||||
try:
|
||||
invalid=wrong.decrypt(encrypted,iv)
|
||||
assert invalid!=material
|
||||
except (ValueError,binascii.Error):pass
|
||||
negative+=1;checked+=1
|
||||
del material
|
||||
assert checked>0
|
||||
result.update(retained_tokens_total=total,retained_tokens_with_native_decryption=checked,
|
||||
different_key_cannot_reproduce_material_count=negative,
|
||||
retained_token_material_decrypted_inside_process=True,existing_token_mfa_proven=False)
|
||||
stage='historical_audit_verification'
|
||||
audit=Audit(app.config)
|
||||
allow_legacy=bool(app.config['PI_CHECK_OLD_SIGNATURES'])
|
||||
historical=audit.session.query(LogEntry).count()
|
||||
assert 0<historical<=2000000
|
||||
signed=0;unsigned=0;invalid=0;legacy=0
|
||||
for entry in audit.session.query(LogEntry).yield_per(1000):
|
||||
if not entry.signature:
|
||||
unsigned+=1;continue
|
||||
signed+=1
|
||||
if not str(entry.signature).startswith(Sign.sig_ver+':'):legacy+=1
|
||||
if not audit.sign_object.verify(audit._log_to_string(entry),entry.signature,allow_legacy):invalid+=1
|
||||
result.update(historical_audit_rows=historical,historical_signed_rows=signed,
|
||||
historical_unsigned_rows=unsigned,historical_invalid_signatures=invalid,
|
||||
historical_legacy_signatures=legacy,all_stored_audit_rows_checked=True,
|
||||
legacy_signature_verification_enabled=allow_legacy,
|
||||
event_completeness_claimed=False)
|
||||
stage='new_isolated_audit_entry'
|
||||
audit.log({'action':'NK49 isolated recovery verification','success':True,
|
||||
'info':'disposable restored database only','client':'127.0.0.1'})
|
||||
audit.finalize_log()
|
||||
fresh=Audit(app.config)
|
||||
assert fresh.session.query(LogEntry).count()==historical+1
|
||||
entry=fresh.session.query(LogEntry).order_by(LogEntry.id.desc()).first()
|
||||
message=fresh._log_to_string(entry)
|
||||
assert fresh.sign_object.verify(message,entry.signature,False)
|
||||
assert not fresh.sign_object.verify(message+' tampered',entry.signature,False)
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
public=rsa.generate_private_key(public_exponent=65537,key_size=2048).public_key().public_bytes(
|
||||
serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
assert not Sign(public_key=public).verify(message,entry.signature,False)
|
||||
result.update(new_audit_entry_signed_verified=True,tampered_entry_refused=True,
|
||||
wrong_audit_public_key_refused=True,
|
||||
audit_signature_continuity_proven=signed==historical and invalid==0,
|
||||
only_loopback_interface_verified=True,external_routes_absent=True,
|
||||
status='native_checks_completed')
|
||||
except Exception as error:
|
||||
result.update(status='failed',failed_stage=stage,failure_type=type(error).__name__)
|
||||
print(json.dumps(result))
|
||||
'''
|
||||
|
||||
|
||||
def native_checks(root, receipt):
|
||||
stem = 'nk49-pi-check-' + uuid.uuid4().hex
|
||||
database = stem + '-db'
|
||||
worker = stem + '-worker'
|
||||
try:
|
||||
start_database(database)
|
||||
with (root / 'databases/privacyidea.dump').open('rb') as dump:
|
||||
run(['docker', 'exec', '-i', database, 'pg_restore', '-U', 'postgres', '-d', 'recovery',
|
||||
'--no-owner', '--no-privileges', '--exit-on-error'], stdin=dump, timeout=600)
|
||||
start_worker(worker, database, root)
|
||||
receipt['container_isolation_verified_before_private_import'] = True
|
||||
result = json.loads(run(['docker', 'exec', worker, 'python3', '-c', PROBE], timeout=600))
|
||||
receipt.update(result)
|
||||
if result.get('status') != 'native_checks_completed':
|
||||
raise bundle.Refused()
|
||||
finally:
|
||||
cleanup([worker, database])
|
||||
receipt['container_cleanup_verified'] = True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--transfer-receipt', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
result = {'schema': 'netkingdom.isolated-privacyidea-recovery.v1', 'status': 'failed',
|
||||
'production_contacted': False, 'secret_values_exported': False,
|
||||
'existing_token_mfa_proven': False, 'audit_signature_continuity_proven': False,
|
||||
'container_cleanup_verified': False, 'temporary_plaintext_cleanup_verified': False,
|
||||
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd = None
|
||||
work = None
|
||||
stage = 'target_preflight'
|
||||
try:
|
||||
result['target'] = preflight()
|
||||
stage = 'transfer_validation'
|
||||
transfer = json.loads(Path(args.transfer_receipt).read_text())
|
||||
local.offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:
|
||||
raise bundle.Refused()
|
||||
path = Path(transfer['fetched_path'])
|
||||
bundle.private(path, max_bytes=bundle.LIMIT)
|
||||
if path.stat().st_size != transfer['ciphertext_bytes'] or local.offline.offsite.digest(path) != transfer['ciphertext_sha256']:
|
||||
raise bundle.Refused()
|
||||
keyfd = local.verify.custody.private_key_fd()
|
||||
if run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,)).strip() != bundle.profile()['public_recipient'].encode():
|
||||
raise bundle.Refused()
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-pi-recovery-', dir='/dev/shm') as temporary:
|
||||
work = Path(temporary)
|
||||
archive = work / 'archive.tar'
|
||||
stage = 'archive_decryption'
|
||||
with archive.open('xb') as output:
|
||||
p = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}', str(path)],
|
||||
stdout=output, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=180)
|
||||
if p.returncode or archive.stat().st_size > bundle.LIMIT + 4 * 1024 * 1024:
|
||||
raise bundle.Refused()
|
||||
restored = work / 'restored';restored.mkdir(mode=0o700)
|
||||
stage = 'archive_validation'
|
||||
manifest, _ = bundle.restore(archive, restored)
|
||||
result.update(ciphertext_sha256=transfer['ciphertext_sha256'],
|
||||
manifest_file_count=len(manifest['files']), all_manifest_hashes_verified=True)
|
||||
stage = 'native_privacyidea_checks'
|
||||
native_checks(restored, result)
|
||||
result['status'] = 'isolated_privacyidea_checked'
|
||||
except (Exception, KeyboardInterrupt):
|
||||
result.update(status='failed', error='isolated_privacyidea_check_failed_private_diagnostics_suppressed')
|
||||
result.setdefault('failed_stage', stage)
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
if work is not None and not work.exists():result['temporary_plaintext_cleanup_verified'] = True
|
||||
result['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(result, output, indent=2);output.write('\n')
|
||||
if result['status'] == 'failed':
|
||||
print('ERROR: isolated privacyIDEA check failed; review safe receipt. No production change.')
|
||||
return 1
|
||||
if not result['audit_signature_continuity_proven']:
|
||||
print('REVIEW: token-material checks completed; historical audit exceptions require owner review. Cleanup passed; see safe receipt.')
|
||||
return 2
|
||||
print('OK: retained token material decrypted internally; historical/new audit signatures and negative checks passed; cleanup passed. Existing-token MFA remains separate.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):signal.signal(sig, local.offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare exact identity backup coverage from read-only workload/PVC metadata.
|
||||
|
||||
This produces a proposal, never reads Secrets, executes in pods, changes cluster
|
||||
resources, captures private files, uploads, schedules or grants credentials.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
CLUSTER_UID='a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
PROFILE=ROOT/'tools/bootstrap-recovery/identity-backup-profile.json'
|
||||
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
|
||||
def query(namespace,kind,name):
|
||||
# Closed read-only types; Secret/ConfigMap data and arbitrary API paths have
|
||||
# no transport in this preparation command.
|
||||
if kind not in {'namespace','deployment','pvc'}:raise Refused()
|
||||
args=['kubectl','--request-timeout=20s']
|
||||
if namespace:args+=['-n',namespace]
|
||||
r=subprocess.run(args+['get',kind,name,'-o','json'],capture_output=True,timeout=25)
|
||||
if r.returncode:raise Refused()
|
||||
return json.loads(r.stdout)
|
||||
|
||||
|
||||
def references(spec):
|
||||
secrets=set();configmaps=set();pvcs=set()
|
||||
for c in spec.get('containers',[])+spec.get('initContainers',[]):
|
||||
for e in c.get('env',[]):
|
||||
v=e.get('valueFrom',{})
|
||||
if 'secretKeyRef' in v:secrets.add(v['secretKeyRef']['name'])
|
||||
if 'configMapKeyRef' in v:configmaps.add(v['configMapKeyRef']['name'])
|
||||
for e in c.get('envFrom',[]):
|
||||
if 'secretRef' in e:secrets.add(e['secretRef']['name'])
|
||||
if 'configMapRef' in e:configmaps.add(e['configMapRef']['name'])
|
||||
for v in spec.get('volumes',[]):
|
||||
if 'secret' in v:secrets.add(v['secret']['secretName'])
|
||||
if 'configMap' in v:configmaps.add(v['configMap']['name'])
|
||||
if 'persistentVolumeClaim' in v:pvcs.add(v['persistentVolumeClaim']['claimName'])
|
||||
for p in v.get('projected',{}).get('sources',[]):
|
||||
if 'secret' in p:secrets.add(p['secret']['name'])
|
||||
if 'configMap' in p:configmaps.add(p['configMap']['name'])
|
||||
return secrets,configmaps,pvcs
|
||||
|
||||
|
||||
def prepare(profile,get=query):
|
||||
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||||
bindings=[]
|
||||
for component in profile['components']:
|
||||
ns=component['namespace'];name=component['deployment']
|
||||
d=get(ns,'deployment',name);m=d['metadata']
|
||||
if (m['uid']!=component['deployment_uid'] or m.get('deletionTimestamp') or
|
||||
d['status'].get('observedGeneration')!=m['generation'] or d['status'].get('readyReplicas')!=1):raise Refused()
|
||||
refs=references(d['spec']['template']['spec'])
|
||||
expected_pvcs={p['name']:p['uid'] for p in component['pvc_refs']}
|
||||
if refs[0]!=set(component['secret_refs']) or refs[1]!=set(component['configmap_refs']) or refs[2]!=set(expected_pvcs):raise Refused()
|
||||
pvcs=[]
|
||||
for pvc,expected in expected_pvcs.items():
|
||||
obj=get(ns,'pvc',pvc)
|
||||
if obj['metadata']['uid']!=expected or obj['metadata'].get('deletionTimestamp') or obj['status'].get('phase')!='Bound':raise Refused()
|
||||
pvcs.append({'name':pvc,'uid':expected})
|
||||
bindings.append({'namespace':ns,'deployment':name,'uid':m['uid'],'generation':m['generation'],
|
||||
'pvc_bindings':pvcs,'secret_names_only':sorted(refs[0]),'configmap_names_only':sorted(refs[1])})
|
||||
if get(None,'namespace','kube-system')['metadata']['uid']!=CLUSTER_UID:raise Refused()
|
||||
return {'schema':'netkingdom.identity-backup-preparation.v1','status':'prepared_not_admitted',
|
||||
'observed_at':datetime.now(timezone.utc).isoformat(),'cluster_uid':CLUSTER_UID,
|
||||
'profile_sha256':hashlib.sha256(PROFILE.read_bytes()).hexdigest(),
|
||||
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
'bindings':bindings,'secret_values_read':False,'backup_captured':False,'uploaded':False,
|
||||
'runtime_changed':False,'credential_grant_created':False,
|
||||
'owner_admission_required':profile['admission_gates']}
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args()
|
||||
try:
|
||||
r=prepare(json.loads(PROFILE.read_text()))
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
except Exception:
|
||||
print('ERROR: coverage preparation refused; private diagnostics suppressed.');return 1
|
||||
print('OK: exact coverage proposal prepared; capture, delivery and owner admission remain pending.');return 0
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a secret-free offline recovery kit; optionally publish exact new files.
|
||||
|
||||
Publication creates only three fixed versioned objects beside identity backups,
|
||||
never changes shares, credentials, existing files, retention or runtime jobs.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('offline', HERE / 'retrieve-identity-offline.py')
|
||||
offline = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(offline)
|
||||
offsite = offline.offsite
|
||||
FILES = [
|
||||
'docs/disaster-recovery-storage-share.md',
|
||||
'docs/identity-service-restore-packet.md',
|
||||
'docs/evidence/2026-10-16-identity-backup-live/actual-offsite.json',
|
||||
'docs/evidence/2026-10-16-identity-outage-recovery/image-observation.json',
|
||||
'tools/bootstrap-recovery/retrieve-identity-offline.py',
|
||||
'tools/bootstrap-recovery/identity-offsite.py',
|
||||
'tools/bootstrap-recovery/verify-identity.py',
|
||||
'tools/bootstrap-recovery/identity-bundle.py',
|
||||
'tools/bootstrap-recovery/check-privacyidea-database.py',
|
||||
'tools/bootstrap-recovery/identity-backup-profile.json',
|
||||
'sso-mfa/bootstrap/sops-custody-unlock.sh',
|
||||
'sso-mfa/bootstrap/sops-private.py',
|
||||
'keys/age.pub',
|
||||
]
|
||||
|
||||
|
||||
def build():
|
||||
content = {name: (ROOT / name).read_bytes() for name in FILES}
|
||||
if any(b'AGE-SECRET-KEY-1' in value for name, value in content.items()
|
||||
if not name.endswith(('.py', '.sh', '.md'))):
|
||||
raise offsite.Refused()
|
||||
sums = ''.join(hashlib.sha256(value).hexdigest() + ' ' + name + '\n'
|
||||
for name, value in content.items()).encode()
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, value in {**content, 'SHA256SUMS': sums}.items():
|
||||
entry = zipfile.ZipInfo(name, date_time=(2026, 10, 16, 0, 0, 0))
|
||||
entry.external_attr = 0o100600 << 16
|
||||
entry.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(entry, value)
|
||||
kit = output.getvalue()
|
||||
version = hashlib.sha256(kit).hexdigest()
|
||||
stem = 'net-kingdom-recovery-' + version[:16]
|
||||
return {
|
||||
stem + '-START-HERE.md': content['docs/disaster-recovery-storage-share.md'],
|
||||
stem + '.zip': kit,
|
||||
stem + '.zip.sha256': (version + ' ' + stem + '.zip\n').encode(),
|
||||
}
|
||||
|
||||
|
||||
def publish(files, headers, client=None):
|
||||
client = client or offsite.opener()
|
||||
result = []
|
||||
for name, raw in files.items():
|
||||
url = offline.ACCOUNT_BASE + name
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
# A retry may accept an identical object after exact readback, but must
|
||||
# never overwrite a conflicting object or retry an unknown PUT outcome.
|
||||
present = False
|
||||
try:
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response:
|
||||
version = offsite.etag(response, url, len(raw))
|
||||
present = True
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
if not present:
|
||||
h = dict(headers, **{'If-None-Match': '*', 'Content-Length': str(len(raw)),
|
||||
'Content-Type': 'application/zip' if name.endswith('.zip') else 'text/plain; charset=utf-8'})
|
||||
with client.open(urllib.request.Request(url, data=raw, method='PUT', headers=h), timeout=60) as response:
|
||||
if response.status != 201 or response.url != url:
|
||||
raise offsite.Refused()
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=headers), timeout=30) as response:
|
||||
version = offsite.etag(response, url, len(raw))
|
||||
h = dict(headers, **{'If-Match': version})
|
||||
with client.open(urllib.request.Request(url, headers=h), timeout=60) as response:
|
||||
if offsite.etag(response, url, len(raw)) != version:
|
||||
raise offsite.Refused()
|
||||
downloaded = response.read(len(raw) + 1)
|
||||
if downloaded != raw:
|
||||
raise offsite.Refused()
|
||||
result.append({'name': name, 'bytes': len(raw), 'sha256': digest,
|
||||
'etag': version, 'readback_verified': True})
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--output-dir', required=True)
|
||||
p.add_argument('--publish', action='store_true')
|
||||
p.add_argument('--auth-source', choices=('prompt', 'attended-openbao'), default='prompt')
|
||||
p.add_argument('--receipt', required=True)
|
||||
p.add_argument('--offline-transfer-receipt', help='After prompted publication, retrieve the known recovered generation using the same independently held access')
|
||||
a = p.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
r = {'schema': 'netkingdom.recovery-kit.v1', 'status': 'failed',
|
||||
'secret_values_exported': False, 'new_grants_created': False}
|
||||
stage = 'option_validation'
|
||||
try:
|
||||
if a.offline_transfer_receipt and (not a.publish or a.auth_source != 'prompt'):
|
||||
raise offsite.Refused()
|
||||
stage = 'build_kit'
|
||||
files = build()
|
||||
stage = 'write_local_kit'
|
||||
directory = Path(a.output_dir)
|
||||
directory.mkdir(mode=0o700, exist_ok=False)
|
||||
for name, raw in files.items():
|
||||
(directory / name).write_bytes(raw)
|
||||
r.update(status='built', files=[{'name': n, 'bytes': len(v), 'sha256': hashlib.sha256(v).hexdigest()}
|
||||
for n, v in files.items()])
|
||||
if a.publish:
|
||||
if a.auth_source == 'prompt':
|
||||
stage = 'private_provider_prompt'
|
||||
secret = offline.private_prompt('Independently held Storage Share Backup app password: ')
|
||||
else:
|
||||
stage = 'attended_owner_custody'
|
||||
spec = importlib.util.spec_from_file_location('inventory', HERE / 'inventory-storage-share.py')
|
||||
owner = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(owner)
|
||||
_, credentials = owner.caller_credentials()
|
||||
secret = credentials['BACKUP_PASSWORD']
|
||||
if not secret or any(c in secret for c in '\r\n\x00'):
|
||||
raise offsite.Refused()
|
||||
try:
|
||||
headers = {'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(),
|
||||
'Accept-Encoding': 'identity'}
|
||||
stage = 'provider_publication'
|
||||
r.update(status='published_readback_verified', files=publish(files, headers),
|
||||
folder='Backup/railiance-backups/net-kingdom-identity',
|
||||
provider='Storage Share', credential_source=a.auth_source)
|
||||
if a.offline_transfer_receipt:
|
||||
stage = 'offline_provider_download'
|
||||
transfer_fd = os.open(a.offline_transfer_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
transfer = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'}
|
||||
try:
|
||||
record = json.loads((ROOT / FILES[2]).read_text())
|
||||
transfer = offline.retrieve(record, secret, 'account')
|
||||
finally:
|
||||
with os.fdopen(transfer_fd, 'w') as out:
|
||||
json.dump(transfer, out, indent=2)
|
||||
out.write('\n')
|
||||
r['offline_retrieval_verified'] = transfer['status'] == 'readback_verified'
|
||||
finally:
|
||||
secret = None
|
||||
r['finished_at'] = datetime.now(timezone.utc).isoformat()
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
r.update(status='failed', error='kit_operation_failed_private_diagnostics_suppressed',
|
||||
failed_stage=stage, **offline.failure_metadata(error))
|
||||
finally:
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(r, output, indent=2)
|
||||
output.write('\n')
|
||||
# Silent: safe to use in the existing captured attended-owner conduit.
|
||||
return int(r['status'] == 'failed')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Download one reviewed ciphertext using privately entered provider access.
|
||||
|
||||
No OpenBao, SSO, Kubernetes, credential discovery, provider writes or age key.
|
||||
Run by the operator in their own terminal, never through a recorded agent PTY.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import getpass
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import warnings
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('offsite', HERE / 'identity-offsite.py')
|
||||
offsite = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(offsite)
|
||||
ACCOUNT_BASE = offsite.HOST + '/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/'
|
||||
|
||||
|
||||
def private_prompt(label):
|
||||
# getpass opens the terminal with os.open, which does not require seeking.
|
||||
# Reject redirected input and turn its echoing fallback warning into an
|
||||
# exception before it can read a credential without echo suppression.
|
||||
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
||||
raise offsite.Refused()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', getpass.GetPassWarning)
|
||||
return getpass.getpass(label)
|
||||
|
||||
|
||||
def failure_metadata(error):
|
||||
# No exception messages, response bodies, URLs or headers enter receipts.
|
||||
if isinstance(error, urllib.error.HTTPError):
|
||||
return {'failure_kind': 'provider_http_error', 'http_status': int(error.code)}
|
||||
if isinstance(error, urllib.error.URLError):
|
||||
return {'failure_kind': 'provider_transport_error'}
|
||||
if isinstance(error, getpass.GetPassWarning):
|
||||
return {'failure_kind': 'terminal_echo_suppression_unavailable'}
|
||||
if isinstance(error, offsite.Refused):
|
||||
return {'failure_kind': 'scope_or_integrity_refusal'}
|
||||
return {'failure_kind': 'local_operation_failed'}
|
||||
|
||||
|
||||
def validate(record):
|
||||
if (record.get('schema') != 'netkingdom.identity-offsite.v1'
|
||||
or record.get('status') != 'readback_verified'
|
||||
or record.get('provider') != 'Storage Share'
|
||||
or record.get('folder') != 'Backup/railiance-backups/net-kingdom-identity'
|
||||
or not offsite.NAME.fullmatch(record.get('artifact', ''))
|
||||
or not re.fullmatch('[a-f0-9]{64}', record.get('ciphertext_sha256', ''))
|
||||
or type(record.get('ciphertext_bytes')) is not int
|
||||
or not 0 < record['ciphertext_bytes'] <= offsite.LIMIT
|
||||
or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"', record.get('etag', ''))):
|
||||
raise offsite.Refused()
|
||||
|
||||
|
||||
def retrieve(record, secret, mode, client=None):
|
||||
validate(record)
|
||||
if mode not in {'account', 'folder-share'} or not secret or any(c in secret for c in '\r\n\x00'):
|
||||
raise offsite.Refused()
|
||||
auth = offsite.headers(secret) if mode == 'folder-share' else {
|
||||
'Authorization': 'Basic ' + base64.b64encode(('Backup:' + secret).encode()).decode(),
|
||||
'Accept-Encoding': 'identity'}
|
||||
url = (offsite.BASE if mode == 'folder-share' else ACCOUNT_BASE) + record['artifact']
|
||||
client = client or offsite.opener()
|
||||
directory = None
|
||||
complete = False
|
||||
try:
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response:
|
||||
if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']:
|
||||
raise offsite.Refused()
|
||||
directory = Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-', dir='/tmp'))
|
||||
target = directory / record['artifact']
|
||||
auth['If-Match'] = record['etag']
|
||||
count = 0
|
||||
digest = hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url, headers=auth), timeout=180) as response:
|
||||
if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']:
|
||||
raise offsite.Refused()
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'wb') as output:
|
||||
while block := response.read(1024 * 1024):
|
||||
count += len(block)
|
||||
if count > record['ciphertext_bytes']:
|
||||
raise offsite.Refused()
|
||||
digest.update(block)
|
||||
output.write(block)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
if count != record['ciphertext_bytes'] or digest.hexdigest() != record['ciphertext_sha256']:
|
||||
raise offsite.Refused()
|
||||
with client.open(urllib.request.Request(url, method='HEAD', headers=auth), timeout=30) as response:
|
||||
if offsite.etag(response, url, record['ciphertext_bytes']) != record['etag']:
|
||||
raise offsite.Refused()
|
||||
complete = True
|
||||
# Closed allowlist: never copy arbitrary source-receipt fields to output.
|
||||
result = {k: record[k] for k in ('schema', 'provider', 'folder', 'artifact',
|
||||
'ciphertext_bytes', 'ciphertext_sha256', 'etag')}
|
||||
result.update(status='readback_verified', fetched_path=str(target),
|
||||
independent_owner_retrieval_verified=True,
|
||||
credential_source='independently_held_provider_access',
|
||||
provider_auth_mode=mode, openbao_contacted=False, sso_contacted=False,
|
||||
provider_writes_performed=False, age_private_key_used=False,
|
||||
secret_values_exported=False, object_unchanged_during_transfer=True,
|
||||
downloaded_at=datetime.now(timezone.utc).isoformat())
|
||||
return result
|
||||
finally:
|
||||
if directory is not None and not complete:
|
||||
shutil.rmtree(directory)
|
||||
|
||||
|
||||
def interrupted(signum, frame):
|
||||
raise InterruptedError()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source-receipt', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
parser.add_argument('--auth', choices=('account', 'folder-share'), default='account')
|
||||
args = parser.parse_args()
|
||||
os.umask(0o077)
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
result = {'status': 'failed', 'error': 'offline_retrieval_failed_private_diagnostics_suppressed'}
|
||||
stage = 'source_receipt_validation'
|
||||
try:
|
||||
record = json.loads(Path(args.source_receipt).read_text())
|
||||
validate(record)
|
||||
# getpass must never fall back to echoed stdin (e.g. a captured agent tool).
|
||||
stage = 'private_provider_prompt'
|
||||
secret = private_prompt('Storage Share Backup app password or exact folder-share token: ')
|
||||
try:
|
||||
stage = 'provider_download'
|
||||
result = retrieve(record, secret, args.auth)
|
||||
finally:
|
||||
secret = None
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
result.update(failed_stage=stage, **failure_metadata(error))
|
||||
finally:
|
||||
with os.fdopen(fd, 'w') as output:
|
||||
json.dump(result, output, indent=2)
|
||||
output.write('\n')
|
||||
ok = result['status'] == 'readback_verified'
|
||||
print('OK: ciphertext retrieved without OpenBao or SSO; ETag, size and SHA256 verified.' if ok
|
||||
else 'ERROR: retrieval refused; private diagnostics suppressed. Use a new receipt path to retry.')
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
signal.signal(sig, interrupted)
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Silent attended owner retrieval of an existing identity ciphertext.
|
||||
|
||||
Uses Backup-account recovery custody only in memory; no private age key, upload,
|
||||
share, grant, deletion or production restore operation.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
import urllib.request
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
inventory=load('inventory-storage-share');offsite=load('identity-offsite')
|
||||
|
||||
|
||||
def retrieve(record,out):
|
||||
if (record.get('schema')!='netkingdom.identity-offsite.v1' or record.get('status')!='readback_verified'
|
||||
or not offsite.NAME.fullmatch(record.get('artifact','')) or not re.fullmatch('[a-f0-9]{64}',record.get('ciphertext_sha256',''))
|
||||
or not 0<record.get('ciphertext_bytes',0)<=offsite.LIMIT):raise offsite.Refused()
|
||||
identity,credentials=inventory.caller_credentials()
|
||||
auth=base64.b64encode((credentials['BACKUP_USERNAME']+':'+credentials['BACKUP_PASSWORD']).encode()).decode()
|
||||
url=offsite.HOST+'/remote.php/dav/files/Backup/railiance-backups/net-kingdom-identity/'+record['artifact']
|
||||
h={'Authorization':'Basic '+auth,'Accept-Encoding':'identity'}
|
||||
client=offsite.opener()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
version=offsite.etag(response,url,record['ciphertext_bytes'])
|
||||
if version!=record['etag']:raise offsite.Refused()
|
||||
h['If-Match']=version
|
||||
directory=Path(tempfile.mkdtemp(prefix='nk49-identity-fetched-',dir='/tmp'))
|
||||
target=directory/record['artifact'];count=0;digest=hashlib.sha256()
|
||||
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
|
||||
if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused()
|
||||
with target.open('xb') as output:
|
||||
while block:=response.read(1024*1024):
|
||||
count+=len(block)
|
||||
if count>record['ciphertext_bytes']:raise offsite.Refused()
|
||||
digest.update(block);output.write(block)
|
||||
if count!=record['ciphertext_bytes'] or digest.hexdigest()!=record['ciphertext_sha256']:raise offsite.Refused()
|
||||
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
|
||||
if offsite.etag(response,url,record['ciphertext_bytes'])!=version:raise offsite.Refused()
|
||||
out.update(record);out.update(status='readback_verified',fetched_path=str(target),
|
||||
independent_owner_retrieval_verified=True,caller_entity_id=identity.get('entity_id'),
|
||||
age_private_key_used=False,secret_values_exported=False,downloaded_at=datetime.now(timezone.utc).isoformat())
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--source-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077);fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:retrieve(json.loads(Path(a.source_receipt).read_text()),r)
|
||||
except Exception:r.update(status='failed',error='contained_owner_retrieval_failed')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# Candidate owner launcher. Review and native fixture proof required before use.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd -- "$ROOT"
|
||||
umask 077
|
||||
[[ $# == 6 ]] || { printf '%s\n' 'Usage: run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT ADMISSION ADMISSION_SHA256 TLS_CERT TLS_KEY TRUST'; exit 1; }
|
||||
[[ "$3" =~ ^[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact reviewed admission digest required.'; exit 1; }
|
||||
for index in 1 2 4 5 6; do
|
||||
[[ -f "${!index}" && ! -L "${!index}" ]] || { printf '%s\n' 'ERROR: provide regular owner-controlled input files.'; exit 1; }
|
||||
done
|
||||
[[ -t 0 && -t 1 && -t 2 ]] || { printf '%s\n' 'ERROR: run in your own interactive terminal.'; exit 1; }
|
||||
for tool in python3 age age-keygen docker; do
|
||||
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
||||
done
|
||||
python3 - <<'PY'
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
p=Path('tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
spec=importlib.util.spec_from_file_location('session',p)
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
try:
|
||||
m.local.preflight()
|
||||
m.run(['docker','image','inspect',m.adapter.IMAGE])
|
||||
except Exception:
|
||||
raise SystemExit('ERROR: exact local Docker/images/tmpfs preflight failed; no key requested.')
|
||||
print('OK: fixed local target and cached pinned images available.')
|
||||
PY
|
||||
NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)"
|
||||
printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN"
|
||||
printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.'
|
||||
printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.'
|
||||
printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.'
|
||||
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
|
||||
--prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \
|
||||
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||
--tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json"
|
||||
printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN"
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Isolated ciphertext recovery check; no cluster, credentials or values emitted.
|
||||
|
||||
This verifies the handoff transport and exact requested file coverage. Current
|
||||
service validity, independent source custody and native consumer recovery need
|
||||
separate owner evidence; this tool cannot infer them from a decrypted archive.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('private_age_recovery', ROOT / 'sso-mfa/bootstrap/sops-private.py')
|
||||
custody = importlib.util.module_from_spec(spec); spec.loader.exec_module(custody)
|
||||
LIMIT = 64 * 1024 * 1024
|
||||
ALLOWED = {f'{c}/secrets.env' for c in ('privacyidea', 'postgres', 'lldap', 'authelia', 'keycape', 'breakglass')} | {
|
||||
'privacyidea/pi.enc', 'privacyidea/private.pem', 'privacyidea/public.pem',
|
||||
'keycape/key.pem', 'authelia/oidc_private_key.pem', 'openbao/init.json'}
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def recover(ciphertext, keyfd, required, work):
|
||||
# Exclude ambient age keys/plugins and unrelated credential environments.
|
||||
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': str(work), 'XDG_CONFIG_HOME': str(work)}
|
||||
result = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}'], input=ciphertext,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, pass_fds=(keyfd,),
|
||||
timeout=60, env=env, cwd=work)
|
||||
if result.returncode or not result.stdout or len(result.stdout) > LIMIT:
|
||||
raise Refused()
|
||||
restored = set(); names = set(); total = 0
|
||||
with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive:
|
||||
for index, member in enumerate(archive):
|
||||
if index >= 128 or member.name in names:
|
||||
raise Refused()
|
||||
names.add(member.name)
|
||||
path = PurePosixPath(member.name)
|
||||
if (path.is_absolute() or '..' in path.parts or '.' in path.parts
|
||||
or not path.parts or path.parts[0] != 'secrets'):
|
||||
raise Refused()
|
||||
if member.isdir():
|
||||
if len(path.parts) > 2 or (len(path.parts) == 2 and path.parts[1] not in {p.split('/')[0] for p in ALLOWED}):
|
||||
raise Refused()
|
||||
continue
|
||||
relative = '/'.join(path.parts[1:])
|
||||
if not member.isfile() or relative not in ALLOWED or not 0 <= member.size <= 16 * 1024 * 1024:
|
||||
raise Refused()
|
||||
total += member.size
|
||||
if total > LIMIT or member.size == 0:
|
||||
raise Refused()
|
||||
content = archive.extractfile(member).read()
|
||||
if len(content) != member.size or b'AGE-SECRET-KEY-' in content:
|
||||
raise Refused()
|
||||
target = work / 'restored' / relative
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'wb') as dest:
|
||||
dest.write(content); dest.flush(); os.fsync(dest.fileno())
|
||||
if target.read_bytes() != content:
|
||||
raise Refused()
|
||||
restored.add(relative)
|
||||
if not required.issubset(restored):
|
||||
raise Refused()
|
||||
return sorted(restored)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--bundle', required=True)
|
||||
parser.add_argument('--bundle-sha256', required=True)
|
||||
parser.add_argument('--required-file', action='append', required=True)
|
||||
parser.add_argument('--receipt', required=True)
|
||||
args = parser.parse_args()
|
||||
if not re.fullmatch('[a-f0-9]{64}', args.bundle_sha256): raise Refused()
|
||||
required = set(args.required_file)
|
||||
if not required or required - ALLOWED or len(required) != len(args.required_file): raise Refused()
|
||||
path = Path(args.bundle).absolute()
|
||||
if any(p.is_symlink() for p in (path, *path.parents)): raise Refused()
|
||||
with path.open('rb') as file:
|
||||
data = file.read(LIMIT + 1)
|
||||
if len(data) > LIMIT or hashlib.sha256(data).hexdigest() != args.bundle_sha256: raise Refused()
|
||||
recipient = custody.public_recipient()
|
||||
keyfd = custody.private_key_fd()
|
||||
try:
|
||||
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
|
||||
pass_fds=(keyfd,), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if derived.returncode or derived.stdout.strip() != recipient.encode(): raise Refused()
|
||||
# Check the negative path with a disposable unrelated key. It never
|
||||
# replaces the registered key, leaves temporary custody, or contacts IAM.
|
||||
with tempfile.TemporaryDirectory(prefix='netkingdom-recovery-', dir='/tmp') as temporary:
|
||||
work = Path(temporary)
|
||||
disposable = work / 'wrong.key'
|
||||
created = subprocess.run(['age-keygen', '-o', str(disposable)], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=10)
|
||||
if created.returncode: raise Refused()
|
||||
wrong = subprocess.run(['age', '-d', '-i', str(disposable)], input=data,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60,
|
||||
env={'PATH': os.environ.get('PATH', os.defpath), 'HOME': temporary,
|
||||
'XDG_CONFIG_HOME': temporary}, cwd=temporary)
|
||||
if wrong.returncode == 0: raise Refused()
|
||||
restored = recover(data, keyfd, required, work)
|
||||
if work.exists(): raise Refused()
|
||||
finally:
|
||||
os.close(keyfd)
|
||||
receipt = {'schema': 'netkingdom.bootstrap-recovery-transport.v1',
|
||||
'observed_at': datetime.now(timezone.utc).isoformat(), 'bundle_sha256': args.bundle_sha256,
|
||||
'registered_recipient_match': True, 'wrong_key_refused': True,
|
||||
'required_files': sorted(required), 'restored_files': restored,
|
||||
'isolated_restore_readback_passed': True, 'temporary_plaintext_cleanup_verified': True,
|
||||
'secret_values_exported': False, 'cluster_contacted': False,
|
||||
'independent_custody_proven': False, 'current_service_validity_proven': False,
|
||||
'native_consumer_recovery_proven': False}
|
||||
# Publish only the closed, safe receipt shape; preserve existing receipts.
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'w') as dest:
|
||||
json.dump(receipt, dest, indent=2); dest.write('\n')
|
||||
print('Ciphertext transport/isolated restore checks passed. Independent custody and current service recovery remain separate owner evidence.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): signal.signal(sig, custody.interrupted)
|
||||
main()
|
||||
except (Refused, custody.Refused, OSError, ValueError, TypeError, KeyError, tarfile.TarError, subprocess.SubprocessError):
|
||||
print('ERROR: recovery transport check refused or failed; no acceptance receipt. Private diagnostics suppressed.', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended native reader/ESO verification; only closed booleans leave custody."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
setup=load('identity-credential-setup');runtime=load('identity-runtime')
|
||||
from state_hub_preflight_lane import bao,data,capabilities,revoke
|
||||
|
||||
|
||||
def verify(action,expected,receipt,kubeconfig):
|
||||
receipt['phase']='validate_approved_contract'
|
||||
ccr,lane=setup.contract(action,expected)
|
||||
receipt['phase']='verify_attended_identity'
|
||||
ident=data(bao(['token','lookup','-format=json']))['data']
|
||||
if 'platform-admin' not in ident.get('policies',[]) or 'root' in ident.get('policies',[]) or not ident.get('path','').startswith('auth/netkingdom/'):raise setup.Refused()
|
||||
capture=runtime.capture;capture.KUBE=['kubectl','--kubeconfig',kubeconfig]
|
||||
receipt['phase']='verify_cluster_identity'
|
||||
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise setup.Refused()
|
||||
def login(sa,audience,negative=False,namespace='sso'):
|
||||
jwt=capture.command(['-n',namespace,'create','token',sa,'--audience='+audience,'--duration=10m']).decode().strip()
|
||||
result=bao(['write','-format=json','auth/kubernetes/login','-'],payload={'role':lane['role'],'jwt':jwt},allow_failure=negative)
|
||||
if negative:
|
||||
if result.returncode==0:
|
||||
revoke(data(result)['auth']['client_token']);raise setup.Refused()
|
||||
if b'403' not in result.stderr and b'400' not in result.stderr:raise setup.Refused()
|
||||
return None
|
||||
return data(result)['auth']
|
||||
receipt['phase']='native_reader_login'
|
||||
auth=login(lane['service_account'],'openbao');token=auth['client_token']
|
||||
receipt['native_reader_metadata']={k:auth.get(k) for k in ['token_policies','identity_policies','policies','lease_duration']}
|
||||
try:
|
||||
receipt['phase']='effective_reader_policy_ttl'
|
||||
if (auth['token_policies']!=[lane['policy']] or auth.get('identity_policies')
|
||||
or auth.get('policies',auth['token_policies'])!=[lane['policy']] or not 0<auth['lease_duration']<=900):raise setup.Refused()
|
||||
paths=[lane['kv_path'],lane['kv_path'].replace('/data/','/metadata/',1),lane['kv_path']+'-sibling',
|
||||
'platform/data/workloads/railiance/backup/offsite-lane','auth/token/create']
|
||||
receipt['phase']='reader_capabilities'
|
||||
cap=capabilities(token,paths)
|
||||
receipt['capabilities_checked']=cap
|
||||
if cap[paths[0]]!=['read'] or any(cap[p]!=['deny'] for p in paths[1:]):raise setup.Refused()
|
||||
receipt['phase']='native_kv_eso_equality'
|
||||
native=data(bao(['read','-format=json',lane['kv_path']],token=token))['data']
|
||||
if set(native['data'])!={'NC_WEBDAV_TOKEN'} or native['data']['NC_WEBDAV_TOKEN']!=runtime.token(action):raise setup.Refused()
|
||||
receipt['kv_version']=native['metadata']['version']
|
||||
finally:revoke(token)
|
||||
receipt['phase']='negative_reader_logins'
|
||||
login('default','openbao',True);login(lane['service_account'],'wrong-audience',True)
|
||||
proposed={'apiVersion':'v1','kind':'ServiceAccount','metadata':{'name':lane['service_account'],'namespace':'default'},'automountServiceAccountToken':False}
|
||||
receipt['wrong_namespace_probe_attempted']=proposed['metadata']
|
||||
test=json.loads(capture.command(['create','-f','-','-o','json'],json.dumps(proposed).encode()))
|
||||
if test['metadata']['name']!=lane['service_account'] or test['metadata']['namespace']!='default':raise setup.Refused()
|
||||
try:login(lane['service_account'],'openbao',True,'default')
|
||||
finally:
|
||||
path='/api/v1/namespaces/default/serviceaccounts/'+lane['service_account']
|
||||
capture.command(['delete','--raw',path,'-f','-'],json.dumps({'apiVersion':'v1','kind':'DeleteOptions',
|
||||
'preconditions':{'uid':test['metadata']['uid']}}).encode())
|
||||
receipt['wrong_namespace_probe_cleanup_verified']=True
|
||||
row=data(bao(['read','-format=json','auth/kubernetes/role/'+lane['role']]))['data']
|
||||
setup.check_role(row,lane)
|
||||
receipt.update(status='reader_and_delivery_verified',exact_namespace_binding_verified=True,
|
||||
exact_policy_and_ttl_verified=True,wrong_service_account_denied=True,wrong_audience_denied=True,
|
||||
wrong_namespace_denied=True,
|
||||
metadata_sibling_escrow_and_token_create_denied=True,kv_write_denied=True,
|
||||
eso_delivery_equality_verified=True,temporary_reader_revoked=True,secret_values_exported=False,
|
||||
source_ccr=ccr['id'],ccr_sha256=expected)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=setup.PROFILES)
|
||||
p.add_argument('--ccr-sha256',required=True);p.add_argument('--kubeconfig',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'status':'failed','source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
try:verify(a.action,a.ccr_sha256,r,a.kubeconfig)
|
||||
except Exception:r.update(status='failed',error='native_delivery_verification_failed_diagnostics_suppressed')
|
||||
finally:
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
return int(r['status']=='failed')
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended independent-key recovery of the new data-bearing off-host bundle."""
|
||||
import argparse
|
||||
from datetime import datetime,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def load(name,path=None):
|
||||
spec=importlib.util.spec_from_file_location(name,path or HERE/(name+'.py'))
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
|
||||
|
||||
|
||||
bundle=load('identity-bundle');pg=load('check-privacyidea-database')
|
||||
custody=load('sops_private',HERE.parents[1]/'sso-mfa/bootstrap/sops-private.py')
|
||||
|
||||
|
||||
def verify(transfer,keyfd,receipt):
|
||||
if (transfer.get('schema')!='netkingdom.identity-offsite.v1' or transfer.get('status')!='readback_verified'
|
||||
or transfer.get('independent_owner_retrieval_verified') is not True
|
||||
or transfer.get('ciphertext_bytes',0)>bundle.LIMIT):raise bundle.Refused()
|
||||
path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT)
|
||||
with path.open('rb') as source:digest=hashlib.file_digest(source,'sha256').hexdigest()
|
||||
if path.stat().st_size!=transfer['ciphertext_bytes'] or digest!=transfer['ciphertext_sha256']:raise bundle.Refused()
|
||||
derived=pg.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()
|
||||
if derived!=bundle.profile()['public_recipient'].encode():raise bundle.Refused()
|
||||
receipt.update(ciphertext_sha256=digest,artifact=transfer['artifact'],registered_recipient_match=True)
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-identity-restore-',dir='/tmp') as temporary:
|
||||
work=Path(temporary);env={'PATH':os.environ.get('PATH',os.defpath),'HOME':temporary,'XDG_CONFIG_HOME':temporary}
|
||||
wrong=work/'wrong.key';pg.run(['age-keygen','-o',str(wrong)],env=env)
|
||||
negative=subprocess.run(['age','-d','-i',str(wrong),str(path)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env,timeout=120)
|
||||
if negative.returncode==0:raise bundle.Refused()
|
||||
receipt['wrong_key_refused']=True
|
||||
plain=work/'archive.tar'
|
||||
with plain.open('xb') as output:
|
||||
result=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,
|
||||
stderr=subprocess.DEVNULL,env=env,pass_fds=(keyfd,),timeout=180)
|
||||
if result.returncode or plain.stat().st_size>bundle.LIMIT+4*1024*1024:raise bundle.Refused()
|
||||
restored=work/'restored';restored.mkdir(mode=0o700)
|
||||
manifest,checks=bundle.restore(plain,restored)
|
||||
receipt.update(checks=checks,manifest_file_count=len(manifest['files']),all_manifest_hashes_verified=True,
|
||||
profile_sha256=manifest['profile_sha256'])
|
||||
container='nk49-identity-db-'+uuid.uuid4().hex
|
||||
try:
|
||||
receipt['database_restore']=pg.restore(restored/'databases/privacyidea.dump',container)
|
||||
finally:
|
||||
pg.run(['docker','rm','--force','--volumes',container])
|
||||
absent=subprocess.run(['docker','container','inspect',container],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=15)
|
||||
pg.run(['docker','info','--format','{{.ServerVersion}}'])
|
||||
if absent.returncode==0:raise bundle.Refused()
|
||||
receipt['container_cleanup_verified']=True
|
||||
if work.exists():raise bundle.Refused()
|
||||
receipt.update(temporary_plaintext_cleanup_verified=True,status='identity_archive_verified',
|
||||
full_service_authentication_proven=False,existing_token_decryption_proven=False,audit_signature_continuity_proven=False)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--transfer-receipt',required=True);p.add_argument('--receipt',required=True)
|
||||
a=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
r={'schema':'netkingdom.identity-recovery.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),
|
||||
'production_contacted':False,'secret_values_exported':False,'container_cleanup_verified':False,
|
||||
'temporary_plaintext_cleanup_verified':False,'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
||||
keyfd=None
|
||||
try:
|
||||
transfer=json.loads(Path(a.transfer_receipt).read_text());keyfd=custody.private_key_fd()
|
||||
verify(transfer,keyfd,r)
|
||||
except Exception:r.update(status='failed',error='identity_recovery_failed_private_diagnostics_suppressed')
|
||||
finally:
|
||||
if keyfd is not None:os.close(keyfd)
|
||||
r['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
|
||||
if r['status']=='identity_archive_verified':
|
||||
print('OK: off-host identity bundle decrypted; complete manifest, key pairs, SQLite integrity and PostgreSQL restore passed; disposable data removed. Service authentication remains separate acceptance.')
|
||||
return 0
|
||||
print('ERROR: identity recovery failed; review safe receipt. Private diagnostics suppressed.');return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,custody.interrupted)
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare/run the exact reviewed T18 candidate on disposable synthetic material.
|
||||
|
||||
Default --plan performs no Docker calls and generates no key/material. Execution
|
||||
requires the reviewed fixture manifest hash and source closure. This runner calls
|
||||
the candidate itself; hooks only pause or inject a post-response transport fault.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
MANIFEST=ROOT/'tests/isolated-keycape-native-fixture-manifest.json'
|
||||
ISSUER='https://nk49-fixture.invalid'
|
||||
CASES=('positive','hard-parent-loss','deadline','nonzero-after-accept')
|
||||
|
||||
|
||||
def load(name,path):
|
||||
spec=importlib.util.spec_from_file_location(name,path)
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
s=load('session',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
|
||||
|
||||
def require(condition):
|
||||
if not condition:raise RuntimeError('synthetic_fixture_refused')
|
||||
|
||||
|
||||
def reviewed_manifest(expected):
|
||||
raw=MANIFEST.read_bytes();require(hashlib.sha256(raw).hexdigest()==expected)
|
||||
manifest=json.loads(raw)
|
||||
for name,digest in manifest['source_sha256'].items():
|
||||
require(hashlib.sha256((ROOT/name).read_bytes()).hexdigest()==digest)
|
||||
return manifest
|
||||
|
||||
|
||||
def private_write(path,value):
|
||||
path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(value)
|
||||
|
||||
|
||||
def materials(root):
|
||||
"""Reuse the existing full archive fixture factory, no real archive accepted."""
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes,serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
import yaml
|
||||
existing=load('existing_bundle_fixture',ROOT/'tests/test_identity_bundle.py')
|
||||
payload=existing.stage.__wrapped__(root)
|
||||
config={'issuer':ISSUER,'port':8080,'privateKeyPem':'/etc/keycape/key.pem','environment':'dev','tokenLifetime':'5m',
|
||||
'lldap':{'url':'ldap://127.0.0.1:1','bindDN':'cn=fixture','bindPW':'synthetic-only','baseDN':'dc=fixture'},
|
||||
'authelia':{'baseURL':'http://127.0.0.1:1','clientId':'synthetic','clientSecret':'synthetic','redirectURI':ISSUER+'/authorize/callback'},
|
||||
'privacyidea':{'baseURL':'http://127.0.0.1:1'},
|
||||
'clients':[{'clientId':'nk49-fixture-consumer','clientType':'confidential','audience':'nk49-fixture',
|
||||
'grantTypes':['client_credentials'],'allowedScopes':['fixture:read'],'secretRef':'env:KEYCAPE_FIXTURE_CLIENT_SECRET',
|
||||
'serviceSubject':'service:nk49-fixture','tenant':'tenant:fixture','roles':['fixture-reader']}]+
|
||||
[{'clientId':name,'displayName':name,'clientType':'public','grantTypes':['authorization_code'],
|
||||
'redirectUris':['https://nk49-fixture.invalid/callback'],'allowedScopes':['openid'],'mfaRequired':True}
|
||||
for name in ('vergabe-demo-company','user-engine-portal')]}
|
||||
secret_path=payload/'resources/sso/secret/keycape-config.json';secret=json.loads(secret_path.read_text())
|
||||
secret['data']['config.yaml']=base64.b64encode(yaml.safe_dump(config).encode()).decode();secret_path.write_text(json.dumps(secret))
|
||||
client_path=payload/'resources/sso/secret/keycape-rapp-qonto-client.json';client=json.loads(client_path.read_text())
|
||||
client['data']={'client-secret':base64.b64encode(b'synthetic-fixture-client-secret').decode()};client_path.write_text(json.dumps(client))
|
||||
factor_path=payload/'resources/sso/secret/keycape-factor-read.json';factor=json.loads(factor_path.read_text())
|
||||
factor['data']={'admin-token':base64.b64encode(b'synthetic-unused-factor-reader').decode()};factor_path.write_text(json.dumps(factor))
|
||||
dep_path=payload/'resources/sso/deployment/keycape.json';dep=json.loads(dep_path.read_text())
|
||||
dep['spec']={'template':{'spec':{'containers':[{'name':'keycape','image':s.adapter.IMAGE,'env':[
|
||||
{'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'},
|
||||
{'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'},
|
||||
{'name':'KEYCAPE_FIXTURE_CLIENT_SECRET','valueFrom':{'secretKeyRef':{'name':'keycape-rapp-qonto-client','key':'client-secret'}}}]}]}}};dep_path.write_text(json.dumps(dep))
|
||||
policy={'Revision':1,'Modes':{k:'mandatory' for k in ('vergabe-demo-company','user-engine-portal')},'History':[
|
||||
{'reference':'synthetic-retained-receipt','actor':'service:fixture','client':'user-engine-portal','before':'mandatory','after':'mandatory','revision':1,'at':1}]}
|
||||
# JSON tags in the owner store are lowercase; retain an existing lock inode
|
||||
# representation as part of the archived tree, never synthesize it after import.
|
||||
policy={k.lower():v for k,v in policy.items()};(payload/'data/keycape/policy.json').write_text(json.dumps(policy))
|
||||
private_write(payload/'data/keycape/policy.json.lock',b'')
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'nk49-fixture.invalid')]);now=datetime.now(timezone.utc)
|
||||
cert=(x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now-timedelta(minutes=5)).not_valid_after(now+timedelta(days=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName('nk49-fixture.invalid')]),critical=False)
|
||||
.add_extension(x509.BasicConstraints(ca=True,path_length=None),critical=True).sign(key,hashes.SHA256()))
|
||||
tls={'/recovery-tls/server.pem':cert.public_bytes(serialization.Encoding.PEM),
|
||||
'/recovery-tls/server-key.pem':key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()),
|
||||
'/recovery-tls/trust.pem':cert.public_bytes(serialization.Encoding.PEM)}
|
||||
for path,value in tls.items():private_write(root/Path(path).name,value)
|
||||
age=root/'synthetic-age.key';s.run(['age-keygen','-o',str(age)])
|
||||
recipient=s.run(['age-keygen','-y',str(age)]).decode().strip();profile=s.bundle.profile();profile['public_recipient']=recipient
|
||||
s.bundle.profile=lambda:profile
|
||||
artifact=root/'net-kingdom-identity-20261016T063159Z-0123456789ab.tar.age';s.bundle.seal(payload,artifact,{'synthetic_native_keycape_fixture':True})
|
||||
raw=s.run(['age','-d','-i',str(age),str(artifact)],max_output=s.bundle.LIMIT)
|
||||
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:manifest=json.load(archive.extractfile('manifest.json'))
|
||||
transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share',
|
||||
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':artifact.name,'ciphertext_bytes':artifact.stat().st_size,
|
||||
'ciphertext_sha256':hashlib.sha256(artifact.read_bytes()).hexdigest(),'etag':'"synthetic"','fetched_path':str(artifact),
|
||||
'independent_owner_retrieval_verified':True}
|
||||
# Derive public executable identity only from the exact cached admitted image.
|
||||
probe=s.plan_session();name=probe['public_image_probe']
|
||||
try:
|
||||
s.run(['docker','create','--pull=never','--name',name,'--network','none','--read-only','--label',s.adapter.LABEL+'='+probe['run_id'],s.adapter.IMAGE])
|
||||
binary=s.adapter.container_file(name,'/keycape')
|
||||
finally:s.adapter.cleanup({**probe,'containers':[name]}, {})
|
||||
admission={'issuer':ISSUER,'ciphertext_sha256':transfer['ciphertext_sha256'],
|
||||
'manifest_sha256':hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest(),
|
||||
'verifier_sha256':hashlib.sha256(binary).hexdigest(),'trust_sha256':hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest(),
|
||||
'tls_cert_sha256':hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest(),
|
||||
'consumer':{'client_id':'nk49-fixture-consumer','secret_env':'KEYCAPE_FIXTURE_CLIENT_SECRET','audience':'nk49-fixture','scope':'fixture:read',
|
||||
'subject':'service:nk49-fixture','tenant':'tenant:fixture','roles':'fixture-reader','deny_scope':'fixture:write'}}
|
||||
private_write(root/'fixture.json',json.dumps({'schema':'nk49.generated-keycape-fixture.v1','transfer':transfer,'admission':admission,'recipient':recipient}).encode())
|
||||
|
||||
|
||||
def worker(root,case):
|
||||
execution=s.isolate_execution();execution['deadline']=time.monotonic()+(20 if case=='deadline' else 120)
|
||||
data=json.loads((root/'fixture.json').read_text());require(data['schema']=='nk49.generated-keycape-fixture.v1')
|
||||
profile=s.bundle.profile();profile['public_recipient']=data['recipient'];s.bundle.profile=lambda:profile
|
||||
s.local.verify.custody.private_key_fd=lambda:os.open(root/'synthetic-age.key',os.O_RDONLY|os.O_NOFOLLOW)
|
||||
tls={'/recovery-tls/'+name:(root/name).read_bytes() for name in ('server.pem','server-key.pem','trust.pem')}
|
||||
receipt={};output=root/(case+'.json');checkpoint=lambda r:output.write_text(json.dumps(r))
|
||||
provision=s.provision
|
||||
def paused(*args):
|
||||
provision(*args)
|
||||
if case in ('hard-parent-loss','deadline'):
|
||||
private_write(root/(case+'.barrier'),b'private-import-and-default-launch-complete')
|
||||
time.sleep(150)
|
||||
s.provision=paused
|
||||
if case=='nonzero-after-accept':
|
||||
original=s.adapter.subprocess.run;injected=False
|
||||
def fault(argv,**kwargs):
|
||||
nonlocal injected
|
||||
result=original(argv,**kwargs)
|
||||
if argv[:3]==['docker','--host',s.adapter.ENDPOINT] and 'run' in argv and not injected and result.returncode==0:
|
||||
injected=True
|
||||
return subprocess.CompletedProcess(argv,1,b'',b'')
|
||||
return result
|
||||
s.adapter.subprocess.run=fault
|
||||
try:s.execute(data['transfer'],data['admission'],tls,receipt,execution,checkpoint=checkpoint)
|
||||
except BaseException:receipt['status']='failed'
|
||||
finally:output.write_text(json.dumps(receipt))
|
||||
return int(receipt.get('status')!='isolated_keycape_native_registration_checked')
|
||||
|
||||
|
||||
def run_cases(root,summary,cases):
|
||||
for case in cases:
|
||||
child=subprocess.Popen([sys.executable,str(Path(__file__).resolve()),'--worker-root',str(root),'--worker-case',case,
|
||||
'--execute-reviewed-fixture','--expected-manifest-sha256',summary['manifest_sha256']],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
end=time.monotonic()+150
|
||||
if case=='hard-parent-loss':
|
||||
while not (root/(case+'.barrier')).exists() and child.poll() is None and time.monotonic()<end:time.sleep(.05)
|
||||
require((root/(case+'.barrier')).exists())
|
||||
execution=json.loads((root/(case+'.json')).read_text())['execution_binding'];require(execution['pid']==child.pid)
|
||||
fd=os.pidfd_open(child.pid)
|
||||
try:signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
finally:os.close(fd)
|
||||
child.wait(timeout=max(1,end-time.monotonic()))
|
||||
receipt=json.loads((root/(case+'.json')).read_text())
|
||||
summary['receipts'][case]=receipt
|
||||
if case=='deadline':require((root/(case+'.barrier')).exists())
|
||||
if case=='positive':
|
||||
require(receipt.get('status')=='isolated_keycape_native_registration_checked' and receipt.get('container_cleanup_verified') and receipt.get('temporary_plaintext_cleanup_verified'))
|
||||
require(receipt.get('native_service_registration_verified') and receipt.get('original_public_signing_key_in_live_jwks'))
|
||||
summary['actual_issuer_original_hostname_tls_verified']=True
|
||||
else:
|
||||
require(receipt.get('status')!='isolated_keycape_native_registration_checked')
|
||||
outcome=Path(receipt['reaper_outcome_path'])
|
||||
while not outcome.exists() and time.monotonic()<end:time.sleep(.05)
|
||||
require(outcome.exists());cleanup=json.loads(outcome.read_text())
|
||||
require(cleanup.get('execution_quiescence_verified') and cleanup.get('temporary_plaintext_cleanup_verified'))
|
||||
if case=='nonzero-after-accept':require(cleanup.get('status')=='bounded_cleanup_uncertain' and cleanup.get('in_flight_daemon_request_uncertain'))
|
||||
else:require(cleanup.get('status')=='bounded_cleanup_verified')
|
||||
summary['cases'][case]='passed'
|
||||
finally:
|
||||
if child.poll() is None:
|
||||
fd=os.pidfd_open(child.pid)
|
||||
try:signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
finally:os.close(fd)
|
||||
child.wait(timeout=5)
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--plan',action='store_true')
|
||||
p.add_argument('--execute-reviewed-fixture',action='store_true');p.add_argument('--expected-manifest-sha256')
|
||||
p.add_argument('--worker-root');p.add_argument('--worker-case',choices=CASES);p.add_argument('--case',choices=(*CASES,'all'),default='positive');args=p.parse_args()
|
||||
if not args.execute_reviewed_fixture:
|
||||
print(MANIFEST.read_text());return 0
|
||||
require(not args.plan);manifest=reviewed_manifest(args.expected_manifest_sha256);os.umask(0o077)
|
||||
if args.worker_root:
|
||||
root=Path(args.worker_root);require(root.parent==Path('/dev/shm') and root.name.startswith('nk49-keycape-fixture-'))
|
||||
s.bundle.private(root,True);return worker(root,args.worker_case)
|
||||
require(not args.worker_case)
|
||||
s.local.preflight();s.run(['docker','image','inspect',s.adapter.IMAGE,'--format','{{.Id}}'])
|
||||
safe=Path(tempfile.mkdtemp(prefix='nk49-keycape-native-fixture-review-',dir='/tmp'))
|
||||
summary={'schema':'nk49.synthetic-keycape-native-fixture.v1','status':'failed','manifest_sha256':args.expected_manifest_sha256,
|
||||
'cases':{},'receipts':{},'actual_credentials_used':False,'actual_recovery_proven':False,'docker_executed':True}
|
||||
print('Safe synthetic fixture result: '+str(safe/'result.json'),flush=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='nk49-keycape-fixture-',dir='/dev/shm') as temporary:
|
||||
root=Path(temporary);materials(root);run_cases(root,summary,CASES if args.case=='all' else (args.case,))
|
||||
summary['status']='synthetic_native_fixture_passed'
|
||||
finally:(safe/'result.json').write_text(json.dumps(summary,indent=2)+'\n')
|
||||
print('Safe synthetic fixture result: '+str(safe/'result.json'))
|
||||
return int(summary['status']!='synthetic_native_fixture_passed')
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location('fixture_runner',ROOT/'tests/run_isolated_keycape_native_fixture.py')
|
||||
f=importlib.util.module_from_spec(spec);spec.loader.exec_module(f)
|
||||
|
||||
class FixturePreparationTests(unittest.TestCase):
|
||||
def test_default_plan_neither_runs_docker_nor_generates_materials(self):
|
||||
output=io.StringIO()
|
||||
with patch.object(sys,'argv',['fixture','--plan']),patch.object(f.s,'run') as run,patch.object(f,'materials') as materials,patch.object(f.s.local,'preflight') as preflight,contextlib.redirect_stdout(output):
|
||||
self.assertEqual(f.main(),0)
|
||||
run.assert_not_called();materials.assert_not_called();preflight.assert_not_called()
|
||||
manifest=json.loads(output.getvalue())
|
||||
self.assertEqual(manifest['status'],'prepared_not_executed_not_approved')
|
||||
self.assertEqual(manifest['fixed_endpoint'],'unix:///var/run/docker.sock')
|
||||
def test_wrong_reviewed_manifest_hash_stops_before_preflight_or_generation(self):
|
||||
with patch.object(sys,'argv',['fixture','--execute-reviewed-fixture','--expected-manifest-sha256','0'*64]),patch.object(f.s,'run') as run,patch.object(f,'materials') as materials,patch.object(f.s.local,'preflight') as preflight:
|
||||
with self.assertRaises(RuntimeError):f.main()
|
||||
run.assert_not_called();materials.assert_not_called();preflight.assert_not_called()
|
||||
def test_manifest_binds_current_candidate_and_existing_fixture_factory(self):
|
||||
digest=hashlib.sha256(f.MANIFEST.read_bytes()).hexdigest()
|
||||
manifest=f.reviewed_manifest(digest)
|
||||
self.assertIn('tests/test_identity_bundle.py',manifest['source_sha256'])
|
||||
self.assertIn('tools/bootstrap-recovery/isolated-keycape-session.py',manifest['source_sha256'])
|
||||
self.assertIn('sso-mfa/k8s/privacyidea/key-custody-remote.py',manifest['source_sha256'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape.py'
|
||||
spec=importlib.util.spec_from_file_location('adapter',SOURCE)
|
||||
a=importlib.util.module_from_spec(spec);spec.loader.exec_module(a)
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name)
|
||||
config={'data':{k:base64.b64encode(v).decode() for k,v in {'config.yaml':b'issuer: https://issuer.example','key.pem':b'fixture-key'}.items()}}
|
||||
self.values={'resources/sso/secret/keycape-config.json':json.dumps(config).encode(),
|
||||
'resources/sso/deployment/keycape.json':json.dumps({'spec':{'template':{'spec':{'containers':[{'name':'keycape','image':a.IMAGE,'env':[
|
||||
{'name':'KEYCAPE_CONFIG','value':'/etc/keycape/config.yaml'},
|
||||
{'name':'KEYCAPE_POLICY_PATH','value':'/var/lib/keycape-policy/policy.json'},
|
||||
{'name':'KEYCAPE_ORIGINAL_SECRET','valueFrom':{'secretKeyRef':{'name':'client','key':'secret'}}}]}]}}}}).encode(),
|
||||
'resources/sso/secret/client.json':json.dumps({'data':{'secret':base64.b64encode(b'fixture-private').decode()}}).encode(),
|
||||
'data/keycape/policy.json':b'{}','data/keycape/receipts/one.json':b'{"original":true}'}
|
||||
for name,value in self.values.items():
|
||||
p=self.root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(value);p.chmod(0o600)
|
||||
self.manifest={'profile':{'components':[{'namespace':'sso','deployment':'keycape','secret_refs':['keycape-config','client'],'configmap_refs':[]}]},
|
||||
'files':[{'path':k,'bytes':len(v),'sha256':hashlib.sha256(v).hexdigest()} for k,v in self.values.items()]}
|
||||
def payload(self):
|
||||
digest=hashlib.sha256(json.dumps(self.manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
return a.private_payload(self.root,self.manifest,digest)
|
||||
def test_original_state_and_secret_delivery_retained(self):
|
||||
p=self.payload();self.assertEqual(p['env']['KEYCAPE_ORIGINAL_SECRET'],'fixture-private')
|
||||
self.assertEqual(base64.b64decode(p['files']['/var/lib/keycape-policy/receipts/one.json']),b'{"original":true}')
|
||||
def test_archive_digest_refused(self):
|
||||
with self.assertRaises(a.Refused):a.private_payload(self.root,self.manifest,'0'*64)
|
||||
def test_changed_file_refused(self):
|
||||
(self.root/'data/keycape/policy.json').write_text('{"changed":true}')
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
def test_symlink_refused(self):
|
||||
p=self.root/'data/keycape/policy.json';p.unlink();p.symlink_to('/etc/hostname')
|
||||
with self.assertRaises(Exception):self.payload()
|
||||
def test_duplicate_manifest_refused(self):
|
||||
self.manifest['files'].append(self.manifest['files'][0])
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
def test_image_mismatch_refused(self):
|
||||
d=json.loads(self.values['resources/sso/deployment/keycape.json']);d['spec']['template']['spec']['containers'][0]['image']='keycape:latest'
|
||||
raw=json.dumps(d).encode();p=self.root/'resources/sso/deployment/keycape.json';p.write_bytes(raw)
|
||||
for row in self.manifest['files']:
|
||||
if row['path'].endswith('/deployment/keycape.json'):row.update(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest())
|
||||
with self.assertRaises(a.Refused):self.payload()
|
||||
|
||||
class NativeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
self.key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption())
|
||||
self.cfg=b'issuer: https://issuer.example\nclients:\n- clientId: existing\n clientType: confidential\n secretRef: env:KEYCAPE_ORIGINAL_SECRET\n grantTypes: [client_credentials]\n'
|
||||
self.files={'/etc/keycape/config.yaml':self.cfg,'/etc/keycape/key.pem':self.key,'/var/lib/keycape-policy/policy.json':b'{}'}
|
||||
self.payload={'env':{'KEYCAPE_ORIGINAL_SECRET':'fixture-private'},'files':{k:base64.b64encode(v).decode() for k,v in self.files.items()}}
|
||||
self.admission={'issuer':'https://issuer.example','verifier_sha256':hashlib.sha256(b'binary').hexdigest(),
|
||||
'trust_path':'/trust.pem','trust_sha256':hashlib.sha256(b'original-trust').hexdigest(),
|
||||
'consumer':{'client_id':'existing','secret_env':'KEYCAPE_ORIGINAL_SECRET','scope':'read','audience':'consumer',
|
||||
'subject':'service:existing','tenant':'tenant:platform','roles':'reader','deny_scope':'write'}}
|
||||
self.session={'issuer':'isolated','volumes':{x:{'target':x} for x in ('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')}}
|
||||
self.output=''.join('PASS '+x+'\n' for x in ['discovery and endpoint origin','exchange and JWKS signature for granted scopes','access-token claim bindings','principal_type is service','sub matches the registration','tenant matches the registration','roles present','granted scope carries no excess grant','predecessor secret refused','excess scope refused: write'])
|
||||
def file(self,container,path):
|
||||
return {**self.files,'/etc/hosts':b'127.0.0.1 issuer.example\n','/keycape':b'binary','/trust.pem':b'original-trust'}[path]
|
||||
def command(self,argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem']).encode()
|
||||
output=io.BytesIO()
|
||||
with tarfile.open(fileobj=output,mode='w') as t:
|
||||
m=tarfile.TarInfo('policy.json');m.size=2;t.addfile(m,io.BytesIO(b'{}'))
|
||||
return output.getvalue()
|
||||
def verify(self,code=0,output=None):
|
||||
with patch.object(a,'check_jwks'),patch.object(a,'runtime_file',return_value=b'original-trust'),patch.object(a,'runtime_files',side_effect=lambda session,paths=(),tree=False:({k:v for k,v in self.files.items() if k.startswith('/var/lib/keycape-policy/')} if tree else dict(self.files))),patch.object(a,'container_file',side_effect=self.file),patch.object(a,'command',side_effect=self.command),patch.object(a.subprocess,'run',return_value=subprocess.CompletedProcess([],code,b'',(self.output if output is None else output).encode())) as run:
|
||||
result=a.run_verifier(self.session,self.admission,self.payload)
|
||||
argv=run.call_args.args[0]
|
||||
self.assertNotIn('fixture-private',' '.join(argv))
|
||||
self.assertIn('-previous-secret-env',argv)
|
||||
return result
|
||||
def test_original_registration_and_unrelated_secret(self):
|
||||
r=self.verify();self.assertTrue(r['wrong_unrelated_secret_explicitly_refused']);self.assertNotIn('fixture-private',json.dumps(r))
|
||||
def test_transport_failure_never_counts_as_wrong_secret(self):
|
||||
with self.assertRaises(a.Refused):self.verify(1,'FAIL discovery and endpoint origin: TLS failure\n')
|
||||
def test_missing_explicit_rejection_never_counts(self):
|
||||
with self.assertRaises(a.Refused):self.verify(0,self.output.replace('PASS predecessor secret refused\n',''))
|
||||
def test_fail_line_refused_even_zero_exit(self):
|
||||
with self.assertRaises(a.Refused):self.verify(0,self.output+'FAIL transport\n')
|
||||
def test_security_environment_override_refused(self):
|
||||
original=self.command
|
||||
def altered(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return json.dumps(['KEYCAPE_ORIGINAL_SECRET=fixture-private','SSL_CERT_FILE=/trust.pem','SSL_CERT_DIR=/unreviewed']).encode()
|
||||
return original(argv,**kwargs)
|
||||
with patch.object(self,'command',side_effect=altered):
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_http_issuer_refused(self):
|
||||
self.admission['issuer']='http://issuer.example'
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_changed_trust_refused(self):
|
||||
self.admission['trust_sha256']='0'*64
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
def test_config_drift_refused(self):
|
||||
self.payload['files']['/etc/keycape/config.yaml']=base64.b64encode(b'changed').decode()
|
||||
with self.assertRaises(a.Refused):self.verify()
|
||||
|
||||
class PublicReadinessTransportTests(unittest.TestCase):
|
||||
def test_provider_probe_failure_is_closed_false_data_not_failed_docker_exec(self):
|
||||
output=io.StringIO()
|
||||
with patch('sys.stdin',io.StringIO('{}')),patch('sys.stdout',output):
|
||||
exec(compile(a.JWKS_TRANSPORT,'<public-readiness>','exec',optimize=2),{})
|
||||
self.assertEqual(json.loads(output.getvalue()),{'original_public_signing_key_in_live_jwks':False})
|
||||
|
||||
class BoundaryTests(unittest.TestCase):
|
||||
def test_remote_docker_refused_before_info(self):
|
||||
session={'run_id':'a'*32,'containers':['nk49-kc-'+('a'*32)+'-worker'],'worker':'nk49-kc-'+('a'*32)+'-worker','issuer':'nk49-kc-'+('a'*32)+'-worker'}
|
||||
with patch.object(a,'command',return_value=b'[{"Endpoints":{"docker":{"Host":"tcp://remote:2375"}}}]') as command:
|
||||
with self.assertRaises(a.Refused):a.inspect_session(session)
|
||||
self.assertEqual(command.call_count,1)
|
||||
def test_failed_verifier_cleanup_is_attempted(self):
|
||||
receipt={};admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}}
|
||||
with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',side_effect=a.Refused),patch.object(a,'cleanup') as cleanup:
|
||||
with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt)
|
||||
cleanup.assert_called_once();self.assertTrue(receipt['container_cleanup_verified']);self.assertEqual(receipt['status'],'failed')
|
||||
|
||||
def test_cleanup_failure_overrides_success(self):
|
||||
admission={'session':{},'ciphertext_sha256':'a'*64,'manifest_sha256':'b'*64,'verifier_sha256':'c'*64,'trust_sha256':'d'*64,'consumer':{x:'yes' for x in ('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')}}
|
||||
result={x:True for x in ('native_service_registration_verified','wrong_unrelated_secret_explicitly_refused','denied_scopes_refused','original_config_registry_policy_receipts_equal','original_https_issuer_and_trust_preserved','original_public_signing_key_in_live_jwks')}
|
||||
result['public_signing_spki_sha256']='e'*64;receipt={}
|
||||
with patch.object(a,'inspect_session'),patch.object(a,'private_payload',return_value={}),patch.object(a,'run_verifier',return_value=result),patch.object(a,'cleanup',side_effect=a.Refused):
|
||||
with self.assertRaises(a.Refused):a.native_checks(Path('/unused'),{},admission,receipt)
|
||||
self.assertEqual(receipt['status'],'failed');self.assertFalse(receipt['container_cleanup_verified'])
|
||||
|
||||
class ContainmentTests(unittest.TestCase):
|
||||
def inspect(self,overrides=None,launch=None):
|
||||
name='nk49-kc-'+('a'*32)+'-issuer'
|
||||
session={'run_id':'a'*32,'containers':[name],'issuer':name,'worker':name,'images':{name:a.IMAGE}}
|
||||
host={'NetworkMode':'none','ReadonlyRootfs':True,'Privileged':False,'CapDrop':['ALL'],'SecurityOpt':['no-new-privileges']}
|
||||
host.update(overrides or {})
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['context','inspect']:return b'[{"Endpoints":{"docker":{"Host":"unix:///var/run/docker.sock"}}}]'
|
||||
if argv[1]=='info':return b'{"ID":"74f93783-6cef-43a8-a3c4-f62586cd3bc1","Name":"bnt-lap001","OSType":"linux"}'
|
||||
if argv[-1]=='{{.Id}}':return b'anchor-id'
|
||||
if '"path"' in argv[-1]:return json.dumps(launch or {'entrypoint':['/keycape'],'cmd':None,'path':'/keycape','args':[]}).encode()
|
||||
return json.dumps({'host':host,'mounts':[],'labels':{a.LABEL:'a'*32},'image':a.IMAGE,'user':'1000:1000'}).encode()
|
||||
with patch.object(a,'command',side_effect=command),patch.dict('os.environ',{'DOCKER_HOST':'unix:///var/run/docker.sock'}):a.inspect_session(session)
|
||||
def test_minimal_network_none_admitted(self):self.inspect()
|
||||
def test_alternate_same_image_command_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect(launch={'entrypoint':['/keycape'],'cmd':['check-config'],'path':'/keycape','args':['check-config']})
|
||||
def test_cap_add_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'CapAdd':['NET_ADMIN']})
|
||||
def test_devices_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'Devices':[{'PathOnHost':'/dev/mem'}]})
|
||||
def test_device_requests_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'DeviceRequests':[{'Driver':'nvidia'}]})
|
||||
def test_host_namespace_refused(self):
|
||||
for field in ('PidMode','IpcMode','UTSMode','UsernsMode','CgroupnsMode'):
|
||||
with self.subTest(field=field),self.assertRaises(a.Refused):self.inspect({field:'host'})
|
||||
def test_security_override_refused(self):
|
||||
with self.assertRaises(a.Refused):self.inspect({'SecurityOpt':['no-new-privileges','seccomp=unconfined']})
|
||||
|
||||
class CleanupTests(unittest.TestCase):
|
||||
def test_partial_cleanup_attempts_every_owned_container(self):
|
||||
session={'run_id':'a'*32,'containers':['first','second','third']};receipt={}
|
||||
removed=[]
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return ('a'*32).encode()
|
||||
if argv[1]=='rm':
|
||||
removed.append(argv[-1])
|
||||
if argv[-1]=='third':raise a.Refused()
|
||||
return b''
|
||||
if argv[1:3]==['container','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$')
|
||||
return (name+'\n').encode() if name=='third' or name not in removed else b''
|
||||
return b''
|
||||
with patch.object(a,'command',side_effect=command):
|
||||
with self.assertRaises(a.Refused):a.cleanup(session,receipt)
|
||||
self.assertEqual(removed,['third','second','first'])
|
||||
self.assertEqual([r['status'] for r in receipt['container_cleanup_obligations']],['still_present','absence_verified','absence_verified'])
|
||||
def test_ownership_mismatch_does_not_remove_and_continues(self):
|
||||
session={'run_id':'a'*32,'containers':['first','second']};receipt={};removed=[]
|
||||
def command(argv,**kwargs):
|
||||
if argv[1:3]==['container','inspect']:return (('b' if argv[3]=='second' else 'a')*32).encode()
|
||||
if argv[1]=='rm':removed.append(argv[-1])
|
||||
if argv[1:3]==['container','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^/')).removeprefix('name=^/').removesuffix('$')
|
||||
return (name+'\n').encode() if name not in removed else b''
|
||||
return b''
|
||||
with patch.object(a,'command',side_effect=command):
|
||||
with self.assertRaises(a.Refused):a.cleanup(session,receipt)
|
||||
self.assertEqual(removed,['first'])
|
||||
self.assertEqual(receipt['container_cleanup_obligations'][0]['status'],'ownership_mismatch_not_removed')
|
||||
def test_inspect_failure_retains_uncertain_obligation(self):
|
||||
receipt={}
|
||||
with patch.object(a,'command',side_effect=a.Refused):
|
||||
with self.assertRaises(a.Refused):a.cleanup({'run_id':'a'*32,'containers':['exact-owned-name']},receipt)
|
||||
self.assertEqual(receipt['container_cleanup_obligations'],[{'container':'exact-owned-name','status':'unverified'}])
|
||||
|
||||
class JWKSTests(unittest.TestCase):
|
||||
def execute(self,n='123',endpoint='https://issuer.example/jwks',resolved='127.0.0.1',extra=False,kid='key-1'):
|
||||
request={'issuer':'https://issuer.example','trust':'public-certificate','n':'123','e':'65537'}
|
||||
def enc(i):return base64.urlsafe_b64encode(int(i).to_bytes((int(i).bit_length()+7)//8,'big')).decode().rstrip('=')
|
||||
responses=[{'issuer':'https://issuer.example','jwks_uri':endpoint},{'keys':[{'kty':'RSA','kid':kid,'use':'sig','alg':'RS256','n':enc(n),'e':enc(65537)}]}]
|
||||
if extra:responses[1]['keys'].append(dict(responses[1]['keys'][0],kid='key-2'))
|
||||
class Response:
|
||||
def __init__(self,value):self.value=value
|
||||
def __enter__(self):return self
|
||||
def __exit__(self,*args):pass
|
||||
def read(self,*args):return json.dumps(self.value).encode()
|
||||
class Opener:
|
||||
def open(self,*args,**kwargs):return Response(responses.pop(0))
|
||||
with patch('sys.stdin',io.StringIO(json.dumps(request))),patch('sys.stdout',io.StringIO()),patch('socket.getaddrinfo',return_value=[(2,1,6,'',(resolved,443))]),patch('ssl.create_default_context'),patch('urllib.request.build_opener',return_value=Opener()):
|
||||
exec(compile(a.JWKS_PROBE,'<jwks-probe>','exec',optimize=2),{})
|
||||
def test_exact_original_public_key(self):self.execute()
|
||||
def test_extra_signing_key_refused_under_optimization(self):
|
||||
with self.assertRaises(ValueError):self.execute(extra=True)
|
||||
def test_wrong_kid_refused_under_optimization(self):
|
||||
with self.assertRaises(ValueError):self.execute(kid='replacement')
|
||||
def test_wrong_live_signing_key_refused(self):
|
||||
with self.assertRaises(ValueError):self.execute(n='124')
|
||||
def test_cross_origin_jwks_refused(self):
|
||||
with self.assertRaises(ValueError):self.execute(endpoint='https://production.example/jwks')
|
||||
def test_production_dns_refused_before_fetch(self):
|
||||
with self.assertRaises(ValueError):self.execute(resolved='192.0.2.1')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'tools/bootstrap-recovery/isolated-keycape-session.py'
|
||||
spec=importlib.util.spec_from_file_location('session',SOURCE)
|
||||
s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s)
|
||||
|
||||
class SessionTests(unittest.TestCase):
|
||||
def test_tmpfs_plan_has_four_exact_original_mounts(self):
|
||||
session=s.plan_session()
|
||||
self.assertEqual({v['target'] for v in session['volumes'].values()},set(s.RUNROOTS))
|
||||
self.assertTrue(all(v['options']==s.VOLUME_OPTIONS for v in session['volumes'].values()))
|
||||
self.assertEqual(session['images'][session['issuer']],s.adapter.IMAGE)
|
||||
def test_non_tmpfs_driver_options_refused(self):
|
||||
session=s.plan_session();name,spec=next(iter(session['volumes'].items()))
|
||||
wrong=[{'Name':name,'Driver':'local','Scope':'local','Options':{'type':'none','device':'/host/data'},'Labels':{s.adapter.LABEL:session['run_id']}}]
|
||||
with patch.object(s,'run',return_value=json.dumps(wrong).encode()):
|
||||
with self.assertRaises(s.adapter.Refused):s.verify_volume(name,spec,session['run_id'])
|
||||
def test_owner_secret_is_not_docker_argv_and_import_is_after_inspection(self):
|
||||
session=s.plan_session();events=[]
|
||||
payload={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'original').decode()},'env':{'KEYCAPE_TEST_SECRET':'fixture-sensitive'}}
|
||||
def run(argv,**kwargs):
|
||||
events.append(('run',argv,kwargs))
|
||||
if argv[-1]=='{{.Id}}':return b'anchor'
|
||||
return b''
|
||||
with patch.object(s,'run',side_effect=run),patch.object(s,'verify_volume'),patch.object(s.adapter,'inspect_session',side_effect=lambda *args,**kwargs:events.append(('inspect',args,kwargs))):
|
||||
s.provision(session,payload,{'/recovery-tls/trust.pem':b'original-trust'},'https://issuer.example')
|
||||
joined=' '.join(' '.join(e[1]) for e in events if e[0]=='run')
|
||||
self.assertNotIn('fixture-sensitive',joined)
|
||||
private_index=next(i for i,e in enumerate(events) if e[0]=='run' and s.IMPORT in e[1])
|
||||
inspect_index=next(i for i,e in enumerate(events) if e[0]=='inspect')
|
||||
self.assertLess(inspect_index,private_index)
|
||||
issuer_call=next(e for e in events if e[0]=='run' and e[1][-1]==s.adapter.IMAGE)
|
||||
self.assertEqual(issuer_call[2]['env']['KEYCAPE_TEST_SECRET'],'fixture-sensitive')
|
||||
self.assertIn('KEYCAPE_TEST_SECRET',issuer_call[1])
|
||||
self.assertNotIn('--entrypoint',issuer_call[1])
|
||||
def test_non_original_https_port_refused_before_any_mutation(self):
|
||||
with patch.object(s,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.provision(s.plan_session(),{'files':{},'env':{}},{},'http://issuer.example')
|
||||
run.assert_not_called()
|
||||
def test_container_failure_still_attempts_each_volume_cleanup(self):
|
||||
session=s.plan_session();receipt={};removed=[]
|
||||
def run(argv,**kwargs):
|
||||
if argv[1:3]==['volume','rm']:removed.append(argv[-1])
|
||||
if argv[1:3]==['volume','ls']:
|
||||
name=next(x for x in argv if x.startswith('name=^')).removeprefix('name=^').removesuffix('$')
|
||||
return (name+'\n').encode() if name not in removed else b''
|
||||
return b''
|
||||
with patch.object(s.adapter,'cleanup',side_effect=s.adapter.Refused),patch.object(s,'verify_volume'),patch.object(s,'run',side_effect=run):
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_all(session,receipt)
|
||||
self.assertEqual(set(removed),set(session['volumes']))
|
||||
self.assertFalse(receipt['container_cleanup_verified'])
|
||||
self.assertEqual(len(receipt['tmpfs_volume_cleanup_obligations']),4)
|
||||
def test_reaper_inherits_no_custody_or_service_environment(self):
|
||||
session=s.plan_session();staging={'path':'/dev/shm/registered','run_id':session['run_id']}
|
||||
with patch.object(s.subprocess,'Popen') as spawn,patch.object(s,'verify_execution'),patch.object(s.os,'pidfd_open',return_value=99),patch.object(s.os,'close'),patch.object(s,'register_workdir',return_value=staging),patch.object(s.select,'select',side_effect=lambda *args:([spawn.return_value.stdout],[],[])):
|
||||
spawn.return_value.stdout.readline.return_value=b'registered\n'
|
||||
s.start_reaper(session,staging,{'pid':123})
|
||||
kwargs=spawn.call_args.kwargs
|
||||
self.assertEqual(set(kwargs['env']),{'PATH','DOCKER_HOST'})
|
||||
self.assertTrue(kwargs['close_fds']);self.assertTrue(kwargs['start_new_session']);self.assertEqual(kwargs['pass_fds'],(99,))
|
||||
|
||||
class TargetBindingTests(unittest.TestCase):
|
||||
def test_every_direct_docker_command_is_explicitly_bound(self):
|
||||
with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run',return_value=__import__('subprocess').CompletedProcess([],0,b'ok')) as run:
|
||||
self.assertEqual(s.run(['docker','info']),b'ok')
|
||||
argv=run.call_args.args[0]
|
||||
self.assertEqual(argv[:3],['docker','--host',s.adapter.ENDPOINT])
|
||||
self.assertEqual(run.call_args.kwargs['env']['DOCKER_HOST'],s.adapter.ENDPOINT)
|
||||
def test_nondefault_unix_socket_is_refused_before_any_docker_call(self):
|
||||
with patch.dict('os.environ',{'DOCKER_HOST':'unix:///tmp/other-daemon.sock'},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'])
|
||||
run.assert_not_called()
|
||||
def test_conflicting_context_is_refused(self):
|
||||
with patch.dict('os.environ',{'DOCKER_CONTEXT':'production'},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'])
|
||||
run.assert_not_called()
|
||||
def test_conflicting_per_call_host_is_refused(self):
|
||||
with patch.dict('os.environ',{},clear=True),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','info'],env={'DOCKER_HOST':'unix:///tmp/other.sock'})
|
||||
run.assert_not_called()
|
||||
def test_reused_preflight_uses_same_bound_command(self):
|
||||
self.assertIs(s.local.command,s.adapter.command)
|
||||
def test_probe_is_registered_before_session_creation(self):
|
||||
session=s.plan_session()
|
||||
self.assertEqual(session['public_image_probe'],'nk49-kc-'+session['run_id']+'-image-probe')
|
||||
self.assertEqual(session['docker_endpoint'],s.adapter.ENDPOINT)
|
||||
|
||||
class PlaintextLifecycleTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup)
|
||||
self.root=Path(self.tmp.name)
|
||||
self.override=patch.object(s,'STAGING_ROOT',self.root);self.override.start();self.addCleanup(self.override.stop)
|
||||
self.run_id='a'*32
|
||||
self.work=self.root/('nk49-keycape-'+self.run_id+'-abcdefgh');self.work.mkdir(mode=0o700)
|
||||
self.staging=s.register_workdir(self.work,self.run_id)
|
||||
def test_exact_plaintext_directory_removed_and_recorded(self):
|
||||
(self.work/'archive.tar').write_bytes(b'synthetic-private-archive')
|
||||
restored=self.work/'restored';restored.mkdir(mode=0o700);(restored/'fixture').write_bytes(b'synthetic')
|
||||
receipt={};s.cleanup_workdir(self.staging,receipt)
|
||||
self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified'])
|
||||
self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'absence_verified')
|
||||
def test_docker_failure_does_not_skip_plaintext_cleanup(self):
|
||||
(self.work/'archive.tar').write_bytes(b'synthetic')
|
||||
receipt={}
|
||||
with patch.object(s,'cleanup_all',side_effect=s.adapter.Refused):
|
||||
with self.assertRaises(s.adapter.Refused):s.recover_obligations({},self.staging,receipt)
|
||||
self.assertFalse(self.work.exists());self.assertTrue(receipt['temporary_plaintext_cleanup_verified'])
|
||||
def test_replacement_inode_is_not_removed(self):
|
||||
# Preserve original inode while replacing its registered pathname.
|
||||
old=self.root/'retained-original';self.work.rename(old);self.work.mkdir(mode=0o700)
|
||||
(self.work/'replacement').write_bytes(b'not-owned-by-this-obligation');receipt={}
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,receipt)
|
||||
self.assertTrue((self.work/'replacement').exists());self.assertTrue(old.exists())
|
||||
self.assertEqual(receipt['plaintext_cleanup_obligation']['status'],'identity_mismatch_not_removed')
|
||||
def test_symlink_root_is_not_followed(self):
|
||||
self.work.rmdir();outside=self.root/'outside';outside.mkdir();(outside/'kept').write_bytes(b'keep')
|
||||
self.work.symlink_to(outside,target_is_directory=True)
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{})
|
||||
self.assertTrue((outside/'kept').exists())
|
||||
def test_descendant_symlink_is_not_followed(self):
|
||||
outside=self.root/'kept';outside.write_bytes(b'keep');(self.work/'link').symlink_to(outside)
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(self.staging,{})
|
||||
self.assertEqual(outside.read_bytes(),b'keep')
|
||||
def test_wrong_owner_identity_is_not_removed(self):
|
||||
changed={**self.staging,'owner_uid':os.getuid()+1}
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir(changed,{})
|
||||
self.assertTrue(self.work.exists())
|
||||
def test_unregistered_path_refused(self):
|
||||
with self.assertRaises(s.adapter.Refused):s.cleanup_workdir({**self.staging,'path':str(self.root)}, {})
|
||||
|
||||
@unittest.skipUnless(hasattr(os,'pidfd_open') and hasattr(signal,'pidfd_send_signal'),'Linux pidfd required')
|
||||
class ExecutionDeadlineTests(unittest.TestCase):
|
||||
def start_synthetic_owner(self,root):
|
||||
late=str(root/'late-creation')
|
||||
code="""import importlib.util,json,subprocess,sys,time
|
||||
spec=importlib.util.spec_from_file_location('session',sys.argv[1]);s=importlib.util.module_from_spec(spec);spec.loader.exec_module(s)
|
||||
execution=s.isolate_execution()
|
||||
child=subprocess.Popen([sys.executable,'-c',"import pathlib,sys,time;time.sleep(2);pathlib.Path(sys.argv[1]).write_text('late')",sys.argv[2]])
|
||||
print(json.dumps({'execution':execution,'child_pid':child.pid}),flush=True)
|
||||
time.sleep(20)
|
||||
"""
|
||||
parent=subprocess.Popen([sys.executable,'-c',code,str(SOURCE),late],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
message=json.loads(parent.stdout.readline());parent.stdout.close()
|
||||
def finish():
|
||||
if parent.poll() is None:parent.kill()
|
||||
parent.wait(timeout=5)
|
||||
try:
|
||||
before=s.process_facts(message['child_pid'])
|
||||
if before is None or before['sid']!=message['execution']['sid'] or before['pgid']!=message['execution']['pgid']:return
|
||||
fd=os.pidfd_open(message['child_pid'])
|
||||
try:
|
||||
after=s.process_facts(message['child_pid'])
|
||||
if after is not None and all(after[k]==before[k] for k in ('pid','sid','pgid','start_ticks','uid')):
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(fd)
|
||||
except ProcessLookupError:pass
|
||||
self.addCleanup(finish)
|
||||
return parent,message['execution'],Path(late)
|
||||
def test_deadline_quiesces_only_fresh_owner_group_before_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid)
|
||||
try:
|
||||
receipt={};s.quiesce_execution(execution,fd,receipt)
|
||||
self.assertTrue(receipt['execution_quiescence_verified'])
|
||||
self.assertFalse(s.group_members(execution));self.assertFalse(late.exists())
|
||||
finally:os.close(fd)
|
||||
def test_hard_parent_loss_stops_surviving_marker_creator(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent,execution,late=self.start_synthetic_owner(Path(directory));fd=os.pidfd_open(parent.pid)
|
||||
try:
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
poll=__import__('select').poll();poll.register(fd,__import__('select').POLLIN);self.assertTrue(poll.poll(2000))
|
||||
receipt={};s.quiesce_execution(execution,fd,receipt)
|
||||
self.assertTrue(receipt['execution_quiescence_verified']);self.assertFalse(late.exists())
|
||||
self.assertFalse(s.group_members(execution))
|
||||
finally:os.close(fd)
|
||||
def test_expired_execution_cannot_start_any_docker_command(self):
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()-1,'staging':'/tmp/not-created'}
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run') as run:
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','create','fixed-image'])
|
||||
run.assert_not_called()
|
||||
def test_reused_or_foreign_session_refuses_signals(self):
|
||||
binding={'pid':123,'sid':123,'pgid':123,'start_ticks':10,'uid':os.getuid()}
|
||||
wrong={**binding,'sid':456,'state':'S'}
|
||||
with patch.object(s,'process_facts',return_value=wrong),patch.object(s.signal,'pidfd_send_signal') as send:
|
||||
with self.assertRaises(s.adapter.Refused):s.quiesce_execution(binding,99,{})
|
||||
send.assert_not_called()
|
||||
def test_inflight_uncertainty_survives_plaintext_directory_removal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'owned-stage';path.mkdir();marker=Path(str(path)+'-docker-inflight');marker.touch()
|
||||
staging={'path':str(path)};self.assertTrue(s.daemon_uncertain(staging));path.rmdir()
|
||||
self.assertTrue(s.daemon_uncertain(staging))
|
||||
|
||||
class ReaperOrderingTests(unittest.TestCase):
|
||||
def exercise(self,quiescence_fails=False):
|
||||
events=[];records=[]
|
||||
def quiet(*args):
|
||||
events.append('quiesce')
|
||||
if quiescence_fails:raise s.adapter.Refused()
|
||||
args[-1]['execution_quiescence_verified']=True
|
||||
def recover(*args):events.append('cleanup')
|
||||
with patch.object(s,'verify_execution'),patch.object(s,'pidfd_dead',return_value=False),patch.object(s.select,'poll') as poll,patch.object(s,'quiesce_execution',side_effect=quiet),patch.object(s,'daemon_uncertain',return_value=True),patch.object(s,'recover_obligations',side_effect=recover),patch.object(s.os,'close'),patch.object(s.os,'open',return_value=99),patch.object(s.os,'fdopen'),patch.object(s.json,'dump',side_effect=lambda r,*args:records.append(dict(r))),patch('sys.stdout',io.StringIO()):
|
||||
poll.return_value.poll.return_value=[]
|
||||
s.reaper_main({}, {'path':'/dev/shm/exact-staging'}, {'deadline':time.monotonic()-1},77)
|
||||
return events,records[0]
|
||||
def test_daemon_uncertainty_survives_verified_resource_readback(self):
|
||||
events,receipt=self.exercise();self.assertEqual(events,['quiesce','cleanup'])
|
||||
self.assertTrue(receipt['execution_quiescence_verified']);self.assertTrue(receipt['in_flight_daemon_request_uncertain'])
|
||||
self.assertEqual(receipt['status'],'bounded_cleanup_uncertain')
|
||||
def test_unverified_quiescence_prevents_any_cleanup(self):
|
||||
events,receipt=self.exercise(True);self.assertEqual(events,['quiesce'])
|
||||
self.assertFalse(receipt['execution_quiescence_verified']);self.assertEqual(receipt['status'],'bounded_cleanup_uncertain')
|
||||
def test_checkpoint_rewrites_one_valid_json_document(self):
|
||||
with tempfile.TemporaryFile() as stream:
|
||||
s.persist_receipt(stream.fileno(),{'status':'registered','long':'x'*100})
|
||||
s.persist_receipt(stream.fileno(),{'status':'failed'})
|
||||
stream.seek(0);self.assertEqual(json.load(stream),{'status':'failed'})
|
||||
def test_nonzero_docker_result_retains_uncertainty_before_inflight_clear(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
staging=str(Path(directory)/'stage');Path(staging).mkdir()
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging}
|
||||
def returned(*args,**kwargs):
|
||||
self.assertTrue(Path(staging+'-docker-inflight').exists())
|
||||
return subprocess.CompletedProcess(args[0],1,b'')
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=returned):
|
||||
with self.assertRaises(s.adapter.Refused):s.run(['docker','create','synthetic-already-accepted'])
|
||||
self.assertFalse(Path(staging+'-docker-inflight').exists());self.assertTrue(s.daemon_uncertain({'path':staging}))
|
||||
def test_only_policy_mount_is_writable_in_issuer(self):
|
||||
plan=s.plan_session();args=s.mounts(plan,readonly=True)
|
||||
mounts=args[1::2]
|
||||
for value in mounts:
|
||||
self.assertEqual(not value.endswith(',readonly'),'target=/var/lib/keycape-policy' in value)
|
||||
def test_docker_timeout_retains_independent_uncertainty_marker(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
staging=str(Path(directory)/'stage');Path(staging).mkdir()
|
||||
binding={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'deadline':time.monotonic()+10,'staging':staging}
|
||||
def timeout(*args,**kwargs):
|
||||
self.assertTrue(Path(staging+'-docker-inflight').exists())
|
||||
raise subprocess.TimeoutExpired('synthetic Docker',1)
|
||||
with patch.object(s.adapter,'EXECUTION',binding),patch.object(s.adapter.subprocess,'run',side_effect=timeout):
|
||||
with self.assertRaises(subprocess.TimeoutExpired):s.run(['docker','create','fixed-image'])
|
||||
self.assertFalse(Path(staging+'-docker-inflight').exists())
|
||||
self.assertTrue(Path(staging+'-docker-uncertain').exists());self.assertTrue(s.daemon_uncertain({'path':staging}))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run T18 session assembly using the existing archive/containment pipeline.
|
||||
|
||||
Candidate only: native pinned-image tests and review are required before use.
|
||||
Shared Docker local-driver tmpfs is required because Docker cp does not support
|
||||
private tmpfs/mount paths, and the original KeyCape image is distroless.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import re
|
||||
import stat
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
|
||||
def load(name):
|
||||
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module);return module
|
||||
|
||||
adapter=load('isolated-keycape')
|
||||
local=load('isolated-lldap')
|
||||
bundle=local.bundle
|
||||
# Bind the reused preflight itself, not only this module's direct Docker calls.
|
||||
local.command=adapter.command
|
||||
_original_preflight=local.preflight
|
||||
def preflight():
|
||||
contexts=json.loads(adapter.command(['docker','context','inspect']))
|
||||
if len(contexts)!=1 or contexts[0]['Endpoints']['docker']['Host']!=adapter.ENDPOINT:raise adapter.Refused()
|
||||
result=_original_preflight();result['docker_endpoint']=adapter.ENDPOINT;return result
|
||||
local.preflight=preflight
|
||||
WORKER=local.CLIENT
|
||||
STAGING_ROOT=Path('/dev/shm')
|
||||
RUNROOTS=('/etc/keycape','/var/lib/keycape-policy','/etc/keycape-factor','/recovery-tls')
|
||||
VOLUME_OPTIONS={'type':'tmpfs','device':'tmpfs','o':'size=33554432,uid=1000,gid=1000,mode=0700,nosuid,nodev,noexec'}
|
||||
|
||||
IMPORT=r'''
|
||||
import base64,json,os,sys
|
||||
from pathlib import Path
|
||||
os.umask(0o077)
|
||||
p=json.load(sys.stdin)
|
||||
allowed=('/etc/keycape/','/var/lib/keycape-policy/','/etc/keycape-factor/','/recovery-tls/')
|
||||
for name,encoded in p.items():
|
||||
f=Path(name)
|
||||
if not name.startswith(allowed) or '..' in f.parts or any(x.is_symlink() for x in (f,*f.parents)):raise ValueError()
|
||||
f.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
fd=os.open(f,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'wb') as out:out.write(base64.b64decode(encoded,validate=True))
|
||||
'''
|
||||
|
||||
# Small transport inside the already pinned helper; it has only loopback routes.
|
||||
# No token handling, auth bypass, dependency imitation, reset or notifier path.
|
||||
TLS=r'''
|
||||
import select,socket,socketserver,ssl,threading
|
||||
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version=ssl.TLSVersion.TLSv1_2
|
||||
context.load_cert_chain('/recovery-tls/server.pem','/recovery-tls/server-key.pem')
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
with context.wrap_socket(self.request,server_side=True) as incoming:
|
||||
with socket.create_connection(('127.0.0.1',8080),timeout=10) as outgoing:
|
||||
incoming.settimeout(15);outgoing.settimeout(15)
|
||||
while True:
|
||||
ready,_,_=select.select([incoming,outgoing],[],[],15)
|
||||
if not ready:return
|
||||
for source in ready:
|
||||
data=source.recv(65536)
|
||||
if not data:return
|
||||
(outgoing if source is incoming else incoming).sendall(data)
|
||||
except Exception:pass
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address=True
|
||||
daemon_threads=True
|
||||
with Server(('127.0.0.1',443),Handler) as server:server.serve_forever()
|
||||
'''
|
||||
|
||||
|
||||
def run(argv,**kwargs):return adapter.command(argv,**kwargs)
|
||||
|
||||
|
||||
def plan_session():
|
||||
run_id=uuid.uuid4().hex
|
||||
stem='nk49-kc-'+run_id
|
||||
worker=stem+'-transport';issuer=stem+'-issuer'
|
||||
volumes={stem+'-v'+str(i):{'target':root,'options':dict(VOLUME_OPTIONS)} for i,root in enumerate(RUNROOTS)}
|
||||
return {'run_id':run_id,'containers':[worker,issuer], 'worker':worker,'issuer':issuer,
|
||||
'images':{worker:WORKER,issuer:adapter.IMAGE},'volumes':volumes,
|
||||
'public_image_probe':stem+'-image-probe','docker_endpoint':adapter.ENDPOINT}
|
||||
|
||||
|
||||
def verify_volume(name,spec,run_id):
|
||||
obj=json.loads(run(['docker','volume','inspect',name]))
|
||||
if (len(obj)!=1 or obj[0]['Name']!=name or obj[0]['Driver']!='local' or obj[0].get('Scope')!='local'
|
||||
or obj[0].get('Options')!=VOLUME_OPTIONS or spec['options']!=VOLUME_OPTIONS
|
||||
or obj[0].get('Labels',{}).get(adapter.LABEL)!=run_id):raise adapter.Refused()
|
||||
|
||||
|
||||
def base_args(session,name,network):
|
||||
return ['docker','run','--detach','--pull=never','--name',name,'--network',network,
|
||||
'--label',adapter.LABEL+'='+session['run_id'],'--read-only','--cap-drop','ALL',
|
||||
'--security-opt','no-new-privileges','--user','1000:1000','--memory','512m','--cpus','1',
|
||||
'--pids-limit','64','--log-driver','none','--restart','no',
|
||||
'--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=16777216,uid=1000,gid=1000,mode=0700']
|
||||
|
||||
|
||||
def mounts(session,readonly=False):
|
||||
args=[]
|
||||
for name,spec in session['volumes'].items():
|
||||
args+=['--mount','type=volume,source='+name+',target='+spec['target']+(',readonly' if readonly and spec['target']!='/var/lib/keycape-policy' else '')]
|
||||
return args
|
||||
|
||||
|
||||
def provision(session,payload,tls,issuer):
|
||||
"""Inspect containment and driver options before the first private import."""
|
||||
from urllib.parse import urlsplit
|
||||
address=urlsplit(issuer)
|
||||
if address.scheme!='https' or not address.hostname or address.port not in (None,443) or address.path not in ('','/'):
|
||||
raise adapter.Refused()
|
||||
for name,spec in session['volumes'].items():
|
||||
run(['docker','volume','create','--driver','local','--label',adapter.LABEL+'='+session['run_id'],
|
||||
'--opt','type=tmpfs','--opt','device=tmpfs','--opt','o='+VOLUME_OPTIONS['o'],name])
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
args=base_args(session,session['worker'],'none')+mounts(session)
|
||||
args+=['--sysctl','net.ipv4.ip_unprivileged_port_start=0','--add-host',address.hostname+':127.0.0.1',
|
||||
'--entrypoint','python3',WORKER,'-c','import time;time.sleep(1800)']
|
||||
run(args)
|
||||
# Check helper and every private filesystem before touching the ciphertext's
|
||||
# private contents. Issuer is created only after originals are present.
|
||||
adapter.inspect_session({**session,'containers':[session['worker']],'issuer':session['worker']},worker_only=True)
|
||||
files={**payload['files'],**{k:base64.b64encode(v).decode() for k,v in tls.items()}}
|
||||
run(['docker','exec','-i',session['worker'],'python3','-c',IMPORT],input=json.dumps(files).encode())
|
||||
anchor=run(['docker','container','inspect',session['worker'],'--format','{{.Id}}']).decode().strip()
|
||||
args=base_args(session,session['issuer'],'container:'+anchor)+mounts(session,readonly=True)
|
||||
# CLI argv carries environment names only. Original credentials stay in the
|
||||
# owner subprocess environment and Docker's private container configuration.
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':'unix:///var/run/docker.sock',**payload['env'],
|
||||
'SSL_CERT_FILE':'/recovery-tls/trust.pem'}
|
||||
for name in (*payload['env'],'SSL_CERT_FILE'):args+=['--env',name]
|
||||
args+=[adapter.IMAGE]
|
||||
run(args,env=env)
|
||||
adapter.inspect_session(session)
|
||||
run(['docker','exec','--detach',session['worker'],'python3','-c',TLS])
|
||||
|
||||
|
||||
def cleanup_all(session,receipt):
|
||||
failed=False
|
||||
try:adapter.cleanup({**session,'containers':session['containers']+[session['public_image_probe']]},receipt)
|
||||
except Exception:failed=True
|
||||
rows=[]
|
||||
for name,spec in session['volumes'].items():
|
||||
row={'volume':name,'status':'unverified'};rows.append(row)
|
||||
try:
|
||||
exists=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines()
|
||||
if not exists:
|
||||
row['status']='absence_verified'
|
||||
continue
|
||||
verify_volume(name,spec,session['run_id'])
|
||||
run(['docker','volume','rm',name])
|
||||
remaining=run(['docker','volume','ls','--filter','name=^'+name+'$','--format','{{.Name}}']).decode().splitlines()
|
||||
if remaining:row['status']='still_present';failed=True
|
||||
else:row['status']='absence_verified'
|
||||
except Exception:failed=True
|
||||
receipt['tmpfs_volume_cleanup_obligations']=rows
|
||||
receipt['container_cleanup_verified']=not failed
|
||||
if failed:raise adapter.Refused()
|
||||
|
||||
def register_workdir(work, run_id):
|
||||
if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+run_id+'-[a-z0-9_]{8}',work.name):raise adapter.Refused()
|
||||
if any(p.is_symlink() for p in (work,*work.parents)):raise adapter.Refused()
|
||||
info=work.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)!=0o700:raise adapter.Refused()
|
||||
return {'path':str(work),'run_id':run_id,'device':info.st_dev,'inode':info.st_ino,'owner_uid':info.st_uid}
|
||||
|
||||
|
||||
def cleanup_workdir(staging, receipt):
|
||||
"""Delete only the registered inode below actual /dev/shm, never a replacement."""
|
||||
row={'path':staging['path'],'device':staging['device'],'inode':staging['inode'],'status':'unverified'}
|
||||
receipt['plaintext_cleanup_obligation']=row
|
||||
work=Path(staging['path'])
|
||||
if work.parent!=STAGING_ROOT or not re.fullmatch('nk49-keycape-'+staging['run_id']+'-[a-z0-9_]{8}',work.name):raise adapter.Refused()
|
||||
if not any(line.split()[1:3]==['/dev/shm','tmpfs'] for line in Path('/proc/mounts').read_text().splitlines()):raise adapter.Refused()
|
||||
if any(p.is_symlink() for p in work.parents):raise adapter.Refused()
|
||||
parentfd=os.open(work.parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
||||
try:
|
||||
try:info=os.stat(work.name,dir_fd=parentfd,follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid!=staging['owner_uid'] or info.st_uid!=os.getuid()
|
||||
or info.st_dev!=staging['device'] or info.st_ino!=staging['inode'] or stat.S_IMODE(info.st_mode)!=0o700):
|
||||
row['status']='identity_mismatch_not_removed';raise adapter.Refused()
|
||||
def remove(parent,name,expected):
|
||||
fd=os.open(name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent)
|
||||
try:
|
||||
current=os.fstat(fd)
|
||||
if (current.st_dev,current.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused()
|
||||
for child in os.listdir(fd):
|
||||
before=os.stat(child,dir_fd=fd,follow_symlinks=False)
|
||||
if before.st_uid!=staging['owner_uid'] or before.st_dev!=staging['device']:raise adapter.Refused()
|
||||
if stat.S_ISDIR(before.st_mode):remove(fd,child,before)
|
||||
elif stat.S_ISREG(before.st_mode) and before.st_nlink==1:os.unlink(child,dir_fd=fd)
|
||||
else:raise adapter.Refused() # symlinks/special files/hardlinks are uncertain, never followed
|
||||
finally:os.close(fd)
|
||||
after=os.stat(name,dir_fd=parent,follow_symlinks=False)
|
||||
if (after.st_dev,after.st_ino)!=(expected.st_dev,expected.st_ino):raise adapter.Refused()
|
||||
os.rmdir(name,dir_fd=parent)
|
||||
remove(parentfd,work.name,info)
|
||||
try:os.stat(work.name,dir_fd=parentfd,follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
row['status']='absence_verified';receipt['temporary_plaintext_cleanup_verified']=True;return
|
||||
raise adapter.Refused()
|
||||
finally:os.close(parentfd)
|
||||
|
||||
|
||||
def recover_obligations(session, staging, receipt):
|
||||
failed=False
|
||||
try:cleanup_all(session,receipt)
|
||||
except Exception:failed=True
|
||||
# The plaintext obligation is attempted even when Docker is unreachable.
|
||||
try:cleanup_workdir(staging,receipt)
|
||||
except Exception:failed=True
|
||||
if failed:raise adapter.Refused()
|
||||
|
||||
|
||||
def process_facts(pid):
|
||||
raw=Path('/proc')/str(pid)/'stat'
|
||||
try:
|
||||
fields=raw.read_text().rsplit(')',1)[1].split()
|
||||
return {'pid':pid,'state':fields[0],'pgid':int(fields[2]),'sid':int(fields[3]),
|
||||
'start_ticks':int(fields[19]),'uid':raw.parent.stat().st_uid}
|
||||
except FileNotFoundError:return None
|
||||
|
||||
|
||||
def isolate_execution():
|
||||
# Called by main before reading TLS/key inputs. Never reuse an inherited
|
||||
# shell, custody-helper, user or unrelated process group.
|
||||
os.setsid()
|
||||
pid=os.getpid();facts=process_facts(pid)
|
||||
if os.getsid(0)!=pid or os.getpgrp()!=pid or facts['sid']!=pid or facts['pgid']!=pid:raise adapter.Refused()
|
||||
return {k:facts[k] for k in ('pid','pgid','sid','start_ticks','uid')}|{'deadline':time.monotonic()+1800}
|
||||
|
||||
|
||||
def verify_execution(execution):
|
||||
if execution['pid']!=execution['sid'] or execution['pid']!=execution['pgid'] or execution['uid']!=os.getuid():raise adapter.Refused()
|
||||
current=process_facts(execution['pid'])
|
||||
if current is not None and any(current[k]!=execution[k] for k in ('pid','pgid','sid','start_ticks','uid')):
|
||||
# Numeric leader PID reuse is not authority to signal a new group.
|
||||
raise adapter.Refused()
|
||||
|
||||
|
||||
def group_members(execution):
|
||||
verify_execution(execution)
|
||||
members=[]
|
||||
for path in Path('/proc').iterdir():
|
||||
if not path.name.isdecimal():continue
|
||||
facts=process_facts(int(path.name))
|
||||
if facts is None or facts['pgid']!=execution['pgid']:continue
|
||||
if facts['sid']!=execution['sid'] or facts['uid']!=execution['uid'] or facts['start_ticks']<execution['start_ticks']:raise adapter.Refused()
|
||||
if facts['state'] not in ('Z','X'):members.append(facts)
|
||||
return members
|
||||
|
||||
|
||||
def pidfd_dead(pidfd):
|
||||
poller=select.poll();poller.register(pidfd,select.POLLIN)
|
||||
return bool(poller.poll(0))
|
||||
|
||||
|
||||
def quiesce_execution(execution,parent_pidfd,receipt):
|
||||
"""Stop only this fresh execution session; cleanup needs no live creator.
|
||||
|
||||
Exact parent pidfd prevents leader reuse. Each remaining session member gets
|
||||
its own pidfd after metadata comparison; no inherited/user-group kill occurs.
|
||||
Unknown membership or uninterruptible survivors refuse cleanup altogether.
|
||||
"""
|
||||
verify_execution(execution)
|
||||
if not pidfd_dead(parent_pidfd):
|
||||
signal.pidfd_send_signal(parent_pidfd,signal.SIGTERM)
|
||||
poller=select.poll();poller.register(parent_pidfd,select.POLLIN);poller.poll(2000)
|
||||
if not pidfd_dead(parent_pidfd):signal.pidfd_send_signal(parent_pidfd,signal.SIGKILL)
|
||||
for attempt in range(50):
|
||||
members=group_members(execution)
|
||||
if pidfd_dead(parent_pidfd) and not members:
|
||||
receipt['execution_quiescence_verified']=True;return
|
||||
for original in members:
|
||||
try:fd=os.pidfd_open(original['pid'])
|
||||
except ProcessLookupError:continue
|
||||
try:
|
||||
current=process_facts(original['pid'])
|
||||
if current is None or pidfd_dead(fd):continue
|
||||
if current!=original: # state may naturally change; bind stable identity below
|
||||
if any(current[k]!=original[k] for k in ('pid','pgid','sid','start_ticks','uid')):raise adapter.Refused()
|
||||
verify_execution(execution)
|
||||
signal.pidfd_send_signal(fd,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
finally:os.close(fd)
|
||||
time.sleep(.05)
|
||||
receipt['execution_quiescence_verified']=False
|
||||
raise adapter.Refused()
|
||||
|
||||
|
||||
def daemon_uncertain(staging):
|
||||
return any(Path(staging['path']+suffix).exists() or Path(staging['path']+suffix).is_symlink() for suffix in ('-docker-inflight','-docker-uncertain'))
|
||||
|
||||
|
||||
def reaper_main(session,staging,execution,parent_pidfd):
|
||||
receipt={'status':'failed','execution_quiescence_verified':False}
|
||||
verify_execution(execution)
|
||||
if pidfd_dead(parent_pidfd):raise adapter.Refused()
|
||||
print('registered',flush=True)
|
||||
poller=select.poll();poller.register(parent_pidfd,select.POLLIN)
|
||||
remaining=max(0,int((execution['deadline']-time.monotonic())*1000))
|
||||
parent_lost=bool(poller.poll(remaining))
|
||||
receipt['cleanup_trigger']='parent_exit' if parent_lost else 'execution_deadline'
|
||||
try:
|
||||
# No deletion races a live creator: first stop/wait this exact owner
|
||||
# session and its children. A daemon request already accepted can still
|
||||
# complete after CLI loss; retain that uncertainty even after readback.
|
||||
quiesce_execution(execution,parent_pidfd,receipt)
|
||||
uncertain=daemon_uncertain(staging)
|
||||
receipt['in_flight_daemon_request_uncertain']=uncertain
|
||||
recover_obligations(session,staging,receipt)
|
||||
receipt['status']='bounded_cleanup_uncertain' if uncertain else 'bounded_cleanup_verified'
|
||||
except Exception:receipt['status']='bounded_cleanup_uncertain'
|
||||
finally:
|
||||
os.close(parent_pidfd)
|
||||
try:
|
||||
fd=os.open(staging['path']+'-reaper.json',os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(receipt,out)
|
||||
except Exception:pass
|
||||
|
||||
|
||||
def start_reaper(session, staging, execution):
|
||||
adapter.docker_environment()
|
||||
verify_execution(execution)
|
||||
if session.get('docker_endpoint')!=adapter.ENDPOINT or staging.get('run_id')!=session['run_id']:raise adapter.Refused()
|
||||
if register_workdir(Path(staging['path']),session['run_id'])!=staging:raise adapter.Refused()
|
||||
parent_pidfd=os.pidfd_open(execution['pid'])
|
||||
code="""import importlib.util,json,sys
|
||||
spec=importlib.util.spec_from_file_location('session',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
m.reaper_main(json.loads(sys.argv[2]),json.loads(sys.argv[3]),json.loads(sys.argv[4]),int(sys.argv[5]))
|
||||
"""
|
||||
try:
|
||||
child=subprocess.Popen([sys.executable,'-c',code,str(Path(__file__).resolve()),json.dumps(session),json.dumps(staging),json.dumps(execution),str(parent_pidfd)],
|
||||
stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
env={'PATH':os.environ.get('PATH',os.defpath),'DOCKER_HOST':adapter.ENDPOINT},
|
||||
close_fds=True,pass_fds=(parent_pidfd,),start_new_session=True)
|
||||
finally:os.close(parent_pidfd)
|
||||
ready,_,_=select.select([child.stdout],[],[],10)
|
||||
if not ready or child.stdout.readline()!=b'registered\n':
|
||||
child.terminate();child.wait(timeout=5);raise adapter.Refused()
|
||||
child.stdout.close()
|
||||
return child
|
||||
|
||||
|
||||
def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
||||
"""Existing private custody/transfer/archive APIs; no second archive format."""
|
||||
verify_execution(execution)
|
||||
if execution['pid']!=os.getpid():raise adapter.Refused()
|
||||
target=local.preflight()
|
||||
for image in (adapter.IMAGE,):run(['docker','image','inspect',image])
|
||||
local.offline.validate(transfer)
|
||||
if transfer.get('independent_owner_retrieval_verified') is not True:raise adapter.Refused()
|
||||
path=Path(transfer['fetched_path']);bundle.private(path,max_bytes=bundle.LIMIT)
|
||||
if (path.stat().st_size!=transfer['ciphertext_bytes'] or local.offline.offsite.digest(path)!=transfer['ciphertext_sha256']
|
||||
or admission['ciphertext_sha256']!=transfer['ciphertext_sha256']):raise adapter.Refused()
|
||||
if hashlib.sha256(tls['/recovery-tls/trust.pem']).hexdigest()!=admission['trust_sha256']:raise adapter.Refused()
|
||||
if hashlib.sha256(tls['/recovery-tls/server.pem']).hexdigest()!=admission['tls_cert_sha256']:raise adapter.Refused()
|
||||
# Validate exact private certificate/key pair and original issuer name before
|
||||
# opening service containers; do not generate replacement trust or keys.
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
cert=x509.load_pem_x509_certificate(tls['/recovery-tls/server.pem'])
|
||||
key=serialization.load_pem_private_key(tls['/recovery-tls/server-key.pem'],password=None)
|
||||
if cert.public_key().public_numbers()!=key.public_key().public_numbers():raise adapter.Refused()
|
||||
keyfd=local.verify.custody.private_key_fd();work=None;session=None;reaper=None;staging=None
|
||||
receipt.update(status='failed',target=target,session_source_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
source_files_sha256={name:hashlib.sha256((HERE/name).read_bytes()).hexdigest() for name in ('isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh','isolated-lldap.py','identity-bundle.py','verify-identity.py','retrieve-identity-offline.py','identity-offsite.py')},
|
||||
temporary_plaintext_cleanup_verified=False,container_cleanup_verified=False,
|
||||
downstream_consumer_reconnect_proven=False,human_mfa_proven=False)
|
||||
try:
|
||||
if run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,)).strip()!=bundle.profile()['public_recipient'].encode():raise adapter.Refused()
|
||||
session=plan_session()
|
||||
work=Path(tempfile.mkdtemp(prefix='nk49-keycape-'+session['run_id']+'-',dir='/dev/shm'))
|
||||
staging=register_workdir(work,session['run_id'])
|
||||
reaper=start_reaper(session,staging,execution)
|
||||
adapter.EXECUTION={**execution,'staging':str(work)}
|
||||
receipt.update(execution_binding=execution,bounded_cleanup_reaper_started=True,plaintext_cleanup_obligation={**staging,'status':'registered_before_plaintext'},docker_endpoint=adapter.ENDPOINT,
|
||||
reaper_outcome_path=staging['path']+'-reaper.json',
|
||||
registered_cleanup={'containers':session['containers']+[session['public_image_probe']],'tmpfs_volumes':list(session['volumes'])})
|
||||
if checkpoint is not None:checkpoint(receipt)
|
||||
archive=work/'archive.tar'
|
||||
with archive.open('xb') as output:
|
||||
p=adapter.process(['age','-d','-i',f'/proc/self/fd/{keyfd}',str(path)],stdout=output,stderr=subprocess.DEVNULL,pass_fds=(keyfd,),timeout=180)
|
||||
if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused()
|
||||
root=work/'restored';root.mkdir(mode=0o700)
|
||||
manifest,_=bundle.restore(archive,root)
|
||||
payload=adapter.private_payload(root,manifest,admission['manifest_sha256'])
|
||||
import yaml
|
||||
cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml']))
|
||||
if cfg.get('port')!=8080 or cfg.get('issuer')!=admission['issuer']:raise adapter.Refused()
|
||||
# Public executable readback uses the daemon archive API only on the
|
||||
# original image rootfs, never a tmpfs or private user-created mount.
|
||||
probe=session['public_image_probe']
|
||||
try:
|
||||
run(['docker','create','--pull=never','--name',probe,'--network','none','--read-only','--label',adapter.LABEL+'='+session['run_id'],adapter.IMAGE])
|
||||
binary=adapter.container_file(probe,'/keycape')
|
||||
if hashlib.sha256(binary).hexdigest()!=admission['verifier_sha256']:raise adapter.Refused()
|
||||
finally:
|
||||
receipt['public_image_probe_cleanup_obligations']=[{'container':probe,'status':'unverified'}]
|
||||
run(['docker','rm',probe])
|
||||
if run(['docker','container','ls','--all','--filter','name=^/'+probe+'$','--format','{{.Names}}']).strip():raise adapter.Refused()
|
||||
receipt['public_image_probe_cleanup_obligations'][0]['status']='absence_verified'
|
||||
provision(session,payload,tls,admission['issuer'])
|
||||
receipt.update(ciphertext_sha256=transfer['ciphertext_sha256'],all_manifest_hashes_verified=True,manifest_file_count=len(manifest['files']))
|
||||
checked_admission={**admission,'session':session,'trust_path':'/recovery-tls/trust.pem'}
|
||||
# Native checker consumes no service secret argument or new grant.
|
||||
# Retry only bounded public readiness; no account/factor attempt.
|
||||
for attempt in range(40):
|
||||
try:
|
||||
numbers=serialization.load_pem_private_key(base64.b64decode(payload['files']['/etc/keycape/key.pem']),password=None).public_key().public_numbers()
|
||||
adapter.check_jwks(session,checked_admission,numbers);break
|
||||
except Exception:
|
||||
if attempt==39:raise
|
||||
time.sleep(.25)
|
||||
adapter.native_checks(root,manifest,checked_admission,receipt,cleanup_session=False)
|
||||
finally:
|
||||
os.close(keyfd)
|
||||
try:
|
||||
if session is not None and staging is not None:
|
||||
uncertain=daemon_uncertain(staging)
|
||||
receipt['in_flight_daemon_request_uncertain']=uncertain
|
||||
recover_obligations(session,staging,receipt)
|
||||
if uncertain:raise adapter.Refused()
|
||||
if reaper is not None:
|
||||
reaper.terminate();reaper.wait(timeout=10)
|
||||
except Exception:
|
||||
receipt['status']='failed'
|
||||
raise
|
||||
finally:
|
||||
adapter.EXECUTION=None
|
||||
if work is not None and not work.exists():receipt['temporary_plaintext_cleanup_verified']=True
|
||||
|
||||
|
||||
def persist_receipt(fd,receipt):
|
||||
raw=(json.dumps(receipt,indent=2)+'\n').encode()
|
||||
os.lseek(fd,0,os.SEEK_SET);os.ftruncate(fd,0)
|
||||
with os.fdopen(os.dup(fd),'wb') as output:
|
||||
output.write(raw);output.flush();os.fsync(output.fileno())
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust','receipt'):p.add_argument('--'+name,required=True)
|
||||
args=p.parse_args();os.umask(0o077)
|
||||
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
|
||||
receipt={'schema':'netkingdom.isolated-keycape-recovery.v1','status':'failed','production_contacted':False,'secret_values_exported':False}
|
||||
try:
|
||||
execution=isolate_execution()
|
||||
raw=Path(args.admission).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=args.admission_sha256:raise adapter.Refused()
|
||||
admission=json.loads(raw)
|
||||
tls={}
|
||||
for name,path in (('server.pem',args.tls_cert),('server-key.pem',args.tls_key),('trust.pem',args.trust)):
|
||||
source=Path(path);bundle.private(source,max_bytes=1024*1024);tls['/recovery-tls/'+name]=source.read_bytes()
|
||||
execute(json.loads(Path(args.transfer_receipt).read_text()),admission,tls,receipt,execution,checkpoint=lambda r:persist_receipt(fd,r))
|
||||
except (Exception,KeyboardInterrupt):receipt.update(status='failed',error='isolated_keycape_failed_private_diagnostics_suppressed')
|
||||
finally:
|
||||
receipt['finished_at']=datetime.now(timezone.utc).isoformat()
|
||||
try:persist_receipt(fd,receipt)
|
||||
finally:os.close(fd)
|
||||
print('OK: isolated KeyCape native checks and cleanup passed.' if receipt['status']=='isolated_keycape_native_registration_checked' else 'ERROR: isolated KeyCape stopped; review safe receipt.')
|
||||
return int(receipt['status']!='isolated_keycape_native_registration_checked')
|
||||
|
||||
if __name__=='__main__':
|
||||
for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,local.offline.interrupted)
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue