RPF-WP-0054-T03: credential.py generate for keycape-client-secret-generate
Silent generate-in-custody: verifies the SecurityCase file (digest, type, materialized, confidential, client_id) per ops-warden 23985370, mints the 5m create-only grant token from the caller's session, proves absence, writes once with CAS 0, verifies by metadata only and revokes the token. Agents, root and callers without the issuer policy are refused. Secret length awaits key-cape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 469992@bnt-lap001 Assistant-Session: e4b8e420-df0a-441d-83ae-7f19130594d9
This commit is contained in:
parent
7d498c8547
commit
c921aa0aed
4 changed files with 453 additions and 2 deletions
|
|
@ -1177,6 +1177,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
|
||||
def main() -> int:
|
||||
guard_verbose_env()
|
||||
if sys.argv[1:2] == ["generate"]:
|
||||
# RPF-WP-0054: generate-in-custody runs on the caller's own session and
|
||||
# must stay silent, so it bypasses the lease runner and issuer prompt.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from keycape_client_secret_generate import main as generate_main
|
||||
return generate_main(sys.argv[2:])
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
args.lease_dir = resolve_repo_path(args.lease_dir)
|
||||
|
|
|
|||
246
scripts/keycape_client_secret_generate.py
Normal file
246
scripts/keycape_client_secret_generate.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
#!/usr/bin/env python3
|
||||
"""RPF-WP-0054-T03: generate a KeyCape confidential-client secret inside custody.
|
||||
|
||||
Entry point for the oidc-browser-relying-party ``mint-secret`` step (lane
|
||||
``keycape-client-secret-generate``). Normally reached as
|
||||
``credential.py generate ...``; ops-warden runs it as a child in the caller's
|
||||
own environment and holds no token.
|
||||
|
||||
credential.py generate --grant keycape/client-secret-generate \\
|
||||
--client-id forgejo --case-file <case.yaml> \\
|
||||
--case-ref forgejo-netkingdom-oidc@sha256:<hex64> --receipt <file>
|
||||
|
||||
Steps (every one fails closed):
|
||||
|
||||
1. Refuse agents (``WARDEN_AGENT_ID``) and root. The caller's session must hold
|
||||
the broker issuer policy for this grant.
|
||||
2. Re-derive the case reference from ``--case-file`` (sha256 over its exact
|
||||
bytes) and require: it equals ``--case-ref``; type
|
||||
``oidc-browser-relying-party@1.0``; status ``materialized``; client_type
|
||||
``confidential``; ``parameters.client_id`` equals ``--client-id``, which
|
||||
matches the type's pattern and is not a reserved entry.
|
||||
3. Mint a 5-minute grant token through token role
|
||||
``keycape-client-secret-generate``. All KV calls below use only that token.
|
||||
4. Prove absence by metadata (404). An existing entry is never touched.
|
||||
5. Generate the value in this process and write it with CAS 0 through the
|
||||
``bao`` child's stdin.
|
||||
6. Verify by metadata only: current version 1, live. The data path is never read.
|
||||
7. Revoke the grant token (revoke-self).
|
||||
|
||||
The process prints nothing. The receipt (mode 0600, refused if it exists) and
|
||||
the optional State Hub event hold the path, version, case reference, booleans and
|
||||
an outcome code only.
|
||||
|
||||
Exit codes: 0 generated (dry-run: would generate); 1 contained internal error;
|
||||
2 precondition refused; 3 entry already exists (never overwritten, including a
|
||||
lost CAS race); 4 token or write failure; 5 verification failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import yaml
|
||||
|
||||
GRANT = 'keycape/client-secret-generate'
|
||||
TOKEN_ROLE = 'keycape-client-secret-generate'
|
||||
GRANT_POLICY = 'keycape-client-secret-generate'
|
||||
ISSUER_POLICY = 'credential-broker-keycape-client-secret-generate-issuer'
|
||||
PREFIX = 'workloads/keycape'
|
||||
RESERVED = {'secrets-engine-openbao'}
|
||||
FIELD = 'CLIENT_SECRET'
|
||||
CASE_TYPE = 'oidc-browser-relying-party@1.0'
|
||||
# oidc-browser-relying-party@1.0 parameter client_id.
|
||||
CLIENT_ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
||||
CASE_REF_RE = re.compile(r'^[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64}$')
|
||||
# Proposed 48 random bytes -> 64 URL-safe characters; key-cape confirmation
|
||||
# pending (RPF-WP-0054-T01).
|
||||
SECRET_BYTES = 48
|
||||
TOKEN_TTL = '5m'
|
||||
SCHEMA = 'platform.keycape-client-secret-generate.v1'
|
||||
|
||||
|
||||
class Exit(Exception):
|
||||
def __init__(self, code: int, reason: str):
|
||||
super().__init__(reason)
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def bao(args, token=None, payload=None):
|
||||
env = dict(os.environ)
|
||||
if token is not None:
|
||||
env['BAO_TOKEN'] = env['VAULT_TOKEN'] = token
|
||||
return subprocess.run(['bao', *args], env=env,
|
||||
input=None if payload is None else json.dumps(payload).encode(),
|
||||
capture_output=True, timeout=60)
|
||||
|
||||
|
||||
def absent(result):
|
||||
return result.returncode != 0 and (b'404' in result.stderr or b'No value found' in result.stderr)
|
||||
|
||||
|
||||
def read_json(result, code):
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
raise Exit(code, 'unparseable_bao_output') from None
|
||||
|
||||
|
||||
def check_case(args, receipt):
|
||||
if not CLIENT_ID_RE.match(args.client_id) or args.client_id in RESERVED:
|
||||
raise Exit(2, 'client_id_invalid_or_reserved')
|
||||
if not CASE_REF_RE.match(args.case_ref):
|
||||
raise Exit(2, 'case_ref_malformed')
|
||||
try:
|
||||
raw = Path(args.case_file).read_bytes()
|
||||
doc = yaml.safe_load(raw)
|
||||
except (OSError, yaml.YAMLError):
|
||||
raise Exit(2, 'case_file_unreadable') from None
|
||||
spec = (doc or {}).get('spec') if isinstance(doc, dict) else None
|
||||
if not isinstance(spec, dict):
|
||||
raise Exit(2, 'case_file_shape')
|
||||
params = spec.get('parameters') or {}
|
||||
derived = f"{spec.get('id')}@sha256:{hashlib.sha256(raw).hexdigest()}"
|
||||
if derived != args.case_ref:
|
||||
raise Exit(2, 'case_ref_mismatch')
|
||||
if spec.get('type') != CASE_TYPE:
|
||||
raise Exit(2, 'case_type_mismatch')
|
||||
if spec.get('status') != 'materialized':
|
||||
raise Exit(2, 'case_not_materialized')
|
||||
if params.get('client_type') != 'confidential':
|
||||
raise Exit(2, 'case_client_not_confidential')
|
||||
if params.get('client_id') != args.client_id:
|
||||
raise Exit(2, 'case_client_id_mismatch')
|
||||
receipt['case_verified'] = True
|
||||
|
||||
|
||||
def run(args, receipt, state):
|
||||
if args.grant != GRANT:
|
||||
raise Exit(2, 'unknown_grant')
|
||||
if os.environ.get('WARDEN_AGENT_ID'):
|
||||
raise Exit(2, 'agent_callers_refused')
|
||||
check_case(args, receipt)
|
||||
|
||||
ident = bao(['token', 'lookup', '-format=json'])
|
||||
if ident.returncode != 0:
|
||||
raise Exit(2, 'caller_session_missing')
|
||||
policies = (read_json(ident, 2).get('data') or {}).get('policies') or []
|
||||
if 'root' in policies:
|
||||
raise Exit(2, 'root_refused')
|
||||
if ISSUER_POLICY not in policies:
|
||||
raise Exit(2, 'caller_lacks_issuer_policy')
|
||||
|
||||
created = bao(['token', 'create', f'-role={TOKEN_ROLE}', f'-policy={GRANT_POLICY}',
|
||||
f'-ttl={TOKEN_TTL}', '-format=json'])
|
||||
if created.returncode != 0:
|
||||
raise Exit(4, 'grant_token_create_failed')
|
||||
try:
|
||||
state['token'] = read_json(created, 4)['auth']['client_token']
|
||||
except (KeyError, TypeError):
|
||||
raise Exit(4, 'grant_token_create_failed') from None
|
||||
token = state['token']
|
||||
|
||||
meta_path = f'platform/metadata/{PREFIX}/{args.client_id}'
|
||||
meta = bao(['read', '-format=json', meta_path], token=token)
|
||||
if meta.returncode == 0:
|
||||
raise Exit(3, 'already_present')
|
||||
if not absent(meta):
|
||||
raise Exit(2, 'absence_not_proven')
|
||||
if args.dry_run:
|
||||
return 'dry_run_would_generate'
|
||||
|
||||
value = secrets.token_urlsafe(SECRET_BYTES)
|
||||
written = bao(['write', '-format=json', f'platform/data/{PREFIX}/{args.client_id}', '-'],
|
||||
token=token, payload={'options': {'cas': 0}, 'data': {FIELD: value}})
|
||||
del value
|
||||
if written.returncode != 0:
|
||||
if bao(['read', '-format=json', meta_path], token=token).returncode == 0:
|
||||
raise Exit(3, 'already_present_lost_race')
|
||||
raise Exit(4, 'write_failed')
|
||||
|
||||
meta = bao(['read', '-format=json', meta_path], token=token)
|
||||
if meta.returncode != 0:
|
||||
raise Exit(5, 'verify_metadata_unreadable')
|
||||
data = read_json(meta, 5).get('data') or {}
|
||||
version = (data.get('versions') or {}).get('1') or {}
|
||||
if data.get('current_version') != 1 or version.get('deletion_time') or version.get('destroyed'):
|
||||
raise Exit(5, 'verify_version_mismatch')
|
||||
receipt['kv_version'] = 1
|
||||
return 'generated'
|
||||
|
||||
|
||||
def record_state_hub(url, receipt):
|
||||
body = {
|
||||
'summary': (f"keycape-client-secret-generate {receipt['outcome']}: {receipt['path']} "
|
||||
f"v{receipt.get('kv_version', '-')} case {receipt['case_ref']}"),
|
||||
'event_type': 'credential_change_evidence',
|
||||
'author': 'railiance-platform-credential-broker',
|
||||
}
|
||||
request = urllib.request.Request(url.rstrip('/') + '/progress/', data=json.dumps(body).encode(),
|
||||
headers={'Content-Type': 'application/json'}, method='POST')
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return 200 <= response.status < 300
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, 1)
|
||||
os.dup2(devnull, 2)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--grant', required=True)
|
||||
parser.add_argument('--client-id', required=True)
|
||||
parser.add_argument('--case-ref', required=True)
|
||||
parser.add_argument('--case-file', required=True)
|
||||
parser.add_argument('--receipt', type=Path)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--record-state-hub', action='store_true')
|
||||
parser.add_argument('--state-hub-url', default=os.environ.get('STATE_HUB_URL', 'http://127.0.0.1:8000'))
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit:
|
||||
return 2
|
||||
receipt = {'schema': SCHEMA, 'mode': 'dry-run' if args.dry_run else 'generate',
|
||||
'grant': args.grant, 'client_id': args.client_id, 'case_ref': args.case_ref,
|
||||
'path': f'platform/{PREFIX}/{args.client_id}', 'value_reads': 0,
|
||||
'credential_values_emitted': False}
|
||||
fd = None
|
||||
if args.receipt is not None:
|
||||
try:
|
||||
fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
except OSError:
|
||||
return 2
|
||||
state = {}
|
||||
try:
|
||||
receipt['outcome'] = run(args, receipt, state)
|
||||
code = 0
|
||||
except Exit as exc:
|
||||
receipt['outcome'] = exc.reason
|
||||
code = exc.code
|
||||
except Exception:
|
||||
receipt['outcome'] = 'contained_internal_error'
|
||||
code = 1
|
||||
if state.get('token'):
|
||||
receipt['grant_token_revoked'] = bao(['token', 'revoke', '-self'], token=state['token']).returncode == 0
|
||||
receipt['exit_code'] = code
|
||||
if args.record_state_hub:
|
||||
try:
|
||||
receipt['state_hub_recorded'] = record_state_hub(args.state_hub_url, receipt)
|
||||
except Exception:
|
||||
receipt['state_hub_recorded'] = False
|
||||
if fd is not None:
|
||||
os.write(fd, (json.dumps(receipt, indent=2, sort_keys=True) + '\n').encode())
|
||||
os.close(fd)
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
177
tests/test_keycape_client_secret_generate.py
Normal file
177
tests/test_keycape_client_secret_generate.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""Offline tests for credential.py generate (RPF-WP-0054-T03) against a fake ``bao``.
|
||||
|
||||
Runs ``credential.py generate`` as a real subprocess, so the silence contract and
|
||||
the delegation from credential.py are tested end to end. The fake records which
|
||||
token each call used: KV calls must use the minted grant token, never the
|
||||
caller's session.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CLI = ROOT / 'scripts/credential.py'
|
||||
GRANT_TOKEN = 'hvs.grant-' + 'G7h8' * 6
|
||||
|
||||
FAKE_BAO = textwrap.dedent(f'''\
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
sf = os.environ['FAKE_BAO_STATE']
|
||||
st = json.load(open(sf))
|
||||
a = sys.argv[1:]
|
||||
tok = os.environ.get('BAO_TOKEN', 'caller-session')
|
||||
st['calls'].append({{'args': a, 'token': tok}})
|
||||
def save(): json.dump(st, open(sf, 'w'))
|
||||
def out(obj): save(); print(json.dumps(obj)); sys.exit(0)
|
||||
def missing(): save(); sys.stderr.write('No value found at x\\n'); sys.exit(2)
|
||||
if a[:2] == ['token', 'lookup']: out({{'data': {{'policies': st['caller_policies']}}}})
|
||||
if a[:2] == ['token', 'create']:
|
||||
if st.get('fail_create'): save(); sys.exit(2)
|
||||
out({{'auth': {{'client_token': '{GRANT_TOKEN}', 'accessor': 'acc'}}}})
|
||||
if a[:2] == ['token', 'revoke']: st['revoked'] = tok; save(); sys.exit(0)
|
||||
if a[0] == 'read':
|
||||
rest = a[-1].split('/', 2)[2]
|
||||
if rest not in st['kv']: missing()
|
||||
n = len(st['kv'][rest])
|
||||
out({{'data': {{'current_version': n, 'versions': {{str(i + 1): {{}} for i in range(n)}}}}}})
|
||||
if a[0] == 'write':
|
||||
rest = a[-2].split('/', 2)[2]
|
||||
body = json.loads(sys.stdin.read())
|
||||
st['written'] = body
|
||||
if st.get('race'): st['kv'][rest] = [{{'x': 1}}]; save(); sys.exit(2)
|
||||
if st.get('fail_write'): save(); sys.exit(2)
|
||||
e = st['kv'].setdefault(rest, [])
|
||||
if body['options']['cas'] != len(e): save(); sys.exit(2)
|
||||
e.append(body['data'])
|
||||
out({{'data': {{'version': len(e)}}}})
|
||||
save(); sys.exit(9)
|
||||
''')
|
||||
|
||||
CASE = textwrap.dedent('''\
|
||||
apiVersion: netkingdom.io/security-case/v0.1
|
||||
kind: SecurityCase
|
||||
spec:
|
||||
id: forgejo-netkingdom-oidc
|
||||
type: oidc-browser-relying-party@1.0
|
||||
status: materialized
|
||||
parameters:
|
||||
client_id: forgejo
|
||||
client_type: confidential
|
||||
''')
|
||||
|
||||
|
||||
class Generate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
(self.tmp / 'bin').mkdir()
|
||||
(self.tmp / 'bin/bao').write_text(FAKE_BAO)
|
||||
(self.tmp / 'bin/bao').chmod(0o755)
|
||||
self.state = {'calls': [], 'kv': {},
|
||||
'caller_policies': ['default', 'credential-broker-keycape-client-secret-generate-issuer']}
|
||||
self.write_case(CASE)
|
||||
|
||||
def write_case(self, text):
|
||||
self.case = self.tmp / 'case.yaml'
|
||||
self.case.write_text(text)
|
||||
self.ref = 'forgejo-netkingdom-oidc@sha256:' + hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
def run_cli(self, *extra, client_id='forgejo', ref=None, env_extra=None):
|
||||
(self.tmp / 'state.json').write_text(json.dumps(self.state))
|
||||
receipt = self.tmp / f'r{len(list(self.tmp.glob("r*.json")))}.json'
|
||||
env = {k: v for k, v in os.environ.items() if k not in ('BAO_TOKEN', 'VAULT_TOKEN', 'WARDEN_AGENT_ID')}
|
||||
env.update(PATH=f'{self.tmp / "bin"}:{env["PATH"]}', FAKE_BAO_STATE=str(self.tmp / 'state.json'),
|
||||
**(env_extra or {}))
|
||||
proc = subprocess.run([sys.executable, str(CLI), 'generate', '--grant', 'keycape/client-secret-generate',
|
||||
'--client-id', client_id, '--case-file', str(self.case),
|
||||
'--case-ref', ref or self.ref, '--receipt', str(receipt), *extra],
|
||||
env=env, capture_output=True, text=True)
|
||||
self.assertEqual((proc.stdout, proc.stderr), ('', ''))
|
||||
self.state = json.loads((self.tmp / 'state.json').read_text())
|
||||
text = receipt.read_text()
|
||||
self.assertNotIn(GRANT_TOKEN, text)
|
||||
written = self.state.get('written')
|
||||
if written:
|
||||
self.assertNotIn(written['data']['CLIENT_SECRET'], text)
|
||||
return proc.returncode, json.loads(text)
|
||||
|
||||
def kv_calls(self):
|
||||
return [c for c in self.state['calls'] if c['args'][0] in ('read', 'write')]
|
||||
|
||||
def test_generates_once_with_grant_token_and_revokes_it(self):
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (0, 'generated'))
|
||||
self.assertEqual(receipt['kv_version'], 1)
|
||||
self.assertTrue(receipt['grant_token_revoked'])
|
||||
self.assertEqual(self.state['revoked'], GRANT_TOKEN)
|
||||
self.assertTrue(all(c['token'] == GRANT_TOKEN for c in self.kv_calls()))
|
||||
self.assertFalse(any(c['args'][0] == 'read' and '/data/' in c['args'][-1] for c in self.kv_calls()))
|
||||
value = self.state['written']['data']['CLIENT_SECRET']
|
||||
self.assertEqual(len(value), 64)
|
||||
self.assertEqual(self.state['written']['options'], {'cas': 0})
|
||||
|
||||
def test_existing_entry_is_never_touched(self):
|
||||
self.state['kv']['workloads/keycape/forgejo'] = [{'CLIENT_SECRET': 'old'}]
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (3, 'already_present'))
|
||||
self.assertNotIn('written', self.state)
|
||||
self.assertTrue(receipt['grant_token_revoked'])
|
||||
|
||||
def test_lost_cas_race_reports_already_present(self):
|
||||
self.state['race'] = True
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (3, 'already_present_lost_race'))
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
code, receipt = self.run_cli('--dry-run')
|
||||
self.assertEqual((code, receipt['outcome']), (0, 'dry_run_would_generate'))
|
||||
self.assertNotIn('written', self.state)
|
||||
|
||||
def test_case_checks_refuse_before_any_bao_call(self):
|
||||
cases = [
|
||||
('case_ref_mismatch', {'ref': 'forgejo-netkingdom-oidc@sha256:' + '0' * 64}),
|
||||
('case_client_id_mismatch', {'client_id': 'gitea'}),
|
||||
('client_id_invalid_or_reserved', {'client_id': 'secrets-engine-openbao'}),
|
||||
('client_id_invalid_or_reserved', {'client_id': '../forgejo'}),
|
||||
('case_ref_malformed', {'ref': 'forgejo'}),
|
||||
]
|
||||
for outcome, kwargs in cases:
|
||||
with self.subTest(outcome=outcome, **kwargs):
|
||||
self.state['calls'] = []
|
||||
code, receipt = self.run_cli(**kwargs)
|
||||
self.assertEqual((code, receipt['outcome']), (2, outcome))
|
||||
self.assertEqual(self.state['calls'], [])
|
||||
|
||||
def test_unmaterialized_or_public_case_refused(self):
|
||||
for text, outcome in ((CASE.replace('materialized', 'accepted'), 'case_not_materialized'),
|
||||
(CASE.replace('confidential', 'public'), 'case_client_not_confidential')):
|
||||
with self.subTest(outcome=outcome):
|
||||
self.write_case(text)
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, outcome))
|
||||
|
||||
def test_agents_root_and_unbound_callers_refused(self):
|
||||
code, receipt = self.run_cli(env_extra={'WARDEN_AGENT_ID': 'codex'})
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'agent_callers_refused'))
|
||||
self.state['caller_policies'] = ['root']
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'root_refused'))
|
||||
self.state['caller_policies'] = ['default']
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (2, 'caller_lacks_issuer_policy'))
|
||||
self.assertFalse(any(c['args'][:2] == ['token', 'create'] for c in self.state['calls']))
|
||||
|
||||
def test_write_failure_is_reported_and_token_revoked(self):
|
||||
self.state['fail_write'] = True
|
||||
code, receipt = self.run_cli()
|
||||
self.assertEqual((code, receipt['outcome']), (4, 'write_failed'))
|
||||
self.assertTrue(receipt['grant_token_revoked'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -45,7 +45,7 @@ case's `client-secret-path` artifacts and are unchanged here.
|
|||
|
||||
```task
|
||||
id: RPF-WP-0054-T01
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "38f92db0-317e-5b2b-8bbd-b4af3272cc21"
|
||||
```
|
||||
|
|
@ -61,6 +61,18 @@ goes into the receipt and into OpenBao `custom_metadata` (non-secret).
|
|||
**Done when:** both replies are recorded here, with no open question about
|
||||
format, field or caller.
|
||||
|
||||
2026-10-09 ops-warden (`23985370`): the caller is the human operator who runs
|
||||
`warden case run`. Warden runs `credential.py generate` as a child process in
|
||||
the caller's environment and holds no token or grant, so the caller's own OIDC
|
||||
session is the acting identity. Warden refuses the step under `WARDEN_AGENT_ID`.
|
||||
The case reference is `<case_id>@sha256:<hex64>` over the case file's exact
|
||||
bytes (`warden case plan` prints it). The platform must also take `--case-file`,
|
||||
re-derive the digest, and check that status is `materialized` and that
|
||||
`parameters.client_id` equals `--client-id`. The client_id pattern is taken from
|
||||
the case type, `^[a-z0-9][a-z0-9-]{1,62}$` (authoritative; it replaces the
|
||||
proposal above). Still open: key-cape confirmation of length and alphabet,
|
||||
asked 2026-10-09.
|
||||
|
||||
## T02 — Decide the grant shape
|
||||
|
||||
```task
|
||||
|
|
@ -103,7 +115,7 @@ built.
|
|||
|
||||
```task
|
||||
id: RPF-WP-0054-T03
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
depends_on: [RPF-WP-0054-T01, RPF-WP-0054-T02]
|
||||
state_hub_task_id: "3e159e92-f5ee-5a92-b887-e06daf7dfbdb"
|
||||
|
|
@ -133,6 +145,16 @@ reserved path, bad client_id, missing case-ref, write failure, verify mismatch,
|
|||
silence contract, and an assertion that no data-path read occurs. No secret
|
||||
text appears in any receipt.
|
||||
|
||||
2026-10-09 progress: `scripts/keycape_client_secret_generate.py`, reached as
|
||||
`credential.py generate --grant keycape/client-secret-generate --client-id <id>
|
||||
--case-file <case.yaml> --case-ref <ref> [--receipt f] [--dry-run]
|
||||
[--record-state-hub]`. It refuses agents, root, and callers without the issuer
|
||||
policy. It verifies the case file as ops-warden specified, mints the 5m grant
|
||||
token, and makes every KV call with that token only. It proves absence, writes
|
||||
once with CAS 0, verifies by metadata (version 1), revokes the token, and prints
|
||||
nothing. There are 8 offline tests. Remaining before done: key-cape's format
|
||||
confirmation (`SECRET_BYTES` is the proposed 48 bytes, 64 URL-safe characters).
|
||||
|
||||
## T04 — Grant, policy and token role source plus CCR
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue