+1
-1
.github/workflows/checks.yml
+1
-1
.github/workflows/checks.yml
···
28
28
- name: get nightly toolchain for jetstream fmt
29
29
run: rustup toolchain install nightly --allow-downgrade -c rustfmt
30
30
- name: fmt
31
-
run: cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot -- --check
31
+
run: cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot --package pocket -- --check
32
32
- name: fmt jetstream (nightly)
33
33
run: cargo +nightly fmt --package jetstream -- --check
34
34
- name: clippy
+1
-1
Makefile
+1
-1
Makefile
···
5
5
cargo test --all-features
6
6
7
7
fmt:
8
-
cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot
8
+
cargo fmt --package links --package constellation --package ufos --package spacedust --package who-am-i --package slingshot --package pocket
9
9
cargo +nightly fmt --package jetstream
10
10
11
11
clippy:
+17
pocket/api-description.md
+17
pocket/api-description.md
···
1
+
_A pocket dimension to stash a bit of non-public user data._
2
+
3
+
4
+
# Pocket: user preference storage
5
+
6
+
This API leverages atproto service proxying to offer a bit of per-user per-app non-public data storage.
7
+
Perfect for things like application preferences that might be better left out of the public PDS data.
8
+
9
+
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.
10
+
11
+
12
+
### Current status
13
+
14
+
> [!important]
15
+
> 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.
16
+
17
+
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!
+1
-1
pocket/src/lib.rs
+1
-1
pocket/src/lib.rs
-1
pocket/src/main.rs
-1
pocket/src/main.rs
+72
-78
pocket/src/server.rs
+72
-78
pocket/src/server.rs
···
1
+
use crate::TokenVerifier;
1
2
use poem::{
2
-
endpoint::make_sync,
3
-
Endpoint,
4
-
Route,
5
-
Server,
6
-
EndpointExt,
7
-
http::{Method, HeaderMap},
3
+
Endpoint, EndpointExt, Route, Server,
4
+
endpoint::{StaticFileEndpoint, make_sync},
5
+
http::Method,
6
+
listener::TcpListener,
8
7
middleware::{CatchPanic, Cors, Tracing},
9
-
listener::TcpListener,
10
8
};
11
9
use poem_openapi::{
12
-
ContactObject,
13
-
ExternalDocumentObject,
14
-
OpenApi,
15
-
OpenApiService,
16
-
Tags,
17
-
Object,
18
-
ApiResponse,
10
+
ApiResponse, ContactObject, ExternalDocumentObject, Object, OpenApi, OpenApiService,
11
+
SecurityScheme, Tags,
12
+
auth::Bearer,
13
+
payload::{Json, PlainText},
19
14
types::Example,
20
-
auth::Bearer,
21
-
payload::Json,
22
-
SecurityScheme,
23
15
};
24
-
use crate::verify;
25
16
use serde::Serialize;
26
17
use serde_json::{Value, json};
27
-
28
18
29
19
#[derive(Debug, SecurityScheme)]
30
20
#[oai(ty = "bearer")]
31
-
struct BlahAuth(Bearer);
32
-
21
+
struct XrpcAuth(Bearer);
33
22
34
23
#[derive(Tags)]
35
24
enum ApiTags {
36
-
/// Bluesky-compatible APIs.
37
-
#[oai(rename = "app.bsky.* queries")]
38
-
AppBsky,
25
+
/// Custom pocket APIs
26
+
#[oai(rename = "Pocket APIs")]
27
+
Pocket,
39
28
}
40
29
41
30
#[derive(Object)]
···
86
75
/// Bad request or no preferences to return
87
76
#[oai(status = 400)]
88
77
BadRequest(XrpcError),
78
+
}
79
+
80
+
#[derive(ApiResponse)]
81
+
enum PutBskyPrefsResponse {
82
+
/// Record found
83
+
#[oai(status = 200)]
84
+
Ok(PlainText<String>),
85
+
/// Bad request or no preferences to return
86
+
#[oai(status = 400)]
87
+
BadRequest(XrpcError),
89
88
// /// Server errors
90
89
// #[oai(status = 500)]
91
90
// ServerError(XrpcError),
92
91
}
93
92
94
93
struct Xrpc {
95
-
domain: String,
94
+
verifier: TokenVerifier,
96
95
}
97
96
98
97
#[OpenApi]
99
98
impl Xrpc {
100
-
/// app.bsky.actor.getPreferences
99
+
/// com.bad-example.pocket.getPreferences
101
100
///
102
101
/// get stored bluesky prefs
103
102
#[oai(
104
-
path = "/app.bsky.actor.getPreferences",
103
+
path = "/com.bad-example.pocket.getPreferences",
105
104
method = "get",
106
-
tag = "ApiTags::AppBsky"
105
+
tag = "ApiTags::Pocket"
107
106
)]
108
-
async fn app_bsky_get_prefs(
109
-
&self,
110
-
BlahAuth(auth): BlahAuth,
111
-
m: &HeaderMap,
112
-
) -> GetBskyPrefsResponse {
113
-
log::warn!("hm: {m:?}");
114
-
match verify(
115
-
&format!("did:web:{}#bsky_appview", self.domain),
116
-
"app.bsky.actor.getPreferences",
117
-
&auth.token,
118
-
).await {
119
-
Ok(did) => log::info!("wooo! {did}"),
120
-
Err(err) => return GetBskyPrefsResponse::BadRequest(xrpc_error("booo", err)),
107
+
async fn app_bsky_get_prefs(&self, XrpcAuth(auth): XrpcAuth) -> GetBskyPrefsResponse {
108
+
let did = match self
109
+
.verifier
110
+
.verify("app.bsky.actor.getPreferences", &auth.token)
111
+
.await
112
+
{
113
+
Ok(d) => d,
114
+
Err(e) => return GetBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())),
121
115
};
122
-
log::warn!("got bearer: {:?}", auth.token);
116
+
log::info!("verified did: {did}");
117
+
// TODO: fetch from storage
123
118
GetBskyPrefsResponse::Ok(Json(GetBskyPrefsResponseObject::example()))
124
119
}
125
120
126
-
/// app.bsky.actor.putPreferences
121
+
/// com.bad-example.pocket.putPreferences
127
122
///
128
123
/// store bluesky prefs
129
124
#[oai(
130
-
path = "/app.bsky.actor.putPreferences",
125
+
path = "/com.bad-example.pocket.putPreferences",
131
126
method = "post",
132
-
tag = "ApiTags::AppBsky"
127
+
tag = "ApiTags::Pocket"
133
128
)]
134
129
async fn app_bsky_put_prefs(
135
130
&self,
131
+
XrpcAuth(auth): XrpcAuth,
136
132
Json(prefs): Json<Value>,
137
-
) -> () {
133
+
) -> PutBskyPrefsResponse {
134
+
let did = match self
135
+
.verifier
136
+
.verify("app.bsky.actor.getPreferences", &auth.token)
137
+
.await
138
+
{
139
+
Ok(d) => d,
140
+
Err(e) => return PutBskyPrefsResponse::BadRequest(xrpc_error("boooo", e.to_string())),
141
+
};
142
+
log::info!("verified did: {did}");
138
143
log::warn!("received prefs: {prefs:?}");
139
-
()
144
+
// TODO: put prefs into storage
145
+
PutBskyPrefsResponse::Ok(PlainText("hiiiiii".to_string()))
140
146
}
141
147
}
142
148
···
157
163
let doc = poem::web::Json(AppViewDoc {
158
164
id: format!("did:web:{domain}"),
159
165
service: [AppViewService {
160
-
id: "#bsky_appview".to_string(),
161
-
r#type: "PocketBlueskyPreferences".to_string(),
166
+
id: "#pocket_prefs".to_string(),
167
+
r#type: "PocketPreferences".to_string(),
162
168
service_endpoint: format!("https://{domain}"),
163
169
}],
164
170
});
165
171
make_sync(move |_| doc.clone())
166
172
}
167
173
168
-
pub async fn serve(
169
-
domain: &str,
170
-
) -> () {
171
-
let api_service = OpenApiService::new(
172
-
Xrpc { domain: domain.to_string() },
173
-
"Pocket",
174
-
env!("CARGO_PKG_VERSION"),
175
-
)
176
-
.server(domain)
177
-
.url_prefix("/xrpc")
178
-
.contact(
179
-
ContactObject::new()
180
-
.name("@microcosm.blue")
181
-
.url("https://bsky.app/profile/microcosm.blue"),
182
-
)
183
-
// .description(include_str!("../api-description.md"))
184
-
.external_document(ExternalDocumentObject::new(
185
-
"https://microcosm.blue/pocket",
186
-
));
174
+
pub async fn serve(domain: &str) -> () {
175
+
let verifier = TokenVerifier::new(domain);
176
+
let api_service = OpenApiService::new(Xrpc { verifier }, "Pocket", env!("CARGO_PKG_VERSION"))
177
+
.server(domain)
178
+
.url_prefix("/xrpc")
179
+
.contact(
180
+
ContactObject::new()
181
+
.name("@microcosm.blue")
182
+
.url("https://bsky.app/profile/microcosm.blue"),
183
+
)
184
+
.description(include_str!("../api-description.md"))
185
+
.external_document(ExternalDocumentObject::new("https://microcosm.blue/pocket"));
187
186
188
187
let app = Route::new()
189
-
.at("/.well-known/did.json", get_did_doc(&domain))
188
+
.nest("/openapi", api_service.spec_endpoint())
190
189
.nest("/xrpc/", api_service)
191
-
// .at("/", StaticFileEndpoint::new("./static/index.html"))
192
-
// .nest("/openapi", api_service.spec_endpoint())
190
+
.at("/.well-known/did.json", get_did_doc(domain))
191
+
.at("/", StaticFileEndpoint::new("./static/index.html"))
193
192
.with(
194
193
Cors::new()
195
194
.allow_method(Method::GET)
196
-
.allow_method(Method::POST)
195
+
.allow_method(Method::POST),
197
196
)
198
197
.with(CatchPanic::new())
199
198
.with(Tracing);
200
199
201
200
let listener = TcpListener::bind("127.0.0.1:3000");
202
-
Server::new(listener)
203
-
.name("pocket")
204
-
.run(app)
205
-
.await
206
-
.unwrap();
207
-
201
+
Server::new(listener).name("pocket").run(app).await.unwrap();
208
202
}
+113
-52
pocket/src/token.rs
+113
-52
pocket/src/token.rs
···
1
-
use jwt_compact::{Claims, UntrustedToken};
2
1
use atrium_crypto::did::parse_multikey;
3
2
use atrium_crypto::verify::Verifier;
4
-
use std::collections::HashMap;
3
+
use jwt_compact::UntrustedToken;
5
4
use serde::Deserialize;
5
+
use std::collections::HashMap;
6
+
use std::time::Duration;
7
+
use thiserror::Error;
6
8
7
9
#[derive(Debug, Deserialize)]
8
10
struct MiniDoc {
9
11
signing_key: String,
12
+
did: String,
10
13
}
11
14
12
-
pub async fn verify(
13
-
expected_aud: &str,
14
-
expected_lxm: &str,
15
-
token: &str,
16
-
) -> Result<String, &'static str> {
17
-
let untrusted = UntrustedToken::new(token).unwrap();
15
+
#[derive(Error, Debug)]
16
+
pub enum VerifyError {
17
+
#[error("The cross-service authorization token failed verification: {0}")]
18
+
VerificationFailed(&'static str),
19
+
#[error("Error trying to resolve the DID to a signing key, retry in a moment: {0}")]
20
+
ResolutionFailed(&'static str),
21
+
}
22
+
23
+
pub struct TokenVerifier {
24
+
domain: String,
25
+
client: reqwest::Client,
26
+
}
27
+
28
+
impl TokenVerifier {
29
+
pub fn new(domain: &str) -> Self {
30
+
let client = reqwest::Client::builder()
31
+
.user_agent(format!(
32
+
"microcosm pocket v{} (dev: @bad-example.com)",
33
+
env!("CARGO_PKG_VERSION")
34
+
))
35
+
.no_proxy()
36
+
.timeout(Duration::from_secs(12)) // slingshot timeout is 10s
37
+
.build()
38
+
.unwrap();
39
+
Self {
40
+
client,
41
+
domain: domain.to_string(),
42
+
}
43
+
}
44
+
45
+
pub async fn verify(&self, expected_lxm: &str, token: &str) -> Result<String, VerifyError> {
46
+
let untrusted = UntrustedToken::new(token).unwrap();
47
+
48
+
// danger! unfortunately we need to decode the DID from the jwt body before we have a public key to verify the jwt with
49
+
let Ok(untrusted_claims) =
50
+
untrusted.deserialize_claims_unchecked::<HashMap<String, String>>()
51
+
else {
52
+
return Err(VerifyError::VerificationFailed(
53
+
"could not deserialize jtw claims",
54
+
));
55
+
};
18
56
19
-
let claims: Claims<HashMap<String, String>> = untrusted.deserialize_claims_unchecked().unwrap();
57
+
// get the (untrusted!) claimed DID
58
+
let Some(untrusted_did) = untrusted_claims.custom.get("iss") else {
59
+
return Err(VerifyError::VerificationFailed(
60
+
"jwt must include the user's did in `iss`",
61
+
));
62
+
};
20
63
21
-
let Some(did) = claims.custom.get("iss") else {
22
-
return Err("jwt must include the user's did in `iss`");
23
-
};
64
+
// bail if it's not even a user-ish did
65
+
if !untrusted_did.starts_with("did:") {
66
+
return Err(VerifyError::VerificationFailed("iss should be a did"));
67
+
}
68
+
if untrusted_did.contains("#") {
69
+
return Err(VerifyError::VerificationFailed(
70
+
"iss should be a user did without a service identifier",
71
+
));
72
+
}
24
73
25
-
if !did.starts_with("did:") {
26
-
return Err("iss should be a did");
27
-
}
28
-
if did.contains("#") {
29
-
return Err("iss should be a user did without a service identifier");
30
-
}
74
+
let endpoint =
75
+
"https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc";
76
+
let doc: MiniDoc = self
77
+
.client
78
+
.get(format!("{endpoint}?identifier={untrusted_did}"))
79
+
.send()
80
+
.await
81
+
.map_err(|_| VerifyError::ResolutionFailed("failed to fetch minidoc"))?
82
+
.error_for_status()
83
+
.map_err(|_| VerifyError::ResolutionFailed("non-ok response for minidoc"))?
84
+
.json()
85
+
.await
86
+
.map_err(|_| VerifyError::ResolutionFailed("failed to parse json to minidoc"))?;
31
87
32
-
println!("Claims: {claims:#?}");
33
-
println!("did: {did:#?}");
88
+
// sanity check before we go ahead with this signing key
89
+
if doc.did != *untrusted_did {
90
+
return Err(VerifyError::VerificationFailed(
91
+
"wtf, resolveMiniDoc returned a doc for a different DID, slingshot bug",
92
+
));
93
+
}
34
94
35
-
let endpoint = "https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc";
36
-
let doc: MiniDoc = reqwest::get(format!("{endpoint}?identifier={did}"))
37
-
.await
38
-
.unwrap()
39
-
.error_for_status()
40
-
.unwrap()
41
-
.json()
42
-
.await
43
-
.unwrap();
95
+
let Ok((alg, public_key)) = parse_multikey(&doc.signing_key) else {
96
+
return Err(VerifyError::VerificationFailed(
97
+
"could not parse signing key form minidoc",
98
+
));
99
+
};
44
100
45
-
log::info!("got minidoc response: {doc:?}");
101
+
// i _guess_ we've successfully bootstrapped the verification of the jwt unless this fails
102
+
if let Err(e) = Verifier::default().verify(
103
+
alg,
104
+
&public_key,
105
+
&untrusted.signed_data,
106
+
untrusted.signature_bytes(),
107
+
) {
108
+
log::warn!("jwt verification failed: {e}");
109
+
return Err(VerifyError::VerificationFailed(
110
+
"jwt signature verification failed",
111
+
));
112
+
}
46
113
47
-
let (alg, public_key) = parse_multikey(&doc.signing_key).unwrap();
48
-
log::info!("parsed key: {public_key:?}");
114
+
// past this point we're should have established trust. crossing ts and dotting is.
115
+
let did = &untrusted_did;
116
+
let claims = &untrusted_claims;
49
117
50
-
Verifier::default().verify(
51
-
alg,
52
-
&public_key,
53
-
&untrusted.signed_data,
54
-
untrusted.signature_bytes(),
55
-
).unwrap();
56
-
// if this passes, then our claims were trustworthy after all(??)
118
+
let Some(aud) = claims.custom.get("aud") else {
119
+
return Err(VerifyError::VerificationFailed("missing aud"));
120
+
};
121
+
if *aud != format!("did:web:{}#bsky_appview", self.domain) {
122
+
return Err(VerifyError::VerificationFailed("wrong aud"));
123
+
}
124
+
let Some(lxm) = claims.custom.get("lxm") else {
125
+
return Err(VerifyError::VerificationFailed("missing lxm"));
126
+
};
127
+
if lxm != expected_lxm {
128
+
return Err(VerifyError::VerificationFailed("wrong lxm"));
129
+
}
57
130
58
-
let Some(aud) = claims.custom.get("aud") else {
59
-
return Err("missing aud");
60
-
};
61
-
if aud != expected_aud {
62
-
return Err("wrong aud");
63
-
}
64
-
let Some(lxm) = claims.custom.get("lxm") else {
65
-
return Err("missing lxm");
66
-
};
67
-
if lxm != expected_lxm {
68
-
return Err("wrong lxm");
131
+
Ok(did.to_string())
69
132
}
70
-
71
-
Ok(did.to_string())
72
133
}
+67
pocket/static/index.html
+67
pocket/static/index.html
···
1
+
<!doctype html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<title>Pocket: atproto user preference storage</title>
6
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7
+
<meta name="description" content="API Documentation for Pocket, a simple user-preference storage system for atproto" />
8
+
<style>
9
+
:root {
10
+
--scalar-small: 13px;
11
+
}
12
+
.scalar-app .markdown .markdown-alert {
13
+
font-size: var(--scalar-small);
14
+
}
15
+
.sidebar-heading-link-title {
16
+
line-height: 1.2;
17
+
}
18
+
.custom-header {
19
+
height: 42px;
20
+
background-color: #221828;
21
+
box-shadow: inset 0 -1px 0 var(--scalar-border-color);
22
+
color: var(--scalar-color-1);
23
+
font-size: var(--scalar-font-size-3);
24
+
font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
25
+
padding: 0 18px;
26
+
justify-content: space-between;
27
+
}
28
+
.custom-header,
29
+
.custom-header nav {
30
+
display: flex;
31
+
align-items: center;
32
+
gap: 18px;
33
+
}
34
+
.custom-header a:hover {
35
+
color: var(--scalar-color-2);
36
+
}
37
+
38
+
.light-mode .custom-header {
39
+
background-color: thistle;
40
+
}
41
+
</style>
42
+
</head>
43
+
<body>
44
+
<header class="custom-header scalar-app">
45
+
<p>
46
+
TODO: thing
47
+
</p>
48
+
<nav>
49
+
<b>a <a href="https://microcosm.blue">microcosm</a> project</b>
50
+
<a href="https://bsky.app/profile/microcosm.blue">@microcosm.blue</a>
51
+
<a href="https://github.com/at-microcosm">github</a>
52
+
</nav>
53
+
</header>
54
+
55
+
<script id="api-reference" type="application/json" data-url="/openapi"></script>
56
+
57
+
<script>
58
+
var configuration = {
59
+
theme: 'purple',
60
+
hideModels: true,
61
+
}
62
+
document.getElementById('api-reference').dataset.configuration = JSON.stringify(configuration)
63
+
</script>
64
+
65
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
66
+
</body>
67
+
</html>