Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .github/workflows/integration-regressions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ on:
- 'src/msg/storage/session.py'
- 'src/msg/storage/read_only.py'
- 'tests/test_oauth.py'
- 'tests/test_oauth_authority.py'
- 'tests/test_http_effect_order.py'
- 'tests/test_read_entry_contract_matrix.py'
- 'tests/test_read_query_keyset_boundary.py'
Expand Down Expand Up @@ -65,6 +66,7 @@ on:
- 'src/msg/storage/session.py'
- 'src/msg/storage/read_only.py'
- 'tests/test_oauth.py'
- 'tests/test_oauth_authority.py'
- 'tests/test_http_effect_order.py'
- 'tests/test_read_entry_contract_matrix.py'
- 'tests/test_read_query_keyset_boundary.py'
Expand Down Expand Up @@ -182,7 +184,7 @@ jobs:
- name: Run recovery, keyset, effect-order, path and webhook regressions
run: >-
python -m pytest -q
tests/test_oauth.py
tests/test_oauth.py tests/test_oauth_authority.py
tests/test_http_effect_order.py tests/test_legacy_routes_removed.py
tests/test_read_query_keyset_boundary.py tests/test_read_entry_contract_matrix.py
tests/test_recovery_runtime_generation.py
Expand Down
12 changes: 10 additions & 2 deletions src/msg/security/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ async def authenticate(self, request, session, *, entry):
old = await session.credential(request.arguments['credential_id'])
from msg.security.oauth import require_binding

await require_binding(session, old, now, self.oauth_config)
await require_binding(
session, old, now, self.oauth_config, custodial_ceiling=self.temporary_ceiling()
)
require(
old.kind == 'token' and old.subject_id == request.subject, 'recovery_unavailable'
)
Expand Down Expand Up @@ -193,7 +195,13 @@ async def authenticate(self, request, session, *, entry):
ids = ()
from msg.security.oauth import require_binding

await require_binding(session, credential, now, self.oauth_config)
await require_binding(
session,
credential,
now,
self.oauth_config,
custodial_ceiling=self.temporary_ceiling(),
)
else:
raise Failure('invalid_proof')
if credential.revoked_at is not None:
Expand Down
70 changes: 54 additions & 16 deletions src/msg/security/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

from msg.core.codec import b64, canonical, loads, parse_time, wire
from msg.core.codec import b64, canonical, decode, loads, parse_time, wire
from msg.core.errors import Failure, require
from msg.core.models import Credential
from msg.core.models import CapabilityGrant, Credential
from msg.security.policy import constraints_subset, scope_subset
from msg.security.quarantine import require_live_authority

DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
Expand Down Expand Up @@ -67,7 +68,25 @@ def scopes_for(client, value):
return scopes


async def require_source(tx, body, now):
async def require_ceiling(tx, ceiling, parent):
# A derived credential is a limit, never a durable copy of authority which
# its signing source has since lost. Reuse the same containment vocabulary
# as certificate/delegation validation, including scope and constraints.
for grant in ceiling:
require(
any([
allowed.capability == grant.capability
and allowed.version == grant.version
and grant.operations <= allowed.operations
and await scope_subset(grant.scope, allowed.scope, tx)
and constraints_subset(grant.constraints, allowed.constraints)
for allowed in parent
]),
'invalid_grant',
)


async def require_source(tx, body, now, *, custodial_ceiling):
parent = await tx.credential(body['parent'])
subject = await tx.subject(body['subject'])
require(
Expand All @@ -85,19 +104,32 @@ async def require_source(tx, body, now):
(subject.resource_id,),
)
require(subject.kind == 'custodial' and row == (parent.id, 'active'), 'invalid_grant')
# Vault keys deliberately carry no request grants. Custodial sessions retain
# their captured token limits under the current temporary policy, while the
# vault key above remains only their live identity/revocation binding.
await require_ceiling(
tx,
tuple(decode(CapabilityGrant, raw) for raw in body.get('ceiling', ())),
custodial_ceiling if body.get('custodial') else parent.ceiling,
)
if body.get('session'):
_, session = get(tx, body['session'], now)
require(not session.get('revoked'), 'invalid_grant')
await require_source(tx, session, now)
await require_source(tx, session, now, custodial_ceiling=custodial_ceiling)
return subject


async def require_binding(tx, credential, now, config):
async def require_binding(tx, credential, now, config, *, custodial_ceiling):
api = tx.one('SELECT body FROM oauth_states WHERE id=?', ('api:' + credential.id,))
if api is not None:
source = loads(api[0])
require(credential.source_credential_id == source['parent'], 'invalid_grant')
await require_source(tx, source, now)
await require_source(tx, source, now, custodial_ceiling=custodial_ceiling)
# API bindings predate OAuth families and store no captured ceiling.
# Their credential grants remain bounded by the current signing source.
await require_ceiling(
tx, credential.ceiling, (await tx.credential(source['parent'])).ceiling
)
row = tx.one('SELECT body FROM oauth_states WHERE id=?', ('access:' + credential.id,))
if row is None:
require(
Expand All @@ -111,7 +143,10 @@ async def require_binding(tx, credential, now, config):
require(credential.source_credential_id == family['parent'], 'invalid_grant')
client = client_for(config, family['client_id'])
require(set(family['scopes']) <= client.scopes and not family.get('revoked'), 'invalid_grant')
await require_source(tx, family, now)
await require_source(tx, family, now, custodial_ceiling=custodial_ceiling)
await require_ceiling(
tx, credential.ceiling, tuple(decode(CapabilityGrant, raw) for raw in family['ceiling'])
)


class OAuthService:
Expand Down Expand Up @@ -241,7 +276,7 @@ async def pending_result(self, tx, value, client_id, kind):
return None, 'authorization_pending'
if body['status'] == 'denied':
return None, 'access_denied'
await require_source(tx, body, now)
await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling())
body['status'] = 'consumed'
save(tx, locator['user'], body)
if kind == 'login':
Expand All @@ -259,7 +294,9 @@ async def session(self, tx, cookie):
id = state_id('session', cookie)
expiry, body = get(tx, id, self.app.clock())
require(not body.get('revoked'), 'invalid_grant')
await require_source(tx, body, self.app.clock())
await require_source(
tx, body, self.app.clock(), custodial_ceiling=self.app.temporary_ceiling()
)
source = {
name: body[name]
for name in ('subject', 'parent', 'auth_version', 'custodial', 'ceiling', 'auth_time')
Expand Down Expand Up @@ -349,7 +386,7 @@ async def exchange(self, tx, args):
),
'invalid_grant',
)
await require_source(tx, body, now)
await require_source(tx, body, now, custodial_ceiling=self.app.temporary_ceiling())
body['consumed'] = True
body['family'] = 'family:' + uuid4().hex
save(tx, id, body)
Expand All @@ -373,19 +410,18 @@ async def exchange(self, tx, args):
require(not family.get('revoked'), 'invalid_grant')
client = client_for(self.config, family['client_id'])
require(set(family['scopes']) <= client.scopes, 'invalid_scope')
await require_source(tx, family, now)
await require_source(tx, family, now, custodial_ceiling=self.app.temporary_ceiling())
old['used'] = True
save(tx, id, old)
# A refresh does not extend the family's absolute lifetime.
return await self.tokens(tx, family, family_id=old['family'], expiry=expiry), None
return None, 'unsupported_grant_type'

async def tokens(self, tx, source, *, family_id=None, expiry=None):
from msg.core.codec import decode
from msg.core.models import CapabilityGrant

now = self.app.clock()
subject = await require_source(tx, source, now)
subject = await require_source(
tx, source, now, custodial_ceiling=self.app.temporary_ceiling()
)
if family_id is None:
family_id = 'family:' + uuid4().hex
expiry = now + timedelta(seconds=self.config.session_ttl)
Expand Down Expand Up @@ -496,7 +532,9 @@ async def bearer(self, tx, value):
'invalid_token',
)
try:
await require_binding(tx, credential, now, self.config)
await require_binding(
tx, credential, now, self.config, custodial_ceiling=self.app.temporary_ceiling()
)
except Failure as exc:
if exc.code in {'recovery_runtime_stale', 'recovery_quarantined'}:
raise
Expand Down
9 changes: 6 additions & 3 deletions src/msg/transports/oauth_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,11 @@ async def dispatch(self, request):
],
})
# Rate limits commit independently, including rejected code guesses.
async with self.service.metadata.transaction(write=True) as tx:
self.oauth.rate(tx, request.client.host if request.client else '')
# Userinfo is a read: do not mutate retained OAuth state during GET or
# POST reads. Authentication still checks the current source/family.
if path != '/oauth/userinfo':
async with self.service.metadata.transaction(write=True) as tx:
self.oauth.rate(tx, request.client.host if request.client else '')
if path == '/oauth/login':
require(request.method == 'GET', 'method_not_allowed')
async with self.service.metadata.transaction(write=True) as tx:
Expand Down Expand Up @@ -346,7 +349,7 @@ async def dispatch(self, request):
'method_not_allowed',
)
args = await form(request) if request.method == 'POST' else {}
async with self.service.metadata.transaction(write=True) as tx:
async with self.service.metadata.transaction(write=path != '/oauth/userinfo') as tx:
self.oauth.fence(tx)
if path == '/oauth/device_authorization':
require(set(args) <= {'client_id', 'scope'}, 'invalid_request')
Expand Down
17 changes: 16 additions & 1 deletion tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ async def test_api_key_private_signature_rotation_delivery_and_header(oauth, tmp


@pytest.mark.asyncio
async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth):
async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth, monkeypatch):
app, _, _, http = oauth
await http.get('/oauth/signup')
binder = http.cookies.get('msg_login')
Expand Down Expand Up @@ -413,6 +413,21 @@ async def test_custodial_signup_login_and_device_consent_reuses_vault(oauth):
assert response.status_code == 200, response.text
async with app.metadata.transaction(write=False) as tx:
assert tx.one('SELECT COUNT(*) FROM custodial_vault')[0] == 1
key_id = tx.one('SELECT signing_key_id FROM custodial_vault')[0]
assert (await tx.credential(key_id)).ceiling == ()
access = response.json()['access_token']
assert (
await http.get('/oauth/userinfo', headers={'Authorization': 'Bearer ' + access})
).status_code == 200
# The vault never receives request grants to make login work. Current policy
# loss must invalidate already-issued custodial access without mutating reads.
from read_only_evidence import readonly_evidence

monkeypatch.setattr(app, 'temporary_ceiling', lambda: ())
monkeypatch.setattr(app.authenticator, 'temporary_ceiling', lambda: ())
async with readonly_evidence(app, monkeypatch):
denied = await http.get('/oauth/userinfo', headers={'Authorization': 'Bearer ' + access})
assert denied.status_code == 401 and denied.json()['error'] == 'invalid_token'


@pytest.mark.parametrize(
Expand Down
72 changes: 72 additions & 0 deletions tests/test_oauth_authority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Derived access must retain current source authority and read-only semantics."""

import os
from dataclasses import replace

import pytest
from read_only_evidence import business_snapshot, readonly_evidence
from test_oauth import device_tokens, oauth as oauth
from test_service import call

from msg.core.codec import b64, unb64, wire
from msg.core.models import Scope


@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['oauth', 'api'])
@pytest.mark.parametrize('contraction', ['operations', 'scope'])
async def test_derived_credentials_recheck_current_source_ceiling(oauth, kind, contraction):
app, key, subject, _http = oauth
if kind == 'oauth':
issued = await device_tokens(oauth)
credential_id, _, value = issued['access_token'].partition('.')
token = (credential_id, unb64(value))
else:
issued = await call(
app,
'identity.token_create',
{'nonce': b64(os.urandom(32)), 'ttl': 60, 'recovery_secret': b64(os.urandom(32))},
key=key,
subject=subject,
contract_version=3,
)
assert issued.status == 'ok', wire(issued)
token = (issued.data['credential_id'], unb64(issued.data['token']))
allowed = await call(app, 'discovery.get', {'id': '/main'}, subject=subject, token=token)
assert allowed.status == 'ok', wire(allowed)
async with app.metadata.transaction(write=True) as tx:
parent = await tx.credential(key.key_id)
owner = await tx.subject(subject)
# Keep the signer, subject/auth_version, revocation and deadlines intact.
# Only the authoritative source ceiling changes, just as in #76 vectors.
ceiling = (
()
if contraction == 'operations'
else tuple(
replace(grant, scope=Scope(resource_id='t_store', descendants=True))
for grant in parent.ceiling
)
)
await tx.save_credential(replace(parent, ceiling=ceiling), owner.auth_version)
before = await business_snapshot(app)
denied = await call(app, 'discovery.get', {'id': '/main'}, subject=subject, token=token)
assert denied.status == 'error' and denied.error.code == 'invalid_grant', wire(denied)
assert await business_snapshot(app) == before


@pytest.mark.asyncio
@pytest.mark.parametrize('method', ['GET', 'POST'])
async def test_userinfo_is_read_only_including_authoritative_oauth_state(
oauth, monkeypatch, method
):
app, _key, subject, http = oauth
issued = await device_tokens(oauth)
async with readonly_evidence(app, monkeypatch):
response = await http.request(
method,
'/oauth/userinfo',
headers={'Authorization': 'Bearer ' + issued['access_token']},
**({'json': {}} if method == 'POST' else {}),
)
assert response.status_code == 200, response.text
assert response.json() == {'sub': subject, 'preferred_username': 'oauth-owner'}
Loading
Loading