feat(recovery): prepare full identity backup and bootstrap handoff
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
This commit is contained in:
parent
dca999222f
commit
994d11c869
29 changed files with 3277 additions and 29 deletions
296
tools/bootstrap-recovery/capture-identity.py
Normal file
296
tools/bootstrap-recovery/capture-identity.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"schema": "netkingdom.identity-backup-profile.v1",
|
||||
"status": "proposed",
|
||||
"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",
|
||||
|
|
@ -133,20 +133,21 @@
|
|||
"historical_database": "existing verified October15 dump remains protected; new bundle must link its own exact database/key capture generation and recovery receipt"
|
||||
},
|
||||
"proposed_destinations": {
|
||||
"primary": "s3://railiance-platform-pg-backup/platform-pg/application-archives/net-kingdom/",
|
||||
"secondary": "Storage Share Backup/railiance-backups/net-kingdom-identity/",
|
||||
"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": "new exact prefix-scoped owner-delivered publisher; do not borrow another workload reader or bootstrap API key",
|
||||
"retention": "initial single verified generation; later seven daily/two weekly only after measured quota/overlap/headroom and separately admitted retention executor; never expire existing good backups",
|
||||
"schedule": "not installed; propose daily capture after logical database backup with per-generation linkage, separate failure alert and operator-approved RPO/RTO"
|
||||
"publisher": "CCR-2026-0048: new folder-only read/create public DAV share, delivered by exact ESO reader; no operator password in runtime",
|
||||
"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",
|
||||
"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": [
|
||||
"Railiance owner accepts exact read/capture executor, immutable helper images, PVC access and task-scoped caller attribution; no production stop/restart inferred",
|
||||
"NetKingdom/service owners accept full asset manifest, original key preservation, consistency interval, bounded recovery environment and native acceptance",
|
||||
"Platform/reef-storage admits both new archive prefixes, immutable create/readback, scoped publisher custody and current quota/headroom; current application archive profiles do not include net-kingdom",
|
||||
"Resolve every referenced credential through encrypted recovery or an independent owner reissue path; no undocumented grant propagation",
|
||||
"Implement and test capture/sealing/transfer adapters against this new profile before live execution; existing bootstrap tar checker is insufficient",
|
||||
"Retrieve by immutable object/version from each admitted provider; digest check, attended independent-key decryption, native service recovery, failed-key case and verified cleanup",
|
||||
"Only then enable recurring capture/alerts and measured retention; preserve T02 legacy provider-credential and deferred-rotation boundaries"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
204
tools/bootstrap-recovery/identity-bundle.py
Normal file
204
tools/bootstrap-recovery/identity-bundle.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#!/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)
|
||||
234
tools/bootstrap-recovery/identity-credential-setup.py
Normal file
234
tools/bootstrap-recovery/identity-credential-setup.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
#!/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,5),'retention':(49,9)}
|
||||
|
||||
|
||||
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):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:
|
||||
status,_=request(client,owner,auth,'PUT',payload.stdout,{'If-None-Match':'*'})
|
||||
if status!=201:raise Refused()
|
||||
created=True
|
||||
status,raw=request(client,public,(token,''))
|
||||
if status!=200 or raw!=payload.stdout:raise Refused()
|
||||
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()
|
||||
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])
|
||||
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.
|
||||
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
|
||||
status,_=request(client,offsite.BASE+'%2e%2e/r01-net-kingdom-pg/',(token,''))
|
||||
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)
|
||||
# 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()
|
||||
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()
|
||||
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()
|
||||
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())
|
||||
144
tools/bootstrap-recovery/identity-custody.json
Normal file
144
tools/bootstrap-recovery/identity-custody.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
109
tools/bootstrap-recovery/identity-files.py
Normal file
109
tools/bootstrap-recovery/identity-files.py
Normal file
|
|
@ -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)
|
||||
65
tools/bootstrap-recovery/identity-isolation.py
Normal file
65
tools/bootstrap-recovery/identity-isolation.py
Normal file
|
|
@ -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())
|
||||
124
tools/bootstrap-recovery/identity-offsite.py
Normal file
124
tools/bootstrap-recovery/identity-offsite.py
Normal file
|
|
@ -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}
|
||||
106
tools/bootstrap-recovery/identity-retention.py
Normal file
106
tools/bootstrap-recovery/identity-retention.py
Normal file
|
|
@ -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'
|
||||
208
tools/bootstrap-recovery/identity-runtime.py
Normal file
208
tools/bootstrap-recovery/identity-runtime.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#!/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)
|
||||
if (es['spec']['target']['name']!=name or es['spec']['data']!=[{'secretKey':'token','remoteRef':{
|
||||
'key':'workloads/net-kingdom/identity-backup/'+action,'property':'NC_WEBDAV_TOKEN'}}]
|
||||
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:
|
||||
if os.geteuid()!=0 or socket.gethostname()!='railiance01':raise offsite.Refused()
|
||||
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())
|
||||
145
tools/bootstrap-recovery/install-identity-backup.py
Normal file
145
tools/bootstrap-recovery/install-identity-backup.py
Normal file
|
|
@ -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():
|
||||
if os.geteuid()!=0 or socket.gethostname()!='railiance01':raise Refused()
|
||||
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())
|
||||
71
tools/bootstrap-recovery/retrieve-identity.py
Normal file
71
tools/bootstrap-recovery/retrieve-identity.py
Normal file
|
|
@ -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())
|
||||
83
tools/bootstrap-recovery/verify-identity-delivery.py
Normal file
83
tools/bootstrap-recovery/verify-identity-delivery.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/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):
|
||||
ccr,lane=setup.contract(action,expected)
|
||||
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]
|
||||
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']
|
||||
auth=login(lane['service_account'],'openbao');token=auth['client_token']
|
||||
try:
|
||||
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']
|
||||
cap=capabilities(token,paths)
|
||||
if cap[paths[0]]!=['read'] or any(cap[p]!=['deny'] for p in paths[1:]):raise setup.Refused()
|
||||
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)
|
||||
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())
|
||||
90
tools/bootstrap-recovery/verify-identity.py
Normal file
90
tools/bootstrap-recovery/verify-identity.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue