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
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user