Skip to content

Commit 3356b63

Browse files
authored
Merge pull request #23 from airlock-protocol/worktree-agent-ac96ae14
feat: dual-mode auth (Ed25519 + OAuth) and challenge deprecation
2 parents f3a0434 + 84f830c commit 3356b63

8 files changed

Lines changed: 449 additions & 25 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: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
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)
11-
check_reputation -> semantic_challenge | issue_verdict (fast-path / blacklist)
11+
check_reputation -> semantic_challenge | issue_verdict (fast-path / blacklist / disabled)
1212
semantic_challenge -> issue_verdict
1313
issue_verdict -> seal_session
1414
seal_session -> END
15+
16+
Identity verification supports dual-mode auth: Ed25519 signatures (default) and
17+
OAuth bearer tokens (when airlock.oauth module is available).
1518
"""
1619

1720
from __future__ import annotations
@@ -23,9 +26,9 @@
2326

2427
from langgraph.graph import END, StateGraph
2528

29+
from airlock.config import get_config
2630
from airlock.crypto.keys import KeyPair, resolve_public_key
2731
from airlock.crypto.signing import sign_attestation, verify_model
28-
from airlock.config import get_config
2932
from airlock.crypto.vc import extract_capabilities, validate_credential
3033
from airlock.engine.state import SessionManager
3134
from airlock.gateway.revocation import RedisRevocationStore, RevocationStore
@@ -84,6 +87,7 @@ class OrchestrationState(TypedDict, total=False):
8487
_challenge_outcome: str | None
8588
_tier: int # TrustTier int value
8689
_local_only: bool
90+
_bearer_token: str | None # OAuth bearer token (if present)
8791

8892

8993
# ---------------------------------------------------------------------------
@@ -306,6 +310,7 @@ async def _handle_handshake(self, event: HandshakeReceived) -> None:
306310
"_challenge_outcome": None,
307311
"_tier": TrustTier.UNKNOWN,
308312
"_local_only": getattr(request, "privacy_mode", "any") == "local_only",
313+
"_bearer_token": getattr(event, "bearer_token", None),
309314
}
310315

311316
# Run the graph synchronously through all nodes.
@@ -620,34 +625,66 @@ def _node_check_revocation(self, state: OrchestrationState) -> OrchestrationStat
620625
def _route_after_revocation(self, state: OrchestrationState) -> str:
621626
if state.get("failed_at") == "check_revocation":
622627
return "failed"
623-
return "verify_signature"
628+
return "verify_identity"
624629

625-
def _node_verify_signature(self, state: OrchestrationState) -> OrchestrationState:
626-
"""Node 2: verify the Ed25519 signature on the HandshakeRequest."""
630+
def _node_verify_identity(self, state: OrchestrationState) -> OrchestrationState:
631+
"""Node 2: verify identity via OAuth bearer token or Ed25519 signature.
632+
633+
Dual-mode auth: tries OAuth first (if bearer token present and oauth module
634+
available), then falls back to Ed25519 signature verification.
635+
"""
627636
checks: list[CheckResult] = list(state.get("check_results", []))
628637
request = state["handshake"]
638+
auth_method = "ed25519"
629639

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)
640+
# --- Try OAuth bearer token first ---
641+
oauth_validated = False
642+
bearer_token = state.get("_bearer_token")
643+
if bearer_token is not None:
644+
try:
645+
from airlock.oauth.token_validator import validate_access_token
646+
647+
gateway_vk = self._airlock_keypair.verify_key if self._airlock_keypair else None
648+
if gateway_vk is not None:
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+
if not oauth_validated:
660+
try:
661+
verify_key = resolve_public_key(request.initiator.did)
662+
valid = verify_model(request, verify_key)
663+
except Exception as exc:
664+
valid = False
665+
logger.debug("Signature verification error: %s", exc)
666+
else:
667+
valid = True
636668

669+
detail = (
670+
f"Identity verified via {auth_method}"
671+
if valid
672+
else "Identity verification failed"
673+
)
637674
checks.append(
638675
CheckResult(
639676
check=VerificationCheck.SIGNATURE,
640677
passed=valid,
641-
detail="Ed25519 signature valid" if valid else "Signature verification failed",
678+
detail=detail,
642679
)
643680
)
644681
state["check_results"] = checks
645682
state["_sig_valid"] = valid
646683
if valid:
647684
state["session"].state = VerificationState.SIGNATURE_VERIFIED
648685
else:
649-
state["error"] = "Invalid signature"
650-
state["failed_at"] = "verify_signature"
686+
state["error"] = "Invalid identity"
687+
state["failed_at"] = "verify_identity"
651688
state["verdict"] = TrustVerdict.REJECTED
652689
state["session"].state = VerificationState.FAILED
653690
return state
@@ -1055,7 +1092,7 @@ def _node_failed(self, state: OrchestrationState) -> OrchestrationState:
10551092
# Conditional edge functions
10561093
# ------------------------------------------------------------------
10571094

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

10611098
def _route_after_vc(self, state: OrchestrationState) -> str:
@@ -1068,6 +1105,9 @@ def _route_after_reputation(self, state: OrchestrationState) -> str:
10681105
elif routing in ("fast_path", "issue_verdict"):
10691106
return "issue_verdict"
10701107
else:
1108+
cfg = get_config()
1109+
if cfg.challenge_fallback_mode == "disabled":
1110+
return "issue_verdict"
10711111
return "semantic_challenge"
10721112

10731113
# ------------------------------------------------------------------
@@ -1079,7 +1119,7 @@ def _build_graph(self) -> Any:
10791119

10801120
graph.add_node("validate_schema", self._node_validate_schema)
10811121
graph.add_node("check_revocation", self._node_check_revocation)
1082-
graph.add_node("verify_signature", self._node_verify_signature)
1122+
graph.add_node("verify_identity", self._node_verify_identity)
10831123
graph.add_node("validate_vc", self._node_validate_vc)
10841124
graph.add_node("validate_delegation", self._node_validate_delegation)
10851125
graph.add_node("cross_ref_capabilities", self._node_cross_ref_capabilities)
@@ -1095,11 +1135,11 @@ def _build_graph(self) -> Any:
10951135
graph.add_conditional_edges(
10961136
"check_revocation",
10971137
self._route_after_revocation,
1098-
{"verify_signature": "verify_signature", "failed": "failed"},
1138+
{"verify_identity": "verify_identity", "failed": "failed"},
10991139
)
11001140
graph.add_conditional_edges(
1101-
"verify_signature",
1102-
self._route_after_signature,
1141+
"verify_identity",
1142+
self._route_after_identity,
11031143
{"validate_vc": "validate_vc", "failed": "failed"},
11041144
)
11051145
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 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: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,17 @@
3030

3131

3232
@pytest.fixture
33-
def a2a_config(tmp_path):
34-
return AirlockConfig(
33+
def a2a_config(tmp_path, monkeypatch):
34+
cfg = AirlockConfig(
3535
lancedb_path=str(tmp_path / "a2a_rep.lance"),
3636
trust_token_secret="a2a_jwt_test_secret_not_for_production_use",
37+
challenge_fallback_mode="ambiguous",
3738
)
39+
# Ensure the global config singleton also uses challenge mode for A2A tests
40+
import airlock.config as _cfg_mod
41+
42+
monkeypatch.setattr(_cfg_mod, "_config_instance", cfg)
43+
return cfg
3844

3945

4046
@pytest.fixture

tests/test_decay_on_read.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,16 @@ def _make_hs(session_id: str, agent: KeyPair, issuer: KeyPair, target: str) -> H
4242

4343

4444
@pytest.mark.asyncio
45-
async def test_decayed_high_score_routes_to_challenge(tmp_path):
45+
async def test_decayed_high_score_routes_to_challenge(tmp_path, monkeypatch):
4646
"""Score stored as 0.80 with old last_interaction decays below fast-path threshold."""
47+
# Enable challenge mode for this test (default is now "disabled")
48+
import airlock.config as _cfg_mod
49+
from airlock.config import AirlockConfig
50+
51+
monkeypatch.setattr(
52+
_cfg_mod, "_config_instance", AirlockConfig(challenge_fallback_mode="ambiguous")
53+
)
54+
4755
agent = KeyPair.from_seed(b"a" * 32)
4856
issuer = KeyPair.from_seed(b"i" * 32)
4957
target = KeyPair.from_seed(b"t" * 32)

0 commit comments

Comments
 (0)