Skip to content

Commit 73a6eb2

Browse files
author
Athan Massouras
committed
fix: refactor bitstring status list verifier
Signed-off-by: Athan Massouras <[email protected]>
1 parent 31cd172 commit 73a6eb2

File tree

3 files changed

+133
-66
lines changed

3 files changed

+133
-66
lines changed

src/bitstring_status_list/verifier.py

Lines changed: 92 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
Protocol,
55
)
66

7-
import requests as r
7+
from aiohttp import ClientSession
8+
import json
89

9-
from bit_array import *
10+
from bit_array import BitArray, b64url_decode, b64url_encode, dict_to_b64
1011
from bitstring_status_list.issuer import MIN_LIST_LENGTH, StatusListLengthError
1112

1213
class EnvelopingTokenVerifier(Protocol):
@@ -34,47 +35,68 @@ def __init__(
3435
self,
3536
credential_status: dict,
3637

37-
headers: Optional[dict] = None,
38-
payload: Optional[dict] = None,
38+
headers: Optional[dict],
39+
payload: dict,
3940

40-
bit_array: Optional[BitArray] = None,
41+
bit_array: BitArray,
4142
):
4243
self.credential_status = credential_status
43-
assert all(key in credential_status.keys() for key in ["id", "type", "statusPurpose", "statusListIndex", "statusListCredential"]),\
44-
"Invalid credentialStatus"
44+
if not all(key in credential_status.keys() for key in ["id", "type", "statusPurpose", "statusListIndex", "statusListCredential"]):
45+
raise StatusVerificationError(f"Invalid credential_status: {credential_status}. \
46+
credential status is expected to have keys: \
47+
[id, type, statusPurpose, statusListIndex, statusListCredential]")
4548

4649
self.headers = headers
4750
self.payload = payload
4851

4952
self._bit_array = bit_array
5053

51-
def establish_connection(
52-
self,
53-
status_list_format: Literal["CWT", "JWT"],
54-
) -> bytes:
55-
""" Establish connection. Returns base64 encoded response. """
56-
issuer_uri = self.credential_status["statusListCredential"]
57-
try:
58-
response = r.get(issuer_uri)
59-
except Exception as e:
60-
raise StatusRetrievalError(f"Dereference of uri {issuer_uri} failed: {e}.")
61-
62-
if not (200 <= response.status_code < 300):
63-
raise StatusRetrievalError(f"Response status from {issuer_uri} was {response.status_code}.")
64-
65-
# When establishing a new connection, clear previous cached values.
66-
self.headers = None
67-
self.payload = None
68-
self._bit_array = None
69-
70-
return response.content
71-
72-
def verify_jwt(
73-
self,
74-
sl_response: bytes,
75-
verifier: EnvelopingTokenVerifier | EmbeddingTokenVerifier,
76-
min_list_length: int = MIN_LIST_LENGTH,
77-
):
54+
@classmethod
55+
async def retrieve_list(
56+
cls,
57+
credential_status: dict,
58+
verifier: EnvelopingTokenVerifier | EmbeddingTokenVerifier,
59+
headers: dict | None = None,
60+
min_list_length: int = MIN_LIST_LENGTH,
61+
) -> "BitstringStatusListVerifier":
62+
"""
63+
Establish connection, parse and verify response, and create instance of
64+
BitstringStatusListVerifier to access it.
65+
66+
Args:
67+
credential_status: REQUIRED. The credentialStatus field of a verifiable credential as
68+
specified in S. 2.1.
69+
70+
verifier: REQUIRED. A callable that verifies the signature of a payload, equivalent to
71+
signer in sign_jwt() in issuer.py.
72+
73+
headers: OPTIONAL. Additional headers for the HTTP request.
74+
75+
min_list_length: OPTIONAL. The minimum list length, recommended to be 131,072 (see S. 6.1)
76+
Returns:
77+
An instance of BitstringStatusListVerifier which has been verified for correctness and
78+
integrity.
79+
"""
80+
81+
headers = headers or {}
82+
83+
async with ClientSession() as session:
84+
async with session.get(credential_status["statusListCredential"], headers=headers) as resp:
85+
if not 200 <= resp.status < 300:
86+
raise StatusRetrievalError(f"Unable to retrieve token at {credential_status["statusListCredential"]}")
87+
88+
token = await resp.read()
89+
90+
return cls.from_jwt(token, credential_status, verifier, min_list_length)
91+
92+
@classmethod
93+
def from_jwt(
94+
cls,
95+
token: bytes | str,
96+
credential_status: dict,
97+
verifier: EnvelopingTokenVerifier | EmbeddingTokenVerifier,
98+
min_list_length: int = MIN_LIST_LENGTH,
99+
) -> "BitstringStatusListVerifier":
78100
"""
79101
Takes a status-list response and a verifier, and ensures that the response matches the
80102
required format, verifying the signature using verifier.
@@ -83,65 +105,81 @@ def verify_jwt(
83105
signature is correct, and raise an exception if not.
84106
85107
Args:
86-
sl_response: REQUIRED. A base64-encoded status_list response, acquired (eg.) from
108+
token: REQUIRED. A base64-encoded status_list response, acquired (eg.) from
87109
establish_connection().
88110
111+
credential_status: REQUIRED. The credentialStatus field of a verifiable credential as
112+
specified in S. 2.1.
113+
89114
verifier: REQUIRED. A callable that verifies the signature of a payload. Must match
90-
the proof format of the sl_response (embedded or enveloping)
115+
the proof format of the token (embedded or enveloping)
91116
92117
min_list_length: OPTIONAL. The minimum list length, recommended to be 131,072 (see S. 6.1)
93118
"""
94-
95-
if b"." in sl_response:
119+
# Check that message is in valid JWT format
120+
if isinstance(token, str):
121+
token = token.encode()
122+
123+
if not token.startswith(b"ey"):
124+
raise ValueError("JWT requested but token is not a JWT")
125+
126+
headers = None
127+
if b"." in token:
96128
# Enveloping proof
97129

98130
# Check that message is in valid JWT format
99-
headers_bytes, payload_bytes, signature = sl_response.split(b".", maxsplit=3)
131+
headers_bytes, payload_bytes, signature = token.split(b".", maxsplit=3)
100132
assert headers_bytes and payload_bytes and signature
101133

102134
# Verify signature. verifier must be of type EnvelopingTokenVerifier
103135
if not verifier(headers_bytes + b"." + payload_bytes, signature):
104136
raise StatusVerificationError("Invalid signature on payload.")
105137

106138
# Extract data
107-
self.headers = json.loads(b64url_decode(headers_bytes))
108-
self.payload = json.loads(b64url_decode(payload_bytes))
139+
headers = json.loads(b64url_decode(headers_bytes))
140+
payload = json.loads(b64url_decode(payload_bytes))
109141
else:
110142
# Embedding proof
111143

112144
# Extract data
113-
self.payload = json.loads(b64url_decode(sl_response))
145+
payload = json.loads(b64url_decode(token))
114146

115147
# Verify signature
116-
unsigned_payload = {key: self.payload[key] for key in self.payload if key != "proof"}
117-
if not verifier(dict_to_b64(unsigned_payload), self.payload["proof"]):
148+
unsigned_payload = {key: payload[key] for key in payload if key != "proof"}
149+
if not verifier(dict_to_b64(unsigned_payload), payload["proof"]):
118150
raise StatusVerificationError("Invalid signature on payload")
119151

120152
# Check values of status list against provided credential
121-
credential_subject = self.payload["credentialSubject"]
122-
if credential_subject["statusPurpose"] != self.credential_status["statusPurpose"]:
153+
credential_subject = payload["credentialSubject"]
154+
if credential_subject["statusPurpose"] != credential_status["statusPurpose"]:
123155
raise StatusVerificationError(
124-
f"statusPurpose in credential is {self.credential_status["statusPurpose"]}, while \
156+
f"statusPurpose in credential is {credential_status["statusPurpose"]}, while \
125157
statusPurpose in status list is {credential_subject["statusPurpose"]}"
126158
)
127159

128160
# If statusPurpose = message, ensure that a statusMessage list exists in the credential
129-
bits = self.credential_status.get("statusSize")
130-
if bits is not None and bits > 1 and self.credential_status.get("statusMessage") is None:
161+
bits = credential_status.get("statusSize")
162+
if bits is not None and bits > 1 and credential_status.get("statusMessage") is None:
131163
raise StatusVerificationError("For statusSize > 1, a message must exist.")
132164

133-
if self.credential_status["statusPurpose"] == "message" and self.credential_status.get("statusMessage") is None:
165+
if credential_status["statusPurpose"] == "message" and credential_status.get("statusMessage") is None:
134166
raise StatusVerificationError("If statusPurpose is `message`, a statusMessage field must \
135167
be included which provides the message associated with each bit.")
136168

137169
# Cache returned status list as BitArray
138-
self._bit_array = BitArray.from_b64(1 if bits is None else bits, credential_subject["encodedList"])
139-
if self._bit_array.size < min_list_length:
170+
bit_array = BitArray.from_b64(1 if bits is None else bits, credential_subject["encodedList"])
171+
if bit_array.size < min_list_length:
140172
raise StatusListLengthError(f"Bitstring status list must be at least {min_list_length} \
141-
bits long, but was {self._bit_array.size} bits long instead.")
173+
bits long, but was {bit_array.size} bits long instead.")
174+
175+
return cls(
176+
credential_status=credential_status,
177+
headers=headers,
178+
payload=payload,
179+
bit_array=bit_array,
180+
)
142181

143182
def get_status(self, idx: Optional[int] = None):
144-
assert self._bit_array is not None, "Before accessing the status, please verify using jwt_verify or cwt_verify"
145183
if idx is None:
146184
idx = int(self.credential_status["statusListIndex"])
147185

src/token_status_list/verifier.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,22 @@ async def retrieve_list(
5656
headers: dict | None = None,
5757
) -> "TokenStatusListVerifier":
5858
"""
59-
Establish connection. Parse and verify response, and create instance of
60-
TokenStatusListVerifier to access it.
59+
Establish connection, parse and verify response, and create instance of
60+
TokenStatusListVerifier to access it.
61+
62+
Args:
63+
status_list_uri: REQUIRED. The uri where the status list can be accessed via HTTP request.
64+
65+
verifier: REQUIRED. A callable that verifies the signature of a payload, equivalent to
66+
signer in sign_jwt() in issuer.py.
67+
68+
encoding: OPTIONAL. Either JWT or CWT. Default is JWT.
69+
70+
headers: OPTIONAL. Additional headers for the HTTP request that is sent to status_list_uri.
71+
72+
Returns:
73+
An instance of TokenStatusListVerifier which has been verified for correctness and
74+
integrity.
6175
"""
6276

6377
headers = headers or {}

tests/test_bitstring_status_list.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,11 @@ def test_verify_jwt_basic_enveloping(status: BitstringStatusListIssuer):
7777
"statusListCredential": "https://example.com/credentials/status/3"
7878
}
7979

80-
verifier = BitstringStatusListVerifier(credential_status)
81-
verifier.verify_jwt(encoded_jwt, verifier=trivial_enveloping_verifier)
80+
verifier = BitstringStatusListVerifier.from_jwt(
81+
token=encoded_jwt,
82+
credential_status=credential_status,
83+
verifier=trivial_embedding_verifier,
84+
)
8285

8386
assert verifier.headers == {"alg": "ES256", "kid": "12"}
8487
assert verifier.payload == {
@@ -115,8 +118,11 @@ def test_verify_jwt_basic_embedding(status: BitstringStatusListIssuer):
115118
"statusListCredential": "https://example.com/credentials/status/3"
116119
}
117120

118-
verifier = BitstringStatusListVerifier(credential_status)
119-
verifier.verify_jwt(encoded_jwt, verifier=trivial_embedding_verifier)
121+
verifier = BitstringStatusListVerifier.from_jwt(
122+
token=encoded_jwt,
123+
credential_status=credential_status,
124+
verifier=trivial_embedding_verifier,
125+
)
120126

121127
assert verifier.payload == {
122128
"@context": [
@@ -178,8 +184,11 @@ def test_status_message():
178184
"statusSize": 2,
179185
}
180186

181-
verifier = BitstringStatusListVerifier(credential_status)
182-
verifier.verify_jwt(encoded_jwt, verifier=trivial_enveloping_verifier)
187+
verifier = BitstringStatusListVerifier.from_jwt(
188+
token=encoded_jwt,
189+
credential_status=credential_status,
190+
verifier=trivial_enveloping_verifier,
191+
)
183192

184193
for i in range(bitstring.status_list.size):
185194
assert verifier.get_status(i) == {
@@ -208,8 +217,11 @@ def test_verify_es256_enveloping(
208217
"statusListCredential": "https://example.com/credentials/status/3"
209218
}
210219

211-
verifier = BitstringStatusListVerifier(credential_status)
212-
verifier.verify_jwt(encoded_jwt, verifier=es256_enveloping_verifier)
220+
verifier = BitstringStatusListVerifier.from_jwt(
221+
token=encoded_jwt,
222+
credential_status=credential_status,
223+
verifier=es256_enveloping_verifier,
224+
)
213225

214226
for i in range(status.status_list.size):
215227
assert verifier.get_status(i) == {
@@ -235,8 +247,11 @@ def test_verify_es256_embedding(
235247
"statusListCredential": "https://example.com/credentials/status/3"
236248
}
237249

238-
verifier = BitstringStatusListVerifier(credential_status)
239-
verifier.verify_jwt(encoded_jwt, verifier=es256_embedding_verifier)
250+
verifier = BitstringStatusListVerifier.from_jwt(
251+
token=encoded_jwt,
252+
credential_status=credential_status,
253+
verifier=es256_embedding_verifier,
254+
)
240255

241256
for i in range(status.status_list.size):
242257
assert verifier.get_status(i) == {

0 commit comments

Comments
 (0)