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..d4855a63 --- /dev/null +++ b/docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md @@ -0,0 +1,23 @@ +# Credential delivery boundary progress + +Issue #162; PR #172, stacked on #167 base2d1cc443c828dd2f735995cc6a5d20021fee6ac7 / tree6171f6041ffd0addaf72eadd35acf562f478281f. Branchfix/token-delivery-boundary-20260929. Coordination #83 comment5879008128. No other source branch is overwritten. + +## Single source and transaction timing + +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. + +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. + +## Independently verified cloud evidence + +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. + +First implementation f015c60f05469f0058255bd645afef084e87840d / tree46af80b065a8e4eaea319b87a70bb5ddb281f966, push run36486854337, artifact10998634353: **1 failed /121 passed /0 errors /0 skipped**. ZIP SHA2560186be18b2ed3393af3fe5eaa5ee11eb1caa315f941cd0a1ddd469fb39fdc189 and source.json independently verified. + +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. + +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. + +## 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/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))) diff --git a/tests/test_token_delivery_boundary.py b/tests/test_token_delivery_boundary.py new file mode 100644 index 00000000..e8e4743f --- /dev/null +++ b/tests/test_token_delivery_boundary.py @@ -0,0 +1,153 @@ +"""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, loads, 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) + # 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): + 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