Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,38 @@ By default, the server will listen on `http://localhost:8000`. You can modify th
- `404 NOT FOUND`: Status list not found
- `406 NOT ACCEPTABLE`: Requested format not supported

### Status List Aggregation

- **Endpoint:** `GET /statuslists/aggregation`
- **Description:** Returns a JSON object containing an array of all status list URIs managed by the server. This allows Relying Parties to discover and cache all available status lists for offline or batch validation.
- **Response Example:**

```json
{
"status_lists": [
"https://example.com/statuslists/1",
"https://example.com/statuslists/2",
"https://example.com/statuslists/3"
]
}
```
- **Content-Type:** `application/json`
- **Authentication:** Not required (public endpoint)

### Status List Token Format Update

- Every status list token (JWT or CWT) now includes an `aggregation_uri` claim, which provides the URI of the aggregation endpoint. Example JWT payload:

```json
{
"sub": "https://example.com/statuslists/1",
"status_list": { "bits": 1, "lst": "..." },
"exp": 2291720170,
"ttl": 43200,
"aggregation_uri": "https://example.com/statuslists/aggregation"
}
```

## Authentication

The server uses JWT-based authentication with the following requirements:
Expand Down
22 changes: 21 additions & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
"lst": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjEyIiwidHlwIjoic3RhdHVzbGlzdCtqd3QifQ..."
},
"exp": 2291720170,
"ttl": 43200
"ttl": 43200,
"aggregation_uri": "https://statuslist.example.com/statuslists/aggregation"
}
```
- **Status List Token (CWT Example)**:
Expand All @@ -72,8 +73,27 @@
646269747301636c73744a78dadbb918000217015d5840251d844ecc6541b8b2fd24
e681836c1a072cad61716fb174d57b162b4b392c1ea08b875a493ca8d1cf4328eee1
b14f33aa899e532844778ba2fff80b5c1e56e5
aggregation_uri: "https://statuslist.example.com/statuslists/aggregation"
```

---

### **Status List Aggregation**

- The Status List Server exposes an aggregation endpoint at `/statuslists/aggregation`.
- This endpoint returns a JSON object with a `status_lists` array containing all status list URIs managed by the server.
- Each status list token (JWT or CWT) includes an `aggregation_uri` claim pointing to this endpoint, enabling Relying Parties to discover and cache all available status lists for offline or batch validation.
- Example response:

```json
{
"status_lists": [
"https://statuslist.example.com/statuslists/1",
"https://statuslist.example.com/statuslists/2"
]
}
```

## **5. Application Design**

This section provides an overview of the **technology stack** and **design principles** used to build the Status List Server.
Expand Down
7 changes: 7 additions & 0 deletions src/database/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ impl SeaOrmStore<StatusListRecord> {
.await
.map_err(|e| RepositoryError::FindError(e.to_string()))
}

pub async fn find_all(&self) -> Result<Vec<StatusListRecord>, RepositoryError> {
status_lists::Entity::find()
.all(&*self.db)
.await
.map_err(|e| RepositoryError::FindError(e.to_string()))
}
}

impl SeaOrmStore<Credentials> {
Expand Down
6 changes: 5 additions & 1 deletion src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ use crate::{
utils::state::AppState,
web::{
auth::auth,
handlers::{credential_handler, get_status_list, publish_status, update_status},
handlers::{
credential_handler, get_status_list, publish_status,
status_list::aggregation::aggregation, update_status,
},
},
};

Expand Down Expand Up @@ -76,5 +79,6 @@ fn status_list_routes(state: AppState) -> Router<AppState> {

Router::new()
.merge(protected_routes)
.route("/aggregation", get(aggregation))
.route("/{list_id}", get(get_status_list))
}
8 changes: 4 additions & 4 deletions src/utils/cert_manager/challenge/dns01.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ impl AwsRoute53DnsUpdater {
change_action: ChangeAction,
value: &str,
) -> Result<String, ChallengeError> {
let record_name = format!("_acme-challenge.{}", domain);
let record_name = format!("_acme-challenge.{domain}");
let hosted_zone_id = self.find_hosted_zone(domain).await?;

// Prepare the TXT record to change
Expand All @@ -195,7 +195,7 @@ impl AwsRoute53DnsUpdater {
.ttl(60)
.resource_records(
ResourceRecord::builder()
.value(format!("\"{}\"", value))
.value(format!("\"{value}\""))
.build()
.map_err(|e| ChallengeError::AwsSdk(e.into()))?,
)
Expand Down Expand Up @@ -279,7 +279,7 @@ impl PebbleDnsUpdater {
#[async_trait]
impl DnsUpdater for PebbleDnsUpdater {
async fn upsert_record(&self, domain: &str, value: &str) -> Result<(), ChallengeError> {
let record_name = format!("_acme-challenge.{}.", domain);
let record_name = format!("_acme-challenge.{domain}.");
let url = format!("{}/set-txt", self.addr);
let body = json!({"host": record_name, "value": value});

Expand All @@ -296,7 +296,7 @@ impl DnsUpdater for PebbleDnsUpdater {
}

async fn remove_record(&self, domain: &str, _value: &str) -> Result<(), ChallengeError> {
let record_name = format!("_acme-challenge.{}.", domain);
let record_name = format!("_acme-challenge.{domain}.");
let url = format!("{}/clear-txt", self.addr);
let body = json!({"host": record_name});

Expand Down
2 changes: 1 addition & 1 deletion src/web/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ mod tests {

let request = Request::builder()
.uri("/test")
.header(header::AUTHORIZATION, format!("Bearer {}", token))
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap();

Expand Down
84 changes: 84 additions & 0 deletions src/web/handlers/status_list/aggregation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use crate::utils::state::AppState;
use crate::web::handlers::status_list::error::StatusListError;
use axum::{extract::State, response::IntoResponse, Json};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct AggregationResponse {
pub status_lists: Vec<String>,
}

pub async fn aggregation(
State(state): State<AppState>,
) -> Result<impl IntoResponse, StatusListError> {
let records = state.status_list_repo.find_all().await.map_err(|e| {
tracing::error!("Failed to fetch all status lists: {:?}", e);
StatusListError::InternalServerError
})?;
let status_lists = records.into_iter().map(|rec| rec.sub).collect();
Ok(Json(AggregationResponse { status_lists }))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{
models::{status_lists, StatusList, StatusListRecord},
test_utils::test_app_state,
};
use axum::{body::to_bytes, http::StatusCode, Router};
use sea_orm::{DatabaseBackend, MockDatabase};
use std::sync::Arc;
use tower::ServiceExt; // for .oneshot()

#[tokio::test]
async fn test_aggregation_returns_all_status_list_uris() {
let status_list1 = StatusListRecord {
list_id: "list1".to_string(),
issuer: "issuer1".to_string(),
status_list: StatusList {
bits: 1,
lst: "foo".to_string(),
},
sub: "https://example.com/statuslists/list1".to_string(),
};
let status_list2 = StatusListRecord {
list_id: "list2".to_string(),
issuer: "issuer2".to_string(),
status_list: StatusList {
bits: 1,
lst: "bar".to_string(),
},
sub: "https://example.com/statuslists/list2".to_string(),
};
let mock_db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results::<status_lists::Model, Vec<_>, _>(vec![vec![
status_list1.clone(),
status_list2.clone(),
]])
.into_connection();
let app_state = test_app_state(Some(Arc::new(mock_db))).await;
let app = Router::new()
.route("/aggregation", axum::routing::get(aggregation))
.with_state(app_state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/aggregation")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let result: AggregationResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(result.status_lists.len(), 2);
assert!(result
.status_lists
.contains(&"https://example.com/statuslists/list1".to_string()));
assert!(result
.status_lists
.contains(&"https://example.com/statuslists/list2".to_string()));
}
}
37 changes: 35 additions & 2 deletions src/web/handlers/status_list/get_status_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ async fn build_response_from_record(
};

let token_bytes = match accept {
ACCEPT_STATUS_LISTS_HEADER_CWT => issue_cwt(status_record, &keypair, certs_parts)?,
_ => issue_jwt(status_record, &keypair, certs_parts)?.into_bytes(),
ACCEPT_STATUS_LISTS_HEADER_CWT => issue_cwt(status_record, &keypair, certs_parts, state)?,
_ => issue_jwt(status_record, &keypair, certs_parts, state)?.into_bytes(),
};

Ok((
Expand All @@ -151,6 +151,7 @@ fn issue_cwt(
status_record: &StatusListRecord,
keypair: &Keypair,
cert_chain: Vec<String>,
state: &AppState,
) -> Result<Vec<u8>, StatusListError> {
let mut claims = vec![];

Expand Down Expand Up @@ -191,6 +192,13 @@ fn issue_cwt(
CborValue::Map(status_list),
));

// Add aggregation_uri claim
let aggregation_uri = format!("https://{}/statuslists/aggregation", state.server_domain);
claims.push((
CborValue::Text("aggregation_uri".into()),
CborValue::Text(aggregation_uri),
));

let payload = CborValue::Map(claims).to_vec().map_err(|err| {
tracing::error!("Failed to serialize claims: {err:?}");
StatusListError::InternalServerError
Expand Down Expand Up @@ -253,23 +261,27 @@ pub struct StatusListToken {
pub status_list: StatusList,
pub sub: String,
pub ttl: Option<i64>,
pub aggregation_uri: Option<String>,
}

fn issue_jwt(
status_record: &StatusListRecord,
keypair: &Keypair,
cert_chain: Vec<String>,
state: &AppState,
) -> Result<String, StatusListError> {
let iat = Utc::now().timestamp();
let ttl = TOKEN_TTL;
let exp = iat + TOKEN_EXP;
let aggregation_uri = format!("https://{}/statuslists/aggregation", state.server_domain);
// Building the claims
let claims = StatusListToken {
exp: Some(exp),
iat,
status_list: status_record.status_list.clone(),
sub: status_record.sub.to_owned(),
ttl: Some(ttl),
aggregation_uri: Some(aggregation_uri),
};
// Building the header
let mut header = Header::new(jsonwebtoken::Algorithm::ES256);
Expand Down Expand Up @@ -382,6 +394,13 @@ mod tests {
token_data.claims.status_list.lst,
encode_compressed(&[0, 0, 0]).unwrap()
);
assert_eq!(
token_data.claims.aggregation_uri,
Some(format!(
"https://{}/statuslists/aggregation",
app_state.server_domain
))
);
}

#[tokio::test]
Expand Down Expand Up @@ -496,6 +515,20 @@ mod tests {
.1
.clone();
assert_eq!(ttl, CborValue::Integer(300.into()));

let aggregation_uri = claims
.iter()
.find(|(k, _)| k == &CborValue::Text("aggregation_uri".to_string()))
.unwrap()
.1
.clone();
assert_eq!(
aggregation_uri,
CborValue::Text(format!(
"https://{}/statuslists/aggregation",
app_state.server_domain
))
);
}

#[tokio::test]
Expand Down
1 change: 1 addition & 0 deletions src/web/handlers/status_list/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::models::StatusEntry;
use serde::Deserialize;

pub mod aggregation;
pub(super) mod constants;
pub(super) mod error;
pub(crate) mod get_status_list;
Expand Down