-
Notifications
You must be signed in to change notification settings - Fork 0
fix: RFC-002 alignment for TrustLevel and BadgeClaims #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||||||
|
|
||||||||
| ## [Unreleased] | ||||||||
|
|
||||||||
| ## [2.4.0] - 2026-01-18 | ||||||||
|
|
||||||||
| ### Fixed | ||||||||
| - **RFC-002 Alignment**: TrustLevel enum values now match RFC-002 §5 exactly | ||||||||
| - **BadgeClaims**: Aligned claim field names with RFC-002 specification | ||||||||
|
|
||||||||
| ### Added | ||||||||
| - **MCP Service Client**: RFC-006/RFC-007 operations via MCP protocol | ||||||||
| - **MCP gRPC Client**: Server identity operations | ||||||||
|
Comment on lines
+17
to
+18
|
||||||||
| - **MCP Service Client**: RFC-006/RFC-007 operations via MCP protocol | |
| - **MCP gRPC Client**: Server identity operations | |
| - **FastAPI SimpleGuard middleware**: Added `exclude_paths` support to bypass badge verification for selected routes (for example `/health`). |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -64,24 +64,38 @@ class VerifyMode(Enum): | |||||||||
|
|
||||||||||
|
|
||||||||||
| class TrustLevel(Enum): | ||||||||||
| """Trust level as defined in RFC-002.""" | ||||||||||
| """Trust level as defined in RFC-002 §5. | ||||||||||
|
|
||||||||||
| Levels: | ||||||||||
| LEVEL_0 (SS): Self-Signed - did:key, iss == sub. Development only. | ||||||||||
| LEVEL_1 (REG): Registered - Account registration with CA. | ||||||||||
| LEVEL_2 (DV): Domain Validated - DNS/HTTP domain ownership proof. | ||||||||||
| LEVEL_3 (OV): Organization Validated - Legal entity verification. | ||||||||||
| LEVEL_4 (EV): Extended Validated - Manual review + security audit. | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| LEVEL_0 = "0" | ||||||||||
| """Self-Signed (SS) - did:key, iss == sub. Development only.""" | ||||||||||
|
|
||||||||||
| LEVEL_1 = "1" | ||||||||||
| """Domain Validated (DV) - Basic verification.""" | ||||||||||
| """Registered (REG) - Account registration with CA.""" | ||||||||||
|
|
||||||||||
| LEVEL_2 = "2" | ||||||||||
| """Organization Validated (OV) - Business verification.""" | ||||||||||
| """Domain Validated (DV) - DNS/HTTP domain ownership proof.""" | ||||||||||
|
|
||||||||||
| LEVEL_3 = "3" | ||||||||||
| """Extended Validation (EV) - Rigorous vetting.""" | ||||||||||
| """Organization Validated (OV) - Legal entity verification.""" | ||||||||||
|
|
||||||||||
| LEVEL_4 = "4" | ||||||||||
| """Extended Validated (EV) - Manual review + security audit.""" | ||||||||||
|
|
||||||||||
| @classmethod | ||||||||||
| def from_string(cls, value: str) -> "TrustLevel": | ||||||||||
| """Create TrustLevel from string value.""" | ||||||||||
| for level in cls: | ||||||||||
| if level.value == value: | ||||||||||
| return level | ||||||||||
| raise ValueError(f"Unknown trust level: {value}") | ||||||||||
| raise ValueError(f"Unknown trust level: {value}. Valid levels: 0 (SS), 1 (REG), 2 (DV), 3 (OV), 4 (EV)") | ||||||||||
|
|
||||||||||
|
|
||||||||||
| @dataclass | ||||||||||
|
|
@@ -90,15 +104,24 @@ class BadgeClaims: | |||||||||
|
|
||||||||||
| Attributes: | ||||||||||
| jti: Unique badge identifier (UUID). | ||||||||||
| issuer: Badge issuer URL (CA). | ||||||||||
| issuer: Badge issuer URL (CA) or did:key for self-signed. | ||||||||||
| subject: Agent DID (did:web format). | ||||||||||
| audience: Optional list of intended audience URLs. | ||||||||||
| issued_at: When the badge was issued. | ||||||||||
| expires_at: When the badge expires. | ||||||||||
| trust_level: Trust level (1=DV, 2=OV, 3=EV). | ||||||||||
| trust_level: Trust level per RFC-002 §5: | ||||||||||
| - 0 (SS): Self-Signed - Development only | ||||||||||
| - 1 (REG): Registered - Account registration | ||||||||||
| - 2 (DV): Domain Validated - DNS/HTTP proof | ||||||||||
| - 3 (OV): Organization Validated - Legal entity | ||||||||||
| - 4 (EV): Extended Validated - Security audit | ||||||||||
| domain: Agent's verified domain. | ||||||||||
| agent_name: Human-readable agent name. | ||||||||||
| agent_id: Extracted agent ID from subject DID. | ||||||||||
| ial: Identity Assurance Level (RFC-002 §7.2.1): | ||||||||||
| - "0": Account-attested (no key proof) | ||||||||||
| - "1": Proof of Possession (key holder verified, has cnf claim) | ||||||||||
| raw_claims: Original JWT claims dict for advanced access. | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| jti: str | ||||||||||
|
|
@@ -110,6 +133,8 @@ class BadgeClaims: | |||||||||
| domain: str | ||||||||||
| agent_name: str = "" | ||||||||||
| audience: List[str] = field(default_factory=list) | ||||||||||
| ial: str = "0" # RFC-002 §7.2.1: Default IAL-0 (account-attested) | ||||||||||
| raw_claims: Optional[dict] = field(default=None, repr=False) # For advanced access | ||||||||||
|
|
||||||||||
| @property | ||||||||||
| def agent_id(self) -> str: | ||||||||||
|
|
@@ -133,6 +158,11 @@ def is_not_yet_valid(self) -> bool: | |||||||||
| @classmethod | ||||||||||
| def from_dict(cls, data: dict) -> "BadgeClaims": | ||||||||||
| """Create BadgeClaims from a dictionary.""" | ||||||||||
| # Handle audience - can be string or list | ||||||||||
| aud = data.get("aud", []) | ||||||||||
| if isinstance(aud, str): | ||||||||||
| aud = [aud] if aud else [] | ||||||||||
|
|
||||||||||
| return cls( | ||||||||||
| jti=data.get("jti", ""), | ||||||||||
| issuer=data.get("iss", ""), | ||||||||||
|
|
@@ -142,12 +172,14 @@ def from_dict(cls, data: dict) -> "BadgeClaims": | |||||||||
| trust_level=TrustLevel.from_string(data.get("trust_level", "1")), | ||||||||||
| domain=data.get("domain", ""), | ||||||||||
| agent_name=data.get("agent_name", ""), | ||||||||||
| audience=data.get("aud", []), | ||||||||||
| audience=aud, | ||||||||||
| ial=data.get("ial", "0"), # RFC-002 §7.2.1 | ||||||||||
| raw_claims=data, # Preserve for advanced access (cnf, key, etc.) | ||||||||||
|
||||||||||
| raw_claims=data, # Preserve for advanced access (cnf, key, etc.) | |
| raw_claims=dict(data), # Preserve for advanced access (cnf, key, etc.) with a snapshot copy |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The has_key_binding property has a logical issue. When raw_claims is None, it returns True if ial == "1", but this is misleading. If raw_claims is None, we cannot verify the presence of the cnf claim, so we shouldn't assume it exists based solely on the IAL value. The property should either:
- Return False when raw_claims is None (safer default)
- Raise an exception indicating that verification cannot be performed
The current implementation could lead to false positives where a badge claims IAL-1 but doesn't actually have the cnf claim.
| return self.ial == "1" | |
| # Without raw_claims we cannot verify the presence of the cnf claim, | |
| # so conservatively report no key binding instead of inferring from IAL. | |
| return False |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,21 +25,29 @@ class TestTrustLevel: | |
| """Tests for TrustLevel enum.""" | ||
|
|
||
| def test_from_string_valid(self): | ||
| """Test parsing valid trust levels.""" | ||
| assert TrustLevel.from_string("1") == TrustLevel.LEVEL_1 | ||
| assert TrustLevel.from_string("2") == TrustLevel.LEVEL_2 | ||
| assert TrustLevel.from_string("3") == TrustLevel.LEVEL_3 | ||
| """Test parsing valid trust levels (RFC-002 §5).""" | ||
| assert TrustLevel.from_string("0") == TrustLevel.LEVEL_0 # Self-Signed (SS) | ||
| assert TrustLevel.from_string("1") == TrustLevel.LEVEL_1 # Registered (REG) | ||
| assert TrustLevel.from_string("2") == TrustLevel.LEVEL_2 # Domain Validated (DV) | ||
| assert TrustLevel.from_string("3") == TrustLevel.LEVEL_3 # Organization Validated (OV) | ||
| assert TrustLevel.from_string("4") == TrustLevel.LEVEL_4 # Extended Validated (EV) | ||
|
|
||
| def test_from_string_invalid(self): | ||
| """Test parsing invalid trust level raises error.""" | ||
| with pytest.raises(ValueError, match="Unknown trust level"): | ||
| TrustLevel.from_string("4") | ||
| TrustLevel.from_string("5") # Invalid - only 0-4 exist | ||
| with pytest.raises(ValueError, match="Unknown trust level"): | ||
| TrustLevel.from_string("99") | ||
| with pytest.raises(ValueError, match="Unknown trust level"): | ||
| TrustLevel.from_string("invalid") | ||
|
|
||
| def test_value_property(self): | ||
| """Test value property returns string.""" | ||
| assert TrustLevel.LEVEL_0.value == "0" | ||
| assert TrustLevel.LEVEL_1.value == "1" | ||
| assert TrustLevel.LEVEL_2.value == "2" | ||
| assert TrustLevel.LEVEL_3.value == "3" | ||
| assert TrustLevel.LEVEL_4.value == "4" | ||
|
|
||
|
|
||
| class TestVerifyMode: | ||
|
|
@@ -167,6 +175,72 @@ def test_to_dict(self): | |
| assert data["domain"] == "example.com" | ||
| assert data["agent_name"] == "Test Agent" | ||
| assert data["aud"] == ["https://service.example.com"] | ||
| assert data["ial"] == "0" # Default IAL-0 | ||
|
|
||
| def test_ial_claim_parsing(self): | ||
| """Test IAL claim is correctly parsed from dict (RFC-002 §7.2.1).""" | ||
| # IAL-0 badge (account-attested, no key proof) | ||
| data_ial0 = { | ||
| "jti": "badge-ial0", | ||
| "iss": "https://registry.capisc.io", | ||
| "sub": "did:web:registry.capisc.io:agents:agent1", | ||
| "iat": 1704067200, | ||
| "exp": 1735689600, | ||
| "trust_level": "1", | ||
| "domain": "example.com", | ||
| "ial": "0", | ||
| } | ||
| claims_ial0 = BadgeClaims.from_dict(data_ial0) | ||
| assert claims_ial0.ial == "0" | ||
| assert not claims_ial0.has_key_binding | ||
|
|
||
| # IAL-1 badge (proof of possession, with cnf claim) | ||
| data_ial1 = { | ||
| "jti": "badge-ial1", | ||
| "iss": "https://registry.capisc.io", | ||
| "sub": "did:web:registry.capisc.io:agents:agent2", | ||
| "iat": 1704067200, | ||
| "exp": 1735689600, | ||
| "trust_level": "1", | ||
| "domain": "example.com", | ||
| "ial": "1", | ||
| "cnf": {"jkt": "sha256-thumbprint-of-key"}, | ||
| } | ||
| claims_ial1 = BadgeClaims.from_dict(data_ial1) | ||
| assert claims_ial1.ial == "1" | ||
| assert claims_ial1.has_key_binding | ||
| assert claims_ial1.confirmation_key == {"jkt": "sha256-thumbprint-of-key"} | ||
|
Comment on lines
+180
to
+212
|
||
|
|
||
| def test_raw_claims_preserved(self): | ||
| """Test raw_claims dict is preserved for advanced access.""" | ||
| data = { | ||
| "jti": "badge-raw", | ||
| "iss": "https://registry.capisc.io", | ||
| "sub": "did:web:registry.capisc.io:agents:test", | ||
| "iat": 1704067200, | ||
| "exp": 1735689600, | ||
| "trust_level": "2", | ||
| "domain": "example.com", | ||
| "custom_claim": "custom_value", # Non-standard claim | ||
| } | ||
| claims = BadgeClaims.from_dict(data) | ||
| assert claims.raw_claims is not None | ||
| assert claims.raw_claims.get("custom_claim") == "custom_value" | ||
|
|
||
| def test_audience_string_to_list(self): | ||
| """Test audience string is converted to list.""" | ||
| data = { | ||
| "jti": "badge-aud", | ||
| "iss": "https://registry.capisc.io", | ||
| "sub": "did:web:registry.capisc.io:agents:test", | ||
| "iat": 1704067200, | ||
| "exp": 1735689600, | ||
| "trust_level": "1", | ||
| "domain": "example.com", | ||
| "aud": "https://single-audience.example.com", # String, not list | ||
| } | ||
| claims = BadgeClaims.from_dict(data) | ||
| assert claims.audience == ["https://single-audience.example.com"] | ||
|
|
||
|
|
||
| class TestVerifyOptions: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CHANGELOG is missing an entry for the exclude_paths feature added to FastAPI middleware. This was mentioned in the PR description as one of the key changes ("Implement exclude_paths parameter") and should be documented in the "Added" section of the changelog.