`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>
139 lines
4.9 KiB
Rust
139 lines
4.9 KiB
Rust
//! 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())
|
|
}
|