docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
a192d24c9a
commit
8231b2a8f5
49 changed files with 7321 additions and 0 deletions
|
|
@ -0,0 +1,779 @@
|
|||
diff --git a/docs/task-execution-mediator-seam.md b/docs/task-execution-mediator-seam.md
|
||||
new file mode 100644
|
||||
index 0000000..964688b
|
||||
--- /dev/null
|
||||
+++ b/docs/task-execution-mediator-seam.md
|
||||
@@ -0,0 +1,107 @@
|
||||
+# Strict task handoff to independent consumption confirmation
|
||||
+
|
||||
+RPF-WP-0035-T12 source preparation, 2026-10-16. This seam calls only the existing
|
||||
+fixed disposable native adapter. It installs no production origin, identity,
|
||||
+permission, real grant, generic broker or backend default.
|
||||
+
|
||||
+## Actual starting gaps and reused owner interfaces
|
||||
+
|
||||
+At Platform source base d042d468 and Secrets c80a944 the native fixture used a
|
||||
+synthetic direct `consume-confirmed` journal transition. No actual strict Secrets
|
||||
+task handoff existed. Generic Secrets consume accepted a substituted approval ID,
|
||||
+coerced idempotent values and did not require the original exact decision. Those
|
||||
+are Secrets-owned defects; Platform did not copy its authorization policy or edit
|
||||
+that repository. The Secrets owner prepared a separate strict module under
|
||||
+SECRETS-WP-0013, referenced by exact hashes in this package's manifest.
|
||||
+
|
||||
+Approval Engine already provides `ExecutionConfirmationClient.consume_exact` and
|
||||
+`confirm_consumption`. The latter performs an independently authenticated GET of
|
||||
+the durable original approval object and filters it to the original consumption
|
||||
+triplet/time. It is historical PIP evidence, not authorization. It neither validates
|
||||
+an Access decision nor makes a supplied receipt authoritative. Platform's adapter
|
||||
+previously assumed its first native call was journal ordinal zero, conflicting
|
||||
+with a preceding durable CAS/confirmation sequence.
|
||||
+
|
||||
+`scripts/task_execution_mediator.py` now performs the narrow owner join:
|
||||
+
|
||||
+1. Require the actual `secrets_engine.task_execution_handoff` dependency; absence
|
||||
+ refuses before provider credentials, journal reservation or native effects.
|
||||
+ The mediator itself calls `authorize_task_execution`. It accepts neither an
|
||||
+ inbound authorization object nor a caller-selected authorizer/transport callback.
|
||||
+2. Independently compile the exact local catalogue/descriptor/grant and compare the
|
||||
+ strict owner's detached handoff descriptor byte-for-byte. Only authorization_id
|
||||
+ may be added to approval reference metadata; purpose and source semantics cannot
|
||||
+ be substituted through an inbound catalogue object. Secrets owns signed Check,
|
||||
+ claim/native-act/full and approval-free digest joins, caller/subject correspondence
|
||||
+ and the strict prepared object. Platform does not render or duplicate that decision.
|
||||
+3. Bind the actual returned full canonical digest into the owner Plan. Reserve the
|
||||
+ approval/request/operation/Plan tuple and the precommitted cleanup obligations
|
||||
+ once while holding the same invocation lock through cleanup.
|
||||
+4. Commit a CAS call intent, invoke Secrets `consume_task_execution` once and require
|
||||
+ its actual typed result with exact original approval/request/decision/time and
|
||||
+ `idempotent is False`. A historical idempotent response cannot open native work.
|
||||
+5. Commit a separate read intent and invoke the actual Approval owner's default
|
||||
+ isolated transport, with independently installed read credential and exact same
|
||||
+ owner-admitted origin. Require `confirm_consumption` to match the native act,
|
||||
+ approval-free digest and original exact triplet/time. Raw approval entries and
|
||||
+ provider credentials never enter journal evidence or receipts.
|
||||
+6. Recheck original approval/decision/operation bounds using the existing Secrets
|
||||
+ time adapter, then persist `consume-confirmed`. Any CAS/read failure, timeout,
|
||||
+ denied read or mismatch records unknown and causes zero native execution. A
|
||||
+ subsequent invocation cannot retry the consumed approval or persisted admission.
|
||||
+7. Only then enter the fixed native grant/metadata adapter. Its next business-call
|
||||
+ ordinal comes from the same live journal, so CAS and GET occupy ordinals0/1 and
|
||||
+ native calls continue at2. Pending/unknown/cleanup-closed state cannot supply a
|
||||
+ next ordinal. Each native request retains its own durable intent and outcome.
|
||||
+8. Cleanup stays under the original distinct obligations even if business work
|
||||
+ fails. Returned evidence explicitly says native apply is unverified: six successful
|
||||
+ native calls do not establish total approved baseline/readback normalization,
|
||||
+ atomic CAS, service-login provenance or native production owner acceptance.
|
||||
+
|
||||
+The owner configuration selects provider route/read credential and the existing
|
||||
+Secrets configuration. These are independently installed runtime inputs, never
|
||||
+part of an HTTP body or authority derived from a caller string. Both providers use
|
||||
+real existing transports. Python import/file checks do not establish runtime
|
||||
+credential isolation; production custody/enforcement remains an external gate.
|
||||
+
|
||||
+## Whole disposable source-server proof
|
||||
+
|
||||
+Tests run the actual Access `serve` binary built from the owner source, with its
|
||||
+real Ed25519 signing implementation and enforce-mode TokenReview client. A local
|
||||
+synthetic reviewer independently admits only the explicitly fixed service token,
|
||||
+principal/audience and expiry. This is not actual Kubernetes identity proof.
|
||||
+Signing keys are generated in private temporary files; the public key and real
|
||||
+policy digest are independently installed in the Secrets trust file. The policy
|
||||
+digest comes from an owner-local actual source CLI before any network response.
|
||||
+
|
||||
+The positive policy is a separately named local synthetic exact-descriptor Rego
|
||||
+fixture with a real deny fixture and Rego test. It never edits or enables the
|
||||
+actual task policy. The actual proposed service task package is exercised unchanged
|
||||
+and returns genuine signed DENY. There is no fabricated DecisionEnvelope or
|
||||
+model-only approval. Real Approval WSGI/SQLite plus distinct independently admitted
|
||||
+synthetic service/human identities create, approve, consume and serve the object.
|
||||
+The strict Secrets module uses its actual mandatory producer-type signature verifier,
|
||||
+retained request/claim/envelope and one-shot strict CAS, followed by the independent
|
||||
+Approval GET and real disposable OpenBao grant/token/metadata/cleanup requests.
|
||||
+
|
||||
+Tests also prove actual independent read-scope denial after successful consume:
|
||||
+the durable approval is consumed, Platform remains unknown with pending read intent,
|
||||
+no target native objects exist and reinvocation cannot consume again. The native
|
||||
+journal is checked for known synthetic token leakage. A source purpose substitution
|
||||
+refuses before authorization/CAS, and a missing strict module refuses before any
|
||||
+credentials. Actual-policy denial leaves the approval approved and journal empty.
|
||||
+All identity, policy/registry admission, source-bundle/baseline digests and grant
|
||||
+approval in these tests are synthetic fixture inputs. These tests do not admit an
|
||||
+estate caller, real grant, production transport or actual protected custody.
|
||||
+
|
||||
+## Remaining production work
|
||||
+
|
||||
+Adopt/publish the exact strict Secrets dependency and this owner seam together;
|
||||
+admit the existing task-execution-grant source projection and actual identity,
|
||||
+policy package, provider origin/custody and complete source revisions. The current
|
||||
+actual policy remains hard-closed. Finish target CCR immutable semantics, total
|
||||
+native expected-state normalization/conflict control, CCR38 provenance, actual
|
||||
+per-call served-time/use enforcement, audit/outbox and protected uncertain/crash
|
||||
+recovery. Then obtain and execute the exact founder-scoped attended trial.
|
||||
+Approval replay prevention and uncertain native-effect reconciliation remain
|
||||
+separate guarantees. T12, Secrets WP13 and native adoption stay open.
|
||||
diff --git a/scripts/task_execution_journal.py b/scripts/task_execution_journal.py
|
||||
index a16291b..8f1b9a2 100644
|
||||
--- a/scripts/task_execution_journal.py
|
||||
+++ b/scripts/task_execution_journal.py
|
||||
@@ -231,6 +231,21 @@ class Journal:
|
||||
return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False,
|
||||
"external_outcome_unknown": bool(pending)}
|
||||
|
||||
+ def next_call_ordinal(self, binding: Binding):
|
||||
+ """Continue the same admitted invocation after owner auth/consume calls.
|
||||
+
|
||||
+ This never grants dispatch or resumes a persisted admission. The actual
|
||||
+ before_call transaction remains the final serialization/intent guard.
|
||||
+ """
|
||||
+ if not self._held or self._admission != binding:
|
||||
+ raise Refused("next call requires same live admission")
|
||||
+ count,pending=self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome='pending'),0) FROM calls WHERE operation=?",(binding.operation_id,)).fetchone()
|
||||
+ cleanup=self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'",(binding.operation_id,)).fetchone()[0]
|
||||
+ state=self.db.execute("SELECT state FROM operations WHERE operation=?",(binding.operation_id,)).fetchone()[0]
|
||||
+ if pending or cleanup or state in {'failed','unknown','cleanup-confirmed'} or count>63:
|
||||
+ raise Refused("call sequence is uncertain, closed or exhausted")
|
||||
+ return count
|
||||
+
|
||||
def before_call(self, binding: Binding, ordinal: int, call_digest: str):
|
||||
"""Persist one exact plan-call intent before the mediator issues it.
|
||||
|
||||
diff --git a/scripts/task_execution_mediator.py b/scripts/task_execution_mediator.py
|
||||
new file mode 100644
|
||||
index 0000000..d42b55f
|
||||
--- /dev/null
|
||||
+++ b/scripts/task_execution_mediator.py
|
||||
@@ -0,0 +1,180 @@
|
||||
+"""Owner mediator seam for the existing strict Secrets handoff and Approval GET.
|
||||
+
|
||||
+Only the disposable NativeFixtureAdapter is callable here. No production backend,
|
||||
+caller-supplied authorizer/transport, approval boolean or portable receipt exists.
|
||||
+The strict Secrets module is an explicit external dependency: absence refuses
|
||||
+before credential-bearing requests or native effects, never a permissive fallback.
|
||||
+"""
|
||||
+from __future__ import annotations
|
||||
+from dataclasses import dataclass
|
||||
+from datetime import datetime,timezone
|
||||
+import importlib
|
||||
+import json
|
||||
+from pathlib import Path
|
||||
+from urllib.parse import urlsplit
|
||||
+
|
||||
+from task_execution_cleanup import run_cleanup
|
||||
+from task_execution_journal import Binding,Cleanup,Journal,Refused
|
||||
+from task_execution_plan import Compiler,encoded,content_hash
|
||||
+from task_execution_native_fixture import FixtureAdmission,NativeFixtureAdapter
|
||||
+
|
||||
+
|
||||
+@dataclass(frozen=True)
|
||||
+class ConfirmationConfig:
|
||||
+ """Owner-installed independent read credential/route, never request fields."""
|
||||
+ origin: str
|
||||
+ token_file: Path
|
||||
+ timeout_seconds: float = 3
|
||||
+
|
||||
+
|
||||
+_HANDOFF_FIELDS={'execution_contract','approval_id','request_digest','decision_id',
|
||||
+ 'native_binding','approval_binding_digest','submitted_request_digest',
|
||||
+ 'decision_not_before','decision_expires_at','approval_expires_at'}
|
||||
+
|
||||
+
|
||||
+def _strict_dependency():
|
||||
+ try:
|
||||
+ module=importlib.import_module('secrets_engine.task_execution_handoff')
|
||||
+ for name in ['TaskExecutionAuthorization','TaskExecutionConsumption',
|
||||
+ 'authorize_task_execution','consume_task_execution']:
|
||||
+ if not hasattr(module,name):raise ImportError()
|
||||
+ return module
|
||||
+ except ImportError:
|
||||
+ raise Refused('strict Secrets task execution handoff dependency unavailable') from None
|
||||
+
|
||||
+
|
||||
+def _fresh(cfg,handoff,descriptor):
|
||||
+ from secrets_engine.application_time import read_window,validity_bounds
|
||||
+ lower,upper=validity_bounds(window=read_window(cfg))
|
||||
+ try:
|
||||
+ parse=lambda value:datetime.fromisoformat(value.replace('Z','+00:00'))
|
||||
+ deadline=parse(descriptor['operation_deadline'])
|
||||
+ approval=parse(handoff['approval_expires_at']);decision=parse(handoff['decision_expires_at'])
|
||||
+ start=parse(handoff['decision_not_before']) if handoff['decision_not_before'] else None
|
||||
+ if any(value.tzinfo is None for value in [deadline,approval,decision]) or (start is not None and start.tzinfo is None):raise ValueError()
|
||||
+ if upper>=min(deadline,approval,decision) or deadline>min(approval,decision) or (start is not None and lower<start):raise ValueError()
|
||||
+ except (KeyError,TypeError,ValueError,AttributeError):
|
||||
+ raise Refused('original task authority deadline refused') from None
|
||||
+
|
||||
+
|
||||
+def _intent(journal,binding,kind,fields):
|
||||
+ digest=content_hash(encoded({'kind':kind,**fields}))
|
||||
+ ordinal=journal.next_call_ordinal(binding)
|
||||
+ journal.before_call(binding,ordinal,digest)
|
||||
+ return ordinal,digest
|
||||
+
|
||||
+
|
||||
+def _unknown(journal,binding):
|
||||
+ state=journal.observe(binding)['state']
|
||||
+ if state not in {'unknown','failed','cleanup-confirmed'}:
|
||||
+ journal.transition(binding,state,'unknown')
|
||||
+
|
||||
+
|
||||
+def run_native_fixture_task(*,compiler:Compiler,journal:Journal,plan_request:dict,
|
||||
+ owner_descriptor:bytes,secrets_cfg,entry,
|
||||
+ confirmation:ConfirmationConfig,
|
||||
+ native_admission:FixtureAdmission):
|
||||
+ """Authorize inside owner boundary, reserve, consume once, independently GET.
|
||||
+
|
||||
+ Strict authorization is executed here through the actual Secrets-owned module;
|
||||
+ an inbound TaskExecutionAuthorization or arbitrary callable is never accepted.
|
||||
+ This function returns closed metadata, not tokens or raw approval/decision data.
|
||||
+ Positive execution requires the module's real signed PDP path; actual unadmitted
|
||||
+ Access policy must still deny. Fixture policy/identity admission is independent
|
||||
+ synthetic setup and cannot become production acceptance.
|
||||
+ """
|
||||
+ strict=_strict_dependency()
|
||||
+ from approval_engine.execution_confirmation import ExecutionConfirmationClient
|
||||
+ from secrets_engine.execution_contract import ExecutionContract
|
||||
+ if type(confirmation) is not ConfirmationConfig or type(native_admission) is not FixtureAdmission:
|
||||
+ raise Refused('independent owner provider configuration required')
|
||||
+ try:
|
||||
+ origin=urlsplit(confirmation.origin)
|
||||
+ if origin.scheme not in {'http','https'} or origin.hostname!='127.0.0.1' or not origin.port or origin.username or origin.password or origin.path or origin.query or origin.fragment:
|
||||
+ raise ValueError()
|
||||
+ if confirmation.origin!=str(getattr(secrets_cfg,'approval_url','')).rstrip('/'):
|
||||
+ raise ValueError()
|
||||
+ descriptor=ExecutionContract.parse(json.loads(owner_descriptor)).as_dict()
|
||||
+ if encoded(descriptor)!=owner_descriptor or native_admission.descriptor!=owner_descriptor:
|
||||
+ raise ValueError()
|
||||
+ except Exception:
|
||||
+ raise Refused('exact owner descriptor or provider route refused') from None
|
||||
+ # An independently pinned owner catalogue must match every engine field. Its
|
||||
+ # approval IDs are source references, never runtime authority.
|
||||
+ proposal=compiler.proposal()
|
||||
+ target={key:getattr(entry,key) for key in proposal['catalog_target']}
|
||||
+ if encoded(target)!=encoded(proposal['catalog_target']) or entry.id!=proposal['catalog_id'] or entry.stage!='prod':
|
||||
+ raise Refused('owner catalogue does not match strict task request')
|
||||
+ snapshot={path:compiler.read(Path(path)) for path in compiler.pins}
|
||||
+ owner_entry=compiler._catalogue_entry(snapshot)
|
||||
+ # Only the served approval pointer is added after issue. Purpose or other
|
||||
+ # source approval semantics cannot be substituted through an inbound entry.
|
||||
+ without_reference=lambda value:{key:item for key,item in value.items() if key!='authorization_id'}
|
||||
+ if encoded(without_reference(entry.approval))!=encoded(without_reference(owner_entry.approval)):
|
||||
+ raise Refused('owner approval metadata differs from source')
|
||||
+ client=ExecutionConfirmationClient(confirmation.origin,confirmation.token_file,
|
||||
+ timeout_seconds=confirmation.timeout_seconds)
|
||||
+ with journal.locked():
|
||||
+ authorization=strict.authorize_task_execution(secrets_cfg,entry,
|
||||
+ execution_contract=ExecutionContract.parse(descriptor))
|
||||
+ if type(authorization) is not strict.TaskExecutionAuthorization:
|
||||
+ raise Refused('strict task authorization missing')
|
||||
+ handoff=authorization.as_owner_handoff()
|
||||
+ if type(handoff) is not dict or set(handoff)!=_HANDOFF_FIELDS or encoded(handoff['execution_contract'])!=owner_descriptor:
|
||||
+ raise Refused('strict task handoff does not match owner descriptor')
|
||||
+ _fresh(secrets_cfg,handoff,descriptor)
|
||||
+ request=dict(plan_request,request_digest=handoff['request_digest'])
|
||||
+ plan=compiler.bind_execution(compiler.compile(request),descriptor,owner_descriptor)
|
||||
+ binding=Binding(handoff['approval_id'],handoff['request_digest'],descriptor['operation_id'],plan.content_digest)
|
||||
+ # Constructor validates fixed native origin/fixture process/grant sources
|
||||
+ # but performs no native HTTP request or credential acquisition.
|
||||
+ native=NativeFixtureAdapter(compiler,plan,journal,binding,native_admission)
|
||||
+ journal.reserve(binding,native.cleanup)
|
||||
+ fields={key:handoff[key] for key in ['approval_id','request_digest','decision_id']}
|
||||
+ try:
|
||||
+ ordinal,digest=_intent(journal,binding,'strict-secrets-task-consume',fields)
|
||||
+ consumed=strict.consume_task_execution(secrets_cfg,authorization)
|
||||
+ if type(consumed) is not strict.TaskExecutionConsumption:
|
||||
+ raise Refused('strict task consumption missing')
|
||||
+ receipt={key:getattr(consumed,key) for key in ['approval_id','request_digest','decision_id','consumed_at','idempotent']}
|
||||
+ if any(receipt[key]!=value for key,value in fields.items()) or receipt['idempotent'] is not False:
|
||||
+ raise Refused('historical or mismatched task consumption refused')
|
||||
+ stamp=datetime.fromisoformat(receipt['consumed_at'].replace('Z','+00:00'))
|
||||
+ if stamp.tzinfo is None:raise Refused('consumption timestamp missing')
|
||||
+ journal.after_call(binding,ordinal,digest,content_hash(encoded(receipt)))
|
||||
+ _fresh(secrets_cfg,handoff,descriptor)
|
||||
+ ordinal,digest=_intent(journal,binding,'independent-approval-consumption-read',fields)
|
||||
+ confirmed=client.confirm_consumption(binding.approval_id,binding.request_digest,
|
||||
+ handoff['decision_id'],expected_binding=handoff['native_binding'],
|
||||
+ expected_pdp_digest=handoff['approval_binding_digest'])
|
||||
+ if confirmed!={key:receipt[key] for key in ['approval_id','request_digest','decision_id','consumed_at']}:
|
||||
+ raise Refused('independent consumption correspondence refused')
|
||||
+ journal.after_call(binding,ordinal,digest,content_hash(encoded(confirmed)))
|
||||
+ _fresh(secrets_cfg,handoff,descriptor)
|
||||
+ journal.transition(binding,'reserved','consume-confirmed',content_hash(encoded(confirmed)))
|
||||
+ except Exception:
|
||||
+ _unknown(journal,binding)
|
||||
+ raise Refused('task consumption or independent confirmation unconfirmed; no native execution') from None
|
||||
+ result={'operation_id':binding.operation_id,'request_digest':binding.request_digest,
|
||||
+ 'decision_id':handoff['decision_id'],'native_apply':'unverified',
|
||||
+ 'native_calls_completed':0,'cleanup_confirmed':False}
|
||||
+ try:
|
||||
+ journal.transition(binding,'consume-confirmed','authenticating')
|
||||
+ # Fixture identity is independently seeded synthetic setup, not CCR38
|
||||
+ # provenance proof or a production service login.
|
||||
+ journal.transition(binding,'authenticating','grant-issuing')
|
||||
+ native.materialize_grant()
|
||||
+ journal.transition(binding,'grant-issued','applying')
|
||||
+ for ordinal in range(6):
|
||||
+ _fresh(secrets_cfg,handoff,descriptor)
|
||||
+ native.metadata_call(ordinal)
|
||||
+ result['native_calls_completed']+=1
|
||||
+ except Exception:
|
||||
+ _unknown(journal,binding)
|
||||
+ result['native_apply']='unknown'
|
||||
+ finally:
|
||||
+ result['cleanup_confirmed']=run_cleanup(journal,binding,native)['confirmed']
|
||||
+ # Native total expected-state normalization/acceptance remains explicitly
|
||||
+ # absent. Six successful calls cannot be relabelled a verified owner trial.
|
||||
+ result['business_state']=journal.observe(binding)['state']
|
||||
+ return result
|
||||
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
|
||||
index 29d168d..c9ee244 100644
|
||||
--- a/scripts/task_execution_native_fixture.py
|
||||
+++ b/scripts/task_execution_native_fixture.py
|
||||
@@ -122,8 +122,8 @@ class NativeFixtureAdapter:
|
||||
self.used+=1
|
||||
evidence_payload = encoded({'token':'owner-retained-task-token'}) if path=='auth/token/lookup' else payload
|
||||
digest=content_hash(encoded({'method':method,'path':path,'payload_sha256':'sha256:'+content_hash(evidence_payload),'credential_slot':'task' if task else 'owner'}))
|
||||
- ordinal=self._ordinal
|
||||
- self.journal.before_call(self.binding,ordinal,digest);self._ordinal+=1
|
||||
+ ordinal=self.journal.next_call_ordinal(self.binding)
|
||||
+ self.journal.before_call(self.binding,ordinal,digest);self._ordinal=ordinal+1
|
||||
try: status,data=self._http(method,path,token,payload)
|
||||
except Exception:
|
||||
state=self.journal.observe(self.binding)['state']
|
||||
diff --git a/tests/task_execution_source_servers.py b/tests/task_execution_source_servers.py
|
||||
new file mode 100644
|
||||
index 0000000..3570382
|
||||
--- /dev/null
|
||||
+++ b/tests/task_execution_source_servers.py
|
||||
@@ -0,0 +1,206 @@
|
||||
+"""Real Approval/Access servers; every admission/key/identity is synthetic local.
|
||||
+
|
||||
+The positive fixture policy is separately named and exact-descriptor scoped. It
|
||||
+never edits either existing unadmitted production task policy or their false gate.
|
||||
+"""
|
||||
+import base64
|
||||
+from contextlib import contextmanager
|
||||
+from dataclasses import replace
|
||||
+from datetime import datetime,timedelta,timezone
|
||||
+import hashlib
|
||||
+from http.client import HTTPConnection
|
||||
+from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import shutil
|
||||
+import socket
|
||||
+import subprocess
|
||||
+import threading
|
||||
+import time
|
||||
+from types import SimpleNamespace
|
||||
+from wsgiref.simple_server import make_server,WSGIRequestHandler
|
||||
+
|
||||
+from cryptography import x509
|
||||
+from cryptography.hazmat.primitives import serialization,hashes
|
||||
+from cryptography.hazmat.primitives.asymmetric import ed25519,rsa
|
||||
+from cryptography.x509.oid import NameOID
|
||||
+
|
||||
+ACCESS=Path(os.environ.get('ACCESS_ENGINE_SOURCE_ROOT','/home/worsch/access-engine'))
|
||||
+APPROVAL=Path(os.environ.get('APPROVAL_ENGINE_SOURCE_ROOT','/home/worsch/approval-engine'))
|
||||
+SECRETS=Path(os.environ.get('SECRETS_ENGINE_SOURCE_ROOT','/home/worsch/secrets-engine'))
|
||||
+FIXTURE_PACKAGE='platform.disposable-task-seam.fixture'
|
||||
+FIXTURE_VERSION='v1'
|
||||
+PRINCIPAL='system:serviceaccount:secrets-engine:secrets-engine'
|
||||
+
|
||||
+
|
||||
+def private(path,text):path.write_text(text);path.chmod(0o600);return path
|
||||
+
|
||||
+def free_port():
|
||||
+ with socket.socket() as sock:sock.bind(('127.0.0.1',0));return sock.getsockname()[1]
|
||||
+
|
||||
+
|
||||
+def http(origin,method,path,body=None,token=None):
|
||||
+ from urllib.parse import urlsplit
|
||||
+ parsed=urlsplit(origin);conn=HTTPConnection(parsed.hostname,parsed.port,timeout=4)
|
||||
+ try:
|
||||
+ headers={'Content-Type':'application/json'}
|
||||
+ if token is not None:headers['Authorization']='Bearer '+token
|
||||
+ conn.request(method,path,body=json.dumps(body) if body is not None else None,headers=headers)
|
||||
+ response=conn.getresponse();raw=response.read(1024*1024)
|
||||
+ return response.status,json.loads(raw) if raw else {}
|
||||
+ finally:conn.close()
|
||||
+
|
||||
+
|
||||
+class QuietWSGI(WSGIRequestHandler):
|
||||
+ def log_message(self,*args):pass
|
||||
+
|
||||
+
|
||||
+@contextmanager
|
||||
+def approval_server(directory):
|
||||
+ from approval_engine.api import App
|
||||
+ from approval_engine.auth import Identity,StaticTokenAuthenticator
|
||||
+ from approval_engine.store import Engine
|
||||
+ service='synthetic-source-service';human='synthetic-source-human';no_read='synthetic-source-no-read'
|
||||
+ def identity(subject,kind,scopes):
|
||||
+ return Identity(subject,'https://synthetic.invalid',('approval-engine',),kind,'tenant:platform',
|
||||
+ frozenset(),frozenset(scopes),{'level':'aal2','methods':['synthetic']},
|
||||
+ 'fixture:synthetic-identity',tenant_source='directory' if kind=='human' else 'registration')
|
||||
+ identities={service:identity('secrets-engine','service',['approval:create','approval:read','approval:consume']),
|
||||
+ human:identity('synthetic-founder','human',['approval:approve','approval:read']),
|
||||
+ no_read:identity('platform-fixture-confirmation','service',['approval:consume'])}
|
||||
+ engine=Engine(str(directory/'approval.sqlite'));app=App(engine,StaticTokenAuthenticator(identities))
|
||||
+ calls=[]
|
||||
+ def logged(environ,start_response):
|
||||
+ calls.append((environ['REQUEST_METHOD'],environ['PATH_INFO']))
|
||||
+ return app(environ,start_response)
|
||||
+ server=make_server('127.0.0.1',0,logged,handler_class=QuietWSGI)
|
||||
+ thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
+ try:
|
||||
+ yield SimpleNamespace(origin='http://127.0.0.1:'+str(server.server_port),engine=engine,
|
||||
+ service_token=service,human_token=human,no_read_token=no_read,calls=calls)
|
||||
+ finally:server.shutdown();server.server_close();thread.join(5);engine.close()
|
||||
+
|
||||
+
|
||||
+@contextmanager
|
||||
+def reviewer_server(directory):
|
||||
+ # Actual Access TokenReview client consumes this independently admitted
|
||||
+ # synthetic reviewer. This is not a Kubernetes authentication proof.
|
||||
+ exp=int(time.time())+900
|
||||
+ payload=base64.urlsafe_b64encode(json.dumps({'exp':exp}).encode()).decode().rstrip('=')
|
||||
+ token='synthetic.'+payload+'.synthetic';reviewer='synthetic-reviewer';calls=[]
|
||||
+ class Handler(BaseHTTPRequestHandler):
|
||||
+ def log_message(self,*args):pass
|
||||
+ def do_POST(self):
|
||||
+ size=int(self.headers.get('Content-Length','0'));data=json.loads(self.rfile.read(size))
|
||||
+ valid=(self.path=='/apis/authentication.k8s.io/v1/tokenreviews'
|
||||
+ and self.headers.get('Authorization')=='Bearer '+reviewer
|
||||
+ and data.get('spec',{}).get('token')==token
|
||||
+ and 'flex-auth' in data.get('spec',{}).get('audiences',[]))
|
||||
+ calls.append(valid)
|
||||
+ result={'apiVersion':'authentication.k8s.io/v1','kind':'TokenReview',
|
||||
+ 'status':{'authenticated':valid,'audiences':['flex-auth'],
|
||||
+ 'user':{'username':PRINCIPAL}}}
|
||||
+ raw=json.dumps(result).encode();self.send_response(201)
|
||||
+ self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(raw)));self.end_headers();self.wfile.write(raw)
|
||||
+ server=ThreadingHTTPServer(('127.0.0.1',0),Handler);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
+ # The actual reviewer constructor requires a valid CA even for its explicitly
|
||||
+ # synthetic HTTP endpoint; this CA never trusts an estate endpoint.
|
||||
+ key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||
+ name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic-local-reviewer')])
|
||||
+ now=datetime.now(timezone.utc)
|
||||
+ cert=x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(days=1)).sign(key,hashes.SHA256())
|
||||
+ ca=directory/'fixture-ca.pem';ca.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
+ reviewer_file=private(directory/'reviewer.token',reviewer)
|
||||
+ try:yield SimpleNamespace(origin='http://127.0.0.1:'+str(server.server_port),caller_token=token,reviewer_file=reviewer_file,ca=ca,calls=calls)
|
||||
+ finally:server.shutdown();server.server_close();thread.join(5)
|
||||
+
|
||||
+
|
||||
+def fixture_policy(directory,descriptor,request):
|
||||
+ policy=directory/'fixture-policy.md'
|
||||
+ fixture=directory/'fixture-cases.json'
|
||||
+ fixture.write_text(json.dumps([{'id':'synthetic-claim-free-deny','request':request,
|
||||
+ 'expect':{'effect':'deny','reason':'fixture_unapproved'}}]))
|
||||
+ document='''---
|
||||
+id: platform.disposable-task-seam.fixture
|
||||
+name: Disposable exact task seam fixture
|
||||
+namespace: secrets-engine:secret-catalog-lane
|
||||
+version: v1
|
||||
+status: proposed
|
||||
+package: fixture.platform.task_seam
|
||||
+allow_ttl: 5m
|
||||
+actions: [execute-ccr]
|
||||
+owner: team:synthetic-fixture
|
||||
+fixtures: [fixture-cases.json]
|
||||
+caring: {profile: caring-0.4.0-rc2, enforce: false}
|
||||
+activation: {mode: local}
|
||||
+---
|
||||
+Synthetic local policy only. No production task policy is edited or enabled.
|
||||
+
|
||||
+```rego
|
||||
+default decision := {"effect":"deny","reason":"fixture_unapproved"}
|
||||
+decision := {"effect":"allow","reason":"synthetic_exact_task_approved"} {
|
||||
+ input.action == "execute-ccr"
|
||||
+ input.tenant == "tenant:platform"
|
||||
+ input.subject.id == "secrets-engine"
|
||||
+ input.subject.type == "service"
|
||||
+ input.resource.system == "secrets-engine"
|
||||
+ input.context.human_control == true
|
||||
+ input.context.approval.valid_now == true
|
||||
+ input.context.approval.consumed == false
|
||||
+ input.context.approval.binding.human_control == true
|
||||
+ input.context.execution_contract == DESCRIPTOR
|
||||
+}
|
||||
+```
|
||||
+''' + '''
|
||||
+```rego test
|
||||
+import data.fixture.platform.task_seam
|
||||
+test_missing_approval_denies { task_seam.decision.effect == "deny" with input as {} }
|
||||
+```
|
||||
+'''
|
||||
+ document=document.replace('DESCRIPTOR',json.dumps(descriptor,separators=(',',':')))
|
||||
+ policy.write_text(document)
|
||||
+ return policy
|
||||
+
|
||||
+
|
||||
+@contextmanager
|
||||
+def access_server(directory,descriptor,request,reviewer,*,actual_policy=False):
|
||||
+ binary=Path(os.environ.get('PLATFORM_ACCESS_FIXTURE_BINARY','/tmp/netkingdom-platform-wave4-access'))
|
||||
+ verifier=Path(os.environ.get('PLATFORM_PDP_FIXTURE_VERIFIER','/tmp/netkingdom-platform-wave4-verify'))
|
||||
+ if not binary.is_file() or not verifier.is_file():raise RuntimeError('build source fixture binaries first')
|
||||
+ registry=directory/'registry.json';registry.write_bytes((ACCESS/'examples/secrets-engine/registry_snapshot.json').read_bytes())
|
||||
+ policy=ACCESS/'examples/secrets-engine-service-task-execution/policy_package.md' if actual_policy else fixture_policy(directory,descriptor,request)
|
||||
+ requestfile=directory/'claim-free-request.json';requestfile.write_text(json.dumps(request))
|
||||
+ # Trusted owner-local source CLI derives the real package pin independently
|
||||
+ # from the network responder. Its DENY is used only as digest provenance.
|
||||
+ offline=subprocess.run([str(binary),'check','--registry',str(registry),'--policy',str(policy),'--request',str(requestfile)],capture_output=True,text=True,timeout=15)
|
||||
+ if offline.returncode!=0:raise RuntimeError('source policy fixture invalid: '+offline.stderr[:300])
|
||||
+ produced=json.loads(offline.stdout);provenance=produced['provenance']
|
||||
+ key=ed25519.Ed25519PrivateKey.generate()
|
||||
+ seed=key.private_bytes(serialization.Encoding.Raw,serialization.PrivateFormat.Raw,serialization.NoEncryption())
|
||||
+ public=key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw)
|
||||
+ b64=lambda value:base64.urlsafe_b64encode(value).decode().rstrip('=')
|
||||
+ seedfile=private(directory/'signing.seed',b64(seed))
|
||||
+ trust={name:provenance[name] for name in ['evaluator','mode','policy_package','policy_version','policy_package_digest']}
|
||||
+ trust.update(schema='secrets-engine.pdp-trust.v1',keys={'synthetic-owner-key':b64(public)})
|
||||
+ trustfile=directory/'pdp-trust.json';trustfile.write_text(json.dumps(trust))
|
||||
+ port=free_port();origin='http://127.0.0.1:'+str(port)
|
||||
+ args=[str(binary),'serve','--addr','127.0.0.1:'+str(port),'--registry',str(registry),'--policy',str(policy),
|
||||
+ '--envelope-signing-key-file',str(seedfile),'--envelope-signing-kid','synthetic-owner-key',
|
||||
+ '--caller-auth-mode','enforce','--caller-audience','flex-auth','--caller-kubernetes-url',reviewer.origin,
|
||||
+ '--caller-reviewer-token-file',str(reviewer.reviewer_file),'--caller-ca-file',str(reviewer.ca),
|
||||
+ '--caller-binding','secrets-engine='+PRINCIPAL]
|
||||
+ process=subprocess.Popen(args,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
+ for _ in range(100):
|
||||
+ if process.poll() is not None:raise RuntimeError('source Access server exited')
|
||||
+ try:
|
||||
+ with socket.create_connection(('127.0.0.1',port),timeout=.1):break
|
||||
+ except OSError:time.sleep(.05)
|
||||
+ else:process.kill();process.wait();raise RuntimeError('source Access server failed to start')
|
||||
+ try:yield SimpleNamespace(origin=origin,trustfile=trustfile,verifier=verifier,
|
||||
+ package=provenance['policy_package'],version=provenance['policy_version'],
|
||||
+ process=process,offline_envelope=produced)
|
||||
+ finally:
|
||||
+ process.terminate()
|
||||
+ try:process.wait(timeout=5)
|
||||
+ except subprocess.TimeoutExpired:process.kill();process.wait()
|
||||
diff --git a/tests/test_task_execution_mediation_journal.py b/tests/test_task_execution_mediation_journal.py
|
||||
new file mode 100644
|
||||
index 0000000..d0f8e19
|
||||
--- /dev/null
|
||||
+++ b/tests/test_task_execution_mediation_journal.py
|
||||
@@ -0,0 +1,32 @@
|
||||
+"""Durable CAS/confirmation call prefix cannot overlap native call ordinals."""
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import sys
|
||||
+import uuid
|
||||
+import pytest
|
||||
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
|
||||
+from task_execution_journal import Binding,Cleanup,Journal,Refused
|
||||
+
|
||||
+
|
||||
+def test_next_call_continues_auth_prefix_without_replay_or_resume(tmp_path):
|
||||
+ os.chmod(tmp_path,0o700);journal=Journal(tmp_path)
|
||||
+ binding=Binding('fixture-approval','sha256:'+'a'*64,str(uuid.uuid4()),'b'*64)
|
||||
+ try:
|
||||
+ with pytest.raises(Refused):journal.next_call_ordinal(binding)
|
||||
+ with journal.locked():
|
||||
+ journal.reserve(binding,(Cleanup('task-token-revoke','c'*64),))
|
||||
+ assert journal.next_call_ordinal(binding)==0
|
||||
+ for ordinal in [0,1]:
|
||||
+ journal.before_call(binding,ordinal,'d'*64)
|
||||
+ with pytest.raises(Refused):journal.next_call_ordinal(binding)
|
||||
+ journal.after_call(binding,ordinal,'d'*64,'e'*64)
|
||||
+ assert journal.next_call_ordinal(binding)==2
|
||||
+ journal.transition(binding,'reserved','consume-confirmed')
|
||||
+ journal.before_call(binding,2,'f'*64)
|
||||
+ with pytest.raises(Refused):journal.before_call(binding,0,'f'*64)
|
||||
+ journal.after_call(binding,2,'f'*64,'e'*64)
|
||||
+ journal.before_cleanup(binding,0,Cleanup('task-token-revoke','c'*64))
|
||||
+ with pytest.raises(Refused):journal.next_call_ordinal(binding)
|
||||
+ with journal.locked():
|
||||
+ with pytest.raises(Refused):journal.next_call_ordinal(binding)
|
||||
+ finally:journal.close()
|
||||
diff --git a/tests/test_task_execution_mediator.py b/tests/test_task_execution_mediator.py
|
||||
new file mode 100644
|
||||
index 0000000..8ad9045
|
||||
--- /dev/null
|
||||
+++ b/tests/test_task_execution_mediator.py
|
||||
@@ -0,0 +1,160 @@
|
||||
+"""Whole source-server/native fixture proof, never production owner acceptance."""
|
||||
+from contextlib import contextmanager
|
||||
+from dataclasses import replace
|
||||
+import importlib
|
||||
+import json
|
||||
+from pathlib import Path
|
||||
+import sys
|
||||
+from types import SimpleNamespace
|
||||
+import uuid
|
||||
+
|
||||
+import pytest
|
||||
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
|
||||
+from test_task_execution_native_fixture import owner,bao_dev,compiler
|
||||
+from task_execution_mediator import ConfirmationConfig,run_native_fixture_task
|
||||
+from task_execution_journal import Refused
|
||||
+from task_execution_source_servers import approval_server,reviewer_server,access_server,http,private
|
||||
+from secrets_engine.approval_consume import _request_purpose
|
||||
+from secrets_engine.approval_claim import binding_from_check_request
|
||||
+from secrets_engine.authorization import build_action_request
|
||||
+from secrets_engine.decision_check import check_decision
|
||||
+from secrets_engine.execution_contract import ExecutionContract
|
||||
+
|
||||
+
|
||||
+@contextmanager
|
||||
+def whole_fixture(owner,tmp_path,*,actual_policy=False,confirmation_read=True):
|
||||
+ native,journal,binding,call,compiler,descriptor=owner
|
||||
+ snapshot={path:compiler.read(Path(path)) for path in compiler.pins}
|
||||
+ entry=compiler._catalogue_entry(snapshot)
|
||||
+ approval_id=str(uuid.uuid4())
|
||||
+ entry=replace(entry,approval={**entry.approval,'authorization_id':approval_id})
|
||||
+ request=build_action_request(entry,'execute-ccr',subject_id='secrets-engine',subject_type='service',
|
||||
+ purpose=_request_purpose(entry),execution_contract=ExecutionContract.parse(descriptor))
|
||||
+ with reviewer_server(tmp_path) as reviewer, access_server(tmp_path,descriptor,request,reviewer,actual_policy=actual_policy) as access, approval_server(tmp_path) as approval:
|
||||
+ issue=access.offline_envelope
|
||||
+ body={'id':approval_id,'binding':binding_from_check_request(issue['binding']),
|
||||
+ 'pdp_digest':issue['binding']['request_digest'],'pdp_path':True,'human_control':True,
|
||||
+ 'validity':{'not_before':'2026-01-01T00:00:00+00:00',
|
||||
+ 'expires_at':reviewer_expiry()}}
|
||||
+ status,obj=http(approval.origin,'POST','/v1/approvals',body,approval.service_token)
|
||||
+ assert status==201
|
||||
+ assert http(approval.origin,'POST','/v1/approvals/'+approval_id+'/entries',{},approval.human_token)[0]==200
|
||||
+ approval_token=private(tmp_path/'approval.token',approval.service_token)
|
||||
+ pdp_token=private(tmp_path/'pdp.token',reviewer.caller_token)
|
||||
+ confirmation_token=private(tmp_path/'confirmation.token',approval.service_token if confirmation_read else approval.no_read_token)
|
||||
+ cfg=SimpleNamespace(approval_url=approval.origin,approval_token_file=approval_token,
|
||||
+ approval_client_secret_file=None,pdp_url=access.origin,pdp_token_file=pdp_token,
|
||||
+ pdp_trust_file=access.trustfile,pdp_verifier=access.verifier,
|
||||
+ authorization_subject_id='secrets-engine',authorization_subject_type='service',
|
||||
+ authorization_policy_package=access.package,authorization_policy_version=access.version,
|
||||
+ clock_trust_file=None)
|
||||
+ yield SimpleNamespace(native=native,journal=journal,call=call,compiler=compiler,
|
||||
+ descriptor=descriptor,entry=entry,cfg=cfg,approval=approval,
|
||||
+ confirmation=ConfirmationConfig(approval.origin,confirmation_token),
|
||||
+ approval_id=approval_id,request=request,reviewer=reviewer)
|
||||
+
|
||||
+
|
||||
+def reviewer_expiry():
|
||||
+ from datetime import datetime,timedelta,timezone
|
||||
+ return (datetime.now(timezone.utc)+timedelta(minutes=10)).isoformat()
|
||||
+
|
||||
+
|
||||
+def invoke(fixture):
|
||||
+ return run_native_fixture_task(compiler=fixture.compiler,journal=fixture.journal,
|
||||
+ plan_request=fixture.native.meta['request'],owner_descriptor=fixture.native.admission.descriptor,
|
||||
+ secrets_cfg=fixture.cfg,entry=fixture.entry,confirmation=fixture.confirmation,
|
||||
+ native_admission=fixture.native.admission)
|
||||
+
|
||||
+
|
||||
+def test_real_source_servers_signed_fixture_allow_and_actual_task_policy_deny(owner,tmp_path):
|
||||
+ # These two Checks test real source servers and independently installed keys;
|
||||
+ # they are not a fabricated DecisionEnvelope or activation of a real policy.
|
||||
+ for actual_policy in [False,True]:
|
||||
+ directory=tmp_path/('actual-policy' if actual_policy else 'fixture-policy');directory.mkdir()
|
||||
+ with whole_fixture(owner,directory,actual_policy=actual_policy) as fixture:
|
||||
+ status,claim=http(fixture.approval.origin,'GET','/v1/approvals/'+fixture.approval_id+'/claim',token=fixture.approval.service_token)
|
||||
+ assert status==200
|
||||
+ request=json.loads(json.dumps(fixture.request));request['context']['approval']=claim
|
||||
+ envelope=check_decision(base_url=fixture.cfg.pdp_url,token_file=fixture.cfg.pdp_token_file,request=request,
|
||||
+ trust_file=fixture.cfg.pdp_trust_file,verifier=fixture.cfg.pdp_verifier)
|
||||
+ assert envelope['effect']==('deny' if actual_policy else 'allow')
|
||||
+ assert envelope['provenance']['caller']['mode']=='enforce'
|
||||
+ assert all(fixture.reviewer.calls)
|
||||
+ assert fixture.approval.engine.get(fixture.approval_id).status=='approved'
|
||||
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)[0]==404
|
||||
+
|
||||
+
|
||||
+def test_missing_strict_dependency_never_reads_credentials_or_reserves(monkeypatch):
|
||||
+ original=importlib.import_module
|
||||
+ def missing(name,*args,**kwargs):
|
||||
+ if name=='secrets_engine.task_execution_handoff':raise ImportError()
|
||||
+ return original(name,*args,**kwargs)
|
||||
+ monkeypatch.setattr(importlib,'import_module',missing)
|
||||
+ with pytest.raises(Refused,match='dependency unavailable'):
|
||||
+ run_native_fixture_task(compiler=None,journal=None,plan_request={},owner_descriptor=b'',
|
||||
+ secrets_cfg=None,entry=None,confirmation=None,native_admission=None)
|
||||
+
|
||||
+
|
||||
+def test_whole_strict_consume_confirmation_precedes_real_native_effect(owner,tmp_path):
|
||||
+ with whole_fixture(owner,tmp_path) as fixture:
|
||||
+ result=invoke(fixture)
|
||||
+ assert result['native_calls_completed']==6 and result['cleanup_confirmed']
|
||||
+ assert result['native_apply']=='unverified' # total owner baseline/readback acceptance remains separate
|
||||
+ obj=fixture.approval.engine.get(fixture.approval_id)
|
||||
+ assert obj.status=='consumed'
|
||||
+ assert obj.consumed_digest==result['request_digest']
|
||||
+ calls=fixture.journal.db.execute('SELECT ordinal,outcome FROM calls ORDER BY ordinal').fetchall()
|
||||
+ assert calls[:2]==[(0,'complete'),(1,'complete')]
|
||||
+ assert len(calls)==13 # CAS + GET + five native grant calls + six metadata calls
|
||||
+ assert fixture.approval.calls.count(('POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
|
||||
+ assert fixture.approval.calls.count(('GET','/v1/approvals/'+fixture.approval_id))==1
|
||||
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)[0]==404
|
||||
+ raw=(fixture.journal.directory/'journal.sqlite').read_bytes()
|
||||
+ for token in [fixture.native.admission.owner_token,fixture.native.admission.identity_session.token,
|
||||
+ fixture.approval.service_token,fixture.reviewer.caller_token]:
|
||||
+ assert token.encode() not in raw
|
||||
+ with pytest.raises(Exception):invoke(fixture)
|
||||
+ assert fixture.approval.calls.count(('POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
|
||||
+
|
||||
+
|
||||
+def test_actual_confirmation_read_scope_denial_keeps_native_untouched(owner,tmp_path):
|
||||
+ with whole_fixture(owner,tmp_path,confirmation_read=False) as fixture:
|
||||
+ with pytest.raises(Refused,match='confirmation unconfirmed'):invoke(fixture)
|
||||
+ assert fixture.approval.engine.get(fixture.approval_id).status=='consumed'
|
||||
+ state=fixture.journal.db.execute('SELECT state FROM operations').fetchone()[0]
|
||||
+ assert state=='unknown'
|
||||
+ assert fixture.journal.db.execute("SELECT COUNT(*) FROM calls WHERE outcome='pending'").fetchone()[0]==1
|
||||
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)[0]==404
|
||||
+ policy_path=fixture.compiler.before_call(fixture.native.plan,1).path
|
||||
+ assert fixture.call('GET',policy_path)[0]==404
|
||||
+ with pytest.raises(Exception):invoke(fixture)
|
||||
+ assert fixture.approval.calls.count(('POST','/v1/approvals/'+fixture.approval_id+'/consume'))==1
|
||||
+
|
||||
+
|
||||
+def test_actual_unadmitted_policy_refuses_before_cas_and_native(owner,tmp_path):
|
||||
+ with whole_fixture(owner,tmp_path,actual_policy=True) as fixture:
|
||||
+ with pytest.raises(Exception):invoke(fixture)
|
||||
+ assert fixture.approval.engine.get(fixture.approval_id).status=='approved'
|
||||
+ assert not any(method=='POST' and path.endswith('/consume') for method,path in fixture.approval.calls)
|
||||
+ assert fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
|
||||
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)[0]==404
|
||||
+
|
||||
+
|
||||
+def test_entry_purpose_substitution_refuses_before_authorizer_or_cas(owner,tmp_path):
|
||||
+ with whole_fixture(owner,tmp_path) as fixture:
|
||||
+ fixture.entry=replace(fixture.entry,approval={**fixture.entry.approval,'purpose':'caller-substituted-purpose'})
|
||||
+ before=list(fixture.approval.calls)
|
||||
+ with pytest.raises(Refused,match='approval metadata differs'):invoke(fixture)
|
||||
+ assert fixture.approval.calls==before
|
||||
+ assert fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
|
||||
+ assert fixture.call('GET','sys/policies/acl/'+fixture.native.policy_name)[0]==404
|
||||
+
|
||||
+
|
||||
+def test_catalogue_bool_integer_alias_refuses_before_authorizer(owner,tmp_path):
|
||||
+ with whole_fixture(owner,tmp_path) as fixture:
|
||||
+ assert fixture.entry.delivery_auth['secret_id_num_uses']==1
|
||||
+ fixture.entry=replace(fixture.entry,delivery_auth={**fixture.entry.delivery_auth,'secret_id_num_uses':True})
|
||||
+ before=list(fixture.approval.calls)
|
||||
+ with pytest.raises(Refused,match='catalogue does not match'):invoke(fixture)
|
||||
+ assert fixture.approval.calls==before
|
||||
+ assert fixture.journal.db.execute('SELECT COUNT(*) FROM operations').fetchone()[0]==0
|
||||
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
index f9af000..4ea8015 100644
|
||||
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
@@ -1262,3 +1262,18 @@ bodies before a native fixture can materialize. Proposed source records gain no
|
||||
execution authority; no real grant, policy or legacy applier registration added.
|
||||
Details and remaining production gates: docs/task-execution-grant-projection.md.
|
||||
T12 remains progress; no owner sync or production effect performed by this package.
|
||||
+
|
||||
+2026-10-16 T12 fourth source package: connected the Secrets-owned strict task
|
||||
+handoff to actual Approval ExecutionConfirmationClient independent GET before
|
||||
+consume-confirmed/native fixture work. Platform accepts no supplied authorization
|
||||
+boolean/object/callback and preserves its exact owner descriptor/grant/Plan join.
|
||||
+Durable CAS/GET intents precede native ordinals through the repaired journal
|
||||
+prefix API. Actual source-server fixture uses signed Access/enforced synthetic
|
||||
+TokenReview, real Approval WSGI/SQLite with distinct synthetic human/service
|
||||
+identities, actual strict Secrets signature/CAS validation and disposable native
|
||||
+OpenBao. Untouched actual task policy remains signed DENY; separately named
|
||||
+synthetic fixture policy is never production admission. Read-scope denial after
|
||||
+CAS records consumed/unknown with zero native effects. Exact dependency/source
|
||||
+hashes, tests and remaining gates are retained with the source package and in
|
||||
+docs/task-execution-mediator-seam.md. No real grant/custody, policy default, owner
|
||||
+sync or native production acceptance is performed; T12 stays progress.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
|
||||
"decision": "approved",
|
||||
"scope": "Source-only owner strict handoff/independent Approval confirmation/native fixture seam",
|
||||
"owner_work_item": "RPF-WP-0035-T12",
|
||||
"base_commit": "d042d468df6d4085bb93a73ec26f4c4103554575",
|
||||
"patch_sha256": "f26a5e1f63f68c487930d3f076502f1bae73d16107d3be2537aeeda56e73628e",
|
||||
"reviewed_files_sha256": {
|
||||
"docs/task-execution-mediator-seam.md": "bedef06305956d1db7887fb9b86b5b5311f34ee3a6106df573c4a93de4e3d577",
|
||||
"scripts/task_execution_journal.py": "507b41a790e678dfd16af747bd851f5abc2fed5a8fb70bca05449d9e5ac674e8",
|
||||
"scripts/task_execution_mediator.py": "312b0b0fd246484d4537485db8db7b0e9b67e7c2cc10a5b2b68ac264ede4c180",
|
||||
"scripts/task_execution_native_fixture.py": "60abb78634038ca9422a35265c7a21b6653fed8ef5b85c4d50e846f4e3da4edf",
|
||||
"tests/task_execution_source_servers.py": "271e78fbfa3bf23f1c852297151d0ad38ab6bb5394851875a565f43453e1c57c",
|
||||
"tests/test_task_execution_mediation_journal.py": "ced684dbb4b2454b50bc100649d9cee3a0882ac8aef04639ce399a69f54b00db",
|
||||
"tests/test_task_execution_mediator.py": "a196a18038567275dfc10fb51ecc4ead99f58637959b9395ae4986ade47b7829",
|
||||
"workplans/RPF-WP-0035-credential-lane-implementation.md": "f70c0011cb5d2ef310d6e2bf392a0b6bb6f78b407b9182a1e38bed2f80472f23"
|
||||
},
|
||||
"strict_secrets_dependency": {
|
||||
"canonical_base": "c80a944",
|
||||
"staged_checkout": "/tmp/netkingdom-secrets-task-handoff",
|
||||
"module": "secrets_engine.task_execution_handoff",
|
||||
"files_sha256": {
|
||||
"src/secrets_engine/task_execution_handoff.py": "b82695b88c201fdcbd8854a0d3c37ee2d2d3feee2c0663d784180fa070ac6677",
|
||||
"src/secrets_engine/approval_consume.py": "bea80a503944b1b9796819cfea5f514c3184a724115130d16c3bc0c675f09a6b"
|
||||
},
|
||||
"source_owner": "secrets-engine; no Platform edits",
|
||||
"r1_module_sha256": "671a393aa2b9355e1c81c502a74f9ce8adad75fe7eb03492d11843a94b24d758",
|
||||
"r1_boundary": "Full129 test proof before owner independent native bool/int digest counterexample; R2 must be used for final integration."
|
||||
},
|
||||
"comment": "INTENT Secure Custody and Durable by Default require independently confirmed exact consumption and durable one-shot admission before owner-held native effects. This seam calls Secrets-owned strict authorization itself, compares exact owner descriptor/grant/Plan, journals CAS and authenticated independent provider GET, and preserves unknown outcomes with no blind retry. Actual existing task policy remains hard-closed. This review approves source preparation only, not founder approval, real grant or production/native acceptance.",
|
||||
"independent_from_implementation": false,
|
||||
"production_admission": false,
|
||||
"native_apply_verification": "unverified; total expected-state/CAS/service provenance acceptance remains separate",
|
||||
"remaining": [
|
||||
"Canonical adoption of exact strict Secrets R2 dependency and owner seam",
|
||||
"Real identity/provider/policy/grant/source/origin/custody admission; actual task runtime policy remains false",
|
||||
"Total native baseline/readback normalization/conflict control and target CCR immutable semantics",
|
||||
"Bounded isolated runtime, served-time/use accounting, audit/outbox and protected uncertain/crash recovery",
|
||||
"Exact founder-scoped attended production trial with owner-native acceptance"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
130 distinct owner checks passed across full R1 and final focused R2 runs; all final mediator/ordinal cases pass with R2
|
||||
|
||||
SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/tmp/netkingdom-secrets-task-handoff/src:/home/worsch/approval-engine python3 -m pytest -xq tests/test_task_execution_mediator.py tests/test_task_execution_mediation_journal.py tests/test_task_execution_plan.py tests/test_task_execution_journal.py tests/test_task_execution_cleanup.py tests/test_task_execution_cleanup_calls.py tests/test_task_execution_grant.py tests/test_task_execution_native_fixture.py
|
||||
129 passed in146.13s with strict Secrets R1; retained as intermediate only after its owner found native bool/int binding ambiguity.
|
||||
|
||||
SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/tmp/netkingdom-secrets-task-handoff/src:/home/worsch/approval-engine python3 -m pytest -xq tests/test_task_execution_mediator.py tests/test_task_execution_mediation_journal.py
|
||||
8 passed in57.14s with strict Secrets R2 and exact JSON catalogue/approval equality plus new bool/int negative.
|
||||
|
||||
No production acceptance. All keys/identity/policy/registry/grant/state/source admission synthetic; actual task policy remains DENY.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Platform exact disposable metadata acceptance
|
||||
|
||||
Existing owner task: RPF-WP-0035-T12 (progress); contract SECRETS-WP-0013.
|
||||
Prepared isolated from requested Platform base e2b832b. Canonical owner writes,
|
||||
sync, grants and installation remain coordinator-owned. No production/native
|
||||
completion or new approval is asserted.
|
||||
|
||||
The fixed adapter requires independently installed canonical metadata snapshots
|
||||
whose hashes match the existing descriptor expected_before/expected_after fields.
|
||||
Both maps must name exactly the two compiler-derived native paths. Explicit null
|
||||
means absent; only OpenBao's empty-errors 404 counts as absence. Each baseline
|
||||
gates that target's write. Complete policy HCL/name/version/cas_required and all
|
||||
role fields (aliases, defaults and unknown additions) compare as canonical JSON;
|
||||
only a valid aware policy modification timestamp is excluded. No expectation is
|
||||
inferred from an effect's returned state. Fixture expected role defaults explicitly
|
||||
pin the observed disposable OpenBao 2.5.5 Kubernetes response.
|
||||
|
||||
The actual mediator calls verify_metadata after all six ordered calls. It records
|
||||
readback->verified with a value-free snapshot commitment digest and returns
|
||||
native_apply=verified. All three separate cleanup obligations must confirm before
|
||||
cleanup-confirmed. A native denial or metadata mismatch transitions unknown even
|
||||
if a direct caller catches the exception; the next write cannot proceed.
|
||||
|
||||
Acceptance is sequential per target. Policy may already be applied when role
|
||||
baseline conflicts. A readback is a statement about that observation, without
|
||||
native CAS, external-writer exclusion or simultaneous final-state guarantee.
|
||||
There is no retry, rollback or reauthorization after uncertainty. SQLite reopening
|
||||
refuses reserve/dispatch, even while the disposable native effect survives.
|
||||
Cleanup handles remain memory-only: restart refusal is not recovery or proof that
|
||||
cleanup can finish after process loss. TSA03 remains open.
|
||||
|
||||
## Native admission packet still required
|
||||
|
||||
No actual fresh approved CCR/catalog/grant or production operation is selected.
|
||||
CCR-2026-0042 remains in_flight and lacks the corresponding Secrets catalogue;
|
||||
the fixture uses synthetic approved copies of CCR-2026-0002 and issue-core
|
||||
catalogue, random operation IDs and ephemeral loopback OpenBao. Do not promote
|
||||
its source paths, before/after defaults, synthetic identity or signing keys into
|
||||
production admission.
|
||||
|
||||
Owners must provide the chosen fresh metadata-only CCR/catalog, exact source and
|
||||
install manifests, exact immutable grant projection/body/hash, final compiled Plan
|
||||
and descriptor, owner-installed backend origin+cluster UID and before/after bytes
|
||||
with their hashes, and independently scoped owner custody. Access owns actual
|
||||
key/trust distribution, enforced caller mapping and exact reviewed runtime-policy
|
||||
admission; current actual policy stays DENY. Secrets owns bounded dispatcher
|
||||
adoption and trust/package configuration. Approval owns actual human approval,
|
||||
strict CAS and independently scoped read confirmation. Platform still needs
|
||||
independent scoped recovery authority and recoverable protected token/accessor
|
||||
custody, external-writer conflict control/native baseline guarantees where
|
||||
required, attended owner-native proof and cleanup-after-process-loss acceptance.
|
||||
These are exact remaining inputs/guarantees, not reasons to withhold this source
|
||||
slice. No cross-owner source edits were made.
|
||||
|
||||
The package records initial failing fixture-interface/teardown checks and their
|
||||
corrections, rather than suppressing them. Test success is disposable fixture
|
||||
source proof only. Public keys and metadata may be generated in ephemeral tests;
|
||||
secret values/handles are excluded from journal evidence and this packet.
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
diff --git a/docs/task-execution-mediator-seam.md b/docs/task-execution-mediator-seam.md
|
||||
index 964688b..463c056 100644
|
||||
--- a/docs/task-execution-mediator-seam.md
|
||||
+++ b/docs/task-execution-mediator-seam.md
|
||||
@@ -54,9 +54,10 @@ with a preceding durable CAS/confirmation sequence.
|
||||
native calls continue at2. Pending/unknown/cleanup-closed state cannot supply a
|
||||
next ordinal. Each native request retains its own durable intent and outcome.
|
||||
8. Cleanup stays under the original distinct obligations even if business work
|
||||
- fails. Returned evidence explicitly says native apply is unverified: six successful
|
||||
- native calls do not establish total approved baseline/readback normalization,
|
||||
- atomic CAS, service-login provenance or native production owner acceptance.
|
||||
+ fails. Wave 5 requires exact committed before/after snapshots before reporting
|
||||
+ native apply verified; six HTTP successes alone remain insufficient. This is
|
||||
+ sequential fixture acceptance, without atomic CAS, service-login provenance or
|
||||
+ native production owner acceptance.
|
||||
|
||||
The owner configuration selects provider route/read credential and the existing
|
||||
Secrets configuration. These are independently installed runtime inputs, never
|
||||
@@ -105,3 +106,10 @@ per-call served-time/use enforcement, audit/outbox and protected uncertain/crash
|
||||
recovery. Then obtain and execute the exact founder-scoped attended trial.
|
||||
Approval replay prevention and uncertain native-effect reconciliation remain
|
||||
separate guarantees. T12, Secrets WP13 and native adoption stay open.
|
||||
+
|
||||
+Wave 5 adds owner-installed exact metadata snapshots committed through the
|
||||
+existing expected_before/expected_after descriptor fields, per-target baseline
|
||||
+and complete role/policy readback comparison, durable readback/verified evidence,
|
||||
+and a separate cleanup-confirmed transition. The sequence remains sequential,
|
||||
+without native CAS or independently recoverable cleanup handles. See
|
||||
+[fixture acceptance](task-execution-native-fixture.md#exact-disposable-metadata-acceptance-2026-10-16-wave-5).
|
||||
diff --git a/docs/task-execution-native-fixture.md b/docs/task-execution-native-fixture.md
|
||||
index 7096f1a..1dd6b91 100644
|
||||
--- a/docs/task-execution-native-fixture.md
|
||||
+++ b/docs/task-execution-native-fixture.md
|
||||
@@ -33,7 +33,7 @@ metadata calls use independently recompiled source/method/path/body. A task use
|
||||
reserved for revoke; native token limits also enforce the ceiling. Business calls
|
||||
check wall and monotonic deadlines before effect, with a five-second cleanup reserve.
|
||||
No retry/remint, caller HCL/JSON/path, mount/AppRole/KV/SecretID or shell API exists.
|
||||
-The adapter cannot claim complete baseline CAS or normalized readback acceptance.
|
||||
+Wave 5 below adds exact sequential snapshot acceptance; native baseline CAS remains absent.
|
||||
|
||||
Every business/grant request records durable intent and closed status evidence.
|
||||
Native cleanup has its own durable per-request intents beneath the original
|
||||
@@ -89,3 +89,35 @@ native token, then performs explicit synthetic owner recovery. No blind retry oc
|
||||
No umbrella broker, general executor, new credential catalogue or standing service
|
||||
admin entitlement is introduced. Approval CAS prevents approval replay; it cannot
|
||||
make uncertain native effects exactly-once. RPF T12 and Secrets WP13 remain open.
|
||||
+
|
||||
+### Exact disposable metadata acceptance (2026-10-16, wave 5)
|
||||
+
|
||||
+The owner-installed `FixtureAdmission` now carries canonical JSON before/after
|
||||
+snapshot bytes. Their SHA-256 values must equal the existing descriptor's
|
||||
+`expected_before` and `expected_after`, and both snapshots must name exactly the
|
||||
+two compiled metadata paths. Explicit JSON null means absence. Only the actual
|
||||
+OpenBao empty-errors 404 is accepted as absence; denied/malformed reads fail.
|
||||
+These inputs contain metadata, never KV values or private token handles.
|
||||
+
|
||||
+Policy comparison retains name, HCL, version and cas_required. Only the
|
||||
+modification timestamp is excluded, after requiring an aware timestamp and the
|
||||
+closed observed policy field set. Role comparison retains every returned field,
|
||||
+including aliases, token defaults and unexpected additions, using canonical JSON
|
||||
+bytes (therefore false cannot equal zero). The disposable Kubernetes fixture pins
|
||||
+the complete OpenBao 2.5.5 response shape. Another plugin/version/default requires
|
||||
+an independently reviewed owner snapshot; nothing infers new accepted defaults.
|
||||
+
|
||||
+Each baseline gates its corresponding write. Policy readback occurs before role
|
||||
+baseline/write/readback. A role conflict can therefore leave the policy applied;
|
||||
+that operation remains unknown. This sequence has no native CAS, aggregate
|
||||
+baseline gate, concurrent-writer exclusion, or simultaneous final-state proof.
|
||||
+After all six calls match the committed snapshots, the same-live adapter records
|
||||
+readback and verified with a value-free evidence digest. The mediator reports
|
||||
+native_apply verified; cleanup-confirmed requires all separate cleanup receipts.
|
||||
+Readback success with unconfirmed cleanup cannot reach cleanup-confirmed.
|
||||
+
|
||||
+Reopening SQLite cannot dispatch, reconsume, restore token/accessor handles or
|
||||
+retry cleanup. Native effects can survive process loss. Cleanup handles remain
|
||||
+memory-only: no independently authorized recovery mechanism is implemented here.
|
||||
+This closes disposable sequential apply acceptance only, not TSA03 or native
|
||||
+production recovery/admission.
|
||||
diff --git a/scripts/task_execution_mediator.py b/scripts/task_execution_mediator.py
|
||||
index d42b55f..6614974 100644
|
||||
--- a/scripts/task_execution_mediator.py
|
||||
+++ b/scripts/task_execution_mediator.py
|
||||
@@ -169,12 +169,16 @@ def run_native_fixture_task(*,compiler:Compiler,journal:Journal,plan_request:dic
|
||||
_fresh(secrets_cfg,handoff,descriptor)
|
||||
native.metadata_call(ordinal)
|
||||
result['native_calls_completed']+=1
|
||||
+ result['readback_evidence_digest']=native.verify_metadata()
|
||||
+ result['native_apply']='verified'
|
||||
except Exception:
|
||||
_unknown(journal,binding)
|
||||
result['native_apply']='unknown'
|
||||
finally:
|
||||
result['cleanup_confirmed']=run_cleanup(journal,binding,native)['confirmed']
|
||||
- # Native total expected-state normalization/acceptance remains explicitly
|
||||
- # absent. Six successful calls cannot be relabelled a verified owner trial.
|
||||
+ if result['native_apply']=='verified' and result['cleanup_confirmed']:
|
||||
+ journal.transition(binding,'verified','cleanup-confirmed',result['readback_evidence_digest'])
|
||||
+ # Readback and cleanup are independent outcomes; no recovery authority
|
||||
+ # or native compare-and-swap is implied by either fixture result.
|
||||
result['business_state']=journal.observe(binding)['state']
|
||||
return result
|
||||
diff --git a/scripts/task_execution_native_fixture.py b/scripts/task_execution_native_fixture.py
|
||||
index c9ee244..caa7da1 100644
|
||||
--- a/scripts/task_execution_native_fixture.py
|
||||
+++ b/scripts/task_execution_native_fixture.py
|
||||
@@ -32,6 +32,8 @@ class PrivateSession:
|
||||
class FixtureAdmission:
|
||||
"""Independently installed owner fixture inputs, not a request schema."""
|
||||
descriptor: bytes
|
||||
+ expected_before: bytes
|
||||
+ expected_after: bytes
|
||||
owner_token: str = field(repr=False)
|
||||
identity_session: PrivateSession = field(repr=False)
|
||||
server_process: subprocess.Popen = field(repr=False)
|
||||
@@ -78,6 +80,22 @@ class NativeFixtureAdapter:
|
||||
self.hcl+='path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
|
||||
if self.meta['execution_grant']['openbao']['policy']!={'name':self.policy_name,'hcl':self.hcl}:
|
||||
raise Refused('native grant body differs from bound owner projection')
|
||||
+ # Installed owner snapshots use the existing descriptor commitments. No
|
||||
+ # request-supplied expectation or response may authorize itself.
|
||||
+ self.expected={}
|
||||
+ for phase in ('before','after'):
|
||||
+ raw=getattr(admission,'expected_'+phase)
|
||||
+ if type(raw) is not bytes or 'sha256:'+content_hash(raw)!=descriptor['expected_'+phase]:
|
||||
+ raise Refused('owner expected-state digest mismatch')
|
||||
+ value=json.loads(raw)
|
||||
+ if type(value) is not dict or set(value)!=set(paths) or encoded(value)!=raw:
|
||||
+ raise Refused('canonical exact target snapshot required')
|
||||
+ if any(item is not None and type(item) is not dict for item in value.values()):
|
||||
+ raise Refused('metadata snapshot object or explicit absence required')
|
||||
+ if phase=='after' and any(item is None for item in value.values()):
|
||||
+ raise Refused('present final metadata required')
|
||||
+ self.expected[phase]=value
|
||||
+ self._observed={'before':{},'after':{}}
|
||||
self.cleanup=tuple(Cleanup(item['kind'],item['call_digest']) for item in cleanup_specs(binding.operation_id))
|
||||
if self.meta['cleanup'] != cleanup_specs(binding.operation_id): raise Refused('cleanup plan mismatch')
|
||||
self.task=None; self._mint_attempted=False; self._policy_attempted=False; self._ordinal=0; self._next_metadata=0
|
||||
@@ -183,11 +201,53 @@ class NativeFixtureAdapter:
|
||||
call=self.compiler.before_call(self.plan,ordinal)
|
||||
status,data=self._business(call.method,call.path,self.task.token,call.payload,task=True)
|
||||
self._next_metadata+=1
|
||||
- self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
|
||||
- # Metadata only. The trusted owner caller must compare native normalized
|
||||
- # baseline/readback to separately admitted expected state. Not KV values.
|
||||
+ try:
|
||||
+ self._ok(status,data,{200,204,404} if ordinal in {0,3} else {200,204})
|
||||
+ if ordinal in {0,2,3,5}:
|
||||
+ phase='before' if ordinal in {0,3} else 'after'
|
||||
+ value=self._metadata_snapshot(call.path,status,data)
|
||||
+ if encoded(value)!=encoded(self.expected[phase][call.path]):
|
||||
+ raise Refused('native exact expected-state mismatch')
|
||||
+ self._observed[phase][call.path]=value
|
||||
+ except Exception:
|
||||
+ state=self.journal.observe(self.binding)['state']
|
||||
+ if state!='unknown': self.journal.transition(self.binding,state,'unknown')
|
||||
+ raise Refused('native baseline or readback unconfirmed') from None
|
||||
return status,data
|
||||
|
||||
+ @staticmethod
|
||||
+ def _metadata_snapshot(path,status,response):
|
||||
+ # HTTP errors/denials are never absence. OpenBao's exact empty 404
|
||||
+ # metadata response is the only absent representation admitted here.
|
||||
+ if status==404 and response=={'errors':[]}:
|
||||
+ return None
|
||||
+ if status!=200 or response.get('errors') or type(response.get('data')) is not dict:
|
||||
+ raise Refused('metadata read not confirmed')
|
||||
+ value=dict(response['data'])
|
||||
+ if path.startswith('sys/policies/acl/'):
|
||||
+ if set(value)!={'cas_required','modified','name','policy','version'}:
|
||||
+ raise Refused('unrecognized policy metadata fields')
|
||||
+ modified=value.pop('modified')
|
||||
+ if type(modified) is not str or datetime.fromisoformat(modified.replace('Z','+00:00')).tzinfo is None:
|
||||
+ raise Refused('invalid policy modification timestamp')
|
||||
+ # All role fields, aliases, defaults and unknown additions participate in
|
||||
+ # exact canonical JSON comparison. True never equals integer 1 here.
|
||||
+ return value
|
||||
+
|
||||
+ def verify_metadata(self):
|
||||
+ self._fresh()
|
||||
+ if self._next_metadata!=6 or self.journal.observe(self.binding)['state']!='applying':
|
||||
+ raise Refused('complete same-live metadata sequence required')
|
||||
+ for phase in ('before','after'):
|
||||
+ if encoded(self._observed[phase])!=encoded(self.expected[phase]):
|
||||
+ raise Refused('complete expected metadata evidence required')
|
||||
+ evidence=content_hash(encoded({'expected_before':'sha256:'+content_hash(self.admission.expected_before),
|
||||
+ 'expected_after':'sha256:'+content_hash(self.admission.expected_after),
|
||||
+ 'scope':'per-target-sequential-readback-no-native-cas'}))
|
||||
+ self.journal.transition(self.binding,'applying','readback',evidence)
|
||||
+ self.journal.transition(self.binding,'readback','verified',evidence)
|
||||
+ return evidence
|
||||
+
|
||||
def _cleanup_admitted(self, kind, digest):
|
||||
self._live()
|
||||
expected=next(item for item in self.cleanup if item.kind==kind)
|
||||
diff --git a/tests/test_task_execution_mediator.py b/tests/test_task_execution_mediator.py
|
||||
index 8ad9045..52024f1 100644
|
||||
--- a/tests/test_task_execution_mediator.py
|
||||
+++ b/tests/test_task_execution_mediator.py
|
||||
@@ -99,7 +99,9 @@ def test_whole_strict_consume_confirmation_precedes_real_native_effect(owner,tmp
|
||||
with whole_fixture(owner,tmp_path) as fixture:
|
||||
result=invoke(fixture)
|
||||
assert result['native_calls_completed']==6 and result['cleanup_confirmed']
|
||||
- assert result['native_apply']=='unverified' # total owner baseline/readback acceptance remains separate
|
||||
+ assert result['native_apply']=='verified'
|
||||
+ assert result['business_state']=='cleanup-confirmed'
|
||||
+ assert len(result['readback_evidence_digest'])==64
|
||||
obj=fixture.approval.engine.get(fixture.approval_id)
|
||||
assert obj.status=='consumed'
|
||||
assert obj.consumed_digest==result['request_digest']
|
||||
diff --git a/tests/test_task_execution_native_fixture.py b/tests/test_task_execution_native_fixture.py
|
||||
index 2a8e419..4c54313 100644
|
||||
--- a/tests/test_task_execution_native_fixture.py
|
||||
+++ b/tests/test_task_execution_native_fixture.py
|
||||
@@ -78,6 +78,19 @@ def owner(compiler,bao_dev,tmp_path):
|
||||
'backend_origin':origin,'cluster_uid':'disposable-fixture-only',
|
||||
'operations':req['operations'],'operation_deadline':(datetime.now(timezone.utc)+timedelta(seconds=240)).isoformat(),
|
||||
'backend_budget':{'ttl_seconds':120,'uses':12},'expected_before':'sha256:'+'0'*64,'expected_after':'sha256:'+'1'*64}
|
||||
+ # Independently installed synthetic expected state, before any native write.
|
||||
+ policy_call=compiler.before_call(plan,1);role_call=compiler.before_call(plan,4)
|
||||
+ body=json.loads(role_call.payload)
|
||||
+ before=m.encoded({policy_call.path:None,role_call.path:None})
|
||||
+ after=m.encoded({policy_call.path:{'cas_required':False,'name':policy_call.path.rsplit('/',1)[1],
|
||||
+ 'policy':json.loads(policy_call.payload)['policy'],'version':1},role_call.path:{
|
||||
+ 'alias_name_source':'serviceaccount_uid','bound_service_account_names':body['bound_service_account_names'],
|
||||
+ 'bound_service_account_namespace_selector':'','bound_service_account_namespaces':body['bound_service_account_namespaces'],
|
||||
+ 'policies':body['policies'].split(','),'token_bound_cidrs':[],'token_explicit_max_ttl':0,
|
||||
+ 'token_max_ttl':0,'token_no_default_policy':False,'token_num_uses':0,'token_period':0,
|
||||
+ 'token_policies':body['policies'].split(','),'token_strictly_bind_ip':False,
|
||||
+ 'token_ttl':900,'token_type':'default','ttl':900}})
|
||||
+ descriptor.update(expected_before=m.tagged(before),expected_after=m.tagged(after))
|
||||
# Exercise the actual consumer closed descriptor parser as well; source pins,
|
||||
# grant/expected-state digests remain deliberately synthetic owner fixtures.
|
||||
from secrets_engine.execution_contract import ExecutionContract
|
||||
@@ -107,7 +120,7 @@ def owner(compiler,bao_dev,tmp_path):
|
||||
plan=compiler.bind_execution(compiler.compile(req),descriptor,installed)
|
||||
binding=Binding('SYNTHETIC-APPROVAL-NOT-PRODUCTION',req['request_digest'],req['operation_id'],plan.content_digest)
|
||||
state=tmp_path/'private-journal';state.mkdir(mode=0o700);journal=Journal(state)
|
||||
- admission=FixtureAdmission(installed,root,identity,process)
|
||||
+ admission=FixtureAdmission(installed,before,after,root,identity,process)
|
||||
native=NativeFixtureAdapter(compiler,plan,journal,binding,admission)
|
||||
try: yield native,journal,binding,call,compiler,descriptor
|
||||
finally: journal.close()
|
||||
@@ -140,6 +153,7 @@ def test_native_fixed_grant_metadata_denials_and_confirmed_cleanup(owner):
|
||||
assert role[1]['data']['bound_service_account_names']==expected['bound_service_account_names']
|
||||
assert role[1]['data']['bound_service_account_namespaces']==expected['bound_service_account_namespaces']
|
||||
assert role[1]['data']['token_policies']==expected['policies'].split(',')
|
||||
+ assert len(native.verify_metadata())==64
|
||||
# Root probes native capabilities without spending the limited task token.
|
||||
for path in ['sys/policies/acl/sibling','auth/kubernetes/role/sibling','secret/data/sibling','auth/token/create','sys/mounts/secret','auth/approle/role/sibling/secret-id']:
|
||||
status,data=call('POST','sys/capabilities',{'token':native.task.token,'paths':[path]})
|
||||
@@ -152,7 +166,7 @@ def test_native_fixed_grant_metadata_denials_and_confirmed_cleanup(owner):
|
||||
assert status==400 and data.get('errors') in (['invalid accessor'],['1 error occurred:\n\t* invalid accessor\n\n'])
|
||||
assert result['confirmed']
|
||||
assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
|
||||
- assert journal.observe(binding)['state']=='applying' # native success is not full owner verification
|
||||
+ assert journal.observe(binding)['state']=='verified'
|
||||
with pytest.raises(Refused): native.metadata_call(6)
|
||||
with pytest.raises(Refused): run_cleanup(journal,binding,native)
|
||||
|
||||
@@ -261,7 +275,7 @@ def test_nonloopback_estate_origin_is_refused_before_native_transport(owner):
|
||||
installed=m.encoded(changed)
|
||||
plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed,installed)
|
||||
changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
|
||||
- admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
|
||||
+ admission=replace(native.admission,descriptor=installed)
|
||||
with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
|
||||
|
||||
|
||||
@@ -294,7 +308,7 @@ def test_proposed_source_grant_never_materializes_even_with_matching_digest(owne
|
||||
installed=m.encoded(changed_descriptor)
|
||||
plan=changed_compiler.bind_execution(changed_compiler.compile(native.meta['request']),changed_descriptor,installed)
|
||||
changed_binding=Binding(binding.approval_id,binding.request_digest,binding.operation_id,plan.content_digest)
|
||||
- admission=FixtureAdmission(installed,native.admission.owner_token,native.admission.identity_session,native.admission.server_process)
|
||||
+ admission=replace(native.admission,descriptor=installed)
|
||||
with pytest.raises(Refused):NativeFixtureAdapter(changed_compiler,plan,journal,changed_binding,admission)
|
||||
assert call('GET','sys/policies/acl/'+native.policy_name)[0]==404
|
||||
|
||||
diff --git a/tests/test_task_execution_readback.py b/tests/test_task_execution_readback.py
|
||||
new file mode 100644
|
||||
index 0000000..5bc0b5b
|
||||
--- /dev/null
|
||||
+++ b/tests/test_task_execution_readback.py
|
||||
@@ -0,0 +1,127 @@
|
||||
+"""Exact disposable native acceptance, partial effects and restart refusal."""
|
||||
+import json
|
||||
+from dataclasses import replace
|
||||
+import pytest
|
||||
+from test_task_execution_native_fixture import owner,bao_dev,compiler,admit
|
||||
+from task_execution_native_fixture import NativeFixtureAdapter
|
||||
+from task_execution_journal import Refused,Journal
|
||||
+from task_execution_cleanup import run_cleanup
|
||||
+
|
||||
+
|
||||
+def start(native,journal,binding):
|
||||
+ admit(native,journal,binding);native.materialize_grant()
|
||||
+ journal.transition(binding,'grant-issued','applying')
|
||||
+
|
||||
+
|
||||
+def test_expectation_digest_pin_rejected_before_reserve(owner):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ with pytest.raises(Refused,match='expected-state digest'):
|
||||
+ NativeFixtureAdapter(compiler,n.plan,j,b,replace(n.admission,expected_after=b'{}'))
|
||||
+ assert c('GET','sys/policies/acl/'+n.policy_name)[0]==404
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('target',[0,3])
|
||||
+def test_actual_conflicting_baseline_gates_its_target_write(owner,target):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ call=compiler.before_call(n.plan,target+1)
|
||||
+ existing={'policy':'path "unrelated" { capabilities = ["read"] }'} if target==0 else json.loads(call.payload)
|
||||
+ assert c('PUT',call.path,existing)[0] in {200,204}
|
||||
+ with j.locked():
|
||||
+ start(n,j,b)
|
||||
+ for i in range(target): n.metadata_call(i)
|
||||
+ with pytest.raises(Refused,match='baseline or readback'):n.metadata_call(target)
|
||||
+ assert j.observe(b)['state']=='unknown'
|
||||
+ if target==3:
|
||||
+ # Policy effect already happened: this is deliberately no aggregate CAS.
|
||||
+ assert c('GET',compiler.before_call(n.plan,1).path)[1]['data']['policy']==json.loads(compiler.before_call(n.plan,1).payload)['policy']
|
||||
+ assert run_cleanup(j,b,n)['confirmed']
|
||||
+ assert j.observe(b)['state']=='unknown'
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('target',[2,5])
|
||||
+def test_actual_readback_drift_never_verifies(owner,target):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ with j.locked():
|
||||
+ start(n,j,b)
|
||||
+ for i in range(target):n.metadata_call(i)
|
||||
+ call=compiler.before_call(n.plan,target-1)
|
||||
+ body=json.loads(call.payload)
|
||||
+ if target==2:body['policy']+='\npath "sibling/*" { capabilities = ["read"] }'
|
||||
+ else:body['policies']+=',unexpected-policy'
|
||||
+ assert c('PUT',call.path,body)[0] in {200,204}
|
||||
+ with pytest.raises(Refused,match='baseline or readback'):n.metadata_call(target)
|
||||
+ with pytest.raises(Refused):n.verify_metadata()
|
||||
+ assert run_cleanup(j,b,n)['confirmed']
|
||||
+ assert j.observe(b)['state']=='unknown'
|
||||
+
|
||||
+
|
||||
+@pytest.mark.parametrize('status,response',[(403,{'errors':[]}), (404,{}),(404,{'errors':['permission denied']}), (200,{'data':None})])
|
||||
+def test_only_exact_backend_absence_counts(status,response):
|
||||
+ with pytest.raises(Refused):NativeFixtureAdapter._metadata_snapshot('auth/kubernetes/role/x',status,response)
|
||||
+
|
||||
+
|
||||
+def test_json_types_and_unknown_role_fields_remain_significant(owner):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ original=n._http
|
||||
+ def changed(method,path,token,body=b''):
|
||||
+ status,response=original(method,path,token,body)
|
||||
+ if method=='GET' and path==compiler.before_call(n.plan,5).path and status==200:
|
||||
+ response['data']['token_num_uses']=False # False == 0 in Python; never canonical JSON.
|
||||
+ return status,response
|
||||
+ n._http=changed
|
||||
+ with j.locked():
|
||||
+ start(n,j,b)
|
||||
+ for i in range(5):n.metadata_call(i)
|
||||
+ with pytest.raises(Refused):n.metadata_call(5)
|
||||
+ assert run_cleanup(j,b,n)['confirmed']
|
||||
+ assert j.observe(b)['state']=='unknown'
|
||||
+
|
||||
+
|
||||
+def test_reopened_sqlite_cannot_resume_or_recover_memory_handles(owner):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ with j.locked():
|
||||
+ start(n,j,b)
|
||||
+ n.metadata_call(0);n.metadata_call(1)
|
||||
+ # Model loss of the live admission; handles deliberately are not reconstructed.
|
||||
+ # The still-running disposable server proves native effect survives reopening.
|
||||
+ location=j.directory
|
||||
+ assert c('GET',compiler.before_call(n.plan,1).path)[0]==200
|
||||
+ j.db.close()
|
||||
+ # Owner journal retains the exact DB path; this is a real reopened SQLite.
|
||||
+ reopened=Journal(location)
|
||||
+ try:
|
||||
+ with reopened.locked():
|
||||
+ assert reopened.observe(b)['dispatch_allowed'] is False
|
||||
+ with pytest.raises(Refused):reopened.reserve(b,n.cleanup)
|
||||
+ with pytest.raises(Refused):reopened.next_call_ordinal(b)
|
||||
+ finally:reopened.close()
|
||||
+ # No recovery/cleanup claim: only same-process fixture teardown retains handles.
|
||||
+
|
||||
+
|
||||
+def test_verified_readback_with_real_cleanup_denial_is_not_complete(owner):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ with j.locked():
|
||||
+ start(n,j,b)
|
||||
+ for i in range(6):n.metadata_call(i)
|
||||
+ n.verify_metadata()
|
||||
+ # Actual backend removal denies the task's self-revoke capability.
|
||||
+ assert c('DELETE','sys/policies/acl/'+n.policy_name)[0] in {200,204}
|
||||
+ result=run_cleanup(j,b,n)
|
||||
+ assert result['confirmed'] is False
|
||||
+ assert j.observe(b)['state']=='verified'
|
||||
+ with pytest.raises(Refused):j.transition(b,'verified','cleanup-confirmed','0'*64)
|
||||
+ assert c('POST','auth/token/lookup-accessor',{'accessor':n.task.accessor})[0]==200
|
||||
+
|
||||
+
|
||||
+def test_actual_denied_baseline_cannot_be_caught_then_write_retried(owner):
|
||||
+ n,j,b,c,compiler,*_=owner
|
||||
+ with j.locked():
|
||||
+ start(n,j,b)
|
||||
+ # Task still has update but loses read: real 403 is not absent baseline.
|
||||
+ denied=n.hcl.replace('["read", "create", "update"]','["create", "update"]')
|
||||
+ assert c('PUT','sys/policies/acl/'+n.policy_name,{'policy':denied})[0] in {200,204}
|
||||
+ with pytest.raises(Refused):n.metadata_call(0)
|
||||
+ assert j.observe(b)['state']=='unknown'
|
||||
+ with pytest.raises(Refused):n.metadata_call(1)
|
||||
+ assert c('GET',compiler.before_call(n.plan,1).path)[0]==404
|
||||
+ assert run_cleanup(j,b,n)['confirmed']
|
||||
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
index 4ea8015..8b09305 100644
|
||||
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
@@ -970,6 +970,19 @@ and defaults stay unchanged. T02 still owns login/claim/TTL/use/MASON acceptance
|
||||
Secrets T06 separately owns dispatcher adoption and steady-state default migration.
|
||||
|
||||
|
||||
+2026-10-16 T12 wave 5 source preparation: disposable adapter now binds owner
|
||||
+canonical before/after metadata snapshots to existing descriptor hashes, rejects
|
||||
+per-target baseline/readback mismatch, and records readback/verified evidence.
|
||||
+Mediator reports cleanup-confirmed only after independent fixed cleanup receipts.
|
||||
+Complete role metadata and policy name/HCL/version/cas are compared; validated
|
||||
+modification timestamp alone is excluded. This is sequential fixture acceptance,
|
||||
+not aggregate CAS, concurrent-writer exclusion or native production completion.
|
||||
+Role conflict may follow a policy write. Restart refuses dispatch and cannot
|
||||
+recover memory-only token/accessor cleanup handles. T12 remains progress; actual
|
||||
+fresh CCR/catalog/grant, source/trust/caller/policy admission, isolated owner custody,
|
||||
+scoped recovery and attended native acceptance remain explicit prerequisites.
|
||||
+
|
||||
+
|
||||
2026-10-11 T11 refinement (same source-review trial): the exact Secrets Engine
|
||||
v3 manifest `6bb05bac74e4939c3e42db2ae95c5f9fb4616bb20cc285811a315c26669ebce1`
|
||||
and all named bytes/proof were verified and independently re-reviewed. The three
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"scope": "local synthetic identities/approvals and actual loopback OpenBao only; no production admission",
|
||||
"environment": {
|
||||
"UV_CACHE_DIR": "/tmp/netkingdom-uv-cache",
|
||||
"SECRETS_ENGINE_SOURCE_ROOT": "/home/worsch/secrets-engine",
|
||||
"PYTHONPATH": "/home/worsch/secrets-engine/src:/home/worsch/approval-engine"
|
||||
},
|
||||
"trials": [
|
||||
{
|
||||
"suite": "tests/test_task_execution_native_fixture.py tests/test_task_execution_mediator.py",
|
||||
"result": "16 passed, 3 failed in 218.18s",
|
||||
"failures": "fixture assertion expected cleanup-confirmed without mediator transition; two old FixtureAdmission positional constructions. Corrected before focused rerun."
|
||||
},
|
||||
{
|
||||
"suite": "initial tests/test_task_execution_readback.py",
|
||||
"result": "11 passed, 1 teardown error in 140.70s",
|
||||
"failure": "test closed Journal fd twice; changed to closing/reopening actual SQLite connection and retaining original fd for fixture teardown."
|
||||
},
|
||||
{
|
||||
"suite": "three corrected original native tests + whole strict mediator positive + real cleanup denial after verified readback",
|
||||
"result": "5 passed in 84.12s"
|
||||
},
|
||||
{
|
||||
"suite": "tests/test_task_execution_readback.py (before final denial case added)",
|
||||
"result": "12 passed in 134.86s"
|
||||
},
|
||||
{
|
||||
"suite": "actual denied baseline cannot catch then retry write + whole strict mediator positive",
|
||||
"result": "2 passed in 39.82s",
|
||||
"final_source": "final error handling transitions unknown for native denial as well as snapshot mismatch"
|
||||
}
|
||||
],
|
||||
"distinct_cases_exercised": 32,
|
||||
"final_targeted_green_distinct_cases": 17,
|
||||
"unchanged_green_cases_from_initial_affected_suites": 15,
|
||||
"checks": [
|
||||
"git diff --check passed"
|
||||
],
|
||||
"limits": [
|
||||
"No full unchanged compiler/journal/grant suite rerun",
|
||||
"Reopened SQLite fixture is restart/refusal evidence, not OS crash cleanup recovery",
|
||||
"Policy and role readbacks are sequential, not atomic final-state proof",
|
||||
"Native cleanup handles remain memory-only",
|
||||
"Synthetic positive policy separate; real actual policy remains DENY"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"owner_repository": "/home/worsch/railiance-platform",
|
||||
"isolated_checkout": "/tmp/netkingdom-platform-20261016",
|
||||
"base_commit": "3b6e647aa06bed88966a4539f9d3cb7ed6792bd4",
|
||||
"patch": "platform-cleanup.patch",
|
||||
"patch_sha256": "be8de4975980e4b88b37ed98d3fdd509d513f85834efc3a5cb744410c4e95392",
|
||||
"files_sha256": {
|
||||
"docs/task-execution-cleanup.md": "44cb21e69838c961eca9805ec3d1f3e64d12b46dc9c2e7b02c69610a97a0f33b",
|
||||
"docs/task-execution-plan-compiler.md": "83f28626f1f6499f52f22c3daafd22c802475e8a77bb1be7ba9901c7ae00bf8b",
|
||||
"scripts/task_execution_cleanup.py": "c1c0b649e8002b9a6e1599d46f70ad3ad3d743323ddb82367468d5bdb103062c",
|
||||
"scripts/task_execution_journal.py": "f512d72be880de2a6345d3550c60a0c8e6e3b6d2bc8123f4502a068afabd973b",
|
||||
"tests/test_task_execution_cleanup.py": "a0d4d6d2c8c22bfed17a109a6f0be02d5f128619866ae5f1ea69f81803133085",
|
||||
"tests/test_task_execution_journal.py": "707ee5404e567ddf9896d680feae47628b9a3cfc9e3ce0073100e1677af53b5c",
|
||||
"workplans/RPF-WP-0035-credential-lane-implementation.md": "f1be966f38a96efbedcb7a129586a6f905e1910fe37ef85c61fe29379fa50af6"
|
||||
},
|
||||
"canonical_owner_changed": false,
|
||||
"canonical_dirty_tracked_observed": [
|
||||
"WORK-RECORDS.md"
|
||||
],
|
||||
"canonical_dirty_diff_sha256": "e4b417412197d27214254ac968742f3be2926df21f6cd52cdb92987089e2223a",
|
||||
"canonical_dirty_diff_handling": "Generated WORK-RECORDS index only; unrelated to executor sources. Inspected but excluded from isolated source patch. No untracked/private source files copied.",
|
||||
"review_sha256": "dcb5b155eabefd7317eb6fc10a4573f7d59d8d7bf5a0b18d70d712ddbdd26b43",
|
||||
"tests": {
|
||||
"command": "SECRETS_ENGINE_SOURCE_ROOT=/home/worsch/secrets-engine PYTHONPATH=/home/worsch/secrets-engine/src python3 -m pytest -q tests/test_task_execution_plan.py tests/test_task_execution_journal.py tests/test_task_execution_cleanup.py",
|
||||
"cwd": "/tmp/netkingdom-platform-20261016",
|
||||
"result": "73 passed in 11.68s",
|
||||
"initial_attempt": "Compiler fixture setup initially failed without SECRETS_ENGINE_SOURCE_ROOT (default /tmp/secrets-engine absent); corrected existing test path configuration and reran.",
|
||||
"mechanisms": [
|
||||
"Actual SQLite FULL synchronous transactions and process file locks",
|
||||
"Four-process cleanup admission race admits exactly one worker",
|
||||
"Abrupt os._exit before and after separately fsynced synthetic effect",
|
||||
"Restart preserves pending outcome and refuses cleanup/business resumption",
|
||||
"Revoke timeout continues independent cleanup but never confirms full cleanup",
|
||||
"Existing 45 compiler and 15 journal regressions retained; 13 new cleanup tests"
|
||||
],
|
||||
"native_openbao_proof": false
|
||||
},
|
||||
"unmet_production_gates": [
|
||||
"Compiler and full canonical request must bind exact cleanup obligations and authoritative grant",
|
||||
"Authenticated initiating identity, served claim/PDP/package and independent atomic consume confirmation",
|
||||
"Owner-held ephemeral token materializer and restricted native adapter",
|
||||
"Exact native baseline/write/readback and per-call trusted-time/use checks",
|
||||
"Native cleanup/recovery and audit acceptance; founder-scoped attended trial"
|
||||
],
|
||||
"work_item": "RPF-WP-0035-T12",
|
||||
"secrets_contract": "SECRETS-WP-0013",
|
||||
"delivery": "Reviewable patch only; no owner commit/sync/push/deploy"
|
||||
}
|
||||
|
|
@ -0,0 +1,595 @@
|
|||
diff --git a/docs/task-execution-cleanup.md b/docs/task-execution-cleanup.md
|
||||
new file mode 100644
|
||||
index 0000000..c21050b
|
||||
--- /dev/null
|
||||
+++ b/docs/task-execution-cleanup.md
|
||||
@@ -0,0 +1,66 @@
|
||||
+# Durable cleanup obligations for the metadata execution pilot
|
||||
+
|
||||
+RPF-WP-0035-T12 source preparation, 2026-10-16. This source is not an installed
|
||||
+mediator, native execution entitlement, founder approval or owner-native proof.
|
||||
+
|
||||
+Platform's existing admission journal made `unknown` and `failed` terminal and
|
||||
+blocked all further calls. Its single business-state cleanup transition could not
|
||||
+express a timed-out write with confirmed token revocation, or a verified write
|
||||
+with unconfirmed cleanup. A future mediator using that interface could suppress
|
||||
+cleanup precisely when needed. The journal now retains the original business
|
||||
+outcome and records cleanup in a separate durable table.
|
||||
+
|
||||
+At fresh reservation the trusted owner supplies at most one commitment each for
|
||||
+`task-token-revoke`, `identity-token-revoke`, and `grant-policy-delete`. Each is a
|
||||
+kind and exact native-call digest; no token/accessor, payload, path override or
|
||||
+shell command is accepted. Obligations and admission are inserted in one SQLite
|
||||
+transaction with FULL synchronous commits and directory fsync. Existing journal
|
||||
+files gain the separate table; old admissions gain no obligations or authority.
|
||||
+Empty cleanup evidence cannot produce a confirmed result or legacy success state.
|
||||
+
|
||||
+Only the same live exclusive invocation that transitioned this binding through
|
||||
+`consume-confirmed` can record cleanup intent. Neither a caller string nor journal
|
||||
+observation establishes consume authority: the trusted mediator must independently
|
||||
+validate the real served approval/PDP/consume before that transition. Cleanup can
|
||||
+proceed when the business call is pending or state is unknown/failed. Starting it
|
||||
+permanently closes business dispatch for that admission. An exact obligation moves
|
||||
+`owed -> pending -> confirmed|unconfirmed`; no retry transition exists. A crash
|
||||
+leaves pending, and reopening never restores admission. Pending business-call
|
||||
+uncertainty remains even if every cleanup succeeds.
|
||||
+
|
||||
+`task_execution_cleanup.run_cleanup` is the fixed caller of those journal methods.
|
||||
+It is intended for the owner mediator's `finally` block inside the original
|
||||
+`Journal.locked()` context. It dispatches only the three named owner cleanup
|
||||
+methods, passing their admitted digest for independent native-call comparison.
|
||||
+Ordinary exceptions/malformed evidence become closed unconfirmed reason evidence
|
||||
+without logging or hashing raw backend text. Remaining independent obligations
|
||||
+continue. A process death or BaseException leaves pending intent and requires
|
||||
+owner reconciliation. A journal write failure prevents the corresponding effect.
|
||||
+The component has no HTTP client, CLI command execution or native credential opener.
|
||||
+
|
||||
+The owner adapter must retain the exact handles in private memory, implement
|
||||
+bounded native calls, compare each reconstructed cleanup call against the admitted
|
||||
+commitment, and return confirmed only after the appropriate native revoke/absence
|
||||
+verification. A timeout, policy deletion alone, or eventual TTL expiry cannot prove
|
||||
+issued-token revocation. Cleanup has the original separate obligation rather than
|
||||
+the expired business deadline; this component does not extend any business grant.
|
||||
+No automatic postcrash recovery is implemented: owner-private recovery requires
|
||||
+fresh scoped authority and must never retry a business effect.
|
||||
+
|
||||
+The current Compiler Plan does not yet contain these cleanup commitments. A
|
||||
+production mediator must bind them into the exact reviewed grant/full canonical
|
||||
+request/owner plan before reservation. The journal's digest parameter does not
|
||||
+verify that correspondence. It must also integrate authenticated initiating actor,
|
||||
+served claim/PDP and independently verified atomic consume, exact source/body and
|
||||
+baseline/readback validation, ephemeral owner-held token materialization, per-call
|
||||
+trusted-time/use enforcement, and native audit/recovery. There is no production
|
||||
+executor in this repository to wire to this driver today; existing production
|
||||
+readiness remains refused. Approval CAS alone cannot provide exactly-once target
|
||||
+effects across process/network failure. T12 and SECRETS-WP-0013 remain open.
|
||||
+
|
||||
+Validation uses actual SQLite/process locks and abrupt `os._exit` before and after
|
||||
+a separately fsynced synthetic fixture effect. Reopened processes cannot resume
|
||||
+cleanup or business work; a four-process race admits one driver. Other tests cover
|
||||
+pending business calls, revoke timeout without suppressing later cleanup, changed
|
||||
+commitments and bindings, malformed confirmations and empty-evidence refusal.
|
||||
+These are disposable local fixtures, not OpenBao or production acceptance.
|
||||
diff --git a/docs/task-execution-plan-compiler.md b/docs/task-execution-plan-compiler.md
|
||||
index 32c440c..9bb49f7 100644
|
||||
--- a/docs/task-execution-plan-compiler.md
|
||||
+++ b/docs/task-execution-plan-compiler.md
|
||||
@@ -94,7 +94,9 @@ cleanup use; this component records limits, it does not enforce native expiry/us
|
||||
or provide a hard elapsed timer. Trusted owner runtime must bound parser/import/
|
||||
filesystem/compiler work by remaining action budget before any actual call.
|
||||
Token custody/revoke, temporary object removal, owner-private recovery after unknown
|
||||
-and truthful native readback remain gates. General lifecycle/apply/exec/default
|
||||
+and truthful native readback remain gates. The 2026-10-16 source cleanup journal
|
||||
+and fixed driver are documented in task-execution-cleanup.md; they preserve unknown
|
||||
+outcomes but are not yet bound into this compiler or a production mediator. General lifecycle/apply/exec/default
|
||||
migration and SecretsT06/RPF35T02/T12 cannot close from this source component.
|
||||
|
||||
Validation:45 compiler tests and 15 journal tests pass, covering real owner K8s/OIDC body generation,
|
||||
diff --git a/scripts/task_execution_cleanup.py b/scripts/task_execution_cleanup.py
|
||||
new file mode 100644
|
||||
index 0000000..c865c41
|
||||
--- /dev/null
|
||||
+++ b/scripts/task_execution_cleanup.py
|
||||
@@ -0,0 +1,72 @@
|
||||
+"""Fixed cleanup driver for a trusted owner mediator; no native transport exists here.
|
||||
+
|
||||
+Call from the mediator's finally block while its original Journal.locked context
|
||||
+is still live. The owner adapter retains credential/handle custody and reconstructs
|
||||
+and compares the native call with the expected digest before effect. Its three
|
||||
+methods take no caller-selected path, body, shell command or token. This source
|
||||
+component neither validates consume nor creates a native execution entitlement.
|
||||
+"""
|
||||
+from __future__ import annotations
|
||||
+
|
||||
+from dataclasses import dataclass
|
||||
+import hashlib
|
||||
+import re
|
||||
+from typing import Protocol
|
||||
+
|
||||
+from task_execution_journal import Binding, Cleanup, Journal, Refused
|
||||
+
|
||||
+
|
||||
+@dataclass(frozen=True)
|
||||
+class CleanupResult:
|
||||
+ confirmed: bool
|
||||
+ evidence_digest: str
|
||||
+
|
||||
+ def validate(self):
|
||||
+ if type(self.confirmed) is not bool or type(self.evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", self.evidence_digest):
|
||||
+ raise Refused("invalid owner cleanup evidence")
|
||||
+
|
||||
+
|
||||
+class OwnerCleanup(Protocol):
|
||||
+ # Confirm requires actual native revoke/absence evidence; a timeout is never
|
||||
+ # proof of absence. Implementations must use bounded calls and discard raw
|
||||
+ # backend failure output. Protected handles never leave the owner adapter.
|
||||
+ def revoke_task_token(self, expected_call_digest: str) -> CleanupResult: ...
|
||||
+ def revoke_identity_token(self, expected_call_digest: str) -> CleanupResult: ...
|
||||
+ def delete_grant_policy(self, expected_call_digest: str) -> CleanupResult: ...
|
||||
+
|
||||
+
|
||||
+def run_cleanup(journal: Journal, binding: Binding, owner: OwnerCleanup):
|
||||
+ """Attempt each fixed obligation once, preserving business/unknown outcomes.
|
||||
+
|
||||
+ Ordinary adapter failures record unconfirmed evidence and do not suppress the
|
||||
+ remaining independent cleanup. BaseException/process death leaves durable
|
||||
+ pending intent; neither this driver nor a reopened journal retries it.
|
||||
+ Journal failures propagate and prohibit dispatch without durable intent.
|
||||
+ """
|
||||
+ obligations = journal.cleanup_status(binding)['obligations']
|
||||
+ if not obligations:
|
||||
+ raise Refused("explicit owner cleanup obligations required")
|
||||
+ if any(item['outcome'] != 'owed' for item in obligations):
|
||||
+ raise Refused("cleanup already attempted; owner reconciliation required")
|
||||
+ for item in obligations:
|
||||
+ obligation = Cleanup(item['kind'], item['call_digest'])
|
||||
+ journal.before_cleanup(binding, item['ordinal'], obligation)
|
||||
+ try:
|
||||
+ if obligation.kind == 'task-token-revoke':
|
||||
+ result = owner.revoke_task_token(obligation.call_digest)
|
||||
+ elif obligation.kind == 'identity-token-revoke':
|
||||
+ result = owner.revoke_identity_token(obligation.call_digest)
|
||||
+ elif obligation.kind == 'grant-policy-delete':
|
||||
+ result = owner.delete_grant_policy(obligation.call_digest)
|
||||
+ else:
|
||||
+ raise Refused("unsupported cleanup obligation")
|
||||
+ if type(result) is not CleanupResult:
|
||||
+ raise Refused("typed owner cleanup evidence required")
|
||||
+ result.validate()
|
||||
+ except Exception:
|
||||
+ # Closed metadata only. Never hash/log a backend exception, response,
|
||||
+ # token, accessor or secret-bearing stdout/stderr.
|
||||
+ result = CleanupResult(False, hashlib.sha256(b'owner-cleanup-unconfirmed/v1').hexdigest())
|
||||
+ journal.after_cleanup(binding, item['ordinal'], obligation,
|
||||
+ confirmed=result.confirmed, evidence_digest=result.evidence_digest)
|
||||
+ return journal.cleanup_status(binding)
|
||||
diff --git a/scripts/task_execution_journal.py b/scripts/task_execution_journal.py
|
||||
index 4bff83c..7c54ac8 100644
|
||||
--- a/scripts/task_execution_journal.py
|
||||
+++ b/scripts/task_execution_journal.py
|
||||
@@ -57,6 +57,26 @@ _NEXT = {
|
||||
}
|
||||
|
||||
|
||||
+@dataclass(frozen=True)
|
||||
+class Cleanup:
|
||||
+ """Owner-derived exact call commitment, never a handle or authority token.
|
||||
+
|
||||
+ The owner mediator must include these commitments in its approved plan before
|
||||
+ reservation, then compare the actual native cleanup call immediately before
|
||||
+ dispatch. This journal does not authenticate that plan or native adapter.
|
||||
+ """
|
||||
+ kind: str
|
||||
+ call_digest: str
|
||||
+
|
||||
+ def validate(self):
|
||||
+ if type(self.kind) is not str or self.kind not in {
|
||||
+ "task-token-revoke", "identity-token-revoke", "grant-policy-delete"
|
||||
+ }:
|
||||
+ raise Refused("unsupported cleanup obligation")
|
||||
+ if type(self.call_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", self.call_digest):
|
||||
+ raise Refused("invalid cleanup call digest")
|
||||
+
|
||||
+
|
||||
class Journal:
|
||||
def __init__(self, directory: Path):
|
||||
# Require an existing owner-private directory: deployment creates it.
|
||||
@@ -82,8 +102,10 @@ class Journal:
|
||||
self.db.execute("PRAGMA journal_mode=DELETE")
|
||||
self.db.execute("CREATE TABLE IF NOT EXISTS operations (approval TEXT PRIMARY KEY, request TEXT NOT NULL, operation TEXT UNIQUE NOT NULL, plan TEXT NOT NULL, state TEXT NOT NULL, evidence TEXT)")
|
||||
self.db.execute("CREATE TABLE IF NOT EXISTS calls (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal))")
|
||||
+ self.db.execute("CREATE TABLE IF NOT EXISTS cleanup (operation TEXT NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, call_digest TEXT NOT NULL, outcome TEXT NOT NULL, evidence TEXT, PRIMARY KEY(operation,ordinal), UNIQUE(operation,kind))")
|
||||
self._held = False
|
||||
self._admission = None
|
||||
+ self._consumed_admission = None
|
||||
self._sync_directory()
|
||||
|
||||
@staticmethod
|
||||
@@ -123,11 +145,20 @@ class Journal:
|
||||
yield self
|
||||
finally:
|
||||
self._admission = None
|
||||
+ self._consumed_admission = None
|
||||
self._held = False
|
||||
fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
|
||||
|
||||
- def reserve(self, binding: Binding):
|
||||
+ def reserve(self, binding: Binding, cleanup: tuple[Cleanup, ...] = ()):
|
||||
binding.validate()
|
||||
+ if type(cleanup) is not tuple or len(cleanup) > 3:
|
||||
+ raise Refused("closed cleanup plan required")
|
||||
+ for item in cleanup:
|
||||
+ if type(item) is not Cleanup:
|
||||
+ raise Refused("typed cleanup obligation required")
|
||||
+ item.validate()
|
||||
+ if len({item.kind for item in cleanup}) != len(cleanup):
|
||||
+ raise Refused("duplicate cleanup obligation")
|
||||
if not self._held or self._admission is not None:
|
||||
raise Refused("reservation requires fresh exclusive invocation")
|
||||
try:
|
||||
@@ -135,6 +166,9 @@ class Journal:
|
||||
self.db.execute("INSERT INTO operations VALUES (?,?,?,?,?,NULL)",
|
||||
(binding.approval_id, binding.request_digest,
|
||||
binding.operation_id, binding.plan_digest, "reserved"))
|
||||
+ self.db.executemany("INSERT INTO cleanup VALUES (?,?,?,?,'owed',NULL)",
|
||||
+ [(binding.operation_id, i, item.kind, item.call_digest)
|
||||
+ for i, item in enumerate(cleanup)])
|
||||
self.db.execute("COMMIT")
|
||||
self._sync_directory()
|
||||
except sqlite3.IntegrityError:
|
||||
@@ -158,6 +192,13 @@ class Journal:
|
||||
try:
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
pending=self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'",(binding.operation_id,)).fetchone()[0]
|
||||
+ started = self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'", (binding.operation_id,)).fetchone()[0]
|
||||
+ if started and target not in {'unknown', 'cleanup-confirmed'}:
|
||||
+ raise Refused("cleanup has closed business execution")
|
||||
+ if target == 'cleanup-confirmed':
|
||||
+ total, owed = self.db.execute("SELECT COUNT(*),COALESCE(SUM(outcome!='confirmed'),0) FROM cleanup WHERE operation=?", (binding.operation_id,)).fetchone()
|
||||
+ if not total or owed:
|
||||
+ raise Refused("explicit cleanup obligations are unconfirmed")
|
||||
if pending and target != 'unknown':
|
||||
raise Refused("unconfirmed external call requires unknown")
|
||||
changed = self.db.execute(
|
||||
@@ -168,6 +209,8 @@ class Journal:
|
||||
raise Refused("journal state changed")
|
||||
self.db.execute("COMMIT")
|
||||
self._sync_directory()
|
||||
+ if target == "consume-confirmed":
|
||||
+ self._consumed_admission = binding
|
||||
except BaseException:
|
||||
if self.db.in_transaction:
|
||||
self.db.execute("ROLLBACK")
|
||||
@@ -182,6 +225,7 @@ class Journal:
|
||||
raise Refused("journal binding mismatch")
|
||||
# Readback cannot create a live admission, even for an interrupted state.
|
||||
pending = self.db.execute("SELECT COUNT(*) FROM calls WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
|
||||
+ pending += self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome='pending'", (binding.operation_id,)).fetchone()[0]
|
||||
return {"state": row[3], "evidence_digest": row[4], "dispatch_allowed": False,
|
||||
"external_outcome_unknown": bool(pending)}
|
||||
|
||||
@@ -201,7 +245,8 @@ class Journal:
|
||||
self.db.execute("BEGIN IMMEDIATE")
|
||||
count,pending = self.db.execute("SELECT COUNT(*), COALESCE(SUM(outcome='pending'),0) FROM calls WHERE operation=?",(binding.operation_id,)).fetchone()
|
||||
state=self.db.execute("SELECT state FROM operations WHERE operation=?",(binding.operation_id,)).fetchone()[0]
|
||||
- if ordinal != count or pending or state in {'failed','unknown','cleanup-confirmed'}:
|
||||
+ cleanup_started = self.db.execute("SELECT COUNT(*) FROM cleanup WHERE operation=? AND outcome!='owed'", (binding.operation_id,)).fetchone()[0]
|
||||
+ if cleanup_started or ordinal != count or pending or state in {'failed','unknown','cleanup-confirmed'}:
|
||||
raise Refused("call is replayed, uncertain or terminal")
|
||||
self.db.execute("INSERT INTO calls VALUES (?,?,?,'pending',NULL)",(binding.operation_id,ordinal,call_digest))
|
||||
self.db.execute("COMMIT")
|
||||
@@ -229,6 +274,61 @@ class Journal:
|
||||
if self.db.in_transaction: self.db.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
+ def cleanup_status(self, binding: Binding):
|
||||
+ # Observation never restores live execution or cleanup authority.
|
||||
+ if self.observe(binding) is None:
|
||||
+ raise Refused("operation is not admitted")
|
||||
+ rows = self.db.execute("SELECT ordinal,kind,call_digest,outcome,evidence FROM cleanup WHERE operation=? ORDER BY ordinal", (binding.operation_id,)).fetchall()
|
||||
+ return {"confirmed": bool(rows) and all(row[3] == "confirmed" for row in rows),
|
||||
+ "obligations": [{"ordinal": row[0], "kind": row[1], "call_digest": row[2],
|
||||
+ "outcome": row[3], "evidence_digest": row[4]} for row in rows]}
|
||||
+
|
||||
+ def _cleanup_admission(self, binding, ordinal, obligation):
|
||||
+ if not self._held or self._admission != binding or self._consumed_admission != binding:
|
||||
+ raise Refused("cleanup requires same live consumed admission")
|
||||
+ if type(ordinal) is not int or not 0 <= ordinal < 3 or type(obligation) is not Cleanup:
|
||||
+ raise Refused("exact cleanup obligation required")
|
||||
+ obligation.validate()
|
||||
+
|
||||
+ def before_cleanup(self, binding: Binding, ordinal: int, obligation: Cleanup):
|
||||
+ """Commit intent for one pre-admitted cleanup call, even after unknown.
|
||||
+
|
||||
+ Never resumes an interrupted invocation. Independent owner recovery after
|
||||
+ a crash still requires the owner's protected recovery channel/approval.
|
||||
+ Cleanup has no business deadline: its authority is the original separate
|
||||
+ explicit obligation, and cannot authorize a read, repair or business write.
|
||||
+ """
|
||||
+ self._cleanup_admission(binding, ordinal, obligation)
|
||||
+ try:
|
||||
+ self.db.execute("BEGIN IMMEDIATE")
|
||||
+ changed = self.db.execute("UPDATE cleanup SET outcome='pending' WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='owed'",
|
||||
+ (binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount
|
||||
+ if changed != 1:
|
||||
+ raise Refused("cleanup is unadmitted, replayed or uncertain")
|
||||
+ self.db.execute("COMMIT")
|
||||
+ self._sync_directory()
|
||||
+ except BaseException:
|
||||
+ if self.db.in_transaction: self.db.execute("ROLLBACK")
|
||||
+ raise
|
||||
+
|
||||
+ def after_cleanup(self, binding: Binding, ordinal: int, obligation: Cleanup,
|
||||
+ *, confirmed: bool, evidence_digest: str):
|
||||
+ self._cleanup_admission(binding, ordinal, obligation)
|
||||
+ if type(confirmed) is not bool or type(evidence_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", evidence_digest):
|
||||
+ raise Refused("closed cleanup evidence required")
|
||||
+ try:
|
||||
+ self.db.execute("BEGIN IMMEDIATE")
|
||||
+ changed = self.db.execute("UPDATE cleanup SET outcome=?, evidence=? WHERE operation=? AND ordinal=? AND kind=? AND call_digest=? AND outcome='pending'",
|
||||
+ ('confirmed' if confirmed else 'unconfirmed', evidence_digest,
|
||||
+ binding.operation_id, ordinal, obligation.kind, obligation.call_digest)).rowcount
|
||||
+ if changed != 1:
|
||||
+ raise Refused("cleanup confirmation mismatch")
|
||||
+ self.db.execute("COMMIT")
|
||||
+ self._sync_directory()
|
||||
+ except BaseException:
|
||||
+ if self.db.in_transaction: self.db.execute("ROLLBACK")
|
||||
+ raise
|
||||
+
|
||||
def close(self):
|
||||
if hasattr(self, "db"):
|
||||
self.db.close()
|
||||
diff --git a/tests/test_task_execution_cleanup.py b/tests/test_task_execution_cleanup.py
|
||||
new file mode 100644
|
||||
index 0000000..348d4b8
|
||||
--- /dev/null
|
||||
+++ b/tests/test_task_execution_cleanup.py
|
||||
@@ -0,0 +1,199 @@
|
||||
+"""Disposable local SQLite/owner-adapter fixtures. No OpenBao or credential access."""
|
||||
+import multiprocessing
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import sys
|
||||
+import tempfile
|
||||
+import unittest
|
||||
+import uuid
|
||||
+
|
||||
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||
+from task_execution_journal import Binding, Cleanup, Journal, Refused
|
||||
+from task_execution_cleanup import CleanupResult, run_cleanup
|
||||
+
|
||||
+OBLIGATIONS = (Cleanup('task-token-revoke', '1'*64),
|
||||
+ Cleanup('identity-token-revoke', '2'*64),
|
||||
+ Cleanup('grant-policy-delete', '3'*64))
|
||||
+
|
||||
+
|
||||
+class OwnerFixture:
|
||||
+ def __init__(self, fail=None): self.calls = []; self.fail = fail
|
||||
+ def call(self, kind, digest):
|
||||
+ self.calls.append((kind, digest))
|
||||
+ if kind == self.fail: raise TimeoutError('SYNTHETIC-PRIVATE-BACKEND-OUTPUT')
|
||||
+ return CleanupResult(True, 'e'*64)
|
||||
+ def revoke_task_token(self, digest): return self.call('task-token-revoke', digest)
|
||||
+ def revoke_identity_token(self, digest): return self.call('identity-token-revoke', digest)
|
||||
+ def delete_grant_policy(self, digest): return self.call('grant-policy-delete', digest)
|
||||
+
|
||||
+
|
||||
+def crash_worker(directory, binding, after_effect):
|
||||
+ journal = Journal(Path(directory))
|
||||
+ with journal.locked():
|
||||
+ journal.reserve(binding, OBLIGATIONS)
|
||||
+ journal.transition(binding, 'reserved', 'consume-confirmed')
|
||||
+ journal.before_cleanup(binding, 0, OBLIGATIONS[0])
|
||||
+ if after_effect:
|
||||
+ # A real durable synthetic effect, separate from the admission DB.
|
||||
+ fd = os.open(Path(directory)/'synthetic-effect', os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600)
|
||||
+ os.write(fd, b'one fixture revocation\n'); os.fsync(fd); os.close(fd)
|
||||
+ os._exit(23)
|
||||
+
|
||||
+
|
||||
+def cleanup_racer(directory, binding, gate, results):
|
||||
+ journal = Journal(Path(directory))
|
||||
+ gate.wait()
|
||||
+ try:
|
||||
+ with journal.locked():
|
||||
+ journal.reserve(binding, OBLIGATIONS)
|
||||
+ journal.transition(binding, 'reserved', 'consume-confirmed')
|
||||
+ run_cleanup(journal, binding, OwnerFixture())
|
||||
+ results.put('admitted')
|
||||
+ except Refused: results.put('refused')
|
||||
+ finally: journal.close()
|
||||
+
|
||||
+
|
||||
+class Tests(unittest.TestCase):
|
||||
+ def setUp(self):
|
||||
+ self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup)
|
||||
+ self.path = Path(self.tmp.name)
|
||||
+ self.binding = Binding('approval-cleanup', 'sha256:'+'a'*64, str(uuid.uuid4()), 'b'*64)
|
||||
+ self.journal = Journal(self.path); self.addCleanup(self.journal.close)
|
||||
+
|
||||
+ def consumed(self):
|
||||
+ self.journal.reserve(self.binding, OBLIGATIONS)
|
||||
+ self.journal.transition(self.binding, 'reserved', 'consume-confirmed')
|
||||
+
|
||||
+ def test_timeout_business_call_keeps_unknown_while_all_cleanup_runs(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed()
|
||||
+ self.journal.before_call(self.binding, 0, 'd'*64)
|
||||
+ self.journal.transition(self.binding, 'consume-confirmed', 'unknown')
|
||||
+ owner = OwnerFixture()
|
||||
+ status = run_cleanup(self.journal, self.binding, owner)
|
||||
+ self.assertTrue(status['confirmed'])
|
||||
+ self.assertEqual(owner.calls, [(c.kind,c.call_digest) for c in OBLIGATIONS])
|
||||
+ self.assertEqual(self.journal.observe(self.binding)['state'], 'unknown')
|
||||
+ self.assertTrue(self.journal.observe(self.binding)['external_outcome_unknown'])
|
||||
+ with self.assertRaises(Refused): self.journal.before_call(self.binding, 1, 'f'*64)
|
||||
+
|
||||
+ def test_failed_business_outcome_preserved(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed(); self.journal.transition(self.binding,'consume-confirmed','failed')
|
||||
+ self.assertTrue(run_cleanup(self.journal,self.binding,OwnerFixture())['confirmed'])
|
||||
+ self.assertEqual(self.journal.observe(self.binding)['state'], 'failed')
|
||||
+
|
||||
+ def test_one_revoke_failure_never_passes_but_independent_cleanup_continues(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed(); owner = OwnerFixture('task-token-revoke')
|
||||
+ result = run_cleanup(self.journal,self.binding,owner)
|
||||
+ self.assertFalse(result['confirmed']); self.assertEqual(len(owner.calls),3)
|
||||
+ self.assertEqual([r['outcome'] for r in result['obligations']], ['unconfirmed','confirmed','confirmed'])
|
||||
+ self.assertNotIn('SYNTHETIC-PRIVATE', str(result))
|
||||
+ with self.assertRaises(Refused): run_cleanup(self.journal,self.binding,owner)
|
||||
+ self.assertEqual(len(owner.calls),3)
|
||||
+ self.assertNotIn(b'SYNTHETIC-PRIVATE', (self.path/'journal.sqlite').read_bytes())
|
||||
+
|
||||
+ def test_cleanup_closes_business_before_native_attempt(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed(); self.journal.before_cleanup(self.binding,0,OBLIGATIONS[0])
|
||||
+ with self.assertRaises(Refused): self.journal.before_call(self.binding,0,'d'*64)
|
||||
+ with self.assertRaises(Refused): self.journal.transition(self.binding,'consume-confirmed','authenticating')
|
||||
+
|
||||
+ def test_no_preconsume_cleanup_even_when_unknown(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.journal.reserve(self.binding,OBLIGATIONS)
|
||||
+ self.journal.transition(self.binding,'reserved','unknown')
|
||||
+ owner = OwnerFixture()
|
||||
+ with self.assertRaises(Refused): run_cleanup(self.journal,self.binding,owner)
|
||||
+ self.assertEqual(owner.calls,[])
|
||||
+
|
||||
+ def test_changed_kind_digest_ordinal_or_binding_never_dispatches(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed()
|
||||
+ for ordinal,item in [(False,OBLIGATIONS[0]),(3,OBLIGATIONS[0]),(0,Cleanup('task-token-revoke','9'*64)),(0,Cleanup('kv-delete','1'*64)),(0,{'kind':'task-token-revoke'})]:
|
||||
+ with self.subTest(ordinal=ordinal,item=item):
|
||||
+ with self.assertRaises(Refused): self.journal.before_cleanup(self.binding,ordinal,item)
|
||||
+ changed = Binding('another',self.binding.request_digest,self.binding.operation_id,self.binding.plan_digest)
|
||||
+ with self.assertRaises(Refused): self.journal.before_cleanup(changed,0,OBLIGATIONS[0])
|
||||
+ self.assertTrue(all(x['outcome']=='owed' for x in self.journal.cleanup_status(self.binding)['obligations']))
|
||||
+
|
||||
+ def test_cleanup_plan_must_be_closed_before_reservation(self):
|
||||
+ for plan in [list(OBLIGATIONS), (OBLIGATIONS[0],OBLIGATIONS[0]), (Cleanup('kv-delete','1'*64),), (Cleanup('task-token-revoke',False),)]:
|
||||
+ with self.journal.locked():
|
||||
+ with self.assertRaises(Refused): self.journal.reserve(self.binding,plan)
|
||||
+ self.assertIsNone(self.journal.observe(self.binding))
|
||||
+
|
||||
+ def test_crash_before_and_after_real_synthetic_effect_does_not_resume(self):
|
||||
+ context = multiprocessing.get_context('fork')
|
||||
+ for after_effect in (False, True):
|
||||
+ with tempfile.TemporaryDirectory() as directory:
|
||||
+ worker = context.Process(target=crash_worker,args=(directory,self.binding,after_effect))
|
||||
+ worker.start(); worker.join(10)
|
||||
+ if worker.is_alive(): worker.terminate(); worker.join(); self.fail('crash fixture exceeded bound')
|
||||
+ self.assertEqual(worker.exitcode,23)
|
||||
+ reopened = Journal(Path(directory))
|
||||
+ try:
|
||||
+ self.assertEqual(reopened.cleanup_status(self.binding)['obligations'][0]['outcome'],'pending')
|
||||
+ self.assertTrue(reopened.observe(self.binding)['external_outcome_unknown'])
|
||||
+ owner = OwnerFixture()
|
||||
+ with reopened.locked():
|
||||
+ with self.assertRaises(Refused): reopened.reserve(self.binding,OBLIGATIONS)
|
||||
+ with self.assertRaises(Refused): run_cleanup(reopened,self.binding,owner)
|
||||
+ with self.assertRaises(Refused): reopened.before_cleanup(self.binding,1,OBLIGATIONS[1])
|
||||
+ self.assertEqual(owner.calls,[])
|
||||
+ effect = Path(directory)/'synthetic-effect'
|
||||
+ self.assertEqual(effect.read_bytes() if effect.exists() else b'',b'one fixture revocation\n' if after_effect else b'')
|
||||
+ self.assertFalse(reopened.cleanup_status(self.binding)['confirmed'])
|
||||
+ finally: reopened.close()
|
||||
+
|
||||
+ def test_actual_process_race_admits_one_cleanup_driver(self):
|
||||
+ context = multiprocessing.get_context('fork'); gate = context.Event(); results = context.Queue()
|
||||
+ workers = [context.Process(target=cleanup_racer,args=(str(self.path),self.binding,gate,results)) for _ in range(4)]
|
||||
+ for worker in workers: worker.start()
|
||||
+ gate.set()
|
||||
+ for worker in workers:
|
||||
+ worker.join(10)
|
||||
+ if worker.is_alive(): worker.terminate(); worker.join(); self.fail('race exceeded bound')
|
||||
+ self.assertEqual(worker.exitcode,0)
|
||||
+ self.assertEqual(sorted(results.get(timeout=2) for _ in workers),['admitted','refused','refused','refused'])
|
||||
+ self.assertTrue(self.journal.cleanup_status(self.binding)['confirmed'])
|
||||
+
|
||||
+ def test_empty_or_malformed_native_result_never_proves_cleanup(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.journal.reserve(self.binding)
|
||||
+ self.journal.transition(self.binding,'reserved','consume-confirmed')
|
||||
+ with self.assertRaises(Refused): run_cleanup(self.journal,self.binding,OwnerFixture())
|
||||
+ self.assertFalse(self.journal.cleanup_status(self.binding)['confirmed'])
|
||||
+
|
||||
+ def test_bad_native_confirmation_still_attempts_remaining_cleanup(self):
|
||||
+ class BadOwner(OwnerFixture):
|
||||
+ def revoke_task_token(self, digest):
|
||||
+ self.calls.append(('task-token-revoke',digest))
|
||||
+ return CleanupResult('true','e'*64)
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed(); owner=BadOwner()
|
||||
+ self.assertFalse(run_cleanup(self.journal,self.binding,owner)['confirmed'])
|
||||
+ self.assertEqual(len(owner.calls),3)
|
||||
+
|
||||
+ def test_confirmation_exact_types_and_no_duplicate_receipts(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed(); self.journal.before_cleanup(self.binding,0,OBLIGATIONS[0])
|
||||
+ for confirmed,digest in [('true','e'*64),(1,'e'*64),(True,None),(True,'E'*64)]:
|
||||
+ with self.assertRaises(Refused): self.journal.after_cleanup(self.binding,0,OBLIGATIONS[0],confirmed=confirmed,evidence_digest=digest)
|
||||
+ self.journal.after_cleanup(self.binding,0,OBLIGATIONS[0],confirmed=True,evidence_digest='e'*64)
|
||||
+ with self.assertRaises(Refused): self.journal.after_cleanup(self.binding,0,OBLIGATIONS[0],confirmed=True,evidence_digest='e'*64)
|
||||
+ with self.assertRaises(Refused): self.journal.before_cleanup(self.binding,0,OBLIGATIONS[0])
|
||||
+
|
||||
+ def test_cleanup_state_is_independent_of_legacy_business_transition(self):
|
||||
+ with self.journal.locked():
|
||||
+ self.consumed()
|
||||
+ states=['consume-confirmed','authenticating','grant-issuing','grant-issued','applying','readback','verified']
|
||||
+ for before,after in zip(states,states[1:]): self.journal.transition(self.binding,before,after)
|
||||
+ with self.assertRaises(Refused): self.journal.transition(self.binding,'verified','cleanup-confirmed','e'*64)
|
||||
+ run_cleanup(self.journal,self.binding,OwnerFixture())
|
||||
+ self.journal.transition(self.binding,'verified','cleanup-confirmed','e'*64)
|
||||
+ self.assertEqual(self.journal.observe(self.binding)['state'],'cleanup-confirmed')
|
||||
+
|
||||
+
|
||||
+if __name__ == '__main__': unittest.main()
|
||||
diff --git a/tests/test_task_execution_journal.py b/tests/test_task_execution_journal.py
|
||||
index cb9874d..b1a76fc 100644
|
||||
--- a/tests/test_task_execution_journal.py
|
||||
+++ b/tests/test_task_execution_journal.py
|
||||
@@ -12,6 +12,7 @@ module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
Binding, Journal, Refused = module.Binding, module.Journal, module.Refused
|
||||
+Cleanup = module.Cleanup
|
||||
|
||||
|
||||
def reserve_worker(directory, binding, gate, results):
|
||||
@@ -74,8 +75,12 @@ class Tests(unittest.TestCase):
|
||||
def test_full_path_cleanup_and_terminal_replay(self):
|
||||
states=['reserved','consume-confirmed','authenticating','grant-issuing','grant-issued','applying','readback','verified','cleanup-confirmed']
|
||||
with self.journal.locked():
|
||||
- self.journal.reserve(self.binding)
|
||||
+ cleanup = Cleanup('task-token-revoke','d'*64)
|
||||
+ self.journal.reserve(self.binding,(cleanup,))
|
||||
for before,after in zip(states,states[1:]):
|
||||
+ if after == 'cleanup-confirmed':
|
||||
+ self.journal.before_cleanup(self.binding,0,cleanup)
|
||||
+ self.journal.after_cleanup(self.binding,0,cleanup,confirmed=True,evidence_digest='c'*64)
|
||||
self.journal.transition(self.binding,before,after,'c'*64)
|
||||
with self.assertRaises(Refused): self.journal.transition(self.binding,'cleanup-confirmed','applying')
|
||||
self.assertEqual(self.journal.observe(self.binding),{'state':'cleanup-confirmed','evidence_digest':'c'*64,'dispatch_allowed':False,'external_outcome_unknown':False})
|
||||
diff --git a/workplans/RPF-WP-0035-credential-lane-implementation.md b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
index ebd890f..269ad08 100644
|
||||
--- a/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
+++ b/workplans/RPF-WP-0035-credential-lane-implementation.md
|
||||
@@ -1225,3 +1225,17 @@ this change needs its own integration review. T12 retains all existing full
|
||||
contract, identity/consume, dispatch, custody and native acceptance requirements.
|
||||
RPF-IN-0012 remains open for the independent contract/wrapped-delivery return;
|
||||
this code correction does not authorize either operation.
|
||||
+
|
||||
+2026-10-16 T12 source preparation (isolated owner checkout; not owner delivery):
|
||||
+added durable precommitted cleanup obligations and separate outcome evidence to
|
||||
+the existing Journal, plus a fixed three-method owner cleanup driver. Same live
|
||||
+postconsume admission can clean after a business timeout/failed/unknown outcome;
|
||||
+cleanup closes business dispatch and cannot replay or restore admission after a
|
||||
+crash. Empty obligations no longer produce cleanup-confirmed. Real SQLite
|
||||
+multiprocess races and abrupt process death before/after a separately durable
|
||||
+synthetic effect verify refusal on reopen. Current compiler/journal/cleanup suite:
|
||||
+73 passed, local source/fixture only. Details: docs/task-execution-cleanup.md.
|
||||
+No native adapter or production mediator exists; full grant/request/cleanup binding,
|
||||
+authenticated consume/identity, ephemeral token issuance, deadline/use checks,
|
||||
+native effect/revoke/absence, audit and protected recovery remain prerequisites.
|
||||
+T12 remains progress. No CCR38 default/entitlement or live grant was changed.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"reviewer": "railiance-platform-owner (agent railiance-platform, codex)",
|
||||
"decision": "approved",
|
||||
"scope": "Source-only durable cleanup journal and fixed three-method cleanup driver; no native or production admission",
|
||||
"comment": "INTENT Secure Custody and Durable by Default require cleanup evidence independent from uncertain business effects. Reviewed exact modified source SHA256s below under existing RPF-WP-0035-T12. This owner source review grants no founder approval or execution authority. Full mediated execution remains changes-required pending grant, identity, consume, native adapter, enforcement and proof.",
|
||||
"owner_work_item": "RPF-WP-0035-T12",
|
||||
"base_commit": "3b6e647aa06bed88966a4539f9d3cb7ed6792bd4",
|
||||
"reviewed_files_sha256": {
|
||||
"docs/task-execution-cleanup.md": "44cb21e69838c961eca9805ec3d1f3e64d12b46dc9c2e7b02c69610a97a0f33b",
|
||||
"docs/task-execution-plan-compiler.md": "83f28626f1f6499f52f22c3daafd22c802475e8a77bb1be7ba9901c7ae00bf8b",
|
||||
"scripts/task_execution_cleanup.py": "c1c0b649e8002b9a6e1599d46f70ad3ad3d743323ddb82367468d5bdb103062c",
|
||||
"scripts/task_execution_journal.py": "f512d72be880de2a6345d3550c60a0c8e6e3b6d2bc8123f4502a068afabd973b",
|
||||
"tests/test_task_execution_cleanup.py": "a0d4d6d2c8c22bfed17a109a6f0be02d5f128619866ae5f1ea69f81803133085",
|
||||
"tests/test_task_execution_journal.py": "707ee5404e567ddf9896d680feae47628b9a3cfc9e3ce0073100e1677af53b5c",
|
||||
"workplans/RPF-WP-0035-credential-lane-implementation.md": "f1be966f38a96efbedcb7a129586a6f905e1910fe37ef85c61fe29379fa50af6"
|
||||
},
|
||||
"native_acceptance": false,
|
||||
"production_executor_available": false,
|
||||
"independent_from_implementation": false,
|
||||
"finding": "Prior business terminal states prevented cleanup after uncertainty; precommitted cleanup now has its own durable lifecycle, with no business replay or admission recovery.",
|
||||
"boundary": "No standing admin grants or generic executor; no native opener. Cleanup commitments must still be incorporated into independently verified full request/owner plan. Trusted owner adapter remains unimplemented."
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
diff --git a/workplans/KEY-WP-0040-mfa-attended-run-findings.md b/workplans/KEY-WP-0040-mfa-attended-run-findings.md
|
||||
index 9074902..df7f210 100644
|
||||
--- a/workplans/KEY-WP-0040-mfa-attended-run-findings.md
|
||||
+++ b/workplans/KEY-WP-0040-mfa-attended-run-findings.md
|
||||
@@ -8,7 +8,7 @@ status: active
|
||||
owner: claude-code
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-11"
|
||||
-updated: "2026-10-14"
|
||||
+updated: "2026-10-16"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "a67f9d60-9b8f-5111-9fe5-9f64e631b1c3"
|
||||
@@ -90,3 +90,45 @@ T02 is a local source edit only; its source/image publication remains pending.
|
||||
2026-10-14: the MFA challenge render test now requires the lost-authenticator
|
||||
link after the OTP form, with rel="noreferrer" and no request state in the URL.
|
||||
T02 stays progress until that page is published and an attended journey sees it.
|
||||
+
|
||||
+## Current publication and attended acceptance — 2026-10-16
|
||||
+
|
||||
+The source-only publication statements above describe the October 12–14 state.
|
||||
+They are superseded for T02: the October 15 founder-run publication recorded in
|
||||
+`docs/operations.md` (Current image) and KEY-WP-0041-T02 delivered image
|
||||
+`sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010`
|
||||
+from source `8c6c071`. That source includes the OTP help link commit `205a1c0`.
|
||||
+The founder verified the image-only diff, server dry-run, Recreate rollout,
|
||||
+Ready pod with zero restarts and discovery HTTP200. No additional image rollout
|
||||
+is needed to make the existing T02 link available. This reconciliation makes
|
||||
+no new live observation and does not infer attended acceptance from publication.
|
||||
+
|
||||
+T01 still needs the actual authenticated preview; T02 still needs the actual
|
||||
+OTP-page observation. Both remain progress. Resume the existing owner procedure
|
||||
+`user-engine/docs/mfa-attended-run.md`, corrected by its October 11 evidence
|
||||
+`user-engine/docs/evidence/2026-10-11-mfa-attended-u05-u08.md`:
|
||||
+
|
||||
+1. In the real user-engine-portal OTP challenge, observe **Code rejected or lost
|
||||
+ your authenticator?**, follow it to `https://users.coulomb.social/security`,
|
||||
+ and confirm the rejected-code/lost-device guidance is reachable. Preserve no
|
||||
+ challenge URL, request state, cookie or OTP value in evidence. Record time,
|
||||
+ client, link text/destination and outcome. This supplies T02 acceptance.
|
||||
+2. With platform-root's own fresh AAL2 browser session, open the existing
|
||||
+ `/platform/factor-recovery` form. Preview `tegwick` using the existing support
|
||||
+ reference `U08-2026-10-11`; inspect any existing reference before changing it.
|
||||
+ Confirm the current factor metadata and shared-identity warning. The October
|
||||
+ 11 suffix was …E1D; a changed factor is a reason to reconcile current state,
|
||||
+ never to force the old selection. Record only time, preview outcome and serial
|
||||
+ suffix. This preview alone makes no factor change and supplies the remaining
|
||||
+ T01 preview evidence.
|
||||
+3. Only in the separately attended recovery window, after the operator verifies
|
||||
+ the person and exact current scope, continue the existing U08 confirmation,
|
||||
+ audited disable, reference readback, replacement enrollment and fresh login
|
||||
+ procedure. Preserve the original reference and safe recovery record id.
|
||||
+ Neither the published bug fix nor a passing preview closes U08. Stop on
|
||||
+ stale metadata, replaced factor or failed audit/readback; do not reset or
|
||||
+ re-enroll merely to make an acceptance check pass.
|
||||
+
|
||||
+No authenticated preview, real-factor mutation or attended page observation was
|
||||
+performed during this source reconciliation. No passwords, codes, token values,
|
||||
+provider keys or production Secret objects were requested or read.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Owner-held KeyCape recovery preparation — candidate R1
|
||||
|
||||
Owner baseline: `878abc93588e12b6da1bac055cfbb3abb8d7adf1`. Canonical files are unchanged. Review `candidate.patch`, `review.json`, `source-closure.json` and the copied `test-root` sources. R6/R7 originals and all failed/positive trial evidence remain unchanged. This packet has not run an owner-held or Docker fixture attempt.
|
||||
|
||||
The candidate corrects pre-input failures in the existing attended path. It checks the exact admission digest, bounded strict JSON metadata, archive/transfer binding, selected service selector and typed hashes for all 17 invoked local source/config dependencies before requesting custody. Third-party Python dependencies are imported before the prompt. TLS files and ciphertext are checked by file metadata only during preparation; their contents are not opened by preparation.
|
||||
|
||||
The existing custody shell gains an explicit `--sealed-fd --prompt -- COMMAND` mode. A new hidden-prompt helper verifies the expected age recipient, seals a private anonymous Linux memfd and passes only its exact descriptor to the existing child. The existing `private_key_fd` accepts a fully sealed, anonymous, owned, private regular descriptor and returns a non-inheritable duplicate. Simultaneous FILE/FD sources, malformed descriptor text, incomplete seals and named descriptors are refused. Original file mode remains compatible; its original SIGKILL cleanup limitation is not changed or claimed fixed. The launcher selects descriptor mode, so no named key file can survive its parent loss. An admitted child retains its descriptor until it exits; this is not child revocation. Existing session/reaper quiescence bounds that child's later cleanup.
|
||||
|
||||
Private manifest bindings are explicit and versioned. Legacy explicit hashes and `explicit-digest-v1` remain valid. `ciphertext-derived-v1` forbids a supplied manifest hash and requires an independently verified transfer and exact ciphertext hash. After that actual ciphertext is checked, age authentication succeeds and the complete existing archive validation passes, the canonical manifest digest is derived privately for the unchanged payload/native checks. It never enters safe receipts/checkpoints. This binds data through exact ciphertext; it grants no client or consumer authority.
|
||||
|
||||
`attempt-metadata.json` records the known archive: 109738344 bytes, ciphertext `7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30`, and the prior 36-file validation. The admission template intentionally contains null unresolved fields and is refused by the validator. Do not use it to request keys. The original issuer/TLS/trust bindings and selected original service registration still require independently established metadata and owner source review.
|
||||
|
||||
Current ingress source names `kc.coulomb.social`, `sso/kc-tls`, cert-manager `ClusterIssuer/letsencrypt-prod` and TLS termination at Traefik before KeyCape port 8080. The identity backup profile excludes `kc-tls`. Independent recovery of original TLS/trust, or an accepted reissue path under normal issuer/cluster outage, is not established by the archive proof. A missing exact routing-catalog match does not establish absence of an owning subsystem. This preparation neither reads the TLS secret nor substitutes fresh self-signed trust. TLS recovery ownership/provider requirements must be resolved through the existing source-owner process.
|
||||
|
||||
The generated-key tests exercise real age-keygen/decryption, real authenticated ciphertext refusal, complete archive restore, wrong explicit manifest, malformed modes, false transfer bindings, absence of private manifest data in receipts, the actual synthetic hidden-prompt shell, exact parent loss and descriptor release. Docker/issuer results in manifest-builder unit tests are mocked and confer no native proof. The original synthetic native fixture remains available under a new hash-bound source manifest for later reviewed successor trials; it still supplies a generated fixture key using its original fixture override and does not by itself exercise the attended descriptor prompt.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```sh
|
||||
python3 -m pytest -q --tb=short test-root/tests/test_private_manifest_binding.py test-root/tests/test_sealed_custody.py test-root/tests/test_prepare_isolated_keycape.py test-root/tests/test_isolated_keycape_session.py test-root/tests/test_isolated_keycape_recovery.py test-root/tests/test_isolated_keycape_fixture_plan.py
|
||||
python3 -m pytest -q --tb=no test-root/tests/test_sops_private.py test-root/tests/test_sops_write.py test-root/tests/test_custody_entrypoints.py test-root/tests/test_service_secret_custody.py
|
||||
bash -n test-root/tools/bootstrap-recovery/run-isolated-keycape.sh test-root/sso-mfa/bootstrap/sops-custody-unlock.sh
|
||||
```
|
||||
|
||||
The existing custody suite passed 94 checks; 61 real-SOPS checks skipped because a trusted `sops` executable is unavailable in this environment. That compatibility coverage remains unverified. Intermediate copied-fixture/source-manifest errors are retained in `review.json`; they were corrected without changing frozen evidence.
|
||||
|
||||
Before any input, review and integrate the exact source, validate the target runtime/dependencies, resolve original TLS/trust and selected registration metadata, finalize the private admission digest and existing owner authority/abort boundaries. The eventual six-argument command remains the canonical `run-isolated-keycape.sh VERIFIED_TRANSFER_RECEIPT PRIVATE_ADMISSION ADMISSION_SHA256 ORIGINAL_TLS_CERT ORIGINAL_TLS_KEY ORIGINAL_TRUST`; all private input stays in the owner's own terminal. Actual recovery execution and selected consumer acceptance remain T18; retained-factor human MFA remains T19. T33 is not ready merely because source preparation has progressed.
|
||||
|
||||
Coordinator public-image readback resolves the pinned issuer executable digest in public-verifier-image-readback.json; the temporary stopped probe was removed and never started. This does not resolve TLS/registration custody or admit native use.
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"manifest_binding_mode": "ciphertext-derived-v1",
|
||||
"ciphertext_sha256": "7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30",
|
||||
"issuer": null,
|
||||
"verifier_sha256": "cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4",
|
||||
"tls_cert_sha256": null,
|
||||
"trust_sha256": null,
|
||||
"consumer": {
|
||||
"client_id": null,
|
||||
"secret_env": null,
|
||||
"audience": null,
|
||||
"scope": null,
|
||||
"subject": null,
|
||||
"tenant": null,
|
||||
"roles": null,
|
||||
"deny_scope": null
|
||||
},
|
||||
"source_sha256": {
|
||||
"sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||
"sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||
"sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||
"tools/bootstrap-recovery/check-privacyidea-database.py": "sha256:90f454c87998d7f5d625e3c6656c3418f8f56275dc1879a9d19ed605431b94a4",
|
||||
"tools/bootstrap-recovery/identity-backup-profile.json": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"tools/bootstrap-recovery/identity-bundle.py": "sha256:524a03c065db4307fcde83d33af39503f3de19cb6ef3a8ca72c991d4a8c08ee6",
|
||||
"tools/bootstrap-recovery/identity-custody.json": "sha256:f8aee8487ed01d7940b82676bb4a5c8d61f51b6c598521b8b8a73588aaea4d4f",
|
||||
"tools/bootstrap-recovery/identity-files.py": "sha256:d0063a9eedd3610e3d45bd2d834aae83cff74e4b3504d138b3548f427e89331d",
|
||||
"tools/bootstrap-recovery/identity-offsite.py": "sha256:e7c31fa9ee081961af873348ee3b4829a645d40c7dc7f1e22c564aa9ed01bc49",
|
||||
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||
"tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"status": "incomplete_not_executable_not_approved",
|
||||
"owner_head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"source_sha256": {
|
||||
"sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||
"sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||
"sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||
"tools/bootstrap-recovery/check-privacyidea-database.py": "sha256:90f454c87998d7f5d625e3c6656c3418f8f56275dc1879a9d19ed605431b94a4",
|
||||
"tools/bootstrap-recovery/identity-backup-profile.json": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"tools/bootstrap-recovery/identity-bundle.py": "sha256:524a03c065db4307fcde83d33af39503f3de19cb6ef3a8ca72c991d4a8c08ee6",
|
||||
"tools/bootstrap-recovery/identity-custody.json": "sha256:f8aee8487ed01d7940b82676bb4a5c8d61f51b6c598521b8b8a73588aaea4d4f",
|
||||
"tools/bootstrap-recovery/identity-files.py": "sha256:d0063a9eedd3610e3d45bd2d834aae83cff74e4b3504d138b3548f427e89331d",
|
||||
"tools/bootstrap-recovery/identity-offsite.py": "sha256:e7c31fa9ee081961af873348ee3b4829a645d40c7dc7f1e22c564aa9ed01bc49",
|
||||
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||
"tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f"
|
||||
},
|
||||
"archive": {
|
||||
"artifact": "net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age",
|
||||
"ciphertext_bytes": 109738344,
|
||||
"ciphertext_sha256": "7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30",
|
||||
"etag": "\"28ec1f7014e492de4c40e1ae75502f08\"",
|
||||
"fetched_path": "/tmp/nk49-identity-fetched-v0x96qlx/net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age"
|
||||
},
|
||||
"manifest": {
|
||||
"file_count_observed": 36,
|
||||
"manifest_binding_mode": "ciphertext-derived-v1",
|
||||
"private_derivation": "After exact independently retrieved ciphertext check, successful authenticated age decryption and full existing archive validation, derive canonical manifest digest only inside owner memory. Never emit it in safe receipts.",
|
||||
"explicit_compatibility": "Legacy explicit manifest_sha256 remains accepted; explicit-digest-v1 may name that original behavior; unknown modes and conflicting bindings are refused."
|
||||
},
|
||||
"original_identity": {
|
||||
"issuer": null,
|
||||
"tls_cert_sha256": null,
|
||||
"trust_sha256": null,
|
||||
"verifier_sha256": "cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4",
|
||||
"image": "forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010",
|
||||
"tls_custody_paths": "Owner-controlled original files; no values or paths inferred from Kubernetes Secrets.",
|
||||
"public_ingress_source": {
|
||||
"host": "kc.coulomb.social",
|
||||
"namespace": "sso",
|
||||
"secret": "kc-tls",
|
||||
"issuer_kind": "ClusterIssuer",
|
||||
"issuer": "letsencrypt-prod",
|
||||
"termination": "Traefik to KeyCape8080"
|
||||
},
|
||||
"recovery_gap": "Current backup profile excludes ingress TLS Secret kc-tls; independent original TLS/trust custody or an accepted reissue path during normal issuer/cluster outage is not established. No self-signed substitute is admitted.",
|
||||
"verifier_readback_receipt": "public-verifier-image-readback.json"
|
||||
},
|
||||
"selected_consumer": {
|
||||
"client_id": null,
|
||||
"secret_env": null,
|
||||
"audience": null,
|
||||
"scope": null,
|
||||
"subject": null,
|
||||
"tenant": null,
|
||||
"roles": null,
|
||||
"deny_scope": null
|
||||
},
|
||||
"dependency_graph": {
|
||||
"native_service_registration": [
|
||||
"original KeyCape image+archived config/environment+signer+policy",
|
||||
"shared verified tmpfs",
|
||||
"loopback trusted TLS transport",
|
||||
"existing native verify-client"
|
||||
],
|
||||
"restored_provider_configuration": [
|
||||
"LLDAP",
|
||||
"privacyIDEA scoped factor-reader",
|
||||
"Authelia"
|
||||
],
|
||||
"provider_network_access": "None; network-none shared namespace, no production contact",
|
||||
"selected_downstream_consumer": "Unselected; no redirect or new grant authorized",
|
||||
"retained_factor_mfa": "Not exercised by service-client flow"
|
||||
},
|
||||
"execution": {
|
||||
"endpoint": "unix:///var/run/docker.sock",
|
||||
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
||||
"engine_name": "bnt-lap001",
|
||||
"deadline_seconds": 1800,
|
||||
"safe_receipt": "fresh /tmp/nk49-isolated-keycape-XXXXXXXX/recovery.json",
|
||||
"abort": "Stop on any binding, startup, uncertainty, native denial or cleanup failure; preserve safe receipt and exact obligations; no wildcard cleanup or retry as success",
|
||||
"custody": "Existing shell --sealed-fd --prompt; strict owned sealed anonymous keyFD; no named key persists. Active child retains descriptor until its own exit, bounded by adapter session/reaper."
|
||||
},
|
||||
"pre_input_unresolved": [
|
||||
"Original issuer/TLS certificate/trust public bindings require independently established metadata; pinned cached executable digest is verified separately",
|
||||
"Selected original service registration and consumer owner acceptance remain absent",
|
||||
"Exact source patch review and owner publication needed before launcher adoption; no source approval asserted",
|
||||
"Sealed descriptor custody successor must be independently reviewed; original file custody path retains old hard-loss limitations",
|
||||
"Real SOPS decrypt/write compatibility61 checks are skipped until trusted sops runtime available"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,752 @@
|
|||
--- a/sso-mfa/bootstrap/sops-private.py
|
||||
+++ b/sso-mfa/bootstrap/sops-private.py
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
+import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -99,7 +100,24 @@
|
||||
|
||||
|
||||
def private_key_fd():
|
||||
+ inherited = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FD', '')
|
||||
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
|
||||
+ if inherited:
|
||||
+ if name or not re.fullmatch(r'[1-9][0-9]{0,8}',inherited) or int(inherited)<3:
|
||||
+ raise Refused('ambiguous custody descriptor')
|
||||
+ fd=os.dup(int(inherited))
|
||||
+ try:
|
||||
+ info=os.fstat(fd)
|
||||
+ required=fcntl.F_SEAL_WRITE|fcntl.F_SEAL_GROW|fcntl.F_SEAL_SHRINK|fcntl.F_SEAL_SEAL
|
||||
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid() or info.st_mode&0o077
|
||||
+ or info.st_nlink!=0 or not 0<info.st_size<=16384
|
||||
+ or fcntl.fcntl(fd,fcntl.F_GET_SEALS)!=required):
|
||||
+ raise Refused('custody descriptor must be anonymous, private and sealed')
|
||||
+ # No named file exists to survive parent SIGKILL. Existing callers
|
||||
+ # own the returned duplicate and verify the expected age recipient.
|
||||
+ return fd
|
||||
+ except BaseException:os.close(fd);raise
|
||||
+
|
||||
if not name:
|
||||
raise Refused('explicit temporary custody key required')
|
||||
path = Path(os.path.abspath(name))
|
||||
--- a/sso-mfa/bootstrap/sops-custody-unlock.sh
|
||||
+++ b/sso-mfa/bootstrap/sops-custody-unlock.sh
|
||||
@@ -14,6 +14,7 @@
|
||||
SOURCE_FILE=""
|
||||
INPUT_MODE=""
|
||||
CHECK_ONLY=false
|
||||
+SEALED_FD=false
|
||||
COMMAND=()
|
||||
|
||||
usage() {
|
||||
@@ -27,6 +28,7 @@
|
||||
--from-stdin Read custody age key material from stdin.
|
||||
--prompt Prompt for one AGE-SECRET-KEY-1... line.
|
||||
--check-only Validate the supplied key and exit.
|
||||
+ --sealed-fd Hidden prompt, explicit command, no named key file.
|
||||
-h, --help Show this help.
|
||||
|
||||
Examples:
|
||||
@@ -83,6 +85,10 @@
|
||||
INPUT_MODE="prompt"
|
||||
shift
|
||||
;;
|
||||
+ --sealed-fd)
|
||||
+ SEALED_FD=true
|
||||
+ shift
|
||||
+ ;;
|
||||
--check-only)
|
||||
CHECK_ONLY=true
|
||||
shift
|
||||
@@ -119,6 +125,11 @@
|
||||
|
||||
if [[ "$CHECK_ONLY" == false && ${#COMMAND[@]} -eq 0 && ! -t 0 ]]; then
|
||||
fail "an interactive custody shell requires a terminal; supply an explicit command"
|
||||
+fi
|
||||
+
|
||||
+if [[ "$SEALED_FD" == true ]]; then
|
||||
+ [[ "$INPUT_MODE" == prompt && "$CHECK_ONLY" == false && ${#COMMAND[@]} -gt 0 ]] || fail "sealed descriptor mode requires --prompt and an explicit command"
|
||||
+ exec python3 "$SCRIPT_DIR/sops-custody-fd.py" --expected-recipient "$EXPECTED_RECIPIENT" -- "${COMMAND[@]}"
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")"
|
||||
@@ -185,7 +196,7 @@
|
||||
fi
|
||||
|
||||
# The supplied key must not compete with an ambient inline key or key command.
|
||||
-unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD
|
||||
+unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD NETKINGDOM_CUSTODY_AGE_KEY_FD
|
||||
export SOPS_AGE_KEY_FILE="$KEY_FILE"
|
||||
export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE"
|
||||
|
||||
--- /dev/null
|
||||
+++ b/sso-mfa/bootstrap/sops-custody-fd.py
|
||||
@@ -0,0 +1,54 @@
|
||||
+#!/usr/bin/env python3
|
||||
+"""Attended custody through a sealed anonymous Linux descriptor, never a key path."""
|
||||
+import argparse
|
||||
+import fcntl
|
||||
+import getpass
|
||||
+import os
|
||||
+import re
|
||||
+import subprocess
|
||||
+import sys
|
||||
+import warnings
|
||||
+
|
||||
+SEALS=fcntl.F_SEAL_WRITE|fcntl.F_SEAL_GROW|fcntl.F_SEAL_SHRINK|fcntl.F_SEAL_SEAL
|
||||
+
|
||||
+class Refused(Exception):pass
|
||||
+
|
||||
+def sealed_key(material):
|
||||
+ if not isinstance(material,bytes) or not 1<=len(material)<=16384 or not re.fullmatch(b'AGE-SECRET-KEY-1[A-Z0-9]+\n?',material):raise Refused()
|
||||
+ fd=os.memfd_create('netkingdom-custody',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING)
|
||||
+ try:
|
||||
+ os.fchmod(fd,0o600)
|
||||
+ with os.fdopen(os.dup(fd),'wb') as out:out.write(material);out.flush()
|
||||
+ os.lseek(fd,0,os.SEEK_SET)
|
||||
+ fcntl.fcntl(fd,fcntl.F_ADD_SEALS,SEALS)
|
||||
+ if fcntl.fcntl(fd,fcntl.F_GET_SEALS)!=SEALS:raise Refused()
|
||||
+ return fd
|
||||
+ except BaseException:os.close(fd);raise
|
||||
+
|
||||
+def run(material,recipient,command):
|
||||
+ if not re.fullmatch(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}',recipient) or not command:raise Refused()
|
||||
+ fd=sealed_key(material)
|
||||
+ try:
|
||||
+ result=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||
+ if result.returncode or result.stdout.strip()!=recipient.encode():raise Refused()
|
||||
+ # The key must not compete with any ambient custody source. Children
|
||||
+ # receive only this exact inherited descriptor, with close-on-exec elsewhere.
|
||||
+ env={k:v for k,v in os.environ.items() if k not in ('NETKINGDOM_CUSTODY_AGE_KEY_FILE','NETKINGDOM_CUSTODY_AGE_KEY_FD','SOPS_AGE_KEY_FILE','SOPS_AGE_KEY','SOPS_AGE_KEY_CMD')}
|
||||
+ env['NETKINGDOM_CUSTODY_AGE_KEY_FD']=str(fd)
|
||||
+ return subprocess.run(command,env=env,pass_fds=(fd,)).returncode
|
||||
+ finally:os.close(fd)
|
||||
+
|
||||
+def main():
|
||||
+ p=argparse.ArgumentParser(description=__doc__);p.add_argument('--expected-recipient',required=True);p.add_argument('command',nargs=argparse.REMAINDER);args=p.parse_args()
|
||||
+ command=args.command[1:] if args.command[:1]==['--'] else args.command
|
||||
+ try:
|
||||
+ if (not all(os.isatty(fd) for fd in (0,1,2)) or not command
|
||||
+ or not re.fullmatch(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}',args.expected_recipient)):raise Refused()
|
||||
+ with warnings.catch_warnings():
|
||||
+ warnings.simplefilter('error',getpass.GetPassWarning)
|
||||
+ material=getpass.getpass('Paste custody age private key line (input hidden): ').encode()+b'\n'
|
||||
+ return run(material,args.expected_recipient,command)
|
||||
+ except Exception:
|
||||
+ print('ERROR: sealed custody input or execution refused; private diagnostics suppressed.',file=sys.stderr);return 1
|
||||
+
|
||||
+if __name__=='__main__':raise SystemExit(main())
|
||||
--- a/tools/bootstrap-recovery/isolated-keycape.py
|
||||
+++ b/tools/bootstrap-recovery/isolated-keycape.py
|
||||
@@ -32,6 +32,32 @@
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
+
|
||||
+
|
||||
+def manifest_binding_mode(admission,transfer):
|
||||
+ """Versioned private-manifest binding; legacy explicit digests stay valid."""
|
||||
+ mode=admission.get('manifest_binding_mode','explicit-digest-v1')
|
||||
+ if mode not in ('explicit-digest-v1','ciphertext-derived-v1'):raise Refused()
|
||||
+ if (not isinstance(admission.get('ciphertext_sha256'),str)
|
||||
+ or not re.fullmatch('[a-f0-9]{64}',admission['ciphertext_sha256'])
|
||||
+ or admission['ciphertext_sha256']!=transfer.get('ciphertext_sha256')
|
||||
+ or transfer.get('independent_owner_retrieval_verified') is not True):raise Refused()
|
||||
+ if mode=='explicit-digest-v1':
|
||||
+ if not isinstance(admission.get('manifest_sha256'),str) or not re.fullmatch('[a-f0-9]{64}',admission['manifest_sha256']):raise Refused()
|
||||
+ elif 'manifest_sha256' in admission:raise Refused()
|
||||
+ return mode
|
||||
+
|
||||
+
|
||||
+def private_manifest_admission(admission,manifest):
|
||||
+ # Only called after the fixed ciphertext bytes were hashed, authenticated
|
||||
+ # decryption succeeded and bundle.restore checked the complete archive.
|
||||
+ # Never return this enriched private object in a safe receipt/checkpoint.
|
||||
+ digest=hashlib.sha256(json.dumps(manifest,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
+ mode=admission.get('manifest_binding_mode','explicit-digest-v1')
|
||||
+ if mode=='explicit-digest-v1':
|
||||
+ if digest!=admission['manifest_sha256']:raise Refused()
|
||||
+ elif mode!='ciphertext-derived-v1' or 'manifest_sha256' in admission:raise Refused()
|
||||
+ return {**admission,'manifest_sha256':digest}
|
||||
|
||||
|
||||
def private_payload(root, manifest, expected_manifest_sha256):
|
||||
--- a/tools/bootstrap-recovery/isolated-keycape-session.py
|
||||
+++ b/tools/bootstrap-recovery/isolated-keycape-session.py
|
||||
@@ -382,6 +382,7 @@
|
||||
|
||||
def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
|
||||
"""Existing private custody/transfer/archive APIs; no second archive format."""
|
||||
+ adapter.manifest_binding_mode(admission,transfer)
|
||||
verify_execution(execution)
|
||||
if execution['pid']!=os.getpid():raise adapter.Refused()
|
||||
target=local.preflight()
|
||||
@@ -422,6 +423,8 @@
|
||||
if p.returncode or archive.stat().st_size>bundle.LIMIT+4*1024*1024:raise adapter.Refused()
|
||||
root=work/'restored';root.mkdir(mode=0o700)
|
||||
manifest,_=bundle.restore(archive,root)
|
||||
+ # Data binding comes from exact reviewed ciphertext, never a new grant.
|
||||
+ admission=adapter.private_manifest_admission(admission,manifest)
|
||||
payload=adapter.private_payload(root,manifest,admission['manifest_sha256'])
|
||||
import yaml
|
||||
cfg=yaml.safe_load(base64.b64decode(payload['files']['/etc/keycape/config.yaml']))
|
||||
--- a/tools/bootstrap-recovery/run-isolated-keycape.sh
|
||||
+++ b/tools/bootstrap-recovery/run-isolated-keycape.sh
|
||||
@@ -14,6 +14,9 @@
|
||||
for tool in python3 age age-keygen docker; do
|
||||
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
||||
done
|
||||
+python3 tools/bootstrap-recovery/prepare-isolated-keycape.py \
|
||||
+ --transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||
+ --tls-cert "$4" --tls-key "$5" --trust "$6"
|
||||
python3 - <<'PY'
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
@@ -29,12 +32,12 @@
|
||||
PY
|
||||
NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)"
|
||||
printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN"
|
||||
-printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.'
|
||||
+printf '%s\n' 'Isolated original issuer/trust and selected registered service-client checks only.'
|
||||
printf '%s\n' 'No browser, downstream consumer or retained-factor MFA acceptance is claimed.'
|
||||
printf '%s\n' 'Enter only the independently held backup age key at the hidden prompt.'
|
||||
bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz \
|
||||
- --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \
|
||||
+ --sealed-fd --prompt -- python3 tools/bootstrap-recovery/isolated-keycape-session.py \
|
||||
--transfer-receipt "$1" --admission "$2" --admission-sha256 "$3" \
|
||||
--tls-cert "$4" --tls-key "$5" --trust "$6" --receipt "$NK49_KC_RUN/recovery.json"
|
||||
printf 'Safe receipt: %s/recovery.json\n' "$NK49_KC_RUN"
|
||||
--- /dev/null
|
||||
+++ b/tools/bootstrap-recovery/prepare-isolated-keycape.py
|
||||
@@ -0,0 +1,119 @@
|
||||
+#!/usr/bin/env python3
|
||||
+"""Validate reviewed public recovery metadata before requesting custody input.
|
||||
+
|
||||
+Never opens TLS material, archive plaintext or any key. This validates bindings,
|
||||
+not owner assent, archive contents or recovery authority.
|
||||
+"""
|
||||
+import argparse
|
||||
+import hashlib
|
||||
+import importlib.util
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import re
|
||||
+import stat
|
||||
+from urllib.parse import urlsplit
|
||||
+
|
||||
+ROOT=Path(__file__).resolve().parents[2]
|
||||
+# The executable closure extends the synthetic fixture closure with the attended
|
||||
+# custody entry point and this validator. Tests and historical receipts do not run.
|
||||
+SOURCE_PATHS=tuple(sorted([
|
||||
+ 'sso-mfa/bootstrap/sops-custody-unlock.sh','sso-mfa/bootstrap/sops-private.py','sso-mfa/bootstrap/sops-custody-fd.py',
|
||||
+ 'sso-mfa/k8s/privacyidea/key-custody-remote.py',
|
||||
+ *('tools/bootstrap-recovery/'+name for name in (
|
||||
+ 'isolated-keycape.py','isolated-keycape-session.py','run-isolated-keycape.sh',
|
||||
+ 'prepare-isolated-keycape.py','isolated-lldap.py','identity-bundle.py',
|
||||
+ 'identity-backup-profile.json','retrieve-identity-offline.py','identity-offsite.py',
|
||||
+ 'verify-identity.py','identity-custody.json','identity-files.py',
|
||||
+ 'check-privacyidea-database.py'))]))
|
||||
+
|
||||
+class Refused(Exception):pass
|
||||
+
|
||||
+def require(value):
|
||||
+ if not value:raise Refused()
|
||||
+
|
||||
+def metadata_path(path):
|
||||
+ path=Path(os.path.abspath(path))
|
||||
+ require(not any(p.is_symlink() for p in (path,*path.parents)))
|
||||
+ st=path.stat()
|
||||
+ require(stat.S_ISREG(st.st_mode) and st.st_uid==os.getuid() and st.st_nlink==1 and not st.st_mode&0o077)
|
||||
+ return path
|
||||
+
|
||||
+def read_metadata(path,limit=128*1024):
|
||||
+ path=metadata_path(path)
|
||||
+ require(0<path.stat().st_size<=limit)
|
||||
+ fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
+ with os.fdopen(fd,'rb') as source:
|
||||
+ info=os.fstat(source.fileno())
|
||||
+ require(stat.S_ISREG(info.st_mode) and info.st_uid==os.getuid() and info.st_nlink==1 and not info.st_mode&0o077 and 0<info.st_size<=limit)
|
||||
+ raw=source.read(limit+1)
|
||||
+ require(0<len(raw)<=limit)
|
||||
+ return raw
|
||||
+
|
||||
+
|
||||
+def strict_json(raw):
|
||||
+ def unique(pairs):
|
||||
+ result={}
|
||||
+ for key,value in pairs:
|
||||
+ require(key not in result)
|
||||
+ result[key]=value
|
||||
+ return result
|
||||
+ return json.loads(raw,object_pairs_hook=unique)
|
||||
+
|
||||
+
|
||||
+def validate(transfer,admission,root=ROOT):
|
||||
+ require(isinstance(admission,dict))
|
||||
+ for field in ('ciphertext_sha256','verifier_sha256','trust_sha256','tls_cert_sha256'):
|
||||
+ require(isinstance(admission.get(field),str) and re.fullmatch('[a-f0-9]{64}',admission[field]))
|
||||
+ require(admission['ciphertext_sha256']==transfer.get('ciphertext_sha256'))
|
||||
+ require(transfer.get('independent_owner_retrieval_verified') is True)
|
||||
+ u=urlsplit(admission.get('issuer',''))
|
||||
+ require(u.scheme=='https' and u.hostname and not u.username and not u.password and not u.query and not u.fragment and u.port in (None,443) and u.path in ('','/'))
|
||||
+ consumer=admission.get('consumer',{})
|
||||
+ fields=('client_id','secret_env','audience','scope','subject','tenant','roles','deny_scope')
|
||||
+ require(isinstance(consumer,dict) and set(consumer)==set(fields))
|
||||
+ for field in fields:
|
||||
+ value=consumer[field]
|
||||
+ require(isinstance(value,str) and 0<len(value)<=1024 and value.strip()==value and not any(ord(c)<32 or ord(c)==127 for c in value))
|
||||
+ require(re.fullmatch('KEYCAPE_[A-Z0-9_]+',consumer['secret_env']))
|
||||
+ require(not set(consumer['scope'].split())&set(consumer['deny_scope'].split()))
|
||||
+ sources=admission.get('source_sha256')
|
||||
+ require(isinstance(sources,dict) and set(sources)==set(SOURCE_PATHS))
|
||||
+ for name,expected in sources.items():
|
||||
+ require(isinstance(expected,str) and re.fullmatch('sha256:[a-f0-9]{64}',expected))
|
||||
+ path=root/name
|
||||
+ require(not any(p.is_symlink() for p in (path,*path.parents)))
|
||||
+ require('sha256:'+hashlib.sha256(path.read_bytes()).hexdigest()==expected)
|
||||
+ spec=importlib.util.spec_from_file_location('adapter',root/'tools/bootstrap-recovery/isolated-keycape.py')
|
||||
+ adapter=importlib.util.module_from_spec(spec);spec.loader.exec_module(adapter)
|
||||
+ adapter.manifest_binding_mode(admission,transfer)
|
||||
+ # Validate provider receipt through the existing fixed contract, with no I/O.
|
||||
+ spec=importlib.util.spec_from_file_location('offline',root/'tools/bootstrap-recovery/retrieve-identity-offline.py')
|
||||
+ offline=importlib.util.module_from_spec(spec);spec.loader.exec_module(offline)
|
||||
+ offline.validate(transfer)
|
||||
+ ciphertext=metadata_path(transfer['fetched_path']) # no content read
|
||||
+ require(ciphertext.stat().st_size==transfer['ciphertext_bytes'])
|
||||
+ return True
|
||||
+
|
||||
+def main():
|
||||
+ p=argparse.ArgumentParser(description=__doc__)
|
||||
+ for name in ('transfer-receipt','admission','admission-sha256','tls-cert','tls-key','trust'):p.add_argument('--'+name,required=True)
|
||||
+ args=p.parse_args()
|
||||
+ try:
|
||||
+ require(re.fullmatch('[a-f0-9]{64}',args.admission_sha256))
|
||||
+ raw=read_metadata(args.admission)
|
||||
+ require(hashlib.sha256(raw).hexdigest()==args.admission_sha256)
|
||||
+ transfer_raw=read_metadata(args.transfer_receipt)
|
||||
+ validate(strict_json(transfer_raw),strict_json(raw))
|
||||
+ for path in (args.tls_cert,args.tls_key,args.trust):metadata_path(path)
|
||||
+ # Import all third-party dependencies before the attended custody prompt.
|
||||
+ import yaml
|
||||
+ from cryptography import x509
|
||||
+ from cryptography.hazmat.primitives import serialization
|
||||
+ except Exception:
|
||||
+ print('ERROR: reviewed metadata/source/dependency preflight refused; no key requested.')
|
||||
+ return 1
|
||||
+ print('OK: reviewed metadata and source closure verified; private contents not inspected.')
|
||||
+ return 0
|
||||
+
|
||||
+if __name__=='__main__':raise SystemExit(main())
|
||||
--- /dev/null
|
||||
+++ b/tests/test_prepare_isolated_keycape.py
|
||||
@@ -0,0 +1,108 @@
|
||||
+import copy
|
||||
+import hashlib
|
||||
+import importlib.util
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import tempfile
|
||||
+import pty
|
||||
+import subprocess
|
||||
+import unittest
|
||||
+from unittest.mock import patch
|
||||
+
|
||||
+ROOT=Path(__file__).parents[1]
|
||||
+spec=importlib.util.spec_from_file_location('prepare',ROOT/'tools/bootstrap-recovery/prepare-isolated-keycape.py')
|
||||
+p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
|
||||
+
|
||||
+class PreparationTests(unittest.TestCase):
|
||||
+ def setUp(self):
|
||||
+ self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
+ self.path=Path(self.temp.name)/'ciphertext';self.path.write_bytes(b'synthetic ciphertext');self.path.chmod(0o600)
|
||||
+ self.transfer={'schema':'netkingdom.identity-offsite.v1','status':'readback_verified','provider':'Storage Share',
|
||||
+ 'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':'net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',
|
||||
+ 'ciphertext_sha256':'a'*64,'ciphertext_bytes':len(b'synthetic ciphertext'),'etag':'"synthetic"','fetched_path':str(self.path),
|
||||
+ 'independent_owner_retrieval_verified':True}
|
||||
+ self.admission={name:'a'*64 for name in ('ciphertext_sha256','manifest_sha256','verifier_sha256','trust_sha256','tls_cert_sha256')}
|
||||
+ self.admission.update(issuer='https://issuer.example',consumer=dict(client_id='existing',secret_env='KEYCAPE_EXISTING_SECRET',audience='existing-api',scope='read',subject='svc:existing',tenant='existing',roles='reader',deny_scope='write'),
|
||||
+ source_sha256={name:'sha256:'+hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in p.SOURCE_PATHS})
|
||||
+ def test_complete_public_bindings_admitted_without_reading_ciphertext(self):
|
||||
+ original=Path.read_bytes
|
||||
+ def read(path):
|
||||
+ self.assertNotEqual(path,self.path)
|
||||
+ return original(path)
|
||||
+ with patch.object(Path,'read_bytes',read):self.assertTrue(p.validate(self.transfer,self.admission,ROOT))
|
||||
+ def test_oversize_metadata_refused_before_any_content_read(self):
|
||||
+ self.path.write_bytes(b'x'*(128*1024+1))
|
||||
+ with patch.object(Path,'read_bytes',side_effect=AssertionError('unbounded read')):
|
||||
+ with self.assertRaises(p.Refused):p.read_metadata(self.path)
|
||||
+ def test_duplicate_json_keys_refused_at_every_level(self):
|
||||
+ for raw in (b'{"issuer":"one","issuer":"two"}',b'{"consumer":{"client_id":"one","client_id":"two"}}'):
|
||||
+ with self.subTest(raw=raw),self.assertRaises(p.Refused):p.strict_json(raw)
|
||||
+ def test_bounded_metadata_reader_accepts_exact_limit(self):
|
||||
+ self.path.write_bytes(b'x'*128)
|
||||
+ self.assertEqual(p.read_metadata(self.path,128),b'x'*128)
|
||||
+ with self.assertRaises(p.Refused):p.read_metadata(self.path,127)
|
||||
+ def test_wrong_archive_binding_refused(self):
|
||||
+ self.admission['ciphertext_sha256']='b'*64
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ def test_ciphertext_size_mismatch_refused_without_decryption(self):
|
||||
+ self.transfer['ciphertext_bytes']+=1
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ def test_missing_independent_transfer_refused(self):
|
||||
+ self.transfer['independent_owner_retrieval_verified']=False
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ def test_missing_or_drifted_source_refused(self):
|
||||
+ for source in p.SOURCE_PATHS:
|
||||
+ bad=copy.deepcopy(self.admission);bad['source_sha256'][source]='0'*64
|
||||
+ with self.subTest(source=source),self.assertRaises(p.Refused):p.validate(self.transfer,bad,ROOT)
|
||||
+ bad=copy.deepcopy(self.admission);bad['source_sha256'].pop(next(iter(p.SOURCE_PATHS)))
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,bad,ROOT)
|
||||
+ def test_unreviewed_extra_dependency_refused(self):
|
||||
+ self.admission['source_sha256']['unreviewed.py']='a'*64
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ def test_ambiguous_or_unsupported_issuer_refused(self):
|
||||
+ for issuer in ('http://issuer.example','https://u:p@issuer.example','https://issuer.example:8443','https://issuer.example/path','https://issuer.example?x=1','https://issuer.example#fragment'):
|
||||
+ self.admission['issuer']=issuer
|
||||
+ with self.subTest(issuer=issuer),self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ def test_missing_consumer_or_overlapping_denial_refused(self):
|
||||
+ self.admission['consumer']['deny_scope']='read'
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ self.admission['consumer'].pop('roles')
|
||||
+ with self.assertRaises(p.Refused):p.validate(self.transfer,self.admission,ROOT)
|
||||
+ def test_symlink_private_input_refused_without_read(self):
|
||||
+ link=self.path.parent/'link';link.symlink_to(self.path)
|
||||
+ with self.assertRaises(p.Refused):p.metadata_path(link)
|
||||
+ def test_publicly_readable_private_input_refused(self):
|
||||
+ self.path.chmod(0o644)
|
||||
+ with self.assertRaises(p.Refused):p.metadata_path(self.path)
|
||||
+ def test_bad_admission_digest_never_reads_tls_or_starts_custody(self):
|
||||
+ admission=self.path.parent/'admission';admission.write_text(json.dumps(self.admission));admission.chmod(0o600)
|
||||
+ argv=['prepare','--transfer-receipt',str(self.path),'--admission',str(admission),'--admission-sha256','b'*64,
|
||||
+ '--tls-cert','/never/read-cert','--tls-key','/never/read-key','--trust','/never/read-trust']
|
||||
+ with patch('sys.argv',argv),patch('builtins.print') as output:
|
||||
+ self.assertEqual(p.main(),1)
|
||||
+ self.assertEqual(output.call_args.args,('ERROR: reviewed metadata/source/dependency preflight refused; no key requested.',))
|
||||
+ def test_actual_launcher_bad_digest_stops_before_hidden_prompt(self):
|
||||
+ master,slave=pty.openpty()
|
||||
+ try:
|
||||
+ args=['bash',str(ROOT/'tools/bootstrap-recovery/run-isolated-keycape.sh'),str(self.path),str(self.path),'b'*64,str(self.path),str(self.path),str(self.path)]
|
||||
+ process=subprocess.Popen(args,stdin=slave,stdout=slave,stderr=slave)
|
||||
+ process.wait(timeout=5);os.close(slave);slave=None
|
||||
+ output=b''
|
||||
+ while True:
|
||||
+ try:
|
||||
+ block=os.read(master,4096)
|
||||
+ if not block:break
|
||||
+ output+=block
|
||||
+ except OSError:break
|
||||
+ self.assertEqual(process.returncode,1)
|
||||
+ self.assertIn(b'preflight refused; no key requested.',output)
|
||||
+ self.assertNotIn(b'Paste custody',output)
|
||||
+ self.assertNotIn(b'Enter only',output)
|
||||
+ finally:
|
||||
+ if slave is not None:os.close(slave)
|
||||
+ os.close(master)
|
||||
+ def test_launcher_prepares_before_sealed_descriptor_custody(self):
|
||||
+ script=(ROOT/'tools/bootstrap-recovery/run-isolated-keycape.sh').read_text()
|
||||
+ self.assertLess(script.index('python3 tools/bootstrap-recovery/prepare-isolated-keycape.py'),script.index('bash sso-mfa/bootstrap/sops-custody-unlock.sh'))
|
||||
+ self.assertIn('--sealed-fd --prompt -- python3',script)
|
||||
--- /dev/null
|
||||
+++ b/tests/test_sealed_custody.py
|
||||
@@ -0,0 +1,143 @@
|
||||
+import fcntl
|
||||
+import importlib.util
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import signal
|
||||
+import pty
|
||||
+import select
|
||||
+import subprocess
|
||||
+import sys
|
||||
+import tempfile
|
||||
+import time
|
||||
+import unittest
|
||||
+from unittest.mock import patch
|
||||
+
|
||||
+ROOT=Path(__file__).parents[1]
|
||||
+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
|
||||
+h=load('fdhelper',ROOT/'sso-mfa/bootstrap/sops-custody-fd.py')
|
||||
+c=load('custody',ROOT/'sso-mfa/bootstrap/sops-private.py')
|
||||
+
|
||||
+class SealedCustodyTests(unittest.TestCase):
|
||||
+ @classmethod
|
||||
+ def setUpClass(cls):
|
||||
+ # Capture generated test key in memory only, never into test output/path.
|
||||
+ r=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True)
|
||||
+ cls.material=next(x for x in r.stdout.splitlines() if x.startswith(b'AGE-SECRET-KEY-'))+b'\n'
|
||||
+ fd=h.sealed_key(cls.material)
|
||||
+ try:cls.recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||
+ finally:os.close(fd)
|
||||
+ def test_sealed_descriptor_private_duplicate_is_cloexec_and_decrypts(self):
|
||||
+ encrypted=subprocess.run(['age','-r',self.recipient],input=b'synthetic recovery material',stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
+ original=h.sealed_key(self.material)
|
||||
+ try:
|
||||
+ with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':str(original)},clear=True):fd=c.private_key_fd()
|
||||
+ try:
|
||||
+ self.assertFalse(os.get_inheritable(fd));self.assertEqual(os.fstat(fd).st_nlink,0)
|
||||
+ r=subprocess.run(['age','-d','-i',f'/proc/self/fd/{fd}'],pass_fds=(fd,),input=encrypted,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL)
|
||||
+ self.assertEqual(r.returncode,0);self.assertEqual(r.stdout,b'synthetic recovery material')
|
||||
+ finally:os.close(fd)
|
||||
+ finally:os.close(original)
|
||||
+ def test_incomplete_seals_or_named_file_refused(self):
|
||||
+ for named in (False,True):
|
||||
+ if named:
|
||||
+ file=tempfile.TemporaryFile();fd=file.fileno();file.write(self.material);file.flush()
|
||||
+ else:fd=os.memfd_create('synthetic-unsealed',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING);os.write(fd,self.material)
|
||||
+ try:
|
||||
+ with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':str(fd)},clear=True),self.assertRaises((c.Refused,OSError)):
|
||||
+ c.private_key_fd()
|
||||
+ finally:
|
||||
+ if named:file.close()
|
||||
+ else:os.close(fd)
|
||||
+ def test_ambiguous_sources_refused(self):
|
||||
+ with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':'3','NETKINGDOM_CUSTODY_AGE_KEY_FILE':'/not/opened'},clear=True),self.assertRaises(c.Refused):c.private_key_fd()
|
||||
+ def test_malformed_descriptor_text_refused_before_dup(self):
|
||||
+ for value in ('-1','2','03',' 3','3 ','1000000000','3/4'):
|
||||
+ with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FD':value},clear=True),patch.object(c.os,'dup') as duplicate:
|
||||
+ with self.subTest(value=value),self.assertRaises(c.Refused):c.private_key_fd()
|
||||
+ duplicate.assert_not_called()
|
||||
+ def test_bad_expected_recipient_refused_before_prompt(self):
|
||||
+ with patch('sys.argv',['sealed','--expected-recipient','age1bad','--','true']),patch.object(h.os,'isatty',return_value=True),patch.object(h.getpass,'getpass') as prompt,patch('builtins.print'):
|
||||
+ self.assertEqual(h.main(),1);prompt.assert_not_called()
|
||||
+ def test_existing_file_mode_still_accepts_private_owner_file(self):
|
||||
+ with tempfile.TemporaryDirectory() as temp:
|
||||
+ path=Path(temp)/'synthetic-key';path.write_bytes(self.material);path.chmod(0o600)
|
||||
+ with patch.dict(os.environ,{'NETKINGDOM_CUSTODY_AGE_KEY_FILE':str(path)},clear=True):fd=c.private_key_fd()
|
||||
+ try:self.assertEqual(os.read(fd,16384),self.material)
|
||||
+ finally:os.close(fd)
|
||||
+ def test_helper_passes_exact_fd_and_clears_ambient_sources(self):
|
||||
+ code="""import os,sys
|
||||
+fd=int(os.environ['NETKINGDOM_CUSTODY_AGE_KEY_FD'])
|
||||
+assert os.fstat(fd).st_nlink==0
|
||||
+assert all(k not in os.environ for k in ('NETKINGDOM_CUSTODY_AGE_KEY_FILE','SOPS_AGE_KEY_FILE','SOPS_AGE_KEY','SOPS_AGE_KEY_CMD'))
|
||||
+"""
|
||||
+ with patch.dict(os.environ,{'SOPS_AGE_KEY':'synthetic-competing','NETKINGDOM_CUSTODY_AGE_KEY_FILE':'/not/read'}):
|
||||
+ self.assertEqual(h.run(self.material,self.recipient,[sys.executable,'-c',code]),0)
|
||||
+ def test_normal_and_error_return_close_parent_fd(self):
|
||||
+ actual=h.sealed_key
|
||||
+ for status in (0,7):
|
||||
+ fds=[]
|
||||
+ def capture(material):
|
||||
+ fd=actual(material);fds.append(fd);return fd
|
||||
+ with patch.object(h,'sealed_key',side_effect=capture):self.assertEqual(h.run(self.material,self.recipient,[sys.executable,'-c',f'raise SystemExit({status})']),status)
|
||||
+ with self.assertRaises(OSError):os.fstat(fds[0])
|
||||
+ def test_wrong_recipient_does_not_start_child_and_closes_fd(self):
|
||||
+ other=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
+ other=next(x for x in other.splitlines() if x.startswith(b'AGE-SECRET-KEY-'))+b'\n';fd=h.sealed_key(other)
|
||||
+ try:recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||
+ finally:os.close(fd)
|
||||
+ with tempfile.TemporaryDirectory() as temp:
|
||||
+ marker=Path(temp)/'child'
|
||||
+ with self.assertRaises(h.Refused):h.run(self.material,recipient,[sys.executable,'-c','import pathlib,sys;pathlib.Path(sys.argv[1]).touch()',str(marker)])
|
||||
+ self.assertFalse(marker.exists())
|
||||
+ def test_actual_attended_shell_sealed_mode_hides_key_and_runs_original_fd_consumer(self):
|
||||
+ code="import importlib.util,os,sys;spec=importlib.util.spec_from_file_location('c',sys.argv[1]);c=importlib.util.module_from_spec(spec);spec.loader.exec_module(c);fd=c.private_key_fd();assert os.fstat(fd).st_nlink==0;os.close(fd)"
|
||||
+ master,slave=pty.openpty()
|
||||
+ child=subprocess.Popen(['bash','-x',str(ROOT/'sso-mfa/bootstrap/sops-custody-unlock.sh'),'--expected-recipient',self.recipient,'--sealed-fd','--prompt','--',sys.executable,'-c',code,str(ROOT/'sso-mfa/bootstrap/sops-private.py')],stdin=slave,stdout=slave,stderr=slave)
|
||||
+ os.close(slave);captured=b'';sent=False;end=time.monotonic()+5
|
||||
+ try:
|
||||
+ while time.monotonic()<end:
|
||||
+ if select.select([master],[],[],.05)[0]:
|
||||
+ try:chunk=os.read(master,65536)
|
||||
+ except OSError:break
|
||||
+ if not chunk:break
|
||||
+ captured+=chunk
|
||||
+ if b'input hidden): ' in captured and not sent:os.write(master,self.material);sent=True
|
||||
+ self.assertEqual(child.wait(timeout=2),0)
|
||||
+ self.assertTrue(sent);self.assertNotIn(self.material.strip(),captured)
|
||||
+ finally:
|
||||
+ if child.poll() is None:child.kill();child.wait(timeout=2)
|
||||
+ os.close(master)
|
||||
+ def test_actual_hard_parent_loss_leaves_no_named_key_and_closes_on_child_exit(self):
|
||||
+ # Public child metadata only crosses the test pipe. The generated key is
|
||||
+ # supplied over stdin and never becomes an argv/environment/path value.
|
||||
+ child_code="import json,os,time;fd=int(os.environ['NETKINGDOM_CUSTODY_AGE_KEY_FD']);print(json.dumps({'pid':os.getpid(),'fd':fd,'nlink':os.fstat(fd).st_nlink}),flush=True);time.sleep(30)"
|
||||
+ parent_code="""import importlib.util,sys
|
||||
+spec=importlib.util.spec_from_file_location('h',sys.argv[1]);h=importlib.util.module_from_spec(spec);spec.loader.exec_module(h)
|
||||
+h.run(sys.stdin.buffer.read(),sys.argv[2],[sys.executable,'-c',sys.argv[3]])
|
||||
+"""
|
||||
+ parent=subprocess.Popen([sys.executable,'-c',parent_code,str(ROOT/'sso-mfa/bootstrap/sops-custody-fd.py'),self.recipient,child_code],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True)
|
||||
+ info=None;child_pidfd=None
|
||||
+ try:
|
||||
+ parent.stdin.write(self.material);parent.stdin.close()
|
||||
+ info=json.loads(parent.stdout.readline());self.assertEqual(info['nlink'],0)
|
||||
+ fdpath=Path('/proc')/str(info['pid'])/'fd'/str(info['fd']);self.assertTrue(fdpath.exists())
|
||||
+ child_pidfd=os.pidfd_open(info['pid'])
|
||||
+ parent.kill();parent.wait(timeout=5)
|
||||
+ # Admitted child retains its inherited FD until it exits; do not
|
||||
+ # confuse parent loss with revocation of that active child.
|
||||
+ self.assertTrue(fdpath.exists())
|
||||
+ signal.pidfd_send_signal(child_pidfd,signal.SIGKILL)
|
||||
+ # pidfd exit readiness proves the exact child's descriptor table was
|
||||
+ # released. /proc may become inaccessible during process teardown;
|
||||
+ # do not interpret permission errors or a reused numeric PID as proof.
|
||||
+ poller=select.poll();poller.register(child_pidfd,select.POLLIN)
|
||||
+ self.assertTrue(poller.poll(5000))
|
||||
+ finally:
|
||||
+ if parent.poll() is None:parent.kill();parent.wait(timeout=5)
|
||||
+ if child_pidfd is not None:
|
||||
+ try:signal.pidfd_send_signal(child_pidfd,signal.SIGKILL)
|
||||
+ except ProcessLookupError:pass
|
||||
+ finally:os.close(child_pidfd)
|
||||
+ parent.stdout.close()
|
||||
--- /dev/null
|
||||
+++ b/tests/test_private_manifest_binding.py
|
||||
@@ -0,0 +1,98 @@
|
||||
+"""Generated archive/key only; actual age authentication + archive restore.
|
||||
+
|
||||
+Docker and issuer behavior are intentionally mocked here, not native evidence.
|
||||
+"""
|
||||
+import base64
|
||||
+from datetime import datetime,timedelta,timezone
|
||||
+import hashlib
|
||||
+import importlib.util
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import subprocess
|
||||
+import time
|
||||
+from unittest.mock import MagicMock,patch
|
||||
+import pytest
|
||||
+from cryptography import x509
|
||||
+from cryptography.hazmat.primitives import hashes,serialization
|
||||
+from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
+from cryptography.x509.oid import NameOID
|
||||
+from test_identity_bundle import stage,b
|
||||
+
|
||||
+ROOT=Path(__file__).parents[1]
|
||||
+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
|
||||
+s=load('session_manifest',ROOT/'tools/bootstrap-recovery/isolated-keycape-session.py')
|
||||
+h=load('helper_manifest',ROOT/'sso-mfa/bootstrap/sops-custody-fd.py')
|
||||
+
|
||||
+@pytest.fixture
|
||||
+def envelope(stage,tmp_path,monkeypatch):
|
||||
+ raw=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
+ key=h.sealed_key(next(line for line in raw.splitlines() if line.startswith(b'AGE-SECRET-KEY-'))+b'\n')
|
||||
+ recipient=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{key}'],pass_fds=(key,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout.decode().strip()
|
||||
+ profile=b.profile();profile['public_recipient']=recipient;monkeypatch.setattr(b,'profile',lambda:profile);monkeypatch.setattr(s.bundle,'profile',lambda:profile)
|
||||
+ artifact=tmp_path/'identity.tar.age';sealed=b.seal(stage,artifact,{'test_only':True})
|
||||
+ signer=rsa.generate_private_key(public_exponent=65537,key_size=2048);name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic.invalid')]);now=datetime.now(timezone.utc)
|
||||
+ cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(signer.public_key()).serial_number(1).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(hours=1)).sign(signer,hashes.SHA256())).public_bytes(serialization.Encoding.PEM)
|
||||
+ tls={'/recovery-tls/server.pem':cert,'/recovery-tls/trust.pem':cert,'/recovery-tls/server-key.pem':signer.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())}
|
||||
+ transfer=dict(schema='netkingdom.identity-offsite.v1',status='readback_verified',provider='Storage Share',folder='Backup/railiance-backups/net-kingdom-identity',artifact='net-kingdom-identity-20261016T063159Z-5882771aa2ce.tar.age',etag='"synthetic"',ciphertext_sha256=sealed['ciphertext_sha256'],ciphertext_bytes=artifact.stat().st_size,fetched_path=str(artifact),independent_owner_retrieval_verified=True)
|
||||
+ admission=dict(ciphertext_sha256=sealed['ciphertext_sha256'],manifest_binding_mode='ciphertext-derived-v1',issuer='https://synthetic.invalid',tls_cert_sha256=hashlib.sha256(cert).hexdigest(),trust_sha256=hashlib.sha256(cert).hexdigest(),verifier_sha256=hashlib.sha256(b'synthetic executable').hexdigest())
|
||||
+ try:yield artifact,key,transfer,admission,tls
|
||||
+ finally:os.close(key)
|
||||
+
|
||||
+def execute(envelope,*,wrong_key=False):
|
||||
+ artifact,key,transfer,admission,tls=envelope;receipt={};seen={};checkpoints=[]
|
||||
+ if wrong_key:
|
||||
+ raw=subprocess.run(['age-keygen'],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,check=True).stdout
|
||||
+ key=h.sealed_key(next(line for line in raw.splitlines() if line.startswith(b'AGE-SECRET-KEY-'))+b'\n')
|
||||
+ actualrun=s.run
|
||||
+ def run(argv,**kwargs):return actualrun(argv,**kwargs) if argv[0]=='age-keygen' else b''
|
||||
+ def native(root,manifest,checked,receipt,**kwargs):seen.update(checked);receipt['status']='synthetic_flow_checked'
|
||||
+ execution={'pid':os.getpid(),'sid':os.getsid(0),'pgid':os.getpgrp(),'uid':os.getuid(),'deadline':time.monotonic()+60}
|
||||
+ try:
|
||||
+ with patch.object(s,'verify_execution'),patch.object(s.local,'preflight',return_value={}),patch.object(s.local.verify.custody,'private_key_fd',side_effect=lambda:os.dup(key)),patch.object(s,'start_reaper',return_value=MagicMock()),patch.object(s,'run',side_effect=run),patch.object(s.adapter,'container_file',return_value=b'synthetic executable'),patch.object(s.adapter,'private_payload',return_value={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'issuer: https://synthetic.invalid\nport: 8080').decode()},'env':{}}),patch.object(s,'provision'),patch.object(s.adapter,'check_jwks'),patch.object(s.adapter,'native_checks',side_effect=native),patch.object(s,'cleanup_all'):
|
||||
+ # Key file must be valid at the later public signer construction.
|
||||
+ payload_key=rsa.generate_private_key(public_exponent=65537,key_size=2048).private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.TraditionalOpenSSL,serialization.NoEncryption())
|
||||
+ with patch.object(s.adapter,'private_payload',return_value={'files':{'/etc/keycape/config.yaml':base64.b64encode(b'issuer: https://synthetic.invalid\nport: 8080').decode(),'/etc/keycape/key.pem':base64.b64encode(payload_key).decode()},'env':{}}):
|
||||
+ s.execute(transfer,admission,tls,receipt,execution,checkpoint=lambda r:checkpoints.append(json.loads(json.dumps(r))))
|
||||
+ return receipt,seen,checkpoints
|
||||
+ finally:
|
||||
+ if wrong_key:os.close(key)
|
||||
+
|
||||
+def test_exact_ciphertext_authenticated_restore_derives_private_manifest_without_safe_leak(envelope):
|
||||
+ receipt,seen,checkpoints=execute(envelope)
|
||||
+ derived=seen['manifest_sha256'];assert len(derived)==64
|
||||
+ safe=json.dumps([receipt,checkpoints]);assert derived not in safe;assert 'manifest_sha256' not in safe
|
||||
+ assert receipt['all_manifest_hashes_verified'] is True
|
||||
+ assert receipt['temporary_plaintext_cleanup_verified'] is True
|
||||
+
|
||||
+def test_changed_ciphertext_refuses_before_archive_decryption(envelope):
|
||||
+ artifact,*_=envelope;artifact.write_bytes(artifact.read_bytes()[:-1]+b'!')
|
||||
+ with patch.object(s.adapter,'process') as process,pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
+ assert not process.called
|
||||
+
|
||||
+def test_modified_ciphertext_rebound_metadata_still_fails_authenticated_age(envelope):
|
||||
+ artifact,key,transfer,admission,tls=envelope
|
||||
+ raw=artifact.read_bytes();artifact.write_bytes(raw[:-32]+b'!'*32)
|
||||
+ digest=hashlib.sha256(artifact.read_bytes()).hexdigest()
|
||||
+ transfer['ciphertext_sha256']=digest;admission['ciphertext_sha256']=digest
|
||||
+ with pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
+
|
||||
+def test_wrong_key_refuses_before_decryption_and_never_exposes_manifest(envelope):
|
||||
+ with pytest.raises(s.adapter.Refused):execute(envelope,wrong_key=True)
|
||||
+
|
||||
+def test_wrong_explicit_manifest_refuses_after_full_restore(envelope):
|
||||
+ admission=envelope[3];admission['manifest_binding_mode']='explicit-digest-v1';admission['manifest_sha256']='0'*64
|
||||
+ with pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
+
|
||||
+@pytest.mark.parametrize('mode',['derived','ciphertext-derived-v2',None,True,{}])
|
||||
+def test_unknown_binding_mode_refuses_before_effects(envelope,mode):
|
||||
+ envelope[3]['manifest_binding_mode']=mode
|
||||
+ with patch.object(s.local,'preflight') as preflight,pytest.raises(s.adapter.Refused):execute(envelope)
|
||||
+ assert not preflight.called
|
||||
+
|
||||
+def test_conflicting_binding_and_unverified_transfer_refuse(envelope):
|
||||
+ admission=envelope[3];transfer=envelope[2];admission['manifest_sha256']='0'*64
|
||||
+ with pytest.raises(s.adapter.Refused):s.adapter.manifest_binding_mode(admission,transfer)
|
||||
+ admission.pop('manifest_sha256');transfer['independent_owner_retrieval_verified']=False
|
||||
+ with pytest.raises(s.adapter.Refused):s.adapter.manifest_binding_mode(admission,transfer)
|
||||
--- a/tests/isolated-keycape-native-fixture-manifest.json
|
||||
+++ b/tests/isolated-keycape-native-fixture-manifest.json
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema": "nk49.keycape-native-fixture-proposal.v1",
|
||||
"status": "prepared_not_executed_not_approved",
|
||||
"source_sha256": {
|
||||
- "sso-mfa/bootstrap/sops-private.py": "sha256:113d17ee4cd7ad405e73cc51e1823dc02bd2e618e7fa67c1b624f0226e334ec6",
|
||||
+ "sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||
"tests/run_isolated_keycape_native_fixture.py": "sha256:ecbadecc475622eaaf20a726110aebef5f4fe7af141110a98aca1640179b4a03",
|
||||
"tests/test_identity_bundle.py": "sha256:be5ce74097594cc7f7e77dbccd6cb53aa72f263cd1bb442ce5464038aed1116c",
|
||||
@@ -24,18 +24,24 @@
|
||||
"tools/bootstrap-recovery/install-identity-backup.py": "sha256:5c612a2a51c9b59aad8cc01d418c2f563cbd92c62dc1fa8be994e5fb59df90f1",
|
||||
"tools/bootstrap-recovery/inventory-scaleway.py": "sha256:a219df205708fa4ed44b763a418585d63c820c25434bc0fd14e805eb5c2f79d3",
|
||||
"tools/bootstrap-recovery/inventory-storage-share.py": "sha256:673f9b17fb2f3fef6fcdb5c50a2bcca69e3df3157a9e5582a2d995c6b87640b5",
|
||||
- "tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:ba274034cd8491089048ad54324be735b7869d54b9d916330e987d7c288531f7",
|
||||
- "tools/bootstrap-recovery/isolated-keycape.py": "sha256:1c018fd7fbfd3afcc96ae61e7eafc283ac1dde3916c3bf7eac9eb50b934b57a5",
|
||||
+ "tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||
+ "tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||
"tools/bootstrap-recovery/isolated-privacyidea.py": "sha256:4df7196678aac1ad594f32abf45c68dc21a9d2760d9e8a536a215a4a3c5d277f",
|
||||
"tools/bootstrap-recovery/prepare-identity-coverage.py": "sha256:cd5f3067d28b4c7cc93ed08b8cf3869218bdecf7c54b5dffed22d697de71dbb1",
|
||||
"tools/bootstrap-recovery/recovery-kit.py": "sha256:6fa0f1494dccaf003b6c6396c5de8b36faa2b276eeb16badd04383f5efb71e66",
|
||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||
"tools/bootstrap-recovery/retrieve-identity.py": "sha256:ec123d47a07305253570dacab7e103f8c193a23f20c9fc87466ea4a95ae0ec13",
|
||||
- "tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:fba8f6c2247f0ff951663a8d6372aa2276ca21aaac6f77fbfdb7f730f525fefa",
|
||||
+ "tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||
"tools/bootstrap-recovery/verify-bundle.py": "sha256:885b05a2011e711228e42e316bda70bbe0d97d481383d61aa8469d0f10452370",
|
||||
"tools/bootstrap-recovery/verify-identity-delivery.py": "sha256:63658d1976806f8d0ce80ba8f750bbcfdca59da936b6eb6a78a250f0e53710f1",
|
||||
- "tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f"
|
||||
+ "tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f",
|
||||
+ "sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||
+ "sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||
+ "tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||
+ "tests/test_prepare_isolated_keycape.py": "sha256:4b0871f9a11a3940983464f1b40eca1613110b1cb3cb9694f4891d58e14c700a",
|
||||
+ "tests/test_sealed_custody.py": "sha256:f0fa07c89e2419464dbee0489462864a4533770417a554b1ab1d1ed5d7b6c49f",
|
||||
+ "tests/test_private_manifest_binding.py": "sha256:2e7ee16af65390343435b7210ef191aa2a10e9dcbb8759ed95fa8bf6fb81893b"
|
||||
},
|
||||
"fixed_endpoint": "unix:///var/run/docker.sock",
|
||||
"engine_id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
||||
@@ -75,7 +81,7 @@
|
||||
"synthetic_source_root": "private /dev/shm/nk49-keycape-fixture-<suffix>",
|
||||
"safe_result": "private /tmp/nk49-keycape-native-fixture-review-<suffix>/result.json"
|
||||
},
|
||||
- "scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential",
|
||||
+ "scope": "candidate itself on complete synthetic format/integrity archive fixture; no native PI/LLDAP/Authelia proof, real custody, real factor or real client credential; successor binds sealed-descriptor custody preparation sources; synthetic fixture itself overrides custody with generated fixture key as before",
|
||||
"mutations": [
|
||||
"conditional creation of exact fresh local containers and local-driver tmpfs volumes",
|
||||
"private synthetic file import to those volumes",
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"scope": "public cached immutable image binary only; no runtime secrets or owner-held archive",
|
||||
"engine": {
|
||||
"id": "74f93783-6cef-43a8-a3c4-f62586cd3bc1",
|
||||
"name": "bnt-lap001"
|
||||
},
|
||||
"image": "forgejo.coulomb.social/coulomb/key-cape@sha256:cdec9dd7c515f7e0a6169fc3fa560ee2cc47bf40d899755dec808c362f5ce010",
|
||||
"container": "nk49-kc-958d409dafc34386a7ae62af878106df-image-probe",
|
||||
"started": false,
|
||||
"cleanup_verified": true,
|
||||
"verifier_sha256": "cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4",
|
||||
"binary_bytes": 13670142,
|
||||
"container_cleanup_obligations": [
|
||||
{
|
||||
"container": "nk49-kc-958d409dafc34386a7ae62af878106df-image-probe",
|
||||
"status": "absence_verified"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"status": "candidate_source_ready_for_independent_review_attempt_metadata_incomplete",
|
||||
"owner_head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"canonical_files_changed": false,
|
||||
"patch_sha256": "sha256:99cc938d80d64049f927049a5e09a698641e121cf0af2bfa1cbb54ebe7528266",
|
||||
"tests": {
|
||||
"combined_source_crypto_archive_adapter_passed": 111,
|
||||
"new_preparation_tests": 16,
|
||||
"new_sealed_descriptor_tests": 11,
|
||||
"new_private_manifest_tests": 11,
|
||||
"legacy_custody_regressions_passed": 94,
|
||||
"legacy_real_sops_skipped": 61,
|
||||
"synthetic_material_only": true,
|
||||
"actual_age_crypto_and_parent_loss": true,
|
||||
"bash_n": "passed",
|
||||
"docker_trial_performed": false
|
||||
},
|
||||
"knowledge": {
|
||||
"version": "0.2.6",
|
||||
"digest": "8d1d6a9d4d07fec7b619618de7dfe2be2254c1790f85f4198c7fe19db9751c02",
|
||||
"cards": [
|
||||
"start",
|
||||
"identity"
|
||||
],
|
||||
"status": "withheld_changed_sources",
|
||||
"source_review": [
|
||||
"AGENTS.md",
|
||||
"INTENT.md",
|
||||
"SECURITY-COMPANION.md",
|
||||
"owner custody launcher/private-key fd",
|
||||
"archive format/profile",
|
||||
"adapter/session/native verifier",
|
||||
"T33"
|
||||
]
|
||||
},
|
||||
"findings": [
|
||||
"Admission digest previously checked only after custody prompt",
|
||||
"No complete source closure bound before key request",
|
||||
"Missing yaml/cryptography only discovered after key request",
|
||||
"Custody shell original named-file path leaves key on hard parent loss; candidate reuses entry point with reviewed sealed anonymous FD extension",
|
||||
"Backup profile excludes ingress TLS; original TLS/trust recovery source unresolved",
|
||||
"Versioned ciphertext-derived manifest mode computes digest privately after authenticated age+full archive validation; no private digest is disclosed",
|
||||
"Bound both metadata sizes before reading, strict duplicate keys, typed source pins before prompting"
|
||||
],
|
||||
"private_reads_or_live_mutations": false,
|
||||
"candidate_fixture_manifest_sha256": "sha256:1dcefbc7ca7b0c2be33135b1b7977b89ba657223ecbb1310cc691d151b3d916e",
|
||||
"failed_intermediate_checks": [
|
||||
"First legacy fixture run missing copied code-only dependencies:37failed57passed61skipped; after public code closure copied94passed61skipped",
|
||||
"First successor manifest update used bare hashes although published R7 requires sha256: qualifiers:1error of86; corrected all86pass",
|
||||
"Final combined run had110passed1failed in test-only /proc teardown permission race; replaced numeric PID reacquisition with retained exact child pidfd+exit readiness proof; focused11 and final111checks pass."
|
||||
],
|
||||
"changed_paths": [
|
||||
"sso-mfa/bootstrap/sops-private.py",
|
||||
"sso-mfa/bootstrap/sops-custody-unlock.sh",
|
||||
"sso-mfa/bootstrap/sops-custody-fd.py",
|
||||
"tools/bootstrap-recovery/isolated-keycape.py",
|
||||
"tools/bootstrap-recovery/isolated-keycape-session.py",
|
||||
"tools/bootstrap-recovery/run-isolated-keycape.sh",
|
||||
"tools/bootstrap-recovery/prepare-isolated-keycape.py",
|
||||
"tests/test_prepare_isolated_keycape.py",
|
||||
"tests/test_sealed_custody.py",
|
||||
"tests/test_private_manifest_binding.py",
|
||||
"tests/isolated-keycape-native-fixture-manifest.json"
|
||||
],
|
||||
"source_sha256": {
|
||||
"sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||
"sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||
"sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||
"tools/bootstrap-recovery/check-privacyidea-database.py": "sha256:90f454c87998d7f5d625e3c6656c3418f8f56275dc1879a9d19ed605431b94a4",
|
||||
"tools/bootstrap-recovery/identity-backup-profile.json": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"tools/bootstrap-recovery/identity-bundle.py": "sha256:524a03c065db4307fcde83d33af39503f3de19cb6ef3a8ca72c991d4a8c08ee6",
|
||||
"tools/bootstrap-recovery/identity-custody.json": "sha256:f8aee8487ed01d7940b82676bb4a5c8d61f51b6c598521b8b8a73588aaea4d4f",
|
||||
"tools/bootstrap-recovery/identity-files.py": "sha256:d0063a9eedd3610e3d45bd2d834aae83cff74e4b3504d138b3548f427e89331d",
|
||||
"tools/bootstrap-recovery/identity-offsite.py": "sha256:e7c31fa9ee081961af873348ee3b4829a645d40c7dc7f1e22c564aa9ed01bc49",
|
||||
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||
"tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f"
|
||||
},
|
||||
"frozen_sources_for_independent_review": true
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"sso-mfa/bootstrap/sops-custody-fd.py": "sha256:df29eaba83ec6b5fc311664dcf4b22d8f3c8ed7274ed8e6c419ada0dcd169c0f",
|
||||
"sso-mfa/bootstrap/sops-custody-unlock.sh": "sha256:86c527a8ae0d0cca66a2345c566b25fbff5bc52c1e22c725548c0fbb2f86a58e",
|
||||
"sso-mfa/bootstrap/sops-private.py": "sha256:acac68e2936ed38b7d7c9e35eb4619647c99c117a3928a1c5ce704b570906780",
|
||||
"sso-mfa/k8s/privacyidea/key-custody-remote.py": "sha256:27b2b885c81923d85054e3999c5613e104fe627779b2b50398ed9bed5088f47e",
|
||||
"tools/bootstrap-recovery/check-privacyidea-database.py": "sha256:90f454c87998d7f5d625e3c6656c3418f8f56275dc1879a9d19ed605431b94a4",
|
||||
"tools/bootstrap-recovery/identity-backup-profile.json": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"tools/bootstrap-recovery/identity-bundle.py": "sha256:524a03c065db4307fcde83d33af39503f3de19cb6ef3a8ca72c991d4a8c08ee6",
|
||||
"tools/bootstrap-recovery/identity-custody.json": "sha256:f8aee8487ed01d7940b82676bb4a5c8d61f51b6c598521b8b8a73588aaea4d4f",
|
||||
"tools/bootstrap-recovery/identity-files.py": "sha256:d0063a9eedd3610e3d45bd2d834aae83cff74e4b3504d138b3548f427e89331d",
|
||||
"tools/bootstrap-recovery/identity-offsite.py": "sha256:e7c31fa9ee081961af873348ee3b4829a645d40c7dc7f1e22c564aa9ed01bc49",
|
||||
"tools/bootstrap-recovery/isolated-keycape-session.py": "sha256:8fa38efe28167b59a6307f342b2ac8fcda78ecda60e6228f22017424d2343c22",
|
||||
"tools/bootstrap-recovery/isolated-keycape.py": "sha256:f67627b7d1647d75187dd899874efe22383bc91de5d97dabb9801076668ab3c2",
|
||||
"tools/bootstrap-recovery/isolated-lldap.py": "sha256:35bfa8411186ad936a199bd450c7e18c2ad51059301a6db9650e6bd055ab1b81",
|
||||
"tools/bootstrap-recovery/prepare-isolated-keycape.py": "sha256:5098bdfa4e0ad30627c1716e5e355a2c78dca6ccc607bd2148a2e06126632288",
|
||||
"tools/bootstrap-recovery/retrieve-identity-offline.py": "sha256:3421dbb3f3280a001d33ea2deb12ab609404a0b4254cd43c21518d77d166f972",
|
||||
"tools/bootstrap-recovery/run-isolated-keycape.sh": "sha256:0d994c9c14ebdffdbf7879bd7562042531083d0e8955424d0ae099898a141668",
|
||||
"tools/bootstrap-recovery/verify-identity.py": "sha256:039864d0141b552887752bf17cfdcf1bef4bdc9126f946a9c0533e4fd54ebf4f"
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# T35/T36 public source preparation
|
||||
|
||||
`source-review.json` binds 17 checked public source files to exact owner commits and typed SHA-256 hashes. All matched their recorded HEAD bytes. No live source, Secret, certificate, key, provider account or client secret was read. The draft TLS owner questions were not sent. Canonical files remain unchanged.
|
||||
|
||||
The source boundary points to **railiance-cluster** for cert-manager, ingress controllers and cluster backup posture; NetKingdom owns its namespace ingress intent. This identifies the appropriate source-owner boundary, not independent TLS key custody or new owner assent.
|
||||
|
||||
Its current backup source can encrypt k3s SQLite state, Helm values and kubeconfig to `/opt/backup/railiance/cluster` with seven local versions. That source does not establish an actually present/fresh independent copy, original `kc-tls` coverage, separately recoverable decryption custody, isolated extraction or restore proof. Its named plaintext `/tmp` staging and whole-cluster restore instructions also need owner review before reuse. Do not run those scripts for this isolated attempt.
|
||||
|
||||
The exact historically deployed **secrets-engine-openbao** registration is a defensible candidate. Its owner rollout constants, NetKingdom completion record, KeyCape native issuance receipt dated 2026-10-07 and secrets-engine's actual consumer probe dated 2026-10-10 agree. It predates the reviewed 2026-10-16 archive. `registration-selector-proposal.json` carries the complete public selector, including denied scope `openbao:admin`. This is stronger than the stale README's “prepared, not live” statement, but does not establish current owner assent or replace private validation of the archived registration.
|
||||
|
||||
Next actions, in order:
|
||||
|
||||
1. Review the unsubmitted `tls-owner-review-request.json` with the identified substrate/source owner through the existing project process. Obtain only provenance/public metadata and the existing independent private custody path. Resolve whether exact original TLS/trust recovery is possible during the ordinary issuer/cluster outage, or obtain an explicit accepted reissue disposition. No public source inspected here establishes the latter.
|
||||
2. Once that owner has identified **public-only original certificate/chain and trust files**, the owner can run this executable metadata step in their own terminal. It has no private-key argument, no network call and refuses non-certificate PEM material. It emits only hashes, SANs, validity dates and counts. The tool does not prove originality or accept reissue:
|
||||
|
||||
```sh
|
||||
python3 public-tls-metadata.py --public-cert ORIGINAL_PUBLIC_CERT_CHAIN.pem --public-trust ORIGINAL_PUBLIC_TRUST.pem
|
||||
```
|
||||
|
||||
The filenames are local placeholders for the owner's independently established public artifacts, not a request for any key or a substitute trust anchor. Keep the returned metadata tied to its actual source provenance. Do not feed a Kubernetes Secret object or certificate/key combined file to this public-only step.
|
||||
3. Have the selected service owner review the public selector proposal against its current intended registration. No client secret retrieval is needed. Bind the original archive's existing registration/env reference and selector through the reviewed private archive/native checks later. No new grant, rotation, production redirect or OpenBao/provider login is implied.
|
||||
4. Combine those verified TLS/trust bindings with the known archive, the already observed original executable hash and the reviewed selector in the private admission. Refresh exact source hashes if publication changed bytes. `ciphertext-derived-v1` keeps the manifest digest private after authenticated decrypt and complete archive checks. Unresolved fields must remain refused, not filled with a fresh self-signed certificate or guessed metadata.
|
||||
5. Run canonical `prepare-isolated-keycape.py` against that **complete** owner-local admission/transfer and metadata-only TLS paths before any custody prompt. It must pass its bounded strict JSON, exact hash/source/selector/dependency and path checks. Then the existing owner process finalizes the exact command/admission digest, target, expected positive/negative results, independent verification and abort limits. Only T18 owns any ensuing real recovery execution; no such attempt is authorized or ready in this packet.
|
||||
|
||||
No secrets-engine `probe-openbao`, current KeyCape exchange, OIDC login, KV read, cluster backup/restore or certificate reissue is part of these steps. Historical consumer proof does not establish reconnect to the restored issuer; that remains separate native acceptance. Human/retained-factor MFA remains T19.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Owner-run public certificate/trust metadata; no private-key argument or network.
|
||||
|
||||
The owner supplies public-only PEM files from the independently established
|
||||
original recovery source. This does not identify that source or accept reissue.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
LIMIT=1024*1024
|
||||
CERT=re.compile(br'-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----')
|
||||
|
||||
def public_bundle(path):
|
||||
# Bound the read before allocation; refuse every non-certificate PEM block.
|
||||
path=Path(os.path.abspath(path))
|
||||
if any(p.is_symlink() for p in (path,*path.parents)) or not 0<path.stat().st_size<=LIMIT:raise ValueError()
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
||||
with os.fdopen(fd,'rb') as source:
|
||||
if not 0<os.fstat(source.fileno()).st_size<=LIMIT:raise ValueError()
|
||||
raw=source.read(LIMIT+1)
|
||||
if len(raw)>LIMIT:raise ValueError()
|
||||
blocks=CERT.findall(raw)
|
||||
if not blocks or len(blocks)>512 or CERT.sub(b'',raw).strip():raise ValueError()
|
||||
return raw,[x509.load_pem_x509_certificate(block) for block in blocks]
|
||||
|
||||
def describe(cert_path,trust_path):
|
||||
certificate,chain=public_bundle(cert_path);trust,roots=public_bundle(trust_path);leaf=chain[0]
|
||||
try:names=leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value.get_values_for_type(x509.DNSName)
|
||||
except x509.ExtensionNotFound:names=[]
|
||||
spki=leaf.public_key().public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
def utc_date(name):
|
||||
value=getattr(leaf,name+'_utc',None)
|
||||
if value is None:value=getattr(leaf,name).replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
return {'schema':'nk49.public-tls-metadata.v1','status':'public_metadata_only_not_originality_or_owner_assent',
|
||||
'tls_cert_sha256':hashlib.sha256(certificate).hexdigest(),'trust_sha256':hashlib.sha256(trust).hexdigest(),
|
||||
'leaf_public_spki_sha256':'sha256:'+hashlib.sha256(spki).hexdigest(),'leaf_dns_sans':names,
|
||||
'leaf_not_before':utc_date('not_valid_before'),'leaf_not_after':utc_date('not_valid_after'),
|
||||
'server_chain_certificates':len(chain),'trust_certificates':len(roots),'network_contacted':False,'private_key_argument_supported':False}
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--public-cert',required=True);p.add_argument('--public-trust',required=True);a=p.parse_args()
|
||||
try:result=describe(a.public_cert,a.public_trust)
|
||||
except Exception:print('{"status":"public_metadata_refused","private_diagnostics_suppressed":true}');return 1
|
||||
print(json.dumps(result,indent=2));return 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"schema": "nk49.original-registration-selector-proposal.v1",
|
||||
"status": "public_source_candidate_not_owner_assent_not_attempt_admission",
|
||||
"consumer_owner": "secrets-engine",
|
||||
"issuer_deployment_owner": "net-kingdom",
|
||||
"issuer_implementation_owner": "key-cape",
|
||||
"issuer_delivery_owner": "railiance-platform",
|
||||
"issuer": "https://kc.coulomb.social",
|
||||
"consumer": {
|
||||
"client_id": "secrets-engine-openbao",
|
||||
"secret_env": "KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET",
|
||||
"audience": "secrets-engine-openbao",
|
||||
"scope": "openbao:login",
|
||||
"subject": "service:secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"roles": "secrets-engine",
|
||||
"deny_scope": "openbao:admin"
|
||||
},
|
||||
"originality_evidence": [
|
||||
"NetKingdom exact-match registration applied2026-10-07 before reviewed archive2026-10-16",
|
||||
"KeyCape actual issuance receipt2026-10-07 with selected claims, unrelated-secret and openbao:admin denial",
|
||||
"secrets-engine actual attended probe2026-10-10 with bound issuer https://kc.coulomb.social"
|
||||
],
|
||||
"private_validation_still_required": [
|
||||
"Archived config must contain unique exact original registration and its archived environment binding; no archived bytes read by this review",
|
||||
"Restored native original-image verification must establish these exact claims and negative cases",
|
||||
"Selected service owner must establish current relevance and expected acceptance through its existing process; historical proof is not new assent",
|
||||
"Downstream secrets-engine reconnect remains separate from issuer-native verify-client"
|
||||
],
|
||||
"not_authorized": [
|
||||
"No production token exchange, OpenBao OIDC login/KV read or provider authentication",
|
||||
"No new client grant, rotation, registration write or consumer redirect",
|
||||
"No secret copied from workstation, live Secret or OpenBao"
|
||||
],
|
||||
"source_review": "source-review.json"
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
{
|
||||
"schema": "nk49.t35-t36-public-source-review.v1",
|
||||
"status": "read_only_source_preparation_complete_not_attempt_ready",
|
||||
"source_refs": [
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/ingress.yaml",
|
||||
"head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"sha256": "sha256:bc5c7524a82de5e8b9755e7d7d408b4a66e034424cb2663c1d21352d292d5a72",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/keycape/ingress.yaml",
|
||||
"supports": "namespace ingress intent names hostname, TLS Secret and ClusterIssuer"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "tools/bootstrap-recovery/identity-backup-profile.json",
|
||||
"head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"sha256": "sha256:40f9e5b5bb957e5b23e03b31701702b850502d16a92a0fa0ebf92618cc3f18a1",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/tools/bootstrap-recovery/identity-backup-profile.json",
|
||||
"supports": "actual identity backup component coverage excludes ingress TLS"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py",
|
||||
"head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"sha256": "sha256:8b834e5f0353a5e11797637420609e06e7bf7de47777c7e78b774f74d4a6afbd",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/keycape/secrets-engine-openbao-client-rollout.py",
|
||||
"supports": "exact public registration constants"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/deployment.yaml",
|
||||
"head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"sha256": "sha256:83777942bc922c7d1719b0ad0b09e873105c342dae3b013bb65eeccf6d905626",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/keycape/deployment.yaml",
|
||||
"supports": "issuer delivery env Secret reference, no secret value"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md",
|
||||
"head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"sha256": "sha256:ef38481d26e599802d94f8b06a4d89c7c99a83c81ca5be9bcf7422b885867479",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/workplans/NK-WP-0046-secrets-engine-openbao-client-registration.md",
|
||||
"supports": "2026-10-07 applied registration/env evidence"
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"path": "sso-mfa/k8s/keycape/README.md",
|
||||
"head": "878abc93588e12b6da1bac055cfbb3abb8d7adf1",
|
||||
"sha256": "sha256:e7a002996fd57477f6bcd054264076fe04da17f8685095163c1c80dd57b753f2",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/net-kingdom/src/commit/878abc93588e12b6da1bac055cfbb3abb8d7adf1/sso-mfa/k8s/keycape/README.md",
|
||||
"supports": "stale prepared/not-live paragraph contradicted by completed workplan and later receipts"
|
||||
},
|
||||
{
|
||||
"repo": "key-cape",
|
||||
"path": "workplans/KEY-WP-0037-secrets-engine-openbao-registration-custody.md",
|
||||
"head": "834b0d06a13bb2ece9cc98392cb39ba0e2167ecb",
|
||||
"sha256": "sha256:3a90a0c460fc439570820b96d1ddc3b7f8646c557ca3a8aa27c6d546bb1a845a",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/key-cape/src/commit/834b0d06a13bb2ece9cc98392cb39ba0e2167ecb/workplans/KEY-WP-0037-secrets-engine-openbao-registration-custody.md",
|
||||
"supports": "accepted exact registration and service claims"
|
||||
},
|
||||
{
|
||||
"repo": "key-cape",
|
||||
"path": "docs/evidence/2026-10-07-secrets-engine-openbao-issuance.json",
|
||||
"head": "834b0d06a13bb2ece9cc98392cb39ba0e2167ecb",
|
||||
"sha256": "sha256:a405ef5289159c525695352ba71da7eca8e897b6f2dc7e5795f9b239361d8068",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/key-cape/src/commit/834b0d06a13bb2ece9cc98392cb39ba0e2167ecb/docs/evidence/2026-10-07-secrets-engine-openbao-issuance.json",
|
||||
"supports": "historical live native issuance, explicit wrong-secret/excess-scope denial"
|
||||
},
|
||||
{
|
||||
"repo": "secrets-engine",
|
||||
"path": "docs/evidence/2026-10-10-keycape-openbao-probe.json",
|
||||
"head": "535681052cd55ca613706ecc60c39d299929b3c5",
|
||||
"sha256": "sha256:bf0b5df9f030499d96770f1d9fb2091a8d37da71422a6d456be7200d3a1a1782",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/secrets-engine/src/commit/535681052cd55ca613706ecc60c39d299929b3c5/docs/evidence/2026-10-10-keycape-openbao-probe.json",
|
||||
"supports": "historical downstream consumer live service-jwt+OpenBao proof"
|
||||
},
|
||||
{
|
||||
"repo": "secrets-engine",
|
||||
"path": "docs/keycape-openbao-consumer-custody.md",
|
||||
"head": "535681052cd55ca613706ecc60c39d299929b3c5",
|
||||
"sha256": "sha256:73efdd549f3feaccc53eefd6f1b95c8b9eb7091648f94ad80978ba5381be9124",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/secrets-engine/src/commit/535681052cd55ca613706ecc60c39d299929b3c5/docs/keycape-openbao-consumer-custody.md",
|
||||
"supports": "consumer owns process-only memory custody, platform owns delivery; contradictory stale final section retained"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "SCOPE.md",
|
||||
"head": "cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0",
|
||||
"sha256": "sha256:e4f6a8dd3d72a1f04f0d94721d57ca2a4c7e98790a8373876a5005b87ec5d8a6",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-cluster/src/commit/cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0/SCOPE.md",
|
||||
"supports": "explicit cert-manager, ingress and cluster backup substrate ownership"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "docs/rail-kubernetes-substrate-profile.md",
|
||||
"head": "cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0",
|
||||
"sha256": "sha256:75beeca7ef9670efc0b92c1ef8a33e24b72acbcdee06ee8de3c6492cb69d4f6b",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-cluster/src/commit/cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0/docs/rail-kubernetes-substrate-profile.md",
|
||||
"supports": "SQLite backup posture and no generic storage contract"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "tools/cmd/railiance-backup-s2",
|
||||
"head": "cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0",
|
||||
"sha256": "sha256:35c60b7ccccd7b4d5a1cf2134c1f2ca78c45040a7ffcc55f2c32c385a0a682a3",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-cluster/src/commit/cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0/tools/cmd/railiance-backup-s2",
|
||||
"supports": "local age-encrypted SQLite/Helm/kubeconfig backup; no independent offsite recovery established here"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "tools/cmd/railiance-restore-s2",
|
||||
"head": "cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0",
|
||||
"sha256": "sha256:25be58968fee3eeb6069e5af08abe601e8f60f04241e8de4bb917a7c639882e5",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-cluster/src/commit/cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0/tools/cmd/railiance-restore-s2",
|
||||
"supports": "source-only destructive whole-state restore guide, not execution recommendation"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "docs/backup-restore.md",
|
||||
"head": "cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0",
|
||||
"sha256": "sha256:4d0bbaad3d69f10179ba9590d3fa12fba2967202c756ecf1952436d1e27de902",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-cluster/src/commit/cac8eabef5a0d0990a7b0de8f5e2c762666ac7f0/docs/backup-restore.md",
|
||||
"supports": "legacy workstation backup excludes cluster TLS-specific proof"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-master",
|
||||
"path": "docs/rail-kubernetes-boundary.md",
|
||||
"head": "a0be0d3142b5392f1fb6271cf91325ce91a0f9d4",
|
||||
"sha256": "sha256:b7a382622e241c174b11a8645aba1801693f22950bb132760b2170fcb828ced1",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-master/src/commit/a0be0d3142b5392f1fb6271cf91325ce91a0f9d4/docs/rail-kubernetes-boundary.md",
|
||||
"supports": "substrate/certificate plumbing and backup posture owner boundary"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-platform",
|
||||
"path": "docs/backup-provider-coverage.md",
|
||||
"head": "08c1f036f2750097750f435efd76e8f6ee6df393",
|
||||
"sha256": "sha256:f5983aac1e8cc1828e9f73340b1f66c0e67cdcddc1a1e49cbe6f5e39e90c6641",
|
||||
"worktree_matches_head": true,
|
||||
"public_source_ref": "https://forgejo.coulomb.social/coulomb/railiance-platform/src/commit/08c1f036f2750097750f435efd76e8f6ee6df393/docs/backup-provider-coverage.md",
|
||||
"supports": "provider asset coverage explicitly distinct, not TLS-specific assurance"
|
||||
}
|
||||
],
|
||||
"findings": [
|
||||
"railiance-cluster is explicit substrate certificate/ingress/backup owner; original independent TLS custody remains unknown",
|
||||
"Existing cluster backup source cannot establish actual independent original TLS recovery",
|
||||
"secrets-engine-openbao is a defensible historically deployed registration candidate that predates reviewed archive; exact archived presence/current owner acceptance still require private native validation",
|
||||
"NetKingdom README prepared/not-live and secrets-engine custody final not-established paragraph are stale against specific dated completed receipts; do not overwrite historical facts or treat them as current authority"
|
||||
],
|
||||
"private_reads": false,
|
||||
"live_reads_or_mutations": false,
|
||||
"messages_sent": false,
|
||||
"canonical_changes": false,
|
||||
"public_metadata_tool": {
|
||||
"status": "prepared_for_review_not_used_with_real_certificate",
|
||||
"source": "public-tls-metadata.py",
|
||||
"source_sha256": "sha256:da7911b1c8771114c1c5a055f73e963d9b342dbeac2a67f2a933534558d4f905",
|
||||
"tests": {
|
||||
"generated_certificate_checks_passed": 4,
|
||||
"real_certificate_or_key_read": false,
|
||||
"initial_failure": "cryptography runtime lacks not_valid_before_utc; corrected version-compatible UTC metadata projection, retained here",
|
||||
"negative_cases": [
|
||||
"oversize before read",
|
||||
"symlink input",
|
||||
"combined or non-certificate PEM rejection"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
from datetime import datetime,timedelta,timezone
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes,serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
spec=importlib.util.spec_from_file_location('metadata',Path(__file__).with_name('public-tls-metadata.py'));m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
@pytest.fixture
|
||||
def cert(tmp_path):
|
||||
key=rsa.generate_private_key(public_exponent=65537,key_size=2048);name=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'synthetic.invalid')]);now=datetime.now(timezone.utc)
|
||||
cert=(x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key()).serial_number(1).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(hours=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName('synthetic.invalid')]),critical=False).sign(key,hashes.SHA256()))
|
||||
p=tmp_path/'public.pem';p.write_bytes(cert.public_bytes(serialization.Encoding.PEM));return p
|
||||
|
||||
def test_public_only_metadata_and_hashes(cert):
|
||||
result=m.describe(cert,cert)
|
||||
assert result['leaf_dns_sans']==['synthetic.invalid'];assert result['network_contacted'] is False
|
||||
assert '-----BEGIN' not in str(result)
|
||||
|
||||
def test_mixed_or_private_pem_is_refused(cert):
|
||||
cert.write_bytes(cert.read_bytes()+b'\n-----BEGIN PRIVATE KEY-----\nsynthetic\n-----END PRIVATE KEY-----')
|
||||
with pytest.raises(ValueError):m.public_bundle(cert)
|
||||
|
||||
def test_oversize_is_refused_before_read(cert):
|
||||
with patch.object(m,'LIMIT',1),patch.object(m.os,'open') as opened:
|
||||
with pytest.raises(ValueError):m.public_bundle(cert)
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_symlink_refused(cert):
|
||||
link=cert.with_name('link.pem');link.symlink_to(cert)
|
||||
with pytest.raises(ValueError):m.public_bundle(link)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"schema": "nk49.original-tls-owner-review-preparation.v1",
|
||||
"status": "draft_not_sent_no_assent",
|
||||
"task": "NK-WP-0049-T35",
|
||||
"source_identified_boundaries": {
|
||||
"namespace_ingress_intent": "net-kingdom",
|
||||
"ingress_controller_cert_manager_and_cluster_backup": "railiance-cluster per explicit SCOPE and railiance-master boundary",
|
||||
"current_original_tls_private_custody": "Not established by checked sources; cert-manager manages Secret lifecycle but source does not prove independent custody",
|
||||
"certificate_provider": "ClusterIssuer name letsencrypt-prod; historical platform docs identify Lets Encrypt issuance. Exact current issuer endpoint/account and challenge configuration are not established from checked source."
|
||||
},
|
||||
"target": {
|
||||
"host": "kc.coulomb.social",
|
||||
"namespace": "sso",
|
||||
"tls_secret": "kc-tls",
|
||||
"issuer_kind": "ClusterIssuer",
|
||||
"issuer_name": "letsencrypt-prod",
|
||||
"termination": "Traefik before KeyCape8080"
|
||||
},
|
||||
"backup_findings": {
|
||||
"identity_archive": "Ingress TLS Secret excluded from reviewed identity backup profile",
|
||||
"cluster_backup_source": "SQLite hot copy plus Helm values/kubeconfig, encrypted to local /opt/backup/railiance/cluster,7retained per type",
|
||||
"scope_limit": "A cluster-state backup may contain TLS objects, but no actual snapshot presence, original Secret coverage, independent offsite copy/custody, freshness or restore proof is established by source inspection",
|
||||
"restore_limit": "Whole-state restore guide requires cluster downtime and state replacement; not an authorized or isolated TLS extraction procedure",
|
||||
"safety_source_findings": [
|
||||
"backup-s2 stages raw state DB and Helm values in named /tmp files; failure/hard-loss cleanup not registered",
|
||||
"restore-s2 prints persistent-key and raw whole-state /tmp restore instructions; source not suitable as the owner-held isolated TLS metadata path without review"
|
||||
]
|
||||
},
|
||||
"questions_for_source_owners": [
|
||||
"Which existing owner/process owns original kc-tls recovery outside the issuer/cluster outage? Name provider/artifact, public provenance and independent key custody without values.",
|
||||
"Is a fresh enough independent encrypted cluster/TLS backup actually present, reachable and restore-tested? What proves exact original certificate/key/trust continuity?",
|
||||
"If reissue is proposed, which owner accepts the changed certificate/key for this continuity claim, and what independently reachable ACME account/domain challenge prerequisites exist when ordinary cluster/issuer is down?",
|
||||
"What are the exact original public certificate/chain hashes, hostname/SAN/validity and trust bundle binding for the selected attempt?",
|
||||
"Can the owner provide only public certificate/trust metadata and a private custody entry point, with no Secret/key dump or arbitrary raw diagnostics?"
|
||||
],
|
||||
"execution_limits": [
|
||||
"No source owner assent has been requested or recorded",
|
||||
"No Secret object, private key, TLS data or live cluster state read",
|
||||
"No fresh self-signed trust substitute or live backup/restore/reissue executed"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
SHELL := /usr/bin/env bash
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Operator age public key — used as bundle encryption recipient
|
||||
OPERATOR_AGE_PUBKEY := $(shell cat keys/age.pub 2>/dev/null | tr -d '[:space:]')
|
||||
SECURITY_BOOTSTRAP_METADATA ?= $(if $(METADATA),$(METADATA),.local/security-bootstrap.json)
|
||||
SECURITY_BOOTSTRAP_HOST ?= $(if $(HOST),$(HOST),127.0.0.1)
|
||||
SECURITY_BOOTSTRAP_PORT ?= $(if $(PORT),$(PORT),8876)
|
||||
OPENBAO_RESTORE_EVIDENCE ?= /tmp/netkingdom-openbao-restore-drill/evidence.json
|
||||
OPENBAO_EMERGENCY_EVIDENCE ?= /tmp/netkingdom-openbao-emergency-drill/evidence.json
|
||||
BOOTSTRAP_CLEANUP_EVIDENCE ?= /tmp/netkingdom-bootstrap-cleanup/evidence.json
|
||||
LIFECYCLE_FLOW_EVIDENCE ?= /tmp/netkingdom-lifecycle-flow/evidence.json
|
||||
ONBOARDING_DRY_RUN_EVIDENCE ?= /tmp/netkingdom-onboarding-dry-run/evidence.json
|
||||
RAILIANCE_PLATFORM_PATH ?= ../railiance-platform
|
||||
KEY_CAPE_PATH ?= ../key-cape
|
||||
CUSTODY_ROSTER ?= .local/custody-roster.json
|
||||
CUSTODY_ROSTER_SIGNATURE ?= .local/custody-roster.json.sig
|
||||
CUSTODY_ROSTER_ALLOWED_SIGNERS ?= .local/custody-roster.allowed_signers
|
||||
CUSTODY_ROSTER_SIGNING_KEY ?= $(HOME)/.ssh/id_custodian_agent
|
||||
CUSTODY_ROSTER_SIGNING_PRINCIPAL ?= platform-custodian
|
||||
|
||||
# ── Help ──────────────────────────────────────────────────────────────────────
|
||||
help: ## Show this help
|
||||
@echo "net-kingdom — available targets"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||
| sort \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-28s %s\n", $$1, $$2}'
|
||||
|
||||
# ── Git hooks ─────────────────────────────────────────────────────────────────
|
||||
hooks: ## Configure git to use repo-local hooks (.githooks)
|
||||
@mkdir -p .githooks
|
||||
git config core.hooksPath .githooks
|
||||
@test -f .githooks/pre-commit \
|
||||
|| (echo "ERROR: .githooks/pre-commit not found"; exit 1)
|
||||
chmod +x .githooks/pre-commit
|
||||
@echo "✔ git hooks enabled (.githooks/pre-commit is active)"
|
||||
|
||||
hooks-test: ## Test that the pre-commit hook blocks plaintext secrets
|
||||
@mkdir -p sso-mfa/bootstrap/secrets/_hooktest
|
||||
@printf '%s=%s\n' PI_SECRET_KEY deadbeef > sso-mfa/bootstrap/secrets/_hooktest/test.env
|
||||
@git add sso-mfa/bootstrap/secrets/_hooktest/test.env 2>/dev/null || true
|
||||
@if git commit -m "TEST: hook must block this" 2>/dev/null; then \
|
||||
echo "FAIL: hook did NOT block plaintext commit"; \
|
||||
git reset --soft HEAD~1 2>/dev/null || true; \
|
||||
else \
|
||||
echo "✔ Hook blocked plaintext as expected"; \
|
||||
fi
|
||||
@git restore --staged sso-mfa/bootstrap/secrets/_hooktest/test.env 2>/dev/null || true
|
||||
@rm -rf sso-mfa/bootstrap/secrets/_hooktest
|
||||
|
||||
# ── SOPS / age ────────────────────────────────────────────────────────────────
|
||||
sops-setup: ## Validate the registered public age recipient; never install a private key
|
||||
@python3 sso-mfa/bootstrap/sops-private.py setup
|
||||
|
||||
# Raw values prevent both Make-function evaluation and shell interpolation.
|
||||
unexport FILE INPUT OUTPUT COMMAND OPERATOR_AGE_PUBKEY
|
||||
sops-edit sops-encrypt sops-decrypt sops-rotate: export NK_SOPS_FILE := $(value FILE)
|
||||
sops-edit: export NK_SOPS_INPUT := $(value INPUT)
|
||||
sops-decrypt: export NK_SOPS_OUTPUT := $(value OUTPUT)
|
||||
|
||||
sops-edit: ## Replace encrypted content from private INPUT outside Git; explicit custody required
|
||||
@python3 sso-mfa/bootstrap/sops-write.py edit
|
||||
|
||||
sops-encrypt: ## Encrypt private, untracked FILE in place with the registered public recipient
|
||||
@python3 sso-mfa/bootstrap/sops-write.py encrypt
|
||||
|
||||
sops-decrypt: ## Decrypt age-only JSON/YAML to new private OUTPUT outside Git; explicit custody required
|
||||
@python3 sso-mfa/bootstrap/sops-private.py decrypt
|
||||
|
||||
sops-rotate: ## Rotate the SOPS data key; recipient changes require a separate reviewed procedure
|
||||
@python3 sso-mfa/bootstrap/sops-write.py rotate
|
||||
|
||||
sops-custody-check sops-custody-shell sops-custody-run: export NK_CUSTODY_RECIPIENT := $(value OPERATOR_AGE_PUBKEY)
|
||||
sops-custody-run: export NK_CUSTODY_COMMAND := $(value COMMAND)
|
||||
|
||||
sops-custody-check: ## Validate a custody age key without keeping it on disk
|
||||
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$$NK_CUSTODY_RECIPIENT" --check-only
|
||||
|
||||
sops-custody-shell: ## Open a shell with a temporary SOPS_AGE_KEY_FILE from custody material
|
||||
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$$NK_CUSTODY_RECIPIENT"
|
||||
|
||||
sops-custody-run: ## Run COMMAND inside the temporary custody handoff
|
||||
@[[ -n "$$NK_CUSTODY_COMMAND" ]] || (echo "Usage: make sops-custody-run COMMAND='<approved command>'"; exit 1)
|
||||
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient "$$NK_CUSTODY_RECIPIENT" -- bash -lc "$$NK_CUSTODY_COMMAND"
|
||||
|
||||
check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted
|
||||
@echo "Checking for unencrypted files under secrets/..."
|
||||
@bad=0; \
|
||||
while IFS= read -r f; do \
|
||||
[[ -z "$$f" ]] && continue; \
|
||||
if grep -qE '^[[:space:]]*sops:[[:space:]]*$$|"sops"[[:space:]]*:' "$$f" 2>/dev/null; then \
|
||||
continue; \
|
||||
fi; \
|
||||
case "$$f" in *.age|*.gpg) continue ;; esac; \
|
||||
echo " UNENCRYPTED: $$f"; bad=1; \
|
||||
done < <(git ls-files --others --cached 'secrets' 2>/dev/null | grep -v '/$$'); \
|
||||
if [[ "$$bad" -ne 0 ]]; then \
|
||||
echo ""; \
|
||||
echo "ERROR: Unencrypted file detected. For private untracked input: make sops-encrypt FILE=<file>; tracked plaintext requires incident review."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "✔ All secrets/ files appear SOPS-encrypted"
|
||||
|
||||
# ── Credential lifecycle ──────────────────────────────────────────────────────
|
||||
creds-init: sops-setup hooks ## One-time setup: verify prerequisites, configure SOPS and git hooks
|
||||
@echo "=== creds-init: checking prerequisites ==="
|
||||
@command -v age >/dev/null 2>&1 || (echo "ERROR: age not installed (apt install age)"; exit 1)
|
||||
@command -v sops >/dev/null 2>&1 || (echo "ERROR: sops not installed (see https://github.com/getsops/sops/releases)"; exit 1)
|
||||
@command -v kubectl >/dev/null 2>&1 \
|
||||
&& echo "✔ kubectl found" \
|
||||
|| echo "WARN: kubectl not found (required for creds-apply/verify)"
|
||||
@echo ""
|
||||
@echo "✔ creds-init complete. Next: make creds-generate"
|
||||
|
||||
creds-generate: ## Generate private service files for public-recipient encryption
|
||||
@command -v openssl >/dev/null 2>&1 || (echo "ERROR: openssl not found"; exit 1)
|
||||
cd sso-mfa/bootstrap && bash gen-secrets.sh
|
||||
@sed -i "s|^generated_at: .*|generated_at: $$(date -Iseconds)|" sso-mfa/bootstrap/creds-state.yaml
|
||||
@echo ""
|
||||
@echo "State: generated_at updated in sso-mfa/bootstrap/creds-state.yaml"
|
||||
@echo "Next : encrypt to the registered public recipient; never copy values into transcripts."
|
||||
@echo " Run 'make creds-bundle' to create an encrypted offsite backup."
|
||||
|
||||
creds-bundle: ## Age-encrypt the ops bundle for offsite storage
|
||||
@[[ -n "$(OPERATOR_AGE_PUBKEY)" ]] \
|
||||
|| (echo "ERROR: keys/age.pub not found — cannot determine encryption recipient"; exit 1)
|
||||
@[[ -d sso-mfa/bootstrap/secrets ]] \
|
||||
|| (echo "ERROR: sso-mfa/bootstrap/secrets/ not found — run 'make creds-generate' first"; exit 1)
|
||||
cd sso-mfa/bootstrap && bash pack-bundle.sh secrets "$(OPERATOR_AGE_PUBKEY)"
|
||||
@sed -i "s|^bundle_at: .*|bundle_at: $$(date -Iseconds)|" sso-mfa/bootstrap/creds-state.yaml
|
||||
@echo ""
|
||||
@echo "State: bundle_at updated in sso-mfa/bootstrap/creds-state.yaml"
|
||||
@echo "Store the .tar.age bundle offsite (external drive, encrypted cloud, second location)."
|
||||
|
||||
creds-apply: ## Apply all create-secrets.sh scripts to the cluster in dependency order
|
||||
bash sso-mfa/bootstrap/creds-apply.sh
|
||||
|
||||
creds-verify: ## Observe current Secret presence without rewriting native acceptance
|
||||
bash sso-mfa/bootstrap/creds-verify.sh
|
||||
|
||||
creds-status: ## Print human-readable credential state from creds-state.yaml
|
||||
bash sso-mfa/bootstrap/creds-status.sh
|
||||
|
||||
creds-rotate: export SECRET := $(SECRET)
|
||||
creds-rotate: ## Guided rotation for one secret: make creds-rotate SECRET=<name>
|
||||
@bash sso-mfa/bootstrap/creds-rotate.sh
|
||||
|
||||
## ── Agent-driven credential lifecycle (NK-WP-0005) ──────────────────────────
|
||||
|
||||
creds-agent-init: ## Attended bootstrap using the public recipient and encrypted recovery handoff
|
||||
@bash sso-mfa/bootstrap/creds-bootstrap-agent.sh
|
||||
|
||||
creds-agent-status: ## Show current v2 bootstrap state (agent mode)
|
||||
@bash sso-mfa/bootstrap/creds-status.sh --v2
|
||||
|
||||
creds-emergency-reprint: ## Print the non-secret recovery guide; no keys or passwords are read
|
||||
@bash sso-mfa/bootstrap/emergency-bundle.sh --reprint
|
||||
|
||||
openbao-init-unseal: ## SOPS-held OpenBao init/unseal (NET-WP-0020 T2; requires sops-held-automation model selected)
|
||||
@bash sso-mfa/bootstrap/openbao-init-unseal.sh
|
||||
|
||||
openbao-init-unseal-dry-run: ## Dry-run the SOPS-held OpenBao init/unseal path
|
||||
@bash sso-mfa/bootstrap/openbao-init-unseal.sh --dry-run
|
||||
|
||||
RTUT_HOME ?= $(HOME)/runbook-tutorials
|
||||
|
||||
runbooks-validate: ## Validate runbook packs under runbooks/ with the runbook-tutorials engine
|
||||
PYTHONPATH=$(RTUT_HOME) python3 -m rtut validate .
|
||||
|
||||
iam-profile-conformance-test: ## Run current IAM Profile conformance fixture tests
|
||||
python3 -m pytest tools/iam-profile-conformance/tests
|
||||
|
||||
playbook-contract-test: ## Run Playbook Capability Contract fixture tests
|
||||
python3 -m pytest tools/playbook-capability-contract/tests
|
||||
|
||||
security-scenario-composer-test: ## Run deterministic security scenario composer tests
|
||||
python3 -m pytest tools/security-scenario-composer/tests
|
||||
|
||||
security-scenario-compose-c0: ## Compose the plan-only C0 local-identity reference
|
||||
python3 tools/security-scenario-composer/security_scenario_composer.py \
|
||||
--scenario examples/security-scenarios/c0-local-identity.yaml \
|
||||
capabilities/playbooks/net-kingdom.local-identity.yaml
|
||||
|
||||
security-scenario-compose-lightweight: ## Compose plan-only KeyCape C1+C2b from owner declarations
|
||||
python3 tools/security-scenario-composer/security_scenario_composer.py \
|
||||
--scenario examples/security-scenarios/c1-c2b-key-cape.yaml \
|
||||
$(KEY_CAPE_PATH)/capabilities/playbooks/key-cape.lightweight-sso.yaml \
|
||||
$(KEY_CAPE_PATH)/capabilities/playbooks/key-cape.privacyidea-token-authority.yaml
|
||||
|
||||
posture-feedback-test: ## Run deterministic posture-feedback tests
|
||||
python3 -m pytest tools/posture-feedback/tests tools/tenancy-posture/test_validate.py
|
||||
|
||||
custody-record-test: ## Run custody-record schema/validator tests (NK-WP-0045-T03)
|
||||
python3 -m pytest tools/custody-record/tests
|
||||
|
||||
posture-feedback-reference: ## Emit the proposal-only reference report at its fixed example time
|
||||
python3 tools/posture-feedback/posture_feedback.py \
|
||||
--as-of 2026-08-23T12:00:00Z \
|
||||
--horizon-days 14 \
|
||||
--fail-on none \
|
||||
examples/posture-feedback/expired-e2.yaml
|
||||
|
||||
emission-cadence-profile-test: ## Run NetKingdom emission-cadence profile tests
|
||||
python3 -m pytest tools/emission-cadence-profile/tests
|
||||
|
||||
security-bootstrap-console-test: ## Run automated tests for bootstrap console UI/sections/runbooks (NET-WP-0018-T07)
|
||||
python3 -m pytest tools/security-bootstrap-console/tests
|
||||
|
||||
# Syntax check for key bootstrap helper scripts (part of T07 layered tests)
|
||||
security-bootstrap-scripts-syntax: ## Shell syntax check for bootstrap scripts
|
||||
bash -n sso-mfa/k8s/lldap/dry-run-nonroot-user.sh
|
||||
bash -n sso-mfa/k8s/lldap/create-user.sh
|
||||
bash -n sso-mfa/k8s/lldap/break-glass.sh
|
||||
@echo "✔ bootstrap scripts syntax OK"
|
||||
|
||||
security-bootstrap-validate-keycape-client: ## Validate KeyCape OpenBao client definition+deployment (T08)
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
validate-keycape-client || true
|
||||
|
||||
security-bootstrap-console: security-bootstrap-metadata-init ## Show guided security bootstrap status and safe actions
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
status
|
||||
|
||||
security-bootstrap-king-kit: ## Print the king credential kit checklist
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py king-kit
|
||||
|
||||
security-bootstrap-validate-kit: ## Validate non-secret king credential metadata
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
validate-king-kit
|
||||
|
||||
security-bootstrap-validate-t02: ## Validate NET-WP-0017-T02 OpenBao audit/recovery gates
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
validate-t02 \
|
||||
--railiance-path "$(RAILIANCE_PLATFORM_PATH)" \
|
||||
--restore-evidence "$(OPENBAO_RESTORE_EVIDENCE)" \
|
||||
--emergency-evidence "$(OPENBAO_EMERGENCY_EVIDENCE)" \
|
||||
--custody-roster "$(CUSTODY_ROSTER)" \
|
||||
--custody-roster-signature "$(CUSTODY_ROSTER_SIGNATURE)" \
|
||||
--custody-roster-allowed-signers "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)"
|
||||
|
||||
security-bootstrap-validate-cleanup: ## Validate NET-WP-0017-T03/T04 cleanup and taint evidence
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
validate-cleanup \
|
||||
--evidence "$(BOOTSTRAP_CLEANUP_EVIDENCE)"
|
||||
|
||||
security-bootstrap-validate-lifecycle-flow: ## Validate NET-WP-0017-T05 lifecycle operator-flow evidence
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
validate-lifecycle-flow \
|
||||
--evidence "$(LIFECYCLE_FLOW_EVIDENCE)"
|
||||
|
||||
security-bootstrap-validate-onboarding-dry-run: ## Validate NET-WP-0017-T06 non-root onboarding dry-run evidence
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
validate-onboarding-dry-run \
|
||||
--evidence "$(ONBOARDING_DRY_RUN_EVIDENCE)"
|
||||
|
||||
security-bootstrap-custody-roster-template: ## Print a non-secret two-of-three custody roster template
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py custody-roster-template
|
||||
|
||||
security-bootstrap-cleanup-evidence-template: ## Print non-secret NET-WP-0017-T03/T04 cleanup and taint evidence JSON template
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py cleanup-evidence-template
|
||||
|
||||
security-bootstrap-lifecycle-flow-template: ## Print non-secret NET-WP-0017-T05 lifecycle operator-flow evidence JSON template
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py lifecycle-flow-template
|
||||
|
||||
security-bootstrap-lifecycle-guide: ## Print the practical T05 operator flow guide (onboard/lock/offboard/review/fabric-admin with previews + commands)
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py lifecycle-guide
|
||||
|
||||
security-bootstrap-onboarding-dry-run-template: ## Print non-secret NET-WP-0017-T06 onboarding dry-run evidence JSON template (use to start T06 evidence after running the lifecycle flow)
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py onboarding-dry-run-template
|
||||
|
||||
security-bootstrap-onboarding-dry-run: ## Retired: see docs/legacy-lldap-lifecycle-retirement.md
|
||||
@bash sso-mfa/k8s/lldap/dry-run-nonroot-user.sh
|
||||
|
||||
security-bootstrap-lifecycle-cleanup-dryrun-users: ## Retired: see docs/legacy-lldap-lifecycle-retirement.md
|
||||
@bash sso-mfa/k8s/lldap/dry-run-nonroot-user.sh
|
||||
|
||||
security-bootstrap-validate-custody-roster: ## Validate and verify the signed local custody roster
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
validate-custody-roster \
|
||||
--roster "$(CUSTODY_ROSTER)" \
|
||||
--signature "$(CUSTODY_ROSTER_SIGNATURE)" \
|
||||
--allowed-signers "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)"
|
||||
|
||||
security-bootstrap-sign-custody-roster: ## Sign the ignored local custody roster with an SSH signing key
|
||||
@mkdir -p "$$(dirname "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)")"
|
||||
@printf '%s ' "$(CUSTODY_ROSTER_SIGNING_PRINCIPAL)" > "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)"
|
||||
@cat "$(CUSTODY_ROSTER_SIGNING_KEY).pub" >> "$(CUSTODY_ROSTER_ALLOWED_SIGNERS)"
|
||||
ssh-keygen -Y sign \
|
||||
-f "$(CUSTODY_ROSTER_SIGNING_KEY)" \
|
||||
-n netkingdom-custody-roster \
|
||||
"$(CUSTODY_ROSTER)"
|
||||
@if [[ "$(CUSTODY_ROSTER_SIGNATURE)" != "$(CUSTODY_ROSTER).sig" ]]; then \
|
||||
cp "$(CUSTODY_ROSTER).sig" "$(CUSTODY_ROSTER_SIGNATURE)"; \
|
||||
fi
|
||||
|
||||
security-bootstrap-approve-custody: ## Approve custody mode metadata: make security-bootstrap-approve-custody ARGS="--mfa-enrolled-confirmed --mfa-enrollment-source identity-provider --recovery-confirmed --custody-packet-prepared --no-secret-capture-confirmed"
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
approve-custody-mode \
|
||||
--mode "$(if $(MODE),$(MODE),temporary-single-king)" \
|
||||
$(ARGS)
|
||||
|
||||
security-bootstrap-custody-packet: ## Print a blank offline custody packet template
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py custody-packet
|
||||
|
||||
security-bootstrap-openbao-preflight: ## Show safe OpenBao preflight commands
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-preflight \
|
||||
--railiance-path ../railiance-platform
|
||||
|
||||
security-bootstrap-openbao-unseal-custody-models: ## List OpenBao unseal custody models and implementation status
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-unseal-custody-models
|
||||
|
||||
security-bootstrap-select-openbao-unseal-custody-model: security-bootstrap-metadata-init ## Select implemented unseal model (blocks planned): make ... MODEL=sops-held-automation
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
select-openbao-unseal-custody-model \
|
||||
--model "$(if $(MODEL),$(MODEL),sops-held-automation)"
|
||||
|
||||
security-bootstrap-select-deployment-profile: security-bootstrap-metadata-init ## Select deployment profile (production blocks sops-held-automation): make ... PROFILE=production
|
||||
@[[ -n "$(PROFILE)" ]] || (echo "Usage: make security-bootstrap-select-deployment-profile PROFILE=lab|production"; exit 1)
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
select-deployment-profile --profile "$(PROFILE)"
|
||||
|
||||
security-bootstrap-openbao-ceremony-record-template: ## Print non-secret attended OpenBao ceremony record template
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py openbao-ceremony-record-template
|
||||
|
||||
security-bootstrap-validate-openbao-ceremony-record: ## Validate non-secret attended ceremony record: make ... [EVIDENCE=.local/openbao-ceremony-record.json]
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
validate-openbao-ceremony-record \
|
||||
--evidence "$(if $(EVIDENCE),$(EVIDENCE),.local/openbao-ceremony-record.json)"
|
||||
|
||||
security-bootstrap-metadata-init: ## Create durable local non-secret bootstrap metadata if missing
|
||||
@mkdir -p "$$(dirname "$(SECURITY_BOOTSTRAP_METADATA)")"
|
||||
@if [[ -f "$(SECURITY_BOOTSTRAP_METADATA)" ]]; then \
|
||||
echo "✔ Metadata already exists: $(SECURITY_BOOTSTRAP_METADATA)"; \
|
||||
elif [[ -f /tmp/net-kingdom-security-bootstrap.json ]]; then \
|
||||
cp /tmp/net-kingdom-security-bootstrap.json "$(SECURITY_BOOTSTRAP_METADATA)"; \
|
||||
echo "✔ Imported previous /tmp bootstrap metadata to $(SECURITY_BOOTSTRAP_METADATA)"; \
|
||||
else \
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py metadata-template \
|
||||
> "$(SECURITY_BOOTSTRAP_METADATA)"; \
|
||||
echo "✔ Created metadata: $(SECURITY_BOOTSTRAP_METADATA)"; \
|
||||
fi
|
||||
|
||||
security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody approval UI on localhost:8876: make security-bootstrap-ui
|
||||
python3 tools/security-bootstrap-console/security_bootstrap_console.py \
|
||||
--metadata "$(SECURITY_BOOTSTRAP_METADATA)" \
|
||||
web-ui \
|
||||
--host "$(SECURITY_BOOTSTRAP_HOST)" \
|
||||
--port "$(SECURITY_BOOTSTRAP_PORT)"
|
||||
|
||||
.PHONY: runbooks-validate help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
|
||||
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
|
||||
creds-status creds-rotate \
|
||||
creds-agent-init creds-agent-status creds-emergency-reprint \
|
||||
openbao-init-unseal openbao-init-unseal-dry-run \
|
||||
iam-profile-conformance-test playbook-contract-test posture-feedback-test \
|
||||
custody-record-test \
|
||||
emission-cadence-profile-test \
|
||||
posture-feedback-reference security-scenario-compose-lightweight \
|
||||
security-bootstrap-console-test security-bootstrap-scripts-syntax \
|
||||
security-bootstrap-console security-bootstrap-king-kit \
|
||||
security-bootstrap-validate-kit security-bootstrap-validate-t02 \
|
||||
security-bootstrap-validate-cleanup \
|
||||
security-bootstrap-validate-lifecycle-flow \
|
||||
security-bootstrap-validate-onboarding-dry-run \
|
||||
security-bootstrap-validate-keycape-client \
|
||||
security-bootstrap-custody-roster-template \
|
||||
security-bootstrap-cleanup-evidence-template \
|
||||
security-bootstrap-lifecycle-flow-template \
|
||||
security-bootstrap-lifecycle-guide \
|
||||
security-bootstrap-onboarding-dry-run-template \
|
||||
security-bootstrap-onboarding-dry-run \
|
||||
security-bootstrap-lifecycle-cleanup-dryrun-users \
|
||||
security-bootstrap-validate-custody-roster \
|
||||
security-bootstrap-sign-custody-roster \
|
||||
security-bootstrap-approve-custody \
|
||||
security-bootstrap-custody-packet security-bootstrap-openbao-preflight \
|
||||
security-bootstrap-openbao-unseal-custody-models \
|
||||
security-bootstrap-select-openbao-unseal-custody-model \
|
||||
security-bootstrap-select-deployment-profile \
|
||||
security-bootstrap-openbao-ceremony-record-template \
|
||||
security-bootstrap-validate-openbao-ceremony-record \
|
||||
security-bootstrap-metadata-init security-bootstrap-ui \
|
||||
security-bootstrap-console-test security-bootstrap-scripts-syntax \
|
||||
security-bootstrap-validate-keycape-client
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private service Secret creation, exact-match verification and named rotation.
|
||||
|
||||
No credential is accepted on argv, evaluated as shell code, or printed. This
|
||||
helper is not an authority grant or a multi-service rotation transaction.
|
||||
"""
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
TARGETS = {
|
||||
'postgresql': ('databases', 'net-kingdom-pg-privacyidea-app', ('privacyidea',)),
|
||||
'privacyidea': ('mfa', 'privacyidea-config', ('privacyidea',)),
|
||||
'lldap': ('sso', 'lldap-secrets', ('lldap',)),
|
||||
'authelia': ('sso', 'authelia-secrets', ('authelia', 'lldap')),
|
||||
}
|
||||
ROTATIONS = {
|
||||
'postgresql': {'PI_DB_PASSWORD': 'password'},
|
||||
'privacyidea': {'PI_SECRET_KEY': 'PI_SECRET_KEY', 'PI_DB_PASSWORD': 'PI_SQLALCHEMY_DATABASE_URI'},
|
||||
'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET'},
|
||||
'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret'},
|
||||
}
|
||||
ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration'
|
||||
|
||||
|
||||
def private(info, directory=False):
|
||||
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused()
|
||||
if not directory and info.st_nlink != 1:
|
||||
raise Refused()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def custody(root, components):
|
||||
root = Path(os.path.abspath(root))
|
||||
if any(p.is_symlink() for p in (root, *root.parents)): raise Refused()
|
||||
descriptors = []
|
||||
try:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
fd = os.open(root, flags); descriptors.append(fd); private(os.fstat(fd), True)
|
||||
children = {}
|
||||
for component in components:
|
||||
child = os.open(component, flags, dir_fd=fd)
|
||||
descriptors.append(child); private(os.fstat(child), True)
|
||||
children[component] = child
|
||||
yield children
|
||||
finally:
|
||||
for fd in reversed(descriptors): os.close(fd)
|
||||
|
||||
|
||||
def read_private(parent, name):
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
||||
try:
|
||||
private(os.fstat(fd))
|
||||
raw = os.read(fd, 65537)
|
||||
if len(raw) > 65536: raise Refused()
|
||||
return raw
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_env(parent):
|
||||
values = {}
|
||||
for line in read_private(parent, 'secrets.env').decode().splitlines():
|
||||
if not line.strip() or line.lstrip().startswith('#'): continue
|
||||
name, separator, value = line.partition('=')
|
||||
if not separator or not re.fullmatch(r'[A-Z][A-Z0-9_]*', name) or name in values:
|
||||
raise Refused()
|
||||
values[name] = value
|
||||
return values
|
||||
|
||||
|
||||
def literal(values, name):
|
||||
value = values.get(name, '')
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{32,128}', value): raise Refused()
|
||||
return value
|
||||
|
||||
|
||||
def run(args, payload=None):
|
||||
completed = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, timeout=60, check=False)
|
||||
if completed.returncode or len(completed.stdout) > 2 * 1024 * 1024: raise Refused()
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def decode_secret(raw, namespace, name):
|
||||
obj = json.loads(raw)
|
||||
if not isinstance(obj, dict) or obj.get('kind') != 'Secret' or obj.get('apiVersion') != 'v1':
|
||||
raise Refused()
|
||||
meta = obj.get('metadata')
|
||||
if not isinstance(meta, dict) or meta.get('name') != name or meta.get('namespace') != namespace:
|
||||
raise Refused()
|
||||
if (not isinstance(meta.get('uid'), str) or not isinstance(meta.get('resourceVersion'), str)
|
||||
or not re.fullmatch(r'[a-zA-Z0-9-]{1,128}', meta['uid'])
|
||||
or not re.fullmatch(r'[0-9]{1,128}', meta['resourceVersion'])):
|
||||
raise Refused()
|
||||
if (meta.get('ownerReferences') or obj.get('immutable')
|
||||
or obj.get('type') not in (('Opaque', 'kubernetes.io/basic-auth')
|
||||
if name == 'net-kingdom-pg-privacyidea-app' else ('Opaque',))):
|
||||
raise Refused()
|
||||
annotations = meta.get('annotations', {})
|
||||
labels = meta.get('labels', {})
|
||||
if not isinstance(annotations, dict) or not isinstance(labels, dict): raise Refused()
|
||||
if any(not isinstance(v, str) for v in (*annotations.values(), *labels.values())): raise Refused()
|
||||
if any(k.startswith(('external-secrets.io/', 'reconcile.external-secrets.io/')) for k in labels): raise Refused()
|
||||
if not isinstance(obj.get('data'), dict) or not obj['data']: raise Refused()
|
||||
for key, value in obj['data'].items():
|
||||
if not isinstance(key, str) or not isinstance(value, str): raise Refused()
|
||||
base64.b64decode(value, validate=True)
|
||||
return obj
|
||||
|
||||
|
||||
def read_key(parent, configured, existing):
|
||||
if configured:
|
||||
path = Path(configured)
|
||||
if not path.is_absolute() or any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused()
|
||||
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
private(os.fstat(fd), True)
|
||||
key = read_private(fd, path.name)
|
||||
finally:
|
||||
os.close(fd)
|
||||
else:
|
||||
try:
|
||||
key = read_private(parent, 'oidc_private_key.pem')
|
||||
except FileNotFoundError:
|
||||
if existing is not None:
|
||||
key = base64.b64decode(existing['data']['oidc_issuer_private_key'], validate=True)
|
||||
else:
|
||||
key = run(['openssl', 'genrsa', '2048'])
|
||||
run(['openssl', 'rsa', '-check', '-noout'], key)
|
||||
temp = '.issuer-' + secrets.token_hex(12)
|
||||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as file:
|
||||
file.write(key); file.flush(); os.fsync(file.fileno())
|
||||
os.link(temp, 'oidc_private_key.pem', src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.unlink(temp, dir_fd=parent)
|
||||
run(['openssl', 'rsa', '-check', '-noout'], key)
|
||||
return key.decode()
|
||||
|
||||
|
||||
def service_values(component, values):
|
||||
# Validate all required literals before any cluster call or local key creation.
|
||||
if component == 'postgresql':
|
||||
return {'username': 'privacyidea', 'password': literal(values['privacyidea'], 'PI_DB_PASSWORD')}
|
||||
if component == 'privacyidea':
|
||||
env = values['privacyidea']; password = literal(env, 'PI_DB_PASSWORD')
|
||||
return {'PI_SECRET_KEY': literal(env, 'PI_SECRET_KEY'), 'PI_PEPPER': literal(env, 'PI_PEPPER'),
|
||||
'PI_SQLALCHEMY_DATABASE_URI': 'postgresql://privacyidea:' + password +
|
||||
'@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db'}
|
||||
if component == 'lldap':
|
||||
return {key: literal(values['lldap'], key) for key in ('LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS')}
|
||||
env = values['authelia']
|
||||
return {'jwt_secret': literal(env, 'AUTHELIA_JWT_SECRET'),
|
||||
'session_secret': literal(env, 'AUTHELIA_SESSION_SECRET'),
|
||||
'storage_encryption_key': literal(env, 'AUTHELIA_STORAGE_ENCRYPTION_KEY'),
|
||||
'oidc_hmac_secret': literal(env, 'AUTHELIA_OIDC_HMAC_SECRET'),
|
||||
'ldap_password': literal(values['lldap'], 'LLDAP_LDAP_USER_PASS')}
|
||||
|
||||
|
||||
def publish(namespace, name, desired, old, allowed):
|
||||
encoded = {k: base64.b64encode(v.encode() if isinstance(v, str) else v).decode()
|
||||
for k, v in desired.items()}
|
||||
if old is None:
|
||||
if allowed: raise Refused() # Rotation may not silently create missing state.
|
||||
obj = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque',
|
||||
'metadata': {'namespace': namespace, 'name': name}, 'data': encoded}
|
||||
result = run(['kubectl', 'create', '-o', 'json', '-f', '-'], json.dumps(obj).encode())
|
||||
expected = encoded; outcome = 'created'
|
||||
else:
|
||||
if not set(encoded).issubset(old['data']): raise Refused()
|
||||
different = {k for k, value in encoded.items() if old['data'][k] != value}
|
||||
if different - allowed: raise Refused()
|
||||
annotations = old['metadata'].get('annotations', {})
|
||||
if not different and ANNOTATION not in annotations: return 'verified'
|
||||
patch = [{'op': 'test', 'path': '/metadata/uid', 'value': old['metadata']['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': old['metadata']['resourceVersion']},
|
||||
{'op': 'test', 'path': '/data', 'value': old['data']}]
|
||||
patch.extend({'op': 'replace', 'path': '/data/' + key, 'value': encoded[key]} for key in sorted(different))
|
||||
if ANNOTATION in annotations:
|
||||
patch.append({'op': 'remove', 'path': '/metadata/annotations/kubectl.kubernetes.io~1last-applied-configuration'})
|
||||
result = run(['kubectl', 'patch', 'secret', name, '-n', namespace, '--type=json',
|
||||
'--patch-file=/dev/stdin', '-o', 'json'], json.dumps(patch).encode())
|
||||
expected = {**old['data'], **{k: encoded[k] for k in different}}
|
||||
outcome = 'updated'
|
||||
readback = decode_secret(result, namespace, name)
|
||||
if readback['data'] != expected or ANNOTATION in readback['metadata'].get('annotations', {}): raise Refused()
|
||||
if old is not None:
|
||||
if readback['metadata']['uid'] != old['metadata']['uid'] or readback['type'] != old['type']: raise Refused()
|
||||
expected_annotations = {k: v for k, v in old['metadata'].get('annotations', {}).items() if k != ANNOTATION}
|
||||
if readback['metadata'].get('annotations', {}) != expected_annotations: raise Refused()
|
||||
if readback['metadata'].get('labels', {}) != old['metadata'].get('labels', {}): raise Refused()
|
||||
return outcome
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) not in (2, 3, 5): raise Refused()
|
||||
component = sys.argv[1]
|
||||
if component not in TARGETS: raise Refused()
|
||||
namespace, name, inputs = TARGETS[component]
|
||||
root = sys.argv[2] if len(sys.argv) >= 3 else Path(__file__).resolve().parent / 'secrets'
|
||||
rotation = None
|
||||
if len(sys.argv) == 5:
|
||||
if sys.argv[3] != '--rotate-key' or sys.argv[4] not in ROTATIONS[component]: raise Refused()
|
||||
rotation = ROTATIONS[component][sys.argv[4]]
|
||||
with custody(root, inputs) as directories:
|
||||
values = {c: read_env(directories[c]) for c in inputs}
|
||||
desired = service_values(component, values)
|
||||
if component == 'authelia':
|
||||
import bcrypt
|
||||
client = literal(values['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET').encode()
|
||||
if len(client) > 72: raise Refused()
|
||||
raw = run(['kubectl', 'get', 'secret', name, '-n', namespace, '--ignore-not-found', '-o', 'json'])
|
||||
old = decode_secret(raw, namespace, name) if raw.strip() else None
|
||||
if rotation and old is None: raise Refused()
|
||||
if component == 'authelia':
|
||||
if old is None:
|
||||
hashed = bcrypt.hashpw(client, bcrypt.gensalt(rounds=12))
|
||||
else:
|
||||
hashed = base64.b64decode(old['data']['keycape_client_secret_hash'], validate=True)
|
||||
if not bcrypt.checkpw(client, hashed): raise Refused()
|
||||
desired['keycape_client_secret_hash'] = hashed.decode()
|
||||
desired['oidc_issuer_private_key'] = read_key(directories['authelia'],
|
||||
values['authelia'].get('AUTHELIA_OIDC_PRIVATE_KEY_FILE', ''), old)
|
||||
outcome = publish(namespace, name, desired, old, {rotation} if rotation else set())
|
||||
print('Service Secret ' + outcome + '; native consumer acceptance remains required.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Refused, OSError, ValueError, KeyError, TypeError, ImportError, subprocess.SubprocessError):
|
||||
print('ERROR: service Secret operation refused or failed. Check private inputs, current ownership '
|
||||
'and the named rotation through approved metadata-only diagnostics. A failed write has an '
|
||||
'unverified remote outcome; reconcile before retry. Local recovery keys are retained.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env bash
|
||||
# creds-apply.sh — run all create-secrets.sh scripts in dependency order.
|
||||
#
|
||||
# Usage:
|
||||
# bash sso-mfa/bootstrap/creds-apply.sh [secrets-dir]
|
||||
# make creds-apply
|
||||
#
|
||||
# Dependency order:
|
||||
# 1. postgresql (no dependencies)
|
||||
# 2. lldap (needs: secrets/lldap/secrets.env)
|
||||
# 3. authelia (needs: lldap/secrets.env → LLDAP_LDAP_USER_PASS)
|
||||
# 4. privacyidea (needs: secrets/privacyidea/secrets.env)
|
||||
# 5. keycape SKIPPED — requires owner-established RPF-WP-0040 factor delivery.
|
||||
# create-secrets.sh is first-creation only, never live replacement.
|
||||
#
|
||||
# After each successful component create/verification, creds-state.yaml is updated.
|
||||
# Differing existing values require a separate named rotation; this is not a batch rotation.
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
if (( $# > 1 )); then
|
||||
echo "ERROR: expected one private secrets directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
SECRETS_DIR="$(realpath -sm -- "${1:-$SCRIPT_DIR/secrets}")"
|
||||
STATE_FILE="$SCRIPT_DIR/creds-state.yaml"
|
||||
K8S_DIR="$REPO_ROOT/sso-mfa/k8s"
|
||||
|
||||
# ── Preflight checks ──────────────────────────────────────────────────────────
|
||||
if [[ -z "${KUBECONFIG:-}" && ! -f "$HOME/.kube/config" ]]; then
|
||||
echo "ERROR: KUBECONFIG is not set and ~/.kube/config does not exist." >&2
|
||||
echo " Set KUBECONFIG or ensure cluster access before running creds-apply." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! kubectl cluster-info &>/dev/null; then
|
||||
echo "ERROR: Cannot reach the Kubernetes cluster." >&2
|
||||
echo " Ensure the cluster is running and KUBECONFIG points to the right context." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$SECRETS_DIR" ]]; then
|
||||
echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2
|
||||
echo " Run 'make creds-generate' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== creds-apply — net-kingdom SSO/MFA secrets ==="
|
||||
echo "Secrets dir : $SECRETS_DIR"
|
||||
echo "Cluster connectivity checked; use only the context admitted by the current procedure."
|
||||
echo ""
|
||||
|
||||
# ── Helper: run a component's create-secrets.sh ───────────────────────────────
|
||||
run_component() {
|
||||
local component="$1"
|
||||
local script_dir="$K8S_DIR/$component"
|
||||
local script="$script_dir/create-secrets.sh"
|
||||
local state_key="$2"
|
||||
|
||||
echo "──────────────────────────────────────────────"
|
||||
echo "Applying: $component"
|
||||
|
||||
if [[ ! -f "$script" ]]; then
|
||||
echo "ERROR: $script not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! (cd "$script_dir" && bash create-secrets.sh "$SECRETS_DIR") >/dev/null 2>&1; then
|
||||
echo "ERROR: component create/verification failed; earlier components may already be applied." >&2
|
||||
echo "Use metadata-only reconciliation before retry; no native acceptance is claimed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update state file
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
sed -i "s|^ $state_key: .*| $state_key: true|" "$STATE_FILE"
|
||||
echo " [state] secrets_applied.$state_key → true"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Step 1: PostgreSQL ────────────────────────────────────────────────────────
|
||||
run_component "postgresql" "postgres"
|
||||
|
||||
# ── Step 2: LLDAP ────────────────────────────────────────────────────────────
|
||||
if [[ ! -f "$SECRETS_DIR/lldap/secrets.env" ]]; then
|
||||
echo "ERROR: $SECRETS_DIR/lldap/secrets.env not found — run creds-generate first." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_component "lldap" "lldap"
|
||||
|
||||
# ── Step 3: Authelia (needs LLDAP bind password from lldap/secrets.env) ───────
|
||||
run_component "authelia" "authelia"
|
||||
|
||||
# ── Step 4: privacyIDEA ───────────────────────────────────────────────────────
|
||||
if [[ ! -f "$SECRETS_DIR/privacyidea/secrets.env" ]]; then
|
||||
echo "ERROR: $SECRETS_DIR/privacyidea/secrets.env not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
run_component "privacyidea" "privacyidea"
|
||||
|
||||
# ── Step 5: KeyCape — SKIPPED (requires scoped factor delivery) ──────────────────────
|
||||
echo "──────────────────────────────────────────────"
|
||||
echo "SKIPPED: keycape (requires RPF-WP-0040 scoped factor delivery)"
|
||||
echo ""
|
||||
echo " After privacyIDEA is Running and bootstrapped:"
|
||||
echo " 1. Export existing keys from the verified Ready privacyIDEA pod:"
|
||||
echo " bash $K8S_DIR/privacyidea/enckey-bootstrap.sh $SECRETS_DIR"
|
||||
echo ""
|
||||
echo " 2. Create the pi-admin user:"
|
||||
echo " bash $K8S_DIR/privacyidea/bootstrap-admin.sh"
|
||||
echo ""
|
||||
echo " 3. Establish scoped factor custody and ESO delivery through railiance-platform RPF-WP-0040."
|
||||
echo ""
|
||||
echo " 4. Create KeyCape bootstrap secrets only if keycape-config is absent:"
|
||||
echo " bash $K8S_DIR/keycape/create-secrets.sh $SECRETS_DIR"
|
||||
echo ""
|
||||
echo " 5. Update state:"
|
||||
echo " make creds-verify"
|
||||
echo ""
|
||||
|
||||
echo "=== creds-apply complete ==="
|
||||
echo "Run 'make creds-status' to review state."
|
||||
echo "Run 'make creds-verify' to confirm K8s secrets exist."
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
#!/usr/bin/env bash
|
||||
# creds-bootstrap-agent.sh — attended bootstrap with public-recipient encryption
|
||||
#
|
||||
# Usage:
|
||||
# bash sso-mfa/bootstrap/creds-bootstrap-agent.sh [--dry-run] [--resume] [--initialize-audit-keys]
|
||||
# make creds-agent-init
|
||||
#
|
||||
# Runs in the operator terminal and ends at the encrypted recovery handoff
|
||||
# acknowledgment. Each phase updates creds-state.yaml so interrupted runs resume
|
||||
# automatically from where they left off.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - age (apt install age)
|
||||
# - kubectl with a reachable cluster (KUBECONFIG set or ~/.kube/config)
|
||||
# - git (configured with commit access)
|
||||
# - openssl
|
||||
# - keys/age.pub — independently registered public recipient
|
||||
# - temporary NETKINGDOM_CUSTODY_AGE_KEY_FILE only when resuming encrypted input
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
K8S_DIR="$REPO_ROOT/sso-mfa/k8s"
|
||||
SECRETS_DIR="$SCRIPT_DIR/secrets"
|
||||
STATE_FILE="$SCRIPT_DIR/creds-state.yaml"
|
||||
AGE_KEY="${NETKINGDOM_CUSTODY_AGE_KEY_FILE:-}"
|
||||
|
||||
DRY_RUN=false
|
||||
AUDIT_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--initialize-audit-keys) AUDIT_ARGS=(--initialize-audit-keys) ;;
|
||||
--resume) ;; # Phase state already controls resume.
|
||||
*) printf '%s\n' 'ERROR: unsupported bootstrap argument.' >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# A plan must neither discover credentials nor contact a cluster or change state.
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
printf '%s\n' 'Plan only: validate registered public recipient; generate or attended-decrypt service files; encrypt; apply/verify; create encrypted ops bundle; acknowledge off-host recovery; clean plaintext.'
|
||||
printf '%s\n' 'No files, credentials, cluster state or historical custody receipts were inspected or changed.'
|
||||
exit 0
|
||||
fi
|
||||
[[ -t 0 && -t 1 ]] || { printf '%s\n' 'ERROR: bootstrap requires an attended terminal; use --dry-run for the non-mutating plan.' >&2; exit 2; }
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo " [bootstrap] $*"; }
|
||||
step() { echo ""; echo "══════════════════════════════════════════════════════"; echo " Phase $*"; echo "══════════════════════════════════════════════════════"; }
|
||||
ok() { echo " ✔ $*"; }
|
||||
warn() { echo " ⚠ $*"; }
|
||||
die() { echo ""; echo " ERROR: $*" >&2; exit 1; }
|
||||
|
||||
dry_run_guard() {
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo " [dry-run] would run: $*"
|
||||
return 0
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
# Read a top-level value from creds-state.yaml
|
||||
state_get() { grep -E "^$1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed 's/ *#.*//' | tr -d '"'; }
|
||||
state_get_nested() { grep -E "^ $1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed 's/ *#.*//' | tr -d '"'; }
|
||||
|
||||
# Update a top-level key in creds-state.yaml
|
||||
state_set() {
|
||||
local key="$1" value="$2"
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
if grep -q "^$key:" "$STATE_FILE"; then
|
||||
sed -i "s|^$key: .*|$key: $value|" "$STATE_FILE"
|
||||
else
|
||||
printf '%s: %s\n' "$key" "$value" >> "$STATE_FILE"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Update a nested (2-space indent) key
|
||||
state_set_nested() {
|
||||
local key="$1" value="$2"
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
sed -i "s|^ $key: .*| $key: $value|" "$STATE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Pre-flight ────────────────────────────────────────────────────────────────
|
||||
|
||||
step "0 — Pre-flight"
|
||||
|
||||
command -v age >/dev/null 2>&1 || die "age not installed (apt install age)"
|
||||
command -v kubectl >/dev/null 2>&1 || die "kubectl not found — install it and configure KUBECONFIG"
|
||||
command -v git >/dev/null 2>&1 || die "git not found"
|
||||
command -v openssl >/dev/null 2>&1 || die "openssl not found"
|
||||
ok "required tools present"
|
||||
|
||||
# Encryption needs only the registered public recipient. Never auto-generate
|
||||
# or discover a private key in HOME. Custody source is independently held.
|
||||
[[ -f "$REPO_ROOT/keys/age.pub" ]] || die "registered keys/age.pub is missing"
|
||||
AGE_PUBKEY=$(grep -m1 '^age1' "$REPO_ROOT/keys/age.pub" || true)
|
||||
[[ -n "$AGE_PUBKEY" ]] || die "registered public age recipient is missing"
|
||||
printf '' | age -r "$AGE_PUBKEY" >/dev/null 2>&1 || die "registered public age recipient is invalid"
|
||||
|
||||
# Refuse an encrypted resume before cluster activity unless the owner explicitly
|
||||
# supplied a temporary key through the custody helper. No ambient key fallback.
|
||||
if [[ "$(state_get secrets_generated)" == "true" && ! -d "$SECRETS_DIR" ]]; then
|
||||
[[ -n "$AGE_KEY" && -f "$AGE_KEY" ]] || die "encrypted resume requires the attended custody unlock helper"
|
||||
DERIVED_RECIPIENT=$(age-keygen -y "$AGE_KEY" 2>/dev/null) || die "custody key validation failed"
|
||||
[[ "$DERIVED_RECIPIENT" == "$AGE_PUBKEY" ]] || die "custody key does not match the registered recipient"
|
||||
unset DERIVED_RECIPIENT
|
||||
fi
|
||||
|
||||
# Cluster reachability
|
||||
if ! kubectl cluster-info &>/dev/null; then
|
||||
die "Cannot reach the Kubernetes cluster. Check KUBECONFIG / cluster status."
|
||||
fi
|
||||
KUBE_CTX=$(kubectl config current-context 2>/dev/null || echo '(unknown)')
|
||||
ok "cluster reachable: $KUBE_CTX"
|
||||
state_set "age_public_recipient_ready" "true"
|
||||
state_set "bootstrap_complete" "false"
|
||||
|
||||
# ── Phase 1: Generate secrets ─────────────────────────────────────────────────
|
||||
|
||||
step "1 — Generate secrets"
|
||||
|
||||
if [[ "$(state_get secrets_generated)" == "true" ]]; then
|
||||
ok "secrets already generated — skipping"
|
||||
else
|
||||
# Existing custody may be the only rollback copy after a partial run.
|
||||
[[ ! -e "$SECRETS_DIR" && ! -L "$SECRETS_DIR" ]] || die "existing service custody requires owner reconciliation; never remove it to regenerate"
|
||||
|
||||
log "running gen-secrets.sh..."
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
(cd "$SCRIPT_DIR" && bash gen-secrets.sh "$SECRETS_DIR")
|
||||
ok "secrets generated in $SECRETS_DIR"
|
||||
state_set "secrets_generated" "true"
|
||||
else
|
||||
echo " [dry-run] would run: bash gen-secrets.sh $SECRETS_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 2: Encrypt + commit ─────────────────────────────────────────────────
|
||||
|
||||
step "2 — Encrypt secrets to secrets.enc/ and commit"
|
||||
|
||||
# Re-check: if secrets_generated is true but secrets/ is gone, decrypt first
|
||||
if [[ "$(state_get secrets_generated)" == "true" && ! -d "$SECRETS_DIR" ]]; then
|
||||
log "secrets/ absent (was shredded) — decrypting from secrets.enc/ for re-apply..."
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
(cd "$SCRIPT_DIR" && bash decrypt-secrets.sh "$SECRETS_DIR" "$AGE_KEY")
|
||||
else
|
||||
echo " [dry-run] would decrypt secrets.enc/ → secrets/"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always (re-)encrypt in case secrets were just regenerated
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
log "encrypting secrets → secrets.enc/ ..."
|
||||
(cd "$SCRIPT_DIR" && bash encrypt-secrets.sh "$SECRETS_DIR" "$AGE_PUBKEY" --no-shred)
|
||||
ok "secrets encrypted to secrets.enc/"
|
||||
|
||||
# Commit the encrypted secrets
|
||||
cd "$REPO_ROOT"
|
||||
if git diff --quiet HEAD sso-mfa/bootstrap/secrets.enc/ 2>/dev/null && \
|
||||
git diff --cached --quiet sso-mfa/bootstrap/secrets.enc/ 2>/dev/null; then
|
||||
ok "secrets.enc/ already committed — no changes"
|
||||
else
|
||||
git add sso-mfa/bootstrap/secrets.enc/ sso-mfa/bootstrap/creds-state.yaml
|
||||
git commit -m "chore(creds): encrypted secrets [agent NK-WP-0005]"
|
||||
ok "encrypted secrets committed"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would encrypt + git commit secrets.enc/"
|
||||
fi
|
||||
|
||||
# ── Phase 3: Inject into cluster ──────────────────────────────────────────────
|
||||
|
||||
step "3 — Inject secrets into cluster (postgres → lldap → authelia → privacyidea)"
|
||||
|
||||
# Check if all non-keycape components are already applied
|
||||
PG=$(state_get_nested postgres); LLDAP=$(state_get_nested lldap)
|
||||
AUTH=$(state_get_nested authelia); PI=$(state_get_nested privacyidea)
|
||||
if [[ "$PG" == "true" && "$LLDAP" == "true" && "$AUTH" == "true" && "$PI" == "true" ]]; then
|
||||
ok "all cluster secrets already applied — skipping (avoids CNPG connection disruption)"
|
||||
else
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
(cd "$SCRIPT_DIR" && bash creds-apply.sh "$SECRETS_DIR")
|
||||
ok "secrets applied to cluster"
|
||||
_PHASE3_RAN=true
|
||||
else
|
||||
echo " [dry-run] would run: bash creds-apply.sh $SECRETS_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 4: Verify initial secrets ───────────────────────────────────────────
|
||||
|
||||
step "4 — Verify K8s secrets (pre-bootstrap)"
|
||||
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
# Verify postgres, lldap, authelia, privacyidea (keycape not yet applied)
|
||||
ALL_OK=true
|
||||
for ns_secret in "databases/net-kingdom-pg-privacyidea-app" "sso/lldap-secrets" "sso/authelia-secrets" "mfa/privacyidea-config"; do
|
||||
ns="${ns_secret%%/*}"
|
||||
name="${ns_secret##*/}"
|
||||
if kubectl get secret "$name" --namespace="$ns" --ignore-not-found -o name 2>/dev/null | grep -q .; then
|
||||
ok "secret $ns/$name exists"
|
||||
else
|
||||
warn "secret $ns/$name is missing"
|
||||
ALL_OK=false
|
||||
fi
|
||||
done
|
||||
[[ "$ALL_OK" == true ]] || die "One or more required secrets are missing — check creds-apply output above"
|
||||
|
||||
# Restart privacyIDEA ONLY if secrets were just applied this run.
|
||||
# Re-applying an unchanged secret still marks it "configured", so we track
|
||||
# whether Phase 3 actually ran to avoid spurious restarts on resume.
|
||||
if [[ "${_PHASE3_RAN:-false}" == "true" ]] && \
|
||||
kubectl get deployment privacyidea -n mfa &>/dev/null 2>&1; then
|
||||
log "restarting privacyIDEA deployment to pick up new secrets..."
|
||||
kubectl rollout restart deployment/privacyidea -n mfa
|
||||
ok "privacyIDEA restart triggered"
|
||||
log "waiting for rollout to complete..."
|
||||
kubectl rollout status deployment/privacyidea -n mfa --timeout=300s
|
||||
ok "privacyIDEA rollout complete"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would verify K8s secrets"
|
||||
echo " [dry-run] would restart privacyIDEA if deployment exists"
|
||||
fi
|
||||
|
||||
# ── Phase 5: Post-apply bootstrap — wait for privacyIDEA ──────────────────────
|
||||
|
||||
step "5 — Post-apply bootstrap (privacyIDEA enckey + admin)"
|
||||
|
||||
NAMESPACE="mfa"
|
||||
MAX_WAIT=300 # 5 minutes
|
||||
|
||||
if [[ "$(state_get enckey_bootstrapped)" == "true" && "$(state_get pi_admin_created)" == "true" ]]; then
|
||||
ok "privacyIDEA already bootstrapped — skipping"
|
||||
else
|
||||
log "waiting for privacyIDEA pod to be Ready (max ${MAX_WAIT}s)..."
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
# Wait for the deployment rollout to fully complete — this ensures any
|
||||
# previous pod from a rollout restart is terminated and the new pod is Ready.
|
||||
if ! kubectl rollout status deployment/privacyidea -n "$NAMESPACE" \
|
||||
--timeout="${MAX_WAIT}s" 2>/dev/null; then
|
||||
die "privacyIDEA deployment did not roll out within ${MAX_WAIT}s — check: kubectl get pods -n $NAMESPACE"
|
||||
fi
|
||||
|
||||
# Find the ready pod (rollout status guarantees exactly one ready pod)
|
||||
PI_POD=$(kubectl get pod -n "$NAMESPACE" \
|
||||
-l app.kubernetes.io/name=privacyidea \
|
||||
--field-selector=status.phase=Running \
|
||||
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||
|
||||
[[ -z "$PI_POD" ]] && die "privacyIDEA pod not found after rollout — check: kubectl get pods -n $NAMESPACE"
|
||||
ok "privacyIDEA pod ready: $PI_POD"
|
||||
|
||||
# Run enckey bootstrap
|
||||
if [[ "$(state_get enckey_bootstrapped)" != "true" ]]; then
|
||||
log "running enckey-bootstrap.sh..."
|
||||
(cd "$K8S_DIR/privacyidea" && bash enckey-bootstrap.sh "$SECRETS_DIR" "${AUDIT_ARGS[@]}")
|
||||
state_set "enckey_bootstrapped" "true"
|
||||
ok "enckey bootstrapped"
|
||||
else
|
||||
ok "enckey already bootstrapped"
|
||||
fi
|
||||
|
||||
# Run pi-admin bootstrap
|
||||
if [[ "$(state_get pi_admin_created)" != "true" ]]; then
|
||||
log "running bootstrap-admin.sh..."
|
||||
(cd "$K8S_DIR/privacyidea" && bash bootstrap-admin.sh "$SECRETS_DIR")
|
||||
state_set "pi_admin_created" "true"
|
||||
ok "pi-admin created"
|
||||
else
|
||||
ok "pi-admin already created"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would wait for privacyIDEA pod, run enckey-bootstrap.sh and bootstrap-admin.sh"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 6: Apply keycape secrets ────────────────────────────────────────────
|
||||
|
||||
step "6 — Create KeyCape bootstrap secrets (requires scoped factor delivery)"
|
||||
|
||||
if [[ "$(state_get_nested keycape)" == "true" ]]; then
|
||||
ok "keycape secrets already applied — skipping"
|
||||
else
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
log "applying keycape secrets..."
|
||||
(cd "$K8S_DIR/keycape" && bash create-secrets.sh "$SECRETS_DIR")
|
||||
state_set_nested "keycape" "true"
|
||||
ok "keycape secrets applied"
|
||||
else
|
||||
echo " [dry-run] would verify RPF-WP-0040 delivery and create first-bootstrap keycape-config"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 7: Final verification ────────────────────────────────────────────────
|
||||
|
||||
step "7 — Final verification (all components)"
|
||||
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
(cd "$SCRIPT_DIR" && bash creds-verify.sh)
|
||||
ok "expected Secret names present; native service and recovery acceptance remain separate"
|
||||
else
|
||||
echo " [dry-run] would run: bash creds-verify.sh"
|
||||
fi
|
||||
|
||||
# ── Phase 7b: OpenBao init/unseal (sops-held-automation, optional) ───────────
|
||||
|
||||
step "7b — OpenBao init/unseal (sops-held-automation, optional)"
|
||||
|
||||
# NET-WP-0020 T2: greenfield-rebuild hook. Runs only when the openbao
|
||||
# namespace exists AND the console has selected sops-held-automation
|
||||
# (the helper enforces that gate itself and refuses attended-ceremony /
|
||||
# auto-unseal-transit). Skipped silently on clusters without OpenBao.
|
||||
if kubectl get namespace openbao &>/dev/null; then
|
||||
if [[ "$(state_get openbao_post_unseal_verified)" == "true" ]]; then
|
||||
ok "OpenBao already verified — skipping"
|
||||
elif [[ "$DRY_RUN" == false ]]; then
|
||||
if (cd "$SCRIPT_DIR" && bash openbao-init-unseal.sh "$SECRETS_DIR"); then
|
||||
state_set "openbao_initialized" "true"
|
||||
state_set "openbao_post_unseal_verified" "true"
|
||||
ok "OpenBao initialized/unsealed and verified"
|
||||
|
||||
# New init material must reach age custody before cleanup.
|
||||
if [[ -d "$SECRETS_DIR/openbao" ]]; then
|
||||
log "encrypting OpenBao init material → secrets.enc/ ..."
|
||||
(cd "$SCRIPT_DIR" && bash encrypt-secrets.sh \
|
||||
"$SECRETS_DIR" "$AGE_PUBKEY" --no-shred)
|
||||
cd "$REPO_ROOT"
|
||||
git add sso-mfa/bootstrap/secrets.enc/ \
|
||||
sso-mfa/bootstrap/creds-state.yaml
|
||||
git diff --cached --quiet || git commit -m \
|
||||
"chore(creds): encrypted OpenBao init material [agent]"
|
||||
fi
|
||||
else
|
||||
die "OpenBao init/unseal not verified; retain all private custody and reconcile through the owner before cleanup or retry"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would run: openbao-init-unseal.sh $SECRETS_DIR"
|
||||
fi
|
||||
else
|
||||
ok "no openbao namespace in this cluster — skipping"
|
||||
fi
|
||||
|
||||
# ── Phase 8: Ops bundle ────────────────────────────────────────────────────────
|
||||
|
||||
step "8 — Create ops bundle (age-encrypted snapshot)"
|
||||
|
||||
BUNDLE_PATH="$REPO_ROOT/ops-bundle-$(date +%Y%m%dT%H%M%S)-$(python3 -c 'import uuid; print(uuid.uuid4().hex)').tar.age"
|
||||
|
||||
# Always snapshot the current service files after apply. An old phase boolean
|
||||
# must not cause a newly generated/rotated file to be omitted from recovery.
|
||||
[[ -d "$SECRETS_DIR" ]] || die "service files absent; cannot create recovery bundle"
|
||||
(cd "$SCRIPT_DIR" && bash pack-bundle.sh "$SECRETS_DIR" "$AGE_PUBKEY" "$BUNDLE_PATH")
|
||||
BUNDLE_DIGEST=$(sha256sum "$BUNDLE_PATH" | cut -d ' ' -f 1)
|
||||
state_set "ops_bundle_created" "true"
|
||||
state_set "ops_bundle_location" "\"$BUNDLE_PATH\""
|
||||
state_set "recovery_handoff_confirmed" "false"
|
||||
|
||||
# ── Phase 9: Encrypted recovery handoff ──────────────────────────────────────
|
||||
step "9 — Encrypted recovery handoff (operator acknowledgment required)"
|
||||
printf 'Encrypted bundle: %s\nSHA256: %s\n' "$BUNDLE_PATH" "$BUNDLE_DIGEST"
|
||||
bash "$SCRIPT_DIR/emergency-bundle.sh" --confirm
|
||||
state_set "recovery_handoff_confirmed" "true"
|
||||
state_set "recovery_handoff_bundle_sha256" "\"$BUNDLE_DIGEST\""
|
||||
state_set "recovery_handoff_confirmed_at" "\"$(date -Iseconds)\""
|
||||
# Historical emergency_bundle_delivered fields are preserved, never reinterpreted.
|
||||
|
||||
# ── Phase 10: Cleanup + finalise ──────────────────────────────────────────────
|
||||
|
||||
step "10 — Cleanup and finalise"
|
||||
|
||||
if [[ "$DRY_RUN" == false ]]; then
|
||||
if [[ -d "$SECRETS_DIR" ]]; then
|
||||
log "shredding plaintext secrets..."
|
||||
find "$SECRETS_DIR" -type f -exec shred -u {} \;
|
||||
rm -rf "$SECRETS_DIR"
|
||||
ok "plaintext secrets shredded"
|
||||
fi
|
||||
|
||||
state_set "bootstrap_complete" "true"
|
||||
|
||||
# Commit final state
|
||||
cd "$REPO_ROOT"
|
||||
git add sso-mfa/bootstrap/creds-state.yaml
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore(creds): bootstrap complete [agent NK-WP-0005]"
|
||||
ok "final state committed"
|
||||
fi
|
||||
else
|
||||
echo " [dry-run] would shred secrets/ and set bootstrap_complete: true"
|
||||
fi
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ NET-KINGDOM CREDENTIAL BOOTSTRAP COMPLETE ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo " All service secrets have been generated, encrypted, committed,"
|
||||
echo " and injected into the cluster. The emergency bundle has been"
|
||||
echo " delivered to you for storage in your personal password manager."
|
||||
echo ""
|
||||
echo " Run 'make creds-agent-status' to review the final state."
|
||||
echo ""
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
#!/usr/bin/env bash
|
||||
# creds-rotate.sh — guided or non-interactive rotation for a single net-kingdom credential.
|
||||
#
|
||||
# Usage:
|
||||
# SECRET=<name> bash sso-mfa/bootstrap/creds-rotate.sh [secrets-dir]
|
||||
# make creds-rotate SECRET=<name>
|
||||
#
|
||||
# # Agent / non-interactive mode (NK-WP-0005):
|
||||
# SECRET=<name> bash sso-mfa/bootstrap/creds-rotate.sh --non-interactive [secrets-dir]
|
||||
#
|
||||
# The script:
|
||||
# 1. Validates the secret name
|
||||
# 2. Prints rotation impact and required coordination steps
|
||||
# 3. Generates a new value (same entropy as original)
|
||||
# 4. Guides through the atomic update sequence (interactive) or runs it
|
||||
# automatically (--non-interactive)
|
||||
# 5. After completion, updates creds-state.yaml
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
STATE_FILE="$SCRIPT_DIR/creds-state.yaml"
|
||||
K8S_DIR="$REPO_ROOT/sso-mfa/k8s"
|
||||
|
||||
NON_INTERACTIVE=false
|
||||
POSITIONAL_ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--non-interactive" ]]; then
|
||||
NON_INTERACTIVE=true
|
||||
elif [[ "$arg" == --* ]]; then
|
||||
echo "ERROR: unknown rotation option." >&2
|
||||
exit 1
|
||||
else
|
||||
POSITIONAL_ARGS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#POSITIONAL_ARGS[@]} > 1 )); then
|
||||
echo "ERROR: expected one private secrets directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
SECRETS_DIR="$(realpath -sm -- "${POSITIONAL_ARGS[0]:-$SCRIPT_DIR/secrets}")"
|
||||
SECRET="${SECRET:-}"
|
||||
|
||||
rnd_hex() { openssl rand -hex "$1"; }
|
||||
rnd_b64() { openssl rand -base64 "$1" | tr -d '\n/+=' | head -c "$2"; }
|
||||
|
||||
# Suppress all child diagnostics: service tools may echo credentials on failure.
|
||||
quiet() {
|
||||
if ! "$@" >/dev/null 2>&1; then
|
||||
echo "ERROR: rotation step failed; inspect service state through approved metadata-only tooling." >&2
|
||||
echo "The private local file may contain the candidate value; rotation is not marked complete." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_value() {
|
||||
local component="$1" format="$2"
|
||||
ENV_FILE="$SECRETS_DIR/$component/secrets.env"
|
||||
python3 "$SCRIPT_DIR/rotation-env.py" check "$SECRETS_DIR" "$component" "$SECRET"
|
||||
if [[ "$format" == hex ]]; then NEW_VAL="$(rnd_hex 32 2>/dev/null)"
|
||||
else NEW_VAL="$(rnd_b64 32 40 2>/dev/null)"; fi
|
||||
echo "Candidate generated; values are never printed."
|
||||
}
|
||||
|
||||
store_value() {
|
||||
printf '%s' "$NEW_VAL" | python3 "$SCRIPT_DIR/rotation-env.py" update "$SECRETS_DIR" "$1" "$SECRET"
|
||||
echo "Candidate stored in the selected private service secrets.env file."
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local prompt="${1:-Continue?}"
|
||||
if [[ "$NON_INTERACTIVE" == true ]]; then
|
||||
echo " [non-interactive] auto-confirming: $prompt"
|
||||
return 0
|
||||
fi
|
||||
echo ""
|
||||
read -rp "$prompt [y/N] " ans
|
||||
[[ "${ans,,}" == "y" ]]
|
||||
}
|
||||
|
||||
header() {
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════"
|
||||
echo " Rotating: $SECRET$([ "$NON_INTERACTIVE" = true ] && echo " (non-interactive)" || true)"
|
||||
echo "════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
update_last_rotated() {
|
||||
local key="$1"
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
# Update or append last_rotated_<key> in creds-state.yaml
|
||||
local ts
|
||||
ts="$(date -Iseconds)"
|
||||
if grep -qE "^last_rotated_${key}:" "$STATE_FILE"; then
|
||||
sed -i "s|^last_rotated_${key}: .*|last_rotated_${key}: \"${ts}\"|" "$STATE_FILE"
|
||||
else
|
||||
echo "last_rotated_${key}: \"${ts}\"" >> "$STATE_FILE"
|
||||
fi
|
||||
echo " [state] last_rotated_${key} → ${ts}"
|
||||
fi
|
||||
}
|
||||
|
||||
post_rotation_reminder() {
|
||||
echo ""
|
||||
echo "Complete native authentication/consumer verification and custody handoff before removing local plaintext."
|
||||
if [[ "$NON_INTERACTIVE" == false ]]; then
|
||||
echo "Post-rotation checklist:"
|
||||
echo " - Transfer the candidate from the private local file into approved custody"
|
||||
echo " - Run: make creds-bundle (refresh offsite backup)"
|
||||
echo " - Run: make creds-verify (confirm cluster state)"
|
||||
else
|
||||
echo "Post-rotation (agent mode):"
|
||||
echo " - Script steps succeeded; this timestamp is not native credential validation"
|
||||
echo " Run: make creds-bundle (refresh offsite backup)"
|
||||
echo " Run: make creds-verify (confirm cluster state)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||
case "$SECRET" in
|
||||
|
||||
PI_SECRET_KEY)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: Flask/privacyIDEA app secret — rotates all active PI sessions."
|
||||
echo " All privacyIDEA users will be logged out."
|
||||
echo ""
|
||||
prepare_value privacyidea hex
|
||||
confirm "Apply rotation?" || exit 0
|
||||
echo ""
|
||||
store_value privacyidea
|
||||
echo " 1. Updating K8s Secret privacyidea-config (namespace: mfa)..."
|
||||
(cd "$K8S_DIR/privacyidea" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET")
|
||||
echo " 2. Restarting privacyIDEA pod..."
|
||||
quiet kubectl rollout restart deployment privacyidea -n mfa
|
||||
quiet kubectl rollout status deployment privacyidea -n mfa
|
||||
update_last_rotated "PI_SECRET_KEY"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
||||
PI_PEPPER)
|
||||
header
|
||||
echo ""
|
||||
echo " ⚠ WARNING: PI_PEPPER CANNOT BE ROTATED without re-hashing all privacyIDEA"
|
||||
echo " user passwords. This is a major destructive operation."
|
||||
echo ""
|
||||
echo " Treat PI_PEPPER as permanent. If it is compromised:"
|
||||
echo " 1. Rotate all user credentials in privacyIDEA"
|
||||
echo " 2. Re-enroll all TOTP tokens"
|
||||
echo " 3. Contact affected users"
|
||||
echo ""
|
||||
echo " This script will NOT automate PI_PEPPER rotation."
|
||||
exit 1
|
||||
;;
|
||||
|
||||
PI_DB_PASSWORD)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: privacyIDEA database password — coordinated updates are required in:"
|
||||
echo " 1. PostgreSQL (CNPG) — ALTER USER privacyidea WITH PASSWORD '...';"
|
||||
echo " 2. CNPG application Secret and privacyidea-config (PI_SQLALCHEMY_DATABASE_URI)"
|
||||
echo " These are separate steps; preserve the candidate and reconcile any partial failure."
|
||||
echo ""
|
||||
prepare_value privacyidea b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
echo ""
|
||||
echo " 1. Locating PostgreSQL before changing the private file..."
|
||||
if ! PG_POD=$(command kubectl get pod -n databases -l postgresql=net-kingdom-pg -o name 2>/dev/null); then
|
||||
echo "ERROR: PostgreSQL discovery failed; rotation stopped." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Ambiguous discovery is not permission to change an arbitrary database pod.
|
||||
if [[ ! "$PG_POD" =~ ^pod/[a-z0-9][a-z0-9.-]*$ ]]; then
|
||||
echo "ERROR: expected exactly one PostgreSQL pod; rotation stopped before mutation." >&2
|
||||
exit 1
|
||||
fi
|
||||
store_value privacyidea
|
||||
printf "ALTER USER privacyidea WITH PASSWORD '%s';\n" "$NEW_VAL" | \
|
||||
quiet kubectl exec -i -n databases "$PG_POD" -- psql -U postgres -v ON_ERROR_STOP=1
|
||||
echo " PostgreSQL password update command succeeded."
|
||||
echo ""
|
||||
echo " 2. Updating PostgreSQL application credential Secret and privacyidea-config..."
|
||||
(cd "$K8S_DIR/postgresql" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET")
|
||||
(cd "$K8S_DIR/privacyidea" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET")
|
||||
echo ""
|
||||
echo " 3. Restarting privacyIDEA pod..."
|
||||
quiet kubectl rollout restart deployment privacyidea -n mfa
|
||||
quiet kubectl rollout status deployment privacyidea -n mfa
|
||||
update_last_rotated "PI_DB_PASSWORD"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
||||
LLDAP_JWT_SECRET)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: LLDAP JWT signing key — all LLDAP sessions will be invalidated."
|
||||
echo ""
|
||||
prepare_value lldap hex
|
||||
confirm "Apply rotation?" || exit 0
|
||||
store_value lldap
|
||||
echo " Updating K8s Secret lldap-secrets (namespace: sso)..."
|
||||
(cd "$K8S_DIR/lldap" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET")
|
||||
echo " Restarting LLDAP pod..."
|
||||
quiet kubectl rollout restart deployment lldap -n sso
|
||||
quiet kubectl rollout status deployment lldap -n sso
|
||||
update_last_rotated "LLDAP_JWT_SECRET"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
||||
LLDAP_LDAP_USER_PASS)
|
||||
echo "ERROR: coordinated LDAP/Authelia/KeyCape rotation requires an owner-reviewed procedure with rollback and native acceptance (NK-WP-0049-T02)." >&2
|
||||
echo "The legacy manual-server/full-config-replacement path is retired before any credential changes." >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
AUTHELIA_SESSION_SECRET)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: Authelia session cookie encryption key."
|
||||
echo " All active Authelia sessions will be invalidated (users re-prompted)."
|
||||
echo ""
|
||||
prepare_value authelia hex
|
||||
confirm "Apply rotation?" || exit 0
|
||||
store_value authelia
|
||||
(cd "$K8S_DIR/authelia" && quiet bash create-secrets.sh "$SECRETS_DIR" --rotate-key "$SECRET")
|
||||
quiet kubectl rollout restart deployment authelia -n sso
|
||||
quiet kubectl rollout status deployment authelia -n sso
|
||||
update_last_rotated "AUTHELIA_SESSION_SECRET"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET)
|
||||
echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2
|
||||
echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
KEYCAPE_RSA_KEY)
|
||||
echo "ERROR: legacy KeyCape replacement rotation is retired; no credentials were changed." >&2
|
||||
echo "Use an owner-reviewed configuration-preserving rotation with rollback and consumer acceptance (NK-WP-0049-T02)." >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
BREAKGLASS_PASSWORD)
|
||||
header
|
||||
echo ""
|
||||
echo "Impact: break-glass emergency access; verify the current recovery consumer after rotation."
|
||||
echo ""
|
||||
prepare_value breakglass b64
|
||||
confirm "Apply rotation?" || exit 0
|
||||
store_value breakglass
|
||||
echo " Updating break-glass K8s Secret (namespace: sso)..."
|
||||
BG_SECRET="break-glass"
|
||||
if ! command kubectl create secret generic "$BG_SECRET" \
|
||||
--namespace=sso \
|
||||
--from-env-file="$ENV_FILE" \
|
||||
--dry-run=client -o yaml 2>/dev/null | \
|
||||
command kubectl apply --server-side --field-manager=netkingdom-rotation -f - >/dev/null 2>&1; then
|
||||
echo "ERROR: break-glass Secret update failed; local candidate retained, rotation not marked complete." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
if [[ "$NON_INTERACTIVE" == false ]]; then
|
||||
echo " Update your password manager: net-kingdom/Break-glass/break-glass"
|
||||
fi
|
||||
update_last_rotated "BREAKGLASS_PASSWORD"
|
||||
post_rotation_reminder
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "ERROR: unknown secret selector."
|
||||
echo ""
|
||||
echo "Known secrets:"
|
||||
echo " PI_SECRET_KEY Flask/PI app secret (session rotation)"
|
||||
echo " PI_PEPPER Password hashing pepper (PERMANENT — read help)"
|
||||
echo " PI_DB_PASSWORD privacyIDEA database password"
|
||||
echo " LLDAP_JWT_SECRET LLDAP JWT signing key"
|
||||
echo " LLDAP_LDAP_USER_PASS LLDAP admin + LDAP bind password (3-way coordinated)"
|
||||
echo " AUTHELIA_SESSION_SECRET Authelia session cookie key"
|
||||
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET Authelia↔KeyCape OIDC client secret"
|
||||
echo " KEYCAPE_RSA_KEY KeyCape JWT signing key (causes auth outage)"
|
||||
echo " BREAKGLASS_PASSWORD Break-glass emergency password"
|
||||
echo ""
|
||||
echo "Usage: make creds-rotate SECRET=<name>"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
esac
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env bash
|
||||
# creds-status.sh — print a human-readable credential state table.
|
||||
#
|
||||
# Usage:
|
||||
# bash sso-mfa/bootstrap/creds-status.sh [state-file] [--v2]
|
||||
# make creds-status
|
||||
# make creds-agent-status
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
STATE_FILE="$SCRIPT_DIR/creds-state.yaml"
|
||||
[[ "${1:-}" == "--v2" || $# -eq 0 ]] || STATE_FILE="$1"
|
||||
|
||||
# --v2 flag forces v2 display regardless of schema_version field
|
||||
FORCE_V2=false
|
||||
for arg in "$@"; do [[ "$arg" == "--v2" ]] && FORCE_V2=true; done
|
||||
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
echo "ERROR: creds-state.yaml not found: $STATE_FILE" >&2
|
||||
echo " This file is created at repo init — check your working directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Simple key extractors (no yaml lib dependency)
|
||||
top_val() { grep -E "^$1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed 's/ *#.*//' | tr -d '"'; }
|
||||
nested_val() { grep -E "^ $1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed 's/ *#.*//' | tr -d '"'; }
|
||||
|
||||
status_icon() {
|
||||
case "$1" in
|
||||
true) echo "✔" ;;
|
||||
false) echo "✗" ;;
|
||||
null) echo "—" ;;
|
||||
*) echo "?" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
SCHEMA_VER="$(top_val schema_version)"
|
||||
|
||||
if [[ "$SCHEMA_VER" == "2" || "$FORCE_V2" == "true" ]]; then
|
||||
# ── Schema v2: agent-driven model (NK-WP-0005) ────────────────────────────
|
||||
echo "=== net-kingdom Credential State (v2 — agent mode) ==="
|
||||
echo ""
|
||||
|
||||
age_key="$(top_val age_key_present)"
|
||||
secrets_gen="$(top_val secrets_generated)"
|
||||
ops_bundle="$(top_val ops_bundle_created)"
|
||||
ops_loc="$(top_val ops_bundle_location)"
|
||||
emerg_delivered="$(top_val emergency_bundle_delivered)"
|
||||
emerg_at="$(top_val emergency_bundle_delivered_at)"
|
||||
bootstrap_complete="$(top_val bootstrap_complete)"
|
||||
|
||||
printf " %-32s %s\n" "historical age key present:" "$(status_icon "$age_key")"
|
||||
printf " %-32s %s\n" "secrets generated:" "$(status_icon "$secrets_gen")"
|
||||
printf " %-32s %s %s\n" "ops bundle created:" \
|
||||
"$(status_icon "$ops_bundle")" \
|
||||
"$([ "$ops_bundle" = "true" ] && echo "${ops_loc:-}" || true)"
|
||||
printf " %-32s %s %s\n" "historical emergency delivery:" \
|
||||
"$(status_icon "$emerg_delivered")" \
|
||||
"$([ "$emerg_delivered" = "true" ] && echo "at ${emerg_at:-<unknown>}" || echo "(pending human confirmation)")"
|
||||
echo ""
|
||||
|
||||
recovery="$(top_val recovery_handoff_confirmed || true)"
|
||||
digest="$(top_val recovery_handoff_bundle_sha256 || true)"
|
||||
printf " %-32s %s\n" "current recovery acknowledgment:" "$(status_icon "$recovery")"
|
||||
printf " %-32s %s\n" "acknowledged bundle SHA256:" "${digest:-not recorded}"
|
||||
echo " Acknowledgment does not establish native recovery readiness."
|
||||
echo ""
|
||||
echo " Secrets applied:"
|
||||
for component in postgres lldap authelia privacyidea keycape; do
|
||||
val="$(nested_val "$component")"
|
||||
printf " %-28s %s\n" "$component" "$(status_icon "$val")"
|
||||
done
|
||||
echo ""
|
||||
|
||||
enckey="$(top_val enckey_bootstrapped)"
|
||||
pi_admin="$(top_val pi_admin_created)"
|
||||
|
||||
printf " %-32s %s\n" "enckey bootstrapped:" "$(status_icon "$enckey")"
|
||||
printf " %-32s %s\n" "pi-admin created:" "$(status_icon "$pi_admin")"
|
||||
echo ""
|
||||
|
||||
printf " %-32s %s\n" "bootstrap complete:" "$(status_icon "$bootstrap_complete")"
|
||||
echo ""
|
||||
echo "Run 'make creds-verify' to refresh secrets_applied state from the live cluster."
|
||||
echo "Run 'make creds-agent-init' to resume bootstrap if not complete."
|
||||
|
||||
else
|
||||
# ── Schema v1: human-as-operator model (NK-WP-0004, legacy) ──────────────
|
||||
echo "=== net-kingdom Credential State (v1 — human mode) ==="
|
||||
echo ""
|
||||
|
||||
generated_at="$(top_val generated_at)"
|
||||
bundle_at="$(top_val bundle_at)"
|
||||
keepass_confirmed="$(top_val keepass_confirmed)"
|
||||
|
||||
printf " %-30s %s\n" "Generated at:" "${generated_at:-—}"
|
||||
printf " %-30s %s\n" "Bundle at:" "${bundle_at:-—}"
|
||||
printf " %-30s %s %s\n" "KeePassXC confirmed:" \
|
||||
"$(status_icon "$keepass_confirmed")" \
|
||||
"$([ "$keepass_confirmed" = "false" ] && echo "(set keepass_confirmed: true manually)" || true)"
|
||||
echo ""
|
||||
|
||||
echo " Secrets applied:"
|
||||
for component in postgres lldap authelia privacyidea keycape; do
|
||||
val="$(nested_val "$component")"
|
||||
note=""
|
||||
[[ "$component" == "keycape" && "$val" == "false" ]] && \
|
||||
note=" (requires RPF-WP-0040 scoped factor delivery)"
|
||||
printf " %-28s %s%s\n" "$component" "$(status_icon "$val")" "$note"
|
||||
done
|
||||
echo ""
|
||||
|
||||
enckey="$(top_val enckey_bootstrapped)"
|
||||
pi_admin="$(top_val pi_admin_created)"
|
||||
|
||||
printf " %-30s %s%s\n" "enckey bootstrapped:" \
|
||||
"$(status_icon "$enckey")" \
|
||||
"$([ "$enckey" = "false" ] && echo " ← TIME-SENSITIVE once pod is live" || true)"
|
||||
printf " %-30s %s\n" "pi-admin created:" "$(status_icon "$pi_admin")"
|
||||
|
||||
echo ""
|
||||
echo "Run 'make creds-verify' to refresh state from the live cluster."
|
||||
fi
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
#!/usr/bin/env bash
|
||||
# creds-verify.sh — observe Secret presence only; no credential/native acceptance.
|
||||
#
|
||||
# Usage:
|
||||
# bash sso-mfa/bootstrap/creds-verify.sh
|
||||
# make creds-verify
|
||||
#
|
||||
# Checks the following K8s secrets:
|
||||
# databases/net-kingdom-pg-privacyidea-app → secrets_applied.postgres
|
||||
# sso/lldap-secrets → secrets_applied.lldap
|
||||
# sso/authelia-secrets → secrets_applied.authelia
|
||||
# mfa/privacyidea-config → secrets_applied.privacyidea
|
||||
# sso/keycape-config → secrets_applied.keycape
|
||||
# mfa/privacyidea-enckey → enckey_bootstrapped
|
||||
# sso/keycape-factor-read → current owner-managed factor delivery
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
STATE_FILE="$SCRIPT_DIR/creds-state.yaml"
|
||||
|
||||
if ! kubectl cluster-info &>/dev/null; then
|
||||
echo "ERROR: Cannot reach the Kubernetes cluster. Verify KUBECONFIG." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Check helper ──────────────────────────────────────────────────────────────
|
||||
secret_exists() {
|
||||
local ns="$1" name="$2"
|
||||
kubectl get secret "$name" --namespace="$ns" --ignore-not-found -o name 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
# Presence cannot establish application, issuance, effective MFA or recovery.
|
||||
# Historical applied/verification state is not rewritten by this diagnostic.
|
||||
update_state_top() { :; }
|
||||
update_state_nested() { :; }
|
||||
|
||||
# ── Results table ─────────────────────────────────────────────────────────────
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
check() {
|
||||
local label="$1" ns="$2" secret="$3" state_fn="$4" state_key="$5"
|
||||
if secret_exists "$ns" "$secret"; then
|
||||
printf " %-40s ✔ exists\n" "$label"
|
||||
"$state_fn" "$state_key" "true"
|
||||
((pass++)) || true
|
||||
else
|
||||
printf " %-40s ✗ missing (ns: %s, secret: %s)\n" "$label" "$ns" "$secret"
|
||||
"$state_fn" "$state_key" "false"
|
||||
((fail++)) || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== creds-verify — net-kingdom SSO/MFA secrets ==="
|
||||
echo ""
|
||||
|
||||
check "postgres (net-kingdom-pg-privacyidea-app)" \
|
||||
databases net-kingdom-pg-privacyidea-app \
|
||||
update_state_nested postgres
|
||||
|
||||
check "lldap (lldap-secrets)" \
|
||||
sso lldap-secrets \
|
||||
update_state_nested lldap
|
||||
|
||||
check "authelia (authelia-secrets)" \
|
||||
sso authelia-secrets \
|
||||
update_state_nested authelia
|
||||
|
||||
check "privacyidea (privacyidea-config)" \
|
||||
mfa privacyidea-config \
|
||||
update_state_nested privacyidea
|
||||
|
||||
check "keycape (keycape-config)" \
|
||||
sso keycape-config \
|
||||
update_state_nested keycape
|
||||
|
||||
echo ""
|
||||
|
||||
check "enckey (privacyidea-enckey)" \
|
||||
mfa privacyidea-enckey \
|
||||
update_state_top enckey_bootstrapped
|
||||
|
||||
check "scoped factor reader (keycape-factor-read)" \
|
||||
sso keycape-factor-read \
|
||||
update_state_top factor_delivery_present
|
||||
|
||||
echo ""
|
||||
echo "Results: $pass present, $fail missing"
|
||||
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
echo "Historical state preserved; presence is not native consumer or recovery acceptance."
|
||||
fi
|
||||
|
||||
[[ "$fail" -eq 0 ]]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# Explicit attended custody input only. Publish plaintext only after all files
|
||||
# decrypt successfully into a private staging directory; never emit values.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; }
|
||||
OUTPUT_DIR="${1:-./secrets}"
|
||||
AGE_KEY="${2:-${NETKINGDOM_CUSTODY_AGE_KEY_FILE:-}}"
|
||||
[[ $# -le 2 ]] || fail "unsupported decrypt argument"
|
||||
[[ -n "$AGE_KEY" && -f "$AGE_KEY" ]] || fail "explicit temporary custody key required"
|
||||
# Validate an owned, private, non-linked age identity before passing it to age.
|
||||
# No HOME/plugin/SSH identity discovery is admitted by this recovery path.
|
||||
python3 - "$AGE_KEY" 2>/dev/null <<'PYKEY' || fail "custody key must be an owned private regular age identity"
|
||||
import os, pathlib, stat, sys
|
||||
p = pathlib.Path(os.path.abspath(sys.argv[1]))
|
||||
if any(x.is_symlink() for x in (p, *p.parents)): raise ValueError()
|
||||
fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077 or st.st_nlink != 1 or st.st_size > 16384:
|
||||
raise ValueError()
|
||||
data = os.read(fd, 16385).decode('ascii')
|
||||
values = [x for x in data.splitlines() if x and not x.startswith('#')]
|
||||
if len(values) != 1 or not values[0].startswith('AGE-SECRET-KEY-1'): raise ValueError()
|
||||
finally: os.close(fd)
|
||||
PYKEY
|
||||
|
||||
ENC_DIR="$(dirname "$OUTPUT_DIR")/secrets.enc"
|
||||
[[ -d "$ENC_DIR" && ! -L "$ENC_DIR" ]] || fail "encrypted source directory unavailable"
|
||||
[[ ! -e "$OUTPUT_DIR" && ! -L "$OUTPUT_DIR" ]] || fail "output already exists"
|
||||
[[ -d "$(dirname "$OUTPUT_DIR")" ]] || fail "output parent unavailable"
|
||||
|
||||
STAGING=$(mktemp -d "$(dirname "$OUTPUT_DIR")/.netkingdom-decrypt.XXXXXXXX")
|
||||
cleanup() {
|
||||
if [[ -d "$STAGING" ]]; then
|
||||
find "$STAGING" -type f -exec shred -u {} \; 2>/dev/null || true
|
||||
rm -rf -- "$STAGING"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
count=0
|
||||
for component_dir in "$ENC_DIR"/*; do
|
||||
[[ -e "$component_dir" || -L "$component_dir" ]] || continue
|
||||
[[ -d "$component_dir" && ! -L "$component_dir" ]] || fail "unexpected encrypted component"
|
||||
component=$(basename "$component_dir")
|
||||
mkdir "$STAGING/$component"
|
||||
for file in "$component_dir"/*.age; do
|
||||
[[ -e "$file" || -L "$file" ]] || continue
|
||||
[[ -f "$file" && ! -L "$file" ]] || fail "unexpected encrypted input"
|
||||
name=$(basename "${file%.age}")
|
||||
age -d -i "$AGE_KEY" -o "$STAGING/$component/$name" "$file" 2>/dev/null || fail "decryption failed; partial plaintext removed"
|
||||
count=$((count + 1))
|
||||
done
|
||||
done
|
||||
[[ "$count" -gt 0 ]] || fail "no encrypted files found"
|
||||
# Never replace an output created by another process during decryption.
|
||||
mv -Tn -- "$STAGING" "$OUTPUT_DIR" || fail "could not publish private output"
|
||||
[[ ! -d "$STAGING" ]] || fail "output appeared during decryption"
|
||||
printf '%s file(s) decrypted to private output; remove plaintext after the approved operation.\n' "$count"
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env bash
|
||||
# Compatibility entry point: metadata-only recovery guide; never export roots.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
CONFIRM=false
|
||||
case "${1:-}" in
|
||||
""|--reprint|--help) [[ $# -le 1 ]] || exit 2 ;;
|
||||
--confirm) [[ $# -eq 1 ]] || exit 2; CONFIRM=true ;;
|
||||
*) printf '%s\n' 'ERROR: secret-export arguments are retired; see docs/security-bootstrap-age-custody.md.' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [[ "$CONFIRM" == true && ( ! -t 0 || ! -t 1 ) ]]; then
|
||||
printf '%s\n' 'ERROR: recovery acknowledgment requires an attended terminal.' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cat <<'GUIDE'
|
||||
NetKingdom recovery guide (non-secret)
|
||||
|
||||
The private age key stays in the operator password safe or offline custody.
|
||||
This command never reads, generates or displays that key or any password.
|
||||
The encrypted ops bundle holds service recovery material; it does not hold
|
||||
its own decryption key. Preserve an off-host copy and keep the key separately.
|
||||
|
||||
Before acknowledging a newly created bundle:
|
||||
1. Store the exact encrypted bundle off-host.
|
||||
2. In the approved isolated recovery workspace, use the custody unlock helper
|
||||
to verify that the independently held key can decrypt that bundle.
|
||||
3. Confirm the needed service recovery files are present without printing them.
|
||||
4. Remove decrypted drill files and retain only the non-secret drill receipt.
|
||||
|
||||
For an actual incident, use the scoped owner recovery procedure. An old
|
||||
bootstrap snapshot may contain superseded passwords. Guide output or a human
|
||||
acknowledgment is not a native restore test or evidence of current credentials.
|
||||
See docs/security-bootstrap-age-custody.md and docs/security-bootstrap-king-credential-kit.md.
|
||||
GUIDE
|
||||
|
||||
if [[ "$CONFIRM" == true ]]; then
|
||||
printf '%s' 'After completing those steps, type STORED AND VERIFIED: ' >&2
|
||||
IFS= read -r answer || exit 2
|
||||
[[ "$answer" == 'STORED AND VERIFIED' ]] || { printf '%s\n' 'ERROR: custody acknowledgment not received.' >&2; exit 2; }
|
||||
unset answer
|
||||
printf '%s\n' 'Recovery handoff acknowledged by the operator; retain the independent drill receipt.'
|
||||
fi
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env bash
|
||||
# encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age
|
||||
#
|
||||
# Usage:
|
||||
# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_PUBLIC_RECIPIENT_OR_FILE]
|
||||
#
|
||||
# SECRETS_DIR plaintext secrets directory (default: ./secrets)
|
||||
# AGE_PUBLIC_RECIPIENT_OR_FILE age public recipient or public-recipient file
|
||||
# (default: registered keys/age.pub)
|
||||
#
|
||||
# Encrypts each *.env file (and pi.enc if present) to
|
||||
# secrets.enc/<component>/<filename>.age. Prefer passing a public age recipient
|
||||
# for normal bootstrap; the private key is needed only for decrypt/apply.
|
||||
#
|
||||
# After a successful encrypt, shreds the plaintext secrets directory unless
|
||||
# --no-shred is passed.
|
||||
#
|
||||
# Run after gen-secrets.sh to store secrets safely in the repo.
|
||||
# Commit secrets.enc/ to git.
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SECRETS_DIR="${1:-./secrets}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
AGE_RECIPIENT_OR_KEY="${2:-$SCRIPT_DIR/../../keys/age.pub}"
|
||||
NO_SHRED=false
|
||||
for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done
|
||||
|
||||
if [[ ! -d "$SECRETS_DIR" ]]; then
|
||||
echo "ERROR: source secrets directory is unavailable." >&2
|
||||
echo "Run gen-secrets.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolve_recipient() {
|
||||
local source="$1"
|
||||
if [[ "$source" == age1* ]]; then
|
||||
printf '%s\n' "$source"
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "$source" ]]; then
|
||||
echo "ERROR: public recipient file is unavailable." >&2
|
||||
echo "Pass an age public recipient such as age1... or a file containing it." >&2
|
||||
return 1
|
||||
fi
|
||||
# Never derive public metadata by loading private custody material.
|
||||
if grep -q 'AGE-SECRET-KEY-' "$source"; then
|
||||
echo "ERROR: encryption requires a public recipient, never a private-key file." >&2
|
||||
return 1
|
||||
fi
|
||||
local recipient
|
||||
recipient=$(grep -m1 '^age1' "$source" || true)
|
||||
if [[ -n "$recipient" ]]; then
|
||||
printf '%s\n' "$recipient"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: public recipient is missing from the supplied file." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
PUBKEY=$(resolve_recipient "$AGE_RECIPIENT_OR_KEY")
|
||||
printf '' | age -r "$PUBKEY" >/dev/null 2>&1 || { echo "ERROR: invalid public recipient." >&2; exit 1; }
|
||||
|
||||
ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc"
|
||||
mkdir -p "$ENC_DIR"
|
||||
|
||||
echo "Encrypting secrets → $ENC_DIR/"
|
||||
echo "Recipient: $PUBKEY"
|
||||
echo ""
|
||||
|
||||
count=0
|
||||
for component_dir in "$SECRETS_DIR"/*/; do
|
||||
component=$(basename "$component_dir")
|
||||
mkdir -p "$ENC_DIR/$component"
|
||||
for f in "$component_dir"*; do
|
||||
[[ -f "$f" ]] || continue
|
||||
fname=$(basename "$f")
|
||||
out="$ENC_DIR/$component/$fname.age"
|
||||
age -r "$PUBKEY" -o "$out" "$f"
|
||||
echo " encrypted: $component/$fname → secrets.enc/$component/$fname.age"
|
||||
count=$((count + 1))
|
||||
done
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "$count file(s) encrypted to $ENC_DIR/"
|
||||
echo ""
|
||||
|
||||
if [[ "$NO_SHRED" == false ]]; then
|
||||
echo "Shredding plaintext secrets..."
|
||||
find "$SECRETS_DIR" -type f -exec shred -u {} \;
|
||||
rm -rf "$SECRETS_DIR"
|
||||
echo "Done. Plaintext secrets shredded."
|
||||
else
|
||||
echo "(--no-shred: plaintext kept at $SECRETS_DIR)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Next: commit secrets.enc/ to git."
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env bash
|
||||
# gen-secrets.sh — generate all bootstrap secrets for the net-kingdom SSO/MFA platform
|
||||
#
|
||||
# Usage:
|
||||
# ./gen-secrets.sh [OUTPUT_DIR]
|
||||
#
|
||||
# Generates all pre-cluster secrets and writes them to OUTPUT_DIR (default: ./secrets).
|
||||
# Output files are structured by component to mirror the KeePassXC entry layout.
|
||||
#
|
||||
# WARNING: The secrets/ directory must NEVER be committed to git.
|
||||
# After entering values into KeePassXC, shred the generated files:
|
||||
# find secrets/ -type f -exec shred -u {} \;
|
||||
#
|
||||
# PI_ENCFILE is NOT generated here — it must be produced inside the privacyIDEA
|
||||
# container after deployment:
|
||||
# Follow ../k8s/privacyidea/KEY-RECOVERY.md; do not generate missing recovery keys.
|
||||
# Extract the resulting file, store it in KeePassXC as a binary attachment on the
|
||||
# privacyIDEA/PI_ENCFILE entry, then create the k8s secret from it.
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
OUT_DIR="${1:-./secrets}"
|
||||
|
||||
if [[ -e "$OUT_DIR" || -L "$OUT_DIR" ]]; then
|
||||
echo "ERROR: $OUT_DIR already exists. Preserve it for reconciliation or choose a new path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Helpers
|
||||
rnd_hex() { openssl rand -hex "$1"; }
|
||||
rnd_b64() { openssl rand -base64 "$1" | tr -d '\n/+=' | head -c "$2"; }
|
||||
|
||||
mkdir -p \
|
||||
"$OUT_DIR/privacyidea" \
|
||||
"$OUT_DIR/postgres" \
|
||||
"$OUT_DIR/lldap" \
|
||||
"$OUT_DIR/authelia" \
|
||||
"$OUT_DIR/keycape" \
|
||||
"$OUT_DIR/breakglass"
|
||||
|
||||
# ── privacyIDEA ────────────────────────────────────────────────────────────────
|
||||
PI_SECRET_KEY="$(rnd_hex 32)" # 64 hex chars — Flask/PI app secret
|
||||
PI_PEPPER="$(rnd_hex 16)" # 32 hex chars — password hashing pepper
|
||||
PI_DB_PASS="$(rnd_b64 32 40)" # 40 printable chars — DB password
|
||||
PI_ADMIN_PASS="$(rnd_b64 32 40)"
|
||||
|
||||
cat > "$OUT_DIR/privacyidea/secrets.env" <<EOF
|
||||
# privacyIDEA secrets — KeePassXC group: net-kingdom/privacyIDEA
|
||||
# Entry: pi-admin → username=pi-admin password=PI_ADMIN_PASSWORD
|
||||
# Entry: database → username=privacyidea password=PI_DB_PASSWORD
|
||||
# Entry: SECRET_KEY → password=PI_SECRET_KEY (no username)
|
||||
# Entry: PI_PEPPER → password=PI_PEPPER (no username)
|
||||
# Entry: PI_ENCFILE → binary attachment (generate AFTER container deploy)
|
||||
|
||||
PI_SECRET_KEY=$PI_SECRET_KEY
|
||||
PI_PEPPER=$PI_PEPPER
|
||||
PI_DB_PASSWORD=$PI_DB_PASS
|
||||
PI_ADMIN_PASSWORD=$PI_ADMIN_PASS
|
||||
|
||||
# PI_ENCFILE: generate with:
|
||||
# Follow ../k8s/privacyidea/KEY-RECOVERY.md; do not generate missing recovery keys.
|
||||
# Export existing keys with enckey-bootstrap.sh and an explicit private custody root.
|
||||
# Then store pi.enc as a binary attachment in KeePassXC → net-kingdom/privacyIDEA/PI_ENCFILE
|
||||
EOF
|
||||
|
||||
# ── PostgreSQL ─────────────────────────────────────────────────────────────────
|
||||
PG_ROOT_PASS="$(rnd_b64 32 40)"
|
||||
# privacyIDEA DB user reuses PI_DB_PASS (single source of truth)
|
||||
# Note: no keycloak DB user — Keycloak was replaced by the Authelia+LLDAP+KeyCape stack.
|
||||
|
||||
cat > "$OUT_DIR/postgres/secrets.env" <<EOF
|
||||
# PostgreSQL secrets — KeePassXC group: net-kingdom/PostgreSQL
|
||||
# Entry: postgres root → username=postgres password=PG_ROOT_PASSWORD
|
||||
# Entry: privacyidea user → username=privacyidea password=PI_DB_PASSWORD (copy from privacyIDEA entry)
|
||||
|
||||
PG_ROOT_PASSWORD=$PG_ROOT_PASS
|
||||
# PI_DB_PASSWORD is in privacyidea/secrets.env — do NOT copy here; link in KeePassXC
|
||||
EOF
|
||||
|
||||
# ── LLDAP ──────────────────────────────────────────────────────────────────────
|
||||
LLDAP_JWT_SECRET="$(rnd_hex 32)" # 64 hex chars — LLDAP JWT signing key
|
||||
LLDAP_LDAP_USER_PASS="$(rnd_b64 32 40)" # 40 printable chars — LLDAP admin + Authelia/KeyCape bind password
|
||||
|
||||
cat > "$OUT_DIR/lldap/secrets.env" <<EOF
|
||||
# LLDAP secrets — KeePassXC group: net-kingdom/LLDAP
|
||||
# Entry: jwt-secret → password=LLDAP_JWT_SECRET (no username)
|
||||
# Entry: admin → username=admin password=LLDAP_LDAP_USER_PASS
|
||||
#
|
||||
# LLDAP_LDAP_USER_PASS is also the LDAP bind password for Authelia and KeyCape.
|
||||
# It is read from this file by both authelia/create-secrets.sh and keycape/create-secrets.sh.
|
||||
|
||||
LLDAP_JWT_SECRET=$LLDAP_JWT_SECRET
|
||||
LLDAP_LDAP_USER_PASS=$LLDAP_LDAP_USER_PASS
|
||||
EOF
|
||||
|
||||
# ── Authelia ───────────────────────────────────────────────────────────────────
|
||||
AUTHELIA_JWT_SECRET="$(rnd_hex 32)" # 64 hex chars — session JWT signing
|
||||
AUTHELIA_SESSION_SECRET="$(rnd_hex 32)" # 64 hex chars — session cookie encryption
|
||||
AUTHELIA_STORAGE_ENCRYPTION_KEY="$(rnd_b64 48 64)" # 64 printable chars — SQLite encryption
|
||||
AUTHELIA_OIDC_HMAC_SECRET="$(rnd_hex 32)" # 64 hex chars — OIDC HMAC
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET="$(rnd_b64 32 40)" # 40 printable chars — Authelia→KeyCape OIDC client secret
|
||||
# OIDC issuer private key: generated by authelia/create-secrets.sh on first run,
|
||||
# or set AUTHELIA_OIDC_PRIVATE_KEY_FILE to a path below and generate with:
|
||||
# openssl genrsa -out secrets/authelia/oidc_private_key.pem 2048
|
||||
|
||||
cat > "$OUT_DIR/authelia/secrets.env" <<EOF
|
||||
# Authelia secrets — KeePassXC group: net-kingdom/Authelia
|
||||
# Entry: jwt-secret → password=AUTHELIA_JWT_SECRET (no username)
|
||||
# Entry: session-secret → password=AUTHELIA_SESSION_SECRET (no username)
|
||||
# Entry: storage-encryption-key → password=AUTHELIA_STORAGE_ENCRYPTION_KEY (no username)
|
||||
# Entry: oidc-hmac-secret → password=AUTHELIA_OIDC_HMAC_SECRET (no username)
|
||||
# Entry: keycape-client-secret → password=AUTHELIA_KEYCAPE_CLIENT_SECRET (no username)
|
||||
# Entry: oidc-private-key → binary attachment oidc_private_key.pem
|
||||
#
|
||||
# AUTHELIA_KEYCAPE_CLIENT_SECRET is the plaintext — authelia/create-secrets.sh hashes
|
||||
# it with bcrypt before storing in the K8s Secret.
|
||||
# The same plaintext goes into KeyCape's config as authelia.clientSecret.
|
||||
|
||||
AUTHELIA_JWT_SECRET=$AUTHELIA_JWT_SECRET
|
||||
AUTHELIA_SESSION_SECRET=$AUTHELIA_SESSION_SECRET
|
||||
AUTHELIA_STORAGE_ENCRYPTION_KEY=$AUTHELIA_STORAGE_ENCRYPTION_KEY
|
||||
AUTHELIA_OIDC_HMAC_SECRET=$AUTHELIA_OIDC_HMAC_SECRET
|
||||
AUTHELIA_KEYCAPE_CLIENT_SECRET=$AUTHELIA_KEYCAPE_CLIENT_SECRET
|
||||
# AUTHELIA_OIDC_PRIVATE_KEY_FILE= # leave blank — authelia/create-secrets.sh will generate
|
||||
EOF
|
||||
|
||||
# ── KeyCape ────────────────────────────────────────────────────────────────────
|
||||
# KeyCape has no generated secrets here:
|
||||
# key.pem — RSA signing key, generated by keycape/create-secrets.sh on first run
|
||||
# Factor JWT — delivered through RPF-WP-0040 ESO, never stored in this directory.
|
||||
# We create the directory and a placeholder file so operators know to check it.
|
||||
|
||||
cat > "$OUT_DIR/keycape/secrets.env" <<EOF
|
||||
# KeyCape secrets — KeePassXC group: net-kingdom/KeyCape
|
||||
# Entry: jwt-signing-key → binary attachment key.pem (generated by keycape/create-secrets.sh)
|
||||
# Factor credential: owner-managed ESO mount; no personal admin token.
|
||||
#
|
||||
# This file is a placeholder. No values to set here manually.
|
||||
# First-bootstrap create-secrets.sh generates only a missing signing key.
|
||||
EOF
|
||||
|
||||
# ── Break-glass ────────────────────────────────────────────────────────────────
|
||||
BG_PASS="$(rnd_b64 32 40)"
|
||||
|
||||
cat > "$OUT_DIR/breakglass/secrets.env" <<EOF
|
||||
# Break-glass secrets — KeePassXC group: net-kingdom/Break-glass
|
||||
# Entry: break-glass → username=break-glass password=BREAKGLASS_PASSWORD
|
||||
# Entry: recovery-otp → TOTP seed (enroll manually after Keycloak is up)
|
||||
|
||||
BREAKGLASS_PASSWORD=$BG_PASS
|
||||
EOF
|
||||
|
||||
# Values and even prefixes must stay out of normal output and shell tracing.
|
||||
printf '%s\n' 'Bootstrap service files generated with private permissions.'
|
||||
printf '%s\n' 'Encrypt to the registered public age recipient, verify off-host recovery, then remove plaintext.'
|
||||
printf '%s\n' 'Never paste these files into chat, logs, metadata or Git.'
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Lab-only adapter. Explicit lab profile, custody model, kubeconfig and
|
||||
# OPENBAO_EXPECTED_CLUSTER_UID are required; never use this on production.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
exec python3 "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/openbao-lab.py" "$@"
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Contained lab-only init/unseal. Production uses the Platform custody ceremony.
|
||||
|
||||
The local model selection is a scope check, not an authorization grant. Caller
|
||||
must supply an owner-admitted lab kubeconfig and independently observed cluster
|
||||
UID. Private init output is retained for reconciliation after any unknown write.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location('private_service_files', Path(__file__).with_name('apply-service-secrets.py'))
|
||||
private = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(private)
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def run(arguments, payload=None, allowed=(0,)):
|
||||
# Inherit only routing/executable inputs, never ambient bearer credentials or
|
||||
# shell hooks. Diagnostics and command output remain inside this process.
|
||||
env = {k: os.environ[k] for k in ('PATH', 'HOME', 'KUBECONFIG') if k in os.environ}
|
||||
result = subprocess.run(['kubectl', *arguments], input=payload, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, timeout=60, env=env)
|
||||
if result.returncode not in allowed or len(result.stdout) > 1024 * 1024:
|
||||
raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def status(namespace, pod):
|
||||
data = json.loads(run(['-n', namespace, 'exec', pod, '--', 'bao', 'status', '-format=json'], allowed=(0, 2)))
|
||||
if not isinstance(data, dict) or type(data.get('initialized')) is not bool or type(data.get('sealed')) is not bool:
|
||||
raise Refused()
|
||||
return data
|
||||
|
||||
|
||||
def init_material(raw):
|
||||
value = json.loads(raw)
|
||||
shares = value.get('unseal_keys_b64')
|
||||
if (not isinstance(shares, list) or not shares or len(shares) > 16
|
||||
or any(not isinstance(x, str) or not re.fullmatch(r'[A-Za-z0-9+/=]{16,256}', x) for x in shares)
|
||||
or len(set(shares)) != len(shares)
|
||||
or not isinstance(value.get('root_token'), str) or not value['root_token']):
|
||||
raise Refused()
|
||||
return shares
|
||||
|
||||
|
||||
def main(args):
|
||||
if args == ['--dry-run']:
|
||||
print('Plan only: validate explicit lab custody and cluster UID; retain private init material; unseal and verify. No files or cluster state inspected.')
|
||||
return
|
||||
if len(args) > 1 or (args and args[0].startswith('-')):
|
||||
raise Refused()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
metadata = Path(os.environ.get('OPENBAO_CONSOLE_METADATA', str(root / '.local/security-bootstrap.json')))
|
||||
model = json.loads(metadata.read_bytes())
|
||||
if model.get('deployment_profile') != 'lab' or model.get('openbao_unseal_custody_model') != 'sops-held-automation':
|
||||
raise Refused()
|
||||
expected = os.environ.get('OPENBAO_EXPECTED_CLUSTER_UID', '')
|
||||
if not re.fullmatch(r'[a-zA-Z0-9-]{1,128}', expected) or not os.environ.get('KUBECONFIG'):
|
||||
raise Refused()
|
||||
if os.environ.get('OPENBAO_RUN_CONFIGURE_INITIAL', '0') != '0':
|
||||
raise Refused() # Platform configuration is a separate owner operation.
|
||||
namespace = os.environ.get('OPENBAO_NAMESPACE', 'openbao')
|
||||
release = os.environ.get('OPENBAO_RELEASE', 'openbao')
|
||||
if any(not re.fullmatch(r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', x) for x in (namespace, release)):
|
||||
raise Refused()
|
||||
shares = int(os.environ.get('OPENBAO_KEY_SHARES', '3'))
|
||||
threshold = int(os.environ.get('OPENBAO_KEY_THRESHOLD', '2'))
|
||||
if not 2 <= threshold <= shares <= 16:
|
||||
raise Refused()
|
||||
# Pin the actual target before credential input or any mutation.
|
||||
obj = json.loads(run(['get', 'namespace', 'kube-system', '-o', 'json']))
|
||||
if obj.get('metadata', {}).get('uid') != expected:
|
||||
raise Refused()
|
||||
pod = release + '-0'
|
||||
target = json.loads(run(['-n', namespace, 'get', 'pod', pod, '-o', 'json']))
|
||||
pod_uid = target.get('metadata', {}).get('uid')
|
||||
if (not isinstance(pod_uid, str) or not pod_uid or target.get('metadata', {}).get('namespace') != namespace
|
||||
or target.get('metadata', {}).get('name') != pod):
|
||||
raise Refused()
|
||||
directory = args[0] if args else str(Path(__file__).parent / 'secrets')
|
||||
did_init = False
|
||||
with private.custody(directory, ()) as _:
|
||||
parent = Path(directory) / 'openbao'
|
||||
try:
|
||||
parent.mkdir(mode=0o700)
|
||||
except FileExistsError:
|
||||
pass
|
||||
with private.custody(directory, ('openbao',)) as fds:
|
||||
fd = fds['openbao']
|
||||
before = status(namespace, pod)
|
||||
if not before['initialized']:
|
||||
# Refuse prior custody material before sending init. An exclusive
|
||||
# owner window is necessary; Kubernetes exec cannot CAS pod UID.
|
||||
try:
|
||||
os.stat('init.json', dir_fd=fd, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
raise Refused()
|
||||
pinned = json.loads(run(['-n', namespace, 'get', 'pod', pod, '-o', 'json']))
|
||||
if pinned.get('metadata', {}).get('uid') != pod_uid:
|
||||
raise Refused()
|
||||
raw = run(['-n', namespace, 'exec', pod, '--', 'bao', 'operator', 'init',
|
||||
'-key-shares=' + str(shares), '-key-threshold=' + str(threshold), '-format=json'])
|
||||
# Preserve even a malformed successful response privately: the
|
||||
# remote initialization cannot be undone or blindly retried.
|
||||
output = os.open('init.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=fd)
|
||||
with os.fdopen(output, 'wb') as dest:
|
||||
dest.write(raw); dest.flush(); os.fsync(dest.fileno())
|
||||
os.fsync(fd)
|
||||
init_material(raw)
|
||||
did_init = True
|
||||
observed = status(namespace, pod)
|
||||
if observed['sealed']:
|
||||
material = init_material(private.read_private(fd, 'init.json'))
|
||||
for share in material:
|
||||
pinned = json.loads(run(['-n', namespace, 'get', 'pod', pod, '-o', 'json']))
|
||||
if pinned.get('metadata', {}).get('uid') != pod_uid:
|
||||
raise Refused()
|
||||
run(['-n', namespace, 'exec', '-i', pod, '--', 'bao', 'write', 'sys/unseal', 'key=-'],
|
||||
(share + '\n').encode())
|
||||
observed = status(namespace, pod)
|
||||
if not observed['sealed']:
|
||||
break
|
||||
if not observed['initialized'] or observed['sealed']:
|
||||
raise Refused()
|
||||
print('EVIDENCE ' + json.dumps({'custody_model': 'sops-held-automation', 'namespace': namespace,
|
||||
'pod': pod, 'cluster_uid': expected, 'pod_uid': pod_uid, 'openbao_initialized': True,
|
||||
'openbao_did_init_this_run': did_init, 'openbao_post_unseal_verified': True,
|
||||
'recovery_custody_verified': False, 'dry_run': False}, sort_keys=True))
|
||||
print('Retain init material in protected custody; encrypt and independently verify recovery. Platform configuration remains a separate owner operation.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main(sys.argv[1:])
|
||||
except (Refused, private.Refused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
||||
print('ERROR: lab init/unseal refused or failed; diagnostics suppressed. Remote outcome may be unknown. Preserve private init material and reconcile through the owner before retry.', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env bash
|
||||
# Encrypt a private service-file snapshot without a plaintext tar intermediate.
|
||||
# Usage: pack-bundle.sh SECRETS_DIR PUBLIC_AGE_RECIPIENT [NEW_OUTPUT_FILE]
|
||||
# Use the independently registered recipient; private age keys never belong in
|
||||
# the payload. Run under owner-exclusive custody maintenance (no same-UID writers).
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
fail() { printf '%s\n' 'ERROR: bundle refused or failed; no new recovery artifact published.' >&2; exit 1; }
|
||||
[[ $# -ge 2 && $# -le 3 ]] || fail
|
||||
SECRETS_DIR="${1%/}"
|
||||
RECIPIENT="$2"
|
||||
OUTPUT="${3:-ops-bundle-$(date +%Y%m%dT%H%M%S)-$RANDOM.tar.age}"
|
||||
[[ "$RECIPIENT" == age1* ]] || fail
|
||||
printf '' | age -r "$RECIPIENT" >/dev/null 2>&1 || fail
|
||||
[[ ! -e "$OUTPUT" && ! -L "$OUTPUT" ]] || fail
|
||||
PARENT="$(dirname -- "$OUTPUT")"
|
||||
[[ -d "$PARENT" ]] || fail
|
||||
STAGING=$(mktemp -d "$PARENT/.netkingdom-bundle-XXXXXXXX") || fail
|
||||
trap 'rm -rf -- "$STAGING"' EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
# Emit only member names to a private local list. Never follow links or include
|
||||
# special/hardlinked files, permissive custody inputs or age private material.
|
||||
if ! python3 - "$SECRETS_DIR" "$OUTPUT" > "$STAGING/members" 2>/dev/null <<'PY'
|
||||
import os, pathlib, stat, sys
|
||||
root = pathlib.Path(os.path.abspath(sys.argv[1]))
|
||||
output = pathlib.Path(os.path.abspath(sys.argv[2]))
|
||||
for path in (root, *root.parents, output.parent, *output.parent.parents):
|
||||
if path.is_symlink(): raise ValueError()
|
||||
if root == output or root in output.parents: raise ValueError()
|
||||
count = 0
|
||||
for path in [root, *sorted(root.rglob('*'))]:
|
||||
info = path.lstat()
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o077: raise ValueError()
|
||||
if stat.S_ISDIR(info.st_mode): continue
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size > 32 * 1024 * 1024:
|
||||
raise ValueError()
|
||||
with path.open('rb') as source:
|
||||
if b'AGE-SECRET-KEY-' in source.read(): raise ValueError()
|
||||
name = str(path.relative_to(root.parent))
|
||||
if any(ord(c) < 32 for c in name): raise ValueError()
|
||||
sys.stdout.buffer.write(name.encode() + b'\0')
|
||||
count += 1
|
||||
if not count: raise ValueError()
|
||||
PY
|
||||
then fail; fi
|
||||
# Source/root are data arguments; member names are NUL delimited and verbatim.
|
||||
if ! tar -C "$(dirname -- "$SECRETS_DIR")" --null --verbatim-files-from \
|
||||
--no-recursion -T "$STAGING/members" -cf - 2>/dev/null \
|
||||
| age -r "$RECIPIENT" -o "$STAGING/ciphertext" 2>/dev/null; then fail; fi
|
||||
python3 - "$STAGING/ciphertext" 2>/dev/null <<'PYSYNC' || fail
|
||||
import os, sys
|
||||
fd=os.open(sys.argv[1], os.O_RDONLY)
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
PYSYNC
|
||||
# Hard-link publication atomically refuses an output created concurrently.
|
||||
ln -T -- "$STAGING/ciphertext" "$OUTPUT" 2>/dev/null || fail
|
||||
printf '%s\n' 'Encrypted bundle published privately; retain it off-host and independently verify recovery before removing source custody.'
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and atomically update a private generated credential file.
|
||||
|
||||
The caller supplies the new value on stdin, never argv. This protects the
|
||||
handoff/output boundary; it does not isolate a hostile process with the same UID.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def private(info, directory=False):
|
||||
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused()
|
||||
if not directory and info.st_nlink != 1:
|
||||
raise Refused()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5 or sys.argv[1] not in ('check', 'update'):
|
||||
raise Refused()
|
||||
action, root_arg, component, key = sys.argv[1:]
|
||||
if component not in ('privacyidea', 'lldap', 'authelia', 'breakglass'):
|
||||
raise Refused()
|
||||
if not re.fullmatch(r'[A-Z][A-Z0-9_]+', key):
|
||||
raise Refused()
|
||||
root = Path(os.path.abspath(root_arg))
|
||||
# Refuse symlink ancestors rather than resolving them silently.
|
||||
for part in (root, *root.parents):
|
||||
if part.is_symlink(): raise Refused()
|
||||
root_fd = parent_fd = file_fd = None
|
||||
temporary = None
|
||||
try:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
root_fd = os.open(root, flags)
|
||||
private(os.fstat(root_fd), directory=True)
|
||||
parent_fd = os.open(component, flags, dir_fd=root_fd)
|
||||
private(os.fstat(parent_fd), directory=True)
|
||||
file_fd = os.open('secrets.env', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd)
|
||||
original = os.fstat(file_fd)
|
||||
private(original)
|
||||
raw = os.read(file_fd, 65537)
|
||||
if len(raw) > 65536: raise Refused()
|
||||
lines = raw.decode('utf-8').splitlines(keepends=True)
|
||||
matches = [i for i, line in enumerate(lines) if line.startswith(key + '=')]
|
||||
if len(matches) != 1: raise Refused()
|
||||
if action == 'check': return
|
||||
value = sys.stdin.buffer.read(257).decode('ascii').strip()
|
||||
if not re.fullmatch(r'[A-Za-z0-9]{32,128}', value): raise Refused()
|
||||
lines[matches[0]] = key + '=' + value + '\n'
|
||||
temporary = '.rotation-' + secrets.token_hex(12)
|
||||
output_fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=parent_fd)
|
||||
with os.fdopen(output_fd, 'w', encoding='utf-8') as output:
|
||||
output.writelines(lines); output.flush(); os.fsync(output.fileno())
|
||||
current = os.stat('secrets.env', dir_fd=parent_fd, follow_symlinks=False)
|
||||
if (current.st_dev, current.st_ino, current.st_mtime_ns, current.st_size) != (
|
||||
original.st_dev, original.st_ino, original.st_mtime_ns, original.st_size):
|
||||
raise Refused()
|
||||
os.replace(temporary, 'secrets.env', src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
||||
temporary = None
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
if temporary is not None and parent_fd is not None:
|
||||
os.unlink(temporary, dir_fd=parent_fd)
|
||||
for fd in (file_fd, parent_fd, root_fd):
|
||||
if fd is not None: os.close(fd)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Refused, OSError, ValueError, UnicodeError):
|
||||
print('ERROR: private credential file validation/update failed; no values are logged.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Attended custody through a sealed anonymous Linux descriptor, never a key path."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import getpass
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
SEALS=fcntl.F_SEAL_WRITE|fcntl.F_SEAL_GROW|fcntl.F_SEAL_SHRINK|fcntl.F_SEAL_SEAL
|
||||
|
||||
class Refused(Exception):pass
|
||||
|
||||
def sealed_key(material):
|
||||
if not isinstance(material,bytes) or not 1<=len(material)<=16384 or not re.fullmatch(b'AGE-SECRET-KEY-1[A-Z0-9]+\n?',material):raise Refused()
|
||||
fd=os.memfd_create('netkingdom-custody',os.MFD_CLOEXEC|os.MFD_ALLOW_SEALING)
|
||||
try:
|
||||
os.fchmod(fd,0o600)
|
||||
with os.fdopen(os.dup(fd),'wb') as out:out.write(material);out.flush()
|
||||
os.lseek(fd,0,os.SEEK_SET)
|
||||
fcntl.fcntl(fd,fcntl.F_ADD_SEALS,SEALS)
|
||||
if fcntl.fcntl(fd,fcntl.F_GET_SEALS)!=SEALS:raise Refused()
|
||||
return fd
|
||||
except BaseException:os.close(fd);raise
|
||||
|
||||
def run(material,recipient,command):
|
||||
if not re.fullmatch(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}',recipient) or not command:raise Refused()
|
||||
fd=sealed_key(material)
|
||||
try:
|
||||
result=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{fd}'],pass_fds=(fd,),stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if result.returncode or result.stdout.strip()!=recipient.encode():raise Refused()
|
||||
# The key must not compete with any ambient custody source. Children
|
||||
# receive only this exact inherited descriptor, with close-on-exec elsewhere.
|
||||
env={k:v for k,v in os.environ.items() if k not in ('NETKINGDOM_CUSTODY_AGE_KEY_FILE','NETKINGDOM_CUSTODY_AGE_KEY_FD','SOPS_AGE_KEY_FILE','SOPS_AGE_KEY','SOPS_AGE_KEY_CMD')}
|
||||
env['NETKINGDOM_CUSTODY_AGE_KEY_FD']=str(fd)
|
||||
return subprocess.run(command,env=env,pass_fds=(fd,)).returncode
|
||||
finally:os.close(fd)
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--expected-recipient',required=True);p.add_argument('command',nargs=argparse.REMAINDER);args=p.parse_args()
|
||||
command=args.command[1:] if args.command[:1]==['--'] else args.command
|
||||
try:
|
||||
if (not all(os.isatty(fd) for fd in (0,1,2)) or not command
|
||||
or not re.fullmatch(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}',args.expected_recipient)):raise Refused()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error',getpass.GetPassWarning)
|
||||
material=getpass.getpass('Paste custody age private key line (input hidden): ').encode()+b'\n'
|
||||
return run(material,args.expected_recipient,command)
|
||||
except Exception:
|
||||
print('ERROR: sealed custody input or execution refused; private diagnostics suppressed.',file=sys.stderr);return 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
#!/usr/bin/env bash
|
||||
# sops-custody-unlock.sh -- run SOPS/age operations with a temporary custody key.
|
||||
#
|
||||
# The private age key is supplied only for this invocation, validated against an
|
||||
# expected public recipient, written to a 0600 temp file, and removed on exit.
|
||||
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
EXPECTED_RECIPIENT="${EXPECTED_AGE_RECIPIENT:-}"
|
||||
SOURCE_FILE=""
|
||||
INPUT_MODE=""
|
||||
CHECK_ONLY=false
|
||||
SEALED_FD=false
|
||||
COMMAND=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
sops-custody-unlock.sh [options] [-- command ...]
|
||||
|
||||
Options:
|
||||
--expected-recipient age1... Required recipient. Defaults to keys/age.pub.
|
||||
--from-file PATH Read custody age key material from PATH.
|
||||
--from-stdin Read custody age key material from stdin.
|
||||
--prompt Prompt for one AGE-SECRET-KEY-1... line.
|
||||
--check-only Validate the supplied key and exit.
|
||||
--sealed-fd Hidden prompt, explicit command, no named key file.
|
||||
-h, --help Show this help.
|
||||
|
||||
Examples:
|
||||
# Run an inter-hub recovery drill after pasting the private key line.
|
||||
sso-mfa/bootstrap/sops-custody-unlock.sh \
|
||||
--expected-recipient age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 \
|
||||
-- make -C /home/worsch/inter-hub recovery-drill
|
||||
|
||||
# Pipe a password-manager field into the helper without printing it.
|
||||
op read 'op://Platform/NetKingdom custodian age key/private-key' \
|
||||
| sso-mfa/bootstrap/sops-custody-unlock.sh --from-stdin -- make recovery-drill
|
||||
|
||||
If no command is supplied, an interactive shell is opened with SOPS_AGE_KEY_FILE
|
||||
set. Exit that shell to remove the temporary key file.
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-recipient)
|
||||
[[ $# -ge 2 ]] || fail "--expected-recipient requires a value"
|
||||
EXPECTED_RECIPIENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--expected-recipient=*)
|
||||
EXPECTED_RECIPIENT="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--from-file)
|
||||
[[ $# -ge 2 ]] || fail "--from-file requires a path"
|
||||
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
|
||||
SOURCE_FILE="$2"
|
||||
INPUT_MODE="file"
|
||||
shift 2
|
||||
;;
|
||||
--from-file=*)
|
||||
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
|
||||
SOURCE_FILE="${1#*=}"
|
||||
INPUT_MODE="file"
|
||||
shift
|
||||
;;
|
||||
--from-stdin)
|
||||
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
|
||||
INPUT_MODE="stdin"
|
||||
shift
|
||||
;;
|
||||
--prompt)
|
||||
[[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode"
|
||||
INPUT_MODE="prompt"
|
||||
shift
|
||||
;;
|
||||
--sealed-fd)
|
||||
SEALED_FD=true
|
||||
shift
|
||||
;;
|
||||
--check-only)
|
||||
CHECK_ONLY=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
COMMAND=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
fail "unknown custody argument"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$EXPECTED_RECIPIENT" && -f "$REPO_ROOT/keys/age.pub" ]]; then
|
||||
EXPECTED_RECIPIENT="$(grep -m1 '^age1' "$REPO_ROOT/keys/age.pub" || true)"
|
||||
fi
|
||||
[[ "$EXPECTED_RECIPIENT" == age1* ]] || fail "expected age recipient not set; pass --expected-recipient age1..."
|
||||
|
||||
command -v age-keygen >/dev/null 2>&1 || fail "age-keygen not found; install age before running custody unlock"
|
||||
|
||||
if [[ -z "$INPUT_MODE" ]]; then
|
||||
if [[ -t 0 ]]; then
|
||||
INPUT_MODE="prompt"
|
||||
else
|
||||
fail "noninteractive input requires explicit --from-stdin or --from-file"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$CHECK_ONLY" == false && ${#COMMAND[@]} -eq 0 && ! -t 0 ]]; then
|
||||
fail "an interactive custody shell requires a terminal; supply an explicit command"
|
||||
fi
|
||||
|
||||
if [[ "$SEALED_FD" == true ]]; then
|
||||
[[ "$INPUT_MODE" == prompt && "$CHECK_ONLY" == false && ${#COMMAND[@]} -gt 0 ]] || fail "sealed descriptor mode requires --prompt and an explicit command"
|
||||
exec python3 "$SCRIPT_DIR/sops-custody-fd.py" --expected-recipient "$EXPECTED_RECIPIENT" -- "${COMMAND[@]}"
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")"
|
||||
KEY_FILE="$TMP_DIR/keys.txt"
|
||||
cleanup() {
|
||||
if [[ -f "$KEY_FILE" ]]; then
|
||||
if command -v shred >/dev/null 2>&1; then
|
||||
shred -u "$KEY_FILE" 2>/dev/null || rm -f "$KEY_FILE"
|
||||
else
|
||||
rm -f "$KEY_FILE"
|
||||
fi
|
||||
fi
|
||||
rmdir "$TMP_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
umask 077
|
||||
case "$INPUT_MODE" in
|
||||
file)
|
||||
[[ -f "$SOURCE_FILE" ]] || fail "custody age key source is unavailable"
|
||||
cp "$SOURCE_FILE" "$KEY_FILE" 2>/dev/null || fail "custody key copy failed"
|
||||
chmod 600 "$KEY_FILE"
|
||||
;;
|
||||
stdin)
|
||||
cat > "$KEY_FILE"
|
||||
chmod 600 "$KEY_FILE"
|
||||
;;
|
||||
prompt)
|
||||
[[ -t 0 ]] || fail "cannot prompt for key because stdin is not a terminal; use --from-stdin or --from-file"
|
||||
printf 'Paste custody age private key line (input hidden): ' >&2
|
||||
IFS= read -r -s key_line
|
||||
printf '\n' >&2
|
||||
[[ -n "$key_line" ]] || fail "no key material supplied"
|
||||
printf '%s\n' "$key_line" > "$KEY_FILE"
|
||||
unset key_line
|
||||
chmod 600 "$KEY_FILE"
|
||||
;;
|
||||
*)
|
||||
fail "unsupported input mode: $INPUT_MODE"
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -s "$KEY_FILE" ]] || fail "no key material supplied"
|
||||
if ! grep -q 'AGE-SECRET-KEY-1' "$KEY_FILE"; then
|
||||
fail "supplied material does not contain an AGE-SECRET-KEY-1 private key"
|
||||
fi
|
||||
|
||||
DERIVED_RECIPIENT="$(age-keygen -y "$KEY_FILE" 2>/dev/null || true)"
|
||||
[[ "$DERIVED_RECIPIENT" == age1* ]] || fail "could not derive an age public recipient from supplied key"
|
||||
|
||||
if [[ "$DERIVED_RECIPIENT" != "$EXPECTED_RECIPIENT" ]]; then
|
||||
printf 'ERROR: custody key does not match expected recipient\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'OK: custody age key matches expected recipient %s\n' "$EXPECTED_RECIPIENT" >&2
|
||||
|
||||
if [[ "$CHECK_ONLY" == true ]]; then
|
||||
printf 'OK: check complete; temporary key file removed on exit\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The supplied key must not compete with an ambient inline key or key command.
|
||||
unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD NETKINGDOM_CUSTODY_AGE_KEY_FD
|
||||
export SOPS_AGE_KEY_FILE="$KEY_FILE"
|
||||
export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE"
|
||||
|
||||
if [[ ${#COMMAND[@]} -gt 0 ]]; then
|
||||
printf 'Running command with temporary SOPS_AGE_KEY_FILE. Key file will be removed after command exit.\n' >&2
|
||||
"${COMMAND[@]}"
|
||||
else
|
||||
printf 'Opening custody shell with temporary SOPS_AGE_KEY_FILE=%s\n' "$SOPS_AGE_KEY_FILE" >&2
|
||||
printf 'Run recovery or lockdown commands, then exit the shell to remove the temp key.\n' >&2
|
||||
bash -i
|
||||
fi
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Age-only SOPS recovery to a new private file; no plaintext process output."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RECIPIENT = re.compile(r'age1[023456789acdefghjklmnpqrstuvwxyz]{58}\Z')
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def public_recipient():
|
||||
lines = (ROOT / 'keys/age.pub').read_text().splitlines()
|
||||
values = [line.strip() for line in lines if line.strip() and not line.startswith('#')]
|
||||
if len(values) != 1 or not RECIPIENT.fullmatch(values[0]):
|
||||
raise Refused('registered public recipient is invalid')
|
||||
result = subprocess.run(['age', '-r', values[0]], input=b'', stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=10)
|
||||
if result.returncode:
|
||||
raise Refused('registered public recipient is invalid')
|
||||
return values[0]
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Refused('duplicate encrypted document key')
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def parse_document(raw, suffix):
|
||||
if suffix == '.json':
|
||||
data = json.loads(raw, object_pairs_hook=unique_object)
|
||||
kind = 'json'
|
||||
elif suffix in {'.yaml', '.yml'}:
|
||||
import yaml
|
||||
class UniqueLoader(yaml.SafeLoader):
|
||||
def construct_mapping(self, node, deep=False):
|
||||
self.flatten_mapping(node)
|
||||
return unique_object((self.construct_object(key, deep=deep),
|
||||
self.construct_object(value, deep=deep))
|
||||
for key, value in node.value)
|
||||
try:
|
||||
data = yaml.load(raw, Loader=UniqueLoader)
|
||||
except yaml.YAMLError:
|
||||
raise Refused('invalid encrypted YAML input') from None
|
||||
kind = 'yaml'
|
||||
else:
|
||||
raise Refused('age recovery supports SOPS JSON/YAML input')
|
||||
return data, kind
|
||||
|
||||
|
||||
def age_document(source, recipient, *, raw=None):
|
||||
# Inspect ciphertext metadata before giving a credential to any subprocess.
|
||||
# This repository's admitted SOPS profile is age-only, not arbitrary cloud
|
||||
# KMS, key services, SSH identities or executable age plugins.
|
||||
if raw is None:
|
||||
with source.open("rb") as encrypted:
|
||||
raw = encrypted.read(8 * 1024 * 1024 + 1)
|
||||
if len(raw) > 8 * 1024 * 1024:
|
||||
raise Refused('encrypted input is too large')
|
||||
data, kind = parse_document(raw, source.suffix)
|
||||
metadata = data.get('sops') if isinstance(data, dict) else None
|
||||
if not isinstance(metadata, dict):
|
||||
raise Refused('SOPS metadata is missing')
|
||||
known = {'age', 'kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp',
|
||||
'key_groups', 'shamir_threshold', 'lastmodified', 'mac', 'version',
|
||||
'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex',
|
||||
'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex',
|
||||
'mac_only_encrypted'}
|
||||
if set(metadata) - known or metadata.get('key_groups'):
|
||||
raise Refused('unsupported SOPS custody profile')
|
||||
if any(metadata.get(key) for key in ('kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'hckms', 'pgp')):
|
||||
raise Refused('recovery requires age-only metadata')
|
||||
recipients = metadata.get('age')
|
||||
if not isinstance(recipients, list) or not recipients:
|
||||
raise Refused('age recipient metadata is missing')
|
||||
if any(not isinstance(row, dict) or not RECIPIENT.fullmatch(str(row.get('recipient', '')))
|
||||
for row in recipients):
|
||||
raise Refused('unsupported age recipient')
|
||||
if recipient not in [row['recipient'] for row in recipients]:
|
||||
raise Refused('document does not include the registered recipient')
|
||||
return raw, kind
|
||||
|
||||
|
||||
def private_key_fd():
|
||||
inherited = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FD', '')
|
||||
name = os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FILE', '')
|
||||
if inherited:
|
||||
if name or not re.fullmatch(r'[1-9][0-9]{0,8}',inherited) or int(inherited)<3:
|
||||
raise Refused('ambiguous custody descriptor')
|
||||
fd=os.dup(int(inherited))
|
||||
try:
|
||||
info=os.fstat(fd)
|
||||
required=fcntl.F_SEAL_WRITE|fcntl.F_SEAL_GROW|fcntl.F_SEAL_SHRINK|fcntl.F_SEAL_SEAL
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid() or info.st_mode&0o077
|
||||
or info.st_nlink!=0 or not 0<info.st_size<=16384
|
||||
or fcntl.fcntl(fd,fcntl.F_GET_SEALS)!=required):
|
||||
raise Refused('custody descriptor must be anonymous, private and sealed')
|
||||
# No named file exists to survive parent SIGKILL. Existing callers
|
||||
# own the returned duplicate and verify the expected age recipient.
|
||||
return fd
|
||||
except BaseException:os.close(fd);raise
|
||||
|
||||
if not name:
|
||||
raise Refused('explicit temporary custody key required')
|
||||
path = Path(os.path.abspath(name))
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused('custody key path must not contain symlinks')
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16384:
|
||||
os.close(fd)
|
||||
raise Refused('custody key must be an owned private regular file')
|
||||
return fd
|
||||
|
||||
|
||||
def decrypt(recipient):
|
||||
source_name = os.environ.get('NK_SOPS_FILE', '')
|
||||
output_name = os.environ.get('NK_SOPS_OUTPUT', '')
|
||||
if not source_name or not output_name:
|
||||
raise Refused('supply FILE and OUTPUT; plaintext stdout is disabled')
|
||||
source = Path(source_name).resolve(strict=True)
|
||||
output = Path(output_name).absolute()
|
||||
parent = output.parent.resolve(strict=True)
|
||||
if any((p / '.git').is_file() or (p / '.git/HEAD').is_file()
|
||||
or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir())
|
||||
for p in [parent, *parent.parents]):
|
||||
raise Refused('plaintext output must be outside Git worktrees')
|
||||
raw, kind = age_document(source, recipient)
|
||||
binary = shutil.which('sops')
|
||||
if binary is None:
|
||||
raise Refused('sops executable is unavailable')
|
||||
directory = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
temporary = '.netkingdom-sops-' + uuid.uuid4().hex
|
||||
keyfd = None
|
||||
try:
|
||||
info = os.fstat(directory)
|
||||
if info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused('OUTPUT requires an owned private parent directory')
|
||||
try:
|
||||
os.stat(output.name, dir_fd=directory, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
raise Refused('OUTPUT already exists')
|
||||
keyfd = private_key_fd()
|
||||
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
pass_fds=(keyfd,), timeout=10)
|
||||
if derived.returncode or derived.stdout.strip() != recipient.encode():
|
||||
raise Refused('custody key does not match the registered recipient')
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600, dir_fd=directory)
|
||||
with os.fdopen(fd, 'wb') as plain, tempfile.TemporaryDirectory(prefix='netkingdom-sops-home-') as isolated:
|
||||
# HOME/XDG isolation matters: SOPS loads ambient keys in addition to
|
||||
# an explicit key file. No inline keys, SSH keys, key commands,
|
||||
# proxies or remote keyservice configuration are inherited.
|
||||
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated,
|
||||
'XDG_CONFIG_HOME': isolated, 'GNUPGHOME': isolated,
|
||||
'SOPS_AGE_KEY_FILE': f'/proc/self/fd/{keyfd}',
|
||||
'AWS_EC2_METADATA_DISABLED': 'true'}
|
||||
result = subprocess.run([binary, '--decrypt', '--input-type', kind,
|
||||
'--output-type', kind, '/dev/stdin'],
|
||||
input=raw, stdout=plain, stderr=subprocess.DEVNULL,
|
||||
env=env, cwd=isolated, pass_fds=(keyfd,), timeout=30)
|
||||
if result.returncode:
|
||||
raise Refused('SOPS decryption failed; no plaintext published')
|
||||
plain.flush()
|
||||
os.fsync(plain.fileno())
|
||||
# Atomic no-overwrite publication within the private directory.
|
||||
os.link(temporary, output.name, src_dir_fd=directory, dst_dir_fd=directory,
|
||||
follow_symlinks=False)
|
||||
os.unlink(temporary, dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
if keyfd is not None:
|
||||
os.close(keyfd)
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
os.close(directory)
|
||||
print('Decrypted to the requested private file. Remove it after the approved operation.')
|
||||
|
||||
|
||||
def interrupted(_signum, _frame):
|
||||
raise Refused('operation interrupted')
|
||||
|
||||
|
||||
def main():
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT):
|
||||
signal.signal(sig, interrupted)
|
||||
try:
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in {'setup', 'decrypt'}:
|
||||
raise Refused('unsupported custody operation')
|
||||
recipient = public_recipient()
|
||||
if sys.argv[1] == 'setup':
|
||||
print('Registered public recipient validated. No private key was read, copied or generated.')
|
||||
else:
|
||||
decrypt(recipient)
|
||||
return 0
|
||||
except Refused as exc:
|
||||
print('ERROR: ' + str(exc), file=sys.stderr)
|
||||
except (OSError, ValueError, TypeError, ImportError, subprocess.SubprocessError):
|
||||
# Parser, filesystem and tool errors can include user data. Never echo.
|
||||
print('ERROR: custody operation failed; input and tool diagnostics suppressed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private SOPS writes for the independently registered single-age profile.
|
||||
|
||||
Edit consumes an explicitly prepared private plaintext file. No editor or shell
|
||||
is launched, and subprocess plaintext output is discarded, never forwarded.
|
||||
"""
|
||||
import contextlib
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
spec = importlib.util.spec_from_file_location('sops_private', Path(__file__).with_name('sops-private.py'))
|
||||
base = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(base)
|
||||
Refused = base.Refused
|
||||
LIMIT = 8 * 1024 * 1024
|
||||
POLICY = ('unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex',
|
||||
'unencrypted_comment_regex', 'encrypted_comment_regex')
|
||||
|
||||
|
||||
def in_git(parent):
|
||||
return any((p / '.git').is_file() or (p / '.git/HEAD').is_file()
|
||||
or ((p / 'HEAD').is_file() and (p / 'objects').is_dir() and (p / 'refs').is_dir())
|
||||
for p in (parent, *parent.parents))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def snapshot(name, *, plaintext=False, replacement=False, lock=False):
|
||||
if not name: raise Refused('explicit FILE or INPUT is required')
|
||||
path = Path(os.path.abspath(name))
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise Refused('file paths must not contain symlinks')
|
||||
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
fd = None
|
||||
try:
|
||||
parent = os.fstat(directory)
|
||||
if parent.st_uid != os.getuid() or parent.st_mode & (0o077 if replacement else 0o022):
|
||||
raise Refused('file directory ownership/mode refused')
|
||||
if replacement and in_git(path.parent):
|
||||
raise Refused('private replacement input must be outside Git')
|
||||
fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||
info = os.fstat(fd)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1
|
||||
or info.st_mode & (0o077 if plaintext else 0o022)):
|
||||
raise Refused('file ownership/type/mode refused')
|
||||
if lock: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with os.fdopen(os.dup(fd), 'rb') as stream:
|
||||
raw = stream.read(LIMIT + 1)
|
||||
if len(raw) > LIMIT: raise Refused('input exceeds the supported size')
|
||||
yield path, directory, info, raw
|
||||
finally:
|
||||
if fd is not None: os.close(fd)
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def plain_document(raw, suffix):
|
||||
data, kind = base.parse_document(raw, suffix)
|
||||
if not isinstance(data, dict) or not data or 'sops' in data:
|
||||
raise Refused('plaintext must be a nonempty JSON/YAML mapping without SOPS metadata')
|
||||
return kind
|
||||
|
||||
|
||||
def encrypted_document(raw, path, recipient):
|
||||
_, kind = base.age_document(path, recipient, raw=raw)
|
||||
data, _ = base.parse_document(raw, path.suffix)
|
||||
metadata = data['sops']
|
||||
# Document recipient metadata is not an independent admission of another
|
||||
# recipient. Never rewrap a data key to an injected or unreviewed identity.
|
||||
if len(metadata['age']) != 1 or metadata['age'][0]['recipient'] != recipient:
|
||||
raise Refused('write requires exactly the independently registered recipient')
|
||||
return kind, metadata
|
||||
|
||||
|
||||
def sops(binary, arguments, raw, env, isolated, keyfd=None, *, discard=False):
|
||||
result = subprocess.run([binary, *arguments, '/dev/stdin'], input=raw,
|
||||
stdout=subprocess.DEVNULL if discard else subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, cwd=isolated, env=env,
|
||||
pass_fds=() if keyfd is None else (keyfd,), timeout=30)
|
||||
if result.returncode: raise Refused('SOPS operation failed; no replacement published')
|
||||
if not discard and (not result.stdout or len(result.stdout) > LIMIT):
|
||||
raise Refused('SOPS output was empty or oversized')
|
||||
return None if discard else result.stdout
|
||||
|
||||
|
||||
def publish(path, directory, original, encrypted):
|
||||
temporary = '.netkingdom-sops-write-' + uuid.uuid4().hex
|
||||
try:
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory)
|
||||
with os.fdopen(fd, 'wb') as file:
|
||||
file.write(encrypted); file.flush(); os.fsync(file.fileno())
|
||||
current = os.stat(path.name, dir_fd=directory, follow_symlinks=False)
|
||||
fields = ('st_dev', 'st_ino', 'st_size', 'st_mtime_ns', 'st_ctime_ns')
|
||||
if any(getattr(current, key) != getattr(original, key) for key in fields):
|
||||
raise Refused('source changed during operation; replacement refused')
|
||||
os.replace(temporary, path.name, src_dir_fd=directory, dst_dir_fd=directory)
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
try: os.unlink(temporary, dir_fd=directory)
|
||||
except FileNotFoundError: pass
|
||||
|
||||
|
||||
def write(action, recipient):
|
||||
binary = shutil.which('sops')
|
||||
if binary is None: raise Refused('sops executable is unavailable')
|
||||
with snapshot(os.environ.get('NK_SOPS_FILE', ''), plaintext=action == 'encrypt', lock=True) as target:
|
||||
path, directory, original, raw = target
|
||||
if action == 'encrypt':
|
||||
kind = plain_document(raw, path.suffix)
|
||||
if in_git(path.parent):
|
||||
tracked = subprocess.run(['git', '-C', str(path.parent), 'ls-files', '--error-unmatch', '--', path.name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
if tracked.returncode != 1:
|
||||
raise Refused('tracked plaintext or unverifiable Git index requires incident review')
|
||||
metadata = {}
|
||||
else:
|
||||
kind, metadata = encrypted_document(raw, path, recipient)
|
||||
keyfd = None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='netkingdom-sops-write-home-') as isolated:
|
||||
env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': isolated, 'XDG_CONFIG_HOME': isolated,
|
||||
'GNUPGHOME': isolated, 'AWS_EC2_METADATA_DISABLED': 'true'}
|
||||
if action != 'encrypt':
|
||||
keyfd = base.private_key_fd()
|
||||
derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
pass_fds=(keyfd,), timeout=10)
|
||||
if derived.returncode or derived.stdout.strip() != recipient.encode():
|
||||
raise Refused('custody key does not match registered recipient')
|
||||
env['SOPS_AGE_KEY_FILE'] = f'/proc/self/fd/{keyfd}'
|
||||
formats = ['--input-type', kind, '--output-type', kind]
|
||||
if action == 'rotate':
|
||||
encrypted = sops(binary, ['--rotate', *formats], raw, env, isolated, keyfd)
|
||||
else:
|
||||
options = []
|
||||
if action == 'edit':
|
||||
# MAC verification must precede replacement of even a
|
||||
# syntactically valid encrypted document.
|
||||
sops(binary, ['--decrypt', *formats], raw, env, isolated, keyfd, discard=True)
|
||||
with snapshot(os.environ.get('NK_SOPS_INPUT', ''), plaintext=True, replacement=True) as replacement:
|
||||
plain = replacement[3]
|
||||
if plain_document(plain, replacement[0].suffix) != kind:
|
||||
raise Refused('replacement format must match the encrypted document')
|
||||
for name in POLICY:
|
||||
value = metadata.get(name)
|
||||
if value:
|
||||
if not isinstance(value, str): raise Refused('invalid encryption policy')
|
||||
options.extend(['--' + name.replace('_', '-'), value])
|
||||
if metadata.get('mac_only_encrypted'):
|
||||
options.append('--mac-only-encrypted')
|
||||
else:
|
||||
plain = raw
|
||||
encrypted = sops(binary, ['--encrypt', '--age', recipient, *formats, *options],
|
||||
plain, env, isolated, keyfd)
|
||||
encrypted_document(encrypted, path, recipient)
|
||||
if keyfd is not None:
|
||||
sops(binary, ['--decrypt', *formats], encrypted, env, isolated, keyfd, discard=True)
|
||||
if base.public_recipient() != recipient:
|
||||
raise Refused('registered recipient changed during operation')
|
||||
publish(path, directory, original, encrypted)
|
||||
finally:
|
||||
if keyfd is not None: os.close(keyfd)
|
||||
print('Encrypted file updated; no plaintext was printed. Retain approved independent recovery custody.')
|
||||
|
||||
|
||||
def main():
|
||||
for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): signal.signal(sig, base.interrupted)
|
||||
try:
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in {'encrypt', 'rotate', 'edit'}:
|
||||
raise Refused('unsupported SOPS write operation')
|
||||
write(sys.argv[1], base.public_recipient())
|
||||
return 0
|
||||
except Refused as exc:
|
||||
print('ERROR: ' + str(exc), file=sys.stderr)
|
||||
except (OSError, ValueError, TypeError, KeyError, ImportError, subprocess.SubprocessError):
|
||||
print('ERROR: SOPS write refused or failed; input and tool diagnostics suppressed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Private creation or exact-match verification; rotations require --rotate-key.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" authelia "$@"
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"""Integration regression for NK-WP-0041; needs requests, PyYAML, cryptography."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import pathlib
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
|
||||
|
||||
def decode(value):
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Exercise real Authelia ID tokens with and without the KeyCape claims policy; scratch data only."
|
||||
)
|
||||
parser.add_argument("--authelia-bin", required=True)
|
||||
binary = str(pathlib.Path(parser.parse_args().authelia_bin).resolve())
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
pem = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
source = yaml.safe_load(
|
||||
yaml.safe_load(
|
||||
(pathlib.Path(__file__).resolve().parents[1] / "configmap.yaml").read_text()
|
||||
)["data"]["configuration.yml"]
|
||||
)["identity_providers"]["oidc"]
|
||||
for enabled in (False, True):
|
||||
with tempfile.TemporaryDirectory(prefix="nk-claims-") as tmp:
|
||||
p = pathlib.Path(tmp)
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
password = "scratch-password-only"
|
||||
digest = (
|
||||
subprocess.check_output(
|
||||
[
|
||||
binary,
|
||||
"crypto",
|
||||
"hash",
|
||||
"generate",
|
||||
"argon2",
|
||||
"--password",
|
||||
password,
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
.strip()
|
||||
.split("Digest: ")[1]
|
||||
)
|
||||
(p / "users.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"users": {
|
||||
"nk-probe": {
|
||||
"displayname": "Scratch User",
|
||||
"password": digest,
|
||||
"email": "nk-probe+x@example.com",
|
||||
"groups": [],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
client = dict(source["clients"][0])
|
||||
client.update(
|
||||
secret="scratch-client-secret-only",
|
||||
redirect_uris=["https://client.example.com/callback"],
|
||||
)
|
||||
if not enabled:
|
||||
client.pop("claims_policy")
|
||||
oidc = {
|
||||
"hmac_secret": "h" * 64,
|
||||
"jwks": [{"key": pem, "algorithm": "RS256", "use": "sig"}],
|
||||
"clients": [client],
|
||||
}
|
||||
if enabled:
|
||||
oidc["claims_policies"] = source["claims_policies"]
|
||||
cfg = {
|
||||
"ntp": {"disable_startup_check": True},
|
||||
"server": {"address": f"tcp://127.0.0.1:{port}/"},
|
||||
"log": {"level": "info"},
|
||||
"authentication_backend": {"file": {"path": str(p / "users.yml")}},
|
||||
"session": {
|
||||
"secret": "s" * 64,
|
||||
"cookies": [
|
||||
{
|
||||
"domain": "example.com",
|
||||
"authelia_url": "https://auth.example.com",
|
||||
}
|
||||
],
|
||||
},
|
||||
"storage": {
|
||||
"encryption_key": "e" * 64,
|
||||
"local": {"path": str(p / "db.sqlite3")},
|
||||
},
|
||||
"notifier": {"filesystem": {"filename": str(p / "notifications")}},
|
||||
"access_control": {"default_policy": "one_factor"},
|
||||
"identity_validation": {"reset_password": {"jwt_secret": "j" * 64}},
|
||||
"identity_providers": {"oidc": oidc},
|
||||
}
|
||||
(p / "config.yml").write_text(yaml.safe_dump(cfg))
|
||||
with (p / "log").open("w") as log:
|
||||
proc = subprocess.Popen(
|
||||
[binary, "--config", str(p / "config.yml")], stdout=log, stderr=log
|
||||
)
|
||||
try:
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
session.headers.update(
|
||||
{
|
||||
"Host": "auth.example.com",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "auth.example.com",
|
||||
}
|
||||
)
|
||||
for _ in range(100):
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError((p / "log").read_text())
|
||||
try:
|
||||
if (
|
||||
session.get(base + "/api/health", timeout=1).status_code
|
||||
== 200
|
||||
):
|
||||
break
|
||||
except requests.ConnectionError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError("Scratch Authelia did not become healthy")
|
||||
r = session.post(
|
||||
base + "/api/firstfactor",
|
||||
json={
|
||||
"username": "nk-probe",
|
||||
"password": password,
|
||||
"keepMeLoggedIn": False,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert r.status_code == 200, (r.status_code, r.text)
|
||||
session.headers["Cookie"] = "; ".join(
|
||||
c.name + "=" + c.value for c in session.cookies
|
||||
)
|
||||
r = session.get(
|
||||
base + "/api/oidc/authorization",
|
||||
params={
|
||||
"client_id": "keycape",
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email groups",
|
||||
"state": "scratch-state-long-enough",
|
||||
"nonce": "scratch-nonce-long-enough",
|
||||
},
|
||||
allow_redirects=False,
|
||||
timeout=5,
|
||||
)
|
||||
loc = r.headers.get("Location", "")
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query)
|
||||
assert "code" in query, (
|
||||
r.status_code,
|
||||
loc,
|
||||
r.text,
|
||||
(p / "log").read_text(),
|
||||
)
|
||||
r = session.post(
|
||||
base + "/api/oidc/token",
|
||||
auth=("keycape", "scratch-client-secret-only"),
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": query["code"][0],
|
||||
"redirect_uri": "https://client.example.com/callback",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert r.status_code == 200, (r.status_code, r.text)
|
||||
parts = r.json()["id_token"].split(".")
|
||||
key.public_key().verify(
|
||||
decode(parts[2]),
|
||||
(".".join(parts[:2])).encode(),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
claims = json.loads(decode(parts[1]))
|
||||
assert claims["iss"] == "https://auth.example.com"
|
||||
assert "keycape" in claims["aud"]
|
||||
assert claims["nonce"] == "scratch-nonce-long-enough"
|
||||
assert claims["exp"] > time.time()
|
||||
assert (claims.get("preferred_username") == "nk-probe") == enabled
|
||||
assert claims["sub"] != "nk-probe"
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"claims_policy": enabled,
|
||||
"signed_id_token_verified": True,
|
||||
"preferred_username_present": "preferred_username"
|
||||
in claims,
|
||||
"subject_is_directory_username": False,
|
||||
}
|
||||
)
|
||||
)
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=10)
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Contained KeyCape approval-client config/image cutover and acceptance."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import jwt
|
||||
import yaml
|
||||
|
||||
PLATFORM = Path('/home/worsch/railiance-platform')
|
||||
KEYCAPE = Path('/home/worsch/key-cape')
|
||||
sys.path.insert(0, str(PLATFORM / 'scripts'))
|
||||
from state_hub_preflight_lane import LaneError, assert_cluster, bao, command, data
|
||||
from keycape_approval_custody import require
|
||||
spec = importlib.util.spec_from_file_location('pin', Path(__file__).with_name('openbao-client-config.py'))
|
||||
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
|
||||
ISSUER = 'https://kc.coulomb.social'
|
||||
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611'
|
||||
VERIFIER = Path('/home/worsch/.local/share/key-cape/verified-bin/dcebd46/keycape')
|
||||
VERIFIER_SHA256 = '4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92'
|
||||
PRIOR_IMAGE = 'forgejo.coulomb.social/coulomb/key-cape:main-153258b'
|
||||
IDS = ('secrets-engine-approval', 'approval-engine-operator')
|
||||
SECRET_NAMES = ('keycape-secrets-engine-approval-client', 'keycape-approval-engine-operator-client')
|
||||
|
||||
|
||||
def verify_artifact():
|
||||
require(VERIFIER.is_file() and not VERIFIER.is_symlink()
|
||||
and hashlib.sha256(VERIFIER.read_bytes()).hexdigest() == VERIFIER_SHA256, 'pinned_verifier_artifact_required')
|
||||
|
||||
|
||||
def registrations():
|
||||
source = yaml.safe_load((KEYCAPE / 'config/service-clients.example.yaml').read_text())['clients']
|
||||
result = [next(c for c in source if c['clientId'] == name) for name in IDS]
|
||||
for i, c in enumerate(result):
|
||||
require(c['audience'] == 'approval-engine' and c['tenant'] == 'tenant:platform'
|
||||
and c['grantTypes'] == ['client_credentials'] and c['clientType'] == 'confidential'
|
||||
and c['tokenLifetime'] == '15m', 'registration_contract_drift')
|
||||
scopes = ['approval:read', 'approval:consume'] if i == 0 else [
|
||||
'approval:create', 'approval:read', 'approval:approve', 'approval:revoke',
|
||||
'approval:supersede', 'approval:observe', 'approval:emit']
|
||||
require(c['allowedScopes'] == scopes and c['roles'] == (["secrets-engine"] if i == 0 else ['approval-operator'])
|
||||
and c['serviceSubject'] == ('service:secrets-engine' if i == 0 else 'service:approval-engine-operator')
|
||||
and c['secretRef'] == ('env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET' if i == 0 else 'env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET'), 'registration_authority_drift')
|
||||
return result
|
||||
|
||||
|
||||
def replacement(secret, clients):
|
||||
raw, config, _ = pin.issuer_document(secret)
|
||||
require(config['authelia'].get('issuer') == pin.VERIFIED_UPSTREAM_ISSUER, 'verified_issuer_pin_required')
|
||||
require(isinstance(config.get('clients'), list) and config['clients'], 'client_sequence_required')
|
||||
existing = [c['clientId'] for c in config['clients']]
|
||||
require(len(existing) == len(set(existing)) and not set(existing).intersection(IDS), 'existing_client_registration_requires_reconciliation')
|
||||
root = yaml.compose(raw)
|
||||
node = next(v for k, v in root.value if k.value == 'clients')
|
||||
require(isinstance(node, yaml.nodes.SequenceNode) and not node.flow_style, 'block_client_sequence_required')
|
||||
index = node.end_mark.index
|
||||
# Insert at the start of the next top-level line, preserving every old byte.
|
||||
line_start = raw.rfind('\n', 0, index) + 1
|
||||
if not raw[line_start:index].strip():
|
||||
index = line_start
|
||||
indent = ' ' * node.start_mark.column
|
||||
fragment = yaml.safe_dump(clients, sort_keys=False, width=120)
|
||||
fragment = ''.join(indent + line if line.strip() else line for line in fragment.splitlines(True))
|
||||
addition = ('' if index == 0 or raw[index - 1] == '\n' else '\n') + fragment
|
||||
updated = raw[:index] + addition + raw[index:]
|
||||
expected = copy.deepcopy(config); expected['clients'].extend(clients)
|
||||
require(yaml.load(updated, Loader=pin.UniqueLoader) == expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode()
|
||||
|
||||
|
||||
def candidate_spec(deployment):
|
||||
result = copy.deepcopy(deployment['spec'])
|
||||
require(result.get('replicas', 1) == 1, 'single_replica_required')
|
||||
container = next(c for c in result['template']['spec']['containers'] if c['name'] == 'keycape')
|
||||
require(container['image'] == PRIOR_IMAGE, 'prior_image_drift')
|
||||
patch = yaml.safe_load((KEYCAPE / 'docs/approval-clients-deployment.patch.yaml').read_text())['spec']
|
||||
approved = patch['template']['spec']['containers'][0]
|
||||
require(approved['image'] == IMAGE and patch['strategy']['type'] == 'Recreate', 'candidate_source_drift')
|
||||
env = container.setdefault('env', [])
|
||||
require(not {x['name'] for x in env}.intersection(x['name'] for x in approved['env']), 'existing_candidate_env')
|
||||
for item, client, secret_name in zip(approved['env'], registrations(), SECRET_NAMES):
|
||||
require(item == {'name': client['secretRef'].removeprefix('env:'), 'valueFrom': {
|
||||
'secretKeyRef': {'name': secret_name, 'key': 'client-secret'}}}, 'env_delivery_contract_drift')
|
||||
env.extend(approved['env']); container['image'] = IMAGE
|
||||
container['readinessProbe']['httpGet'] = {'path': '/readyz', 'port': 8080}
|
||||
result['strategy'] = {'type': 'Recreate'}
|
||||
return result
|
||||
|
||||
|
||||
def patch_object(kube, kind, obj, path, value, dry=False):
|
||||
patch = [{'op': 'test', 'path': '/metadata/uid', 'value': obj['metadata']['uid']},
|
||||
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': obj['metadata']['resourceVersion']},
|
||||
{'op': 'replace', 'path': path, 'value': value}]
|
||||
args = kube + ['-n', 'sso', 'patch', kind, obj['metadata']['name'], '--type=json', '--patch-file=/dev/stdin', '-o', 'json']
|
||||
if dry:
|
||||
args += ['--dry-run=server']
|
||||
return data(command(args, payload=patch))
|
||||
|
||||
|
||||
def get(kube, kind, name):
|
||||
return data(command(kube + ['-n', 'sso', 'get', kind, name, '-o', 'json']))
|
||||
|
||||
|
||||
def ready(kube, image, timeout=150):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
dep = get(kube, 'deployment', 'keycape')
|
||||
status = dep.get('status', {})
|
||||
if (status.get('observedGeneration') == dep['metadata']['generation']
|
||||
and status.get('updatedReplicas') == status.get('readyReplicas') == status.get('availableReplicas') == 1
|
||||
and status.get('replicas') == 1):
|
||||
podlist = data(command(kube + ['-n', 'sso', 'get', 'pods', '-l', 'app.kubernetes.io/name=keycape', '-o', 'json']))['items']
|
||||
if len(podlist) == 1 and not podlist[0]['metadata'].get('deletionTimestamp'):
|
||||
containers = podlist[0].get('status', {}).get('containerStatuses', [])
|
||||
if any(c['name'] == 'keycape' and c['ready'] and
|
||||
(c.get('imageID', '').removeprefix('docker-pullable://') == image
|
||||
if '@sha256:' in image else c.get('image') == image) for c in containers):
|
||||
return {'deployment_uid': dep['metadata']['uid'], 'generation': dep['metadata']['generation'],
|
||||
'pod_uid': podlist[0]['metadata']['uid'], 'single_ready_replica': True}
|
||||
time.sleep(3)
|
||||
raise LaneError('keycape_readiness_timeout')
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def http(path, fields=None, credentials=None):
|
||||
headers = {'Accept': 'application/json'}
|
||||
if credentials:
|
||||
headers['Authorization'] = 'Basic ' + base64.b64encode(':'.join(credentials).encode()).decode()
|
||||
body = None
|
||||
if fields is not None:
|
||||
body = urllib.parse.urlencode(fields).encode()
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
request = urllib.request.Request(ISSUER + path, data=body, headers=headers)
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
try:
|
||||
with opener.open(request, timeout=20) as response:
|
||||
return response.status, json.load(response)
|
||||
except urllib.error.HTTPError as error:
|
||||
try:
|
||||
return error.code, json.load(error)
|
||||
except (ValueError, TypeError):
|
||||
raise LaneError('http_' + str(error.code) + '_non_json_error') from None
|
||||
|
||||
|
||||
def denied(result, status, feature):
|
||||
code, body = result
|
||||
require(code == status and body.get('error') == 'invalid_profile_usage'
|
||||
and body.get('feature') == feature, 'keycape_denial_inconclusive')
|
||||
|
||||
|
||||
|
||||
def verified_claims(token, public_key):
|
||||
# Match authclient/client.go: tolerate at most 30s future iat, but no
|
||||
# extension to expiry or not-before. PyJWT's global leeway would extend both.
|
||||
claims = jwt.decode(token, public_key, algorithms=['RS256'], issuer=ISSUER, audience='approval-engine',
|
||||
options={'verify_iat': False, 'require': ['exp', 'iat', 'sub', 'iss', 'aud']})
|
||||
require(type(claims['iat']) is int and type(claims['exp']) is int
|
||||
and claims['iat'] <= int(time.time()) + 30 and claims['iat'] < claims['exp'], 'issued_at_binding_failed')
|
||||
return claims
|
||||
|
||||
|
||||
def acceptance(kube, receipt):
|
||||
receipt['acceptance_phase'] = 'discovery'
|
||||
code, discovery = http('/.well-known/openid-configuration')
|
||||
require(code == 200 and discovery['issuer'] == ISSUER
|
||||
and discovery['token_endpoint'] == ISSUER + '/token'
|
||||
and discovery['jwks_uri'].startswith(ISSUER + '/'), 'discovery_binding_mismatch')
|
||||
receipt['acceptance_phase'] = 'jwks'
|
||||
code, jwks = http(discovery['jwks_uri'].removeprefix(ISSUER))
|
||||
require(code == 200, 'jwks_failed')
|
||||
receipt['clients'] = []
|
||||
for client, secret_name in zip(registrations(), SECRET_NAMES):
|
||||
receipt['acceptance_client'] = client['clientId']
|
||||
receipt['acceptance_phase'] = 'verifier_secret_read'
|
||||
secret = get(kube, 'secret', secret_name)
|
||||
credential = base64.b64decode(secret['data']['client-secret'], validate=True).decode()
|
||||
scopes = ' '.join(client['allowedScopes'])
|
||||
receipt['acceptance_phase'] = 'token_exchange'
|
||||
result, response = http('/token', {'grant_type': 'client_credentials', 'scope': scopes}, (client['clientId'], credential))
|
||||
require(result == 200 and response.get('token_type') == 'Bearer' and response.get('expires_in') == 900, 'service_issuance_failed')
|
||||
receipt['acceptance_phase'] = 'signature_and_claims'
|
||||
token = response['access_token']; header = jwt.get_unverified_header(token)
|
||||
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
|
||||
require(header.get('alg') == 'RS256' and len(keys) == 1, 'signing_key_selection_failed')
|
||||
claims = verified_claims(token, jwt.PyJWK.from_dict(keys[0]).key)
|
||||
require(claims['sub'] == client['serviceSubject'] and claims['tenant'] == 'tenant:platform'
|
||||
and claims['aud'] == 'approval-engine'
|
||||
and claims['roles'] == client['roles'] and claims['exp'] - claims['iat'] == 900
|
||||
and claims['principal_type'] == 'service'
|
||||
and set(claims['scope'].split()) == set(client['allowedScopes']), 'exact_claims_mismatch')
|
||||
excessive = 'approval:approve' if client['clientId'] == IDS[0] else 'approval:consume'
|
||||
receipt['acceptance_phase'] = 'excess_scope_denial'
|
||||
denied(http('/token', {'grant_type': 'client_credentials', 'scope': excessive},
|
||||
(client['clientId'], credential)), 400, 'scope')
|
||||
receipt['acceptance_phase'] = 'wrong_secret_denial'
|
||||
denied(http('/token', {'grant_type': 'client_credentials', 'scope': scopes},
|
||||
(client['clientId'], secrets.token_urlsafe(48))), 401, 'Authorization')
|
||||
args = [str(VERIFIER), 'verify-client',
|
||||
'-issuer', ISSUER, '-client-id', client['clientId'], '-audience', 'approval-engine', '-scope', scopes,
|
||||
'-secret-env', client['secretRef'].removeprefix('env:'), '-expect-subject', client['serviceSubject'],
|
||||
'-expect-tenant', 'tenant:platform', '-expect-roles', ','.join(client['roles']), '-deny-scope', excessive]
|
||||
receipt['acceptance_phase'] = 'pinned_artifact_verifier'
|
||||
verify_artifact()
|
||||
verifier_env = os.environ.copy()
|
||||
verifier_env[client['secretRef'].removeprefix('env:')] = credential
|
||||
command(args, env=verifier_env)
|
||||
receipt['clients'].append({'client_id': client['clientId'], 'live_jwks_signature_verified': True,
|
||||
'exact_claims_verified': True, 'lifetime_seconds': 900, 'maximum_future_iat_seconds': 30, 'expiry_leeway_seconds': 0, 'excess_scope_denied': True,
|
||||
'wrong_secret_denied': True, 'pinned_artifact_verifier_passed': True,
|
||||
'verifier_location': 'attended owner process', 'verifier_sha256': VERIFIER_SHA256,
|
||||
'real_predecessor_rotation_tested': False, 'observed_wall_clock_expiry': False})
|
||||
receipt['acceptance_phase'] = 'human_consume_denial'
|
||||
query = urllib.parse.urlencode({'client_id': 'openbao-admin', 'response_type': 'code',
|
||||
'redirect_uri': 'http://localhost:8250/oidc/callback', 'scope': 'openid approval:consume',
|
||||
'code_challenge_method': 'S256', 'code_challenge': 'A' * 43, 'state': secrets.token_urlsafe(32)})
|
||||
denied(http('/authorize?' + query), 400, 'scope')
|
||||
receipt['human_client_consume_denied'] = True
|
||||
receipt['acceptance_phase'] = 'passed'
|
||||
|
||||
|
||||
def rollout(kube, receipt, recovery_path):
|
||||
assert_cluster(kube)
|
||||
verify_artifact()
|
||||
before = get(kube, 'secret', 'keycape-config')
|
||||
deployment = get(kube, 'deployment', 'keycape')
|
||||
require(before['metadata']['uid'] == '2e94519d-1550-41c7-9701-2efe47fe1fd3'
|
||||
and deployment['metadata']['uid'] == '99ddd83c-cb3f-4847-bcf8-35f1aa87627f', 'owner_object_identity_mismatch')
|
||||
config = replacement(before, registrations()); candidate = candidate_spec(deployment)
|
||||
patch_object(kube, 'secret', before, '/data/config.yaml', config, dry=True)
|
||||
patch_object(kube, 'deployment', deployment, '/spec', candidate, dry=True)
|
||||
previous = {'deployment': deployment, 'secret': before}
|
||||
recovery_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
require(recovery_path.parent.stat().st_mode & 0o077 == 0, 'private_recovery_directory_required')
|
||||
fd = os.open(recovery_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, 'w') as out:
|
||||
json.dump(previous, out)
|
||||
receipt['protected_recovery_retained'] = True
|
||||
changed = None; deployed = None
|
||||
try:
|
||||
changed = patch_object(kube, 'secret', before, '/data/config.yaml', config)
|
||||
require(changed['data'] == dict(before['data'], **{'config.yaml': config}), 'secret_readback_mismatch')
|
||||
deployed = patch_object(kube, 'deployment', deployment, '/spec', candidate)
|
||||
receipt.update(ready(kube, IMAGE))
|
||||
after = get(kube, 'secret', 'keycape-config')
|
||||
require(after['data'] == changed['data'], 'configuration_changed_during_rollout')
|
||||
acceptance(kube, receipt)
|
||||
receipt.update(status='service_acceptance_passed_pending_fresh_human_login', image=IMAGE,
|
||||
config_resource_version=after['metadata']['resourceVersion'], signing_key_unchanged=True,
|
||||
unrelated_config_bytes_preserved=True, existing_human_login_after=False)
|
||||
except Exception as failure:
|
||||
receipt['failure_class'] = type(failure).__name__
|
||||
# Read after uncertain API outcomes too; never assume a timeout means no write.
|
||||
now = get(kube, 'secret', 'keycape-config')
|
||||
require(now['metadata']['uid'] == before['metadata']['uid'], 'rollback_config_identity_drift')
|
||||
expected_data = dict(before['data'], **{'config.yaml': config})
|
||||
require(now['data'] in (before['data'], expected_data), 'rollback_config_drift_requires_owner_reconcile')
|
||||
if now['data'] == expected_data:
|
||||
patch_object(kube, 'secret', now, '/data/config.yaml', before['data']['config.yaml'])
|
||||
now = get(kube, 'deployment', 'keycape')
|
||||
require(now['metadata']['uid'] == deployment['metadata']['uid'], 'rollback_deployment_identity_drift')
|
||||
expected_spec = deployed['spec'] if deployed else candidate
|
||||
require(now['spec'] in (deployment['spec'], expected_spec), 'rollback_deployment_drift_requires_owner_reconcile')
|
||||
if now['spec'] == expected_spec:
|
||||
patch_object(kube, 'deployment', now, '/spec', deployment['spec'])
|
||||
ready(kube, PRIOR_IMAGE)
|
||||
receipt['compatible_pair_restored'] = True
|
||||
raise
|
||||
|
||||
|
||||
def run(args, receipt):
|
||||
require(Path.home().parent.name == '.warden-attended-login' and not os.environ.get('BAO_TOKEN')
|
||||
and not os.environ.get('VAULT_TOKEN'), 'attended_warden_envelope_required')
|
||||
identity = data(bao(['token', 'lookup', '-format=json']))['data']
|
||||
require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required')
|
||||
kube = ['kubectl', '--kubeconfig', args.kubeconfig]
|
||||
if args.action == 'rollout':
|
||||
receipt['existing_human_login_before'] = True
|
||||
rollout(kube, receipt, Path(args.recovery))
|
||||
else:
|
||||
assert_cluster(kube); receipt.update(ready(kube, IMAGE)); acceptance(kube, receipt)
|
||||
receipt.update(status='service_and_existing_human_login_acceptance_passed', existing_human_login_after=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('action', choices=['rollout', 'verify-after-login'])
|
||||
parser.add_argument('--kubeconfig', required=True)
|
||||
parser.add_argument('--recovery')
|
||||
parser.add_argument('--receipt', required=True)
|
||||
args = parser.parse_args()
|
||||
receipt = {'schema': 'netkingdom.keycape-approval-rollout.v1', 'status': 'failed', 'values_emitted': False}
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
run(args, receipt)
|
||||
except Exception as exc:
|
||||
receipt['error'] = str(exc) if isinstance(exc, (LaneError, pin.IssuerPinError)) else 'contained_operation_failed'
|
||||
finally:
|
||||
with os.fdopen(fd, 'w') as out:
|
||||
json.dump(receipt, out, indent=2); out.write('\n')
|
||||
return 0 if receipt['status'] != 'failed' else 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Migrate only the two reviewed baseline registrations; never export Secret values.
|
||||
|
||||
Runtime policy overrides are issuer-owned in its policy PVC. This migration is
|
||||
for initial activation; subsequent changes use the authenticated policy journey.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('config_pin', ROOT/'openbao-client-config.py')
|
||||
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
|
||||
CLIENTS = {'vergabe-demo-company', 'user-engine-portal'}
|
||||
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
|
||||
class Refused(Exception): pass
|
||||
|
||||
def require(condition, reason):
|
||||
if not condition: raise Refused(reason)
|
||||
|
||||
def replacement(secret):
|
||||
try: raw,config,_ = pin.issuer_document(secret)
|
||||
except Exception: raise Refused("issuer_configuration_unreadable") from None
|
||||
clients = config.get('clients',[])
|
||||
ids = [c.get('clientId') for c in clients]
|
||||
require(len(ids)==len(set(ids)) and CLIENTS.issubset(ids), 'reviewed_clients_required')
|
||||
document = yaml.compose(raw)
|
||||
nodes = next(v for k,v in document.value if k.value=='clients')
|
||||
edits=[]; expected=copy.deepcopy(config)
|
||||
for index,client in enumerate(clients):
|
||||
if client['clientId'] not in CLIENTS: continue
|
||||
require(client.get('clientType')=='public' and client.get('grantTypes')==['authorization_code'], 'client_shape_changed')
|
||||
require('mfaRequired' not in client, 'explicit_policy_requires_review')
|
||||
if client.get('mfaOptional') is True: continue
|
||||
require('mfaOptional' not in client, 'explicit_policy_requires_review')
|
||||
node=nodes.value[index]
|
||||
require(not node.flow_style, 'block_client_required')
|
||||
column=node.value[0][0].start_mark.column
|
||||
position=node.end_mark.index
|
||||
line_start=raw.rfind('\n',0,position)+1
|
||||
if not raw[line_start:position].strip(): position=line_start
|
||||
addition=('' if position==0 or raw[position-1]=='\n' else '\n')+' '*column+'mfaOptional: true\n'
|
||||
edits.append((position,addition));expected['clients'][index]['mfaOptional']=True
|
||||
updated=raw
|
||||
for position,addition in sorted(edits,reverse=True):updated=updated[:position]+addition+updated[position:]
|
||||
require(yaml.load(updated,Loader=pin.UniqueLoader)==expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(),bool(edits)
|
||||
|
||||
def kube(args,payload=None):
|
||||
result=subprocess.run(['kubectl','--request-timeout=20s',*args],input=json.dumps(payload) if payload is not None else None,capture_output=True,text=True,timeout=30)
|
||||
require(result.returncode==0,'kubernetes_operation_failed')
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def run(args):
|
||||
require(kube(['get','namespace','kube-system','-o','json'])['metadata']['uid']==CLUSTER_UID,'cluster_changed')
|
||||
secret=kube(['-n','sso','get','secret','keycape-config','-o','json'])
|
||||
require(not secret['metadata'].get('ownerReferences'),'controller_owned_configuration')
|
||||
value,changed=replacement(secret);metadata=pin.safe_metadata(secret)
|
||||
receipt={'clients':sorted(CLIENTS),'before':metadata,'change_needed':changed,'other_clients_unchanged':True,'secret_values_emitted':False,'mode':args.mode}
|
||||
if args.mode=='inspect':return receipt
|
||||
require(metadata=={'uid':args.expected_uid,'resource_version':args.expected_resource_version},'observed_revision_changed')
|
||||
if changed:
|
||||
patch=[{'op':'test','path':'/metadata/uid','value':metadata['uid']},{'op':'test','path':'/metadata/resourceVersion','value':metadata['resource_version']},{'op':'replace','path':'/data/config.yaml','value':value}]
|
||||
command=['-n','sso','patch','secret','keycape-config','--type=json','--patch-file=/dev/stdin','-o','json']
|
||||
if args.mode=='dry-run':command.append('--dry-run=server')
|
||||
response=kube(command,patch)
|
||||
require(response['data']==dict(secret['data'],**{'config.yaml':value}),'patch_readback_mismatch')
|
||||
if args.mode=='apply':
|
||||
response=kube(['-n','sso','get','secret','keycape-config','-o','json'])
|
||||
require(response['metadata']['uid']==metadata['uid'] and response['data']==dict(secret['data'],**{'config.yaml':value}),'readback_changed')
|
||||
receipt['after']=pin.safe_metadata(response)
|
||||
return receipt
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['inspect','dry-run','apply']);parser.add_argument('--expected-uid');parser.add_argument('--expected-resource-version')
|
||||
try: print(json.dumps(run(parser.parse_args())))
|
||||
except Refused as error:print(json.dumps({'success':False,'failure':str(error)}));raise SystemExit(1)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Legacy root-token pod prompt withdrawn under NK-WP-0049.
|
||||
set +x
|
||||
printf '%s\n' 'Retired: use railiance-platform declarations and its governed OpenBao administration operation through warden access openbao-platform-admin-login. Prepare the exact owner-reviewed config change first. No token, auth mount or role is read or changed here.' >&2
|
||||
exit 2
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# Retired: KeyCape uses the scoped, renewable factor-reader lane.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
printf '%s\n' 'ERROR: personal privacyIDEA admin-token delivery is retired.' 'Use railiance-platform RPF-WP-0040 scoped factor custody, ESO delivery and renewal recovery.' 'This helper performs no login, credential read, Secret change or restart.' >&2
|
||||
exit 1
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Create first-bootstrap config with private inputs and scoped factor delivery.
|
||||
|
||||
Never replace an existing Secret, mint a provider token or rotate a signing key.
|
||||
Child output is captured, including Kubernetes error bodies containing Secret data.
|
||||
"""
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def private(info, directory=False):
|
||||
kind = stat.S_ISDIR if directory else stat.S_ISREG
|
||||
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise Refused()
|
||||
if not directory and info.st_nlink != 1:
|
||||
raise Refused()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def custody(root):
|
||||
root = Path(os.path.abspath(root))
|
||||
if any(p.is_symlink() for p in (root, *root.parents)):
|
||||
raise Refused()
|
||||
descriptors = []
|
||||
try:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
fd = os.open(root, flags); descriptors.append(fd)
|
||||
private(os.fstat(fd), True)
|
||||
children = {}
|
||||
for name in ('lldap', 'authelia', 'keycape'):
|
||||
child = os.open(name, flags, dir_fd=fd); descriptors.append(child)
|
||||
private(os.fstat(child), True)
|
||||
children[name] = child
|
||||
yield children
|
||||
finally:
|
||||
for fd in reversed(descriptors): os.close(fd)
|
||||
|
||||
|
||||
def read_file(parent, name):
|
||||
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
||||
try:
|
||||
private(os.fstat(fd))
|
||||
raw = os.read(fd, 65537)
|
||||
if len(raw) > 65536: raise Refused()
|
||||
return raw
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def env_value(parent, name):
|
||||
values = [line.split('=', 1)[1] for line in read_file(parent, 'secrets.env').decode().splitlines()
|
||||
if line.startswith(name + '=')]
|
||||
# Consume the generator's literal format, never execute a shell env file.
|
||||
if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]):
|
||||
raise Refused()
|
||||
return values[0]
|
||||
|
||||
|
||||
def run(args, payload=None):
|
||||
result = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, timeout=60, check=False)
|
||||
if result.returncode or len(result.stdout) > 65536: raise Refused()
|
||||
return result.stdout
|
||||
|
||||
|
||||
def signing_key(parent):
|
||||
try:
|
||||
key = read_file(parent, 'key.pem')
|
||||
except FileNotFoundError:
|
||||
key = run(['openssl', 'genrsa', '2048'])
|
||||
run(['openssl', 'rsa', '-check', '-noout'], key)
|
||||
# Publish atomically without replacement, so concurrent creation cannot
|
||||
# overwrite a pre-existing key. Keep this private key after API failure.
|
||||
temp = '.key-' + secrets.token_hex(12)
|
||||
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as file:
|
||||
file.write(key); file.flush(); os.fsync(file.fileno())
|
||||
os.link(temp, 'key.pem', src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.unlink(temp, dir_fd=parent)
|
||||
run(['openssl', 'rsa', '-check', '-noout'], key)
|
||||
return key
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 2: raise Refused()
|
||||
here = Path(__file__).resolve().parent
|
||||
root = sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets'
|
||||
with custody(root) as dirs:
|
||||
bind_password = env_value(dirs['lldap'], 'LLDAP_LDAP_USER_PASS')
|
||||
client_secret = env_value(dirs['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET')
|
||||
config = json.loads((here / 'bootstrap-config.json').read_text())
|
||||
config['lldap']['bindPW'] = bind_password
|
||||
config['authelia']['clientSecret'] = client_secret
|
||||
# Existence is not an update grant. API create also closes the race
|
||||
# between this preflight and publication; AlreadyExists stays a failure.
|
||||
existing = run(['kubectl', 'get', 'secret', 'keycape-config', '-n', 'sso', '--ignore-not-found', '-o', 'name'])
|
||||
if existing.strip(): raise Refused()
|
||||
ready = run(['kubectl', 'get', 'externalsecret', 'keycape-factor-read', '-n', 'sso',
|
||||
'-o', 'jsonpath={.status.conditions[?(@.type=="Ready")].status}'])
|
||||
factor = run(['kubectl', 'get', 'secret', 'keycape-factor-read', '-n', 'sso', '-o', 'name'])
|
||||
if ready.strip() != b'True' or factor.strip() != b'secret/keycape-factor-read': raise Refused()
|
||||
key = signing_key(dirs['keycape'])
|
||||
data = {'config.yaml': yaml.safe_dump(config, sort_keys=False).encode(), 'key.pem': key}
|
||||
manifest = {'apiVersion': 'v1', 'kind': 'Secret', 'type': 'Opaque',
|
||||
'metadata': {'name': 'keycape-config', 'namespace': 'sso'},
|
||||
'data': {name: base64.b64encode(value).decode() for name, value in data.items()}}
|
||||
response = run(['kubectl', 'create', '-o', 'name', '-f', '-'], json.dumps(manifest).encode())
|
||||
if response.strip() != b'secret/keycape-config': raise Refused()
|
||||
print('KeyCape bootstrap Secret created with mounted factor credential configuration.')
|
||||
print('Preserve the private signing key in approved custody. Native login and factor acceptance remain required.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (Refused, OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
||||
print('ERROR: KeyCape bootstrap refused or failed. Existing configuration is never replaced; '
|
||||
'check private inputs and RPF-WP-0040 delivery using metadata-only diagnostics. '
|
||||
'If creation was attempted, its remote outcome must be reconciled before retry.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue