this repo has no description
14
fork

Configure Feed

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

feature: add unified atpdid CLI and expand PLC/webvh capabilities

Add the `atpdid` binary, a unified CLI tool for AT Protocol DID
operations including key management (generate, inspect), identity
resolution, PLC directory operations (verify, create, update, submit),
and did:webvh document management (verify, create, update).

Extend the PLC module with audit log fetching and operation submission,
add new error variants for PLC submission and audit log failures, and
introduce did:webvh genesis document creation and update entry support
with Ed25519 Data Integrity proofs.

Also adds a calendar event record field to atproto-record.

+1614 -1
+16 -1
CLAUDE.md
··· 33 33 - **Verify records**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record>` (verifies all signatures) 34 34 - **Verify attestation**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record> <attestation>` (verifies specific attestation) 35 35 36 + #### DID Management (atpdid) 37 + - **Generate keys**: `cargo run --features clap,hickory-dns --bin atpdid -- key generate p256` (supports p256, p384, k256, ed25519) 38 + - **Inspect keys**: `cargo run --features clap,hickory-dns --bin atpdid -- key inspect <did_key> [--jwk]` 39 + - **Resolve identities**: `cargo run --features clap,hickory-dns --bin atpdid -- resolve <handle_or_did> [--document]` 40 + - **Verify PLC audit log**: `cargo run --features clap,hickory-dns --bin atpdid -- plc verify <did_or_handle> [--verbose]` 41 + - **Create PLC identity**: `cargo run --features clap,hickory-dns --bin atpdid -- plc create --rotation-key <key>` 42 + - **Update PLC identity**: `cargo run --features clap,hickory-dns --bin atpdid -- plc update <did> --signing-key <key>` 43 + - **Submit PLC operation**: `cargo run --features clap,hickory-dns --bin atpdid -- plc submit <json> --did <did>` 44 + - **Verify did:webvh**: `cargo run --features clap,hickory-dns --bin atpdid -- webvh verify <did> [--verbose]` 45 + - **Create did:webvh**: `cargo run --features clap,hickory-dns --bin atpdid -- webvh create --hostname <host>` 46 + - **Update did:webvh**: `cargo run --features clap,hickory-dns --bin atpdid -- webvh update <did> --signing-key <key> --log-file <path> --state <json>` 47 + 36 48 #### DASL Operations 37 49 - **Compute CID**: `cargo run --features clap --bin atpcid -- '<json_or_data>'` (auto-detects record vs raw CID; use `-` for stdin, `--raw` to force RAW CID, `--record` to force DAG-CBOR record CID) 38 50 ··· 67 79 - **atproto-xrpcs-helloworld**: Complete example XRPC service 68 80 69 81 Features: 70 - - **13 CLI tools** with consistent clap-based command-line interfaces (optional via `clap` feature) 82 + - **14 CLI tools** with consistent clap-based command-line interfaces (optional via `clap` feature) 71 83 - **Rust edition 2024** with modern async/await patterns 72 84 - **Comprehensive error handling** with structured error types 73 85 - **Full test coverage** with unit tests across all modules ··· 163 175 164 176 #### DASL Operations (atproto-dasl) 165 177 - **`crates/atproto-dasl/src/bin/atpcid.rs`**: Compute CIDs from input data (auto-detects DAG-CBOR record vs RAW CID) 178 + 179 + #### DID Management (atproto-identity) 180 + - **`src/bin/atpdid.rs`**: Unified DID management tool — key generation/inspection, identity resolution, PLC audit/create/update/submit, and did:webvh verify/create/update 166 181 167 182 #### Identity Management (atproto-identity) 168 183 - **`src/bin/atproto-identity-resolve.rs`**: Resolve AT Protocol handles and DIDs to canonical identifiers
+7
crates/atproto-identity/Cargo.toml
··· 56 56 doc = true 57 57 required-features = ["clap"] 58 58 59 + [[bin]] 60 + name = "atpdid" 61 + test = false 62 + bench = false 63 + doc = true 64 + required-features = ["clap", "hickory-dns"] 65 + 59 66 [dependencies] 60 67 anyhow.workspace = true 61 68 async-trait.workspace = true
+1113
crates/atproto-identity/src/bin/atpdid.rs
··· 1 + //! AT Protocol DID resolution, verification, and management tool. 2 + //! 3 + //! A unified CLI for DID operations including key management, identity resolution, 4 + //! PLC directory operations, and did:webvh document management. 5 + 6 + use std::collections::HashMap; 7 + use std::io::Read as _; 8 + use std::process; 9 + 10 + use anyhow::Result; 11 + use atproto_identity::{ 12 + config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 13 + key::{self, KeyData, KeyType}, 14 + plc::{ 15 + self, AuditLogEntry, Did, DidBuilder, Operation, OperationChainValidator, PlcState, 16 + ServiceEndpoint, UnsignedOperation, 17 + }, 18 + resolve::{HickoryDnsResolver, InputType, parse_input, resolve_subject}, 19 + web::query as web_query, 20 + webvh, 21 + }; 22 + use clap::{Parser, Subcommand}; 23 + 24 + /// AT Protocol DID Management Tool 25 + #[derive(Parser)] 26 + #[command( 27 + name = "atpdid", 28 + version, 29 + about = "AT Protocol DID resolution, verification, and management", 30 + long_about = " 31 + A unified command-line tool for AT Protocol DID operations. Supports key 32 + management, identity resolution, PLC directory operations (verify, create, 33 + update, submit), and did:webvh document management. 34 + 35 + ENVIRONMENT VARIABLES: 36 + PLC_HOSTNAME PLC directory hostname (default: \"plc.directory\") 37 + USER_AGENT HTTP user agent string (default: auto-generated) 38 + CERTIFICATE_BUNDLES Colon-separated paths to additional CA certificates 39 + DNS_NAMESERVERS Comma-separated DNS nameserver addresses 40 + 41 + EXAMPLES: 42 + # Generate a P-256 key pair: 43 + atpdid key generate p256 44 + 45 + # Inspect a did:key: 46 + atpdid key inspect did:key:zDnaeXduWbJ1b1Kgjf3uCdCpMDF1LEDizUiyxAxGwerou3Nh2 47 + 48 + # Resolve a handle: 49 + atpdid resolve ngerakines.me 50 + 51 + # Verify a PLC audit log: 52 + atpdid plc verify did:plc:ewvi7nxzyoun6zhxrhs64oiz 53 + 54 + # Verify a did:webvh: 55 + atpdid webvh verify did:webvh:QmTest:example.com 56 + " 57 + )] 58 + struct Args { 59 + #[command(subcommand)] 60 + command: Commands, 61 + } 62 + 63 + #[derive(Subcommand)] 64 + enum Commands { 65 + /// Cryptographic key operations 66 + Key { 67 + #[command(subcommand)] 68 + command: KeyCommands, 69 + }, 70 + /// Resolve AT Protocol handles and DIDs 71 + Resolve { 72 + /// One or more AT Protocol handles or DIDs to resolve 73 + subjects: Vec<String>, 74 + 75 + /// Fetch and display the full DID document 76 + #[arg(long)] 77 + document: bool, 78 + }, 79 + /// PLC directory operations 80 + Plc { 81 + #[command(subcommand)] 82 + command: PlcCommands, 83 + }, 84 + /// WebVH (did:webvh) operations 85 + Webvh { 86 + #[command(subcommand)] 87 + command: WebvhCommands, 88 + }, 89 + } 90 + 91 + #[derive(Subcommand)] 92 + enum KeyCommands { 93 + /// Generate a new key pair 94 + Generate { 95 + /// Key type to generate 96 + #[arg(value_enum)] 97 + key_type: KeyTypeArg, 98 + 99 + /// Output JWK representation instead of multibase (P-256, P-384, K-256 only) 100 + #[arg(long)] 101 + jwk: bool, 102 + }, 103 + /// Inspect a key (did:key multibase string) 104 + Inspect { 105 + /// The key to inspect (did:key or multibase string) 106 + key: String, 107 + 108 + /// Output JWK representation (P-256 and P-384 only) 109 + #[arg(long)] 110 + jwk: bool, 111 + }, 112 + } 113 + 114 + #[derive(clap::ValueEnum, Clone)] 115 + enum KeyTypeArg { 116 + /// P-256 (secp256r1/ES256) 117 + P256, 118 + /// P-384 (secp384r1/ES384) 119 + P384, 120 + /// K-256 (secp256k1/ES256K) 121 + K256, 122 + /// Ed25519 (EdDSA) 123 + Ed25519, 124 + } 125 + 126 + #[derive(Subcommand)] 127 + enum PlcCommands { 128 + /// Verify a PLC audit log 129 + Verify { 130 + /// The DID or handle to verify 131 + #[arg(value_name = "DID_OR_HANDLE")] 132 + subject: String, 133 + 134 + /// Show verbose output including all operations 135 + #[arg(short, long)] 136 + verbose: bool, 137 + 138 + /// Only show summary 139 + #[arg(short, long)] 140 + quiet: bool, 141 + 142 + /// Custom PLC directory hostname 143 + #[arg(long)] 144 + plc_hostname: Option<String>, 145 + }, 146 + /// Create a new did:plc identity 147 + Create { 148 + /// Rotation key (multibase private key string, repeatable) 149 + #[arg(long = "rotation-key", required = true)] 150 + rotation_keys: Vec<String>, 151 + 152 + /// Verification method as name=key (repeatable) 153 + #[arg(long = "verification-method")] 154 + verification_methods: Vec<String>, 155 + 156 + /// Also-known-as URI (repeatable) 157 + #[arg(long = "also-known-as")] 158 + also_known_as: Vec<String>, 159 + 160 + /// Service as name=type,endpoint (repeatable) 161 + #[arg(long = "service")] 162 + services: Vec<String>, 163 + }, 164 + /// Create a PLC update operation 165 + Update { 166 + /// The DID to update 167 + did: String, 168 + 169 + /// Private key to sign the operation (multibase) 170 + #[arg(long = "signing-key")] 171 + signing_key: String, 172 + 173 + /// New rotation key (multibase, repeatable) 174 + #[arg(long = "rotation-key")] 175 + rotation_keys: Vec<String>, 176 + 177 + /// New verification method as name=key (repeatable) 178 + #[arg(long = "verification-method")] 179 + verification_methods: Vec<String>, 180 + 181 + /// New also-known-as URI (repeatable) 182 + #[arg(long = "also-known-as")] 183 + also_known_as: Vec<String>, 184 + 185 + /// New service as name=type,endpoint (repeatable) 186 + #[arg(long = "service")] 187 + services: Vec<String>, 188 + 189 + /// Custom PLC directory hostname 190 + #[arg(long)] 191 + plc_hostname: Option<String>, 192 + }, 193 + /// Sign an unsigned PLC operation (reads JSON from stdin) 194 + Sign { 195 + /// Private key to sign the operation (multibase) 196 + #[arg(long = "signing-key")] 197 + signing_key: String, 198 + }, 199 + /// Submit a signed PLC operation 200 + Submit { 201 + /// Signed operation JSON (use - for stdin) 202 + operation: String, 203 + 204 + /// The DID to submit the operation for 205 + #[arg(long)] 206 + did: String, 207 + 208 + /// Custom PLC directory hostname 209 + #[arg(long)] 210 + plc_hostname: Option<String>, 211 + }, 212 + } 213 + 214 + #[derive(Subcommand)] 215 + enum WebvhCommands { 216 + /// Verify a did:webvh log 217 + Verify { 218 + /// The did:webvh DID to verify 219 + did: String, 220 + 221 + /// Show verbose output 222 + #[arg(short, long)] 223 + verbose: bool, 224 + }, 225 + /// Create a new did:webvh genesis document 226 + Create { 227 + /// Hostname for the DID 228 + #[arg(long)] 229 + hostname: String, 230 + 231 + /// Optional path component 232 + #[arg(long)] 233 + path: Option<String>, 234 + 235 + /// Ed25519 private key (multibase); generates one if not provided 236 + #[arg(long = "signing-key")] 237 + signing_key: Option<String>, 238 + 239 + /// Also generate a did:web did.json document 240 + #[arg(long = "did-web")] 241 + did_web: bool, 242 + }, 243 + /// Create an update entry for an existing did:webvh 244 + Update { 245 + /// The did:webvh DID to update 246 + did: String, 247 + 248 + /// Ed25519 private key (multibase) for signing 249 + #[arg(long = "signing-key")] 250 + signing_key: String, 251 + 252 + /// Path to the current did.jsonl file (use - for stdin) 253 + #[arg(long = "log-file")] 254 + log_file: String, 255 + 256 + /// New DID document state as JSON string 257 + #[arg(long = "state")] 258 + state: String, 259 + 260 + /// New update keys (multibase, repeatable) 261 + #[arg(long = "update-key")] 262 + update_keys: Vec<String>, 263 + }, 264 + } 265 + 266 + #[tokio::main] 267 + async fn main() -> Result<()> { 268 + let args = Args::parse(); 269 + 270 + match args.command { 271 + Commands::Key { command } => handle_key(command), 272 + Commands::Resolve { subjects, document } => handle_resolve(subjects, document).await, 273 + Commands::Plc { command } => handle_plc(command).await, 274 + Commands::Webvh { command } => handle_webvh(command).await, 275 + } 276 + } 277 + 278 + // --------------------------------------------------------------------------- 279 + // Key commands 280 + // --------------------------------------------------------------------------- 281 + 282 + fn handle_key(command: KeyCommands) -> Result<()> { 283 + match command { 284 + KeyCommands::Generate { key_type, jwk } => { 285 + let (kt, label) = match key_type { 286 + KeyTypeArg::P256 => (KeyType::P256Private, "p256"), 287 + KeyTypeArg::P384 => (KeyType::P384Private, "p384"), 288 + KeyTypeArg::K256 => (KeyType::K256Private, "k256"), 289 + KeyTypeArg::Ed25519 => (KeyType::Ed25519Private, "ed25519"), 290 + }; 291 + 292 + let private_key = key::generate_key(kt)?; 293 + let public_key = key::to_public(&private_key)?; 294 + 295 + if jwk { 296 + let jwk_result: Result<elliptic_curve::JwkEcKey, _> = (&private_key).try_into(); 297 + match jwk_result { 298 + Ok(jwk_key) => { 299 + println!("{}", serde_json::to_string_pretty(&jwk_key)?); 300 + } 301 + Err(e) => { 302 + eprintln!("JWK output not available for {}: {}", label, e); 303 + process::exit(1); 304 + } 305 + } 306 + } else { 307 + println!("{} private: {}", label, private_key); 308 + println!("{} public: {}", label, public_key); 309 + } 310 + 311 + Ok(()) 312 + } 313 + KeyCommands::Inspect { key, jwk } => { 314 + let key_data = if key.trim_start().starts_with('{') { 315 + parse_jwk_to_key_data(&key)? 316 + } else { 317 + key::identify_key(&key)? 318 + }; 319 + println!("Key type: {}", key_data.key_type()); 320 + 321 + // Derive public key if this is a private key 322 + match key::to_public(&key_data) { 323 + Ok(public) => { 324 + println!("Public key: {}", public); 325 + } 326 + Err(_) => { 327 + // Already a public key 328 + println!("Public key: {}", key_data); 329 + } 330 + } 331 + 332 + if jwk { 333 + let jwk_result: Result<elliptic_curve::JwkEcKey, _> = (&key_data).try_into(); 334 + match jwk_result { 335 + Ok(jwk_key) => { 336 + println!("JWK: {}", serde_json::to_string_pretty(&jwk_key)?); 337 + } 338 + Err(e) => { 339 + eprintln!("JWK conversion not available: {}", e); 340 + } 341 + } 342 + } 343 + 344 + Ok(()) 345 + } 346 + } 347 + } 348 + 349 + // --------------------------------------------------------------------------- 350 + // Resolve commands 351 + // --------------------------------------------------------------------------- 352 + 353 + async fn handle_resolve(subjects: Vec<String>, document: bool) -> Result<()> { 354 + let (http_client, dns_resolver, plc_hostname) = build_http_context()?; 355 + 356 + for subject in subjects { 357 + let resolved_did = match resolve_subject(&http_client, &dns_resolver, &subject).await { 358 + Ok(value) => value, 359 + Err(err) => { 360 + eprintln!("{err}"); 361 + continue; 362 + } 363 + }; 364 + 365 + if !document { 366 + println!("{resolved_did}"); 367 + continue; 368 + } 369 + 370 + let did_document: Result<_, anyhow::Error> = match parse_input(&resolved_did) { 371 + Ok(InputType::Plc(did)) => plc::query(&http_client, &plc_hostname, &did) 372 + .await 373 + .map_err(Into::into), 374 + Ok(InputType::Web(did)) => web_query(&http_client, &did).await.map_err(Into::into), 375 + Ok(InputType::WebVH(did)) => webvh::query(&http_client, &did).await.map_err(Into::into), 376 + Ok(InputType::Handle(_)) => { 377 + eprintln!("error: subject resolved to handle"); 378 + continue; 379 + } 380 + Err(err) => { 381 + eprintln!("{err}"); 382 + continue; 383 + } 384 + }; 385 + 386 + match did_document { 387 + Ok(value) => println!("{}", serde_json::to_string_pretty(&value)?), 388 + Err(err) => { 389 + eprintln!("{err}"); 390 + continue; 391 + } 392 + } 393 + } 394 + 395 + Ok(()) 396 + } 397 + 398 + // --------------------------------------------------------------------------- 399 + // PLC commands 400 + // --------------------------------------------------------------------------- 401 + 402 + async fn handle_plc(command: PlcCommands) -> Result<()> { 403 + match command { 404 + PlcCommands::Verify { 405 + subject, 406 + verbose, 407 + quiet, 408 + plc_hostname: plc_hostname_override, 409 + } => { 410 + let (http_client, dns_resolver, default_plc_hostname) = build_http_context()?; 411 + let plc_hostname = plc_hostname_override.unwrap_or(default_plc_hostname); 412 + 413 + // Resolve subject to DID if it's a handle 414 + let did = match parse_input(&subject) { 415 + Ok(InputType::Plc(did)) => did, 416 + Ok(InputType::Handle(_)) => { 417 + resolve_subject(&http_client, &dns_resolver, &subject).await? 418 + } 419 + _ => { 420 + eprintln!("error: subject must be a did:plc or handle"); 421 + process::exit(1); 422 + } 423 + }; 424 + 425 + let parsed_did = Did::parse(&did) 426 + .map_err(|e| { 427 + eprintln!("Error: Invalid DID format: {}", e); 428 + process::exit(1); 429 + }) 430 + .unwrap(); 431 + 432 + if !quiet { 433 + println!("Fetching audit log for: {}", parsed_did); 434 + println!(" Source: {}", plc_hostname); 435 + println!(); 436 + } 437 + 438 + let audit_log = 439 + plc::fetch_audit_log(&http_client, &plc_hostname, parsed_did.as_str()).await?; 440 + 441 + if audit_log.is_empty() { 442 + eprintln!("Error: No operations found in audit log"); 443 + process::exit(1); 444 + } 445 + 446 + if !quiet { 447 + println!("Audit Log Summary:"); 448 + println!(" Total operations: {}", audit_log.len()); 449 + println!(" Genesis operation: {}", audit_log[0].cid); 450 + println!(" Latest operation: {}", audit_log.last().unwrap().cid); 451 + println!(); 452 + } 453 + 454 + if verbose { 455 + println!("Operations:"); 456 + for (i, entry) in audit_log.iter().enumerate() { 457 + let status = if entry.nullified { "NULLIFIED" } else { "OK" }; 458 + println!(" [{}] {} {} - {}", i, status, entry.cid, entry.created_at); 459 + if entry.operation.is_genesis() { 460 + println!(" Type: Genesis (creates the DID)"); 461 + } else { 462 + println!(" Type: Update"); 463 + } 464 + if let Some(prev) = entry.operation.prev() { 465 + println!(" Previous: {}", prev); 466 + } 467 + } 468 + println!(); 469 + } 470 + 471 + // Check for forks and nullified operations 472 + let has_forks = detect_forks(&audit_log); 473 + let has_nullified = audit_log.iter().any(|e| e.nullified); 474 + 475 + if has_forks || has_nullified { 476 + if !quiet { 477 + if has_forks { 478 + println!("Fork detected - multiple operations reference the same prev CID"); 479 + } 480 + if has_nullified { 481 + println!( 482 + "Nullified operations detected - will validate canonical chain only" 483 + ); 484 + } 485 + println!(); 486 + } 487 + 488 + let operations: Vec<_> = audit_log.iter().map(|e| e.operation.clone()).collect(); 489 + let timestamps: Vec<_> = audit_log 490 + .iter() 491 + .map(|e| { 492 + e.created_at 493 + .parse::<chrono::DateTime<chrono::Utc>>() 494 + .unwrap_or_else(|_| chrono::Utc::now()) 495 + }) 496 + .collect(); 497 + 498 + match OperationChainValidator::validate_chain_with_forks(&operations, &timestamps) { 499 + Ok(final_state) => { 500 + display_final_state(&final_state, quiet); 501 + } 502 + Err(e) => { 503 + eprintln!("Validation failed: {}", e); 504 + process::exit(1); 505 + } 506 + } 507 + } else { 508 + // Linear chain validation 509 + validate_linear_chain(&audit_log, verbose)?; 510 + 511 + // Validate signatures 512 + validate_signatures(&audit_log, verbose); 513 + 514 + // Display final state 515 + let final_entry = audit_log.last().unwrap(); 516 + if let Some(final_state) = extract_state(&final_entry.operation) { 517 + display_final_state(&final_state, quiet); 518 + } else { 519 + eprintln!("Error: Could not extract final state"); 520 + process::exit(1); 521 + } 522 + } 523 + 524 + Ok(()) 525 + } 526 + PlcCommands::Create { 527 + rotation_keys, 528 + verification_methods, 529 + also_known_as, 530 + services, 531 + } => { 532 + let mut builder = DidBuilder::new(); 533 + 534 + for rk_str in &rotation_keys { 535 + let key_data = key::identify_key(rk_str)?; 536 + builder = builder.add_rotation_key(key_data); 537 + } 538 + 539 + for vm_str in &verification_methods { 540 + let (name, key_str) = parse_key_value_pair(vm_str, "verification-method")?; 541 + let key_data = key::identify_key(&key_str)?; 542 + builder = builder.add_verification_method(name, key_data); 543 + } 544 + 545 + for uri in also_known_as { 546 + builder = builder.add_also_known_as(uri); 547 + } 548 + 549 + for svc_str in &services { 550 + let (name, rest) = parse_key_value_pair(svc_str, "service")?; 551 + let (svc_type, endpoint) = parse_service_value(&rest)?; 552 + builder = builder.add_service(name, ServiceEndpoint::new(svc_type, endpoint)); 553 + } 554 + 555 + let (did, operation, _keys) = builder.build()?; 556 + println!("{}", did); 557 + println!(); 558 + println!("{}", serde_json::to_string_pretty(&operation)?); 559 + 560 + Ok(()) 561 + } 562 + PlcCommands::Update { 563 + did, 564 + signing_key, 565 + rotation_keys, 566 + verification_methods, 567 + also_known_as, 568 + services, 569 + plc_hostname: plc_hostname_override, 570 + } => { 571 + let (http_client, _dns_resolver, default_plc_hostname) = build_http_context()?; 572 + let plc_hostname = plc_hostname_override.unwrap_or(default_plc_hostname); 573 + 574 + // Fetch current audit log to get the latest CID 575 + let audit_log = plc::fetch_audit_log(&http_client, &plc_hostname, &did).await?; 576 + if audit_log.is_empty() { 577 + eprintln!("Error: No operations found for DID"); 578 + process::exit(1); 579 + } 580 + 581 + let latest = audit_log.last().unwrap(); 582 + let prev_cid = latest.operation.cid()?; 583 + 584 + // Parse signing key 585 + let signing_key_data = key::identify_key(&signing_key)?; 586 + 587 + // Build rotation keys 588 + let rk_strings: Vec<String> = if rotation_keys.is_empty() { 589 + latest 590 + .operation 591 + .rotation_keys() 592 + .map(|rks| rks.to_vec()) 593 + .unwrap_or_default() 594 + } else { 595 + rotation_keys 596 + }; 597 + 598 + // Build verification methods 599 + let mut vm_map: HashMap<String, String> = HashMap::new(); 600 + for vm_str in &verification_methods { 601 + let (name, key_str) = parse_key_value_pair(vm_str, "verification-method")?; 602 + vm_map.insert(name, key_str); 603 + } 604 + 605 + // Build services 606 + let mut svc_map: HashMap<String, ServiceEndpoint> = HashMap::new(); 607 + for svc_str in &services { 608 + let (name, rest) = parse_key_value_pair(svc_str, "service")?; 609 + let (svc_type, endpoint) = parse_service_value(&rest)?; 610 + svc_map.insert(name, ServiceEndpoint::new(svc_type, endpoint)); 611 + } 612 + 613 + let unsigned = 614 + Operation::new_update(rk_strings, vm_map, also_known_as, svc_map, prev_cid); 615 + 616 + let signed = unsigned.sign(&signing_key_data)?; 617 + println!("{}", serde_json::to_string_pretty(&signed)?); 618 + 619 + Ok(()) 620 + } 621 + PlcCommands::Sign { signing_key } => { 622 + let signing_key_data = key::identify_key(&signing_key)?; 623 + 624 + let mut buf = String::new(); 625 + std::io::stdin().read_to_string(&mut buf)?; 626 + 627 + let unsigned: UnsignedOperation = serde_json::from_str(&buf)?; 628 + let signed = unsigned.sign(&signing_key_data)?; 629 + 630 + println!("{}", serde_json::to_string_pretty(&signed)?); 631 + 632 + Ok(()) 633 + } 634 + PlcCommands::Submit { 635 + operation, 636 + did, 637 + plc_hostname: plc_hostname_override, 638 + } => { 639 + let (http_client, _dns_resolver, default_plc_hostname) = build_http_context()?; 640 + let plc_hostname = plc_hostname_override.unwrap_or(default_plc_hostname); 641 + 642 + let op_json = if operation == "-" { 643 + let mut buf = String::new(); 644 + std::io::stdin().read_to_string(&mut buf)?; 645 + buf 646 + } else { 647 + operation 648 + }; 649 + 650 + let op: Operation = serde_json::from_str(&op_json)?; 651 + plc::submit(&http_client, &plc_hostname, &did, &op).await?; 652 + println!("Operation submitted successfully"); 653 + 654 + Ok(()) 655 + } 656 + } 657 + } 658 + 659 + // --------------------------------------------------------------------------- 660 + // WebVH commands 661 + // --------------------------------------------------------------------------- 662 + 663 + async fn handle_webvh(command: WebvhCommands) -> Result<()> { 664 + match command { 665 + WebvhCommands::Verify { did, verbose } => { 666 + let (http_client, _dns_resolver, _plc_hostname) = build_http_context()?; 667 + 668 + let scid = webvh::extract_scid(&did)?; 669 + let url = webvh::did_webvh_to_url(&did)?; 670 + 671 + if verbose { 672 + println!("Resolving: {}", did); 673 + println!(" SCID: {}", scid); 674 + println!(" URL: {}", url); 675 + println!(); 676 + } 677 + 678 + let body = http_client 679 + .get(&url) 680 + .send() 681 + .await 682 + .map_err(|e| anyhow::anyhow!("HTTP request failed: {}", e))? 683 + .text() 684 + .await 685 + .map_err(|e| anyhow::anyhow!("Failed to read response: {}", e))?; 686 + 687 + let resolved = webvh::log::process_log(&did, &scid, &body)?; 688 + 689 + if verbose { 690 + println!("Log entries: {}", resolved.entry_count); 691 + println!("Version: {}", resolved.version_id); 692 + println!("Version time: {}", resolved.version_time); 693 + println!("Method: {}", resolved.parameters.method); 694 + println!("SCID: {}", resolved.parameters.scid); 695 + println!("Portable: {}", resolved.parameters.portable); 696 + println!("Deactivated: {}", resolved.parameters.deactivated); 697 + println!("TTL: {}s", resolved.parameters.ttl); 698 + if !resolved.parameters.update_keys.is_empty() { 699 + println!("Update keys:"); 700 + for uk in &resolved.parameters.update_keys { 701 + println!(" {}", uk); 702 + } 703 + } 704 + println!(); 705 + } 706 + 707 + // Check for witness configuration and re-verify with witnesses if present 708 + if resolved.parameters.witness.is_some() { 709 + let witness_url = webvh::did_webvh_to_witness_url(&did)?; 710 + if verbose { 711 + println!("Witness configuration detected, fetching: {}", witness_url); 712 + } 713 + let witness_body = http_client 714 + .get(&witness_url) 715 + .send() 716 + .await 717 + .map_err(|e| anyhow::anyhow!("HTTP request failed: {}", e))? 718 + .text() 719 + .await 720 + .map_err(|e| anyhow::anyhow!("Failed to read response: {}", e))?; 721 + 722 + let witness_proofs: Vec<webvh::WitnessProofEntry> = 723 + serde_json::from_str(&witness_body)?; 724 + 725 + let resolved = webvh::log::process_log_with_witnesses( 726 + &did, 727 + &scid, 728 + &body, 729 + Some(&witness_proofs), 730 + )?; 731 + 732 + println!("{}", serde_json::to_string_pretty(&resolved.document)?); 733 + } else { 734 + println!("{}", serde_json::to_string_pretty(&resolved.document)?); 735 + } 736 + 737 + if verbose { 738 + println!(); 739 + println!("Verification successful!"); 740 + } 741 + 742 + Ok(()) 743 + } 744 + WebvhCommands::Create { 745 + hostname, 746 + path, 747 + signing_key, 748 + did_web, 749 + } => { 750 + let key_data = match signing_key { 751 + Some(k) => Some(key::identify_key(&k)?), 752 + None => None, 753 + }; 754 + 755 + let result = 756 + webvh::create::create_genesis(&hostname, path.as_deref(), key_data.as_ref())?; 757 + 758 + println!("DID: {}", result.did); 759 + println!("SCID: {}", result.scid); 760 + println!("Update key: {}", result.update_key); 761 + println!(); 762 + println!("did.jsonl:"); 763 + println!("{}", result.jsonl); 764 + 765 + if did_web { 766 + let did_web_suffix = match &path { 767 + Some(p) => format!("{}:{}", hostname, p.replace('/', ":")), 768 + None => hostname.clone(), 769 + }; 770 + let did_web_id = format!("did:web:{}", did_web_suffix); 771 + 772 + let multikey = result 773 + .update_key 774 + .strip_prefix("did:key:") 775 + .unwrap_or(&result.update_key); 776 + 777 + let did_doc = serde_json::json!({ 778 + "@context": [ 779 + "https://www.w3.org/ns/did/v1", 780 + "https://w3id.org/security/multikey/v1" 781 + ], 782 + "id": did_web_id, 783 + "alsoKnownAs": [result.did], 784 + "authentication": [{ 785 + "id": format!("{}#{}", did_web_id, multikey), 786 + "type": "Multikey", 787 + "controller": &did_web_id, 788 + "publicKeyMultibase": multikey, 789 + }], 790 + }); 791 + 792 + println!(); 793 + println!("did:web DID: {}", did_web_id); 794 + println!(); 795 + println!("did.json:"); 796 + println!("{}", serde_json::to_string_pretty(&did_doc)?); 797 + } 798 + 799 + Ok(()) 800 + } 801 + WebvhCommands::Update { 802 + did, 803 + signing_key, 804 + log_file, 805 + state, 806 + update_keys, 807 + } => { 808 + let signing_key_data = key::identify_key(&signing_key)?; 809 + 810 + let current_log = if log_file == "-" { 811 + let mut buf = String::new(); 812 + std::io::stdin().read_to_string(&mut buf)?; 813 + buf 814 + } else { 815 + std::fs::read_to_string(&log_file)? 816 + }; 817 + 818 + let new_state: serde_json::Value = serde_json::from_str(&state)?; 819 + let new_keys = if update_keys.is_empty() { 820 + None 821 + } else { 822 + Some(update_keys) 823 + }; 824 + 825 + let update_line = webvh::create::create_update_entry( 826 + &did, 827 + &current_log, 828 + new_state, 829 + &signing_key_data, 830 + new_keys, 831 + )?; 832 + 833 + println!("{}", update_line); 834 + 835 + Ok(()) 836 + } 837 + } 838 + } 839 + 840 + // --------------------------------------------------------------------------- 841 + // Helpers 842 + // --------------------------------------------------------------------------- 843 + 844 + /// Build the HTTP client, DNS resolver, and PLC hostname from environment. 845 + fn build_http_context() -> Result<(reqwest::Client, HickoryDnsResolver, String)> { 846 + let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 847 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 848 + let default_user_agent = format!( 849 + "atpdid ({}; +https://tangled.org/ngerakines.me/atproto-crates)", 850 + version()? 851 + ); 852 + let user_agent = default_env("USER_AGENT", &default_user_agent); 853 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 854 + 855 + let mut client_builder = reqwest::Client::builder(); 856 + for ca_certificate in certificate_bundles.as_ref() { 857 + let cert = std::fs::read(ca_certificate)?; 858 + let cert = reqwest::Certificate::from_pem(&cert)?; 859 + client_builder = client_builder.add_root_certificate(cert); 860 + } 861 + client_builder = client_builder.user_agent(user_agent); 862 + let http_client = client_builder.build()?; 863 + 864 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 865 + 866 + Ok((http_client, dns_resolver, plc_hostname)) 867 + } 868 + 869 + /// Parse a JWK JSON string into a KeyData. 870 + /// 871 + /// Supports P-256, P-384, and K-256 EC keys. Detects private vs public 872 + /// based on the presence of the `d` parameter. 873 + fn parse_jwk_to_key_data(jwk_json: &str) -> Result<KeyData> { 874 + use elliptic_curve::sec1::ToEncodedPoint; 875 + 876 + let jwk: elliptic_curve::JwkEcKey = 877 + serde_json::from_str(jwk_json).map_err(|e| anyhow::anyhow!("invalid JWK: {}", e))?; 878 + 879 + let crv = jwk.crv(); 880 + let has_private = serde_json::to_value(&jwk)? 881 + .get("d") 882 + .is_some_and(|d| d.is_string()); 883 + 884 + match (crv, has_private) { 885 + ("P-256", true) => { 886 + let secret = p256::SecretKey::from_jwk(&jwk) 887 + .map_err(|e| anyhow::anyhow!("invalid P-256 private JWK: {}", e))?; 888 + Ok(KeyData::new( 889 + KeyType::P256Private, 890 + secret.to_bytes().to_vec(), 891 + )) 892 + } 893 + ("P-256", false) => { 894 + let public = p256::PublicKey::from_jwk(&jwk) 895 + .map_err(|e| anyhow::anyhow!("invalid P-256 public JWK: {}", e))?; 896 + Ok(KeyData::new( 897 + KeyType::P256Public, 898 + public.to_encoded_point(true).as_bytes().to_vec(), 899 + )) 900 + } 901 + ("P-384", true) => { 902 + let secret = p384::SecretKey::from_jwk(&jwk) 903 + .map_err(|e| anyhow::anyhow!("invalid P-384 private JWK: {}", e))?; 904 + Ok(KeyData::new( 905 + KeyType::P384Private, 906 + secret.to_bytes().to_vec(), 907 + )) 908 + } 909 + ("P-384", false) => { 910 + let public = p384::PublicKey::from_jwk(&jwk) 911 + .map_err(|e| anyhow::anyhow!("invalid P-384 public JWK: {}", e))?; 912 + Ok(KeyData::new( 913 + KeyType::P384Public, 914 + public.to_encoded_point(true).as_bytes().to_vec(), 915 + )) 916 + } 917 + ("secp256k1", true) => { 918 + let secret = k256::SecretKey::from_jwk(&jwk) 919 + .map_err(|e| anyhow::anyhow!("invalid K-256 private JWK: {}", e))?; 920 + Ok(KeyData::new( 921 + KeyType::K256Private, 922 + secret.to_bytes().to_vec(), 923 + )) 924 + } 925 + ("secp256k1", false) => { 926 + let public = k256::PublicKey::from_jwk(&jwk) 927 + .map_err(|e| anyhow::anyhow!("invalid K-256 public JWK: {}", e))?; 928 + Ok(KeyData::new( 929 + KeyType::K256Public, 930 + public.to_encoded_point(true).as_bytes().to_vec(), 931 + )) 932 + } 933 + (crv, _) => Err(anyhow::anyhow!("unsupported JWK curve: {}", crv)), 934 + } 935 + } 936 + 937 + /// Parse a `name=value` pair from a CLI argument. 938 + fn parse_key_value_pair(s: &str, arg_name: &str) -> Result<(String, String)> { 939 + let (name, value) = s 940 + .split_once('=') 941 + .ok_or_else(|| anyhow::anyhow!("invalid --{} format, expected name=value", arg_name))?; 942 + Ok((name.to_string(), value.to_string())) 943 + } 944 + 945 + /// Parse a service value in the format `type,endpoint`. 946 + fn parse_service_value(s: &str) -> Result<(String, String)> { 947 + let (svc_type, endpoint) = s 948 + .split_once(',') 949 + .ok_or_else(|| anyhow::anyhow!("invalid --service value format, expected type,endpoint"))?; 950 + Ok((svc_type.to_string(), endpoint.to_string())) 951 + } 952 + 953 + /// Detect if there are fork points in the audit log. 954 + fn detect_forks(audit_log: &[AuditLogEntry]) -> bool { 955 + let mut prev_counts: HashMap<String, usize> = HashMap::new(); 956 + for entry in audit_log { 957 + if let Some(prev) = entry.operation.prev() { 958 + *prev_counts.entry(prev.to_string()).or_insert(0) += 1; 959 + } 960 + } 961 + prev_counts.values().any(|&count| count > 1) 962 + } 963 + 964 + /// Validate a linear chain (no forks). 965 + fn validate_linear_chain(audit_log: &[AuditLogEntry], verbose: bool) -> Result<()> { 966 + if verbose { 967 + println!("Chain Validation:"); 968 + } 969 + 970 + for i in 1..audit_log.len() { 971 + let prev_cid = &audit_log[i - 1].cid; 972 + let expected_prev = audit_log[i].operation.prev(); 973 + 974 + if let Some(actual_prev) = expected_prev { 975 + if actual_prev != prev_cid { 976 + eprintln!("Validation failed: Chain linkage broken at operation {}", i); 977 + eprintln!(" Expected prev: {}", prev_cid); 978 + eprintln!(" Actual prev: {}", actual_prev); 979 + process::exit(1); 980 + } 981 + if verbose { 982 + println!(" [{}] Chain link valid", i); 983 + } 984 + } else { 985 + eprintln!( 986 + "Validation failed: Non-genesis operation {} missing prev field", 987 + i 988 + ); 989 + process::exit(1); 990 + } 991 + } 992 + 993 + if verbose { 994 + println!(); 995 + } 996 + 997 + Ok(()) 998 + } 999 + 1000 + /// Validate cryptographic signatures on audit log entries. 1001 + fn validate_signatures(audit_log: &[AuditLogEntry], verbose: bool) { 1002 + if verbose { 1003 + println!("Signature Validation:"); 1004 + } 1005 + 1006 + let mut current_rotation_keys: Vec<String> = Vec::new(); 1007 + 1008 + for (i, entry) in audit_log.iter().enumerate() { 1009 + if entry.nullified { 1010 + continue; 1011 + } 1012 + 1013 + if i == 0 { 1014 + if let Some(rotation_keys) = entry.operation.rotation_keys() { 1015 + current_rotation_keys = rotation_keys.to_vec(); 1016 + if verbose { 1017 + println!(" [{}] Genesis - {} rotation keys", i, rotation_keys.len()); 1018 + } 1019 + } 1020 + continue; 1021 + } 1022 + 1023 + if !current_rotation_keys.is_empty() { 1024 + let verifying_keys: Vec<KeyData> = current_rotation_keys 1025 + .iter() 1026 + .filter_map(|k| { 1027 + let key_data = key::identify_key(k).ok()?; 1028 + key::to_public(&key_data).ok() 1029 + }) 1030 + .collect(); 1031 + 1032 + if let Err(e) = entry.operation.verify(&verifying_keys) { 1033 + eprintln!("Validation failed: Invalid signature at operation {}", i); 1034 + eprintln!(" Error: {}", e); 1035 + process::exit(1); 1036 + } 1037 + 1038 + if verbose { 1039 + println!(" [{}] Signature verified", i); 1040 + } 1041 + } 1042 + 1043 + if let Some(new_rotation_keys) = entry.operation.rotation_keys() 1044 + && new_rotation_keys != current_rotation_keys 1045 + { 1046 + current_rotation_keys = new_rotation_keys.to_vec(); 1047 + } 1048 + } 1049 + 1050 + if verbose { 1051 + println!(); 1052 + } 1053 + } 1054 + 1055 + /// Extract PLC state from an operation. 1056 + fn extract_state(operation: &Operation) -> Option<PlcState> { 1057 + match operation { 1058 + Operation::PlcOperation { 1059 + rotation_keys, 1060 + verification_methods, 1061 + also_known_as, 1062 + services, 1063 + .. 1064 + } => Some(PlcState { 1065 + rotation_keys: rotation_keys.clone(), 1066 + verification_methods: verification_methods.clone(), 1067 + also_known_as: also_known_as.clone(), 1068 + services: services.clone(), 1069 + }), 1070 + _ => None, 1071 + } 1072 + } 1073 + 1074 + /// Display the final PLC state. 1075 + fn display_final_state(final_state: &PlcState, quiet: bool) { 1076 + if quiet { 1077 + println!("VALID"); 1078 + return; 1079 + } 1080 + 1081 + println!("Validation successful!"); 1082 + println!(); 1083 + println!("Final DID State:"); 1084 + println!(" Rotation keys: {}", final_state.rotation_keys.len()); 1085 + for (i, rk) in final_state.rotation_keys.iter().enumerate() { 1086 + println!(" [{}] {}", i, rk); 1087 + } 1088 + println!(); 1089 + println!( 1090 + " Verification methods: {}", 1091 + final_state.verification_methods.len() 1092 + ); 1093 + for (name, vm_key) in &final_state.verification_methods { 1094 + println!(" {}: {}", name, vm_key); 1095 + } 1096 + println!(); 1097 + if !final_state.also_known_as.is_empty() { 1098 + println!(" Also known as: {}", final_state.also_known_as.len()); 1099 + for uri in &final_state.also_known_as { 1100 + println!(" - {}", uri); 1101 + } 1102 + println!(); 1103 + } 1104 + if !final_state.services.is_empty() { 1105 + println!(" Services: {}", final_state.services.len()); 1106 + for (name, service) in &final_state.services { 1107 + println!( 1108 + " {}: {} ({})", 1109 + name, service.endpoint, service.service_type 1110 + ); 1111 + } 1112 + } 1113 + }
+50
crates/atproto-identity/src/errors.rs
··· 303 303 /// Details about the invalid timestamp 304 304 details: String, 305 305 }, 306 + 307 + /// Occurs when submitting an operation to the PLC directory fails 308 + #[error("error-atproto-identity-plc-25 Submission failed: {url} status {status}: {body}")] 309 + SubmissionFailed { 310 + /// The URL that was requested 311 + url: String, 312 + /// The HTTP status code 313 + status: u16, 314 + /// The response body 315 + body: String, 316 + }, 317 + 318 + /// Occurs when fetching the audit log from the PLC directory fails 319 + #[error("error-atproto-identity-plc-26 Audit log fetch failed: {url} {error}")] 320 + AuditLogFetchFailed { 321 + /// The URL that was requested 322 + url: String, 323 + /// The underlying HTTP error 324 + error: reqwest::Error, 325 + }, 326 + 327 + /// Occurs when the audit log response cannot be parsed 328 + #[error("error-atproto-identity-plc-27 Audit log parse failed: {url} {error}")] 329 + AuditLogParseFailed { 330 + /// The URL that was requested 331 + url: String, 332 + /// The underlying parse error 333 + error: reqwest::Error, 334 + }, 306 335 } 307 336 308 337 /// Error types that can occur when working with cryptographic keys ··· 569 598 #[error("error-atproto-identity-webvh-23 Hostname normalization failed: {details}")] 570 599 HostnameNormalizationFailed { 571 600 /// Details about the normalization failure 601 + details: String, 602 + }, 603 + 604 + /// Occurs when genesis document creation fails 605 + #[error("error-atproto-identity-webvh-24 Genesis creation failed: {details}")] 606 + GenesisCreationFailed { 607 + /// Details about the creation failure 608 + details: String, 609 + }, 610 + 611 + /// Occurs when update entry creation fails 612 + #[error("error-atproto-identity-webvh-25 Update creation failed: {details}")] 613 + UpdateCreationFailed { 614 + /// Details about the creation failure 615 + details: String, 616 + }, 617 + 618 + /// Occurs when signing a log entry fails 619 + #[error("error-atproto-identity-webvh-26 Signing failed: {details}")] 620 + SigningFailed { 621 + /// Details about the signing failure 572 622 details: String, 573 623 }, 574 624 }
+77
crates/atproto-identity/src/plc/mod.rs
··· 13 13 //! - [`builder`] - Fluent API for creating new did:plc identities 14 14 //! - [`chain`] - Operation chain validation with fork resolution 15 15 16 + use serde::Deserialize; 16 17 use tracing::{Instrument, instrument}; 17 18 18 19 use super::errors::PLCDIDError; ··· 31 32 pub use operations::{Operation, UnsignedOperation}; 32 33 pub use state::{PlcState, ServiceEndpoint}; 33 34 35 + /// An entry in the PLC directory audit log. 36 + #[derive(Debug, Deserialize)] 37 + pub struct AuditLogEntry { 38 + /// The DID this operation is for. 39 + pub did: String, 40 + 41 + /// The operation itself. 42 + pub operation: Operation, 43 + 44 + /// CID of this operation. 45 + pub cid: String, 46 + 47 + /// Timestamp when this operation was created. 48 + #[serde(rename = "createdAt")] 49 + pub created_at: String, 50 + 51 + /// Nullified flag (if this operation was invalidated). 52 + #[serde(default)] 53 + pub nullified: bool, 54 + } 55 + 34 56 /// Queries a PLC directory for a DID document. 35 57 /// Fetches the complete DID document from the specified PLC hostname. 36 58 #[instrument(skip(http_client), err)] ··· 54 76 .await 55 77 .map_err(|error| PLCDIDError::DocumentParseFailed { url, error }) 56 78 } 79 + 80 + /// Fetches the complete audit log for a DID from the PLC directory. 81 + #[instrument(skip(http_client), err)] 82 + pub async fn fetch_audit_log( 83 + http_client: &reqwest::Client, 84 + plc_hostname: &str, 85 + did: &str, 86 + ) -> Result<Vec<AuditLogEntry>, PLCDIDError> { 87 + let url = format!("https://{}/{}/log/audit", plc_hostname, did); 88 + 89 + let response = http_client 90 + .get(&url) 91 + .send() 92 + .instrument(tracing::info_span!("http_client_get_audit_log")) 93 + .await 94 + .map_err(|error| PLCDIDError::AuditLogFetchFailed { 95 + url: url.clone(), 96 + error, 97 + })?; 98 + 99 + response 100 + .json::<Vec<AuditLogEntry>>() 101 + .await 102 + .map_err(|error| PLCDIDError::AuditLogParseFailed { url, error }) 103 + } 104 + 105 + /// Submits a signed PLC operation to the PLC directory. 106 + #[instrument(skip(http_client, operation), err)] 107 + pub async fn submit( 108 + http_client: &reqwest::Client, 109 + plc_hostname: &str, 110 + did: &str, 111 + operation: &Operation, 112 + ) -> Result<(), PLCDIDError> { 113 + let url = format!("https://{}/{}", plc_hostname, did); 114 + 115 + let response = http_client 116 + .post(&url) 117 + .json(operation) 118 + .send() 119 + .instrument(tracing::info_span!("http_client_post_operation")) 120 + .await 121 + .map_err(|error| PLCDIDError::HttpRequestFailed { 122 + url: url.clone(), 123 + error, 124 + })?; 125 + 126 + if !response.status().is_success() { 127 + let status = response.status().as_u16(); 128 + let body = response.text().await.unwrap_or_default(); 129 + return Err(PLCDIDError::SubmissionFailed { url, status, body }); 130 + } 131 + 132 + Ok(()) 133 + }
+343
crates/atproto-identity/src/webvh/create.rs
··· 1 + //! Creation utilities for did:webvh genesis documents and update entries. 2 + //! 3 + //! Provides functions to create new did:webvh identities and append update 4 + //! entries to existing DID logs. Genesis entries are signed with Ed25519 5 + //! Data Integrity proofs using the `eddsa-jcs-2022` cryptosuite. 6 + 7 + use chrono::Utc; 8 + use serde_json::json; 9 + 10 + use crate::errors::WebVHDIDError; 11 + use crate::key::{self, KeyData, KeyType}; 12 + 13 + use super::jcs; 14 + use super::model::{DataIntegrityProof, LogEntry}; 15 + use super::scid::{compute_entry_hash, compute_multihash_base58btc}; 16 + 17 + /// Result of creating a new did:webvh genesis document. 18 + pub struct GenesisResult { 19 + /// The derived DID string (e.g., `did:webvh:{scid}:example.com`). 20 + pub did: String, 21 + /// The computed Self-Certifying Identifier. 22 + pub scid: String, 23 + /// The complete did.jsonl content (single line). 24 + pub jsonl: String, 25 + /// The genesis log entry as a JSON value. 26 + pub entry: serde_json::Value, 27 + /// The Ed25519 public key (multibase-encoded did:key string). 28 + pub update_key: String, 29 + } 30 + 31 + /// Creates a new did:webvh genesis log entry. 32 + /// 33 + /// Generates an Ed25519 signing key (or uses the provided one), constructs 34 + /// the genesis entry with a Data Integrity proof, computes the SCID, and 35 + /// returns the complete did.jsonl content and derived DID. 36 + /// 37 + /// The `signing_key` must be an Ed25519 private key. If not provided, a new 38 + /// key pair is generated. 39 + pub fn create_genesis( 40 + hostname: &str, 41 + path: Option<&str>, 42 + signing_key: Option<&KeyData>, 43 + ) -> Result<GenesisResult, WebVHDIDError> { 44 + // Generate or use provided Ed25519 key 45 + let (private_key, public_key) = match signing_key { 46 + Some(key) => { 47 + let public = key::to_public(key).map_err(|e| WebVHDIDError::SigningFailed { 48 + details: format!("failed to derive public key: {}", e), 49 + })?; 50 + (key.clone(), public) 51 + } 52 + None => { 53 + let private = key::generate_key(KeyType::Ed25519Private).map_err(|e| { 54 + WebVHDIDError::GenesisCreationFailed { 55 + details: format!("failed to generate Ed25519 key: {}", e), 56 + } 57 + })?; 58 + let public = 59 + key::to_public(&private).map_err(|e| WebVHDIDError::GenesisCreationFailed { 60 + details: format!("failed to derive public key: {}", e), 61 + })?; 62 + (private, public) 63 + } 64 + }; 65 + 66 + let multikey = format!("{}", &public_key) 67 + .strip_prefix("did:key:") 68 + .ok_or_else(|| WebVHDIDError::GenesisCreationFailed { 69 + details: "failed to extract multikey from public key".to_string(), 70 + })? 71 + .to_string(); 72 + 73 + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); 74 + 75 + // Build the DID path suffix 76 + let did_suffix = match path { 77 + Some(p) => format!("{}:{}", hostname, p.replace('/', ":")), 78 + None => hostname.to_string(), 79 + }; 80 + 81 + // Step 1: Build preliminary entry with {SCID} placeholders 82 + let preliminary = json!({ 83 + "versionId": "{SCID}", 84 + "versionTime": now, 85 + "parameters": { 86 + "method": "did:webvh:1.0", 87 + "scid": "{SCID}", 88 + "updateKeys": [multikey], 89 + }, 90 + "state": { 91 + "@context": [ 92 + "https://www.w3.org/ns/did/v1", 93 + "https://w3id.org/security/multikey/v1" 94 + ], 95 + "id": format!("did:webvh:{{SCID}}:{}", did_suffix), 96 + "authentication": [{ 97 + "id": format!("did:webvh:{{SCID}}:{}#{}", did_suffix, multikey), 98 + "type": "Multikey", 99 + "controller": format!("did:webvh:{{SCID}}:{}", did_suffix), 100 + "publicKeyMultibase": multikey, 101 + }], 102 + } 103 + }); 104 + 105 + // Step 2: Compute SCID from JCS-canonicalized preliminary entry 106 + let canonical = jcs::canonicalize(&preliminary); 107 + let scid = compute_multihash_base58btc(canonical.as_bytes()); 108 + 109 + // Step 3: Replace {SCID} with actual value 110 + let json_str = 111 + serde_json::to_string(&preliminary).map_err(|e| WebVHDIDError::GenesisCreationFailed { 112 + details: format!("failed to serialize preliminary entry: {}", e), 113 + })?; 114 + let replaced = json_str.replace("{SCID}", &scid); 115 + let mut entry: serde_json::Value = 116 + serde_json::from_str(&replaced).map_err(|e| WebVHDIDError::GenesisCreationFailed { 117 + details: format!("failed to parse replaced entry: {}", e), 118 + })?; 119 + 120 + // Step 4: Recompute versionId with actual entry hash 121 + let entry_hash = compute_entry_hash(&entry)?; 122 + entry["versionId"] = json!(format!("1-{}", entry_hash)); 123 + 124 + // Step 5: Sign the entry (without proof field) using Ed25519 125 + let entry_without_proof = { 126 + let mut e = entry.clone(); 127 + if let Some(obj) = e.as_object_mut() { 128 + obj.remove("proof"); 129 + } 130 + e 131 + }; 132 + let canonical_for_signing = jcs::canonicalize(&entry_without_proof); 133 + let signature = key::sign(&private_key, canonical_for_signing.as_bytes()).map_err(|e| { 134 + WebVHDIDError::SigningFailed { 135 + details: format!("Ed25519 signing failed: {}", e), 136 + } 137 + })?; 138 + let proof_value = multibase::encode(multibase::Base::Base58Btc, &signature); 139 + 140 + // Step 6: Construct Data Integrity proof 141 + let verification_method = format!("did:key:{}#{}", multikey, multikey); 142 + let proof = DataIntegrityProof { 143 + r#type: "DataIntegrityProof".to_string(), 144 + cryptosuite: "eddsa-jcs-2022".to_string(), 145 + verification_method, 146 + created: now.clone(), 147 + proof_purpose: "assertionMethod".to_string(), 148 + proof_value, 149 + }; 150 + 151 + entry["proof"] = 152 + serde_json::to_value([&proof]).map_err(|e| WebVHDIDError::GenesisCreationFailed { 153 + details: format!("failed to serialize proof: {}", e), 154 + })?; 155 + 156 + let did = format!("did:webvh:{}:{}", scid, did_suffix); 157 + let jsonl = 158 + serde_json::to_string(&entry).map_err(|e| WebVHDIDError::GenesisCreationFailed { 159 + details: format!("failed to serialize entry: {}", e), 160 + })?; 161 + 162 + Ok(GenesisResult { 163 + did, 164 + scid, 165 + jsonl, 166 + entry, 167 + update_key: format!("did:key:{}", multikey), 168 + }) 169 + } 170 + 171 + /// Creates a new update log entry for an existing did:webvh DID. 172 + /// 173 + /// Parses the current log, creates a new entry with the updated DID document 174 + /// state, signs it with the provided Ed25519 key, and returns the new JSONL 175 + /// line to append. 176 + pub fn create_update_entry( 177 + did: &str, 178 + current_log: &str, 179 + new_state: serde_json::Value, 180 + signing_key: &KeyData, 181 + new_update_keys: Option<Vec<String>>, 182 + ) -> Result<String, WebVHDIDError> { 183 + // Parse current log to get last entry 184 + let lines: Vec<&str> = current_log 185 + .lines() 186 + .filter(|l| !l.trim().is_empty()) 187 + .collect(); 188 + if lines.is_empty() { 189 + return Err(WebVHDIDError::UpdateCreationFailed { 190 + details: "current log is empty".to_string(), 191 + }); 192 + } 193 + 194 + let last_entry: LogEntry = serde_json::from_str(lines.last().unwrap()).map_err(|e| { 195 + WebVHDIDError::UpdateCreationFailed { 196 + details: format!("failed to parse last log entry: {}", e), 197 + } 198 + })?; 199 + 200 + // Parse version number from last entry 201 + let version_num: u64 = last_entry 202 + .version_id 203 + .split('-') 204 + .next() 205 + .and_then(|n| n.parse().ok()) 206 + .ok_or_else(|| WebVHDIDError::UpdateCreationFailed { 207 + details: "failed to parse version number from last entry".to_string(), 208 + })?; 209 + 210 + let new_version = version_num + 1; 211 + let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); 212 + 213 + // Build parameters (only include changed parameters) 214 + let parameters = match new_update_keys { 215 + Some(keys) => json!({ "updateKeys": keys }), 216 + None => json!({}), 217 + }; 218 + 219 + // Build the new entry (without proof, with placeholder versionId) 220 + let mut entry = json!({ 221 + "versionId": format!("{}-placeholder", new_version), 222 + "versionTime": now, 223 + "parameters": parameters, 224 + "state": new_state, 225 + }); 226 + 227 + // Compute entry hash and set proper versionId 228 + let entry_hash = compute_entry_hash(&entry)?; 229 + entry["versionId"] = json!(format!("{}-{}", new_version, entry_hash)); 230 + 231 + // Get public key for verification method 232 + let public_key = key::to_public(signing_key).map_err(|e| WebVHDIDError::SigningFailed { 233 + details: format!("failed to derive public key: {}", e), 234 + })?; 235 + let multikey = format!("{}", &public_key) 236 + .strip_prefix("did:key:") 237 + .ok_or_else(|| WebVHDIDError::SigningFailed { 238 + details: "failed to extract multikey from public key".to_string(), 239 + })? 240 + .to_string(); 241 + 242 + // Sign the entry 243 + let entry_without_proof = { 244 + let mut e = entry.clone(); 245 + if let Some(obj) = e.as_object_mut() { 246 + obj.remove("proof"); 247 + } 248 + e 249 + }; 250 + let canonical = jcs::canonicalize(&entry_without_proof); 251 + let signature = 252 + key::sign(signing_key, canonical.as_bytes()).map_err(|e| WebVHDIDError::SigningFailed { 253 + details: format!("Ed25519 signing failed: {}", e), 254 + })?; 255 + let proof_value = multibase::encode(multibase::Base::Base58Btc, &signature); 256 + 257 + let proof = DataIntegrityProof { 258 + r#type: "DataIntegrityProof".to_string(), 259 + cryptosuite: "eddsa-jcs-2022".to_string(), 260 + verification_method: format!("did:key:{}#{}", multikey, multikey), 261 + created: now, 262 + proof_purpose: "assertionMethod".to_string(), 263 + proof_value, 264 + }; 265 + 266 + entry["proof"] = 267 + serde_json::to_value([&proof]).map_err(|e| WebVHDIDError::UpdateCreationFailed { 268 + details: format!("failed to serialize proof: {}", e), 269 + })?; 270 + 271 + let _ = did; // Used for context validation in future enhancements 272 + 273 + serde_json::to_string(&entry).map_err(|e| WebVHDIDError::UpdateCreationFailed { 274 + details: format!("failed to serialize update entry: {}", e), 275 + }) 276 + } 277 + 278 + #[cfg(test)] 279 + mod tests { 280 + use super::*; 281 + 282 + #[test] 283 + fn test_create_genesis_basic() { 284 + let result = create_genesis("example.com", None, None).unwrap(); 285 + assert!(result.did.starts_with("did:webvh:")); 286 + assert!(result.did.contains("example.com")); 287 + assert!(result.scid.starts_with('z')); 288 + assert!(!result.jsonl.is_empty()); 289 + assert!(result.update_key.starts_with("did:key:")); 290 + } 291 + 292 + #[test] 293 + fn test_create_genesis_with_path() { 294 + let result = create_genesis("example.com", Some("dids/issuer"), None).unwrap(); 295 + assert!(result.did.contains("example.com:dids:issuer")); 296 + } 297 + 298 + #[test] 299 + fn test_create_genesis_with_provided_key() { 300 + let key = key::generate_key(KeyType::Ed25519Private).unwrap(); 301 + let result = create_genesis("example.com", None, Some(&key)).unwrap(); 302 + assert!(result.did.starts_with("did:webvh:")); 303 + } 304 + 305 + #[test] 306 + fn test_create_genesis_entry_has_proof() { 307 + let result = create_genesis("example.com", None, None).unwrap(); 308 + let entry: serde_json::Value = serde_json::from_str(&result.jsonl).unwrap(); 309 + assert!(entry.get("proof").is_some()); 310 + let proofs = entry["proof"].as_array().unwrap(); 311 + assert_eq!(proofs.len(), 1); 312 + assert_eq!(proofs[0]["type"], "DataIntegrityProof"); 313 + assert_eq!(proofs[0]["cryptosuite"], "eddsa-jcs-2022"); 314 + } 315 + 316 + #[test] 317 + fn test_create_update_entry() { 318 + let key = key::generate_key(KeyType::Ed25519Private).unwrap(); 319 + let genesis = create_genesis("example.com", None, Some(&key)).unwrap(); 320 + 321 + let new_state = json!({ 322 + "@context": ["https://www.w3.org/ns/did/v1"], 323 + "id": &genesis.did, 324 + "service": [{ 325 + "id": format!("{}#pds", genesis.did), 326 + "type": "AtprotoPersonalDataServer", 327 + "serviceEndpoint": "https://pds.example.com" 328 + }] 329 + }); 330 + 331 + let update_line = 332 + create_update_entry(&genesis.did, &genesis.jsonl, new_state, &key, None).unwrap(); 333 + assert!(!update_line.is_empty()); 334 + 335 + let update_entry: serde_json::Value = serde_json::from_str(&update_line).unwrap(); 336 + assert!( 337 + update_entry["versionId"] 338 + .as_str() 339 + .unwrap() 340 + .starts_with("2-") 341 + ); 342 + } 343 + }
+1
crates/atproto-identity/src/webvh/mod.rs
··· 18 18 //! 3. Process each log entry sequentially with full verification 19 19 //! 4. Return the latest DID document 20 20 21 + pub mod create; 21 22 pub mod jcs; 22 23 pub mod log; 23 24 pub mod model;
+7
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
··· 156 156 /// Used as the `$type` field value for media attachments associated with events. 157 157 pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media"; 158 158 159 + /// Default value for the media role field. 160 + fn default_role() -> String { 161 + "banner".to_string() 162 + } 163 + 159 164 /// Media structure for event-related visual content. 160 165 /// 161 166 /// Represents images, videos, or other media associated with an event. ··· 166 171 pub content: TypedBlob, 167 172 168 173 /// Alternative text description for accessibility 174 + #[serde(skip_serializing_if = "String::is_empty", default)] 169 175 pub alt: String, 170 176 171 177 /// The role/purpose of this media (e.g., "banner", "poster", "thumbnail") 178 + #[serde(default = "default_role")] 172 179 pub role: String, 173 180 174 181 /// Optional aspect ratio information