From b4ac30c010bf871d7500972c9d6568a8bb23e812 Mon Sep 17 00:00:00 2001 From: gov Date: Fri, 6 Nov 2026 15:18:00 -0500 Subject: [PATCH] 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 --- Cargo.lock | 23 +++++ Cargo.toml | 2 + README.md | 21 ++-- src/bin/{setup.rs => setup/declare.rs} | 21 +--- src/bin/setup/did.rs | 138 +++++++++++++++++++++++++ src/bin/setup/main.rs | 19 ++++ src/config.rs | 3 + 7 files changed, 199 insertions(+), 28 deletions(-) rename src/bin/{setup.rs => setup/declare.rs} (80%) create mode 100644 src/bin/setup/did.rs create mode 100644 src/bin/setup/main.rs diff --git a/Cargo.lock b/Cargo.lock index 69bd142..cffc469 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,8 @@ dependencies = [ "jacquard-axum", "k256", "rand_core 0.6.4", + "reqwest", + "rpassword", "rusqlite", "serde", "serde_ipld_dagcbor", @@ -2847,6 +2849,17 @@ dependencies = [ "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]] name = "rsa" version = "0.9.10" @@ -2867,6 +2880,16 @@ dependencies = [ "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]] name = "rusqlite" version = "0.37.0" diff --git a/Cargo.toml b/Cargo.toml index 359ccac..f6ae9dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ jacquard = { version = "=0.12.2", default-features = false, features = ["api_blu jacquard-axum = "=0.12.2" k256 = { version = "0.13", features = ["ecdsa"] } 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"] } serde = { version = "1", features = ["derive"] } serde_ipld_dagcbor = "0.6" diff --git a/README.md b/README.md index 8136f18..f8eed59 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 `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. ## 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 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 +``` + diff --git a/src/bin/setup.rs b/src/bin/setup/declare.rs similarity index 80% rename from src/bin/setup.rs rename to src/bin/setup/declare.rs index 581c824..cc75edd 100644 --- a/src/bin/setup.rs +++ b/src/bin/setup/declare.rs @@ -1,9 +1,7 @@ -//! One-time labeler setup. -//! -//! `cargo run --bin setup -- declare` 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 re-run after editing the list. -use anyhow::{Result, bail}; +//! 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 +//! re-run after editing the city list; it overwrites the record. +use anyhow::Result; use cities_labeler::cities::Cities; use cities_labeler::config::Config; use cities_labeler::login; @@ -18,16 +16,7 @@ use jacquard::types::language::Language; use jacquard::types::recordkey::RecordKey; use jacquard::types::string::Datetime; -#[tokio::main] -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<()> { +pub async fn run(config: &Config) -> Result<()> { let cities = Cities::load(&config.cities_path)?; let (agent, did) = login(&config.handle, &config.app_password).await?; diff --git a/src/bin/setup/did.rs b/src/bin/setup/did.rs new file mode 100644 index 0000000..6c00d04 --- /dev/null +++ b/src/bin/setup/did.rs @@ -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, + services: BTreeMap, +} + +#[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) -> 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 { + print!("{label}"); + std::io::stdout().flush()?; + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + Ok(line.trim().to_owned()) +} diff --git a/src/bin/setup/main.rs b/src/bin/setup/main.rs new file mode 100644 index 0000000..e0842ff --- /dev/null +++ b/src/bin/setup/main.rs @@ -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 "), + } +} diff --git a/src/config.rs b/src/config.rs index 4c903ed..75bdaf2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,8 @@ pub struct Config { pub port: u16, pub db_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 { @@ -28,6 +30,7 @@ impl Config { port: optional("PORT", "4100").parse().context("PORT")?, db_path: optional("DB_PATH", "labeler.sqlite"), cities_path: optional("CITIES_PATH", "data/cities.json"), + labeler_url: optional("LABELER_URL", "https://cities-labeler.cosocial.ca"), }) } }