Skip to content

Commit f792cab

Browse files
shivdeep1claude
andcommitted
feat: dual-mode identity verification and disable semantic challenge by default
Rename verify_signature node to verify_identity with support for both Ed25519 signatures and OAuth bearer tokens. OAuth validation uses a conditional import (graceful fallback when airlock.oauth module absent). Disable semantic challenge by default (challenge_fallback_mode=disabled) so unknown-reputation agents route directly to issue_verdict. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6760e43 commit f792cab

8 files changed

Lines changed: 591 additions & 29 deletions

File tree

airlock/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class AirlockConfig(BaseSettings):
7373
expect_replicas: int = Field(default=1, ge=1)
7474

7575
# Challenge fallback mode when LLM is unavailable: "ambiguous" (default) or "rule_based".
76-
challenge_fallback_mode: str = "ambiguous"
76+
challenge_fallback_mode: str = "disabled"
7777

7878
# -----------------------------------------------------------------------
7979
# Scoring (generic defaults — production overrides via env vars)

airlock/engine/orchestrator.py

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
Node map (11 nodes):
44
resolve -> validate_schema
55
validate_schema -> check_revocation
6-
check_revocation -> verify_signature (or failed)
7-
verify_signature -> validate_vc (or failed)
6+
check_revocation -> verify_identity (or failed)
7+
verify_identity -> validate_vc (or failed)
88
validate_vc -> validate_delegation (or failed)
99
validate_delegation -> cross_ref_capabilities (or failed)
1010
cross_ref_capabilities -> check_reputation (or failed)
@@ -23,9 +23,9 @@
2323

2424
from langgraph.graph import END, StateGraph
2525

26+
from airlock.config import get_config
2627
from airlock.crypto.keys import KeyPair, resolve_public_key
2728
from airlock.crypto.signing import sign_attestation, verify_model
28-
from airlock.config import get_config
2929
from airlock.crypto.vc import extract_capabilities, validate_credential
3030
from airlock.engine.state import SessionManager
3131
from airlock.gateway.revocation import RedisRevocationStore, RevocationStore
@@ -84,6 +84,7 @@ class OrchestrationState(TypedDict, total=False):
8484
_challenge_outcome: str | None
8585
_tier: int # TrustTier int value
8686
_local_only: bool
87+
_bearer_token: str | None
8788

8889

8990
# ---------------------------------------------------------------------------
@@ -306,6 +307,7 @@ async def _handle_handshake(self, event: HandshakeReceived) -> None:
306307
"_challenge_outcome": None,
307308
"_tier": TrustTier.UNKNOWN,
308309
"_local_only": getattr(request, "privacy_mode", "any") == "local_only",
310+
"_bearer_token": getattr(event, "bearer_token", None),
309311
}
310312

311313
# Run the graph synchronously through all nodes.
@@ -620,34 +622,64 @@ def _node_check_revocation(self, state: OrchestrationState) -> OrchestrationStat
620622
def _route_after_revocation(self, state: OrchestrationState) -> str:
621623
if state.get("failed_at") == "check_revocation":
622624
return "failed"
623-
return "verify_signature"
625+
return "verify_identity"
626+
627+
def _node_verify_identity(self, state: OrchestrationState) -> OrchestrationState:
628+
"""Node 2: verify agent identity via OAuth bearer token or Ed25519 signature.
624629
625-
def _node_verify_signature(self, state: OrchestrationState) -> OrchestrationState:
626-
"""Node 2: verify the Ed25519 signature on the HandshakeRequest."""
630+
Dual-mode authentication:
631+
1. If a bearer token is present, attempt OAuth validation first.
632+
2. If OAuth succeeds and token subject matches initiator DID, mark valid.
633+
3. Otherwise fall back to Ed25519 signature verification.
634+
"""
627635
checks: list[CheckResult] = list(state.get("check_results", []))
628636
request = state["handshake"]
637+
auth_method = "ed25519"
629638

630-
try:
631-
verify_key = resolve_public_key(request.initiator.did)
632-
valid = verify_model(request, verify_key)
633-
except Exception as exc:
634-
valid = False
635-
logger.debug("Signature verification error: %s", exc)
639+
# --- Try OAuth bearer token first ---
640+
oauth_validated = False
641+
bearer_token = state.get("_bearer_token")
642+
if bearer_token is not None:
643+
try:
644+
from airlock.oauth.token_validator import validate_access_token
645+
646+
gateway_vk = (
647+
self._airlock_keypair.verify_key if self._airlock_keypair is not None else None
648+
)
649+
token_data = validate_access_token(bearer_token, gateway_vk)
650+
if token_data.get("sub") == request.initiator.did:
651+
oauth_validated = True
652+
auth_method = "oauth"
653+
except ImportError:
654+
logger.debug("OAuth module not available, falling back to Ed25519")
655+
except Exception as exc:
656+
logger.debug("OAuth token validation failed: %s", exc)
657+
658+
# --- Fall back to Ed25519 signature verification ---
659+
valid = oauth_validated
660+
if not valid:
661+
try:
662+
verify_key = resolve_public_key(request.initiator.did)
663+
valid = verify_model(request, verify_key)
664+
except Exception as exc:
665+
valid = False
666+
logger.debug("Signature verification error: %s", exc)
636667

668+
detail = f"{auth_method} identity verified" if valid else "Identity verification failed"
637669
checks.append(
638670
CheckResult(
639671
check=VerificationCheck.SIGNATURE,
640672
passed=valid,
641-
detail="Ed25519 signature valid" if valid else "Signature verification failed",
673+
detail=detail,
642674
)
643675
)
644676
state["check_results"] = checks
645677
state["_sig_valid"] = valid
646678
if valid:
647679
state["session"].state = VerificationState.SIGNATURE_VERIFIED
648680
else:
649-
state["error"] = "Invalid signature"
650-
state["failed_at"] = "verify_signature"
681+
state["error"] = "Invalid identity credentials"
682+
state["failed_at"] = "verify_identity"
651683
state["verdict"] = TrustVerdict.REJECTED
652684
state["session"].state = VerificationState.FAILED
653685
return state
@@ -1055,7 +1087,7 @@ def _node_failed(self, state: OrchestrationState) -> OrchestrationState:
10551087
# Conditional edge functions
10561088
# ------------------------------------------------------------------
10571089

1058-
def _route_after_signature(self, state: OrchestrationState) -> str:
1090+
def _route_after_identity(self, state: OrchestrationState) -> str:
10591091
return "validate_vc" if state.get("_sig_valid") else "failed"
10601092

10611093
def _route_after_vc(self, state: OrchestrationState) -> str:
@@ -1068,6 +1100,9 @@ def _route_after_reputation(self, state: OrchestrationState) -> str:
10681100
elif routing in ("fast_path", "issue_verdict"):
10691101
return "issue_verdict"
10701102
else:
1103+
cfg = get_config()
1104+
if cfg.challenge_fallback_mode == "disabled":
1105+
return "issue_verdict"
10711106
return "semantic_challenge"
10721107

10731108
# ------------------------------------------------------------------
@@ -1079,7 +1114,7 @@ def _build_graph(self) -> Any:
10791114

10801115
graph.add_node("validate_schema", self._node_validate_schema)
10811116
graph.add_node("check_revocation", self._node_check_revocation)
1082-
graph.add_node("verify_signature", self._node_verify_signature)
1117+
graph.add_node("verify_identity", self._node_verify_identity)
10831118
graph.add_node("validate_vc", self._node_validate_vc)
10841119
graph.add_node("validate_delegation", self._node_validate_delegation)
10851120
graph.add_node("cross_ref_capabilities", self._node_cross_ref_capabilities)
@@ -1095,11 +1130,11 @@ def _build_graph(self) -> Any:
10951130
graph.add_conditional_edges(
10961131
"check_revocation",
10971132
self._route_after_revocation,
1098-
{"verify_signature": "verify_signature", "failed": "failed"},
1133+
{"verify_identity": "verify_identity", "failed": "failed"},
10991134
)
11001135
graph.add_conditional_edges(
1101-
"verify_signature",
1102-
self._route_after_signature,
1136+
"verify_identity",
1137+
self._route_after_identity,
11031138
{"validate_vc": "validate_vc", "failed": "failed"},
11041139
)
11051140
graph.add_conditional_edges(

airlock/gateway/handlers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ def _is_valid_did(did: str) -> bool:
6767
logger = logging.getLogger(__name__)
6868

6969

70+
def _extract_bearer_token(request: Request) -> str | None:
71+
"""Extract an OAuth bearer token from the Authorization header, if present."""
72+
auth_header = request.headers.get("authorization", "")
73+
if auth_header.lower().startswith("bearer "):
74+
return auth_header[7:].strip()
75+
return None
76+
77+
7078
def _audit_bg(request: Request, **kwargs: object) -> None:
7179
"""Fire-and-forget audit trail append (non-blocking)."""
7280
trail = getattr(request.app.state, "audit_trail", None)
@@ -310,13 +318,15 @@ async def handle_handshake(
310318
)
311319

312320
# Publish to event bus — orchestrator handles the rest asynchronously
321+
bearer_token = _extract_bearer_token(request)
313322
event_bus = request.app.state.event_bus
314323
if not event_bus.try_publish(
315324
HandshakeReceived(
316325
session_id=session_id,
317326
timestamp=datetime.now(UTC),
318327
request=body,
319328
callback_url=callback_url,
329+
bearer_token=bearer_token,
320330
)
321331
):
322332
return _nack(request, "Event queue saturated", "SERVICE_BUSY", session_id)

airlock/schemas/events.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class HandshakeReceived(VerificationEvent):
2626
event_type: Literal["handshake_received"] = "handshake_received"
2727
request: HandshakeRequest
2828
callback_url: str | None = None
29+
bearer_token: str | None = None
2930

3031

3132
class SignatureVerified(VerificationEvent):

tests/test_a2a_gateway.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,10 +409,20 @@ async def test_verify_challenge_path_has_challenge_payload(
409409
target_kp.did,
410410
text="Semantic path",
411411
)
412-
async with AsyncClient(
413-
transport=ASGITransport(app=a2a_app), base_url="http://test"
414-
) as client:
415-
resp = await client.post("/a2a/verify", json=body)
412+
# Re-enable challenge mode for this test (default is now "disabled")
413+
from unittest.mock import patch
414+
415+
with patch("airlock.engine.orchestrator.get_config") as mock_cfg:
416+
from airlock.config import get_config
417+
418+
real_cfg = get_config()
419+
mock_cfg.return_value = real_cfg.model_copy(
420+
update={"challenge_fallback_mode": "ambiguous"}
421+
)
422+
async with AsyncClient(
423+
transport=ASGITransport(app=a2a_app), base_url="http://test"
424+
) as client:
425+
resp = await client.post("/a2a/verify", json=body)
416426
data = resp.json()
417427
assert data["verdict"] == "DEFERRED"
418428
assert data["challenge"] is not None

tests/test_decay_on_read.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ async def on_challenge(sid: str, _ch: ChallengeRequest) -> None:
101101
expires_at=now + timedelta(minutes=2),
102102
)
103103

104-
with patch(
104+
# Re-enable challenge mode (default is now "disabled")
105+
from airlock.config import AirlockConfig
106+
107+
challenge_cfg = AirlockConfig(challenge_fallback_mode="ambiguous")
108+
109+
with patch("airlock.engine.orchestrator.get_config", return_value=challenge_cfg), patch(
105110
"airlock.engine.orchestrator.generate_challenge",
106111
new=AsyncMock(return_value=fake_ch),
107112
):

0 commit comments

Comments
 (0)