Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/token-delivery-boundary.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions docs/TOKEN_DELIVERY_BOUNDARY_PROGRESS.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 13 additions & 58 deletions src/msg/application.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
"""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
from msg.security.capabilities import install_capabilities, primary_ceiling, base_grants, temporary_ceiling
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


Expand All @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions src/msg/security/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading