feat: add setup did command to publish labeler identity
`cargo run --bin setup -- did` adds #atproto_label and #atproto_labeler to the DID document via the PDS-signed PLC flow, prompting for the main password and the emailed code. Splits setup into declare/did modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c0d56cdaf
commit
b4ac30c010
7 files changed
+199
-28
No files matched your search
Generated
+23
@@ -461,6 +461,8 @@ dependencies = [
|
|||||||
"jacquard-axum",
|
"jacquard-axum",
|
||||||
"k256",
|
"k256",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"reqwest",
|
||||||
|
"rpassword",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_ipld_dagcbor",
|
"serde_ipld_dagcbor",
|
||||||
@@ -2847,6 +2849,17 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -2867,6 +2880,16 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ jacquard = { version = "=0.12.2", default-features = false, features = ["api_blu
|
|||||||
jacquard-axum = "=0.12.2"
|
jacquard-axum = "=0.12.2"
|
||||||
k256 = { version = "0.13", features = ["ecdsa"] }
|
k256 = { version = "0.13", features = ["ecdsa"] }
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
rpassword = "7"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_ipld_dagcbor = "0.6"
|
serde_ipld_dagcbor = "0.6"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# cosocial cities labeler
|
# Canadian Cities Labeler
|
||||||
|
|
||||||
An atproto labeler that puts a Canadian city badge on accounts that ask for one over Bluesky DMs.
|
An atproto labeler that puts a Canadian city badge on accounts that ask for one over Bluesky DMs.
|
||||||
|
|
||||||
@@ -10,17 +10,6 @@ The labeler polls its DMs, matches the city against `data/cities.json`, and sign
|
|||||||
label (e.g. `halifax-ns`), negating any previous one. Labels are stored in SQLite and served over
|
label (e.g. `halifax-ns`), negating any previous one. Labels are stored in SQLite and served over
|
||||||
`com.atproto.label.subscribeLabels` and `queryLabels`.
|
`com.atproto.label.subscribeLabels` and `queryLabels`.
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
dms.rs DM polling, onboarding, replies
|
|
||||||
cities.rs city list and matching
|
|
||||||
labeler.rs set_city: negate the old label, apply the new one
|
|
||||||
labels.rs label signing
|
|
||||||
server.rs queryLabels + subscribeLabels (axum, jacquard-axum)
|
|
||||||
db.rs SQLite
|
|
||||||
bin/keygen.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
Built on [jacquard](https://tangled.org/nonbinary.computer/jacquard) for atproto types and XRPC.
|
Built on [jacquard](https://tangled.org/nonbinary.computer/jacquard) for atproto types and XRPC.
|
||||||
|
|
||||||
## Local test
|
## Local test
|
||||||
@@ -39,3 +28,11 @@ DM it from a test account, then check
|
|||||||
|
|
||||||
Labels won't appear in the Bluesky app until the labeler is deployed publicly and its DID
|
Labels won't appear in the Bluesky app until the labeler is deployed publicly and its DID
|
||||||
document has the `#atproto_label` key and `#atproto_labeler` service.
|
document has the `#atproto_label` key and `#atproto_labeler` service.
|
||||||
|
|
||||||
|
## First time setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --bin setup -- declare # label definitions; gives the account a Subscribe button
|
||||||
|
cargo run --bin setup -- did # run after deploying; requires labeler account's main password + emailed code
|
||||||
|
```
|
||||||
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
//! One-time labeler setup.
|
//! Publishes the labeler declaration (`app.bsky.labeler.service/self`) with one
|
||||||
//!
|
//! label per city. This is what gives the account a Subscribe button. Safe to
|
||||||
//! `cargo run --bin setup -- declare` publishes the labeler declaration
|
//! re-run after editing the city list; it overwrites the record.
|
||||||
//! (`app.bsky.labeler.service/self`) with one label per city. This is what
|
use anyhow::Result;
|
||||||
//! gives the account a Subscribe button. Safe to re-run after editing the list.
|
|
||||||
use anyhow::{Result, bail};
|
|
||||||
use cities_labeler::cities::Cities;
|
use cities_labeler::cities::Cities;
|
||||||
use cities_labeler::config::Config;
|
use cities_labeler::config::Config;
|
||||||
use cities_labeler::login;
|
use cities_labeler::login;
|
||||||
@@ -18,16 +16,7 @@ use jacquard::types::language::Language;
|
|||||||
use jacquard::types::recordkey::RecordKey;
|
use jacquard::types::recordkey::RecordKey;
|
||||||
use jacquard::types::string::Datetime;
|
use jacquard::types::string::Datetime;
|
||||||
|
|
||||||
#[tokio::main]
|
pub async fn run(config: &Config) -> Result<()> {
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let config = Config::from_env()?;
|
|
||||||
match std::env::args().nth(1).as_deref() {
|
|
||||||
Some("declare") => declare(&config).await,
|
|
||||||
_ => bail!("usage: setup declare"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn declare(config: &Config) -> Result<()> {
|
|
||||||
let cities = Cities::load(&config.cities_path)?;
|
let cities = Cities::load(&config.cities_path)?;
|
||||||
let (agent, did) = login(&config.handle, &config.app_password).await?;
|
let (agent, did) = login(&config.handle, &config.app_password).await?;
|
||||||
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Adds the label signing key (`#atproto_label`) and the labeler endpoint
|
||||||
|
//! (`#atproto_labeler`) to the account's DID document. Needs the account's main
|
||||||
|
//! password and a code emailed by the PDS. Run it once the labeler is live at
|
||||||
|
//! `LABELER_URL`; it exits early if both entries are already in place.
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use cities_labeler::config::Config;
|
||||||
|
use cities_labeler::login;
|
||||||
|
use jacquard::AuthorizationToken;
|
||||||
|
use jacquard::api::com_atproto::identity::sign_plc_operation::SignPlcOperation;
|
||||||
|
use jacquard::api::com_atproto::identity::submit_plc_operation::SubmitPlcOperation;
|
||||||
|
use jacquard::client::{Agent, MemoryCredentialSession};
|
||||||
|
use jacquard::common::to_data;
|
||||||
|
use jacquard::common::types::crypto::multikey;
|
||||||
|
use jacquard::prelude::*;
|
||||||
|
use k256::ecdsa::SigningKey;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const PLC_DIRECTORY: &str = "https://plc.directory";
|
||||||
|
|
||||||
|
/// The parts of the DID's current PLC state we change; everything else
|
||||||
|
/// (rotation keys, handle) is left as the PDS has it.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PlcData {
|
||||||
|
verification_methods: BTreeMap<String, String>,
|
||||||
|
services: BTreeMap<String, PlcService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Deserialize, Serialize)]
|
||||||
|
struct PlcService {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(config: &Config) -> Result<()> {
|
||||||
|
let key = SigningKey::from_slice(&hex::decode(&config.signing_key_hex)?)
|
||||||
|
.context("SIGNING_KEY_HEX")?;
|
||||||
|
let public = key.verifying_key().to_encoded_point(true);
|
||||||
|
let label_key = format!("did:key:{}", multikey(0xE7, public.as_bytes()));
|
||||||
|
let labeler = PlcService {
|
||||||
|
kind: "AtprotoLabeler".into(),
|
||||||
|
endpoint: config.labeler_url.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// identity changes need the main password; app passwords are refused
|
||||||
|
let password = rpassword::prompt_password(format!("main password for {}: ", config.handle))?;
|
||||||
|
let (agent, did) = login(&config.handle, &password).await?;
|
||||||
|
|
||||||
|
let url = format!("{PLC_DIRECTORY}/{did}/data");
|
||||||
|
let mut plc: PlcData = reqwest::get(&url)
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading {url}"))?;
|
||||||
|
if plc.verification_methods.get("atproto_label") == Some(&label_key)
|
||||||
|
&& plc.services.get("atproto_labeler") == Some(&labeler)
|
||||||
|
{
|
||||||
|
println!("{did} already points at {}", config.labeler_url);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
plc.verification_methods
|
||||||
|
.insert("atproto_label".into(), label_key.clone());
|
||||||
|
plc.services.insert("atproto_labeler".into(), labeler);
|
||||||
|
println!(
|
||||||
|
"will set on {did}:\n #atproto_label {label_key}\n #atproto_labeler {}",
|
||||||
|
config.labeler_url
|
||||||
|
);
|
||||||
|
|
||||||
|
request_signature(&agent).await?;
|
||||||
|
let token = prompt("code emailed to the account: ")?;
|
||||||
|
let signed = agent
|
||||||
|
.send(SignPlcOperation {
|
||||||
|
token: Some(token.as_str().into()),
|
||||||
|
verification_methods: Some(to_data(&plc.verification_methods)?),
|
||||||
|
services: Some(to_data(&plc.services)?),
|
||||||
|
also_known_as: None,
|
||||||
|
rotation_keys: None,
|
||||||
|
extra_data: None,
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
.into_output()?;
|
||||||
|
|
||||||
|
if prompt("submit this PLC operation? [y/N] ")? != "y" {
|
||||||
|
bail!("aborted; nothing submitted");
|
||||||
|
}
|
||||||
|
let response = agent
|
||||||
|
.send(SubmitPlcOperation {
|
||||||
|
operation: signed.operation,
|
||||||
|
extra_data: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
// success is an empty body, which into_output() can't decode as JSON
|
||||||
|
if !response.status().is_success() {
|
||||||
|
response.into_output()?;
|
||||||
|
bail!("submitPlcOperation failed");
|
||||||
|
}
|
||||||
|
println!("submitted; check {PLC_DIRECTORY}/{did}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the PDS to email a PLC operation code. Sent by hand because jacquard
|
||||||
|
/// serializes this no-input procedure as a `null` JSON body, which the PDS
|
||||||
|
/// rejects with "A request body was provided when none was expected".
|
||||||
|
async fn request_signature(agent: &Agent<MemoryCredentialSession>) -> Result<()> {
|
||||||
|
let Some(AuthorizationToken::Bearer(token)) = agent.inner().access_token().await else {
|
||||||
|
bail!("not logged in");
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"{}/xrpc/com.atproto.identity.requestPlcOperationSignature",
|
||||||
|
agent.endpoint().await.as_str().trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(token.as_str())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
bail!(
|
||||||
|
"requestPlcOperationSignature failed: {} {}",
|
||||||
|
response.status(),
|
||||||
|
response.text().await?
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt(label: &str) -> Result<String> {
|
||||||
|
print!("{label}");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
Ok(line.trim().to_owned())
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! One-time labeler setup.
|
||||||
|
//!
|
||||||
|
//! - `cargo run --bin setup -- declare` publishes the label definitions.
|
||||||
|
//! - `cargo run --bin setup -- did` publishes the labeler's identity entries.
|
||||||
|
mod declare;
|
||||||
|
mod did;
|
||||||
|
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use cities_labeler::config::Config;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let config = Config::from_env()?;
|
||||||
|
match std::env::args().nth(1).as_deref() {
|
||||||
|
Some("declare") => declare::run(&config).await,
|
||||||
|
Some("did") => did::run(&config).await,
|
||||||
|
_ => bail!("usage: setup <declare|did>"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ pub struct Config {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub db_path: String,
|
pub db_path: String,
|
||||||
pub cities_path: String,
|
pub cities_path: String,
|
||||||
|
/// Public HTTPS URL where this labeler serves its XRPC endpoints.
|
||||||
|
pub labeler_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn required(name: &str) -> Result<String> {
|
fn required(name: &str) -> Result<String> {
|
||||||
@@ -28,6 +30,7 @@ impl Config {
|
|||||||
port: optional("PORT", "4100").parse().context("PORT")?,
|
port: optional("PORT", "4100").parse().context("PORT")?,
|
||||||
db_path: optional("DB_PATH", "labeler.sqlite"),
|
db_path: optional("DB_PATH", "labeler.sqlite"),
|
||||||
cities_path: optional("CITIES_PATH", "data/cities.json"),
|
cities_path: optional("CITIES_PATH", "data/cities.json"),
|
||||||
|
labeler_url: optional("LABELER_URL", "https://cities-labeler.cosocial.ca"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user