From b68526267a4aaea2df8418ad4d400f64a845fa63 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 05:29:52 +0800 Subject: [PATCH 1/3] test(identity): expose credential delivery ownership without changing its protocol Refs #162 #83 #85. Add independent-construction and real one-time-claim regressions before extracting the existing Application policy. Keep existing credential, client recovery, batch and security suites in the cloud gate. --- .github/workflows/token-delivery-boundary.yml | 82 ++++++++++ docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md | 9 ++ tests/test_token_delivery_boundary.py | 147 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 .github/workflows/token-delivery-boundary.yml create mode 100644 docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md create mode 100644 tests/test_token_delivery_boundary.py diff --git a/.github/workflows/token-delivery-boundary.yml b/.github/workflows/token-delivery-boundary.yml new file mode 100644 index 00000000..5178b251 --- /dev/null +++ b/.github/workflows/token-delivery-boundary.yml @@ -0,0 +1,82 @@ +name: Credential delivery boundary +on: + push: + branches: [fix/token-delivery-boundary-20260929] + pull_request: + paths: + - 'src/msg/**' + - 'tests/test_token_delivery_boundary.py' + - '.github/workflows/token-delivery-boundary.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: token-delivery-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: false +jobs: + focused: + runs-on: ubuntu-latest + timeout-minutes: 12 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest" + --health-interval 5s --health-timeout 5s --health-retries 10 + valkey: + image: valkey/valkey:9.0.6 + ports: ['6379:6379'] + options: >- + --health-cmd "valkey-cli ping" + --health-interval 5s --health-timeout 5s --health-retries 10 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + MSG_TEST_VALKEY_URL: redis://127.0.0.1:6379/0 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + cache: pip + - name: Install isolated test dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq age + python -m pip install -e '.[dev]' + - name: Record source identity + env: + EVENT_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + mkdir -p artifacts + python - <<'PY' + import json, os, platform, subprocess + from pathlib import Path + def git(ref): + return subprocess.check_output(['git', 'rev-parse', ref], text=True).strip() + Path('artifacts/source.json').write_text(json.dumps({ + 'head': os.environ['EVENT_HEAD'], 'checkout': git('HEAD'), 'tree': git('HEAD^{tree}'), + 'run': os.environ['GITHUB_RUN_ID'], 'attempt': os.environ['GITHUB_RUN_ATTEMPT'], + 'python': platform.python_version(), 'scope': 'isolated-regression-not-production' + }, indent=2) + '\n') + PY + - name: Exercise boundaries and unchanged security contracts + run: >- + python -m pytest tests/test_token_delivery_boundary.py tests/test_token_delivery.py + tests/test_credential_delivery_contract.py tests/test_credential_delivery_matrix.py + tests/test_credential_recovery_window.py tests/test_client_token_delivery.py + tests/test_batch_secret_release.py tests/test_security_patch_regressions.py + --junitxml=artifacts/token-delivery-boundary.xml + - uses: actions/upload-artifact@v4 + if: always() + with: + name: token-delivery-boundary-evidence + path: artifacts/ + if-no-files-found: error diff --git a/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md new file mode 100644 index 00000000..171cc9f8 --- /dev/null +++ b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md @@ -0,0 +1,9 @@ +# Credential delivery boundary progress + +Issue #162, supplemental to #167; base2d1cc443c828dd2f735995cc6a5d20021fee6ac7 / tree6171f6041ffd0addaf72eadd35acf562f478281f. Branchfix/token-delivery-boundary-20260929. Coordination #83 comment5879008128. No other source branch is overwritten. + +Regression-first: independent import/construction, one verifier owner, compatibility forwarding without business policy in Application, unchanged derivation/receipt bytes, actual PostgreSQL concurrent one-time claim and expired refusal without consuming a claim. Existing full token/credential/recovery/client/batch security suites remain. Project execution is cloud-only; source editing and artifact inspection are local. + +Pending implementation: one narrow TokenDelivery owner, explicit metadata/key/clock/current-window dependencies, same service for identity forwarding and post-commit response release. Keep transaction-local recovery binding separate from the later claim commit; only then may a token enter a response. Persisted results/events/receipts remain secret-free. Preserve current settings/clock providers rather than freeze them accidentally. + +Final focused/full four-shard/node-ID/conformance/build/recovery outcomes are pending. No deployment, actual credentials, Root/PIN, backup destruction, external delivery or recovery promotion. #67 stays completed and its original functional regressions remain binding; this change is ownership-only, not a new token protocol. diff --git a/tests/test_token_delivery_boundary.py b/tests/test_token_delivery_boundary.py new file mode 100644 index 00000000..86c91522 --- /dev/null +++ b/tests/test_token_delivery_boundary.py @@ -0,0 +1,147 @@ +"""Credential delivery has one owner, separate from service composition.""" +import ast +import asyncio +import hashlib +import hmac +import importlib +import importlib.util +import inspect +from pathlib import Path +import subprocess +import sys + +import pytest + +from msg.core.codec import b64, canonical, unb64, wire +from msg.core.errors import Failure +from msg.core.requests import request_for, receipt_bytes +from msg.security.crypto import verify +from test_service import NOW, temporary_v3_args + + +def delivery_module(): + assert importlib.util.find_spec('msg.security.token_delivery') is not None + return importlib.import_module('msg.security.token_delivery') + + +def test_composition_only_delegates_the_existing_delivery_api(): + from msg.application import Application + module = delivery_module() + tree = ast.parse(inspect.getsource(Application)) + methods = {node.name: node for node in tree.body[0].body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + for name in ('issued_token', 'record_token_delivery', '_secrets_for_caller'): + method = methods[name] + assert len(method.body) == 1 and isinstance(method.body[0], ast.Return) + assert 'token_delivery' in ast.unparse(method) + assert Application.recovery_verifier is module.recovery_verifier + assert module.TokenDelivery.recovery_verifier is module.recovery_verifier + + +def test_domain_import_and_constructor_need_no_application_or_plugins(): + module = delivery_module() + assert set(inspect.signature(module.TokenDelivery).parameters) == { + 'metadata', 'token_secret', 'clock', 'recovery_window'} + code = """ +import importlib.abc +import sys +class NoApplication(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.startswith(('msg.application', 'msg.plugins', 'msg.transports', 'msg.storage')): + raise AssertionError('delivery imports composition/adapter: ' + fullname) +sys.meta_path.insert(0, NoApplication()) +from msg.security.token_delivery import TokenDelivery +instance = TokenDelivery(metadata=None, token_secret=b'x'*32, + clock=lambda: None, recovery_window=lambda: 900) +assert not hasattr(instance, 'application') +""" + result = subprocess.run([sys.executable, '-c', code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +def test_derivation_and_recovery_verifier_keep_the_published_bytes(): + module = delivery_module() + instance = module.TokenDelivery(metadata=None, token_secret=b'k'*32, + clock=lambda: NOW, recovery_window=lambda: 900) + request = request_for('identity.token_create', {'nonce': b64(b'n'*32)}, + 'https://example.invalid', request_id='stable-delivery', contract_version=2) + expected = hmac.digest(b'k'*32, b'issued-token-v1\0' + canonical({ + 'subject': 'u_subject', 'request_id': 'stable-delivery', 'nonce': b64(b'n'*32)}), 'sha256') + assert instance.issued_token(request, 'u_subject') == expected + assert instance.issued_token(request, 'u_other') != expected + assert module.recovery_verifier(b64(b'r'*32)) == hashlib.sha256( + b'token-recovery-v1\0' + b'r'*32).hexdigest() + with pytest.raises(Failure, match='invalid_recovery_secret'): + module.recovery_verifier(b64(b'r'*31)) + + +def test_authentication_uses_the_same_verifier_owner(): + from msg.security import authentication + module = delivery_module() + assert authentication.recovery_verifier is module.recovery_verifier + source = Path(authentication.__file__).read_text() + assert 'token-recovery-v1' not in source + + +async def test_real_service_binds_one_delivery_instance_for_all_executors(installed): + app, _ = installed + module = delivery_module() + assert isinstance(app.token_delivery, module.TokenDelivery) + assert app.executor.response_hook == app.token_delivery.release + assert app.new_executor().response_hook == app.token_delivery.release + + +async def test_standalone_delivery_claim_commits_once_without_persisting_secrets(installed): + app, _ = installed + module = delivery_module() + args, rid, _, _ = temporary_v3_args(request_id='domain-claim-once') + request = request_for('identity.temporary', args, app.settings.service_url, + request_id=rid, contract_version=3, + expires_at=NOW.replace(minute=1)) + app.executor.response_hook = None + committed = await app.executor.execute(request) + assert committed.status == 'ok' and 'token' not in committed.data, wire(committed) + delivery = module.TokenDelivery(metadata=app.metadata, token_secret=app._token_secret, + clock=lambda: NOW, recovery_window=lambda: app.settings.credential_delivery_recovery_window) + answers = await asyncio.gather(delivery.release(request, committed), + delivery.release(request, committed), return_exceptions=True) + successes = [value for value in answers if not isinstance(value, BaseException)] + failures = [value for value in answers if isinstance(value, Failure)] + assert len(successes) == len(failures) == 1 + assert failures[0].code == 'token_delivery_unavailable' + released = successes[0] + assert unb64(released.data['token']) == app.issued_token(request, committed.subject) + assert released.receipt == committed.receipt + verify(app.receipt_signer.public_key, receipt_bytes(released), released.receipt, purpose='receipt') + async with app.metadata.transaction(write=False) as tx: + saved = await tx.request_result(committed.subject, rid, request.payload_digest) + assert saved.receipt == committed.receipt and receipt_bytes(saved) == receipt_bytes(committed) + assert saved.data == committed.data and 'token' not in saved.data + row = tx.one('SELECT claimed_at,recovery_verifier FROM token_deliveries WHERE credential_id=?', + (committed.data['credential_id'],)) + assert row[0] is not None and row[1] == module.recovery_verifier(args['recovery_secret']) + assert released.data['token'] not in str(row) and args['recovery_secret'] not in str(row) + assert tx.one('SELECT COUNT(*) FROM events WHERE request_id=?', (rid,))[0] == 1 + + +async def test_delivery_failure_returns_no_new_secret_or_claim(installed): + app, _ = installed + module = delivery_module() + args, rid, _, _ = temporary_v3_args(request_id='domain-expired-claim') + request = request_for('identity.temporary', args, app.settings.service_url, + request_id=rid, contract_version=3, expires_at=NOW.replace(minute=1)) + app.executor.response_hook = None + committed = await app.executor.execute(request) + assert committed.status == 'ok', wire(committed) + async with app.metadata.transaction(write=False) as tx: + credential = await tx.credential(committed.data['credential_id']) + delivery = module.TokenDelivery(metadata=app.metadata, token_secret=app._token_secret, + clock=lambda: credential.expires_at, recovery_window=lambda: 900) + with pytest.raises(Failure, match='credential_expired'): + await delivery.release(request, committed) + async with app.metadata.transaction(write=False) as tx: + assert tx.one('SELECT claimed_at FROM token_deliveries WHERE credential_id=?', + (credential.id,))[0] is None + saved = await tx.request_result(committed.subject, rid, request.payload_digest) + assert saved.receipt == committed.receipt and receipt_bytes(saved) == receipt_bytes(committed) + assert saved.data == committed.data and 'token' not in saved.data From f015c60f05469f0058255bd645afef084e87840d Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 05:33:00 +0800 Subject: [PATCH 2/3] refactor(identity): give one-time credential delivery a single domain owner Refs #162 #83 #85. Preserve issuance/claim transaction timing, legacy derivation and receipt bytes, expiry and one-time response semantics. Application only composes/delegates; authentication shares the exact verifier. Narrow current clock/window providers preserve live settings changes. All three native source blobs match the reviewed local text. --- docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md | 18 ++++- src/msg/application.py | 71 ++++-------------- src/msg/security/authentication.py | 5 +- src/msg/security/token_delivery.py | 93 ++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 64 deletions(-) create mode 100644 src/msg/security/token_delivery.py diff --git a/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md index 171cc9f8..32116115 100644 --- a/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md +++ b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md @@ -2,8 +2,20 @@ Issue #162, supplemental to #167; base2d1cc443c828dd2f735995cc6a5d20021fee6ac7 / tree6171f6041ffd0addaf72eadd35acf562f478281f. Branchfix/token-delivery-boundary-20260929. Coordination #83 comment5879008128. No other source branch is overwritten. -Regression-first: independent import/construction, one verifier owner, compatibility forwarding without business policy in Application, unchanged derivation/receipt bytes, actual PostgreSQL concurrent one-time claim and expired refusal without consuming a claim. Existing full token/credential/recovery/client/batch security suites remain. Project execution is cloud-only; source editing and artifact inspection are local. +## Source ownership -Pending implementation: one narrow TokenDelivery owner, explicit metadata/key/clock/current-window dependencies, same service for identity forwarding and post-commit response release. Keep transaction-local recovery binding separate from the later claim commit; only then may a token enter a response. Persisted results/events/receipts remain secret-free. Preserve current settings/clock providers rather than freeze them accidentally. +Before: Application owns composition plus issued_token/recovery_verifier/record_token_delivery/_secrets_for_caller. Authentication contains a second recovery-verifier formula. -Final focused/full four-shard/node-ID/conformance/build/recovery outcomes are pending. No deployment, actual credentials, Root/PIN, backup destruction, external delivery or recovery promotion. #67 stays completed and its original functional regressions remain binding; this change is ownership-only, not a new token protocol. +After: security.token_delivery owns TokenDelivery and the one recovery_verifier function. Application constructs it, retains compatibility forwarding only, and binds all executors directly to the same release method. Authentication consumes the same pure verifier. Domain dependencies are explicitly metadata, the existing token derivation key, clock and a current-window provider; there is no Root signer, vault, Registry, plugin or HTTP object. + +Current providers deliberately preserve later settings/clock changes. Static text/AST review found the issued_token and release bodies identical to the original, recovery_verifier body identical, and record_token_delivery identical except access to the narrow current-window provider. No project code/test was executed locally. + +## Timing and compatibility + +The existing handler records recovery binding inside its issuance transaction. After it commits, release opens its own write transaction and commits the one-time claim before adding the token to response data. A failed/dropped response still requires the existing independent recovery operation. Results, events, original receipts, IDs, purpose strings, deadlines and operation versions retain their meaning. No new generic response hook, protocol version, identity or recovery mechanism. + +## Regression and cloud evidence + +Tests-first headb68526267a4aaea2df8418ad4d400f64a845fa63 / tree0f653206afd064f3e752cbb3442c202fbd72a4bb contains seven new ownership/construction/wire/concurrent-claim/expired-refusal cases. Focused cloud workflow retains all existing token, credential, recovery-window, client recovery, batch secret and security regressions. The red focused run is not cancelled by the later implementation head; both are bound to exact source identity. Results remain pending until actual artifacts are read. + +Final focused/full four-shard/node-ID/conformance/build/recovery outcomes are pending. No deployment, actual credentials, Root/PIN, backup destruction, external delivery or recovery promotion. #67 stays completed and its original functional regressions remain binding; this change is ownership-only, not a new token protocol. #167 must finish its own combined main gate separately. diff --git a/src/msg/application.py b/src/msg/application.py index 1862a579..752c312f 100644 --- a/src/msg/application.py +++ b/src/msg/application.py @@ -1,20 +1,17 @@ """Explicit composition root for network services. Contains no root signer.""" from __future__ import annotations -from dataclasses import replace -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from pathlib import Path -import hashlib import hmac import importlib from msg.constants import ROOT_SPACE, ROOT_SUBJECT -from msg.core.codec import loads, decode, unb64, b64, canonical, wire +from msg.core.codec import loads, decode, unb64 from msg.core.cursors import CursorCodec -from msg.core.errors import Failure, require +from msg.core.errors import require from msg.core.executor import OperationExecutor from msg.core.models import Certificate, Scope -from msg.core.requests import SECRET_DELIVERY_MIN_VERSION from msg.core.registry import Registry from msg.security.authentication import AuthenticationService from msg.security.authorization import AuthorizationService @@ -22,6 +19,7 @@ from msg.security.certificates import CertificateValidator from msg.security.crypto import Ed25519Signer from msg.security.quarantine import active as quarantine_active +from msg.security.token_delivery import TokenDelivery, recovery_verifier from msg.storage.git import GitContentStore @@ -39,6 +37,7 @@ def __init__(self,settings, *, clock=None, selftest_run_id=None): self.metadata=None self.contents=None self.executor=None + self.token_delivery=None self._loaded=False # Plugins are installed code, never resources, posts, or configuration expressions. implemented=('identity','content','discussion','communication','discovery','achievements','recovery','sharing','money','offers','store','bounty','orders','delivery') @@ -97,6 +96,9 @@ async def load(self): require(len(self._token_secret)==32,'invalid_service_key') self._vault_key=hmac.digest(self._token_secret,b'custodial-vault-aesgcm-v1','sha256') self.cursors=CursorCodec(hmac.digest(self._token_secret,b'cursor-key-v1','sha256')) + self.token_delivery=TokenDelivery(metadata=self.metadata,token_secret=self._token_secret, + clock=lambda: self.clock(), + recovery_window=lambda: self.settings.credential_delivery_recovery_window) self.certificates=CertificateValidator(self.registry,root_certificate,root_public,self.settings.service_url,self.clock) async with self.metadata.transaction(write=False) as tx: quarantined=quarantine_active(tx) @@ -133,7 +135,7 @@ def new_executor(self, authenticator=None): max_request_bytes=self.settings.server.limits.max_request_bytes, result_projection=self._result_projection, event_notifications=self._event_notifications) - executor.response_hook=self._secrets_for_caller + executor.response_hook=self.token_delivery.release executor.recovery_drill_marker=self.settings.config_dir/'recovery-drill.json' if self.executor is not None: executor.recovery_quarantined=self.executor.recovery_quarantined @@ -156,62 +158,15 @@ async def online_issuer(self,tx): return cert def issued_token(self,request,subject): - # Nonces are high-entropy claims, not random tokens persisted in results. - return hmac.digest(self._token_secret,b'issued-token-v1\0'+canonical({ - 'subject':subject,'request_id':request.request_id,'nonce':request.arguments['nonce']}),'sha256') + return self.token_delivery.issued_token(request,subject) - @staticmethod - def recovery_verifier(value): - secret=unb64(value,limit=64) - require(len(secret)>=32,'invalid_recovery_secret') - return hashlib.sha256(b'token-recovery-v1\0'+secret).hexdigest() + recovery_verifier=staticmethod(recovery_verifier) def record_token_delivery(self,tx,request,credential,now,*,recovery_deadline=None): - """Bind recovery before commit; neither token nor recovery secret is stored.""" - require(request.operation in SECRET_DELIVERY_MIN_VERSION and - request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation], - 'credential_delivery_upgrade_required') - field='new_recovery_secret' if request.operation=='identity.token_recover' else 'recovery_secret' - secret=unb64(request.arguments[field],limit=64) - require(secret!=unb64(request.arguments['nonce'],limit=64),'recovery_secret_not_independent') - if request.operation=='identity.token_recover': - require(secret!=unb64(request.arguments['recovery_secret'],limit=64), - 'recovery_secret_not_independent') - expires=min(credential.expires_at, - recovery_deadline if recovery_deadline is not None else - now+timedelta(seconds=self.settings.credential_delivery_recovery_window)) - require(expires>now,'recovery_unavailable') - tx.execute('''INSERT INTO token_deliveries - (credential_id,subject,request_id,request_digest,recovery_verifier,recovery_expires_at, - claimed_at,consumed_at) VALUES (?,?,?,?,?,?,NULL,NULL)''', - (credential.id,credential.subject_id,request.request_id,request.payload_digest, - self.recovery_verifier(request.arguments[field]),wire(expires)),write=True) + return self.token_delivery.record_token_delivery(tx,request,credential,now,recovery_deadline=recovery_deadline) async def _secrets_for_caller(self,request,result): - if result.status!='ok' or request.operation not in SECRET_DELIVERY_MIN_VERSION: - return result - require(request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation], - 'credential_delivery_upgrade_required') - token=self.issued_token(request,result.subject) - async with self.metadata.transaction(write=True) as tx: - credential=await tx.credential(result.data['credential_id']) - require(credential.revoked_at is None and credential.expires_at>self.clock(),'credential_expired') - require(hmac.compare_digest(credential.verifier,hashlib.sha256(token).digest()),'invalid_token_result') - row=tx.one('''SELECT subject,request_id,request_digest,claimed_at,consumed_at - FROM token_deliveries WHERE credential_id=?''',(credential.id,)) - require(row is not None and row[0]==result.subject and row[1]==request.request_id - and row[2]==request.payload_digest and row[4] is None,'token_delivery_unavailable', - details={'committed_result':wire(result,compact=True)}) - require(row[3] is None,'token_delivery_unavailable', - details={'committed_result':wire(result,compact=True)}) - updated=tx.execute('''UPDATE token_deliveries SET claimed_at=? WHERE credential_id=? - AND claimed_at IS NULL AND consumed_at IS NULL''', - (wire(self.clock()),credential.id),write=True) - require(updated.rowcount==1,'token_delivery_unavailable', - details={'committed_result':wire(result,compact=True)}) - # The claim commits before the adapter sees the token. A dropped response - # must use a separate, pre-bound recovery secret to rotate the credential. - return replace(result,data=dict(result.data,token=b64(token))) + return await self.token_delivery.release(request,result) async def close(self): if self.metadata is not None: diff --git a/src/msg/security/authentication.py b/src/msg/security/authentication.py index 3c2281ee..3a7291b1 100644 --- a/src/msg/security/authentication.py +++ b/src/msg/security/authentication.py @@ -11,6 +11,7 @@ from msg.core.requests import payload_fields,signing_bytes from msg.security.crypto import key_id,subject_id,verify from msg.security.quarantine import active as quarantine_active +from msg.security.token_delivery import recovery_verifier CUSTODIAL_SIGNED_WRITES=frozenset({ @@ -64,9 +65,7 @@ async def authenticate(self,request,session, *, entry): require(old.kind=='token' and old.subject_id==request.subject,'recovery_unavailable') actor=await session.subject(old.subject_id) require(not actor.local_only and actor.resource_id!=ROOT_SUBJECT,'local_only') - raw=unb64(request.arguments['recovery_secret'],limit=64) - require(len(raw)>=32,'invalid_recovery_secret') - verifier=hashlib.sha256(b'token-recovery-v1\0'+raw).hexdigest() + verifier=recovery_verifier(request.arguments['recovery_secret']) row=session.one('''SELECT recovery_verifier,recovery_expires_at,consumed_at,request_id FROM token_deliveries WHERE credential_id=? AND subject=?''', (old.id,actor.resource_id)) diff --git a/src/msg/security/token_delivery.py b/src/msg/security/token_delivery.py new file mode 100644 index 00000000..e60bc6b0 --- /dev/null +++ b/src/msg/security/token_delivery.py @@ -0,0 +1,93 @@ +"""One-time credential delivery, distinct from service composition. + +The handler records the verifier in its existing issuance transaction. After that +transaction commits, release claims the delivery in a separate write transaction; +only a committed claim can produce a response-only token. Lost responses must use +the pre-bound recovery operation, never replay a persistent secret. This module +owns no signer, vault, root capability, HTTP adapter or plugin registry. + +Clock and window providers preserve the configured current values without giving +the domain access to the whole settings/Application object. These are trusted +in-process dependencies, not a Python sandbox or an additional authorization path. +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace +from datetime import datetime, timedelta +import hashlib +import hmac + +from msg.core.codec import b64, canonical, unb64, wire +from msg.core.contracts import MetadataStore +from msg.core.errors import require +from msg.core.requests import SECRET_DELIVERY_MIN_VERSION + + +def recovery_verifier(value): + secret=unb64(value,limit=64) + require(len(secret)>=32,'invalid_recovery_secret') + return hashlib.sha256(b'token-recovery-v1\0'+secret).hexdigest() + +class TokenDelivery: + def __init__(self, *, metadata: MetadataStore, token_secret: bytes, + clock: Callable[[], datetime], recovery_window: Callable[[], int]): + require(len(token_secret)==32,'invalid_service_key') + self.metadata=metadata + self._token_secret=token_secret + self.clock=clock + self.recovery_window=recovery_window + + def issued_token(self,request,subject): + # Nonces are high-entropy claims, not random tokens persisted in results. + return hmac.digest(self._token_secret,b'issued-token-v1\0'+canonical({ + 'subject':subject,'request_id':request.request_id,'nonce':request.arguments['nonce']}),'sha256') + + recovery_verifier=staticmethod(recovery_verifier) + + def record_token_delivery(self,tx,request,credential,now,*,recovery_deadline=None): + """Bind recovery before commit; neither token nor recovery secret is stored.""" + require(request.operation in SECRET_DELIVERY_MIN_VERSION and + request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation], + 'credential_delivery_upgrade_required') + field='new_recovery_secret' if request.operation=='identity.token_recover' else 'recovery_secret' + secret=unb64(request.arguments[field],limit=64) + require(secret!=unb64(request.arguments['nonce'],limit=64),'recovery_secret_not_independent') + if request.operation=='identity.token_recover': + require(secret!=unb64(request.arguments['recovery_secret'],limit=64), + 'recovery_secret_not_independent') + expires=min(credential.expires_at, + recovery_deadline if recovery_deadline is not None else + now+timedelta(seconds=self.recovery_window())) + require(expires>now,'recovery_unavailable') + tx.execute('''INSERT INTO token_deliveries + (credential_id,subject,request_id,request_digest,recovery_verifier,recovery_expires_at, + claimed_at,consumed_at) VALUES (?,?,?,?,?,?,NULL,NULL)''', + (credential.id,credential.subject_id,request.request_id,request.payload_digest, + self.recovery_verifier(request.arguments[field]),wire(expires)),write=True) + + async def release(self,request,result): + if result.status!='ok' or request.operation not in SECRET_DELIVERY_MIN_VERSION: + return result + require(request.contract_version>=SECRET_DELIVERY_MIN_VERSION[request.operation], + 'credential_delivery_upgrade_required') + token=self.issued_token(request,result.subject) + async with self.metadata.transaction(write=True) as tx: + credential=await tx.credential(result.data['credential_id']) + require(credential.revoked_at is None and credential.expires_at>self.clock(),'credential_expired') + require(hmac.compare_digest(credential.verifier,hashlib.sha256(token).digest()),'invalid_token_result') + row=tx.one('''SELECT subject,request_id,request_digest,claimed_at,consumed_at + FROM token_deliveries WHERE credential_id=?''',(credential.id,)) + require(row is not None and row[0]==result.subject and row[1]==request.request_id + and row[2]==request.payload_digest and row[4] is None,'token_delivery_unavailable', + details={'committed_result':wire(result,compact=True)}) + require(row[3] is None,'token_delivery_unavailable', + details={'committed_result':wire(result,compact=True)}) + updated=tx.execute('''UPDATE token_deliveries SET claimed_at=? WHERE credential_id=? + AND claimed_at IS NULL AND consumed_at IS NULL''', + (wire(self.clock()),credential.id),write=True) + require(updated.rowcount==1,'token_delivery_unavailable', + details={'committed_result':wire(result,compact=True)}) + # The claim commits before the adapter sees the token. A dropped response + # must use a separate, pre-bound recovery secret to rotate the credential. + return replace(result,data=dict(result.data,token=b64(token))) From 26e43fcde63d6d5f50feec7d676f16f3924bfbfe Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Tue, 29 Sep 2026 05:44:04 +0800 Subject: [PATCH 3/3] test(identity): inspect persisted event JSON for one-time delivery evidence Refs #162 #83. Correct the new test's nonexistent events.request_id column after independently verified cloud failure. Keep the exact one-event assertion and strengthen subject/secret checks against real stored Event JSON. Production code and schema are unchanged. --- docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md | 22 ++++++++++++---------- tests/test_token_delivery_boundary.py | 10 ++++++++-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md index 32116115..d4855a63 100644 --- a/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md +++ b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md @@ -1,21 +1,23 @@ # Credential delivery boundary progress -Issue #162, supplemental to #167; base2d1cc443c828dd2f735995cc6a5d20021fee6ac7 / tree6171f6041ffd0addaf72eadd35acf562f478281f. Branchfix/token-delivery-boundary-20260929. Coordination #83 comment5879008128. No other source branch is overwritten. +Issue #162; PR #172, stacked on #167 base2d1cc443c828dd2f735995cc6a5d20021fee6ac7 / tree6171f6041ffd0addaf72eadd35acf562f478281f. Branchfix/token-delivery-boundary-20260929. Coordination #83 comment5879008128. No other source branch is overwritten. -## Source ownership +## Single source and transaction timing -Before: Application owns composition plus issued_token/recovery_verifier/record_token_delivery/_secrets_for_caller. Authentication contains a second recovery-verifier formula. +security.token_delivery owns TokenDelivery and the one recovery_verifier function. Application constructs it, retains compatibility forwarding only, and binds HTTP/SSH executors directly to the same release method. Authentication consumes that verifier, not a second formula. Dependencies: metadata, the existing token derivation key, clock and current-window providers. No Root signer, vault, Registry, plugin or HTTP object. -After: security.token_delivery owns TokenDelivery and the one recovery_verifier function. Application constructs it, retains compatibility forwarding only, and binds all executors directly to the same release method. Authentication consumes the same pure verifier. Domain dependencies are explicitly metadata, the existing token derivation key, clock and a current-window provider; there is no Root signer, vault, Registry, plugin or HTTP object. +The original issued_token/release/verifier bodies are unchanged; record_token_delivery changes only current-window access. Live clock/settings changes are preserved. The handler still records recovery binding inside issuance. After commit, release independently commits the one-time claim before exposing the response token. Lost responses still require recovery. IDs, operation versions, purpose strings, deadlines, persistent results/events/receipts retain their existing meaning. No project code/test executed locally. -Current providers deliberately preserve later settings/clock changes. Static text/AST review found the issued_token and release bodies identical to the original, recovery_verifier body identical, and record_token_delivery identical except access to the narrow current-window provider. No project code/test was executed locally. +## Independently verified cloud evidence -## Timing and compatibility +RED b68526267a4aaea2df8418ad4d400f64a845fa63 / tree0f653206afd064f3e752cbb3442c202fbd72a4bb, run36486490339, artifact10999622677: **7 failed /115 passed /0 errors /0 skipped**. All seven new ownership checks failed; all existing suites passed. ZIP SHA2560fed854c2890019a5892fbabdde7a6fe3093b05d6805b64d701504f9f44de958; source.json confirms head/tree/attempt1/Python3.15.0rc2. -The existing handler records recovery binding inside its issuance transaction. After it commits, release opens its own write transaction and commits the one-time claim before adding the token to response data. A failed/dropped response still requires the existing independent recovery operation. Results, events, original receipts, IDs, purpose strings, deadlines and operation versions retain their meaning. No new generic response hook, protocol version, identity or recovery mechanism. +First implementation f015c60f05469f0058255bd645afef084e87840d / tree46af80b065a8e4eaea319b87a70bb5ddb281f966, push run36486854337, artifact10998634353: **1 failed /121 passed /0 errors /0 skipped**. ZIP SHA2560186be18b2ed3393af3fe5eaa5ee11eb1caa315f941cd0a1ddd469fb39fdc189 and source.json independently verified. -## Regression and cloud evidence +The sole failure is a new test's SQL query for events.request_id, which does not exist: events stores seq/id/body and canonical Event JSON includes request_id. All earlier assertions in that case (concurrent one-time release, receipt verification, secret-free persistent result, recovery binding) passed. The corrected test reads real body JSON, still requires exactly one matching event, and additionally checks its subject and absence of both token and recovery secret. No production schema/code or old assertion is changed to accommodate a fabricated column. -Tests-first headb68526267a4aaea2df8418ad4d400f64a845fa63 / tree0f653206afd064f3e752cbb3442c202fbd72a4bb contains seven new ownership/construction/wire/concurrent-claim/expired-refusal cases. Focused cloud workflow retains all existing token, credential, recovery-window, client recovery, batch secret and security regressions. The red focused run is not cancelled by the later implementation head; both are bound to exact source identity. Results remain pending until actual artifacts are read. +The corrected head must rerun the full credential selection and full four-shard/node-ID/conformance/build/recovery gates. Neither RED nor first implementation is a passing merge candidate. Latest final status belongs to #172 comments/actual Actions, not historical evidence. -Final focused/full four-shard/node-ID/conformance/build/recovery outcomes are pending. No deployment, actual credentials, Root/PIN, backup destruction, external delivery or recovery promotion. #67 stays completed and its original functional regressions remain binding; this change is ownership-only, not a new token protocol. #167 must finish its own combined main gate separately. +## Boundaries + +#67 remains completed with every original token/credential/recovery-window/client/batch/security regression retained. This is ownership repair, not a new token protocol. No deployment, actual credentials, Root/PIN, funds, external delivery, backup destruction or promotion. #167 finishes its own stable combined-main gate separately. diff --git a/tests/test_token_delivery_boundary.py b/tests/test_token_delivery_boundary.py index 86c91522..e8e4743f 100644 --- a/tests/test_token_delivery_boundary.py +++ b/tests/test_token_delivery_boundary.py @@ -12,7 +12,7 @@ import pytest -from msg.core.codec import b64, canonical, unb64, wire +from msg.core.codec import b64, canonical, loads, unb64, wire from msg.core.errors import Failure from msg.core.requests import request_for, receipt_bytes from msg.security.crypto import verify @@ -121,7 +121,13 @@ async def test_standalone_delivery_claim_commits_once_without_persisting_secrets (committed.data['credential_id'],)) assert row[0] is not None and row[1] == module.recovery_verifier(args['recovery_secret']) assert released.data['token'] not in str(row) and args['recovery_secret'] not in str(row) - assert tx.one('SELECT COUNT(*) FROM events WHERE request_id=?', (rid,))[0] == 1 + # Event fields are stored in canonical JSON, not separate SQL columns. + events = [loads(row[0]) for row in tx.rows('SELECT body FROM events')] + matching = [event for event in events if event.get('request_id') == rid] + assert len(matching) == 1 + assert matching[0]['subject'] == committed.subject + assert released.data['token'] not in canonical(matching[0]).decode() + assert args['recovery_secret'] not in canonical(matching[0]).decode() async def test_delivery_failure_returns_no_new_secret_or_claim(installed):