Prepare independently reviewed native custody capability verification
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
c52be782d0
commit
969fe218df
7 changed files with 1000 additions and 1 deletions
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"at": "2026-10-11",
|
||||
"facts": [
|
||||
"Neither exact custody lane policy grants sys/capabilities-self; no default policy. Existing survey sys/capabilities queries lane tokens in memory.",
|
||||
"Existing survey source receipt display_name is token-claude-MASON-WP-0009-T04; required T05 grant is token-claude-MASON-WP-0009-T05.",
|
||||
"ESO lifecycle uses separately issued ServiceAccount TokenRequest JWT, not controller or Secret token.",
|
||||
"Nonmember CLI failure never accepted; independently trusted identity and same-flow owner-correlated groups refusal still required.",
|
||||
"Previously accepted service JWT positive/login/revoke proofs retained; new preparation is distinct custody-reader evidence."
|
||||
],
|
||||
"founder_native_disposition": "not-yet-recorded",
|
||||
"native_result": "not-run",
|
||||
"owner_source_review": "not-yet-performed",
|
||||
"preparation_result": "corrected-source-preparation",
|
||||
"prepared_by": "custodian-codex delegated preparation for ops-mason; no owner authority assumed",
|
||||
"previous_manifest_sha256": "e5d4781c6280c3bc8a3930c63cfb563f59752465720d6c2c3e95eb0551f8e949",
|
||||
"prior_outcome": "independent source changes_requested; not native success",
|
||||
"proposal_id": "MASON-WP-0009-T05-native-capability-proof-20261011",
|
||||
"refinements": [
|
||||
"endpoint-specific HTTP status/content/body/object/error handling; revoke only204empty",
|
||||
"nonempty typed bounded native accessors; recovered-token cleanup",
|
||||
"positive-integer TTL",
|
||||
"incrementally bounded subprocess capture; no unbounded PIPE claim",
|
||||
"approved digest guard before token/receipt/native IO"
|
||||
],
|
||||
"remaining_gates": [
|
||||
"ops-mason owner formal exact-source review",
|
||||
"supervisor exact source disposition and founder scoped native attendance",
|
||||
"operator native capability matrix with cleanup",
|
||||
"ESO native capability matrix with cleanup; platform TokenRequest approval",
|
||||
"key-cape validated same-attempt human/nonmember/aal2 evidence and platform correlated bound-group refusal",
|
||||
"ops-mason native owner acceptance and ops-warden pointer activation"
|
||||
],
|
||||
"routing": {
|
||||
"initial_default_memory_append_failed_outside_sandbox": true,
|
||||
"memory_disabled_read_succeeded": true,
|
||||
"mode": "pointer",
|
||||
"route": "openbao-structure-survey",
|
||||
"warden_executes": false
|
||||
},
|
||||
"safety": {
|
||||
"commits": 0,
|
||||
"credential_reads": 0,
|
||||
"credential_requests": 0,
|
||||
"foreign_owner_reviews": 0,
|
||||
"native_actions": 0,
|
||||
"owner_checkout_writes": 0,
|
||||
"policy_widening": 0,
|
||||
"pushes": 0,
|
||||
"secret_gets": 0
|
||||
},
|
||||
"schema": "mason-native-preparation/v1",
|
||||
"source_scope": "existing MASON-WP-0009-T05 only; source preparation, no live acceptance; unrelated WORK-RECORDS.md and ADHOC-2026-10-11.md preserved",
|
||||
"source_sha256": {
|
||||
"docs/keycape-client-custody-attended-verification.md": "554d20f722c13742eb86460991df420035cec11a469fef09e09567a64e07b247",
|
||||
"scripts/verify-keycape-client-custody.py": "f7685646dca48db5874174940c1826db186e1ed73e167814d8c0f37af3ad619c",
|
||||
"tests/test_keycape_client_custody_verifier.py": "ad981635eb97f48ca2e092cdbcb6e47007d89eb272a85578485dbb299bb7c479",
|
||||
"workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md": "9bea9cb4f2619c42a6cf3a7cf1fb14edc8539d702c437939b397ad9ebe1ce7d6"
|
||||
},
|
||||
"task": "MASON-WP-0009-T05",
|
||||
"verification": {
|
||||
"command": "python3 -m unittest discover -s /tmp/mason-jwt-capability-next-stage-v2/ops-mason/tests -q",
|
||||
"result": "passed-offline",
|
||||
"tests": 29
|
||||
}
|
||||
}
|
||||
168
docs/keycape-client-custody-attended-verification.md
Normal file
168
docs/keycape-client-custody-attended-verification.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# MASON-WP-0009-T05: founder-run native capability proof
|
||||
|
||||
Preparation only. No credential was read, native session started, TokenRequest
|
||||
issued, policy widened, KV GET performed, or route activated during preparation.
|
||||
The existing T05 task stays in progress. Formal ops-mason owner source review,
|
||||
founder attendance approval for the exact staged digest, and live native receipts
|
||||
remain necessary. An `--approval-reference` argument records the founder's prior
|
||||
disposition; it cannot approve itself or establish runtime isolation.
|
||||
|
||||
## Exact reuse and authority
|
||||
|
||||
The existing verifier `scripts/verify-fluid-telegram-lane.sh` demonstrates attended
|
||||
OIDC and value-free capabilities, but accepts every failed login as a nonmember
|
||||
refusal. It is not reused as proof. secrets-engine's
|
||||
`OpenBaoOidcClientSecretSource` demonstrates `bao login -no-store -format=json`,
|
||||
but its next operation reads CLIENT_SECRET, so that provider is not invoked here.
|
||||
Existing `scripts/bao-session.sh grant MASON-WP-0009-T05` provides query authority:
|
||||
45m `ops-mason-survey`, founder minting, explicit data denies, no policy/role/token
|
||||
write. The grant may also carry `default`; all other token/identity policies are
|
||||
refused. No new policy or credential owner is proposed.
|
||||
|
||||
Source inspection found neither CCR-0033's ESO reader nor CCR-0034's operator
|
||||
reader grants `sys/capabilities-self` (both omit default). Calling capabilities
|
||||
under their own tokens would prove endpoint denial, not the required path matrix.
|
||||
The verifier uses the survey grant's existing `sys/capabilities` to query the
|
||||
native lane token in memory. The lane token never enters argv, environment,
|
||||
receipts or a token helper. All HTTP calls use a fixed loopback endpoint, reject
|
||||
redirects/proxies and allow only login, self lookup, capability query and self
|
||||
revocation endpoints. No secret or metadata GET exists in the HTTP interface.
|
||||
|
||||
Credential routing was read first: `WARDEN_MEMORY=0 warden route show
|
||||
openbao-structure-survey --json`. That entry routes to ops-mason and is not a
|
||||
credential vending surface. The initial default route lookup printed routing
|
||||
metadata but failed to append its memory event outside this sandbox; no escalation
|
||||
was requested. There is no active operator route in current ops-warden source;
|
||||
the previously reported draft proposal must be accepted there separately.
|
||||
|
||||
## Concrete attended proposal
|
||||
|
||||
Proposal ID: `MASON-WP-0009-T05-native-capability-proof-20261011`.
|
||||
Target: existing OpenBao on the railiance01 loopback tunnel and, for ESO only,
|
||||
railiance01's `external-secrets/external-secrets` ServiceAccount TokenRequest.
|
||||
Expected result: two exact native read-only matrices, confirmed lane/query
|
||||
self-revokes, and a separately accepted nonmember refusal. Estimated software
|
||||
cost EUR 0; bounded founder browser/session time around 10–20 minutes. Risk:
|
||||
short-lived read-capable session tokens exist in founder process memory, and ESO
|
||||
TokenRequest briefly mints a 10m JWT for the existing ServiceAccount. No secret
|
||||
value is requested. No policy or deployment changes occur. Actual runtime
|
||||
isolation remains unverified; exact source review and founder attendance are
|
||||
procedural gates, not an autopilot grant.
|
||||
|
||||
After ops-mason accepts the source and the supervisor records the exact digest
|
||||
and scoped founder go, the founder runs from their terminal. For each mode,
|
||||
obtain a fresh existing survey grant through the established session helper.
|
||||
Do not hand an agent an operator token or inspect either session file in chat.
|
||||
Use a new output path each time and a private browser session for the chosen human.
|
||||
The verifier requires TTY attendance, rejects ambient BAO_TOKEN/VAULT_TOKEN and
|
||||
namespaces, and refuses a source digest different from the reviewed digest.
|
||||
|
||||
```bash
|
||||
python3 scripts/verify-keycape-client-custody.py operator \
|
||||
--attended --approval-reference '<recorded founder disposition>' \
|
||||
--approved-source-sha256 '<reviewed script digest>' \
|
||||
--survey-token-fd 3 --receipt '<new absolute 0600 receipt path>' \
|
||||
3< "$HOME/.claude-bao-token"
|
||||
```
|
||||
|
||||
Repeat with `eso` and then `nonmember`, fresh grant and receipt for each. Opening
|
||||
the existing grant FD is performed only in the founder terminal. The verifier
|
||||
closes it and self-revokes the query token. Existing `bao-session.sh revoke` is
|
||||
then used by the founder to remove the stale grant file; its printed success is
|
||||
not accepted as revocation evidence because that helper suppresses revoke errors.
|
||||
The verifier's native revoke receipt is the relevant evidence.
|
||||
|
||||
For ESO the runner first captures node metadata via fixed `ssh -oBatchMode=yes
|
||||
railiance01 'kubectl get nodes -o json'` and refuses any estate other than one node
|
||||
with address `92.205.62.239`. It then captures only the exact audience `openbao`,
|
||||
10m ServiceAccount TokenRequest JWT in memory and logs in at
|
||||
`auth/kubernetes/login` as `external-secrets-keycape-secrets-engine-openbao`.
|
||||
No Secret object, pod or controller token is read. The independently minted lane
|
||||
token is revoked; the running ESO controller's token is never touched. If the
|
||||
node metadata does not expose the documented IP, stop for platform owner source
|
||||
review of the estate pin; do not loosen the check at runtime.
|
||||
|
||||
## Receipt acceptance and remaining proof gates
|
||||
|
||||
Schema `mason-client-custody-native/v1` includes exact script digest, proposal
|
||||
reference, mode, timestamp, accessor SHA256, matrix and cleanup booleans. Files
|
||||
are reserved with O_EXCL/O_NOFOLLOW and mode 0600. Raw CLI/HTTP errors and auth
|
||||
responses are never copied into receipts. Capability queries must return every
|
||||
named path; missing rows, unexpected default/identity policies, wrong role,
|
||||
wrong ESO namespace/name, TTL over 5m/15m, any data write or parent/sibling/metadata
|
||||
capability fail closed. `capability_matrix_observed` means observations passed;
|
||||
`owner_acceptance: pending` is retained even when exit status is zero.
|
||||
|
||||
Operator and ESO acceptance requires ops-mason's actual owner review against the
|
||||
existing CCRs/source receipts, native role/policy/TTL observations, exact read-only
|
||||
matrix and **both** lane/query revocation confirmations. Operator role/claims
|
||||
remain grounded in the existing approved CCR-0034 binding, not a CLI role label
|
||||
chosen by the human. ESO metadata must show the fixed service account and role.
|
||||
|
||||
Nonmember never auto-passes. A semantic groups mismatch from the OIDC callback is
|
||||
only `group_refusal_candidate`; transport failure, cancellation, HTTP 403 alone,
|
||||
wrong issuer, malformed JSON, or MFA failure is `unproved_login_failure`. An
|
||||
unexpected successful nonmember login is revoked and fails the run.
|
||||
|
||||
Before ops-mason accepts check 3, key-cape supplies a value-free owner-authenticated
|
||||
identity record proving the **same attempted subject** is human, outside
|
||||
net-kingdom-admins and aal2, with validated issuer/signature/expiry; platform supplies
|
||||
a correlated native OpenBao callback refusal specifically for groups. The records
|
||||
must bind the same attempt using an owner-verified request/flow correlation digest,
|
||||
not merely nearby timestamps, a user-selected `nonmember` mode or decoded JWT
|
||||
claims. Include owner record ids/digests, observed time, subject fingerprint,
|
||||
issuer validation boolean, `group_member=false`, `aal2=true`, callback endpoint,
|
||||
refusal class and no token issued. The identity/flow digest must be produced and
|
||||
verified inside the owners' attended/audit surface; the runner does not possess
|
||||
the upstream OIDC JWT and cannot manufacture this evidence. If owners cannot
|
||||
produce this correlation, check 3 stays unproved. Never publish raw JWTs, callback
|
||||
URLs/codes/states, cookies, session ids or authentication errors.
|
||||
|
||||
This is separate from KEY-WP-0039 human/coding-agent JWT boundary evidence and
|
||||
secrets-engine's service identity denial matrix. Those do not prove this reader's
|
||||
nonmember binding. The existing successful service-jwt exchange and self-revokes
|
||||
remain accepted prior evidence; do not discard or regenerate them merely to
|
||||
complete these different gates. ops-warden may activate the pointer only after
|
||||
all T05 required owner-native receipts are actually accepted.
|
||||
|
||||
## Cleanup and failure recovery
|
||||
|
||||
On every obtained lane token, including policy mismatch/unexpected nonmember
|
||||
success, attempt lane revoke-self, then query revoke-self. A failed revoke changes
|
||||
result to unproved. A query token that fails its identity check is not trusted for
|
||||
further calls; record no cleanup confirmation and use the established founder
|
||||
revocation path. Callback timeout/malformed native auth may leave a token whose
|
||||
handle was not recovered: no cleanup success is claimed; the role's 5m/15m TTL
|
||||
bounds it and the platform owner decides recovery. No policy is widened to retry.
|
||||
|
||||
The Kubernetes TokenRequest JWT cannot be individually revoked by this procedure;
|
||||
it expires after 10m and is never persisted. Do not delete the shared ServiceAccount
|
||||
or revoke the controller's token as cleanup. No KV rollback exists because no KV
|
||||
request occurred. Session cleanup is the rollback; any unconfirmed cleanup is
|
||||
explicit open evidence and blocks acceptance until owner resolution.
|
||||
|
||||
|
||||
## Corrected source revision — 2026-10-11
|
||||
|
||||
The prior immutable stage is retained at `/tmp/mason-jwt-capability-next-stage`
|
||||
(manifest e5d4781c6280c3bc8a3930c63cfb563f59752465720d6c2c3e95eb0551f8e949).
|
||||
Independent source review requested changes: its HTTP202/text response could
|
||||
falsely confirm revocation and missing native accessors could hash the empty
|
||||
string. This revised source rejects every unsupported status/content/body/error
|
||||
shape. Lookup/capability/Kubernetes-login require HTTP200 application/json and
|
||||
endpoint-specific object data/auth, no `errors` member, with bounded headers/body.
|
||||
Revoke succeeds only on HTTP204 with an empty body. Error envelopes never imply
|
||||
cleanup; a recovered token from a mixed login envelope is retained solely for
|
||||
finally cleanup. Nonempty bounded typed actual accessors are required before
|
||||
fingerprinting; empty/malformed query accessors still revoke a scope-validated
|
||||
query token, and empty/malformed lane accessors still revoke recovered lane tokens.
|
||||
|
||||
TTL is a positive integer within the declared bound, with booleans refused.
|
||||
CLI stdout/stderr are captured while running with a 64KiB limit per stream;
|
||||
excess or timeout kills the local process group and leaves native outcome
|
||||
unproved. No unlimited capture is described as bounded. The exact approved
|
||||
source digest is checked before receipt/token/native IO. 29 offline tests cover
|
||||
these corrections, actual relative redirect refusal with hostile proxy settings,
|
||||
synthetic oversized subprocess output, valid native204 cleanup versus bad202,
|
||||
missing-accessor cleanup, and mixed-error session recovery. Source corrections
|
||||
are not a new native success, owner acceptance, founder go or adoption proof.
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"reviewer": "ops-mason-owner (agent ops-mason, codex)",
|
||||
"decision": "approved",
|
||||
"comment": "INTENT Mission four-phase source-grounded construction consistency and Responsibility boundary permit this capability-only verification preparation with no secret-value retrieval or access widening. Exact reviewed revision2 manifest SHA256 4dbf808e2d79c2e6600a163e147db7d15b075e59a8edf8cd236f31fdce9b5155, script f7685646dca48db5874174940c1826db186e1ed73e167814d8c0f37af3ad619c. Own completed work item ADHOC-2026-10-11-T03 refined preparation recorded before this formal entry; first changes-requested review remains historical. Both blocking source defects corrected and independently verified. Approval is source review only: no founder authorization, runtime isolation attestation, native acceptance, route activation or whole-plan closure.",
|
||||
"own_task": "ADHOC-2026-10-11-T03",
|
||||
"own_completed_record_sha256": "7fadf4475ff765999b71659495b786737a9641c8ad875afc97bd264a8c9f0aec",
|
||||
"reviewed_manifest_sha256": "4dbf808e2d79c2e6600a163e147db7d15b075e59a8edf8cd236f31fdce9b5155",
|
||||
"reviewed_files": [
|
||||
{
|
||||
"original_sha256": null,
|
||||
"path": "scripts/verify-keycape-client-custody.py",
|
||||
"repo": "ops-mason",
|
||||
"staged_path": "/tmp/mason-jwt-capability-next-stage-v2/ops-mason/scripts/verify-keycape-client-custody.py",
|
||||
"staged_sha256": "f7685646dca48db5874174940c1826db186e1ed73e167814d8c0f37af3ad619c"
|
||||
},
|
||||
{
|
||||
"original_sha256": null,
|
||||
"path": "tests/test_keycape_client_custody_verifier.py",
|
||||
"repo": "ops-mason",
|
||||
"staged_path": "/tmp/mason-jwt-capability-next-stage-v2/ops-mason/tests/test_keycape_client_custody_verifier.py",
|
||||
"staged_sha256": "ad981635eb97f48ca2e092cdbcb6e47007d89eb272a85578485dbb299bb7c479"
|
||||
},
|
||||
{
|
||||
"original_sha256": null,
|
||||
"path": "docs/keycape-client-custody-attended-verification.md",
|
||||
"repo": "ops-mason",
|
||||
"staged_path": "/tmp/mason-jwt-capability-next-stage-v2/ops-mason/docs/keycape-client-custody-attended-verification.md",
|
||||
"staged_sha256": "554d20f722c13742eb86460991df420035cec11a469fef09e09567a64e07b247"
|
||||
},
|
||||
{
|
||||
"original_sha256": "857458214d94f7852f841bad25887625ee4d90575a79b5153465c661a2e0089f",
|
||||
"path": "workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md",
|
||||
"repo": "ops-mason",
|
||||
"staged_path": "/tmp/mason-jwt-capability-next-stage-v2/ops-mason/workplans/MASON-WP-0009-keycape-secrets-engine-openbao-client-custody.md",
|
||||
"staged_sha256": "9bea9cb4f2619c42a6cf3a7cf1fb14edc8539d702c437939b397ad9ebe1ce7d6"
|
||||
},
|
||||
{
|
||||
"original_sha256": null,
|
||||
"path": "docs/evidence/2026-10-11-keycape-client-custody-native-preparation.json",
|
||||
"repo": "ops-mason",
|
||||
"staged_path": "/tmp/mason-jwt-capability-next-stage-v2/ops-mason/docs/evidence/2026-10-11-keycape-client-custody-native-preparation.json",
|
||||
"staged_sha256": "8c6b15ce589f322d7f01e8ce6415f3726f34cb301b29b2436618ce4806a12494"
|
||||
}
|
||||
],
|
||||
"prior_review": "docs/reviews/2026-10-11-keycape-client-custody-independent-source-review.json",
|
||||
"prior_outcome_preserved": "changes_requested on revision1",
|
||||
"corrections_verified": [
|
||||
"lookup/login/capability exact200JSON/endpoint-shape/errors absence",
|
||||
"revoke204emptybody only; failures leave confirmation false and result unproved",
|
||||
"typed nonempty bounded native accessors; safe recovered-token cleanup on missing correlation handle",
|
||||
"mixed auth+errors preserves recovered token solely for cleanup, never acceptance",
|
||||
"typed exact positiveTTLlimits; expired/unbounded refused",
|
||||
"early source digest mismatch before credentialFD or nativeAPI access",
|
||||
"HTTP preparse response bound; native subprocess incremental per-stream64KiB capture with process cleanup",
|
||||
"fixed origin, proxy disabled, all redirectfollowing refused"
|
||||
],
|
||||
"tests": {
|
||||
"maker_suite": "29 passed independently",
|
||||
"additional_independent_suite": "4 passed",
|
||||
"additional_tests_sha256": "ce4dd7ebd2f88f031de9cf8aba89f6c761f80f67d56c56adc3717c5d3a902176",
|
||||
"additional_counterexamples": [
|
||||
"actual cross-origin302target receives no request/token",
|
||||
"absent revoke response and cleanup failure remain unproved",
|
||||
"expiredTTL and missing accessor refuse and attempt recovered-token cleanup",
|
||||
"oversized native stderr refused before full capture"
|
||||
]
|
||||
},
|
||||
"remaining_live_gates": [
|
||||
"founder scoped exact-source/native attendance go",
|
||||
"operator and ESO actual native matrices+metadata+both revokes accepted by owner",
|
||||
"issuer-authenticated nonmember human/aal2 plus same-attempt platform groups-refusal correlation",
|
||||
"unknown/native timeout or failed cleanup owner resolution",
|
||||
"ops-warden pointer acceptance and activation"
|
||||
],
|
||||
"native_actions": false,
|
||||
"credential_requests": false,
|
||||
"secret_values_accessed": false,
|
||||
"owner_checkout_writes": false,
|
||||
"approval_or_admission_fields_changed": false,
|
||||
"whole_plan_closed": false
|
||||
}
|
||||
325
scripts/verify-keycape-client-custody.py
Normal file
325
scripts/verify-keycape-client-custody.py
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
#!/usr/bin/env python3
|
||||
"""MASON-WP-0009-T05 attended, value-free lane verifier; no KV GETs.
|
||||
|
||||
The existing operator/ESO policies do NOT grant capabilities-self. An existing
|
||||
read-only ops-mason-survey grant queries their tokens via sys/capabilities.
|
||||
This file prepares a founder-run procedure, not a new authorization surface.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import selectors
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
DATA = 'platform/data/workloads/keycape/secrets-engine-openbao'
|
||||
OP_ROLE = 'keycape-secrets-engine-openbao-operator-workload-kv-read'
|
||||
ESO_ROLE = 'external-secrets-keycape-secrets-engine-openbao'
|
||||
POLICY = 'workload-kv-read-keycape-secrets-engine-openbao-'
|
||||
PATHS = [DATA, DATA.replace('/data/', '/metadata/'),
|
||||
'platform/data/workloads/keycape', 'platform/data/workloads/keycape/',
|
||||
DATA + '-other', 'platform/data/workloads/keycape/other',
|
||||
'platform/data/workloads/keycape/*', 'sys/policies/acl/ops-mason-survey',
|
||||
'auth/token/create', 'auth/token/revoke-self']
|
||||
BASE = 'http://127.0.0.1:18200'
|
||||
|
||||
class Refused(Exception):
|
||||
pass
|
||||
|
||||
class RecoveredLoginRefused(Refused):
|
||||
"""Retain only an already recovered session for finally cleanup, never proof."""
|
||||
def __init__(self, code, auth):
|
||||
super().__init__(code)
|
||||
token = auth.get('client_token') if isinstance(auth, dict) else None
|
||||
self.token = token if isinstance(token, str) and 0 < len(token) <= 4096 and not any(c.isspace() for c in token) else ''
|
||||
|
||||
class Transport(Exception):
|
||||
pass
|
||||
|
||||
class Api:
|
||||
"""Hard-coded endpoint allowlist prevents secret GETs, role/policy writes."""
|
||||
def __init__(self):
|
||||
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}),
|
||||
NoRedirect())
|
||||
def call(self, endpoint, token='', body=None):
|
||||
allowed = {'auth/token/lookup-self', 'auth/token/revoke-self',
|
||||
'sys/capabilities', 'sys/capabilities-self',
|
||||
'auth/kubernetes/login'}
|
||||
if endpoint not in allowed:
|
||||
raise Refused('endpoint_not_allowlisted')
|
||||
req = urllib.request.Request(BASE + '/v1/' + endpoint,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers={'Content-Type': 'application/json', 'X-Vault-Token': token},
|
||||
method='GET' if endpoint == 'auth/token/lookup-self' else 'POST')
|
||||
try:
|
||||
with self.opener.open(req, timeout=20) as response:
|
||||
if endpoint == 'auth/token/revoke-self':
|
||||
# OpenBao's native self-revoke success is 204 with no body.
|
||||
if response.status != 204 or response.read(1):
|
||||
raise Refused('unconfirmed_revoke_response')
|
||||
return {}
|
||||
if response.status != 200 or response.headers.get_content_type() != 'application/json':
|
||||
raise Refused('invalid_status_or_content_type')
|
||||
length = response.headers.get('Content-Length')
|
||||
if length is not None and (not length.isdecimal() or int(length) > 65536):
|
||||
raise Refused('oversized_or_invalid_content_length')
|
||||
raw = response.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise Refused('oversized_response')
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict):
|
||||
raise Refused('invalid_response_envelope')
|
||||
if 'errors' in result:
|
||||
if endpoint == 'auth/kubernetes/login':
|
||||
raise RecoveredLoginRefused('native_error_envelope', result.get('auth'))
|
||||
raise Refused('native_error_envelope')
|
||||
key = 'auth' if endpoint == 'auth/kubernetes/login' else 'data'
|
||||
if not isinstance(result.get(key), dict):
|
||||
raise Refused('invalid_endpoint_response_shape')
|
||||
return result
|
||||
except urllib.error.HTTPError as error:
|
||||
error.close() # never read/print server errors
|
||||
raise Refused('http_' + str(error.code)) from None
|
||||
except (OSError, ValueError, urllib.error.URLError):
|
||||
raise Transport('transport_or_invalid_response') from None
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
raise Refused('redirect_refused')
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
def accessor_digest(value):
|
||||
if not isinstance(value, str) or not 1 <= len(value) <= 256 or any(c.isspace() for c in value):
|
||||
raise Refused('missing_or_invalid_native_accessor')
|
||||
return digest(value)
|
||||
|
||||
def checked_caps(response, expected):
|
||||
data = response.get('data', response)
|
||||
# Only batch-shaped named results accepted, never substitute a default.
|
||||
if not isinstance(data, dict) or not all(path in data for path in expected):
|
||||
raise Refused('incomplete_capability_matrix')
|
||||
result = {}
|
||||
for path, caps in expected.items():
|
||||
actual = data[path]
|
||||
if not isinstance(actual, list) or sorted(actual) != sorted(caps):
|
||||
raise Refused('capability_mismatch')
|
||||
result[path] = actual
|
||||
return result
|
||||
|
||||
def survey(api, token):
|
||||
info = api.call('auth/token/lookup-self', token)['data']
|
||||
if info.get('identity_policies'):
|
||||
raise Refused('query_identity_policy_additions')
|
||||
policies = set(info.get('policies', []))
|
||||
if policies not in ({'ops-mason-survey'}, {'ops-mason-survey', 'default'}):
|
||||
raise Refused('query_policy_mismatch')
|
||||
if info.get('display_name') != 'token-claude-MASON-WP-0009-T05' or (type(info.get('ttl')) is not int or not 0 < info['ttl'] <= 2700):
|
||||
raise Refused('query_identity_or_ttl_mismatch')
|
||||
expected = {DATA: ['deny'], 'sys/policies/acl/ops-mason-survey': ['deny'],
|
||||
'auth/token/create': ['deny'], 'sys/capabilities': ['update']}
|
||||
checked_caps(api.call('sys/capabilities-self', token, {'paths': list(expected)}), expected)
|
||||
return info
|
||||
|
||||
def safe_env(home):
|
||||
# Never inherit credential variables, token helpers, proxies, CLI plugins.
|
||||
return {'PATH': '/home/worsch/.local/bin:/usr/local/bin:/usr/bin:/bin', 'HOME': home,
|
||||
'BAO_ADDR': BASE, 'VAULT_ADDR': BASE,
|
||||
'DISPLAY': os.environ.get('DISPLAY', ''),
|
||||
'WSL_INTEROP': os.environ.get('WSL_INTEROP', ''),
|
||||
'WSL_DISTRO_NAME': os.environ.get('WSL_DISTRO_NAME', '')}
|
||||
|
||||
def run(argv, env):
|
||||
# Capture each stream with a real memory bound while the child runs; never
|
||||
# retain an unbounded PIPE response and only check its length afterwards.
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(argv, env=env, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
||||
buffers = {process.stdout: bytearray(), process.stderr: bytearray()}
|
||||
deadline = time.monotonic() + 300
|
||||
with selectors.DefaultSelector() as selector:
|
||||
for stream in buffers:
|
||||
selector.register(stream, selectors.EVENT_READ)
|
||||
while selector.get_map():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise Transport('native_transport_or_timeout')
|
||||
for key, _ in selector.select(min(remaining, 1)):
|
||||
chunk = os.read(key.fileobj.fileno(), 8192)
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
continue
|
||||
target = buffers[key.fileobj]
|
||||
if len(target) + len(chunk) > 65536:
|
||||
raise Refused('oversized_native_response')
|
||||
target.extend(chunk)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise Transport('native_transport_or_timeout')
|
||||
code = process.wait(timeout=remaining)
|
||||
return code, bytes(buffers[process.stdout]), bytes(buffers[process.stderr])
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise Transport('native_transport_or_timeout') from None
|
||||
finally:
|
||||
if process is not None:
|
||||
if process.poll() is None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
|
||||
def oidc(env, runner=run):
|
||||
# -no-store is mandatory; output/errors stay inside the founder's process.
|
||||
code, raw, err = runner(['/home/worsch/.local/bin/bao', 'login', '-no-store', '-format=json',
|
||||
'-method=oidc', '-path=netkingdom', 'role=' + OP_ROLE], env)
|
||||
if code:
|
||||
# A candidate refusal still needs independent issuer + correlated
|
||||
# OpenBao owner evidence. HTTP 403, cancellation or timeout never pass.
|
||||
claim_refusal = b'claim "groups" does not match' in err or b'claim "groups" not found' in err
|
||||
return None, 'group_refusal_candidate' if claim_refusal else 'unproved_login_failure'
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict) or not isinstance(result.get('auth'), dict):
|
||||
raise Refused('malformed_native_login')
|
||||
if 'errors' in result:
|
||||
raise RecoveredLoginRefused('native_error_envelope', result['auth'])
|
||||
return result['auth'], 'login_succeeded'
|
||||
except (ValueError, KeyError, TypeError):
|
||||
raise Refused('malformed_native_login') from None
|
||||
|
||||
def eso(api, runner=run):
|
||||
# Existing owner SSH certificate, no kubeconfig/Secret reads. Separate
|
||||
# TokenRequest (10m, openbao audience); no ESO controller session revoked.
|
||||
env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': os.path.expanduser('~')}
|
||||
rc, out, _ = runner(['ssh', '-oBatchMode=yes', 'railiance01',
|
||||
'kubectl get nodes -o json'], env)
|
||||
if rc:
|
||||
raise Transport('cluster_identity_unproved')
|
||||
try:
|
||||
nodes = json.loads(out)['items']
|
||||
addresses = {a['address'] for n in nodes for a in n['status']['addresses']}
|
||||
if len(nodes) != 1 or '92.205.62.239' not in addresses:
|
||||
raise Refused('wrong_cluster')
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise Refused('invalid_cluster_identity') from None
|
||||
rc, jwt, _ = runner(['ssh', '-oBatchMode=yes', 'railiance01',
|
||||
'kubectl -n external-secrets create token external-secrets --audience=openbao --duration=10m'], env)
|
||||
if rc or not jwt.strip():
|
||||
raise Transport('tokenrequest_failed')
|
||||
return api.call('auth/kubernetes/login', body={'role': ESO_ROLE,
|
||||
'jwt': jwt.decode().strip()})['auth']
|
||||
|
||||
def lane_matrix(api, query, auth, mode):
|
||||
token = auth.get('client_token')
|
||||
if not isinstance(token, str) or not token or any(c.isspace() for c in token):
|
||||
raise Refused('malformed_lane_token')
|
||||
metadata = auth.get('metadata', {})
|
||||
expected_role = OP_ROLE if mode == 'operator' else ESO_ROLE
|
||||
if metadata.get('role') != expected_role:
|
||||
raise Refused('lane_role_mismatch')
|
||||
if mode == 'eso' and (metadata.get('service_account_name') != 'external-secrets' or
|
||||
metadata.get('service_account_namespace') != 'external-secrets'):
|
||||
raise Refused('eso_identity_mismatch')
|
||||
if auth.get('identity_policies'):
|
||||
raise Refused('lane_identity_policy_additions')
|
||||
expected_policy = POLICY + ('operator' if mode == 'operator' else 'eso')
|
||||
if set(auth.get('policies', [])) != {expected_policy}:
|
||||
raise Refused('lane_policy_mismatch')
|
||||
if (type(auth.get('lease_duration')) is not int or not 0 < auth['lease_duration'] <= (300 if mode == 'operator' else 900)):
|
||||
raise Refused('lane_ttl_mismatch')
|
||||
expected = {p: ['deny'] for p in PATHS}
|
||||
expected[DATA] = ['read']
|
||||
expected['auth/token/revoke-self'] = ['update']
|
||||
return checked_caps(api.call('sys/capabilities', query,
|
||||
{'token': token, 'paths': PATHS}), expected)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('mode', choices=['operator', 'eso', 'nonmember'])
|
||||
parser.add_argument('--survey-token-fd', type=int, required=True)
|
||||
parser.add_argument('--approval-reference', required=True)
|
||||
parser.add_argument('--approved-source-sha256', required=True)
|
||||
parser.add_argument('--receipt', type=Path, required=True)
|
||||
parser.add_argument('--attended', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if not args.attended or not sys.stdin.isatty() or args.survey_token_fd < 3:
|
||||
parser.error('founder terminal attendance and separate survey token FD required')
|
||||
if any(os.environ.get(k) for k in ('BAO_TOKEN', 'VAULT_TOKEN', 'BAO_NAMESPACE', 'VAULT_NAMESPACE')):
|
||||
parser.error('ambient authority refused')
|
||||
source_sha256 = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
if args.approved_source_sha256 != source_sha256:
|
||||
parser.error('approved source digest differs; no session requested')
|
||||
# Reservation prevents overwrites/symlink substitution before credentials.
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
receipt = {'schema': 'mason-client-custody-native/v1', 'task': 'MASON-WP-0009-T05',
|
||||
'mode': args.mode, 'source_sha256': source_sha256,
|
||||
'approval_reference': args.approval_reference, 'at': int(time.time()),
|
||||
'secret_reads': 0, 'result': 'unproved', 'owner_acceptance': 'pending',
|
||||
'query_revoke': False, 'lane_revoke': False}
|
||||
api = Api()
|
||||
query = ''
|
||||
query_valid = False
|
||||
lane = ''
|
||||
try:
|
||||
with os.fdopen(args.survey_token_fd, 'r') as handle:
|
||||
query = handle.read(4097).strip()
|
||||
if not query or len(query) > 4096 or any(c.isspace() for c in query):
|
||||
raise Refused('malformed_query_token')
|
||||
query_info = survey(api, query)
|
||||
query_valid = True
|
||||
receipt['query_accessor_sha256'] = accessor_digest(query_info.get('accessor'))
|
||||
if args.mode == 'eso':
|
||||
auth = eso(api)
|
||||
else:
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory(prefix='mason-oidc-') as home:
|
||||
auth, result = oidc(safe_env(home))
|
||||
receipt['native_login_result'] = result
|
||||
if auth is None:
|
||||
# Never pronounce the nonmember check passed from CLI text.
|
||||
raise Refused(result)
|
||||
lane = auth.get('client_token', '')
|
||||
receipt['lane_accessor_sha256'] = accessor_digest(auth.get('accessor'))
|
||||
if args.mode == 'nonmember':
|
||||
raise Refused('nonmember_obtained_token')
|
||||
receipt['capabilities'] = lane_matrix(api, query, auth, args.mode)
|
||||
receipt['result'] = 'capability_matrix_observed'
|
||||
except RecoveredLoginRefused as error:
|
||||
lane = error.token
|
||||
receipt['failure_class'] = str(error)
|
||||
except (Refused, Transport) as error:
|
||||
receipt['failure_class'] = str(error)
|
||||
except Exception:
|
||||
receipt['failure_class'] = 'invalid_or_unexpected_response'
|
||||
finally:
|
||||
if lane:
|
||||
try:
|
||||
api.call('auth/token/revoke-self', lane, {})
|
||||
receipt['lane_revoke'] = True
|
||||
except Exception:
|
||||
receipt['result'] = 'unproved'
|
||||
if query_valid:
|
||||
try:
|
||||
api.call('auth/token/revoke-self', query, {})
|
||||
receipt['query_revoke'] = True
|
||||
except Exception:
|
||||
receipt['result'] = 'unproved'
|
||||
query = lane = ''
|
||||
with os.fdopen(fd, 'w') as handle:
|
||||
json.dump(receipt, handle, indent=2, sort_keys=True)
|
||||
handle.write('\n')
|
||||
return 0 if receipt['result'] == 'capability_matrix_observed' else 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
298
tests/test_keycape_client_custody_verifier.py
Normal file
298
tests/test_keycape_client_custody_verifier.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts/verify-keycape-client-custody.py'
|
||||
spec = importlib.util.spec_from_file_location('custody', SCRIPT)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
NATIVE_API = m.Api
|
||||
|
||||
class Fake:
|
||||
def __init__(self, info=None, changes=None):
|
||||
self.calls = []
|
||||
self.info = info or {'policies': ['ops-mason-survey', 'default'],
|
||||
'display_name': 'token-claude-MASON-WP-0009-T05', 'ttl': 2700,
|
||||
'accessor': 'test-query-accessor'}
|
||||
self.changes = changes or {}
|
||||
def call(self, endpoint, token='', body=None):
|
||||
self.calls.append((endpoint, token, body))
|
||||
if endpoint == 'auth/token/lookup-self':
|
||||
return {'data': self.info}
|
||||
if endpoint == 'sys/capabilities-self':
|
||||
result = {m.DATA: ['deny'], 'sys/policies/acl/ops-mason-survey': ['deny'],
|
||||
'auth/token/create': ['deny'], 'sys/capabilities': ['update']}
|
||||
else:
|
||||
result = {p: ['deny'] for p in m.PATHS}
|
||||
result[m.DATA] = ['read']
|
||||
result['auth/token/revoke-self'] = ['update']
|
||||
result.update(self.changes)
|
||||
return {'data': result}
|
||||
|
||||
class Tests(unittest.TestCase):
|
||||
def auth(self, mode='operator'):
|
||||
return {'client_token': 'test-only-not-a-credential', 'accessor': 'test-accessor',
|
||||
'policies': [m.POLICY + mode], 'lease_duration': 300,
|
||||
'metadata': {'role': m.OP_ROLE if mode == 'operator' else m.ESO_ROLE,
|
||||
'service_account_name': 'external-secrets',
|
||||
'service_account_namespace': 'external-secrets'}}
|
||||
def test_query_identity_and_data_deny(self):
|
||||
self.assertEqual(m.survey(Fake(), 'test-query')['accessor'], 'test-query-accessor')
|
||||
def test_broad_ambient_query_refused(self):
|
||||
for policies in [['root'], ['ops-mason-survey', 'platform-admin']]:
|
||||
with self.subTest(policies=policies), self.assertRaises(m.Refused):
|
||||
m.survey(Fake(info={'policies': policies}), 'test-query')
|
||||
def test_wrong_task_and_unbounded_grant_refused(self):
|
||||
for field, value in [('display_name', 'claude-another-task'), ('ttl', 2701), ('ttl', 0)]:
|
||||
fake = Fake()
|
||||
fake.info[field] = value
|
||||
with self.subTest(field=field, value=value), self.assertRaises(m.Refused):
|
||||
m.survey(fake, 'test-query')
|
||||
def test_query_cannot_read_value_or_create_token(self):
|
||||
for path in [m.DATA, 'auth/token/create', 'sys/policies/acl/ops-mason-survey']:
|
||||
with self.subTest(path=path), self.assertRaises(m.Refused):
|
||||
m.survey(Fake(changes={path: ['read']}), 'test-query')
|
||||
def test_operator_and_eso_native_matrix(self):
|
||||
for mode in ['operator', 'eso']:
|
||||
fake = Fake()
|
||||
result = m.lane_matrix(fake, 'test-query', self.auth(mode), mode)
|
||||
self.assertEqual(result[m.DATA], ['read'])
|
||||
self.assertEqual(fake.calls[0][0], 'sys/capabilities')
|
||||
self.assertEqual(fake.calls[0][1], 'test-query')
|
||||
def test_data_write_and_missing_read_refused(self):
|
||||
for caps in [['read', 'update'], ['deny'], ['sudo', 'read']]:
|
||||
with self.subTest(caps=caps), self.assertRaises(m.Refused):
|
||||
m.lane_matrix(Fake(changes={m.DATA: caps}), 'test-query', self.auth(), 'operator')
|
||||
def test_parent_sibling_metadata_and_policy_escalation_refused(self):
|
||||
for path in m.PATHS[1:-1]:
|
||||
with self.subTest(path=path), self.assertRaises(m.Refused):
|
||||
m.lane_matrix(Fake(changes={path: ['read']}), 'test-query', self.auth(), 'operator')
|
||||
def test_incomplete_matrix_refused(self):
|
||||
with self.assertRaises(m.Refused):
|
||||
m.checked_caps({'data': {m.DATA: ['read']}}, {m.DATA: ['read'], 'other': ['deny']})
|
||||
def test_wrong_policy_default_identity_role_ttl_refused(self):
|
||||
for field, value in [('policies', ['root']), ('policies', [m.POLICY+'operator', 'default']),
|
||||
('metadata', {'role': 'platform-admin'}), ('lease_duration', 301),
|
||||
('client_token', 'invalid token')]:
|
||||
auth = self.auth()
|
||||
auth[field] = value
|
||||
with self.subTest(field=field, value=value), self.assertRaises(m.Refused):
|
||||
m.lane_matrix(Fake(), 'test-query', auth, 'operator')
|
||||
def test_wrong_eso_namespace_refused(self):
|
||||
auth = self.auth('eso')
|
||||
auth['metadata']['service_account_namespace'] = 'sso'
|
||||
with self.assertRaises(m.Refused):
|
||||
m.lane_matrix(Fake(), 'test-query', auth, 'eso')
|
||||
def test_login_failure_is_never_negative_acceptance(self):
|
||||
for err in [b'connection refused', b'HTTP 403 permission denied', b'user cancelled',
|
||||
b'claim "groups" does not match any associated bound claim values']:
|
||||
result, category = m.oidc({}, lambda *args: (1, b'', err))
|
||||
self.assertIsNone(result)
|
||||
self.assertIn(category, ['unproved_login_failure', 'group_refusal_candidate'])
|
||||
self.assertNotIn('accepted', category)
|
||||
def test_no_store_and_fixed_native_role(self):
|
||||
calls = []
|
||||
def runner(argv, env):
|
||||
calls.append(argv)
|
||||
return 1, b'', b'cancelled'
|
||||
m.oidc({}, runner)
|
||||
self.assertIn('-no-store', calls[0])
|
||||
self.assertIn('role=' + m.OP_ROLE, calls[0])
|
||||
def test_api_rejects_secret_gets_role_writes_and_generic_endpoints(self):
|
||||
api = m.Api()
|
||||
for endpoint in [m.DATA, m.DATA.replace('/data/', '/metadata/'),
|
||||
'sys/policies/acl/new', 'auth/netkingdom/role/new', 'auth/token/create']:
|
||||
with self.subTest(endpoint=endpoint), self.assertRaises(m.Refused):
|
||||
api.call(endpoint, 'test-only')
|
||||
def test_credentials_not_in_child_env(self):
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
with patch.dict(os.environ, {'BAO_TOKEN': 'test', 'VAULT_TOKEN': 'test',
|
||||
'HTTPS_PROXY': 'https://wrong', 'BAO_NAMESPACE': 'other'}):
|
||||
env = m.safe_env('/tmp/test-only')
|
||||
for name in ['BAO_TOKEN', 'VAULT_TOKEN', 'HTTPS_PROXY', 'BAO_NAMESPACE']:
|
||||
self.assertNotIn(name, env)
|
||||
self.assertEqual(env['HOME'], '/tmp/test-only')
|
||||
def test_eso_cluster_mismatch_stops_before_tokenrequest(self):
|
||||
calls = []
|
||||
def runner(argv, env):
|
||||
calls.append(argv)
|
||||
return 0, b'{"items": [{"status": {"addresses": [{"address": "10.0.0.1"}]}}]}', b''
|
||||
with self.assertRaises(m.Refused):
|
||||
m.eso(Fake(), runner)
|
||||
self.assertEqual(len(calls), 1)
|
||||
def exercise_main(self, auth, failure=None, mode='operator', query_info=None, bad_revoke_response=None, login_error=None):
|
||||
import tempfile
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
credential = Path(directory) / 'test-fixture'
|
||||
credential.write_text('test-only-query')
|
||||
token_fd = os.open(credential, os.O_RDONLY)
|
||||
receipt = Path(directory) / 'receipt.json'
|
||||
fake = Fake()
|
||||
if query_info is not None:
|
||||
fake.info.update(query_info)
|
||||
old_call = fake.call
|
||||
def call(endpoint, token='', body=None):
|
||||
if endpoint == 'auth/token/revoke-self' and bad_revoke_response is not None:
|
||||
native = NATIVE_API()
|
||||
native.opener = self.opener(bad_revoke_response)
|
||||
return native.call(endpoint, token, body)
|
||||
if endpoint == 'auth/token/revoke-self' and token == failure:
|
||||
raise m.Transport('test_cleanup_transport')
|
||||
return old_call(endpoint, token, body)
|
||||
fake.call = call
|
||||
argv = ['verifier', mode, '--survey-token-fd', str(token_fd),
|
||||
'--approval-reference', 'test-not-an-approval', '--approved-source-sha256',
|
||||
m.hashlib.sha256(SCRIPT.read_bytes()).hexdigest(), '--attended', '--receipt', str(receipt)]
|
||||
with patch.object(m.sys, 'argv', argv), patch.object(m.sys.stdin, 'isatty', return_value=True), \
|
||||
patch.dict(os.environ, {}, clear=True), patch.object(m, 'Api', return_value=fake), \
|
||||
patch.object(m, 'oidc', return_value=(auth, 'login_succeeded'), side_effect=login_error):
|
||||
code = m.main()
|
||||
return code, m.json.loads(receipt.read_text()), fake.calls
|
||||
def test_cleanup_attempted_even_when_policy_check_fails(self):
|
||||
auth = self.auth()
|
||||
auth['policies'] = ['root']
|
||||
code, receipt, calls = self.exercise_main(auth)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertTrue(receipt['lane_revoke'])
|
||||
self.assertTrue(receipt['query_revoke'])
|
||||
self.assertNotIn('test-only-not-a-credential', m.json.dumps(receipt))
|
||||
def test_cleanup_transport_invalidates_positive_matrix(self):
|
||||
for token in ['test-only-query', 'test-only-not-a-credential']:
|
||||
code, receipt, calls = self.exercise_main(self.auth(), failure=token)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(receipt['result'], 'unproved')
|
||||
self.assertEqual(len([c for c in calls if c[0] == 'auth/token/revoke-self']), 1)
|
||||
def test_unexpected_nonmember_success_is_revoked_and_fails(self):
|
||||
code, receipt, calls = self.exercise_main(self.auth(), mode='nonmember')
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(receipt['failure_class'], 'nonmember_obtained_token')
|
||||
self.assertTrue(receipt['lane_revoke'])
|
||||
self.assertEqual(receipt['owner_acceptance'], 'pending')
|
||||
def response(self, status=200, content='application/json', raw=b'{"data":{}}', length=None):
|
||||
from email.message import Message
|
||||
headers = Message()
|
||||
if content:
|
||||
headers['Content-Type'] = content
|
||||
if length is not None:
|
||||
headers['Content-Length'] = length
|
||||
class Response:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def read(self, count): return raw[:count]
|
||||
response = Response()
|
||||
response.status = status
|
||||
response.headers = headers
|
||||
return response
|
||||
def opener(self, response):
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(open=lambda *args, **kwargs: response)
|
||||
def test_mixed_login_error_preserves_recovered_token_cleanup(self):
|
||||
raw=m.json.dumps({'errors':['private-synthetic'], 'auth':self.auth()}).encode()
|
||||
with self.assertRaises(m.RecoveredLoginRefused) as error:
|
||||
m.oidc({},lambda *args:(0,raw,b''))
|
||||
code, receipt, _ = self.exercise_main(self.auth(), login_error=error.exception)
|
||||
self.assertEqual(code,1)
|
||||
self.assertTrue(receipt['lane_revoke'])
|
||||
self.assertTrue(receipt['query_revoke'])
|
||||
self.assertNotIn('private-synthetic',m.json.dumps(receipt))
|
||||
native=m.Api();native.opener=self.opener(self.response(raw=raw))
|
||||
with self.assertRaises(m.RecoveredLoginRefused) as error:
|
||||
native.call('auth/kubernetes/login',body={})
|
||||
self.assertEqual(error.exception.token,'test-only-not-a-credential')
|
||||
def test_endpoint_http_success_contracts(self):
|
||||
for endpoint, raw in [('auth/token/lookup-self', b'{"data":{}}'),
|
||||
('sys/capabilities', b'{"data":{}}'),
|
||||
('auth/kubernetes/login', b'{"auth":{}}')]:
|
||||
api = m.Api();api.opener = self.opener(self.response(raw=raw))
|
||||
self.assertIsInstance(api.call(endpoint, 'test', {}), dict)
|
||||
api = m.Api();api.opener = self.opener(self.response(status=204, content='', raw=b''))
|
||||
self.assertEqual(api.call('auth/token/revoke-self', 'test', {}), {})
|
||||
def test_bad_metadata_status_content_size_shape_errors_refused(self):
|
||||
responses = [self.response(status=status) for status in [201,202,206,301,302,307,308]]
|
||||
responses += [self.response(content='text/plain'), self.response(length='65537'),
|
||||
self.response(length='-1'), self.response(raw=b'x'*65537),
|
||||
self.response(raw=b'private-synthetic-invalid-json'),
|
||||
self.response(raw=b'{"data":[]}'), self.response(raw=b'{}'),
|
||||
self.response(raw=b'{"errors":[],"data":{}}'),
|
||||
self.response(raw=b'{"errors":["private-synthetic"],"data":{}}')]
|
||||
for response in responses:
|
||||
api=m.Api();api.opener=self.opener(response)
|
||||
with self.subTest(response=response), self.assertRaises((m.Refused,m.Transport)):
|
||||
api.call('auth/token/lookup-self', 'synthetic')
|
||||
def test_revoke202_wrong_content_error_body_never_confirmed_in_receipt(self):
|
||||
responses = [self.response(status=status) for status in [200,201,202,206]]
|
||||
responses += [self.response(status=204, raw=b'error')]
|
||||
for response in responses:
|
||||
code, receipt, _ = self.exercise_main(self.auth(), bad_revoke_response=response)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertEqual(receipt['result'], 'unproved')
|
||||
self.assertFalse(receipt['lane_revoke'])
|
||||
self.assertFalse(receipt['query_revoke'])
|
||||
def test_valid204_main_cleanup_confirmation(self):
|
||||
code, receipt, _ = self.exercise_main(self.auth(), bad_revoke_response=self.response(status=204, content='', raw=b''))
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(receipt['lane_revoke'])
|
||||
self.assertTrue(receipt['query_revoke'])
|
||||
def test_missing_invalid_accessors_fail_with_recovered_token_cleanup(self):
|
||||
for accessor in [None, '', 123, ' ', 'x'*257]:
|
||||
auth=self.auth();auth['accessor']=accessor
|
||||
code, receipt, _ = self.exercise_main(auth)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertTrue(receipt['lane_revoke'])
|
||||
self.assertTrue(receipt['query_revoke'])
|
||||
self.assertNotIn('lane_accessor_sha256',receipt)
|
||||
code, receipt, _ = self.exercise_main(self.auth(), query_info={'accessor':accessor})
|
||||
self.assertEqual(code, 1)
|
||||
self.assertTrue(receipt['query_revoke'])
|
||||
self.assertNotIn('query_accessor_sha256',receipt)
|
||||
def test_ttl_boolean_zero_negative_overbudget_refused(self):
|
||||
for ttl in [True,False,0,-1,301,'300']:
|
||||
auth=self.auth();auth['lease_duration']=ttl
|
||||
with self.subTest(ttl=ttl), self.assertRaises(m.Refused):
|
||||
m.lane_matrix(Fake(), 'test-query', auth, 'operator')
|
||||
def test_approved_revision_mismatch_precedes_receipt_token_or_native(self):
|
||||
from unittest.mock import patch
|
||||
import os
|
||||
argv=['verifier','operator','--survey-token-fd','99','--approval-reference','test',
|
||||
'--approved-source-sha256','wrong','--attended','--receipt','/tmp/must-not-exist-mason-test']
|
||||
with patch.object(m.sys,'argv',argv), patch.object(m.sys.stdin,'isatty',return_value=True), \
|
||||
patch.dict(os.environ,{},clear=True), patch.object(m.os,'open',side_effect=AssertionError('no IO')):
|
||||
with self.assertRaises(SystemExit) as error:
|
||||
m.main()
|
||||
self.assertEqual(error.exception.code,2)
|
||||
def test_subprocess_capture_is_bounded_while_running(self):
|
||||
import sys
|
||||
with self.assertRaises(m.Refused):
|
||||
m.run([sys.executable,'-c', 'import sys; sys.stdout.write("x"*1000000)'], {'PATH':'/usr/bin:/bin'})
|
||||
result=m.run([sys.executable,'-c','import sys; print("synthetic"); sys.stderr.write("synthetic error")'],{'PATH':'/usr/bin:/bin'})
|
||||
self.assertEqual(result,(0,b'synthetic\n',b'synthetic error'))
|
||||
def test_real_relative_redirect_and_hostile_proxy_refused(self):
|
||||
import threading
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
calls=[]
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
calls.append(self.path)
|
||||
self.send_response(302);self.send_header('Location','/private');self.end_headers()
|
||||
def log_message(self,*args):pass
|
||||
server=HTTPServer(('127.0.0.1',0),Handler)
|
||||
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
||||
try:
|
||||
with patch.object(m,'BASE','http://127.0.0.1:'+str(server.server_port)), \
|
||||
patch.dict(os.environ, {'HTTP_PROXY':'http://127.0.0.1:1','http_proxy':'http://127.0.0.1:1','NO_PROXY':'','no_proxy':''}):
|
||||
with self.assertRaises(m.Refused):
|
||||
m.Api().call('auth/token/lookup-self','synthetic-not-a-credential')
|
||||
finally:
|
||||
server.shutdown();server.server_close();thread.join()
|
||||
self.assertEqual(calls,['/v1/auth/token/lookup-self'])
|
||||
def test_redirect_refused(self):
|
||||
with self.assertRaises(m.Refused):
|
||||
m.NoRedirect().redirect_request(None, None, None, None, None, None)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -84,3 +84,33 @@ This bounded review task is complete. Corrections and native proof remain live
|
|||
in MASON-WP-0009-T05; no plan completion or route activation is claimed and no
|
||||
new residual is parked in prose. No native action, credential request, value
|
||||
access, approval disposition, commit, push or consistency sync was performed.
|
||||
|
||||
|
||||
### T03 corrected-source re-review — same review trial
|
||||
|
||||
Completed independent re-review preparation before the refined formal entry.
|
||||
Original request-changes review and frozen refinement01 are preserved. Exact
|
||||
corrected manifest SHA256 4dbf808e2d79c2e6600a163e147db7d15b075e59a8edf8cd236f31fdce9b5155,
|
||||
verifier SHA256 f7685646dca48db5874174940c1826db186e1ed73e167814d8c0f37af3ad619c.
|
||||
Verified all five immutable hashes and actual existing WP0009 baseline.
|
||||
|
||||
Corrected source now accepts only HTTP200 application/json object envelopes with
|
||||
endpoint data/auth shape and no errors member. Self-revoke requires HTTP204 and
|
||||
empty body; stale or error responses cannot confirm cleanup. Nonempty typed
|
||||
bounded accessors are mandatory; recovered query/lane tokens still revoke after
|
||||
missing accessor or other later verification refusal. TTL types/limits, mixed
|
||||
errors plus auth recovery, source digest preflight, incremental 64KiB native
|
||||
stdout/stderr capture and redirect/proxy guards are covered. All 29 maker tests
|
||||
pass independently. Four additional independent counterexamples pass: cross-origin
|
||||
redirect target gets no token/request; absent revoke response and failed cleanup
|
||||
remain unproved; expired TTL/missing accessor retain cleanup with no correlation
|
||||
success; oversized native stderr is refused while child runs.
|
||||
|
||||
INTENT Mission/Responsibility boundary remains respected: no KV GET, no widening,
|
||||
source preparation only, exact founder go still required. Nonmember acceptance
|
||||
continues to need issuer-authenticated human nonmember/aal2 and correlated native
|
||||
platform groups refusal for the same attempted subject/flow; callback text alone
|
||||
never passes. Source acceptance does not attest native isolation or finish T05.
|
||||
Corrections to the first review's two blocking defects are complete at source;
|
||||
existing MASON-WP-0009-T05 retains all native matrices, nonmember correlation,
|
||||
owner acceptance and route activation. No new residual or native act is claimed.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ flavor: planning
|
|||
owner: claude
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-06"
|
||||
updated: "2026-10-07"
|
||||
updated: "2026-10-11"
|
||||
state_hub_workstream_id: "d9be08f5-a0f5-5cd1-8f87-0a6868661d77"
|
||||
---
|
||||
|
||||
|
|
@ -182,3 +182,35 @@ only, 5m, 8 uses, no default policy). ops-mason's checks are complete. Pointer
|
|||
entry proposed to ops-warden as draft, non-resolvable (message `8ad8ed4e`).
|
||||
Left owner-run: capability checks with live operator and ESO tokens and a
|
||||
non-member refusal.
|
||||
|
||||
|
||||
2026-10-11 (preparation only, no native action): a guarded founder-run verifier
|
||||
and packet are prepared for T05: `scripts/verify-keycape-client-custody.py`,
|
||||
`docs/keycape-client-custody-attended-verification.md`, source preparation record
|
||||
`docs/evidence/2026-10-11-keycape-client-custody-native-preparation.json`.
|
||||
The existing read-only survey query authority is reused because neither lane
|
||||
policy grants sys/capabilities-self; lane tokens are queried in memory via
|
||||
sys/capabilities without any KV GET. The query grant is checked for exact task
|
||||
identity, policies, bounded TTL and explicit data denial. Native OIDC uses
|
||||
-no-store; ESO uses a separately minted, 10m Kubernetes TokenRequest for the
|
||||
existing ServiceAccount, never a Secret/controller-token read. Failed cleanup
|
||||
invalidates observations. Nonmember CLI failure cannot pass: independently
|
||||
verified key-cape identity/groups/aal2 and platform's correlated groups refusal
|
||||
are required before owner acceptance. 29 offline tests passed, including broad
|
||||
query authority, wrong role/ESO identity, unsafe matrix, endpoint secret-read
|
||||
refusal, transport versus claim failure, unexpected nonmember success and
|
||||
cleanup failures. Formal ops-mason source review, exact-revision founder
|
||||
attendance approval, native receipts and nonmember owner correlation remain
|
||||
unresolved. T05 and route activation remain open; no foreign owner review,
|
||||
authority grant, workplan completion or live acceptance is asserted.
|
||||
|
||||
|
||||
2026-10-11 source refinement (same T05 trial, no native action): independent
|
||||
review of the initial immutable stage requested HTTP revoke/status/error-shape
|
||||
and native accessor corrections. Corrected source requires endpoint-specific
|
||||
HTTP200 JSON or empty HTTP204 self-revoke, rejects mixed errors, requires actual
|
||||
nonempty native accessors, preserves recovered-token cleanup on malformed login,
|
||||
uses positive integer TTLs and bounded incremental native stdout/stderr capture.
|
||||
29 tests pass; corrected manifest is separate and preserves the rejected input.
|
||||
Formal review of corrected bytes and all founder/native acceptance gates remain
|
||||
open; this refinement is not a second scored success or an authority change.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue