diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f26327e..2a1b2cb 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -28,7 +28,7 @@ jobs: - name: get nightly toolchain for jetstream fmt run: rustup toolchain install nightly --allow-downgrade -c rustfmt - name: fmt - run: cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot -- --check + run: cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot --package pocket -- --check - name: fmt jetstream (nightly) run: cargo +nightly fmt --package jetstream -- --check - name: clippy diff --git a/Makefile b/Makefile index eb29af2..7e52ceb 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ test: cargo test --all-features fmt: - cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot + cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot --package pocket cargo +nightly fmt --package jetstream clippy: diff --git a/pocket/api-description.md b/pocket/api-description.md new file mode 100644 index 0000000..3d2a5be --- /dev/null +++ b/pocket/api-description.md @@ -0,0 +1,17 @@ +_A pocket dimension to stash a bit of non-public user data._ + + +# Pocket: user preference storage + +This API leverages atproto service proxying to offer a bit of per-user per-app non-public data storage. +Perfect for things like application preferences that might be better left out of the public PDS data. + +The intent is to use oauth scopes to isolate storage on a per-application basis, and to allow easy data migration from a community hosted instance to your own if you end up needing that. + + +### Current status + +> [!important] +> Pocket is currently in a **v0, pre-release state**. There is one production instance and you can use it! Expect short downtimes for restarts as development progresses and occaisional data loss until it's stable. + +ATProto might end up adding a similar feature to [PDSs](https://atproto.com/guides/glossary#pds-personal-data-server). If/when that happens, you should use it instead of this! diff --git a/pocket/src/lib.rs b/pocket/src/lib.rs index 1a7a0be..c6a7fd8 100644 --- a/pocket/src/lib.rs +++ b/pocket/src/lib.rs @@ -2,4 +2,4 @@ mod server; mod token; pub use server::serve; -pub use token::verify; +pub use token::TokenVerifier; diff --git a/pocket/src/main.rs b/pocket/src/main.rs index a68f1b8..46a7ad9 100644 --- a/pocket/src/main.rs +++ b/pocket/src/main.rs @@ -6,4 +6,3 @@ async fn main() { println!("Hello, world!"); serve("mac.cinnebar-tet.ts.net").await } - diff --git a/pocket/src/server.rs b/pocket/src/server.rs index 940630d..9c031e0 100644 --- a/pocket/src/server.rs +++ b/pocket/src/server.rs @@ -1,41 +1,30 @@ +use crate::TokenVerifier; use poem::{ - endpoint::make_sync, - Endpoint, - Route, - Server, - EndpointExt, - http::{Method, HeaderMap}, - middleware::{CatchPanic, Cors, Tracing}, + Endpoint, EndpointExt, Route, Server, + endpoint::{StaticFileEndpoint, make_sync}, + http::Method, listener::TcpListener, + middleware::{CatchPanic, Cors, Tracing}, }; use poem_openapi::{ - ContactObject, - ExternalDocumentObject, - OpenApi, - OpenApiService, - Tags, - Object, - ApiResponse, - types::Example, + ApiResponse, ContactObject, ExternalDocumentObject, Object, OpenApi, OpenApiService, + SecurityScheme, Tags, auth::Bearer, - payload::Json, - SecurityScheme, + payload::{Json, PlainText}, + types::Example, }; -use crate::verify; use serde::Serialize; use serde_json::{Value, json}; - #[derive(Debug, SecurityScheme)] #[oai(ty = "bearer")] -struct BlahAuth(Bearer); - +struct XrpcAuth(Bearer); #[derive(Tags)] enum ApiTags { - /// Bluesky-compatible APIs. - #[oai(rename = "app.bsky.* queries")] - AppBsky, + /// Custom pocket APIs + #[oai(rename = "Pocket APIs")] + Pocket, } #[derive(Object)] @@ -86,57 +75,74 @@ enum GetBskyPrefsResponse { /// Bad request or no preferences to return #[oai(status = 400)] BadRequest(XrpcError), +} + +#[derive(ApiResponse)] +enum PutBskyPrefsResponse { + /// Record found + #[oai(status = 200)] + Ok(PlainText), + /// Bad request or no preferences to return + #[oai(status = 400)] + BadRequest(XrpcError), // /// Server errors // #[oai(status = 500)] // ServerError(XrpcError), } struct Xrpc { - domain: String, + verifier: TokenVerifier, } #[OpenApi] impl Xrpc { - /// app.bsky.actor.getPreferences + /// com.bad-example.pocket.getPreferences /// /// get stored bluesky prefs #[oai( - path = "/app.bsky.actor.getPreferences", + path = "/com.bad-example.pocket.getPreferences", method = "get", - tag = "ApiTags::AppBsky" + tag = "ApiTags::Pocket" )] - async fn app_bsky_get_prefs( - &self, - BlahAuth(auth): BlahAuth, - m: &HeaderMap, - ) -> GetBskyPrefsResponse { - log::warn!("hm: {m:?}"); - match verify( - &format!("did:web:{}#bsky_appview", self.domain), - "app.bsky.actor.getPreferences", - &auth.token, - ).await { - Ok(did) => log::info!("wooo! {did}"), - Err(err) => return GetBskyPrefsResponse::BadRequest(xrpc_error("booo", err)), + async fn app_bsky_get_prefs(&self, XrpcAuth(auth): XrpcAuth) -> GetBskyPrefsResponse { + let did = match self + .verifier + .verify("app.bsky.actor.getPreferences", &auth.token) + .await + { + Ok(d) => d, + Err(e) => return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), }; - log::warn!("got bearer: {:?}", auth.token); + log::info!("verified did: {did}"); + // TODO: fetch from storage GetBskyPrefsResponse::Ok(Json(GetBskyPrefsResponseObject::example())) } - /// app.bsky.actor.putPreferences + /// com.bad-example.pocket.putPreferences /// /// store bluesky prefs #[oai( - path = "/app.bsky.actor.putPreferences", + path = "/com.bad-example.pocket.putPreferences", method = "post", - tag = "ApiTags::AppBsky" + tag = "ApiTags::Pocket" )] async fn app_bsky_put_prefs( &self, + XrpcAuth(auth): XrpcAuth, Json(prefs): Json, - ) -> () { + ) -> PutBskyPrefsResponse { + let did = match self + .verifier + .verify("app.bsky.actor.getPreferences", &auth.token) + .await + { + Ok(d) => d, + Err(e) => return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())), + }; + log::info!("verified did: {did}"); log::warn!("received prefs: {prefs:?}"); - () + // TODO: put prefs into storage + PutBskyPrefsResponse::Ok(PlainText("hiiiiii".to_string())) } } @@ -157,52 +163,40 @@ fn get_did_doc(domain: &str) -> impl Endpoint + use<> { let doc = poem::web::Json(AppViewDoc { id: format!("did:web:{domain}"), service: [AppViewService { - id: "#bsky_appview".to_string(), - r#type: "PocketBlueskyPreferences".to_string(), + id: "#pocket_prefs".to_string(), + r#type: "PocketPreferences".to_string(), service_endpoint: format!("https://{domain}"), }], }); make_sync(move |_| doc.clone()) } -pub async fn serve( - domain: &str, -) -> () { - let api_service = OpenApiService::new( - Xrpc { domain: domain.to_string() }, - "Pocket", - env!("CARGO_PKG_VERSION"), - ) - .server(domain) - .url_prefix("/xrpc") - .contact( - ContactObject::new() - .name("@microcosm.blue") - .url("https://bsky.app/profile/microcosm.blue"), - ) - // .description(include_str!("../api-description.md")) - .external_document(ExternalDocumentObject::new( - "https://microcosm.blue/pocket", - )); +pub async fn serve(domain: &str) -> () { + let verifier = TokenVerifier::new(domain); + let api_service = OpenApiService::new(Xrpc { verifier }, "Pocket", env!("CARGO_PKG_VERSION")) + .server(domain) + .url_prefix("/xrpc") + .contact( + ContactObject::new() + .name("@microcosm.blue") + .url("https://bsky.app/profile/microcosm.blue"), + ) + .description(include_str!("../api-description.md")) + .external_document(ExternalDocumentObject::new("https://microcosm.blue/pocket")); let app = Route::new() - .at("/.well-known/did.json", get_did_doc(&domain)) + .nest("/openapi", api_service.spec_endpoint()) .nest("/xrpc/", api_service) - // .at("/", StaticFileEndpoint::new("./static/index.html")) - // .nest("/openapi", api_service.spec_endpoint()) + .at("/.well-known/did.json", get_did_doc(domain)) + .at("/", StaticFileEndpoint::new("./static/index.html")) .with( Cors::new() .allow_method(Method::GET) - .allow_method(Method::POST) + .allow_method(Method::POST), ) .with(CatchPanic::new()) .with(Tracing); let listener = TcpListener::bind("127.0.0.1:3000"); - Server::new(listener) - .name("pocket") - .run(app) - .await - .unwrap(); - + Server::new(listener).name("pocket").run(app).await.unwrap(); } diff --git a/pocket/src/token.rs b/pocket/src/token.rs index 23e513f..33a7394 100644 --- a/pocket/src/token.rs +++ b/pocket/src/token.rs @@ -1,72 +1,133 @@ -use jwt_compact::{Claims, UntrustedToken}; use atrium_crypto::did::parse_multikey; use atrium_crypto::verify::Verifier; -use std::collections::HashMap; +use jwt_compact::UntrustedToken; use serde::Deserialize; +use std::collections::HashMap; +use std::time::Duration; +use thiserror::Error; #[derive(Debug, Deserialize)] struct MiniDoc { signing_key: String, + did: String, } -pub async fn verify( - expected_aud: &str, - expected_lxm: &str, - token: &str, -) -> Result { - let untrusted = UntrustedToken::new(token).unwrap(); - - let claims: Claims> = untrusted.deserialize_claims_unchecked().unwrap(); +#[derive(Error, Debug)] +pub enum VerifyError { + #[error("The cross-service authorization token failed verification: {0}")] + VerificationFailed(&'static str), + #[error("Error trying to resolve the DID to a signing key, retry in a moment: {0}")] + ResolutionFailed(&'static str), +} - let Some(did) = claims.custom.get("iss") else { - return Err("jwt must include the user's did in `iss`"); - }; +pub struct TokenVerifier { + domain: String, + client: reqwest::Client, +} - if !did.starts_with("did:") { - return Err("iss should be a did"); - } - if did.contains("#") { - return Err("iss should be a user did without a service identifier"); +impl TokenVerifier { + pub fn new(domain: &str) -> Self { + let client = reqwest::Client::builder() + .user_agent(format!( + "microcosm pocket v{} (dev: @bad-example.com)", + env!("CARGO_PKG_VERSION") + )) + .no_proxy() + .timeout(Duration::from_secs(12)) // slingshot timeout is 10s + .build() + .unwrap(); + Self { + client, + domain: domain.to_string(), + } } - println!("Claims: {claims:#?}"); - println!("did: {did:#?}"); + pub async fn verify(&self, expected_lxm: &str, token: &str) -> Result { + let untrusted = UntrustedToken::new(token).unwrap(); - let endpoint = "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; - let doc: MiniDoc = reqwest::get(format!("{endpoint}?identifier={did}")) - .await - .unwrap() - .error_for_status() - .unwrap() - .json() - .await - .unwrap(); + // danger! unfortunately we need to decode the DID from the jwt body before we have a public key to verify the jwt with + let Ok(untrusted_claims) = + untrusted.deserialize_claims_unchecked::>() + else { + return Err(VerifyError::VerificationFailed( + "could not deserialize jtw claims", + )); + }; - log::info!("got minidoc response: {doc:?}"); + // get the (untrusted!) claimed DID + let Some(untrusted_did) = untrusted_claims.custom.get("iss") else { + return Err(VerifyError::VerificationFailed( + "jwt must include the user's did in `iss`", + )); + }; - let (alg, public_key) = parse_multikey(&doc.signing_key).unwrap(); - log::info!("parsed key: {public_key:?}"); + // bail if it's not even a user-ish did + if !untrusted_did.starts_with("did:") { + return Err(VerifyError::VerificationFailed("iss should be a did")); + } + if untrusted_did.contains("#") { + return Err(VerifyError::VerificationFailed( + "iss should be a user did without a service identifier", + )); + } - Verifier::default().verify( - alg, - &public_key, - &untrusted.signed_data, - untrusted.signature_bytes(), - ).unwrap(); - // if this passes, then our claims were trustworthy after all(??) + let endpoint = + "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc"; + let doc: MiniDoc = self + .client + .get(format!("{endpoint}?identifier={untrusted_did}")) + .send() + .await + .map_err(|_| VerifyError::ResolutionFailed("failed to fetch minidoc"))? + .error_for_status() + .map_err(|_| VerifyError::ResolutionFailed("non-ok response for minidoc"))? + .json() + .await + .map_err(|_| VerifyError::ResolutionFailed("failed to parse json to minidoc"))?; - let Some(aud) = claims.custom.get("aud") else { - return Err("missing aud"); - }; - if aud != expected_aud { - return Err("wrong aud"); - } - let Some(lxm) = claims.custom.get("lxm") else { - return Err("missing lxm"); - }; - if lxm != expected_lxm { - return Err("wrong lxm"); - } + // sanity check before we go ahead with this signing key + if doc.did != *untrusted_did { + return Err(VerifyError::VerificationFailed( + "wtf, resolveMiniDoc returned a doc for a different DID, slingshot bug", + )); + } + + let Ok((alg, public_key)) = parse_multikey(&doc.signing_key) else { + return Err(VerifyError::VerificationFailed( + "could not parse signing key form minidoc", + )); + }; + + // i _guess_ we've successfully bootstrapped the verification of the jwt unless this fails + if let Err(e) = Verifier::default().verify( + alg, + &public_key, + &untrusted.signed_data, + untrusted.signature_bytes(), + ) { + log::warn!("jwt verification failed: {e}"); + return Err(VerifyError::VerificationFailed( + "jwt signature verification failed", + )); + } - Ok(did.to_string()) + // past this point we're should have established trust. crossing ts and dotting is. + let did = &untrusted_did; + let claims = &untrusted_claims; + + let Some(aud) = claims.custom.get("aud") else { + return Err(VerifyError::VerificationFailed("missing aud")); + }; + if *aud != format!("did:web:{}#bsky_appview", self.domain) { + return Err(VerifyError::VerificationFailed("wrong aud")); + } + let Some(lxm) = claims.custom.get("lxm") else { + return Err(VerifyError::VerificationFailed("missing lxm")); + }; + if lxm != expected_lxm { + return Err(VerifyError::VerificationFailed("wrong lxm")); + } + + Ok(did.to_string()) + } } diff --git a/pocket/static/index.html b/pocket/static/index.html new file mode 100644 index 0000000..05f8bd3 --- /dev/null +++ b/pocket/static/index.html @@ -0,0 +1,67 @@ + + + + + Pocket: atproto user preference storage + + + + + +
+

+ TODO: thing +

+ +
+ + + + + + + + diff --git a/quasar/src/lib.rs b/quasar/src/lib.rs index 80aaac2..851d8a6 100644 --- a/quasar/src/lib.rs +++ b/quasar/src/lib.rs @@ -1 +1,3 @@ mod storage; + +pub use storage::Storage; diff --git a/quasar/src/storage.rs b/quasar/src/storage.rs index 4a26f98..e243f5f 100644 --- a/quasar/src/storage.rs +++ b/quasar/src/storage.rs @@ -1,4 +1,4 @@ -trait Storage { +pub trait Storage { }