wip: Custom mirroring tangled knot written in Rust
rust tangled mirror knot
1

Configure Feed

Select the types of activity you want to include in your feed.

did: prepare and sign dids

Clément (Jun 12, 2026, 10:08 PM +0200) ef65cfd7 eb359ca4

+138
+5
Cargo.lock
··· 2116 2116 version = "0.0.0" 2117 2117 dependencies = [ 2118 2118 "axum", 2119 + "base64 0.22.1", 2119 2120 "clap", 2121 + "data-encoding", 2120 2122 "jacquard", 2121 2123 "jacquard-axum", 2122 2124 "k256", ··· 2125 2127 "miette", 2126 2128 "multibase", 2127 2129 "rand 0.8.6", 2130 + "reqwest", 2128 2131 "serde", 2132 + "serde_ipld_dagcbor", 2129 2133 "serde_json", 2134 + "sha2", 2130 2135 "tokio", 2131 2136 "tower-http", 2132 2137 "tracing",
+6
Cargo.toml
··· 8 8 9 9 [workspace.dependencies] 10 10 axum = { version = "0.8", features = ["macros"] } 11 + base64 = "0.22" 11 12 clap = { version = "4.6", features = ["derive", "env"] } 13 + data-encoding = "2" 12 14 http = "1.4" 13 15 jacquard = "0.11" 14 16 jacquard-axum = { version = "0.11", features = ["service-auth"] } ··· 17 19 mime_guess = "2.0.5" 18 20 multibase = "0.9" 19 21 rand = "0.8" 22 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 20 23 rust-embed = "8.11.0" 21 24 serde = { version = "1.0", features = ["derive"] } 25 + serde_ipld_dagcbor = "0.6" 22 26 serde_json = "1.0" 27 + sha2 = "0.10" 28 + thiserror = "2.0" 23 29 tokio = { version = "1.52", features = ["rt", "macros", "rt-multi-thread"] } 24 30 tower-http = { version = "0.6", features = ["trace"] } 25 31 tracing = "0.1"
+5
crates/knotty-knot/Cargo.toml
··· 9 9 knotty-lexicons = { path = "../knotty-lexicons" } 10 10 11 11 axum.workspace = true 12 + base64.workspace = true 12 13 clap.workspace = true 14 + data-encoding.workspace = true 13 15 jacquard-axum.workspace = true 14 16 jacquard.workspace = true 15 17 k256.workspace = true 16 18 miette.workspace = true 17 19 multibase.workspace = true 18 20 rand.workspace = true 21 + reqwest.workspace = true 19 22 serde.workspace = true 23 + serde_ipld_dagcbor.workspace = true 20 24 serde_json.workspace = true 25 + sha2.workspace = true 21 26 tokio.workspace = true 22 27 tower-http.workspace = true 23 28 tracing-subscriber.workspace = true
+122
crates/knotty-knot/src/repodid.rs
··· 1 + use base64::Engine as _; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use data_encoding::BASE32_NOPAD; 4 + use k256::ecdsa::{Signature, SigningKey, signature::Signer}; 5 + use miette::{Context, IntoDiagnostic}; 6 + use serde_json::{Value, json}; 7 + use sha2::{Digest, Sha256}; 8 + 9 + pub struct PreparedDid { 10 + pub did: String, 11 + signing_key: SigningKey, 12 + signed_op: Value, 13 + } 14 + 15 + /// Generate a fresh secp256k1 keypair, build and sign a genesis `plc_operation`, 16 + /// and derive the `did:plc` from `sha256(dag-cbor(signed_op))`. 17 + pub fn prepare(knot_service_url: &str) -> miette::Result<PreparedDid> { 18 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 19 + let did_key = to_did_key(&signing_key); 20 + 21 + let unsigned_op = json!({ 22 + "type": "plc_operation", 23 + "rotationKeys": [did_key], 24 + "verificationMethods": { "atproto": did_key }, 25 + "alsoKnownAs": [], 26 + "services": { 27 + "atproto_pds": { 28 + "type": "AtprotoPersonalDataServer", 29 + "endpoint": knot_service_url 30 + } 31 + }, 32 + "prev": null 33 + }); 34 + 35 + let signed_op = sign(unsigned_op, &signing_key).wrap_err("dag-cbot sigining")?; 36 + let did = derive_did(&signed_op).wrap_err("deriving did")?; 37 + 38 + Ok(PreparedDid { 39 + did, 40 + signing_key, 41 + signed_op, 42 + }) 43 + } 44 + 45 + impl PreparedDid { 46 + /// Persist the signing key to `{storage_path}/{did}.key`. 47 + pub fn store_key(&self, storage_path: &std::path::Path) -> miette::Result<()> { 48 + let path = storage_path.join(format!("{}.key", self.did)); 49 + std::fs::write(path, self.signing_key.to_bytes()) 50 + .into_diagnostic() 51 + .wrap_err("writing signing key to disk")?; 52 + Ok(()) 53 + } 54 + 55 + /// POST the signed genesis operation to `{plc_url}/{did}`. 56 + pub async fn submit(&self, plc_url: &str, http_client: &reqwest::Client) -> miette::Result<()> { 57 + let url = format!("{}/{}", plc_url.trim_end_matches('/'), self.did); 58 + let resp = http_client 59 + .post(&url) 60 + .json(&self.signed_op) 61 + .send() 62 + .await 63 + .into_diagnostic() 64 + .wrap_err("submitting did to PLC")?; 65 + 66 + if !resp.status().is_success() { 67 + let status = resp.status(); 68 + let body = resp.text().await.unwrap_or_default(); 69 + miette::bail!("submitting to PLC failed returned - HTTP {status}: {body}") 70 + } 71 + 72 + Ok(()) 73 + } 74 + } 75 + 76 + /// Encode a secp256k1 signing key as a `did:key:z...` string. 77 + fn to_did_key(key: &SigningKey) -> String { 78 + let compressed = key.verifying_key().to_encoded_point(true); 79 + // secp256k1-pub multicodec varint: 0xe7 0x01 80 + let mut payload = vec![0xe7u8, 0x01]; 81 + payload.extend_from_slice(compressed.as_bytes()); 82 + format!( 83 + "did:key:{}", 84 + multibase::encode(multibase::Base::Base58Btc, &payload) 85 + ) 86 + } 87 + 88 + /// Strip any existing `sig`, DAG-CBOR encode, sign the bytes, insert `sig`. 89 + fn sign(mut op: Value, key: &SigningKey) -> miette::Result<Value> { 90 + if let Some(obj) = op.as_object_mut() { 91 + obj.remove("sig"); 92 + } 93 + 94 + let cbor = serde_ipld_dagcbor::to_vec(&op) 95 + .into_diagnostic() 96 + .wrap_err("cbor signing operation")?; 97 + 98 + let signature: Signature = key.sign(&cbor); 99 + // Normalize to low-S form as required by the ATProto crypto spec. 100 + let signature = signature.normalize_s().unwrap_or(signature); 101 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 102 + 103 + op.as_object_mut() 104 + .expect("op is an object") 105 + .insert("sig".to_string(), json!(sig_b64)); 106 + 107 + Ok(op) 108 + } 109 + 110 + /// Derive a `did:plc` from a signed genesis operation. 111 + /// 112 + /// Per the did:plc spec: `sha256(dag-cbor(signed_op))`, base32-lower, first 24 chars. 113 + fn derive_did(signed_op: &Value) -> miette::Result<String> { 114 + let cbor = serde_ipld_dagcbor::to_vec(signed_op) 115 + .into_diagnostic() 116 + .wrap_err("deriving did from signed operation")?; 117 + 118 + let hash = Sha256::digest(&cbor); 119 + let encoded = BASE32_NOPAD.encode(&hash).to_lowercase(); 120 + 121 + Ok(format!("did:plc:{}", &encoded[..24])) 122 + }