fix(identity): retain group provenance and prepare sealed recovery custody
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Identity provider journey acceptance / provider (push) Failing after 20s
Build and Publish identity-provisioner / build-and-push (push) Successful in 8s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1468b-63fa-7710-85dc-8f2e16b3ebfa
This commit is contained in:
tegwick 2026-10-17 00:28:17 +02:00
parent 878abc9358
commit 4f65e41ac5
22 changed files with 1685 additions and 89 deletions

View file

@ -0,0 +1,114 @@
# Durable service group creation provenance
This source change narrows the existing authenticated identity-provisioner
`/v1/groups/ensure` and `/v1/groups/revoke` endpoints. It introduces no new
principal, credential, policy decision, or rollback authorization. An endpoint's
service token and a journal receipt do not establish an accepted case, caller
session, owner assent, or permission to use a protected execution lane.
## Request and receipt migration
Ensure keeps `groups`, `members`, `idempotency_key` and `correlation_id`.
The original correlation is now part of its immutable binding: the original
idempotency key with a different correlation or group/member request is refused.
The response adds `ensure_idempotency_key`, `ensure_correlation_id`,
`created_groups` (name and exact directory id) and `created_memberships`
(name, directory id and username). The owner journal independently retains the
complete prestate and expected postimage. Response inventories are advisory;
caller-supplied inventories are rejected and never authorize undo.
Revoke retains the original group/member request, its own `idempotency_key` and
`correlation_id`, and optional boolean `delete_if_empty`. It additionally requires
`ensure_idempotency_key` and `ensure_correlation_id` from the original ensure.
Legacy revoke requests fail before directory access. No old deployment inventory
is imported as proof of creation: pre-upgrade groups and memberships cannot be
rolled back automatically without independent owner reconciliation.
An ensure replay returns its original receipt and never recreates subsequently
removed state. Reusing its key after rollback is refused. Revoke replays return
its original outcome; changing deletion options after completion is refused.
There are at most 16 groups, 256 requested membership entries, 4096 retained
preexisting members per operation, and 256 journal records by default. Records
have no timeout or automatic eviction. Capacity exhaustion stops new operations.
## Storage policy and local fixture configuration
The implemented SQLite backend is for local development and disposable tests.
It is not an admitted production storage design. Accepted
[ADR-0015 section3](adr/ADR-0015-netkingdom-railiance-workload-packaging-and-relational-platform.md)
requires production relational storage through CloudNative PG and `rapp-postgres`
using `database-per-consumer`; it explicitly rejects per-workload SQLite-on-a-PVC
as a production pattern. The rail-kubernetes wave-1 contract also does not supply
a generic persistent-storage contract. This source does not authorize adding a
SQLite PVC to identity-provisioner.
Production group operations remain unadmitted and must stay disabled until the
existing owner paths admit a CNPG-backed implementation of the same journal
semantics, credentials, storage/backup/restore and writer-isolation contracts,
or an explicit governed exception to ADR-0015 identifies the exact scope,
limits, duration, recovery and risk owner. No such backend, exception or native
assent is supplied here. `railiance-platform` owns shared database/custody
governance; `rapp-postgres` owns the package consumer provisioning surface.
The identity-provisioner runtime package/operating assignment must also be
explicitly settled rather than inferred from User Engine's dependency on it.
For local/disposable tests only, `GROUP_PROVENANCE_DB` selects an absolute,
non-symlink SQLite path in a private test directory owned by the process UID,
with mode0700 for the directory and mode0600 for the database. The local fixture
explicitly initializes a genuinely new empty store with
`GroupProvenance(absolute_path, initialize=True)`; initialization refuses an
existing file. Ordinary opening never recreates a missing store. The completed
disposable fixture uses private container tmpfs and preserves the file across
separate Python processes; that proves process-restart semantics, not production
durability. `/tmp`, `emptyDir`, tmpfs and an image layer do not satisfy native
retention or recovery duties.
With no configuration, ordinary directory readiness reports
`service_groups: disabled` and group endpoints refuse work. Missing/untrusted or
unavailable configured local storage prevents startup or returns a fixed
`group_provenance` dependency failure without path/database diagnostics.
`service_groups: available` checks capability only: the implementation does not
infer or enforce production admission, policy exceptions, accepted cases or
writer isolation from a filesystem path.
The existing reference Deployment remains one `Recreate` replica at UID/GID10001
with `readOnlyRootFilesystem: true`, no provenance volume and an older immutable
image. Image rebuild, native owner review and credential delivery remain separate
acts. Preserve its nonroot/read-only boundaries. Native retention and recovery
must preserve original idempotency bindings, prestate, confirmed creations and
unresolved intents; losing a journal never authorizes replacement initialization
and replay. No arbitrary JSON import, receipt expiry or automatic uncertainty
clearing is introduced. Owner reconciliation needs independent evidence.
## Safety and remaining native acceptance
Before each directory mutation, durable intent is committed. A successful
response and exact readback are required before recording a confirmed creation.
A definite failure before any mutation attempt retains a `refused` record and
releases the active fence; its original key still cannot retry. Transport loss,
readback failure after an attempted mutation or a process exit leaves pending/uncertain state;
it survives reopening and blocks new mutations instead of becoming retry success.
Every database connection rechecks the private path and original device/inode;
missing/replaced storage is refused. Every intent update must affect exactly one
matching journal row before execution can continue.
The journal records mutation intent and confirmed outcomes, not an atomic LDAP
transaction or a complete audit emission guarantee.
Rollback validates every targeted group id and complete member postimage before
its first destructive operation, and verifies each subsequent change. It removes
only memberships confirmed newly added by that ensure. It deletes only groups
confirmed newly created by that ensure, and only while empty. Preexisting groups,
including empty groups, are never deleted. Existing members are never included
in the removal inventory. Detected shared-member drift or group replacement
refuses the operation before any destructive call and retains uncertainty.
LLDAP exposes no membership-generation token or conditional mutation/CAS here.
Readback cannot detect a remove/re-add ABA sequence or close a read/write race
with an external writer. Native rollback therefore still requires an owner-proven
writer isolation/fencing contract or a provider-side versioned/CAS operation;
these synthetic fixtures do not prove that boundary. A durable SQLite file is
trusted local owner-held state, not a production storage admission, tamper-evident
remote evidence or an authorization broker. Existing caller authentication is unchanged; case/source/principal binding,
per-attempt protected session revocation, independently admitted evidence callbacks,
native storage/backup recovery and native endpoint acceptance remain outstanding
under NK-WP-0048-T14/T16. This source change alone does not finish those tasks.

View file

@ -3,7 +3,7 @@ FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e
RUN useradd --create-home --uid 10001 provisioner
WORKDIR /app
COPY --from=lldap /app/lldap_set_password /app/lldap_set_password
COPY provisioner.py password_setup.py server.py /app/
COPY provisioner.py group_provenance.py password_setup.py server.py /app/
USER 10001:10001
EXPOSE 8080
CMD ["python", "server.py"]

View file

@ -0,0 +1,170 @@
"""Bounded, owner-private recovery journal; never an authorization source.
An unresolved write intent is retained indefinitely and blocks further writes.
Only the directory owner may reconcile it using independently obtained evidence.
"""
from __future__ import annotations
import json
from contextlib import contextmanager
import os
from pathlib import Path
import sqlite3
import stat
from typing import Any
from urllib.parse import quote
class GroupProvenanceUnavailable(RuntimeError):
def __init__(self):
super().__init__("group_provenance_unavailable")
class GroupProvenance:
def __init__(self, path: str, *, capacity: int = 256, initialize: bool = False) -> None:
target = Path(path)
if not target.is_absolute() or target.parent.resolve() != target.parent or capacity < 1 or capacity > 4096:
raise ValueError("private absolute provenance path and bounded capacity required")
parent = target.parent.lstat()
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
raise ValueError("provenance directory must be owner-private")
# Service startup never creates a replacement for missing durable state.
flags = os.O_RDWR | os.O_NOFOLLOW
if initialize:
flags |= os.O_CREAT | os.O_EXCL
fd = os.open(target, flags, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1:
raise ValueError("provenance file must be owner-private and regular")
finally:
os.close(fd)
self.path = str(target)
self._device, self._inode = info.st_dev, info.st_ino
self.capacity = capacity
with self._db() as db:
if initialize:
db.execute("CREATE TABLE group_receipts (key TEXT PRIMARY KEY, binding TEXT NOT NULL, state TEXT NOT NULL, active INTEGER UNIQUE, body TEXT NOT NULL)")
db.execute("PRAGMA user_version=1")
if db.execute("PRAGMA user_version").fetchone()[0] != 1:
raise RuntimeError("unrecognized group provenance schema")
db.execute("SELECT key, binding, state, active, body FROM group_receipts LIMIT 0")
def _check_file(self):
try:
target = Path(self.path)
parent = target.parent.lstat()
info = target.lstat()
if target.parent.resolve() != target.parent or not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
raise GroupProvenanceUnavailable()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_nlink != 1:
raise GroupProvenanceUnavailable()
if (info.st_dev, info.st_ino) != (self._device, self._inode):
raise GroupProvenanceUnavailable()
except OSError:
raise GroupProvenanceUnavailable() from None
def check(self):
with self._db() as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 1:
raise GroupProvenanceUnavailable()
db.execute("SELECT key FROM group_receipts LIMIT 0")
@staticmethod
def _load(body, key, binding):
try:
if not isinstance(body, str) or len(body) > 1048576:
raise GroupProvenanceUnavailable()
value = json.loads(body)
if not isinstance(value, dict) or value.get("key") != key or value.get("binding") != binding:
raise GroupProvenanceUnavailable()
if value.get("state") not in {"pending", "ensured", "revoking", "revoked", "uncertain", "refused"}:
raise GroupProvenanceUnavailable()
if not isinstance(value.get("groups"), dict) or len(value["groups"]) > 16:
raise GroupProvenanceUnavailable()
for field, limit in (("created_groups", 16), ("created_memberships", 256)):
if not isinstance(value.get(field), list) or len(value[field]) > limit or any(not isinstance(item, dict) for item in value[field]):
raise GroupProvenanceUnavailable()
if "intent" not in value or value["intent"] is not None and not isinstance(value["intent"], dict):
raise GroupProvenanceUnavailable()
for group in value["groups"].values():
if not isinstance(group, dict) or "id" not in group or not all(isinstance(group.get(f), list) and all(isinstance(u, str) for u in group[f]) for f in ("before", "after")):
raise GroupProvenanceUnavailable()
if value["state"] in {"ensured", "revoking", "revoked"} and (
not isinstance(value.get("requested_groups"), list) or not isinstance(value.get("ensure_correlation_id"), str)
):
raise GroupProvenanceUnavailable()
return value
except (TypeError, ValueError):
raise GroupProvenanceUnavailable() from None
@contextmanager
def _db(self):
db = None
try:
self._check_file()
db = sqlite3.connect("file:" + quote(self.path, safe="/") + "?mode=rw", uri=True, timeout=5)
self._check_file()
db.execute("PRAGMA synchronous=FULL")
with db:
yield db
except sqlite3.Error:
raise GroupProvenanceUnavailable() from None
finally:
if db is not None:
db.close()
def reserve(self, key: str, binding: str) -> tuple[dict[str, Any], bool]:
with self._db() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone()
if row:
if row[0] != binding:
raise ValueError("ensure idempotency key is bound to another request")
return self._load(row[1], key, binding), False
if db.execute("SELECT COUNT(*) FROM group_receipts").fetchone()[0] >= self.capacity:
raise RuntimeError("provenance capacity reached; retained records require owner disposition")
receipt = {"key": key, "binding": binding, "state": "pending", "intent": None,
"groups": {}, "created_groups": [], "created_memberships": []}
try:
db.execute("INSERT INTO group_receipts VALUES (?, ?, ?, 1, ?)",
(key, binding, "pending", json.dumps(receipt, sort_keys=True)))
except sqlite3.IntegrityError:
raise RuntimeError("another group operation is unresolved") from None
return receipt, True
def get(self, key: str, binding: str) -> dict[str, Any]:
with self._db() as db:
row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone()
if not row or row[0] != binding:
raise ValueError("unknown ensure receipt or request binding mismatch")
return self._load(row[1], key, binding)
def claim_revoke(self, key: str, binding: str, delete_if_empty: bool) -> dict[str, Any]:
with self._db() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT binding, body FROM group_receipts WHERE key=?", (key,)).fetchone()
if not row or row[0] != binding:
raise ValueError("unknown ensure receipt or request binding mismatch")
receipt = self._load(row[1], key, binding)
if receipt["state"] == "revoked":
if receipt["delete_if_empty"] != delete_if_empty:
raise ValueError("completed rollback options cannot change")
return receipt
if receipt["state"] != "ensured":
raise RuntimeError("group provenance unresolved; independent owner recovery required")
receipt.update(state="revoking", delete_if_empty=delete_if_empty)
try:
db.execute("UPDATE group_receipts SET state='revoking', active=1, body=? WHERE key=?",
(json.dumps(receipt, sort_keys=True), key))
except sqlite3.IntegrityError:
raise RuntimeError("another group operation is unresolved") from None
return receipt
def save(self, receipt: dict[str, Any]) -> None:
active = 1 if receipt["state"] in {"pending", "revoking", "uncertain"} else None
with self._db() as db:
written = db.execute("UPDATE group_receipts SET state=?, active=?, body=? WHERE key=? AND binding=?",
(receipt["state"], active, json.dumps(receipt, sort_keys=True), receipt["key"], receipt["binding"]))
if written.rowcount != 1:
raise GroupProvenanceUnavailable()

View file

@ -4,12 +4,15 @@ from __future__ import annotations
from dataclasses import dataclass
import json
import hashlib
import re
import time
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from group_provenance import GroupProvenance, GroupProvenanceUnavailable
@dataclass(frozen=True)
class Result:
@ -19,6 +22,14 @@ class Result:
resumed: bool
@dataclass(frozen=True)
class GroupResult(Result):
ensure_idempotency_key: str
ensure_correlation_id: str
created_groups: tuple[dict[str, Any], ...]
created_memberships: tuple[dict[str, Any], ...]
@dataclass(frozen=True)
class DriftResult:
provider: str
@ -53,7 +64,9 @@ class LLDAPProvisioner:
opener: Callable = urlopen,
auth_rejected_cooldown: float = 30.0,
clock: Callable[[], float] = time.monotonic,
group_provenance: GroupProvenance | None = None,
) -> None:
self.group_provenance = group_provenance
self.base_url = base_url.rstrip("/")
self.admin_password = admin_password
self.opener = opener
@ -217,7 +230,10 @@ mutation Remove($userId: String!, $groupId: Int!) {
if exc.code == "auth_rejected":
self._auth_rejected_until = now + self.auth_rejected_cooldown
raise
return {"status": "ready", "dependency": "directory"}
if self.group_provenance is not None:
self.group_provenance.check()
return {"status": "ready", "dependency": "directory",
"service_groups": "available" if self.group_provenance is not None else "disabled"}
def _login(self, timeout: float = 10) -> str:
return directory_login(
@ -227,53 +243,156 @@ mutation Remove($userId: String!, $groupId: Int!) {
timeout=timeout,
)
def ensure_service_groups(self, payload: dict[str, Any]) -> Result:
"""Create the listed service-access groups if absent and add the listed
memberships. Never removes anything and never creates users."""
groups, members = _service_group_request(payload)
token = self._login()
users, directory_groups = self._directory(token)
known = {str(u.get("id")) for u in users}
unknown = sorted({u for us in members.values() for u in us} - known)
if unknown:
raise ValueError(f"unknown directory users: {unknown}")
existed = {str(g.get("displayName")) for g in directory_groups}
ids = {name: self._ensure_group(token, directory_groups, name) for name in groups}
for group, users_ in members.items():
for username in users_:
self._add_group(token, username, ids[group])
for group, users_ in members.items():
for username in users_:
user = self._user(token, username) or {}
if group not in {str(g.get("displayName")) for g in user.get("groups", ())}:
raise RuntimeError(f"membership of {username!r} in {group!r} did not read back")
return Result("netkingdom-lldap", ",".join(groups), "groups_ensured", set(groups) <= existed)
def _group_store(self) -> GroupProvenance:
store = self.group_provenance
if store is None:
raise GroupProvenanceUnavailable()
store.check()
return store
def revoke_service_groups(self, payload: dict[str, Any]) -> Result:
"""Rollback of ensure_service_groups: remove exactly the listed memberships,
and delete a listed group only when delete_if_empty is true and no member
remains."""
@staticmethod
def _group_binding(groups, members, correlation_id) -> str:
# Original correlation is part of the exact ensure binding.
canonical = {"ensure_correlation_id": correlation_id, "groups": sorted(groups), "members": {g: sorted(members.get(g, [])) for g in sorted(groups)}}
return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
@staticmethod
def _group_result(receipt, *, resumed=False) -> GroupResult:
status = "groups_ensured" if receipt["state"] == "ensured" else (
"groups_revoked_and_deleted" if receipt.get("deleted") else "groups_revoked")
return GroupResult("netkingdom-lldap", ",".join(receipt["requested_groups"]), status, resumed,
receipt["key"], receipt["ensure_correlation_id"], tuple(receipt["created_groups"]), tuple(receipt["created_memberships"]))
def ensure_service_groups(self, payload: dict[str, Any]) -> GroupResult:
"""Additive ensure with durable creation provenance, not new authority.
Replays return the original result, never recreate removed state. Pending
or ambiguous calls survive restart and require independent owner recovery.
"""
groups, members = _service_group_request(payload)
store = self._group_store()
receipt, fresh = store.reserve(payload["idempotency_key"], self._group_binding(groups, members, payload["correlation_id"]))
if not fresh:
if receipt["state"] != "ensured":
raise RuntimeError("group provenance unresolved or already rolled back")
return self._group_result(receipt, resumed=True)
attempted = False
try:
token = self._login()
users, directory_groups = self._directory(token)
known = {str(u.get("id")) for u in users}
if {u for us in members.values() for u in us} - known:
raise ValueError("unknown directory users")
ids = {str(g["displayName"]): int(g["id"]) for g in directory_groups}
receipt["requested_groups"] = sorted(groups)
receipt["ensure_correlation_id"] = payload["correlation_id"]
# Complete prestate is persisted before the first directory mutation.
for name in groups:
gid = ids.get(name)
before = sorted(self._group_members(token, gid)) if gid is not None else []
if len(before) + sum(len(g["before"]) for g in receipt["groups"].values()) > 4096:
raise ValueError("group prestate exceeds bounded provenance capacity")
receipt["groups"][name] = {"id": gid, "before": before, "after": sorted(set(before) | set(members.get(name, [])))}
store.save(receipt)
for name in groups:
state = receipt["groups"][name]
if state["id"] is None:
receipt["intent"] = {"operation": "create_group", "name": name}
store.save(receipt)
attempted = True
gid = self._ensure_group(token, directory_groups, name)
state["id"] = gid
# A successful creation response plus readback is needed.
checked = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]}
if checked.get(name) != gid or self._group_members(token, gid):
raise RuntimeError("created group readback differs")
receipt["created_groups"].append({"name": name, "id": gid})
receipt["intent"] = None
store.save(receipt)
for username in members.get(name, []):
if username in state["before"]:
continue
receipt["intent"] = {"operation": "add_member", "name": name, "id": state["id"], "username": username}
store.save(receipt)
# Unlike the lifecycle helper, duplicate/racing additions are
# ambiguous, never accepted as proof that this attempt created it.
attempted = True
result = self._gql(token, "mutation Add($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }",
{"userId": username, "groupId": state["id"]})
if result.get("addUserToGroup", {}).get("ok") is not True or username not in self._group_members(token, state["id"]):
raise RuntimeError("membership creation did not read back")
receipt["created_memberships"].append({"name": name, "id": state["id"], "username": username})
receipt["intent"] = None
store.save(receipt)
self._check_group_postimage(token, receipt)
receipt["state"] = "ensured"
store.save(receipt)
except Exception:
receipt["state"] = "uncertain" if attempted else "refused"
receipt["failure_phase"] = "mutation_attempted" if attempted else "before_mutation"
store.save(receipt)
raise
return self._group_result(receipt)
def _check_group_postimage(self, token, receipt) -> None:
ids = {str(g["displayName"]): int(g["id"]) for g in self._directory(token)[1]}
for name, state in receipt["groups"].items():
if ids.get(name) != state["id"] or sorted(self._group_members(token, state["id"])) != state["after"]:
raise RuntimeError("group postimage changed; independent owner reconciliation required")
def revoke_service_groups(self, payload: dict[str, Any]) -> GroupResult:
"""Reverse confirmed creations from the owner journal only.
Caller-supplied creation inventories confer no rights. This protects
preexisting state and refuses detected drift; native LLDAP has no CAS or
membership-generation token, so external writer isolation is still an
owner acceptance prerequisite (readback alone cannot exclude ABA).
"""
groups, members = _service_group_request(payload)
_required(payload, "ensure_idempotency_key", "ensure_correlation_id")
delete_if_empty = payload.get("delete_if_empty", False)
if not isinstance(delete_if_empty, bool):
raise ValueError("delete_if_empty must be true or false")
token = self._login()
_, directory_groups = self._directory(token)
ids = {str(g.get("displayName")): int(g["id"]) for g in directory_groups}
for group, users_ in members.items():
if group not in ids:
continue
for username in users_:
user = self._user(token, username) or {}
if group in {str(g.get("displayName")) for g in user.get("groups", ())}:
self._remove_group(token, username, ids[group])
deleted = []
if delete_if_empty:
for group in groups:
if group in ids and not self._group_members(token, ids[group]):
self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": ids[group]})
deleted.append(group)
return Result("netkingdom-lldap", ",".join(groups), "groups_revoked" if not deleted else "groups_revoked_and_deleted", False)
store = self._group_store()
receipt = store.claim_revoke(payload["ensure_idempotency_key"], self._group_binding(groups, members, payload["ensure_correlation_id"]), delete_if_empty)
if receipt["state"] == "revoked":
return self._group_result(receipt, resumed=True)
try:
token = self._login()
# Validate the entire operation before the first destructive call.
self._check_group_postimage(token, receipt)
for edge in receipt["created_memberships"]:
receipt["intent"] = {"operation": "remove_member", **edge}
store.save(receipt)
self._remove_group(token, edge["username"], edge["id"])
expected = receipt["groups"][edge["name"]]["after"]
expected.remove(edge["username"])
self._check_group_postimage(token, receipt)
receipt["intent"] = None
store.save(receipt)
receipt["deleted"] = []
if delete_if_empty:
for group in receipt["created_groups"]:
# Only this attempt's groups, never a preexisting empty group.
self._check_group_postimage(token, receipt)
if self._group_members(token, group["id"]):
raise RuntimeError("created group is no longer empty")
receipt["intent"] = {"operation": "delete_group", **group}
store.save(receipt)
self._gql(token, "mutation Del($id: Int!) { deleteGroup(groupId: $id) { ok } }", {"id": group["id"]})
if any(int(g["id"]) == group["id"] or g["displayName"] == group["name"] for g in self._directory(token)[1]):
raise RuntimeError("group deletion did not read back")
receipt["deleted"].append(group["name"])
del receipt["groups"][group["name"]]
receipt["intent"] = None
store.save(receipt)
receipt["state"] = "revoked"
store.save(receipt)
except Exception:
receipt["state"] = "uncertain"
store.save(receipt)
raise
return self._group_result(receipt)
def _group_members(self, token: str, group_id: int) -> list[str]:
value = self._gql(token, "query G($id: Int!) { group(groupId: $id) { users { id } } }", {"id": group_id})
@ -437,7 +556,19 @@ def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str
platform-suspension and LLDAP admin groups are reserved and can never be
touched through this path.
"""
if not isinstance(payload, dict):
raise ValueError("service group request must be an object")
_required(payload, "groups", "idempotency_key", "correlation_id")
allowed = {"groups", "members", "idempotency_key", "correlation_id", "delete_if_empty", "ensure_idempotency_key", "ensure_correlation_id"}
if set(payload) - allowed:
raise ValueError("unsupported service group request fields")
if not isinstance(payload["idempotency_key"], str) or len(payload["idempotency_key"]) > 256:
raise ValueError("invalid idempotency key")
if "ensure_idempotency_key" in payload and (not isinstance(payload["ensure_idempotency_key"], str) or not 16 <= len(payload["ensure_idempotency_key"]) <= 256):
raise ValueError("invalid ensure idempotency key")
for key in ("correlation_id", "ensure_correlation_id"):
if key in payload and (not isinstance(payload[key], str) or not 1 <= len(payload[key]) <= 256):
raise ValueError("invalid correlation identifier")
groups = payload["groups"]
if not isinstance(groups, list) or not groups or len(groups) > 16:
raise ValueError("groups must be a list of 1 to 16 names")
@ -446,14 +577,18 @@ def _service_group_request(payload: dict[str, Any]) -> tuple[list[str], dict[str
raise ValueError(f"invalid service group name {name!r}")
if name.startswith(RESERVED_GROUP_PREFIXES):
raise ValueError(f"group {name!r} is reserved")
members = payload.get("members") or {}
members = payload.get("members", {})
if not isinstance(members, dict):
raise ValueError("members must map group names to usernames")
total_members = 0
for group, users in members.items():
if group not in groups:
raise ValueError(f"members names group {group!r}, which is not in groups")
if not isinstance(users, list) or not all(isinstance(u, str) and DIRECTORY_USERNAME.fullmatch(u) for u in users):
raise ValueError(f"members of {group!r} must be directory usernames")
total_members += len(users)
if total_members > 256:
raise ValueError("at most 256 service memberships per request")
return list(dict.fromkeys(groups)), {g: list(dict.fromkeys(u)) for g, u in members.items()}

View file

@ -6,6 +6,7 @@ import os
from urllib.parse import parse_qs, urlsplit
import secrets
from group_provenance import GroupProvenance, GroupProvenanceUnavailable
from provisioner import DependencyFailure, LLDAPProvisioner, _directory_username, dispatch
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
@ -23,6 +24,8 @@ class Handler(BaseHTTPRequestHandler):
if path.path == "/readyz":
try:
return self._send(200, self.provisioner.preflight())
except GroupProvenanceUnavailable:
return self._send(503, {"error": "dependency_unavailable", "dependency": "group_provenance"})
except DependencyFailure as exc:
return self._send(503, exc.payload())
except RuntimeError:
@ -53,6 +56,8 @@ class Handler(BaseHTTPRequestHandler):
return self._send(404, {"error": "not_found"})
except (ValueError, json.JSONDecodeError) as exc:
return self._send(400, {"error": "invalid_request", "message": str(exc)})
except GroupProvenanceUnavailable:
return self._send(503, {"error": "dependency_unavailable", "dependency": "group_provenance"})
except DependencyFailure as exc:
return self._send(503, exc.payload())
except RuntimeError:
@ -109,10 +114,19 @@ class Handler(BaseHTTPRequestHandler):
return
def configured_group_provenance():
path = os.environ.get("GROUP_PROVENANCE_DB", "")
try:
return GroupProvenance(path) if path else None
except (OSError, ValueError, RuntimeError):
raise GroupProvenanceUnavailable() from None
def main():
Handler.provisioner = LLDAPProvisioner(
base_url=os.environ["LLDAP_URL"],
admin_password=os.environ["LLDAP_ADMIN_PASSWORD"],
group_provenance=configured_group_provenance(),
)
Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip()
if not Handler.service_token:

View file

@ -199,7 +199,7 @@ class DirectoryContractTests(unittest.TestCase):
def test_preflight_covers_login_and_directory_read(self):
self.assertEqual(
{"status": "ready", "dependency": "directory"},
{"status": "ready", "dependency": "directory", "service_groups": "disabled"},
self.provisioner.preflight(),
)
self.assertEqual(1, self.state.logins)

View file

@ -0,0 +1,326 @@
"""Synthetic actual provisioner/HTTP/storage proof; no live LDAP or authority."""
import concurrent.futures
from dataclasses import asdict
from http.server import ThreadingHTTPServer
import json
import os
import sqlite3
from pathlib import Path
import tempfile
import threading
import unittest
from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from group_provenance import GroupProvenance
from provisioner import LLDAPProvisioner, dispatch
from server import Handler, configured_group_provenance
from test_service_groups import Directory, req
def rollback(**kw):
return req(ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"], **kw)
class ProvenanceTests(unittest.TestCase):
def test_inventory_excludes_preexisting_membership_and_empty_group(self):
d = Directory()
d.members[3].add("tegwick")
result = d.ensure_service_groups(req())
self.assertEqual([{"name": "forge-coulomb-owners", "id": 4}, {"name": "forge-coulomb-readers", "id": 5}], list(result.created_groups))
self.assertEqual([{"name": "forge-coulomb-owners", "id": 4, "username": "tegwick"}], list(result.created_memberships))
d.revoke_service_groups(rollback(delete_if_empty=True))
self.assertEqual({"tegwick"}, d.members[3])
self.assertIn("forge-admins", d.groups)
self.assertNotIn("forge-coulomb-owners", d.groups)
def test_preexisting_empty_group_never_deleted(self):
d = Directory()
d.ensure_service_groups(req())
d.revoke_service_groups(rollback(delete_if_empty=True))
self.assertIn("forge-admins", d.groups)
self.assertEqual(set(), d.members[3])
def test_unknown_receipt_and_digest_mismatch_before_login(self):
d = Directory()
with self.assertRaises(ValueError):
d.revoke_service_groups(rollback())
d.ensure_service_groups(req())
d.calls.clear()
with patch.object(d, "_login", side_effect=AssertionError("must not login")):
with self.assertRaises(ValueError):
d.revoke_service_groups(rollback(members={"forge-admins": ["agent-codex"]}))
with self.assertRaises(ValueError):
d.ensure_service_groups(req(groups=["another-group"], members={}))
self.assertEqual([], d.calls)
def test_unsigned_inventory_and_legacy_revoke_cannot_mutate(self):
d = Directory()
d.ensure_service_groups(req())
d.calls.clear()
for payload in [req(), rollback(created_groups=[{"name": "forge-admins", "id": 3}]), rollback(created_memberships=[]), rollback(provenance={})]:
with self.assertRaises(ValueError):
d.revoke_service_groups(payload)
self.assertEqual([], d.calls)
def test_shared_membership_drift_refuses_all_destructive_calls(self):
d = Directory()
d.ensure_service_groups(req())
d.members[3].add("agent-codex")
d.calls.clear()
with self.assertRaisesRegex(RuntimeError, "postimage"):
d.revoke_service_groups(rollback(delete_if_empty=True))
self.assertEqual([], d.calls)
self.assertEqual({"tegwick", "agent-codex"}, d.members[3])
def test_replaced_group_identity_refuses_all_destructive_calls(self):
d = Directory()
d.ensure_service_groups(req())
d.groups["forge-admins"] = 10
d.members[10] = {"tegwick"}
d.calls.clear()
with self.assertRaisesRegex(RuntimeError, "postimage"):
d.revoke_service_groups(rollback())
self.assertEqual([], d.calls)
def test_successful_ensure_replay_never_recreates_later_removed_state(self):
d = Directory()
first = d.ensure_service_groups(req())
d.members[3].clear()
d.calls.clear()
replay = d.ensure_service_groups(req())
self.assertTrue(replay.resumed)
self.assertEqual(first.created_memberships, replay.created_memberships)
self.assertEqual([], d.calls)
self.assertEqual(set(), d.members[3])
def test_interrupted_add_persists_intent_and_blocks_restart_retry(self):
d = Directory()
original = d._gql
def lost_response(token, query, variables):
result = original(token, query, variables)
if "addUserToGroup" in query:
raise RuntimeError("synthetic lost response")
return result
d._gql = lost_response
with self.assertRaises(RuntimeError):
d.ensure_service_groups(req())
d.group_provenance = GroupProvenance(d.group_provenance.path)
receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"]))
self.assertEqual("uncertain", receipt["state"])
self.assertEqual("add_member", receipt["intent"]["operation"])
self.assertEqual([], receipt["created_memberships"])
d.calls.clear()
for operation, payload in [(d.ensure_service_groups, req()), (d.revoke_service_groups, rollback()), (d.ensure_service_groups, req(idempotency_key="different-key-is-not-recovery"))]:
with self.assertRaises(RuntimeError):
operation(payload)
self.assertEqual([], d.calls)
def test_interrupted_rollback_retains_confirmed_inventory_and_pending_intent(self):
d = Directory()
d.ensure_service_groups(req())
original = d._remove_group
def lost_response(*args):
original(*args)
raise RuntimeError("lost remove response")
d._remove_group = lost_response
with self.assertRaises(RuntimeError):
d.revoke_service_groups(rollback())
d.group_provenance = GroupProvenance(d.group_provenance.path)
receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"]))
self.assertEqual("uncertain", receipt["state"])
self.assertEqual("remove_member", receipt["intent"]["operation"])
self.assertEqual(2, len(receipt["created_memberships"]))
d.calls.clear()
with self.assertRaises(RuntimeError):
d.revoke_service_groups(rollback())
self.assertEqual([], d.calls)
def test_unresolved_pre_call_intent_survives_hard_process_loss(self):
d = Directory()
d._gql = lambda *_: (_ for _ in ()).throw(SystemExit("process lost before response"))
with self.assertRaises(SystemExit):
d.ensure_service_groups(req())
d.group_provenance = GroupProvenance(d.group_provenance.path)
receipt = d.group_provenance.get(req()["idempotency_key"], d._group_binding(req()["groups"], req()["members"], req()["correlation_id"]))
self.assertEqual("pending", receipt["state"])
self.assertEqual("add_member", receipt["intent"]["operation"])
with self.assertRaises(RuntimeError):
d.ensure_service_groups(req())
def test_missing_config_private_permissions_and_lost_database_fail_closed(self):
with patch.dict(os.environ, {}, clear=True):
self.assertIsNone(configured_group_provenance())
d = Directory()
path = d.group_provenance.path
with patch.dict(os.environ, {"GROUP_PROVENANCE_DB": path}):
self.assertIsInstance(configured_group_provenance(), GroupProvenance)
os.chmod(path, 0o644)
with self.assertRaises(RuntimeError):
configured_group_provenance()
os.chmod(path, 0o600)
os.unlink(path)
with self.assertRaises(RuntimeError):
configured_group_provenance()
self.assertFalse(Path(path).exists())
provider = LLDAPProvisioner(base_url="https://invalid", admin_password="synthetic")
with patch.object(provider, "_login", side_effect=AssertionError("no directory")):
with self.assertRaises(RuntimeError):
provider.ensure_service_groups(req())
with self.assertRaises(RuntimeError):
provider.revoke_service_groups(rollback())
def test_missing_intent_row_stops_before_any_directory_effect(self):
d = Directory()
store = d.group_provenance
original_save = store.save
def lose_row(receipt):
if receipt["intent"] is not None:
with sqlite3.connect(store.path) as db:
db.execute("DELETE FROM group_receipts")
original_save(receipt)
store.save = lose_row
with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"):
d.ensure_service_groups(req())
self.assertEqual([], d.calls)
def test_live_instance_rejects_replaced_database_before_effect(self):
d = Directory()
original = d.group_provenance
replacement = str(Path(d.storage.name) / "replacement.db")
GroupProvenance(replacement, initialize=True)
os.replace(replacement, original.path)
with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"):
d.ensure_service_groups(req())
self.assertEqual([], d.calls)
def test_failed_before_mutation_is_retained_without_stranding_other_keys(self):
for failure in ("unknown-user", "oversized-prestate", "directory-read-failed"):
d = Directory()
old_members = d._group_members
request = req(members={"forge-admins": ["nobody"]}) if failure == "unknown-user" else req()
if failure == "oversized-prestate":
d._group_members = lambda *_: [f"synthetic{i}" for i in range(4097)]
if failure == "directory-read-failed":
d._group_members = lambda *_: (_ for _ in ()).throw(RuntimeError("read failed"))
with self.assertRaises((ValueError, RuntimeError)):
d.ensure_service_groups(request)
binding = d._group_binding(request["groups"], request["members"], request["correlation_id"])
receipt = d.group_provenance.get(request["idempotency_key"], binding)
self.assertEqual("refused", receipt["state"])
self.assertEqual("before_mutation", receipt["failure_phase"])
self.assertEqual([], d.calls)
d._group_members = old_members
with self.assertRaises(RuntimeError):
d.ensure_service_groups(request)
self.assertEqual("groups_ensured", d.ensure_service_groups(req(idempotency_key="another-valid-operation-key")).status)
def test_current_correlations_are_bound_to_original_ensure(self):
d = Directory()
d.ensure_service_groups(req())
d.calls.clear()
with self.assertRaises(ValueError):
d.ensure_service_groups(req(correlation_id="other-case"))
payload = rollback()
payload["ensure_correlation_id"] = "other-case"
with self.assertRaises(ValueError):
d.revoke_service_groups(payload)
self.assertEqual([], d.calls)
def test_readiness_separates_directory_health_from_group_capability(self):
provider = LLDAPProvisioner(base_url="https://invalid", admin_password="synthetic")
provider._login = lambda **_: "synthetic"
provider._gql = lambda *_args, **_kw: {"groups": []}
self.assertEqual("disabled", provider.preflight()["service_groups"])
d = Directory()
provider.group_provenance = d.group_provenance
self.assertEqual("available", provider.preflight()["service_groups"])
os.chmod(d.group_provenance.path, 0o644)
with self.assertRaisesRegex(RuntimeError, "group_provenance_unavailable"):
provider.preflight()
def test_concurrent_sqlite_reservation_allows_one_original_attempt(self):
d = Directory()
path = d.group_provenance.path
def reserve(_):
return GroupProvenance(path).reserve("synthetic-concurrent-key", "same-binding")[1]
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
fresh = list(pool.map(reserve, range(24)))
self.assertEqual(1, sum(fresh))
def test_bounded_records_never_age_or_evict_into_authority(self):
d = Directory()
store = GroupProvenance(d.group_provenance.path, capacity=1)
receipt, _ = store.reserve("one", "binding")
receipt.update(state="ensured", requested_groups=[], ensure_correlation_id="test")
store.save(receipt)
with self.assertRaisesRegex(RuntimeError, "capacity"):
store.reserve("two", "binding")
self.assertFalse(store.reserve("one", "binding")[1])
def test_rollback_replay_and_key_reuse_do_not_recreate(self):
d = Directory()
d.ensure_service_groups(req())
first = d.revoke_service_groups(rollback(delete_if_empty=True))
d.calls.clear()
result = d.revoke_service_groups(rollback(delete_if_empty=True))
self.assertEqual(first.status, result.status)
self.assertTrue(result.resumed)
with self.assertRaises(RuntimeError):
d.ensure_service_groups(req())
with self.assertRaises(ValueError):
d.revoke_service_groups(rollback(delete_if_empty=False))
self.assertEqual([], d.calls)
def test_real_http_handler_reinitialized_from_config_retains_receipt(self):
d = Directory()
path = d.group_provenance.path
def boot():
with patch.dict(os.environ, {"GROUP_PROVENANCE_DB": path}):
provider = LLDAPProvisioner(base_url="https://synthetic.invalid", admin_password="unused", group_provenance=configured_group_provenance())
# Only directory transport is synthetic; actual initialized adapter,
# dispatch, HTTP authentication, configuration and SQLite are used.
for name in ("_login", "_directory", "_group_members", "_ensure_group", "_gql", "_remove_group"):
setattr(provider, name, getattr(d, name))
class LocalHandler(Handler):
provisioner = provider
service_token = "synthetic-service-token"
server = ThreadingHTTPServer(("127.0.0.1", 0), LocalHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def stop(server, thread):
server.shutdown(); server.server_close(); thread.join()
def post(server, endpoint, payload):
request = Request(f"http://127.0.0.1:{server.server_port}/v1/groups/{endpoint}", data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer synthetic-service-token"})
with urlopen(request) as response:
return json.load(response)
server, thread = boot()
try:
created = post(server, "ensure", req())
finally:
stop(server, thread)
server, thread = boot()
try:
d.calls.clear()
replay = post(server, "ensure", req())
self.assertTrue(replay["resumed"])
self.assertEqual(created["created_memberships"], replay["created_memberships"])
self.assertEqual([], d.calls)
revoked = post(server, "revoke", rollback(delete_if_empty=True))
self.assertEqual("groups_revoked_and_deleted", revoked["status"])
self.assertIn("forge-admins", d.groups)
with self.assertRaises(HTTPError) as error:
post(server, "revoke", req())
self.assertEqual(400, error.exception.code)
d.calls.clear()
os.chmod(path, 0o644)
with self.assertRaises(HTTPError) as error:
post(server, "ensure", req(idempotency_key="different-invocation-key"))
self.assertEqual(503, error.exception.code)
self.assertEqual({"error": "dependency_unavailable", "dependency": "group_provenance"}, json.load(error.exception))
self.assertEqual([], d.calls)
finally:
stop(server, thread)

View file

@ -1,4 +1,8 @@
import unittest
import tempfile
from pathlib import Path
from group_provenance import GroupProvenance
from provisioner import LLDAPProvisioner, dispatch
@ -7,6 +11,8 @@ class Directory(LLDAPProvisioner):
"""In-memory LLDAP: users, groups and memberships."""
def __init__(self):
self.storage = tempfile.TemporaryDirectory()
self.group_provenance = GroupProvenance(str(Path(self.storage.name) / "groups.db"), initialize=True)
self.users = {"tegwick", "agent-codex"}
self.groups = {"lldap_admin": 1, "tenant:trial:one:users": 2, "forge-admins": 3}
self.members = {1: {"tegwick"}, 2: set(), 3: set()}
@ -44,6 +50,9 @@ class Directory(LLDAPProvisioner):
return sorted(self.members[group_id])
def _gql(self, token, query, variables):
if "addUserToGroup" in query:
self._add_group(token, variables["userId"], variables["groupId"])
return {"addUserToGroup": {"ok": True}}
if "deleteGroup" in query:
self.calls.append(("delete", variables["id"]))
name = next(n for n, i in self.groups.items() if i == variables["id"])
@ -108,18 +117,18 @@ class EnsureTests(unittest.TestCase):
class RevokeTests(unittest.TestCase):
def test_removes_only_listed_memberships_and_keeps_groups_by_default(self):
d = Directory()
d.members[3].add("agent-codex")
dispatch(d, "/v1/groups/ensure", req())
d.members[d.groups["forge-admins"]].add("agent-codex")
r = dispatch(d, "/v1/groups/revoke", req())
r = dispatch(d, "/v1/groups/revoke", req(ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"]))
self.assertEqual("groups_revoked", r.status)
self.assertEqual({"agent-codex"}, d.members[d.groups["forge-admins"]])
self.assertIn("forge-coulomb-owners", d.groups)
def test_deletes_only_empty_listed_groups_when_asked(self):
d = Directory()
d.members[3].add("agent-codex")
dispatch(d, "/v1/groups/ensure", req())
d.members[d.groups["forge-admins"]].add("agent-codex")
r = dispatch(d, "/v1/groups/revoke", req(delete_if_empty=True))
r = dispatch(d, "/v1/groups/revoke", req(delete_if_empty=True, ensure_idempotency_key=req()["idempotency_key"], ensure_correlation_id=req()["correlation_id"]))
self.assertEqual("groups_revoked_and_deleted", r.status)
self.assertIn("forge-admins", d.groups)
self.assertNotIn("forge-coulomb-owners", d.groups)

View file

@ -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())

View file

@ -14,6 +14,7 @@ EXPECTED_RECIPIENT="${EXPECTED_AGE_RECIPIENT:-}"
SOURCE_FILE=""
INPUT_MODE=""
CHECK_ONLY=false
SEALED_FD=false
COMMAND=()
usage() {
@ -27,6 +28,7 @@ Options:
--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 @@ while [[ $# -gt 0 ]]; do
INPUT_MODE="prompt"
shift
;;
--sealed-fd)
SEALED_FD=true
shift
;;
--check-only)
CHECK_ONLY=true
shift
@ -121,6 +127,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")"
KEY_FILE="$TMP_DIR/keys.txt"
cleanup() {
@ -185,7 +196,7 @@ if [[ "$CHECK_ONLY" == true ]]; then
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"

View file

@ -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 age_document(source, recipient, *, raw=None):
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))

View file

@ -70,3 +70,16 @@ CoulombCore identity cutover and retained cluster deletion completed on
[NK-WP-0022](../../workplans/NK-WP-0022-railiance01-identity-cutover-and-coulombcore-retirement.md).
Railiance01 is authoritative. Recover through current owner runbooks and
protected independent backups; the old cluster and its local PVCs are gone.
### Identity-provisioner service group provenance
The [owner storage and request migration contract](../../docs/service-group-provenance.md)
describes the durable journal required by governed service-group ensure/revoke.
The existing reference identity-provisioner Deployment has a read-only root
filesystem, UID10001 and no provenance volume. The implemented SQLite backend
is local/disposable only. Accepted ADR-0015 rejects SQLite-on-a-PVC for production;
native journal storage requires the CNPG/`rapp-postgres` consumer contract or an
explicit governed exception, plus owner-admitted image/storage/caller and writer
isolation. Keep production group operations disabled pending that admission.
This guidance does not apply or authorize a native volume change.

View file

@ -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",

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -382,6 +382,7 @@ m.reaper_main(json.loads(sys.argv[2]),json.loads(sys.argv[3]),json.loads(sys.arg
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 @@ def execute(transfer,admission,tls,receipt,execution,checkpoint=None):
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']))

View file

@ -34,6 +34,32 @@ def load_bundle():
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):
"""Bind the complete validated archive before selecting KeyCape material.

View file

@ -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())

View file

@ -14,6 +14,9 @@ done
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 @@ print('OK: fixed local target and cached pinned images available.')
PY
NK49_KC_RUN="$(mktemp -d /tmp/nk49-isolated-keycape-XXXXXXXX)"
printf 'Safe evidence directory: %s\n' "$NK49_KC_RUN"
printf '%s\n' 'Owner-reviewed candidate: isolated original issuer/trust and current registered service-client checks only.'
printf '%s\n' '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"

View file

@ -9,7 +9,7 @@ flavor: implementation
owner: claude-code
topic_slug: netkingdom
created: "2026-10-08"
updated: "2026-10-16"
updated: "2026-10-17"
depends_on: []
related_repos:
- ops-mason
@ -323,7 +323,7 @@ the rationale. The type is `acknowledged`; it becomes `implemented` with T06.
```task
id: NK-WP-0048-T06
status: wait
depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, NK-WP-0048-T13, NK-WP-0048-T14, NK-WP-0048-T15, NK-WP-0048-T16, NK-WP-0048-T17, NK-WP-0048-T18, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, NK-WP-0048-T13, NK-WP-0048-T14, NK-WP-0048-T15, NK-WP-0048-T16, NK-WP-0048-T17, NK-WP-0048-T18, NK-WP-0048-T20, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
blocking_reason: "Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status."
priority: high
state_hub_task_id: "d9b26bcf-a7c6-59a2-92a0-f913d76a203c"
@ -686,24 +686,48 @@ hook; exact pre-existing staged/working brief bytes were restored. The separate
canonical integration receipt retains those refusals and projection evidence.
Native admission and all other-owner acceptance remain open.
## T14 — Preserve created-only provenance for group ensure and revoke
## T14 — Admit governed group rollback against native owner boundaries
```task
id: NK-WP-0048-T14
status: todo
depends_on: [NK-WP-0048-T20]
priority: high
state_hub_task_id: "fce95440-a25b-5f27-8ef3-a3d182099113"
```
Owner: NetKingdom. Next action: extend or wrap the existing identity-provisioner
ensure/revoke contract with independently retained prestate and exact newly
created groups/memberships before exposing reversible owner callbacks. Current
ensure returns no membership-creation inventory; current revoke may remove a
membership that predated this case. Completion requires actual helper-backed
positive/refusal fixtures, all validation before mutation, per-attempt evidence
and revocation, readback, ambiguity handling, and rollback that preserves every
pre-existing/shared group and membership. No existing endpoint or service token
is treated as new authorization.
Owner: NetKingdom. Next action: specify a CNPG-backed journal through the
existing `rapp-postgres` database-per-consumer contract, or obtain an explicit
scoped governed exception to accepted ADR-0015 before any production SQLite
proposal. The implemented SQLite backend supplies local/disposable proof only;
a new PVC is not an admitted production path. Prepare the native writer-isolation
contract for the existing identity-provisioner, and bind the
existing protected owner callbacks to the accepted case/source/principal,
per-attempt evidence and session revocation. Identify the actual deployment and
custody owner through the existing ADR-0015 routes before proposing a rollout;
an existing service token, caller-supplied JSON or provenance receipt establishes
no new authorization.
2026-10-17 refinement: T20 extracts the completed durable provenance source
candidate and disposable LLDAP compatibility proof from this task. The source
returns exact creation inventories, retains prestate/intents across process
restart and refuses detected shared-member/group-identity drift. Its publication
and projection are still pending the coordinator's separate integration receipt.
This narrows T14 without closing its original externally referenced native gate.
The task remains `todo` because preparation of the native contracts can proceed;
no missing owner assent is represented as received.
Completion still requires independently reviewed/admitted ADR-0015-conforming
CNPG storage (or an explicit governed exception), backup/lost-store recovery and
migration of legacy requests; a native
writer-isolation/fencing or provider CAS/version contract that closes external
writer races and remove/re-add ABA; exact case/source/principal and current
native object bindings for protected callbacks; and actual per-attempt evidence,
revocation and readback/refusal verification through the existing owner paths.
Only independently retained confirmed creations may be undone. Every
pre-existing/shared group and membership must remain preserved; unresolved
intents never expire into retry authority. The disposable fixture supplies none
of these native custody, authorization, owner-acceptance or production claims.
## T15 — Define authenticated recovery for lost registration process state
@ -880,3 +904,74 @@ projection. T19's assigned UUID is retained. The original staged and working bri
bytes were restored and independently compared. The exact source, projection and
publication checks are in Custodian browser-resume-r1/canonical-integration.json.
Native acceptance remains open under T16 and the other named owner tasks.
## T20 — Prepare durable group provenance and prove disposable LLDAP compatibility
```task
id: NK-WP-0048-T20
status: done
priority: high
state_hub_task_id: "863fcbdb-e246-51ba-87ca-a2f44d01d6f8"
```
Completed 2026-10-17, source candidate preparation and bounded synthetic
compatibility proof only. This extracts evidenced implementation/fixture scope
from T14; T14 retains native storage, writer isolation, protected callback and
owner-acceptance gates. Source integration/publication and State Hub projection
are pending the coordinator's separate receipts; no revision or task UUID is
invented here.
The existing identity-provisioner group ensure/revoke interface now has a
reviewed candidate for a bounded owner-private SQLite journal, proven only in
local/disposable use. The initial storage instructions wrongly suggested a native
persistent-volume path; source review against accepted ADR-0015 corrected them
before publication. Production SQLite-on-a-PVC is not admitted: T14 retains
CNPG/rapp-postgres storage admission or a separately governed exception.
The local journal saves
complete prestate and intent before each attempted directory mutation, inventories
only confirmed new groups/memberships, binds original ensure idempotency and
correlation to the exact request digest, and refuses unsigned caller inventories
or legacy broad revoke requests. Replays never recreate removed state. Missing,
replaced or untrusted storage and missing intent rows stop execution; definite
before-mutation refusals retain evidence without stranding unrelated valid work.
Interrupted writes/readback remain unresolved across reopening. Revoke validates
all targeted native ids and complete member postimages before destructive work,
removes only confirmed creations and never deletes a preexisting empty group.
Server/configuration wiring, image-copy closure and owner storage/request
migration instructions are included. The existing endpoint's caller
authentication remains the existing boundary, not a new grant.
The candidate was prepared against NetKingdom
`878abc93588e12b6da1bac055cfbb3abb8d7adf1`; exact source patch digest
`sha256:1f3f3ca94c46dadac8f24896053d4d23179de795e50a24298a0cfbcc46cca30e`.
The independent Custodian review passed all 65 provisioner checks. The earlier
62-check candidate, two independent-review findings and subsequent fixes remain
retained rather than overwritten. Seven additional fixture-plan checks exercise
the preparation boundary; these counts overlap the same candidate and do not
measure task-completion percentage or independent acceptance.
A separately reviewed bounded fixture then exercised the exact candidate against
cached pinned disposable LLDAP and Python images, through actual GraphQL. All 14
closed compatibility/containment oracles passed: exact creation inventory,
separate-process SQLite reopening/replay/revoke, complete preexisting directory
preservation including empty/shared groups and memberships, refusal of shared
membership drift, retained uncertainty after a lost response following a real
addition, and refusal of unknown/cross-case receipt bindings. The fixture used
only newly generated synthetic users/passwords/key material in private tmpfs,
with a network-none anchor and shared loopback helper, no host ports/mounts,
no image pulls and no production or actual credential contact. The coordinator's
safe receipt reports both exact owned containers absent after cleanup; final
independent cleanup verification remains the coordinator's integration duty.
Evidence: Custodian
`docs/changes/netkingdom-target-2026-10-16/group-provenance-r1/`, including exact
source base/pre/postimages, patch/manifest, independent review and retained
refinements. Native fixture manifest
`sha256:8a0514f5f14137355972400cd4852f48c892316d28e3abd3c86635d7cb81eef2`;
retained safe result `native-fixture/execution-result.json` (original
`/tmp/nk48-groups-native-review-5pywb19z/result.json`). The source task supplies
no native owner assent, production rollout, protected session/evidence callbacks,
ordinary type admission or consumer login proof. LLDAP has no conditional
membership mutation or generation token here: exact readback cannot exclude
ABA or a concurrent external writer, so T14 remains open.

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: netkingdom
created: "2026-10-12"
updated: "2026-10-16"
updated: "2026-10-17"
flavor: implementation
depends_on: []
state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804"
@ -28,8 +28,8 @@ These are retrospective task records of evidenced work, not additional delivery
or newly granted authority. The earlier dated narrative remains in Git at
`cc2dd01`; its evidence references are carried into the tasks below.
**Current position:** 22 tasks done; 2 in progress; 1 ready to start (`todo`);
6 waiting on explicit prerequisites. These are task counts, not an effort-based
**Current position, source refinement 2026-10-17:** 24 tasks done; 2 in
progress; 3 ready to start (`todo`); 7 waiting on explicit prerequisites. These are task counts, not an effort-based
completion percentage. Assignees are also written in task descriptions because
the current Forge projector does not populate the dedicated Hub assignee field. The outage-retrieval tool and restore-packet draft are now prepared;
independent outage retrieval and Storage Share publication are accepted;
@ -958,27 +958,150 @@ retained, and scanner policy/exemptions are unchanged.
```task
id: NK-WP-0049-T33
status: todo
status: wait
priority: high
assignee: codex
depends_on: [NK-WP-0049-T32]
depends_on: [NK-WP-0049-T32, NK-WP-0049-T34, NK-WP-0049-T35, NK-WP-0049-T36]
blocking_reason: "Source custody/preflight preparation is evidenced in T34; await original TLS/trust recovery disposition T35 and selected original registration/private-admission metadata T36 before final reviewed command and existing owner process."
state_hub_task_id: "56c8d469-7a10-5d50-a30e-95cbf3cd4756"
```
**Accountable worker:** NetKingdom codex with KeyCape and selected consumer owners.
Prepare the exact archive/manifest, original issuer/TLS/trust and executable
bindings, selected current registration and dependency graph using public metadata.
Use the existing independently held archive-key and owner-controlled TLS custody
paths; never put secret values in the packet. Validate the attended launcher and
its full dependency closure, execution deadline, recovery obligations and safe
receipt path before asking for any owner-held input. Bind approved source revision
and rollback/abort limits through the existing owner process.
This umbrella retains final attempt readiness. Completed custody/preflight source
preparation is extracted into T34; T35 resolves original TLS/trust recovery and
T36 prepares the selected original registration and private admission. This split
records distinct outcomes without counting source preparation as recovery.
**Done when:** reviewed executable command and metadata-only admission packet
are ready for the existing attended custody path; prerequisites and independent
receipt verification are explicit. T18 still owns actual execution, original
consumer continuity and acceptance. T19 retains retained-factor MFA. This task
neither authorizes a production consumer redirect nor creates a new client grant.
After those deliverables, assemble the exact existing six-argument attended
command and admission digest, bind the integrated source/dependency revision and
fixed local target, and record execution deadline, safe receipt location, abort,
rollback and independent receipt verification through the existing owner process.
Keep private manifest derivation inside custody and original TLS/client/key inputs
in the owner's own terminal; the current null fields must refuse execution.
Existing session authorization is retained within scope; this record does not
supply missing owner assent or approve any actual recovery attempt.
**Done when:** reviewed executable command and complete metadata-only/private
admission packet are ready for the existing attended custody path; prerequisites
and independent receipt verification are explicit. T18 still owns actual
execution, original consumer continuity and acceptance. T19 retains retained-factor
MFA. This task neither authorizes a production consumer redirect nor creates a
new client grant.
## Deliver attended custody, metadata preflight and private manifest preparation source
```task
id: NK-WP-0049-T34
status: done
priority: high
assignee: codex
depends_on: [NK-WP-0049-T32]
state_hub_task_id: "821b3a56-6f36-5812-abc9-5a00e37cb2e7"
```
**Accountable worker:** NetKingdom codex; independent Custodian source review.
Completed 2026-10-17 as source preparation. Existing attended custody entry points
now have a reviewed candidate sealed anonymous descriptor mode: hidden prompt,
actual public-recipient verification, strict owned/private/full-seal descriptor
admission, non-inheritable returned duplicate and explicit descriptor passing.
The existing file mode stays compatible, with its original hard-loss limitation;
no improved file-mode cleanup is claimed. Exact parent-loss tests prove descriptor
lifetime without signalling inherited user shell groups. An admitted child retains
its descriptor until it exits; parent loss is not child revocation.
The candidate checks bounded, duplicate-free public JSON, exact admission digest,
transfer/archive and selected service bindings, typed hashes for all 17 invoked
local source/config dependencies and required Python imports before requesting a
key. TLS and ciphertext preparation uses file metadata only. Legacy explicit
manifest digests remain supported; versioned ciphertext-derived binding computes
the canonical digest privately only after exact reviewed ciphertext, authenticated
age decryption and full existing archive validation. The derived private digest
never enters safe receipts/checkpoints and grants no new consumer authority.
The coordinator independently passed 111 focused checks. The existing custody
regression suite passed 94 checks; 61 real-SOPS tests skipped because that trusted
runtime is unavailable, so that compatibility coverage remains unverified. Tests
use generated keys/archive material only, including actual age cryptography,
synthetic hidden-prompt and exact parent-loss processes. Manifest-builder tests
mock Docker/issuer behavior and confer no new native proof. Frozen R6/R7 proof,
intermediate test failures and scanner history remain retained.
Bounded public image readback separately establishes the cached original image's
13670142-byte `/keycape` executable as
`sha256:cd89121393e1465aa76009763602a5b8c75f9dddb5679aea6242623f135b51b4`.
This is executable identity metadata, not recovery or custody acceptance.
Evidence: Custodian `docs/changes/netkingdom-target-2026-10-16/recovery-attempt-r1/`
(`candidate.patch`, `review.json`, source closure, attempt/admission metadata and
`public-verifier-image-readback.json`). Canonical integration/publication and
projection receipts must be recorded separately when performed; this completed
source-preparation record does not invent them. T33 remains open for complete
attempt readiness; actual recovery/consumer acceptance remains T18 and MFA T19.
## Establish original KeyCape TLS and trust recovery ownership for outage use
```task
id: NK-WP-0049-T35
status: todo
priority: high
assignee: codex
depends_on: [NK-WP-0049-T34]
state_hub_task_id: "1bfd1703-589f-5511-bad5-812d860e4a71"
```
**Accountable worker:** NetKingdom codex, coordinating with the source owner of
Ingress/cert-manager/Traefik and the original TLS custody/provider boundary.
**Next action:** identify and record that actual owner's existing recovery path,
then obtain its metadata/disposition through the ordinary owner process. A missing
exact credential-routing catalog match is not evidence that no owner exists.
Current source declares `kc.coulomb.social`, `sso/kc-tls`, cert-manager
`ClusterIssuer/letsencrypt-prod` and Traefik TLS termination before KeyCape 8080.
The identity backup profile excludes `kc-tls`; the verified archive therefore
establishes no independent recovery of that TLS identity/trust. Determine whether
the exact original certificate/key/trust is independently recoverable during an
issuer/cluster outage, or whether a documented reissue path is acceptable for the
selected continuity claim. Do not read or place the TLS key in work records, use
a fresh self-signed trust substitute, mutate production or claim owner assent.
**Done when:** named owner/provider, independent outage prerequisites, original
public identity/certificate/trust hashes or accepted reissue disposition, private
custody entry point and verification/abort limits are explicit and reviewed.
Record any unavailable recovery capability as a live owner work record and bind
its dependency/unblock condition; a prose-only gap or untested archive is not
completion. This delivers preparation/disposition, not actual TLS recovery.
## Prepare selected original service registration and private admission bindings
```task
id: NK-WP-0049-T36
status: todo
priority: high
assignee: codex
depends_on: [NK-WP-0049-T34]
state_hub_task_id: "8b72334b-fc27-5707-a189-bad345eaf3bc"
```
**Accountable worker:** NetKingdom codex with KeyCape and the selected existing
service consumer's owner. **Next action:** identify that owner and the exact
original registration's non-secret selector metadata using existing source/owner
evidence; preserve independent private custody and never retrieve a client secret.
Bind reviewed ciphertext `7117b3fe1e4840c86b301c94c85e6b16709e5626bb0884bd0580bde630e30e30`,
its independently verified transfer, source/image/executable closure and an
explicit versioned manifest-binding mode. For the original service registration,
record client identifier, secret environment name, audience, granted scope,
subject, tenant, required roles and denied scope without values or new grants.
Describe the native service-client dependency graph and distinguish unexercised
LLDAP/Authelia/privacyIDEA/downstream flows. Keep private manifest material inside
the existing custody process, never in Git, safe receipts, State Hub or chat.
**Done when:** the selected existing service owner and exact non-secret registration
bindings are independently established, the private admission builder validates
positive/negative source/archive/selector cases, and its metadata is ready to
combine with T35's original TLS/trust disposition under T33. This task may prepare
registration metadata while T35 proceeds independently; it does not finalize the
whole admission, perform actual recovery, redirect a consumer or confer assent.
## Accept remaining recovery and caller evidence and close the G08 handoff
@ -999,7 +1122,7 @@ Owner boundary: NetKingdom, Platform and affected service owners, with Custodian
G08 receiving the final evidence. The completed archive/job milestones remain
accepted independently of this final gate.
**Done when:** T02 and T15–T31 have their stated completed or explicitly accepted
**Done when:** T02 and T15–T36 have their stated completed or explicitly accepted
handoff outcomes; service authentication/token/audit proof and current/legacy
caller dispositions are accepted; the source workplan and projected tasks agree;
and G08 has an exact evidence/handoff record. Record actionable residuals as live
@ -1035,3 +1158,11 @@ failures/automation errors; existing warnings and scanner false-positive history
remain retained. Custodian identity-restore-adapter-next/canonical-r7-integration.json
binds source/projection/native-fixture evidence separately. T18 remains waiting
for the actual owner-held attempt and selected consumer acceptance.
2026-10-17 source/attempt refinement: T34 records completed attended custody,
preflight/private-manifest source preparation and its bounded tests; T35 and T36
make original TLS recovery ownership and selected original admission metadata live
work. T33 retains final attempt readiness and its original Hub UUID. This adds no
actual recovery, MFA, native authority or owner assent. Source task counts above
refer to this file; projection has not been claimed for this proposed refinement.