Skip to content

Commit 5ea7a1e

Browse files
committed
security: harden nonce replay guard, constant-time signature comparison
1 parent fd1c675 commit 5ea7a1e

22 files changed

Lines changed: 4065 additions & 105 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,6 @@ REVIEW_BRIEF.md
8686
AIRLOCK_COMPANY.md
8787
PROTOCOL_DEBATE.md
8888
IMPLEMENTATION_PLAN.md
89+
V03_IMPLEMENTATION_PLAN.md
90+
SECURITY_AUDIT_POW_ARGON2.md
8991
.claude/

airlock/crypto/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from airlock.crypto.keys import KeyPair, resolve_public_key
44
from airlock.crypto.signing import (
55
canonicalize,
6+
sign_attestation,
67
sign_message,
78
sign_model,
9+
verify_attestation,
810
verify_model,
911
verify_signature,
1012
)
@@ -15,9 +17,11 @@
1517
"canonicalize",
1618
"issue_credential",
1719
"resolve_public_key",
20+
"sign_attestation",
1821
"sign_message",
1922
"sign_model",
2023
"validate_credential",
24+
"verify_attestation",
2125
"verify_model",
2226
"verify_signature",
2327
]

airlock/crypto/signing.py

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

33
import json
4-
from base64 import b64decode, b64encode
4+
import logging
5+
import uuid as uuid_mod
6+
from base64 import b64decode, b64encode, urlsafe_b64encode
57
from datetime import UTC, datetime
8+
from enum import Enum, IntEnum, StrEnum
69
from typing import TYPE_CHECKING, Any
710

811
from nacl.exceptions import BadSignatureError
@@ -12,6 +15,71 @@
1215
if TYPE_CHECKING:
1316
from airlock.schemas.handshake import SignatureEnvelope
1417

18+
logger = logging.getLogger(__name__)
19+
20+
_SIGNATURE_FIELDS = frozenset({"signature", "airlock_signature", "trust_token"})
21+
22+
23+
def _prepare_for_json(obj: Any) -> Any:
24+
"""Recursively convert Python objects to JSON-safe, cross-language types.
25+
26+
Ensures deterministic serialization that produces identical output in
27+
Python, Go, Rust, and JavaScript implementations (C-09 interop fix).
28+
29+
Conversion rules:
30+
- datetime -> ISO 8601 with timezone (naive datetimes treated as UTC)
31+
- IntEnum -> int value
32+
- StrEnum -> str value
33+
- Enum -> raw .value
34+
- UUID -> lowercase hyphenated string
35+
- bytes -> base64url encoding (no padding)
36+
- BaseModel -> model.model_dump(mode="json")
37+
- set -> sorted list (recursed)
38+
- dict -> recurse into values
39+
- list / tuple -> recurse into elements
40+
- str, int, float, bool, None -> pass through
41+
- other -> TypeError
42+
"""
43+
# Enums must be checked first: IntEnum is a subclass of int,
44+
# StrEnum is a subclass of str, so they'd pass the scalar check below.
45+
# Plain Enum (e.g. Enum with int/str value) is NOT a subclass of int/str.
46+
if isinstance(obj, IntEnum):
47+
return int(obj)
48+
if isinstance(obj, StrEnum):
49+
return str(obj.value)
50+
if isinstance(obj, Enum):
51+
return obj.value
52+
53+
# JSON-native scalars: pass through unchanged
54+
if obj is None or isinstance(obj, (bool, int, float, str)):
55+
return obj
56+
57+
if isinstance(obj, datetime):
58+
if obj.tzinfo is None or obj.tzinfo.utcoffset(obj) is None:
59+
# Naive datetime: treat as UTC
60+
obj = obj.replace(tzinfo=UTC)
61+
return obj.isoformat()
62+
63+
if isinstance(obj, uuid_mod.UUID):
64+
return str(obj)
65+
66+
if isinstance(obj, bytes):
67+
return urlsafe_b64encode(obj).rstrip(b"=").decode("ascii")
68+
69+
if isinstance(obj, BaseModel):
70+
return obj.model_dump(mode="json")
71+
72+
if isinstance(obj, set):
73+
return [_prepare_for_json(item) for item in sorted(obj, key=str)]
74+
75+
if isinstance(obj, dict):
76+
return {k: _prepare_for_json(v) for k, v in obj.items()}
77+
78+
if isinstance(obj, (list, tuple)):
79+
return [_prepare_for_json(item) for item in obj]
80+
81+
raise TypeError(f"Cannot canonicalize type: {type(obj)}")
82+
1583

1684
def canonicalize(data: dict[str, Any]) -> bytes:
1785
"""Produce deterministic canonical JSON bytes.
@@ -20,10 +88,20 @@ def canonicalize(data: dict[str, Any]) -> bytes:
2088
- Sort keys
2189
- No whitespace
2290
- UTF-8 encoding
23-
Strips 'signature' key if present (we sign the unsigned form).
91+
- ensure_ascii=False for cross-language consistency
92+
93+
All values are first normalized via ``_prepare_for_json`` so that
94+
datetimes, enums, UUIDs, bytes, etc. are converted to language-agnostic
95+
representations *before* JSON encoding. This guarantees identical
96+
canonical bytes across Python, Go, Rust, and JavaScript (C-09 fix).
97+
98+
Strips known signature/token fields so we sign the unsigned form.
2499
"""
25-
cleaned = {k: v for k, v in data.items() if k != "signature"}
26-
return json.dumps(cleaned, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
100+
cleaned = {k: v for k, v in data.items() if k not in _SIGNATURE_FIELDS}
101+
prepared = _prepare_for_json(cleaned)
102+
return json.dumps(prepared, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
103+
"utf-8"
104+
)
27105

28106

29107
def sign_message(message_dict: dict[str, Any], signing_key: SigningKey) -> str:
@@ -59,6 +137,13 @@ def sign_model(model: BaseModel, signing_key: SigningKey) -> SignatureEnvelope:
59137
"""Sign a Pydantic model and return a SignatureEnvelope.
60138
61139
Canonical form excludes the 'signature' field.
140+
141+
NOTE: ``model.model_dump(mode="json")`` already converts datetimes,
142+
enums, UUIDs etc. to JSON-safe primitives via Pydantic's serializer,
143+
so the dict passed to ``sign_message`` → ``canonicalize`` contains only
144+
str/int/float/bool/None/list/dict. ``_prepare_for_json`` will simply
145+
pass these through. This path is therefore safe for cross-language
146+
signature verification without additional pre-conversion.
62147
"""
63148
from airlock.schemas.handshake import SignatureEnvelope
64149

@@ -83,3 +168,40 @@ def verify_model(model: BaseModel, verify_key: VerifyKey) -> bool:
83168
data = model.model_dump(mode="json")
84169
data.pop("signature", None)
85170
return verify_signature(data, sig.value, verify_key)
171+
172+
173+
def sign_attestation(attestation: BaseModel, signing_key: SigningKey) -> str:
174+
"""Sign an AirlockAttestation and return a base64-encoded Ed25519 signature.
175+
176+
Canonical form excludes ``airlock_signature`` and ``trust_token`` fields
177+
(handled by :func:`canonicalize`). The returned string is suitable for
178+
setting on ``AirlockAttestation.airlock_signature``.
179+
"""
180+
data = attestation.model_dump(mode="json")
181+
return sign_message(data, signing_key)
182+
183+
184+
def verify_attestation(attestation: BaseModel, public_key: VerifyKey | bytes) -> bool:
185+
"""Verify the ``airlock_signature`` on an :class:`AirlockAttestation`.
186+
187+
Parameters
188+
----------
189+
attestation:
190+
The attestation model instance. Must have an ``airlock_signature``
191+
field containing a base64-encoded Ed25519 signature string.
192+
public_key:
193+
Either a :class:`~nacl.signing.VerifyKey` or raw 32-byte public key.
194+
195+
Returns
196+
-------
197+
bool
198+
``True`` if the signature is valid, ``False`` otherwise (including
199+
when ``airlock_signature`` is ``None``).
200+
"""
201+
sig_b64 = getattr(attestation, "airlock_signature", None)
202+
if sig_b64 is None:
203+
return False
204+
if isinstance(public_key, bytes):
205+
public_key = VerifyKey(public_key)
206+
data = attestation.model_dump(mode="json")
207+
return verify_signature(data, sig_b64, public_key)

airlock/engine/orchestrator.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121

2222
from langgraph.graph import END, StateGraph
2323

24-
from airlock.crypto.keys import resolve_public_key
25-
from airlock.crypto.signing import verify_model
24+
from airlock.crypto.keys import KeyPair, resolve_public_key
25+
from airlock.crypto.signing import sign_attestation, verify_model
2626
from airlock.crypto.vc import validate_credential
2727
from airlock.engine.state import SessionManager
2828
from airlock.gateway.revocation import RedisRevocationStore, RevocationStore
@@ -114,11 +114,13 @@ def __init__(
114114
session_mgr: SessionManager | None = None,
115115
vc_allowed_issuers: frozenset[str] | None = None,
116116
revocation_store: RevocationStore | RedisRevocationStore | None = None,
117+
airlock_keypair: KeyPair | None = None,
117118
) -> None:
118119
self._reputation = reputation_store
119120
self._revocation: RevocationStore | RedisRevocationStore | None = revocation_store
120121
self._registry = agent_registry
121122
self._airlock_did = airlock_did
123+
self._airlock_keypair = airlock_keypair
122124
self._model = litellm_model
123125
self._api_base = litellm_api_base
124126
self._on_challenge = on_challenge
@@ -137,6 +139,17 @@ def __init__(
137139

138140
self._graph = self._build_graph()
139141

142+
def _sign_attestation_if_keypair(self, attestation: AirlockAttestation) -> AirlockAttestation:
143+
"""Sign the attestation with the gateway keypair if available.
144+
145+
Must be called BEFORE adding the trust_token so the signature
146+
covers the attestation content without ephemeral fields.
147+
"""
148+
if self._airlock_keypair is None:
149+
return attestation
150+
sig = sign_attestation(attestation, self._airlock_keypair.signing_key)
151+
return attestation.model_copy(update={"airlock_signature": sig})
152+
140153
async def _persist_graph_snapshot(self, final_state: OrchestrationState) -> None:
141154
"""Mirror LangGraph session + checks into ``SessionManager`` for HTTP polling."""
142155
if self._session_mgr is None:
@@ -407,6 +420,8 @@ async def _handle_challenge_response(self, event: ChallengeResponseReceived) ->
407420
issued_at=now,
408421
privacy_mode=privacy_mode_str,
409422
)
423+
# Sign BEFORE adding trust_token so the signature covers core content
424+
attestation = self._sign_attestation_if_keypair(attestation)
410425
if verdict == TrustVerdict.VERIFIED and self._trust_token_secret:
411426
attestation = attestation.model_copy(
412427
update={
@@ -493,6 +508,8 @@ async def _deliver_verdict(self, state: OrchestrationState) -> None:
493508
issued_at=now,
494509
privacy_mode=privacy_mode_str,
495510
)
511+
# Sign BEFORE adding trust_token so the signature covers core content
512+
attestation = self._sign_attestation_if_keypair(attestation)
496513
if verdict == TrustVerdict.VERIFIED and self._trust_token_secret:
497514
attestation = attestation.model_copy(
498515
update={

airlock/gateway/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
from __future__ import annotations
22

3-
from airlock.gateway.app import create_app
3+
4+
def create_app(*args, **kwargs): # type: ignore[no-untyped-def]
5+
"""Lazy wrapper to avoid circular import between engine and gateway."""
6+
from airlock.gateway.app import create_app as _create_app
7+
8+
return _create_app(*args, **kwargs)
9+
410

511
__all__ = ["create_app"]

airlock/gateway/admin_routes.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
1111
from pydantic import BaseModel, Field
1212

13+
from airlock.gateway.revocation import RevocationReason
1314
from airlock.reputation.scoring import INITIAL_SCORE
1415
from airlock.schemas.identity import AgentProfile
1516
from airlock.schemas.reputation import TrustScore
@@ -107,26 +108,51 @@ async def delete_agent(
107108
return {"deleted": True, "did": did}
108109

109110

111+
class RevokeBody(BaseModel):
112+
reason: RevocationReason = RevocationReason.KEY_COMPROMISE
113+
114+
110115
@router.post("/revoke/{did:path}", response_model=dict[str, Any])
111116
async def revoke_agent(
112117
did: str,
113118
request: Request,
114119
_: Annotated[None, Depends(require_admin_token)],
120+
body: RevokeBody | None = None,
121+
) -> dict[str, Any]:
122+
reason = body.reason if body is not None else RevocationReason.KEY_COMPROMISE
123+
store = request.app.state.revocation_store
124+
changed = await store.revoke(did, reason=reason)
125+
return {"revoked": True, "did": did, "changed": changed, "reason": reason.value}
126+
127+
128+
@router.post("/suspend/{did:path}", response_model=dict[str, Any])
129+
async def suspend_agent(
130+
did: str,
131+
request: Request,
132+
_: Annotated[None, Depends(require_admin_token)],
115133
) -> dict[str, Any]:
116134
store = request.app.state.revocation_store
117-
changed = await store.revoke(did)
118-
return {"revoked": True, "did": did, "changed": changed}
135+
changed = await store.suspend(did)
136+
return {"suspended": True, "did": did, "changed": changed}
119137

120138

121-
@router.post("/unrevoke/{did:path}", response_model=dict[str, Any])
122-
async def unrevoke_agent(
139+
@router.post("/reinstate/{did:path}", response_model=dict[str, Any])
140+
async def reinstate_agent(
123141
did: str,
124142
request: Request,
125143
_: Annotated[None, Depends(require_admin_token)],
126144
) -> dict[str, Any]:
127145
store = request.app.state.revocation_store
128-
changed = await store.unrevoke(did)
129-
return {"unrevoked": True, "did": did, "changed": changed}
146+
changed = await store.reinstate(did)
147+
if not changed:
148+
# Could be because the DID is permanently revoked or not suspended
149+
reason = store.get_revocation_reason(did)
150+
if reason is not None:
151+
raise HTTPException(
152+
status_code=409,
153+
detail=f"Cannot reinstate permanently revoked DID (reason: {reason.value})",
154+
)
155+
return {"reinstated": changed, "did": did}
130156

131157

132158
@router.get("/revoked", response_model=dict[str, Any])

airlock/gateway/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
143143
session_mgr=session_mgr,
144144
vc_allowed_issuers=vc_allowed,
145145
revocation_store=revocation_store,
146+
airlock_keypair=airlock_kp,
146147
)
147148
event_bus.register(orchestrator.handle_event)
148149
await event_bus.start()

airlock/gateway/handlers.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,31 @@ async def handle_handshake(
207207
status_code=400,
208208
)
209209
if body.pow is not None:
210-
from airlock.pow import verify_pow
211-
212-
if not verify_pow(body.pow):
213-
return JSONResponse(
214-
{"error": "pow_invalid", "detail": "Proof-of-work verification failed"},
215-
status_code=400,
216-
)
210+
from airlock.pow import verify_pow_with_store
211+
212+
pow_store = getattr(request.app.state, "pow_challenges", None)
213+
if pow_store is not None:
214+
ok, reason = verify_pow_with_store(body.pow, pow_store)
215+
if not ok:
216+
error_map: dict[str, tuple[str, int]] = {
217+
"unknown_challenge": ("Challenge ID not recognised or already used", 400),
218+
"expired_challenge": ("PoW challenge has expired", 400),
219+
"invalid_proof": ("Proof-of-work verification failed", 400),
220+
}
221+
detail, status = error_map.get(reason or "", ("PoW verification failed", 400))
222+
return JSONResponse(
223+
{"error": f"pow_{reason}", "detail": detail, "status_code": status},
224+
status_code=status,
225+
)
226+
else:
227+
# Fallback: no challenge store available — hash-only check
228+
from airlock.pow import verify_pow
229+
230+
if not verify_pow(body.pow):
231+
return JSONResponse(
232+
{"error": "pow_invalid", "detail": "Proof-of-work verification failed"},
233+
status_code=400,
234+
)
217235

218236
session_mgr = request.app.state.session_mgr
219237
now = datetime.now(UTC)

0 commit comments

Comments
 (0)