Skip to content

Commit 0712cc6

Browse files
committed
feat: signed CRL, trust token revocation, async fingerprinting, DID rate limiting
Add pull-based signed CRL endpoint (GET /crl, /.well-known/airlock-crl) with Ed25519 signatures, monotonic crl_number, ETag caching, and tiered freshness degradation (NORMAL → DEGRADED → EMERGENCY → FAIL_CLOSED). Separate CRL signing key support via crl_signing_key_hex config. Trust token decode now checks revocation status — revoked/suspended DIDs are rejected at introspect time even if the JWT is unexpired. Default TTL reduced from 600s to 120s to shrink the revocation gap window. FingerprintStore converted from threading.Lock to asyncio.Lock for non-blocking concurrent request handling. Sync wrappers preserved for backward compatibility. DID-based rate limiting extracted into DIDRateLimiter class with DID format validation, structured 429 error responses, and Retry-After headers. IETF BCP 72 Security Considerations document (1,400+ lines) covering threat model, identity attacks, trust scoring, PoW, semantic challenges, privacy, network attacks, token security, revocation, federation, and operational security. 601 tests, 0 failures. Signed-off-by: Shivdeep Singh <shivdeepsachdeva@gmail.com>
1 parent 5ea7a1e commit 0712cc6

25 files changed

Lines changed: 3367 additions & 82 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to the Airlock Protocol are documented in this file.
55
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.1] - 2026-04-05
9+
10+
### Fixed
11+
- **PoW Challenge Replay** (CRITICAL): `verify_pow()` now validates challenges against a server-side store with one-time use enforcement and expiry checks
12+
- **RFC 8785 Canonical JSON** (CRITICAL): Removed `default=str` from `canonicalize()` — explicit type conversion ensures cross-language signature verification (Go, Rust, JS)
13+
14+
### Changed
15+
- **Revocation model**: `revoke()` is now permanent and irreversible for key compromise scenarios; added `suspend()`/`reinstate()` for reversible holds
16+
- **Attestation signing**: `AirlockAttestation.airlock_signature` is now populated with a real Ed25519 signature, enabling cryptographic verification by relying parties
17+
- Added `RevocationReason` enum with 7 reason codes
18+
- New admin endpoints: `POST /admin/suspend/{did}`, `POST /admin/reinstate/{did}`
19+
20+
### Removed
21+
- `unrevoke()` method — replaced by `suspend()`/`reinstate()`
22+
- `DELETE /admin/revoke/{did}` endpoint
23+
24+
### Security
25+
- 4 security audit documents added to `docs/security/`
26+
827
## [0.2.0] - 2026-04-05
928

1029
### Added

airlock/config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class AirlockConfig(BaseSettings):
4747

4848
# HS256 trust token issued only on VERIFIED (set secret in production).
4949
trust_token_secret: str = ""
50-
trust_token_ttl_seconds: int = Field(default=600, ge=60, le=86_400)
50+
trust_token_ttl_seconds: int = Field(default=120, ge=60, le=86_400)
5151

5252
# Comma-separated issuer DIDs; empty = any issuer (dev). Non-empty = VC issuer must match.
5353
vc_issuer_allowlist: str = ""
@@ -153,6 +153,16 @@ class AirlockConfig(BaseSettings):
153153
fingerprint_exact_duplicate_action: str = "fail"
154154
fingerprint_near_duplicate_action: str = "flag"
155155

156+
# -----------------------------------------------------------------------
157+
# CRL (Certificate Revocation List)
158+
# -----------------------------------------------------------------------
159+
crl_update_interval_seconds: int = Field(default=60, ge=30, le=600)
160+
crl_max_cache_age_seconds: int = Field(default=300, ge=60, le=3600)
161+
crl_emergency_cache_age_seconds: int = Field(default=3600, ge=300, le=86400)
162+
# Separate CRL signing key (hex-encoded 32-byte Ed25519 seed).
163+
# Falls back to gateway_seed_hex if empty.
164+
crl_signing_key_hex: str = ""
165+
156166
# Event bus drain timeout during shutdown (seconds).
157167
event_bus_drain_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0)
158168

airlock/crypto/signing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def _prepare_for_json(obj: Any) -> Any:
2424
"""Recursively convert Python objects to JSON-safe, cross-language types.
2525
2626
Ensures deterministic serialization that produces identical output in
27-
Python, Go, Rust, and JavaScript implementations (C-09 interop fix).
27+
Python, Go, Rust, and JavaScript implementations.
2828
2929
Conversion rules:
3030
- datetime -> ISO 8601 with timezone (naive datetimes treated as UTC)
@@ -93,7 +93,7 @@ def canonicalize(data: dict[str, Any]) -> bytes:
9393
All values are first normalized via ``_prepare_for_json`` so that
9494
datetimes, enums, UUIDs, bytes, etc. are converted to language-agnostic
9595
representations *before* JSON encoding. This guarantees identical
96-
canonical bytes across Python, Go, Rust, and JavaScript (C-09 fix).
96+
canonical bytes across Python, Go, Rust, and JavaScript.
9797
9898
Strips known signature/token fields so we sign the unsigned form.
9999
"""

airlock/gateway/app.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717
from airlock.engine.event_bus import EventBus
1818
from airlock.engine.orchestrator import VerificationOrchestrator
1919
from airlock.engine.state import SessionManager
20+
from airlock.gateway.crl import CRLGenerator
2021
from airlock.gateway.identity import gateway_keypair_from_config
2122
from airlock.gateway.logging_config import configure_airlock_logging
2223
from airlock.gateway.metrics import HttpRequestMetrics
2324
from airlock.gateway.observability import add_observability_middleware
2425
from airlock.gateway.policy import parse_did_allowlist
25-
from airlock.gateway.rate_limit import InMemorySlidingWindow, RedisSlidingWindow
26+
from airlock.gateway.rate_limit import DIDRateLimiter, InMemorySlidingWindow, RedisSlidingWindow
2627
from airlock.gateway.replay import InMemoryReplayGuard, RedisReplayGuard
2728
from airlock.gateway.revocation import RedisRevocationStore, RevocationStore
2829
from airlock.gateway.startup_validate import AirlockStartupError, validate_startup_config
@@ -89,24 +90,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
8990
max_events=cfg.rate_limit_per_ip_per_minute,
9091
window_seconds=60.0,
9192
)
92-
rate_limit_handshake_did: RedisSlidingWindow | InMemorySlidingWindow = (
93-
RedisSlidingWindow(
94-
redis_client,
95-
max_events=cfg.rate_limit_handshake_per_did_per_minute,
96-
window_seconds=60.0,
97-
)
93+
_did_backend: RedisSlidingWindow | InMemorySlidingWindow = RedisSlidingWindow(
94+
redis_client,
95+
max_events=cfg.rate_limit_handshake_per_did_per_minute,
96+
window_seconds=60.0,
9897
)
9998
else:
10099
nonce_guard = InMemoryReplayGuard(ttl_seconds=cfg.nonce_replay_ttl_seconds)
101100
rate_limit_ip = InMemorySlidingWindow(
102101
max_events=cfg.rate_limit_per_ip_per_minute,
103102
window_seconds=60.0,
104103
)
105-
rate_limit_handshake_did = InMemorySlidingWindow(
104+
_did_backend = InMemorySlidingWindow(
106105
max_events=cfg.rate_limit_handshake_per_did_per_minute,
107106
window_seconds=60.0,
108107
)
109108

109+
did_rate_limiter = DIDRateLimiter(_did_backend)
110+
110111
vc_allowed = parse_did_allowlist(cfg.vc_issuer_allowlist)
111112
rate_limit_register_hour: RedisSlidingWindow | InMemorySlidingWindow | None = None
112113
if cfg.register_max_per_ip_per_hour > 0:
@@ -129,6 +130,26 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
129130
else:
130131
revocation_store = RevocationStore()
131132

133+
# ---- CRL generator ----
134+
crl_signing_seed = (cfg.crl_signing_key_hex or "").strip()
135+
if len(crl_signing_seed) == 64:
136+
try:
137+
from nacl.signing import SigningKey as _NaClSigningKey
138+
139+
crl_signing_key = _NaClSigningKey(bytes.fromhex(crl_signing_seed))
140+
except (ValueError, Exception):
141+
crl_signing_key = airlock_kp.signing_key
142+
else:
143+
crl_signing_key = airlock_kp.signing_key
144+
145+
crl_generator = CRLGenerator(
146+
revocation_store=revocation_store,
147+
signing_key=crl_signing_key,
148+
issuer_did=airlock_kp.did,
149+
update_interval_seconds=cfg.crl_update_interval_seconds,
150+
max_cache_age_seconds=cfg.crl_max_cache_age_seconds,
151+
)
152+
132153
audit_trail = AuditTrail()
133154

134155
_tok = (cfg.trust_token_secret or "").strip()
@@ -157,11 +178,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
157178
app.state.agent_registry = agent_registry
158179
app.state.heartbeat_store = heartbeat_store
159180
app.state.revocation_store = revocation_store
181+
app.state.crl_generator = crl_generator
160182
app.state.audit_trail = audit_trail
161183
app.state.airlock_kp = airlock_kp
162184
app.state.nonce_guard = nonce_guard
163185
app.state.rate_limit_ip = rate_limit_ip
164-
app.state.rate_limit_handshake_did = rate_limit_handshake_did
186+
app.state.did_rate_limiter = did_rate_limiter
165187
app.state.rate_limit_register_hour = rate_limit_register_hour
166188
app.state.http_metrics = HttpRequestMetrics()
167189
app.state.pow_challenges: dict[str, Any] = {}

airlock/gateway/crl.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""CRL (Certificate Revocation List) generator for the Airlock gateway.
2+
3+
Builds a signed CRL from the current revocation state, caches it until
4+
the next update interval, and tracks a monotonically increasing crl_number.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
from datetime import UTC, datetime, timedelta
11+
from typing import TYPE_CHECKING
12+
13+
from airlock.crypto.signing import sign_message
14+
from airlock.schemas.crl import CRLEntry, SignedCRL
15+
16+
if TYPE_CHECKING:
17+
from nacl.signing import SigningKey
18+
19+
from airlock.gateway.revocation import RedisRevocationStore, RevocationStore
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class CRLGenerator:
25+
"""Generates and caches signed CRLs from the current revocation state.
26+
27+
Parameters
28+
----------
29+
revocation_store:
30+
The revocation store to read current revoked/suspended DIDs from.
31+
signing_key:
32+
Ed25519 signing key used to sign the CRL.
33+
issuer_did:
34+
DID of the gateway that issues the CRL.
35+
update_interval_seconds:
36+
Seconds between CRL regenerations (controls ``next_update``).
37+
max_cache_age_seconds:
38+
Value published in the CRL's ``max_cache_age_seconds`` field.
39+
"""
40+
41+
def __init__(
42+
self,
43+
revocation_store: RevocationStore | RedisRevocationStore,
44+
signing_key: SigningKey,
45+
issuer_did: str,
46+
update_interval_seconds: int = 60,
47+
max_cache_age_seconds: int = 300,
48+
) -> None:
49+
self._store = revocation_store
50+
self._signing_key = signing_key
51+
self._issuer_did = issuer_did
52+
self._update_interval = update_interval_seconds
53+
self._max_cache_age = max_cache_age_seconds
54+
self._crl_number: int = 0
55+
self._cached_crl: SignedCRL | None = None
56+
57+
@property
58+
def crl_number(self) -> int:
59+
"""Current CRL sequence number."""
60+
return self._crl_number
61+
62+
def _is_cache_fresh(self) -> bool:
63+
"""Return True if the cached CRL has not passed its next_update time."""
64+
if self._cached_crl is None:
65+
return False
66+
now = datetime.now(UTC)
67+
return now < self._cached_crl.next_update
68+
69+
async def generate(self) -> SignedCRL:
70+
"""Build a new SignedCRL from the current revocation state.
71+
72+
Increments crl_number, builds entries from all revoked and suspended
73+
DIDs, signs the CRL, and updates the cache.
74+
"""
75+
self._crl_number += 1
76+
now = datetime.now(UTC)
77+
next_update = now + timedelta(seconds=self._update_interval)
78+
79+
entries: list[CRLEntry] = []
80+
81+
# Add permanently revoked DIDs
82+
revoked_reasons = self._store.get_revoked_with_reasons()
83+
for did, reason in sorted(revoked_reasons.items()):
84+
entries.append(
85+
CRLEntry(
86+
did=did,
87+
status="revoked",
88+
reason=reason.value,
89+
revoked_at=now,
90+
)
91+
)
92+
93+
# Add suspended DIDs
94+
suspended_dids = await self._store.list_suspended()
95+
for did in suspended_dids:
96+
entries.append(
97+
CRLEntry(
98+
did=did,
99+
status="suspended",
100+
reason="investigation",
101+
revoked_at=now,
102+
)
103+
)
104+
105+
crl = SignedCRL(
106+
version=1,
107+
crl_number=self._crl_number,
108+
issuer_did=self._issuer_did,
109+
this_update=now,
110+
next_update=next_update,
111+
max_cache_age_seconds=self._max_cache_age,
112+
entries=entries,
113+
signature=None,
114+
)
115+
116+
# Sign the CRL (signature field is excluded by canonicalize)
117+
crl_dict = crl.model_dump(mode="json")
118+
signature = sign_message(crl_dict, self._signing_key)
119+
crl = crl.model_copy(update={"signature": signature})
120+
121+
self._cached_crl = crl
122+
logger.info(
123+
"CRL #%d generated: %d entries, next_update=%s",
124+
self._crl_number,
125+
len(entries),
126+
next_update.isoformat(),
127+
)
128+
return crl
129+
130+
async def get_or_generate(self) -> SignedCRL:
131+
"""Return the cached CRL if fresh, otherwise regenerate."""
132+
if self._is_cache_fresh():
133+
return self._cached_crl # type: ignore[return-value]
134+
return await self.generate()

airlock/gateway/crl_freshness.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Tiered CRL freshness assessment for fail-open/fail-closed degradation.
2+
3+
Determines how stale a CRL is and what operational mode the gateway should
4+
use when making trust decisions.
5+
6+
Modes (in order of increasing severity):
7+
NORMAL -- CRL is fresh (age < update interval)
8+
DEGRADED -- CRL is stale but within max_cache_age (warn on attestations)
9+
EMERGENCY -- CRL is very stale (only allow high-trust agents)
10+
FAIL_CLOSED -- CRL is unacceptably stale (reject all verifications)
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import logging
16+
from datetime import UTC, datetime
17+
from enum import StrEnum
18+
from typing import TYPE_CHECKING
19+
20+
if TYPE_CHECKING:
21+
from airlock.config import AirlockConfig
22+
from airlock.schemas.crl import SignedCRL
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class CRLFreshnessMode(StrEnum):
28+
"""Operational mode based on CRL staleness."""
29+
30+
NORMAL = "normal"
31+
DEGRADED = "degraded"
32+
EMERGENCY = "emergency"
33+
FAIL_CLOSED = "fail_closed"
34+
35+
36+
def assess_crl_freshness(crl: SignedCRL, config: AirlockConfig) -> CRLFreshnessMode:
37+
"""Determine CRL freshness mode based on age thresholds.
38+
39+
Parameters
40+
----------
41+
crl:
42+
The CRL to assess.
43+
config:
44+
Gateway configuration containing threshold values.
45+
46+
Returns
47+
-------
48+
CRLFreshnessMode
49+
The operational mode the gateway should use.
50+
51+
Thresholds
52+
----------
53+
- NORMAL: crl_age < crl_update_interval_seconds
54+
- DEGRADED: crl_age < crl_max_cache_age_seconds
55+
- EMERGENCY: crl_age < crl_emergency_cache_age_seconds
56+
- FAIL_CLOSED: crl_age >= crl_emergency_cache_age_seconds
57+
"""
58+
now = datetime.now(UTC)
59+
crl_age_seconds = (now - crl.this_update).total_seconds()
60+
61+
if crl_age_seconds < config.crl_update_interval_seconds:
62+
return CRLFreshnessMode.NORMAL
63+
64+
if crl_age_seconds < config.crl_max_cache_age_seconds:
65+
logger.warning(
66+
"CRL #%d is stale (age=%.0fs, threshold=%ds) — DEGRADED mode",
67+
crl.crl_number,
68+
crl_age_seconds,
69+
config.crl_update_interval_seconds,
70+
)
71+
return CRLFreshnessMode.DEGRADED
72+
73+
if crl_age_seconds < config.crl_emergency_cache_age_seconds:
74+
logger.warning(
75+
"CRL #%d is very stale (age=%.0fs, threshold=%ds) — EMERGENCY mode",
76+
crl.crl_number,
77+
crl_age_seconds,
78+
config.crl_max_cache_age_seconds,
79+
)
80+
return CRLFreshnessMode.EMERGENCY
81+
82+
logger.error(
83+
"CRL #%d exceeds emergency threshold (age=%.0fs, limit=%ds) — FAIL_CLOSED",
84+
crl.crl_number,
85+
crl_age_seconds,
86+
config.crl_emergency_cache_age_seconds,
87+
)
88+
return CRLFreshnessMode.FAIL_CLOSED

0 commit comments

Comments
 (0)