From b70c92ffe43d7e1b10402e3cebf68716c8c47aec Mon Sep 17 00:00:00 2001 From: Danny Browning Date: Mon, 8 Jul 2024 23:52:31 -0600 Subject: [PATCH] feat: validate read request for private data --- Cargo.lock | 95 ++++++ Cargo.toml | 4 + api-server/api/openapi.yaml | 36 +++ api-server/docs/default_api.md | 14 +- api-server/examples/client/main.rs | 8 +- api-server/examples/server/server.rs | 12 +- api-server/src/client/mod.rs | 48 +++ api-server/src/lib.rs | 29 +- api-server/src/server/mod.rs | 105 ++++++- api/Cargo.toml | 10 + api/ceramic.yaml | 28 ++ api/src/auth.rs | 421 ++++++++++++++++++++++++++ api/src/lib.rs | 1 + api/src/server.rs | 38 ++- api/src/tests.rs | 6 +- event/Cargo.toml | 1 + event/src/lib.rs | 1 + event/src/unvalidated/mod.rs | 3 +- event/src/unvalidated/signed/cacao.rs | 232 +++++++------- 19 files changed, 970 insertions(+), 122 deletions(-) create mode 100644 api/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 73bc28d2a..55b888181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,6 +731,57 @@ dependencies = [ "serde", ] +[[package]] +name = "biscuit-auth" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ef2e2634c5493e5374f70bc45c9c8e5ebb547261973fe72698f6833d4b32ea" +dependencies = [ + "base64 0.13.1", + "biscuit-parser", + "biscuit-quote", + "ed25519-dalek 2.1.1", + "getrandom 0.1.16", + "hex", + "nom", + "prost 0.10.4", + "prost-types 0.10.1", + "rand 0.8.5", + "rand_core 0.6.4", + "regex", + "sha2 0.9.9", + "thiserror", + "time 0.3.36", + "zeroize", +] + +[[package]] +name = "biscuit-parser" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9fd6da963e73f7e6db729c3bd76784863ba405b15acbbb5cb08ebc2adbd3bf" +dependencies = [ + "hex", + "nom", + "proc-macro2", + "quote", + "thiserror", + "time 0.3.36", +] + +[[package]] +name = "biscuit-quote" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe3634b644a8df1434e3e14841e480c4238059c66a94e479b7eff98c2bd3" +dependencies = [ + "biscuit-parser", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -1029,10 +1080,15 @@ version = "0.32.0" dependencies = [ "anyhow", "async-trait", + "base64 0.21.7", + "biscuit-auth", "ceramic-api-server", "ceramic-core", "ceramic-event", "ceramic-metadata", + "chrono", + "did-method-key", + "did-pkh", "expect-test", "futures", "hex", @@ -1041,9 +1097,14 @@ dependencies = [ "jemalloc_pprof", "mockall", "multibase 0.9.1", + "once_cell", "recon", + "regex", "serde", "serde_ipld_dagcbor", + "serde_json", + "ssi", + "ssi-dids", "swagger", "test-log", "tikv-jemalloc-ctl", @@ -1117,6 +1178,7 @@ dependencies = [ "anyhow", "base64 0.21.7", "ceramic-core", + "chrono", "cid 0.11.1", "expect-test", "ipld-core", @@ -6260,6 +6322,16 @@ dependencies = [ "syn 2.0.65", ] +[[package]] +name = "prost" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71adf41db68aa0daaefc69bb30bcd68ded9b9abaad5d1fbb6304c4fb390e083e" +dependencies = [ + "bytes 1.6.0", + "prost-derive 0.10.1", +] + [[package]] name = "prost" version = "0.11.9" @@ -6302,6 +6374,19 @@ dependencies = [ "which", ] +[[package]] +name = "prost-derive" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b670f45da57fb8542ebdbb6105a925fe571b67f9e7ed9f47a06a84e72b4e7cc" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "prost-derive" version = "0.11.9" @@ -6328,6 +6413,16 @@ dependencies = [ "syn 2.0.65", ] +[[package]] +name = "prost-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0a014229361011dc8e69c8a1ec6c2e8d0f2af7c91e3ea3f5b2170298461e68" +dependencies = [ + "bytes 1.6.0", + "prost 0.10.4", +] + [[package]] name = "prost-types" version = "0.11.9" diff --git a/Cargo.toml b/Cargo.toml index f1e582931..6616b4537 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ axum = "0.6" backoff = "0.4" base64 = "0.21" bincode = "1.3.3" +biscuit-auth = "4.1.1" bs58 = "0.4" bytecheck = "0.6.7" bytes = "1.1" @@ -72,6 +73,8 @@ dag-jose = "0.2" deadqueue = "0.2.3" derivative = "2.2" derive_more = "0.99.17" +did-pkh = "0.2.1" +did-method-key = "0.2.2" dirs-next = "2" expect-test = "1.4.1" fastmurmur3 = "0.1.2" @@ -154,6 +157,7 @@ smallvec = "1.10" sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio", "chrono"] } ssh-key = { version = "0.5.1", default-features = false } ssi = { version = "0.7", features = ["ed25519"] } +ssi-dids = "0.1.1" swagger = { version = "6.1", features = [ "serdejson", "server", diff --git a/api-server/api/openapi.yaml b/api-server/api/openapi.yaml index 7d5c16768..4fd3e4783 100644 --- a/api-server/api/openapi.yaml +++ b/api-server/api/openapi.yaml @@ -119,6 +119,22 @@ paths: /events/{event_id}: get: parameters: + - description: Bearer token + explode: false + in: header + name: Authorization + required: false + schema: + type: string + style: simple + - description: "URI to filter events against, such as ceramic://* or ceramic://*?model=base64url" + explode: true + in: query + name: resource + required: false + schema: + type: string + style: form - description: "CID of the root block of the event, used to identify of the\ \ event" explode: false @@ -141,6 +157,8 @@ paths: schema: $ref: '#/components/schemas/BadRequestResponse' description: bad request + "401": + description: Unauthorized "404": content: text/plain: @@ -430,6 +448,22 @@ paths: /feed/events: get: parameters: + - description: Bearer token + explode: false + in: header + name: Authorization + required: false + schema: + type: string + style: simple + - description: "URI to filter events against, such as ceramic://* or ceramic://*?model=base64url" + explode: true + in: query + name: resource + required: false + schema: + type: string + style: form - description: "token that designates the point to resume from, that is find\ \ keys added after this point" explode: true @@ -475,6 +509,8 @@ paths: schema: $ref: '#/components/schemas/BadRequestResponse' description: bad request + "401": + description: Unauthorized "500": content: application/json: diff --git a/api-server/docs/default_api.md b/api-server/docs/default_api.md index 27184a838..c46a580d7 100644 --- a/api-server/docs/default_api.md +++ b/api-server/docs/default_api.md @@ -120,7 +120,7 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **** -> models::Event (event_id) +> models::Event (event_id, optional) Get event data ### Required Parameters @@ -128,6 +128,16 @@ Get event data Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **event_id** | **String**| CID of the root block of the event, used to identify of the event | + **optional** | **map[string]interface{}** | optional parameters | nil if no parameters + +### Optional Parameters +Optional parameters are passed through a map[string]interface{}. + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **event_id** | **String**| CID of the root block of the event, used to identify of the event | + **authorization** | **String**| Bearer token | + **resource** | **String**| URI to filter events against, such as ceramic://_* or ceramic://_*?model=base64url | ### Return type @@ -360,6 +370,8 @@ Optional parameters are passed through a map[string]interface{}. Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- + **authorization** | **String**| Bearer token | + **resource** | **String**| URI to filter events against, such as ceramic://_* or ceramic://_*?model=base64url | **resume_at** | **String**| token that designates the point to resume from, that is find keys added after this point | **limit** | **i32**| The maximum number of events to return, default is 100. The max with data is 10000. | **include_data** | **String**| Whether to include the event data (carfile) in the response. In the future, only the payload or other options may be supported: * `none` - Empty, only the event ID is returned * `full` - The entire event carfile (including the envelope and payload) | diff --git a/api-server/examples/client/main.rs b/api-server/examples/client/main.rs index e7fde6097..5448a67f8 100644 --- a/api-server/examples/client/main.rs +++ b/api-server/examples/client/main.rs @@ -151,7 +151,11 @@ fn main() { ); } Some("EventsEventIdGet") => { - let result = rt.block_on(client.events_event_id_get("event_id_example".to_string())); + let result = rt.block_on(client.events_event_id_get( + "event_id_example".to_string(), + Some("authorization_example".to_string()), + Some("resource_example".to_string()), + )); info!( "{:?} (X-Span-ID: {:?})", result, @@ -230,6 +234,8 @@ fn main() { } Some("FeedEventsGet") => { let result = rt.block_on(client.feed_events_get( + Some("authorization_example".to_string()), + Some("resource_example".to_string()), Some("resume_at_example".to_string()), Some(56), Some("include_data_example".to_string()), diff --git a/api-server/examples/server/server.rs b/api-server/examples/server/server.rs index 780a55d4b..593469bed 100644 --- a/api-server/examples/server/server.rs +++ b/api-server/examples/server/server.rs @@ -162,11 +162,15 @@ where async fn events_event_id_get( &self, event_id: String, + authorization: Option, + resource: Option, context: &C, ) -> Result { info!( - "events_event_id_get(\"{}\") - X-Span-ID: {:?}", + "events_event_id_get(\"{}\", {:?}, {:?}) - X-Span-ID: {:?}", event_id, + authorization, + resource, context.get().0.clone() ); Err(ApiError("Generic failure".into())) @@ -271,13 +275,17 @@ where /// Get all new event keys since resume token async fn feed_events_get( &self, + authorization: Option, + resource: Option, resume_at: Option, limit: Option, include_data: Option, context: &C, ) -> Result { info!( - "feed_events_get({:?}, {:?}, {:?}) - X-Span-ID: {:?}", + "feed_events_get({:?}, {:?}, {:?}, {:?}, {:?}) - X-Span-ID: {:?}", + authorization, + resource, resume_at, limit, include_data, diff --git a/api-server/src/client/mod.rs b/api-server/src/client/mod.rs index f9182e5dd..01a8a4ad4 100644 --- a/api-server/src/client/mod.rs +++ b/api-server/src/client/mod.rs @@ -722,6 +722,8 @@ where async fn events_event_id_get( &self, param_event_id: String, + param_authorization: Option, + param_resource: Option, context: &C, ) -> Result { let mut client_service = self.client_service.clone(); @@ -734,6 +736,9 @@ where // Query parameters let query_string = { let mut query_string = form_urlencoded::Serializer::new("".to_owned()); + if let Some(param_resource) = param_resource { + query_string.append_pair("resource", ¶m_resource); + } query_string.finish() }; if !query_string.is_empty() { @@ -769,6 +774,24 @@ where }, ); + // Header parameters + #[allow(clippy::single_match)] + match param_authorization { + Some(param_authorization) => { + request.headers_mut().append( + HeaderName::from_static("authorization"), + #[allow(clippy::redundant_clone)] + match header::IntoHeaderValue(param_authorization.clone()).try_into() { + Ok(header) => header, + Err(e) => { + return Err(ApiError(format!("Invalid header authorization - {}", e))); + } + }, + ); + } + None => {} + } + let response = client_service .call((request, context.clone())) .map_err(|e| ApiError(format!("No response received: {}", e))) @@ -802,6 +825,7 @@ where })?; Ok(EventsEventIdGetResponse::BadRequest(body)) } + 401 => Ok(EventsEventIdGetResponse::Unauthorized), 404 => { let body = response.into_body(); let body = body @@ -1507,6 +1531,8 @@ where async fn feed_events_get( &self, + param_authorization: Option, + param_resource: Option, param_resume_at: Option, param_limit: Option, param_include_data: Option, @@ -1518,6 +1544,9 @@ where // Query parameters let query_string = { let mut query_string = form_urlencoded::Serializer::new("".to_owned()); + if let Some(param_resource) = param_resource { + query_string.append_pair("resource", ¶m_resource); + } if let Some(param_resume_at) = param_resume_at { query_string.append_pair("resumeAt", ¶m_resume_at); } @@ -1562,6 +1591,24 @@ where }, ); + // Header parameters + #[allow(clippy::single_match)] + match param_authorization { + Some(param_authorization) => { + request.headers_mut().append( + HeaderName::from_static("authorization"), + #[allow(clippy::redundant_clone)] + match header::IntoHeaderValue(param_authorization.clone()).try_into() { + Ok(header) => header, + Err(e) => { + return Err(ApiError(format!("Invalid header authorization - {}", e))); + } + }, + ); + } + None => {} + } + let response = client_service .call((request, context.clone())) .map_err(|e| ApiError(format!("No response received: {}", e))) @@ -1595,6 +1642,7 @@ where })?; Ok(FeedEventsGetResponse::BadRequest(body)) } + 401 => Ok(FeedEventsGetResponse::Unauthorized), 500 => { let body = response.into_body(); let body = body diff --git a/api-server/src/lib.rs b/api-server/src/lib.rs index 662ae920e..eb549cf99 100644 --- a/api-server/src/lib.rs +++ b/api-server/src/lib.rs @@ -59,6 +59,8 @@ pub enum EventsEventIdGetResponse { Success(models::Event), /// bad request BadRequest(models::BadRequestResponse), + /// Unauthorized + Unauthorized, /// Event not found EventNotFound(String), /// Internal server error @@ -129,6 +131,8 @@ pub enum FeedEventsGetResponse { Success(models::EventFeed), /// bad request BadRequest(models::BadRequestResponse), + /// Unauthorized + Unauthorized, /// Internal server error InternalServerError(models::ErrorResponse), } @@ -259,6 +263,8 @@ pub trait Api { async fn events_event_id_get( &self, event_id: String, + authorization: Option, + resource: Option, context: &C, ) -> Result; @@ -316,6 +322,8 @@ pub trait Api { /// Get all new event keys since resume token async fn feed_events_get( &self, + authorization: Option, + resource: Option, resume_at: Option, limit: Option, include_data: Option, @@ -409,6 +417,8 @@ pub trait ApiNoContext { async fn events_event_id_get( &self, event_id: String, + authorization: Option, + resource: Option, ) -> Result; /// cors @@ -459,6 +469,8 @@ pub trait ApiNoContext { /// Get all new event keys since resume token async fn feed_events_get( &self, + authorization: Option, + resource: Option, resume_at: Option, limit: Option, include_data: Option, @@ -567,9 +579,13 @@ impl + Send + Sync, C: Clone + Send + Sync> ApiNoContext for Contex async fn events_event_id_get( &self, event_id: String, + authorization: Option, + resource: Option, ) -> Result { let context = self.context().clone(); - self.api().events_event_id_get(event_id, &context).await + self.api() + .events_event_id_get(event_id, authorization, resource, &context) + .await } /// cors @@ -651,13 +667,22 @@ impl + Send + Sync, C: Clone + Send + Sync> ApiNoContext for Contex /// Get all new event keys since resume token async fn feed_events_get( &self, + authorization: Option, + resource: Option, resume_at: Option, limit: Option, include_data: Option, ) -> Result { let context = self.context().clone(); self.api() - .feed_events_get(resume_at, limit, include_data, &context) + .feed_events_get( + authorization, + resource, + resume_at, + limit, + include_data, + &context, + ) .await } diff --git a/api-server/src/server/mod.rs b/api-server/src/server/mod.rs index 1d7231563..057d19063 100644 --- a/api-server/src/server/mod.rs +++ b/api-server/src/server/mod.rs @@ -396,7 +396,56 @@ where .expect("Unable to create Bad Request response for invalid percent decode")) }; - let result = api_impl.events_event_id_get(param_event_id, &context).await; + // Header parameters + let param_authorization = headers.get(HeaderName::from_static("authorization")); + + let param_authorization = match param_authorization { + Some(v) => { + match header::IntoHeaderValue::::try_from((*v).clone()) { + Ok(result) => Some(result.0), + Err(err) => { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Invalid header Authorization - {}", err))) + .expect("Unable to create Bad Request response for invalid header Authorization")); + } + } + } + None => None, + }; + + // Query parameters (note that non-required or collection query parameters will ignore garbage values, rather than causing a 400 response) + let query_params = + form_urlencoded::parse(uri.query().unwrap_or_default().as_bytes()) + .collect::>(); + let param_resource = query_params + .iter() + .filter(|e| e.0 == "resource") + .map(|e| e.1.clone()) + .next(); + let param_resource = match param_resource { + Some(param_resource) => { + let param_resource = + ::from_str(¶m_resource); + match param_resource { + Ok(param_resource) => Some(param_resource), + Err(e) => return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Couldn't parse query parameter resource - doesn't match schema: {}", e))) + .expect("Unable to create Bad Request response for invalid query parameter resource")), + } + } + None => None, + }; + + let result = api_impl + .events_event_id_get( + param_event_id, + param_authorization, + param_resource, + &context, + ) + .await; let mut response = Response::new(Body::empty()); response.headers_mut().insert( HeaderName::from_static("x-span-id"), @@ -434,6 +483,10 @@ where .expect("impossible to fail to serialize"); *response.body_mut() = Body::from(body_content); } + EventsEventIdGetResponse::Unauthorized => { + *response.status_mut() = StatusCode::from_u16(401) + .expect("Unable to turn 401 into a StatusCode"); + } EventsEventIdGetResponse::EventNotFound(body) => { *response.status_mut() = StatusCode::from_u16(404) .expect("Unable to turn 404 into a StatusCode"); @@ -1074,10 +1127,47 @@ where // FeedEventsGet - GET /feed/events hyper::Method::GET if path.matched(paths::ID_FEED_EVENTS) => { + // Header parameters + let param_authorization = headers.get(HeaderName::from_static("authorization")); + + let param_authorization = match param_authorization { + Some(v) => { + match header::IntoHeaderValue::::try_from((*v).clone()) { + Ok(result) => Some(result.0), + Err(err) => { + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Invalid header Authorization - {}", err))) + .expect("Unable to create Bad Request response for invalid header Authorization")); + } + } + } + None => None, + }; + // Query parameters (note that non-required or collection query parameters will ignore garbage values, rather than causing a 400 response) let query_params = form_urlencoded::parse(uri.query().unwrap_or_default().as_bytes()) .collect::>(); + let param_resource = query_params + .iter() + .filter(|e| e.0 == "resource") + .map(|e| e.1.clone()) + .next(); + let param_resource = match param_resource { + Some(param_resource) => { + let param_resource = + ::from_str(¶m_resource); + match param_resource { + Ok(param_resource) => Some(param_resource), + Err(e) => return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Couldn't parse query parameter resource - doesn't match schema: {}", e))) + .expect("Unable to create Bad Request response for invalid query parameter resource")), + } + } + None => None, + }; let param_resume_at = query_params .iter() .filter(|e| e.0 == "resumeAt") @@ -1136,7 +1226,14 @@ where }; let result = api_impl - .feed_events_get(param_resume_at, param_limit, param_include_data, &context) + .feed_events_get( + param_authorization, + param_resource, + param_resume_at, + param_limit, + param_include_data, + &context, + ) .await; let mut response = Response::new(Body::empty()); response.headers_mut().insert( @@ -1175,6 +1272,10 @@ where .expect("impossible to fail to serialize"); *response.body_mut() = Body::from(body_content); } + FeedEventsGetResponse::Unauthorized => { + *response.status_mut() = StatusCode::from_u16(401) + .expect("Unable to turn 401 into a StatusCode"); + } FeedEventsGetResponse::InternalServerError(body) => { *response.status_mut() = StatusCode::from_u16(500) .expect("Unable to turn 500 into a StatusCode"); diff --git a/api/Cargo.toml b/api/Cargo.toml index fa711c6cf..7cdbee969 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -11,17 +11,27 @@ publish = false [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true +biscuit-auth.workspace = true ceramic-api-server.workspace = true ceramic-core.workspace = true ceramic-event.workspace = true ceramic-metadata.workspace = true +chrono.workspace = true +did-method-key.workspace = true +did-pkh.workspace = true futures.workspace = true ipld-core.workspace = true iroh-car.workspace = true multibase.workspace = true +once_cell.workspace = true recon.workspace = true +regex.workspace = true serde.workspace = true +serde_json.workspace = true serde_ipld_dagcbor.workspace = true +ssi.workspace = true +ssi-dids.workspace = true swagger.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/api/ceramic.yaml b/api/ceramic.yaml index ada0157d4..3c4a11033 100644 --- a/api/ceramic.yaml +++ b/api/ceramic.yaml @@ -137,6 +137,18 @@ paths: get: summary: Get event data parameters: + - name: Authorization + in: header + description: Bearer token + required: false + schema: + type: string + - name: resource + in: query + description: URI to filter events against, such as ceramic://* or ceramic://*?model=base64url + schema: + type: string + required: false - name: event_id in: path description: CID of the root block of the event, used to identify of the event @@ -156,6 +168,8 @@ paths: application/json: schema: $ref: "#/components/schemas/BadRequestResponse" + "401": + description: Unauthorized "404": description: Event not found content: @@ -397,6 +411,18 @@ paths: get: summary: Get all new event keys since resume token parameters: + - name: Authorization + in: header + description: Bearer token + required: false + schema: + type: string + - name: resource + in: query + description: URI to filter events against, such as ceramic://* or ceramic://*?model=base64url + schema: + type: string + required: false - name: resumeAt in: query description: token that designates the point to resume from, that is find keys added after this point @@ -433,6 +459,8 @@ paths: application/json: schema: $ref: "#/components/schemas/BadRequestResponse" + "401": + description: Unauthorized "500": description: Internal server error content: diff --git a/api/src/auth.rs b/api/src/auth.rs new file mode 100644 index 000000000..d18d0537d --- /dev/null +++ b/api/src/auth.rs @@ -0,0 +1,421 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use biscuit_auth::macros::*; +use biscuit_auth::{Biscuit, KeyPair}; +use ceramic_core::ssi::did::DIDMethods; +use ceramic_core::ssi::did_resolve::{DIDResolver, ResolutionInputMetadata}; +use ceramic_core::{Cid, Jwk}; +use ceramic_event::unvalidated::signed::cacao::{Capability, MetadataValue, SignatureMetadata}; +use futures::StreamExt; +use iroh_car::CarReader; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::ser::SerializeMap; +use serde::Serialize; +use ssi::jws::verify_bytes_warnable; +use std::collections::HashMap; +use std::io::Cursor; +use std::str::FromStr; + +static PREV_REGEX: Lazy = Lazy::new(|| Regex::new(r#"prev:(.+)"#).unwrap()); +static BISCUIT_REGEX: Lazy = Lazy::new(|| Regex::new(r#"biscuit:(.+)"#).unwrap()); + +#[derive(Clone, Debug)] +pub enum Operation { + Read, + #[allow(dead_code)] + Write, +} + +impl std::fmt::Display for Operation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => write!(f, "read"), + Self::Write => write!(f, "write"), + } + } +} + +fn authenticate_biscuit( + biscuit: &Biscuit, + operation: &Operation, + resource: &str, + _allowed_resources: &[String], +) -> Result<(), String> { + let mut auth = authorizer!( + r#" + operation({operation}); + resource({resource}); + + is_allowed($user, $res) <- + user($user), + resource($res), + right($user, $res); + + allow if is_allowed($user, $resource); + "#, + operation = operation.to_string(), + resource = resource, + ); + auth.set_time(); + auth.add_token(biscuit) + .map_err(|e| format!("Failed to authorize biscuit: {e}"))?; + auth.authorize() + .map_err(|e| format!("Failed to authorize: {e}"))?; + Ok(()) +} + +struct Capabilities { + biscuit: Option>, + resources: Vec, +} + +struct Authentication { + root: Cid, + capabilities: HashMap, +} + +static DID_METHODS: Lazy = Lazy::new(|| { + let mut m = DIDMethods::default(); + m.insert(Box::new(did_method_key::DIDKey)); + m.insert(Box::new(did_pkh::DIDPKH)); + m +}); + +#[derive(Debug)] +struct Sorted<'a> { + header_data: Vec<(&'a str, &'a MetadataValue)>, + alg: MetadataValue, + kid: MetadataValue, +} + +impl<'a> Sorted<'a> { + fn new(metadata: &'a SignatureMetadata) -> Self { + let header_data: Vec<_> = metadata.rest.iter().map(|(k, v)| (k.as_str(), v)).collect(); + Self { + header_data, + alg: MetadataValue::String(metadata.alg.clone()), + kid: MetadataValue::String(metadata.kid.clone()), + } + } +} + +impl<'a> Serialize for Sorted<'a> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let mut header_data: Vec<_> = self + .header_data + .iter() + .map(|(k, v)| (*k, *v)) + .chain(vec![("alg", &self.alg), ("kid", &self.kid)]) + .collect(); + header_data.sort_by(|a, b| a.0.cmp(b.0)); + let mut s = serializer.serialize_map(Some(header_data.len()))?; + for (k, v) in &header_data { + s.serialize_entry(k, v)?; + } + s.end() + } +} + +async fn verify_capability(cacao: &Capability) -> Result<(), String> { + let did = if let Some((did, _)) = cacao.signature.metadata.kid.split_once('#') { + did + } else { + &cacao.signature.metadata.kid + }; + let meta = ResolutionInputMetadata::default(); + let (meta, opt_doc, _opt_doc_meta) = DID_METHODS.resolve(did, &meta).await; + if let Some(s) = meta.error { + return Err(s); + } + let doc = opt_doc.ok_or_else(|| format!("No document found for {did}"))?; + let jwk = Jwk::new(&doc).await.map_err(|e| e.to_string())?; + + //reconstruct header and payload + let payload = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&cacao.payload) + .map_err(|e| e.to_string())? + .as_slice(), + ); + let header = Sorted::new(&cacao.signature.metadata); + let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).map_err(|e| e.to_string())?); + let input = format!("{header}.{payload}"); + let sig = URL_SAFE_NO_PAD + .decode(cacao.signature.signature.as_bytes()) + .map_err(|_| "Invalid signature")?; + if let Err(e) = verify_bytes_warnable( + cacao.signature.r#type.algorithm(), + input.as_bytes(), + &jwk, + &sig, + ) { + tracing::warn!( + "Validation failed: {}\n Sig={}", + e, + cacao.signature.signature + ); + } + + Ok(()) +} + +async fn read_authentication(data: &str) -> Result { + let car_data = URL_SAFE_NO_PAD + .decode(data.as_bytes()) + .map_err(|_| format!("Data was not Base64 URL: {data}"))?; + let reader = Cursor::new(&car_data); + let car_reader = CarReader::new(reader) + .await + .map_err(|e| format!("Failed to read CAR: {e}"))?; + + let root = *car_reader + .header() + .roots() + .first() + .ok_or_else(|| "No roots present".to_string())?; + let mut blocks = Box::pin(car_reader.stream()); + let mut cacaos: HashMap = HashMap::default(); + while let Some(block) = blocks.next().await { + let (cid, data) = block.map_err(|e| format!("Failed to read block from CAR: {e}"))?; + match serde_ipld_dagcbor::from_slice::(&data) { + Ok(cacao) => { + verify_capability(&cacao).await?; + cacaos.insert(cid, cacao); + } + Err(e) => { + tracing::trace!("Failed to decode data for {cid}: {e}"); + } + } + } + let blocks = cacaos.iter().map(|(cid, sig)| { + let mut resources = vec![]; + let mut prev = vec![]; + let mut biscuit = None; + let sig_resources = if let Some(res) = &sig.payload.resources { + res + } else { + &vec![] + }; + for res in sig_resources { + if let Some(res) = PREV_REGEX.captures_iter(res).next() { + let (_, [cid]) = res.extract(); + let cid = Cid::from_str(cid).map_err(|_| format!("Invalid previous CID: {cid}"))?; + if !cacaos.contains_key(&cid) { + //return Err(format!("No signature found for {cid}")); + tracing::warn!("No signature found for {cid}"); + } + prev.push(cid); + } else if let Some(res) = BISCUIT_REGEX.captures_iter(res).next() { + let (_, [biscuit_data]) = res.extract(); + let biscuit_data = URL_SAFE_NO_PAD + .decode(biscuit_data) + .map_err(|_| format!("Invalid biscuit: {biscuit_data}"))?; + + tracing::trace!("Biscuit: {}", String::from_utf8_lossy(&biscuit_data)); + + if biscuit.is_none() { + biscuit = Some(biscuit_data); + } else { + return Err("Multiple biscuits specified".to_string()); + } + } else { + resources.push(res.clone()); + } + } + Ok((*cid, Capabilities { resources, biscuit })) + }); + let blocks: Result<_, String> = blocks.collect(); + Ok(Authentication { + root, + capabilities: blocks?, + }) +} + +pub async fn authenticate(data: &str, operation: Operation, resource: &str) -> Result<(), String> { + let auth = read_authentication(data).await?; + + let mut validated_root = false; + for (cid, cap) in auth.capabilities.iter() { + if let Some(biscuit) = &cap.biscuit { + let mut bldr = biscuit_auth::builder::BiscuitBuilder::new(); + bldr.add_code(String::from_utf8_lossy(biscuit)) + .map_err(|e| format!("Invalid code: {e}"))?; + let kp = KeyPair::new(); + let biscuit = bldr + .build(&kp) + .map_err(|e| format!("Invalid biscuit: {e}"))?; + authenticate_biscuit(&biscuit, &operation, resource, &cap.resources)?; + } + if cid == &auth.root { + validated_root = true; + } + } + + if validated_root { + tracing::debug!("Token validated for {}", auth.root); + Ok(()) + } else { + Err("Failed to validate CAR".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ceramic_core::{DidDocument, StreamId}; + use ceramic_event::cid_from_dag_cbor; + use ceramic_event::unvalidated::signed::cacao::{ + Header, HeaderType, Payload, Signature, SignatureType, + }; + use ceramic_event::unvalidated::signed::{JwkSigner, Signer}; + use iroh_car::{CarHeader, CarHeaderV1, CarWriter}; + use ssi::jwk::Params; + use ssi_dids::{DIDMethod, DocumentBuilder, Source}; + use std::time::Duration; + + #[tokio::test] + async fn should_authenticate_biscuit() { + let now = chrono::Utc::now(); + let expiry = now + Duration::from_secs(60); + let mut bldr = biscuit_auth::builder::BiscuitBuilder::new(); + bldr.add_code(&format!( + r#" + user("did:pkh:eip155:1:0xfa3F54AE9C4287CA09a486dfaFaCe7d1d4095d93"); + right("did:pkh:eip155:1:0xfa3F54AE9C4287CA09a486dfaFaCe7d1d4095d93", "ceramic://*?model=kjzl6hvfrbw6cadyci5lvsff4jxl1idffrp2ld3i0k1znz0b3k67abkmtf7p7q3"); + check if time($time), $time < {}; + "#, + expiry.to_rfc3339() + )).unwrap(); + let kp = KeyPair::new(); + let biscuit = bldr.build(&kp).unwrap(); + + authenticate_biscuit( + &biscuit, + &Operation::Read, + "ceramic://*?model=kjzl6hvfrbw6cadyci5lvsff4jxl1idffrp2ld3i0k1znz0b3k67abkmtf7p7q3", + &vec![], + ) + .unwrap() + } + + #[tokio::test] + async fn should_authenticate() { + let header = r#"Y6Jlcm9vdHOC2CpYJQABcRIgCrnpZxbxpRk4zP9p18ohYhLqnaBWELv-hNX7vpsEbi3YKlglAAFxEiATGjqMmNzLPBmtmlSsL4czjuBbdgYZHQ6lE4nwK544d2d2ZXJzaW9uAdkEAXESIHgQFit07BJc13FxaE03BZkURgb47hnQc7bIjR7RvV7Xo2FooWF0Z2VpcDQzNjFhcKljYXVkeDlkaWQ6a2V5OnpEbmFlaXp0ZkJqMm5lTmpDMnpTQ0hxbVF0QVBWczE4Y3JOelA0V3pKdENVUFc0UDVjZXhweBgyMDI0LTA3LTE1VDA3OjU5OjM0LjEwNlpjaWF0eBgyMDI0LTA3LTA4VDA3OjU5OjM0LjEwNlpjaXNzeD1kaWQ6cGtoOmVpcDE1NToxMzc6MHg0MzllNjZkYjViODViMDY1Yjk2MmRiMGIzYjIxZTYwNzY3NGMxZDBiZW5vbmNlallXTmNHODNKWjdmZG9tYWluaWxvY2FsaG9zdGd2ZXJzaW9uYTFpcmVzb3VyY2VzgXhRY2VyYW1pYzovLyo_bW9kZWw9a2p6bDZodmZyYnc2Y2FkeWNpNWx2c2ZmNGp4bDFpZGZmcnAybGQzaTBrMXpuejBiM2s2N2Fia210ZjdwN3EzaXN0YXRlbWVudHg8R2l2ZSB0aGlzIGFwcGxpY2F0aW9uIGFjY2VzcyB0byBzb21lIG9mIHlvdXIgZGF0YSBvbiBDZXJhbWljYXOiYXN4hDB4ZjliNjhkNGYzYTBkMDdkYTE5YzZkZmM0MzA5YTQ3YmE1NmQ5MGRlY2QyNTI2ZGE3OGJmZWFjODM2OTExZDMwMTVhZjIwYTFmMDEwYWVmYmEwNmQ2NTdhZDdmMmM5ZGEwY2FhZjJlNmI4NGY4MTE1NDc5NTdkZmRiYjBmMWZmZDMxY2F0ZmVpcDE5MccJAXESIAq56WcW8aUZOMz_adfKIWIS6p2gVhC7_oTV-76bBG4to2FooWF0Z2NhaXAxMjJhcKljYXVkeDtkaWQ6cGtoOmVpcDE1NToxOjB4ZmEzRjU0QUU5QzQyODdDQTA5YTQ4NmRmYUZhQ2U3ZDFkNDA5NWQ5M2NleHB4GDIwMjQtMDgtMTBUMTc6MDY6NTkuOTcyWmNpYXR4GDIwMjQtMDctMDhUMDc6NTk6MzQuMTA2WmNpc3N4OWRpZDprZXk6ekRuYWVpenRmQmoybmVOakMyelNDSHFtUXRBUFZzMThjck56UDRXekp0Q1VQVzRQNWVub25jZWpZV05jRzgzSlo3ZmRvbWFpbmlsb2NhbGhvc3RndmVyc2lvbmExaXJlc291cmNlc4N4UWNlcmFtaWM6Ly8qP21vZGVsPWtqemw2aHZmcmJ3NmNhZHljaTVsdnNmZjRqeGwxaWRmZnJwMmxkM2kwazF6bnowYjNrNjdhYmttdGY3cDdxM3hAcHJldjpiYWZ5cmVpZHljYWxjdzVobWNqb25vNGxybmJndG9ibXpjcmRhbjZob2RoaWhobndpcnVwbmRwazYyNHkBlmJpc2N1aXQ6THk4Z2JtOGdjbTl2ZENCclpYa2dhV1FnYzJWMENuVnpaWElvSW1ScFpEcHdhMmc2Wldsd01UVTFPakU2TUhobVlUTkdOVFJCUlRsRE5ESTROME5CTURsaE5EZzJaR1poUm1GRFpUZGtNV1EwTURrMVpEa3pJaWs3Q25KcFoyaDBLQ0prYVdRNmNHdG9PbVZwY0RFMU5Ub3hPakI0Wm1FelJqVTBRVVU1UXpReU9EZERRVEE1WVRRNE5tUm1ZVVpoUTJVM1pERmtOREE1TldRNU15SXNJQ0pqWlhKaGJXbGpPaTh2S2o5dGIyUmxiRDFyYW5wc05taDJabkppZHpaallXUjVZMmsxYkhaelptWTBhbmhzTVdsa1ptWnljREpzWkROcE1Hc3hlbTU2TUdJemF6WTNZV0pyYlhSbU4zQTNjVE1pS1RzS1kyaGxZMnNnYVdZZ2RHbHRaU2drZEdsdFpTa3NJQ1IwYVcxbElEd2dNakF5TkMwd09DMHhNRlF4Tnpvd05qbzFPVm83Q2dpc3RhdGVtZW50eDxHaXZlIHRoaXMgYXBwbGljYXRpb24gYWNjZXNzIHRvIHNvbWUgb2YgeW91ciBkYXRhIG9uIENlcmFtaWNhc6NhbaNjYWxnZUVTMjU2Y2NhcHhCaXBmczovL2JhZnlyZWlkeWNhbGN3NWhtY2pvbm80bHJuYmd0b2JtemNyZGFuNmhvZGhpaGhud2lydXBuZHBrNjI0Y2tpZHhrZGlkOmtleTp6RG5hZWl6dGZCajJuZU5qQzJ6U0NIcW1RdEFQVnMxOGNyTnpQNFd6SnRDVVBXNFA1I3pEbmFlaXp0ZkJqMm5lTmpDMnpTQ0hxbVF0QVBWczE4Y3JOelA0V3pKdENVUFc0UDVhc3hWa1dsUUoxRDYta2pxWHpZQTZsSE1SN0VoT1paZFM3QTJkUUlIZHFPV0NxZEhEWGc2QTJvekF2RTVLWC1kOFRlMmV5VzNMR016REdzSmMtX2VObDRDNEFhdGNqd3PXBAFxEiARavXbIEU6OeTVxMhWIPI_Kvg_6WZAZBiAECpS6JZ4MqNhaKFhdGdlaXA0MzYxYXCpY2F1ZHg5ZGlkOmtleTp6RG5hZXdHdUtmMjVFNlFEaGgxcUFqSnl2d0dSWG5VSkgyc0hvVnJlS1huUmt0QldmY2V4cHgYMjAyNC0wNy0xOFQxNzowNjo0NC43NjJaY2lhdHgYMjAyNC0wNy0xMVQxNzowNjo0NC43NjJaY2lzc3g7ZGlkOnBraDplaXAxNTU6MToweGZhM2Y1NGFlOWM0Mjg3Y2EwOWE0ODZkZmFmYWNlN2QxZDQwOTVkOTNlbm9uY2VqOWVHNW1FY09XdWZkb21haW5pbG9jYWxob3N0Z3ZlcnNpb25hMWlyZXNvdXJjZXOBeFFjZXJhbWljOi8vKj9tb2RlbD1ranpsNmh2ZnJidzZjYWR5Y2k1bHZzZmY0anhsMWlkZmZycDJsZDNpMGsxem56MGIzazY3YWJrbXRmN3A3cTNpc3RhdGVtZW50eDxHaXZlIHRoaXMgYXBwbGljYXRpb24gYWNjZXNzIHRvIHNvbWUgb2YgeW91ciBkYXRhIG9uIENlcmFtaWNhc6Jhc3iEMHhiNDE0ZTgzYmZmOTFkMjZkZTEwODYxNWQwZDdiNmU1NzhlODJhZTQ4MDQwZDE3N2M2NWExZDdkMjhiOGU0MWU1NmExNzM0MTRlZDNkMWU2Y2Q0YmY4NDRlMzA3MzBlZTU4ODkxZTc3MzcwNjhkMDJiNTgzYTNhYWU5YmM2NDI1ZjFjYXRmZWlwMTkx7gYBcRIgExo6jJjcyzwZrZpUrC-HM47gW3YGGR0OpROJ8CueOHejYWihYXRnY2FpcDEyMmFwqWNhdWR4OWRpZDprZXk6ekRuYWV3R3VLZjI1RTZRRGhoMXFBakp5dndHUlhuVUpIMnNIb1ZyZUtYblJrdEJXZmNleHB4GDIwMjQtMDctMThUMTc6MDY6NDQuNzYyWmNpYXR4GDIwMjQtMDctMTFUMTc6MDY6NDQuNzYyWmNpc3N4OWRpZDprZXk6ekRuYWV3R3VLZjI1RTZRRGhoMXFBakp5dndHUlhuVUpIMnNIb1ZyZUtYblJrdEJXZmVub25jZWo5ZUc1bUVjT1d1ZmRvbWFpbmlsb2NhbGhvc3RndmVyc2lvbmExaXJlc291cmNlc4N4UWNlcmFtaWM6Ly8qP21vZGVsPWtqemw2aHZmcmJ3NmNhZHljaTVsdnNmZjRqeGwxaWRmZnJwMmxkM2kwazF6bnowYjNrNjdhYmttdGY3cDdxM3hAcHJldjpiYWZ5cmVpYXJubDI1d2ljZmhpNDZqdm9lemJsY2I0cjdmbDRkNzJsZ2lic2JyYWFxZmpqb3JmdHlnaXhAcHJldjpiYWZ5cmVpYWt4aHV3b2Z4cnV1bXRydGg3bmhsNHVpbGNjbHZqM2ljd2NjNTc1Ymd2N283andiZG9mdWlzdGF0ZW1lbnR4PEdpdmUgdGhpcyBhcHBsaWNhdGlvbiBhY2Nlc3MgdG8gc29tZSBvZiB5b3VyIGRhdGEgb24gQ2VyYW1pY2Fzo2Fto2NhbGdlRVMyNTZjY2FweEJpcGZzOi8vYmFmeXJlaWFybmwyNXdpY2ZoaTQ2anZvZXpibGNiNHI3Zmw0ZDcybGdpYnNicmFhcWZqam9yZnR5Z2lja2lkeGtkaWQ6a2V5OnpEbmFld0d1S2YyNUU2UURoaDFxQWpKeXZ3R1JYblVKSDJzSG9WcmVLWG5Sa3RCV2YjekRuYWV3R3VLZjI1RTZRRGhoMXFBakp5dndHUlhuVUpIMnNIb1ZyZUtYblJrdEJXZmFzeFZLVzhMd0R4YkQtVHAtdWYzTUh6RXZ2UE96ZF9nWEVfWlpyZmNvd1Jyd1F0ODd4M2NqUlpTamVHbU1CWUVpRWdXYm84VXlQUEotS2lNanBCOGZtQWJxQWF0Y2p3cw"#; + let resource = + r#"ceramic://*?model=kjzl6hvfrbw6cadyci5lvsff4jxl1idffrp2ld3i0k1znz0b3k67abkmtf7p7q3"#; + + authenticate(header, Operation::Read, resource) + .await + .unwrap(); + } + + fn generate_did_and_private_key() -> (DidDocument, String) { + let key = ssi::jwk::JWK::generate_ed25519().unwrap(); + let private_key = if let Params::OKP(params) = &key.params { + let pk = params.private_key.as_ref().unwrap(); + hex::encode(pk.0.as_slice()) + } else { + panic!("Failed to generate private key"); + }; + let did = did_method_key::DIDKey.generate(&Source::Key(&key)).unwrap(); + let mut builder = DocumentBuilder::default(); + builder.id(did); + let doc = builder.build().unwrap(); + (doc, private_key) + } + + async fn create_capability( + signer: &impl Signer, + parent: Option<&DidDocument>, + resources: Vec, + ) -> Capability { + let did = signer.id(); + let aud = parent + .map(|d| d.id.clone()) + .unwrap_or_else(|| did.id.clone()); + let payload = Payload { + issuer: did.id.clone(), + audience: aud, + issued_at: chrono::Utc::now(), + domain: "ceramic".to_string(), + version: "1.0.0".to_string(), + expiration: None, + statement: None, + not_before: None, + request_id: None, + resources: Some(resources), + nonce: "a".to_string(), + }; + let metadata = SignatureMetadata { + kid: did.id.clone(), + alg: format!("{:?}", signer.algorithm()), + rest: HashMap::default(), + }; + let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&metadata).unwrap()); + let encoded_payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let signing_input = format!("{header}.{encoded_payload}"); + let signed = signer.sign(signing_input.as_bytes()).unwrap(); + let signature = Signature { + signature: URL_SAFE_NO_PAD.encode(&signed), + metadata, + r#type: SignatureType::JWS, + }; + Capability { + payload: payload, + signature: signature, + header: Header { + r#type: HeaderType::CAIP122, + }, + } + } + + #[tokio::test] + async fn should_authenticate_car() { + // create our root block + let (owner_did, owner_key) = generate_did_and_private_key(); + let owner_signer = JwkSigner::new(owner_did.clone(), &owner_key).await.unwrap(); + let (delegated_did, delegated_key) = generate_did_and_private_key(); + let delegated_signer = JwkSigner::new(delegated_did, &delegated_key).await.unwrap(); + + let stream_id = + StreamId::from_str("kjzl6hvfrbw6cadyci5lvsff4jxl1idffrp2ld3i0k1znz0b3k67abkmtf7p7q3") + .unwrap(); + let resource = format!("ceramic://*?model={stream_id}"); + let mut resources = vec![resource.clone()]; + let owner_cap = create_capability(&owner_signer, None, resources.clone()).await; + let owner_car = serde_ipld_dagcbor::to_vec(&owner_cap).unwrap(); + let owner_cid = cid_from_dag_cbor(&owner_car); + + let biscuit = biscuit!( + r#" + user({user}); + right({user}, "model", {stream_id}); + right({user}, {resource}); + "#, + user = delegated_signer.id().id.clone(), + stream_id = stream_id.to_string(), + resource = resource.clone(), + ); + let biscuit = URL_SAFE_NO_PAD.encode(biscuit.dump_code().as_bytes()); + resources.push(format!("prev:{}", owner_cid)); + resources.push(format!("biscuit:{}", biscuit)); + let delegated_cap = create_capability(&delegated_signer, Some(&owner_did), resources).await; + let delegated_car = serde_ipld_dagcbor::to_vec(&delegated_cap).unwrap(); + let delegated_cid = cid_from_dag_cbor(&delegated_car); + + let header = CarHeader::V1(CarHeaderV1::from(vec![delegated_cid])); + let mut buffer = Vec::new(); + let mut writer = CarWriter::new(header, &mut buffer); + writer.write(owner_cid, owner_car).await.unwrap(); + writer.write(delegated_cid, delegated_car).await.unwrap(); + let car = writer.finish().await.unwrap(); + + let bearer = URL_SAFE_NO_PAD.encode(&car); + + authenticate(&bearer, Operation::Read, &resource) + .await + .unwrap(); + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs index a17bfba90..0b4c9ae75 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -7,5 +7,6 @@ pub use server::{ EventDataResult, EventInsertResult, EventStore, IncludeEventData, InterestStore, Server, }; +mod auth; #[cfg(test)] mod tests; diff --git a/api/src/server.rs b/api/src/server.rs index fef92c96f..2411134cd 100644 --- a/api/src/server.rs +++ b/api/src/server.rs @@ -20,6 +20,8 @@ use std::{ sync::{Arc, Mutex}, }; +use crate::auth; +use crate::auth::Operation; use anyhow::Result; use async_trait::async_trait; use ceramic_api_server::models::{BadRequestResponse, ErrorResponse, EventData}; @@ -35,6 +37,7 @@ use ceramic_api_server::{ InterestsPostResponse, }; use ceramic_core::{Cid, EventId, Interest, Network, PeerId, StreamId}; +use ceramic_event::ssi::jsonld::syntax::parse::Error::Stream; use futures::TryFutureExt; use recon::Key; use swagger::{ApiError, ByteArray}; @@ -436,6 +439,7 @@ where pub async fn get_event_feed( &self, + _resource: Option, resume_at: Option, limit: Option, include_data: Option, @@ -848,12 +852,29 @@ where #[instrument(skip(self, _context), ret(level = Level::DEBUG), err(level = Level::ERROR))] async fn feed_events_get( &self, + authorization: Option, + resource: Option, resume_at: Option, limit: Option, include_data: Option, _context: &C, ) -> Result { - self.get_event_feed(resume_at, limit, include_data) + let filter = if self.authentication { + if let (Some(auth), Some(resource)) = (authorization, resource) { + auth::authenticate(&auth, Operation::Read, &resource) + .await + .map_err(|err| { + tracing::debug!("Unauthorized: {err}"); + ApiError("Unauthorized".to_string()) + })?; + Some(resource) + } else { + return Err(ApiError("Unauthorized".to_string())); + } + } else { + None + }; + self.get_event_feed(filter, resume_at, limit, include_data) .await .or_else(|err| Ok(FeedEventsGetResponse::InternalServerError(err))) } @@ -952,8 +973,23 @@ where async fn events_event_id_get( &self, event_id: String, + bearer: Option, + resource: Option, _context: &C, ) -> Result { + if self.authentication { + if let (Some(bearer), Some(resource)) = (bearer, resource) { + auth::authenticate(&bearer, Operation::Read, &resource) + .await + .map_err(|err| { + tracing::debug!("Unauthorized: {err}"); + ApiError("Unauthorized".to_string()) + })?; + } else { + return Err(ApiError("Unauthorized".to_string())); + } + } + self.get_events_event_id(event_id) .await .or_else(|err| Ok(EventsEventIdGetResponse::InternalServerError(err))) diff --git a/api/src/tests.rs b/api/src/tests.rs index a72ec9617..bf06d3e5b 100644 --- a/api/src/tests.rs +++ b/api/src/tests.rs @@ -673,7 +673,9 @@ async fn test_events_event_id_get_by_event_id_success() { .returning(move |_| Ok(Some(event_data.clone()))); let mock_interest = MockAccessInterestStoreTest::new(); let server = Server::new(peer_id, network, mock_interest, Arc::new(mock_event_store)); - let result = server.events_event_id_get(event_id_str, &Context).await; + let result = server + .events_event_id_get(event_id_str, None, None, &Context) + .await; let EventsEventIdGetResponse::Success(event) = result.unwrap() else { panic!("Expected EventsEventIdGetResponse::Success but got another variant"); }; @@ -702,7 +704,7 @@ async fn test_events_event_id_get_by_cid_success() { let mock_interest = MockAccessInterestStoreTest::new(); let server = Server::new(peer_id, network, mock_interest, Arc::new(mock_event_store)); let result = server - .events_event_id_get(event_cid.to_string(), &Context) + .events_event_id_get(event_cid.to_string(), None, None, &Context) .await; let EventsEventIdGetResponse::Success(event) = result.unwrap() else { panic!("Expected EventsEventIdGetResponse::Success but got another variant"); diff --git a/event/Cargo.toml b/event/Cargo.toml index e9c6bfab6..cbfc4cfb2 100644 --- a/event/Cargo.toml +++ b/event/Cargo.toml @@ -13,6 +13,7 @@ publish = false anyhow.workspace = true base64.workspace = true ceramic-core.workspace = true +chrono.workspace = true cid.workspace = true ipld-core.workspace = true iroh-car.workspace = true diff --git a/event/src/lib.rs b/event/src/lib.rs index 435376659..99d7576cd 100644 --- a/event/src/lib.rs +++ b/event/src/lib.rs @@ -6,6 +6,7 @@ mod bytes; pub mod unvalidated; pub use ceramic_core::*; +pub use unvalidated::cid_from_dag_cbor; #[cfg(test)] pub mod tests { diff --git a/event/src/unvalidated/mod.rs b/event/src/unvalidated/mod.rs index 9e5dd94ba..916db96b1 100644 --- a/event/src/unvalidated/mod.rs +++ b/event/src/unvalidated/mod.rs @@ -13,7 +13,8 @@ use cid::Cid; use ipld_core::{codec::Codec, ipld::Ipld}; use serde_ipld_dagcbor::codec::DagCborCodec; -fn cid_from_dag_cbor(data: &[u8]) -> Cid { +/// Create a CID from a DAG-CBOR encoded data +pub fn cid_from_dag_cbor(data: &[u8]) -> Cid { Cid::new_v1( >::CODE, Code::Sha2_256.digest(data), diff --git a/event/src/unvalidated/signed/cacao.rs b/event/src/unvalidated/signed/cacao.rs index 773ae08b8..08dbc529f 100644 --- a/event/src/unvalidated/signed/cacao.rs +++ b/event/src/unvalidated/signed/cacao.rs @@ -1,155 +1,167 @@ //! Structures for encoding and decoding CACAO capability objects. use serde::{Deserialize, Serialize}; +use ssi::jwk::Algorithm; +use std::collections::HashMap; /// Capability object, see https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-74.md -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Capability { + /// Header for capability #[serde(rename = "h")] - header: Header, + pub header: Header, + /// Payload for capability #[serde(rename = "p")] - payload: Payload, + pub payload: Payload, + /// Signature for capability #[serde(rename = "s")] - signature: Signature, + pub signature: Signature, } -impl Capability { - /// Get the header - pub fn header(&self) -> &Header { - &self.header - } - - /// Get the payload - pub fn payload(&self) -> &Payload { - &self.payload - } - - /// Get the signature - pub fn signature(&self) -> &Signature { - &self.signature - } +/// Type of Capability Header +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum HeaderType { + /// EIP-4361 Capability + #[serde(rename = "eip4361")] + EIP4361, + /// CAIP-122 Capability + #[serde(rename = "caip122")] + CAIP122, } -/// Header for a CACAO -#[derive(Debug, Serialize, Deserialize)] + +/// Header for a Capability +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Header { + /// Type of the Capability Header #[serde(rename = "t")] - r#type: String, + pub r#type: HeaderType, } -impl Header { - /// Get the type of the CACAO - pub fn r#type(&self) -> &str { - &self.r#type - } -} +/// Time format for capability +pub type CapabilityTime = chrono::DateTime; /// Payload for a CACAO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Payload { - domain: String, - - #[serde(rename = "iss")] - issuer: String, - + /// Audience for payload #[serde(rename = "aud")] - audience: String, + pub audience: String, - version: String, + /// Domain for payload + pub domain: String, - nonce: String, + /// Expiration time + #[serde(rename = "exp", skip_serializing_if = "Option::is_none")] + pub expiration: Option, + /// Issued at time #[serde(rename = "iat")] - issued_at: String, + pub issued_at: CapabilityTime, - #[serde(rename = "nbf", skip_serializing_if = "Option::is_none")] - not_before: Option, + /// Issuer for payload. For capability will be DID in URI format + #[serde(rename = "iss")] + pub issuer: String, - #[serde(rename = "exp", skip_serializing_if = "Option::is_none")] - expiration: Option, + /// Not before time + #[serde(rename = "nbf", skip_serializing_if = "Option::is_none")] + pub not_before: Option, - #[serde(skip_serializing_if = "Option::is_none")] - statement: Option, + /// Nonce of payload + pub nonce: String, + /// Request ID #[serde(rename = "requestId", skip_serializing_if = "Option::is_none")] - request_id: Option, + pub request_id: Option, + /// Resources #[serde(skip_serializing_if = "Option::is_none")] - resources: Option>, -} - -impl Payload { - /// Get the domain - pub fn domain(&self) -> &str { - &self.domain - } - - /// Get the issuer as a DID pkh string - pub fn issuer(&self) -> &str { - &self.issuer - } - - /// Get the audience as a URI - pub fn audience(&self) -> &str { - &self.audience - } + pub resources: Option>, - /// Get the version - pub fn version(&self) -> &str { - &self.version - } - - /// Get the nonce - pub fn nonce(&self) -> &str { - &self.nonce - } - - /// Get the issued at date and time as a RFC3339 string - pub fn issued_at(&self) -> &str { - &self.issued_at - } + /// Subject of payload + #[serde(skip_serializing_if = "Option::is_none")] + pub statement: Option, - /// Get the not before date and time as a RFC3339 string - pub fn not_before(&self) -> Option<&String> { - self.not_before.as_ref() - } + /// Version of payload + pub version: String, +} - /// Get the expiration date and time as a RFC3339 string - pub fn expiration(&self) -> Option<&String> { - self.expiration.as_ref() - } +/// Type of Signature +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum SignatureType { + /// EIP-191 Signature + #[serde(rename = "eip191")] + EIP191, + /// EIP-1271 Signature + #[serde(rename = "eip1271")] + EIP1271, + /// ED25519 signature for solana + #[serde(rename = "solana:ed25519")] + SolanaED25519, + /// ED25519 signature for tezos + #[serde(rename = "tezos:ed25519")] + TezosED25519, + /// SECP256K1 signature for stacks + #[serde(rename = "stacks:secp256k1")] + StacksSECP256K1, + /// SECP256K1 signature for webauthn + #[serde(rename = "webauthn:p256")] + WebAuthNP256, + /// JWS signature + #[serde(rename = "jws")] + JWS, +} - /// Get the statement - pub fn statement(&self) -> Option<&String> { - self.statement.as_ref() +impl SignatureType { + /// Convert signature type to algorithm + pub fn algorithm(&self) -> Algorithm { + match self { + SignatureType::EIP191 => Algorithm::ES256, + SignatureType::EIP1271 => Algorithm::ES256, + SignatureType::SolanaED25519 => Algorithm::EdDSA, + SignatureType::TezosED25519 => Algorithm::EdDSA, + SignatureType::StacksSECP256K1 => Algorithm::ES256K, + SignatureType::WebAuthNP256 => Algorithm::ES256, + SignatureType::JWS => Algorithm::ES256, + } } +} - /// Get the request Id - pub fn request_id(&self) -> Option<&String> { - self.request_id.as_ref() - } +/// Values for unknown metadata +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum MetadataValue { + /// Boolean value + Boolean(bool), + /// Integer value + Integer(i64), + /// Null value + Null, + /// String value + String(String), +} - /// Get the resources - pub fn resources(&self) -> Option<&[String]> { - self.resources.as_ref().map(|r| &r[..]) - } +/// Metadata for signature +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SignatureMetadata { + /// Algorithm for signature + pub alg: String, + /// Key ID for signature + pub kid: String, + /// Other metadata + #[serde(flatten)] + pub rest: HashMap, } /// Signature of a CACAO -#[derive(Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Signature { + /// Metadata for signature + #[serde(rename = "m")] + pub metadata: SignatureMetadata, + /// Type of signature #[serde(rename = "t")] - r#type: String, + pub r#type: SignatureType, + /// Signature bytes #[serde(rename = "s")] - signature: String, -} - -impl Signature { - /// Get the type of the signature - pub fn r#type(&self) -> &str { - &self.r#type - } - /// Get the signature bytes as hex encoded string prefixed with 0x - pub fn signature(&self) -> &str { - &self.signature - } + pub signature: String, }