Skip to content

Commit 8b32a1c

Browse files
Merge pull request #12 from HarshvMahawar/dev-branch-2
Add foundational components for EAR generation (JWT creation and signing) and CI setup
2 parents 34aebe8 + 3e6eb74 commit 8b32a1c

22 files changed

+1144
-51
lines changed

.flake8

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[flake8]
2+
max-line-length = 88

.github/workflows/tox.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Run Tox on PR
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
- '**' # Run on all branches for PRs
8+
9+
jobs:
10+
tox-tests:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
python-version: [3.9, 3.11] # Test against multiple Python versions
16+
17+
steps:
18+
# Checkout the code
19+
- name: Checkout code
20+
uses: actions/checkout@v3
21+
22+
# Setup Python
23+
- name: Set up Python ${{ matrix.python-version }}
24+
uses: actions/setup-python@v4
25+
with:
26+
python-version: ${{ matrix.python-version }}
27+
28+
# Install tox
29+
- name: Install tox
30+
run: pip install tox
31+
32+
# Run tox
33+
- name: Run tox
34+
run: tox

.pylintrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
[MESSAGES CONTROL]
2-
disable = C0114, C0115, C0116 ; Disable missing module/class/function docstring warnings
2+
disable = C0114, C0115, C0116, redefined-outer-name, duplicate-code
33

44
[FORMAT]
55
max-line-length = 88 ; Match Black's default line length
6+
max-attributes=10
67

78
[MASTER]
89
ignore = venv ; Ignore virtual environment folder

README.md

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,68 @@
1-
# python-ear
1+
# **python-ear**
22

3-
A python implementation of [draft-fv-rats-ear](https://datatracker.ietf.org/doc/draft-fv-rats-ear/).
3+
A Python library that implements the EAT Attestation Result (EAR) data format, as specified in [draft-fv-rats-ear](https://datatracker.ietf.org/doc/draft-fv-rats-ear/). This library provides implementations for both CBOR-based and JSON-based serialisations.
44

5-
# Proposal
5+
---
66

7-
Following are the tools that will be used in the development of this library
7+
## **Overview**
88

9-
## CWT and JWT creation
9+
The goal of this project is to standardize attestation results by defining a shared information and data model, enabling seamless integration with other components of the RATS architecture. This focuses specifically on harmonizing attestation results to facilitate interoperability between various verifiers and relying parties.
1010

11-
1. [python-cwt](https://python-cwt.readthedocs.io/en/stable/)
12-
2. [python-jwt](https://pypi.org/project/python-jose/)
11+
This implementation was initiated as part of the **Veraison Mentorship** under the Linux Foundation Mentorship Program (**LFX Mentorship**), focusing on the following capabilities:
1312

14-
## Code formatting and styling
13+
- **Populating EAR Claims-Sets:** Define and populate claims that represent evidence and attestation results.
14+
- **Signing EAR Claims-Sets:** Support signing using private keys, ensuring data integrity and authenticity.
15+
- **Encoding and Decoding:**
16+
- Encode signed EAR claims as **CWT** (Concise Binary Object Representation Web Tokens) or **JWT** (JSON Web Tokens).
17+
- Decode signed EARs from CWT or JWT formats, enabling interoperability between different systems.
18+
- **Signature Verification:** Verify signatures using public keys to ensure the authenticity of claims.
19+
- **Accessing Claims:** Provide interfaces to access and manage EAR claims efficiently.
1520

16-
1. [black](https://pypi.org/project/black/)
17-
2. [isort](https://pypi.org/project/isort/)
21+
This library is developed in Python and makes use of existing packages for CWT and JWT management, static code analysis, and testing.
1822

19-
## Linting and static analysis
23+
---
2024

21-
1. [flake8](https://pypi.org/project/flake8/)
22-
2. [mypy](https://pypi.org/project/mypy/)
25+
## **Key Features**
2326

24-
## Testing
27+
1. **Standards Compliance:**
28+
Implements draft-fv-rats-ear as per IETF specifications to ensure compatibility with the RATS architecture.
2529

26-
1. [pytest](https://pypi.org/project/pytest/)
30+
2. **Token Management:**
31+
- **CWT Support:** Utilizes [python-cwt](https://python-cwt.readthedocs.io/en/stable/) for handling CBOR Web Tokens.
32+
- **JWT Support:** Uses [python-jose](https://pypi.org/project/python-jose/) for JSON Web Tokens management.
33+
34+
3. **Security:**
35+
- Supports signing of EAR claims with private keys and verification with public keys.
36+
- Adopts secure cryptographic practices for token creation and verification.
37+
38+
4. **Static Analysis and Code Quality:**
39+
- Ensures code quality using linters and static analysis tools.
40+
- Maintains type safety and code consistency.
41+
42+
5. **Testing:**
43+
- Comprehensive unit tests using `pytest` to validate all functionalities.
44+
45+
---
46+
47+
## **Technical Stack**
48+
49+
### **Token Creation and Management**
50+
51+
- **CWT:** [python-cwt](https://python-cwt.readthedocs.io/en/stable/)
52+
- **JWT:** [python-jose](https://pypi.org/project/python-jose/)
53+
54+
### **Code Formatting and Styling**
55+
56+
- **black:** Ensures consistent code formatting.
57+
- **isort:** Manages import statements.
58+
59+
### **Linting and Static Analysis**
60+
61+
- **flake8:** For PEP 8 compliance and linting.
62+
- **mypy:** Static type checking.
63+
- **pyright:** Advanced type checking for Python.
64+
- **pylint:** Code analysis for error detection and enforcing coding standards.
65+
66+
### **Testing**
67+
68+
- **pytest:** Framework for writing and executing tests.

src/base.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import json
2+
from abc import ABC
3+
from collections import namedtuple
4+
from typing import Any, ClassVar, Dict, Tuple, Type, TypeVar, Union, get_args
5+
6+
T = TypeVar("T", bound="BaseJCSerializable")
7+
8+
KeyMapping = namedtuple("KeyMapping", ["int_key", "str_key"])
9+
10+
11+
def to_data(value: Any, keys_as_int=False) -> Any:
12+
if hasattr(value, "to_data"):
13+
return value.to_data(keys_as_int)
14+
if hasattr(value, "items"): # dict-like
15+
return {
16+
to_data(k, keys_as_int): to_data(v, keys_as_int) for k, v in value.items()
17+
}
18+
if hasattr(value, "__iter__") and not isinstance(value, str): # list-like
19+
return [to_data(v, keys_as_int) for v in value]
20+
21+
if hasattr(
22+
value, "value"
23+
): # custom classes that have value attr but don't have 'to_data'
24+
return value.value # type: ignore[attr-defined]
25+
# scalar and no to_data(), so assume serializable as-is
26+
return value
27+
28+
29+
class BaseJCSerializable(ABC):
30+
jc_map: ClassVar[Dict[str, Tuple[int, str]]]
31+
32+
def to_data(self, keys_as_int=False) -> Dict[Union[str, int], Any]:
33+
return {
34+
(int_key if keys_as_int else str_key): to_data(
35+
getattr(self, attr), keys_as_int
36+
)
37+
for attr, (int_key, str_key) in self.jc_map.items()
38+
}
39+
40+
@classmethod
41+
def from_data(cls: Type[T], data: dict, keys_as_int=False) -> T:
42+
key_attr = "int_key" if keys_as_int else "str_key"
43+
init_kwargs = {}
44+
reverse_map = {
45+
getattr(mapping, key_attr): attr for attr, mapping in cls.jc_map.items()
46+
}
47+
48+
for key, value in data.items():
49+
if key not in reverse_map:
50+
continue
51+
52+
attr = reverse_map[key]
53+
field_type = getattr(cls, "__annotations__", {}).get(attr)
54+
if field_type is None:
55+
continue
56+
57+
args = get_args(field_type)
58+
59+
if hasattr(field_type, "from_data"):
60+
# Direct object
61+
init_kwargs[attr] = field_type.from_data(value, keys_as_int=keys_as_int)
62+
63+
elif hasattr(field_type, "items") and hasattr(args[1], "from_data"):
64+
# Dict[str | int, CustomClass]
65+
init_kwargs[attr] = {
66+
k: args[1].from_data(v, keys_as_int=keys_as_int)
67+
for k, v in value.items()
68+
}
69+
70+
elif args:
71+
# custom classes that dont have 'from_data'
72+
init_kwargs[attr] = args[0](value)
73+
74+
else:
75+
init_kwargs[attr] = field_type(value)
76+
77+
return cls(**init_kwargs)
78+
79+
def to_dict(self) -> Dict[str, Any]:
80+
# default str_keys
81+
return self.to_data() # type: ignore[return-value] # pyright: ignore[reportGeneralTypeIssues] # noqa: E501 # pylint: disable=line-too-long
82+
83+
def to_int_keys(self) -> Dict[Union[str, int], Any]:
84+
return self.to_data(keys_as_int=True)
85+
86+
@classmethod
87+
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
88+
return cls.from_data(data)
89+
90+
@classmethod
91+
def from_int_keys(cls: Type[T], data: Dict[int, Any]) -> T:
92+
return cls.from_data(data, keys_as_int=True)
93+
94+
@classmethod
95+
def from_json(cls, json_str: str):
96+
return cls.from_dict(json.loads(json_str))
97+
98+
def to_json(self):
99+
return json.dumps(self.to_data())

src/claims.py

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,76 @@
1-
import json
21
from dataclasses import dataclass, field
3-
from typing import Any, Dict
2+
from datetime import datetime, timedelta
3+
from typing import Dict
44

5+
from jose import jwt # type: ignore # pylint: disable=import-error
56

7+
from src.base import BaseJCSerializable, KeyMapping
8+
from src.errors import EARValidationError
9+
from src.jwt_config import DEFAULT_ALGORITHM, DEFAULT_EXPIRATION_MINUTES
10+
from src.submod import Submod
11+
from src.verifier_id import VerifierID
12+
13+
14+
# https://datatracker.ietf.org/doc/draft-fv-rats-ear/
615
@dataclass
7-
class EARClaims:
16+
class AttestationResult(BaseJCSerializable):
817
profile: str
918
issued_at: int
10-
verifier_id: Dict[str, str] = field(default_factory=dict)
11-
submods: Dict[str, Any] = field(default_factory=dict)
19+
verifier_id: VerifierID
20+
submods: Dict[str, Submod] = field(default_factory=dict)
1221

13-
def to_dict(self) -> Dict[str, Any]:
14-
return {
15-
"eat_profile": self.profile,
16-
"iat": self.issued_at,
17-
"ear.verifier-id": self.verifier_id,
18-
"submods": self.submods,
19-
}
22+
# https://www.ietf.org/archive/id/draft-ietf-rats-eat-31.html#section-7.2.4
23+
jc_map = {
24+
"profile": KeyMapping(265, "eat_profile"),
25+
"issued_at": KeyMapping(6, "iat"),
26+
"verifier_id": KeyMapping(1004, "ear.verifier-id"),
27+
"submods": KeyMapping(266, "submods"),
28+
}
2029

21-
@classmethod
22-
def from_dict(cls, data: Dict[str, Any]):
23-
return cls(
24-
profile=data.get("eat_profile", ""),
25-
issued_at=data.get("iat", 0),
26-
verifier_id=data.get("ear.verifier-id", {}),
27-
submods=data.get("submods", {}),
28-
)
30+
def validate(self):
31+
# Validates an AttestationResult object
32+
if not isinstance(self.profile, str) or not self.profile:
33+
raise EARValidationError(
34+
"AttestationResult profile must be a non-empty string"
35+
)
36+
if not isinstance(self.issued_at, int) or self.issued_at <= 0:
37+
raise EARValidationError(
38+
"AttestationResult issued_at must be a positive integer"
39+
)
2940

30-
def to_json(self) -> str:
31-
return json.dumps(self.to_dict())
41+
self.verifier_id.validate()
42+
43+
for submod, details in self.submods.items():
44+
if not isinstance(details, Submod):
45+
raise EARValidationError(
46+
f"Submodule {submod} must contain a valid trust_vector and status"
47+
)
48+
49+
trust_vector = details.trust_vector
50+
trust_vector.validate()
51+
52+
def encode_jwt(
53+
self,
54+
secret_key: str,
55+
algorithm: str = DEFAULT_ALGORITHM,
56+
expiration_minutes: int = DEFAULT_EXPIRATION_MINUTES,
57+
) -> str:
58+
# Signs an AttestationResult object and returns a JWT
59+
payload = self.to_dict()
60+
payload["exp"] = int(
61+
datetime.timestamp(datetime.now() + timedelta(minutes=expiration_minutes))
62+
)
63+
return jwt.encode(
64+
payload, secret_key, algorithm=algorithm
65+
) # pyright: ignore[reportGeneralTypeIssues]
3266

3367
@classmethod
34-
def from_json(cls, json_str: str):
35-
return cls.from_dict(json.loads(json_str))
68+
def decode_jwt(
69+
cls, token: str, secret_key: str, algorithm: str = DEFAULT_ALGORITHM
70+
):
71+
# Verifies a JWT and returns the decoded AttestationResult object.
72+
try:
73+
payload = jwt.decode(token, secret_key, algorithms=[algorithm])
74+
return cls.from_dict(payload)
75+
except Exception as exc:
76+
raise ValueError(f"JWT decoding failed: {exc}") from exc

src/errors.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class EARValidationError(Exception):
2+
# Custom exception for validation errors in AttestationResult
3+
pass

src/example/__init__.py

Whitespace-only changes.

src/example/jwt_example.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from datetime import datetime
2+
3+
from src.claims import AttestationResult
4+
from src.jwt_config import generate_secret_key
5+
from src.submod import Submod
6+
from src.trust_claims import TRUSTWORTHY_INSTANCE_CLAIM, UNRECOGNIZED_INSTANCE_CLAIM
7+
from src.trust_tier import TRUST_TIER_AFFIRMING, TRUST_TIER_CONTRAINDICATED
8+
from src.trust_vector import TrustVector
9+
from src.verifier_id import VerifierID
10+
11+
# import json
12+
13+
# Generate a secret key for signing
14+
secret_key = generate_secret_key()
15+
16+
# Create an AttestationResult object
17+
attestation_result = AttestationResult(
18+
profile="test_profile",
19+
issued_at=int(datetime.timestamp(datetime.now())),
20+
verifier_id=VerifierID(developer="Acme Inc.", build="v1"),
21+
submods={
22+
"submod1": Submod(
23+
trust_vector=TrustVector(instance_identity=UNRECOGNIZED_INSTANCE_CLAIM),
24+
status=TRUST_TIER_AFFIRMING,
25+
),
26+
"submod2": Submod(
27+
trust_vector=TrustVector(instance_identity=TRUSTWORTHY_INSTANCE_CLAIM),
28+
status=TRUST_TIER_CONTRAINDICATED,
29+
),
30+
},
31+
)
32+
33+
# payload = attestation_result.encode_jwt(secret_key=secret_key)
34+
# print(payload)
35+
36+
# decoded = AttestationResult.decode_jwt(token=payload, secret_key=secret_key)
37+
# output_data = decoded.to_dict()
38+
39+
# with open("jwt_output.json", "w", encoding="utf-8") as f:
40+
# json.dump(output_data, f, indent=4)
41+
42+
# print("Output successfully written to output.json")

0 commit comments

Comments
 (0)