A library for ATProtocol identities.

Compare changes

Choose any two refs to compare.

+19 -9
CLAUDE.md
··· 27 - **Sign data**: `cargo run --features clap --bin atproto-identity-sign -- <did_key> <json_file>` 28 - **Validate signatures**: `cargo run --features clap --bin atproto-identity-validate -- <did_key> <json_file> <signature>` 29 30 #### Record Operations 31 - - **Sign records**: `cargo run --features clap --bin atproto-record-sign -- <issuer_did> <signing_key> <record_input> repository=<repo> collection=<collection>` 32 - - **Verify records**: `cargo run --features clap --bin atproto-record-verify -- <issuer_did> <key> <record_input> repository=<repo> collection=<collection>` 33 34 #### Client Tools 35 - **App password auth**: `cargo run --features clap --bin atproto-client-app-password -- <subject> <access_token> <xrpc_path>` ··· 44 ## Architecture 45 46 A comprehensive Rust workspace with multiple crates: 47 - - **atproto-identity**: Core identity management with 10 modules (resolve, plc, web, model, validation, config, errors, key, storage, storage_lru) 48 - - **atproto-record**: Record signature operations and validation 49 - **atproto-client**: HTTP client with OAuth and identity integration 50 - **atproto-jetstream**: WebSocket event streaming with compression 51 - **atproto-oauth**: OAuth workflow implementation with DPoP, PKCE, JWT, and storage abstractions ··· 55 - **atproto-xrpcs-helloworld**: Complete example XRPC service 56 57 Features: 58 - - **12 CLI tools** with consistent clap-based command-line interfaces (optional via `clap` feature) 59 - **Rust edition 2024** with modern async/await patterns 60 - **Comprehensive error handling** with structured error types 61 - **Full test coverage** with unit tests across all modules ··· 136 ### Core Library Modules (atproto-identity) 137 - **`src/lib.rs`**: Main library exports 138 - **`src/resolve.rs`**: Core resolution logic for handles and DIDs, DNS/HTTP resolution 139 - - **`src/plc.rs`**: PLC directory client for did:plc resolution 140 - **`src/web.rs`**: Web DID client for did:web resolution and URL conversion 141 - **`src/model.rs`**: Data structures for DID documents and AT Protocol entities 142 - **`src/validation.rs`**: Input validation for handles and DIDs 143 - **`src/config.rs`**: Configuration management and environment variable handling 144 - **`src/errors.rs`**: Structured error types following project conventions 145 - **`src/key.rs`**: Cryptographic key operations including signature validation and key identification for P-256, P-384, and K-256 curves 146 - - **`src/storage.rs`**: Storage abstraction interface for DID document caching 147 - **`src/storage_lru.rs`**: LRU-based storage implementation (requires `lru` feature) 148 149 ### CLI Tools (require --features clap) 150 ··· 154 - **`src/bin/atproto-identity-sign.rs`**: Create cryptographic signatures of JSON data 155 - **`src/bin/atproto-identity-validate.rs`**: Validate cryptographic signatures 156 157 #### Record Operations (atproto-record) 158 - - **`src/bin/atproto-record-sign.rs`**: Sign AT Protocol records with cryptographic signatures 159 - - **`src/bin/atproto-record-verify.rs`**: Verify AT Protocol record signatures 160 161 #### Client Tools (atproto-client) 162 - **`src/bin/atproto-client-app-password.rs`**: Make XRPC calls using app password authentication
··· 27 - **Sign data**: `cargo run --features clap --bin atproto-identity-sign -- <did_key> <json_file>` 28 - **Validate signatures**: `cargo run --features clap --bin atproto-identity-validate -- <did_key> <json_file> <signature>` 29 30 + #### Attestation Operations 31 + - **Sign records (inline)**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline <source_record> <signing_key> <metadata_record>` 32 + - **Sign records (remote)**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- remote <source_record> <repository_did> <metadata_record>` 33 + - **Verify records**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record>` (verifies all signatures) 34 + - **Verify attestation**: `cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- <record> <attestation>` (verifies specific attestation) 35 + 36 #### Record Operations 37 + - **Generate CID**: `cat record.json | cargo run --features clap --bin atproto-record-cid` (reads JSON from stdin, outputs CID) 38 39 #### Client Tools 40 - **App password auth**: `cargo run --features clap --bin atproto-client-app-password -- <subject> <access_token> <xrpc_path>` ··· 49 ## Architecture 50 51 A comprehensive Rust workspace with multiple crates: 52 + - **atproto-identity**: Core identity management with 11 modules (resolve, plc, web, model, validation, config, errors, key, storage_lru, traits, url) 53 + - **atproto-attestation**: CID-first attestation utilities for creating and verifying record signatures 54 + - **atproto-record**: Record utilities including TID generation, AT-URI parsing, and CID generation 55 - **atproto-client**: HTTP client with OAuth and identity integration 56 - **atproto-jetstream**: WebSocket event streaming with compression 57 - **atproto-oauth**: OAuth workflow implementation with DPoP, PKCE, JWT, and storage abstractions ··· 61 - **atproto-xrpcs-helloworld**: Complete example XRPC service 62 63 Features: 64 + - **13 CLI tools** with consistent clap-based command-line interfaces (optional via `clap` feature) 65 - **Rust edition 2024** with modern async/await patterns 66 - **Comprehensive error handling** with structured error types 67 - **Full test coverage** with unit tests across all modules ··· 142 ### Core Library Modules (atproto-identity) 143 - **`src/lib.rs`**: Main library exports 144 - **`src/resolve.rs`**: Core resolution logic for handles and DIDs, DNS/HTTP resolution 145 + - **`src/plc.rs`**: PLC directory client for did:plc resolution 146 - **`src/web.rs`**: Web DID client for did:web resolution and URL conversion 147 - **`src/model.rs`**: Data structures for DID documents and AT Protocol entities 148 - **`src/validation.rs`**: Input validation for handles and DIDs 149 - **`src/config.rs`**: Configuration management and environment variable handling 150 - **`src/errors.rs`**: Structured error types following project conventions 151 - **`src/key.rs`**: Cryptographic key operations including signature validation and key identification for P-256, P-384, and K-256 curves 152 - **`src/storage_lru.rs`**: LRU-based storage implementation (requires `lru` feature) 153 + - **`src/traits.rs`**: Core trait definitions for identity resolution and key resolution 154 + - **`src/url.rs`**: URL utilities for AT Protocol services 155 156 ### CLI Tools (require --features clap) 157 ··· 161 - **`src/bin/atproto-identity-sign.rs`**: Create cryptographic signatures of JSON data 162 - **`src/bin/atproto-identity-validate.rs`**: Validate cryptographic signatures 163 164 + #### Attestation Operations (atproto-attestation) 165 + - **`src/bin/atproto-attestation-sign.rs`**: Sign AT Protocol records with inline or remote attestations using CID-first specification 166 + - **`src/bin/atproto-attestation-verify.rs`**: Verify cryptographic signatures on AT Protocol records with attestation validation 167 + 168 #### Record Operations (atproto-record) 169 + - **`src/bin/atproto-record-cid.rs`**: Generate CID (Content Identifier) for AT Protocol records using DAG-CBOR serialization 170 171 #### Client Tools (atproto-client) 172 - **`src/bin/atproto-client-app-password.rs`**: Make XRPC calls using app password authentication
+576 -626
Cargo.lock
··· 3 version = 4 4 5 [[package]] 6 - name = "addr2line" 7 - version = "0.24.2" 8 - source = "registry+https://github.com/rust-lang/crates.io-index" 9 - checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 - dependencies = [ 11 - "gimli", 12 - ] 13 - 14 - [[package]] 15 - name = "adler2" 16 - version = "2.0.0" 17 - source = "registry+https://github.com/rust-lang/crates.io-index" 18 - checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 - 20 - [[package]] 21 name = "aho-corasick" 22 - version = "1.1.3" 23 source = "registry+https://github.com/rust-lang/crates.io-index" 24 - checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 dependencies = [ 26 "memchr", 27 ] ··· 34 35 [[package]] 36 name = "anstream" 37 - version = "0.6.19" 38 source = "registry+https://github.com/rust-lang/crates.io-index" 39 - checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" 40 dependencies = [ 41 "anstyle", 42 "anstyle-parse", ··· 49 50 [[package]] 51 name = "anstyle" 52 - version = "1.0.11" 53 source = "registry+https://github.com/rust-lang/crates.io-index" 54 - checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" 55 56 [[package]] 57 name = "anstyle-parse" ··· 64 65 [[package]] 66 name = "anstyle-query" 67 - version = "1.1.3" 68 source = "registry+https://github.com/rust-lang/crates.io-index" 69 - checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" 70 dependencies = [ 71 - "windows-sys 0.59.0", 72 ] 73 74 [[package]] 75 name = "anstyle-wincon" 76 - version = "3.0.9" 77 source = "registry+https://github.com/rust-lang/crates.io-index" 78 - checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" 79 dependencies = [ 80 "anstyle", 81 "once_cell_polyfill", 82 - "windows-sys 0.59.0", 83 ] 84 85 [[package]] 86 name = "anyhow" 87 - version = "1.0.98" 88 source = "registry+https://github.com/rust-lang/crates.io-index" 89 - checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 90 91 [[package]] 92 name = "async-trait" 93 - version = "0.1.88" 94 source = "registry+https://github.com/rust-lang/crates.io-index" 95 - checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" 96 dependencies = [ 97 "proc-macro2", 98 "quote", 99 - "syn", 100 ] 101 102 [[package]] ··· 106 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 107 108 [[package]] 109 name = "atproto-client" 110 version = "0.13.0" 111 dependencies = [ 112 "anyhow", 113 "atproto-identity", 114 "atproto-oauth", 115 "atproto-record", ··· 122 "secrecy", 123 "serde", 124 "serde_json", 125 - "thiserror 2.0.12", 126 "tokio", 127 "tracing", 128 "urlencoding", 129 ] 130 131 [[package]] ··· 148 "serde", 149 "serde_ipld_dagcbor", 150 "serde_json", 151 - "thiserror 2.0.12", 152 "tokio", 153 "tracing", 154 "urlencoding", 155 "zeroize", 156 ] ··· 167 "http", 168 "serde", 169 "serde_json", 170 - "thiserror 2.0.12", 171 "tokio", 172 "tokio-util", 173 "tokio-websockets", ··· 190 "reqwest", 191 "serde", 192 "serde_json", 193 - "thiserror 2.0.12", 194 "tokio", 195 "tracing", 196 "zeroize", ··· 221 "serde_ipld_dagcbor", 222 "serde_json", 223 "sha2", 224 - "thiserror 2.0.12", 225 "tokio", 226 "tracing", 227 "ulid", ··· 239 "reqwest", 240 "serde", 241 "serde_json", 242 - "thiserror 2.0.12", 243 "zeroize", 244 ] 245 ··· 266 "secrecy", 267 "serde", 268 "serde_json", 269 - "thiserror 2.0.12", 270 "tokio", 271 "tracing", 272 "zeroize", ··· 277 version = "0.13.0" 278 dependencies = [ 279 "anyhow", 280 "atproto-identity", 281 "base64", 282 "chrono", 283 "clap", 284 "serde", 285 "serde_ipld_dagcbor", 286 "serde_json", 287 - "thiserror 2.0.12", 288 "tokio", 289 ] 290 291 [[package]] ··· 309 "reqwest-middleware", 310 "serde", 311 "serde_json", 312 - "thiserror 2.0.12", 313 "tokio", 314 "tracing", 315 ] ··· 336 "reqwest-middleware", 337 "serde", 338 "serde_json", 339 - "thiserror 2.0.12", 340 "tokio", 341 "tracing", 342 ] 343 344 [[package]] 345 name = "autocfg" 346 - version = "1.4.0" 347 source = "registry+https://github.com/rust-lang/crates.io-index" 348 - checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 349 350 [[package]] 351 name = "axum" 352 - version = "0.8.4" 353 source = "registry+https://github.com/rust-lang/crates.io-index" 354 - checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" 355 dependencies = [ 356 "axum-core", 357 "axum-macros", ··· 369 "mime", 370 "percent-encoding", 371 "pin-project-lite", 372 - "rustversion", 373 - "serde", 374 "serde_json", 375 "serde_path_to_error", 376 "serde_urlencoded", ··· 384 385 [[package]] 386 name = "axum-core" 387 - version = "0.5.2" 388 source = "registry+https://github.com/rust-lang/crates.io-index" 389 - checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" 390 dependencies = [ 391 "bytes", 392 "futures-core", ··· 395 "http-body-util", 396 "mime", 397 "pin-project-lite", 398 - "rustversion", 399 "sync_wrapper", 400 "tower-layer", 401 "tower-service", ··· 410 dependencies = [ 411 "proc-macro2", 412 "quote", 413 - "syn", 414 - ] 415 - 416 - [[package]] 417 - name = "backtrace" 418 - version = "0.3.75" 419 - source = "registry+https://github.com/rust-lang/crates.io-index" 420 - checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" 421 - dependencies = [ 422 - "addr2line", 423 - "cfg-if", 424 - "libc", 425 - "miniz_oxide", 426 - "object", 427 - "rustc-demangle", 428 - "windows-targets 0.52.6", 429 ] 430 431 [[package]] ··· 441 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 442 443 [[package]] 444 name = "base64" 445 version = "0.22.1" 446 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 448 449 [[package]] 450 name = "base64ct" 451 - version = "1.7.3" 452 source = "registry+https://github.com/rust-lang/crates.io-index" 453 - checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" 454 455 [[package]] 456 name = "bitflags" 457 - version = "2.9.1" 458 source = "registry+https://github.com/rust-lang/crates.io-index" 459 - checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" 460 461 [[package]] 462 name = "block-buffer" ··· 469 470 [[package]] 471 name = "bumpalo" 472 - version = "3.17.0" 473 source = "registry+https://github.com/rust-lang/crates.io-index" 474 - checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 475 476 [[package]] 477 name = "bytes" ··· 480 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 481 482 [[package]] 483 name = "cbor4ii" 484 version = "0.2.14" 485 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 490 491 [[package]] 492 name = "cc" 493 - version = "1.2.24" 494 source = "registry+https://github.com/rust-lang/crates.io-index" 495 - checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" 496 dependencies = [ 497 "jobserver", 498 "libc", 499 "shlex", ··· 501 502 [[package]] 503 name = "cfg-if" 504 - version = "1.0.0" 505 source = "registry+https://github.com/rust-lang/crates.io-index" 506 - checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 507 508 [[package]] 509 name = "cfg_aliases" ··· 513 514 [[package]] 515 name = "chrono" 516 - version = "0.4.41" 517 source = "registry+https://github.com/rust-lang/crates.io-index" 518 - checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" 519 dependencies = [ 520 "num-traits", 521 "serde", ··· 537 538 [[package]] 539 name = "clap" 540 - version = "4.5.40" 541 source = "registry+https://github.com/rust-lang/crates.io-index" 542 - checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" 543 dependencies = [ 544 "clap_builder", 545 "clap_derive", ··· 547 548 [[package]] 549 name = "clap_builder" 550 - version = "4.5.40" 551 source = "registry+https://github.com/rust-lang/crates.io-index" 552 - checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" 553 dependencies = [ 554 "anstream", 555 "anstyle", ··· 559 560 [[package]] 561 name = "clap_derive" 562 - version = "4.5.40" 563 source = "registry+https://github.com/rust-lang/crates.io-index" 564 - checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" 565 dependencies = [ 566 "heck", 567 "proc-macro2", 568 "quote", 569 - "syn", 570 ] 571 572 [[package]] 573 name = "clap_lex" 574 - version = "0.7.5" 575 source = "registry+https://github.com/rust-lang/crates.io-index" 576 - checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" 577 578 [[package]] 579 name = "colorchoice" ··· 582 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 583 584 [[package]] 585 name = "const-oid" 586 version = "0.9.6" 587 source = "registry+https://github.com/rust-lang/crates.io-index" 588 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 589 590 [[package]] 591 name = "core-foundation" 592 version = "0.9.4" 593 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 706 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 707 dependencies = [ 708 "data-encoding", 709 - "syn", 710 ] 711 712 [[package]] ··· 740 dependencies = [ 741 "proc-macro2", 742 "quote", 743 - "syn", 744 ] 745 746 [[package]] ··· 800 "heck", 801 "proc-macro2", 802 "quote", 803 - "syn", 804 ] 805 806 [[package]] ··· 820 ] 821 822 [[package]] 823 name = "fnv" 824 version = "1.0.7" 825 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 833 834 [[package]] 835 name = "form_urlencoded" 836 - version = "1.2.1" 837 source = "registry+https://github.com/rust-lang/crates.io-index" 838 - checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 839 dependencies = [ 840 "percent-encoding", 841 ] ··· 896 dependencies = [ 897 "proc-macro2", 898 "quote", 899 - "syn", 900 ] 901 902 [[package]] ··· 930 ] 931 932 [[package]] 933 - name = "generator" 934 - version = "0.8.5" 935 - source = "registry+https://github.com/rust-lang/crates.io-index" 936 - checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" 937 - dependencies = [ 938 - "cc", 939 - "cfg-if", 940 - "libc", 941 - "log", 942 - "rustversion", 943 - "windows", 944 - ] 945 - 946 - [[package]] 947 name = "generic-array" 948 - version = "0.14.7" 949 source = "registry+https://github.com/rust-lang/crates.io-index" 950 - checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 951 dependencies = [ 952 "typenum", 953 "version_check", ··· 963 "cfg-if", 964 "js-sys", 965 "libc", 966 - "wasi 0.11.0+wasi-snapshot-preview1", 967 "wasm-bindgen", 968 ] 969 970 [[package]] 971 name = "getrandom" 972 - version = "0.3.3" 973 source = "registry+https://github.com/rust-lang/crates.io-index" 974 - checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" 975 dependencies = [ 976 "cfg-if", 977 "js-sys", 978 "libc", 979 "r-efi", 980 - "wasi 0.14.2+wasi-0.2.4", 981 "wasm-bindgen", 982 ] 983 984 [[package]] 985 - name = "gimli" 986 - version = "0.31.1" 987 - source = "registry+https://github.com/rust-lang/crates.io-index" 988 - checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 989 - 990 - [[package]] 991 name = "group" 992 version = "0.13.0" 993 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1000 1001 [[package]] 1002 name = "h2" 1003 - version = "0.4.10" 1004 source = "registry+https://github.com/rust-lang/crates.io-index" 1005 - checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" 1006 dependencies = [ 1007 "atomic-waker", 1008 "bytes", ··· 1019 1020 [[package]] 1021 name = "hashbrown" 1022 - version = "0.15.3" 1023 source = "registry+https://github.com/rust-lang/crates.io-index" 1024 - checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" 1025 dependencies = [ 1026 "allocator-api2", 1027 "equivalent", 1028 "foldhash", 1029 ] 1030 1031 [[package]] 1032 name = "heck" ··· 1050 "idna", 1051 "ipnet", 1052 "once_cell", 1053 - "rand 0.9.1", 1054 "ring", 1055 - "thiserror 2.0.12", 1056 "tinyvec", 1057 "tokio", 1058 "tracing", ··· 1072 "moka", 1073 "once_cell", 1074 "parking_lot", 1075 - "rand 0.9.1", 1076 "resolv-conf", 1077 "smallvec", 1078 - "thiserror 2.0.12", 1079 "tokio", 1080 "tracing", 1081 ] ··· 1146 1147 [[package]] 1148 name = "hyper" 1149 - version = "1.6.0" 1150 source = "registry+https://github.com/rust-lang/crates.io-index" 1151 - checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 1152 dependencies = [ 1153 "bytes", 1154 "futures-channel", 1155 - "futures-util", 1156 "h2", 1157 "http", 1158 "http-body", ··· 1160 "httpdate", 1161 "itoa", 1162 "pin-project-lite", 1163 "smallvec", 1164 "tokio", 1165 "want", ··· 1167 1168 [[package]] 1169 name = "hyper-rustls" 1170 - version = "0.27.6" 1171 source = "registry+https://github.com/rust-lang/crates.io-index" 1172 - checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" 1173 dependencies = [ 1174 "http", 1175 "hyper", ··· 1184 1185 [[package]] 1186 name = "hyper-util" 1187 - version = "0.1.13" 1188 source = "registry+https://github.com/rust-lang/crates.io-index" 1189 - checksum = "b1c293b6b3d21eca78250dc7dbebd6b9210ec5530e038cbfe0661b5c47ab06e8" 1190 dependencies = [ 1191 "base64", 1192 "bytes", ··· 1200 "libc", 1201 "percent-encoding", 1202 "pin-project-lite", 1203 - "socket2", 1204 "system-configuration", 1205 "tokio", 1206 "tower-service", ··· 1210 1211 [[package]] 1212 name = "icu_collections" 1213 - version = "2.0.0" 1214 source = "registry+https://github.com/rust-lang/crates.io-index" 1215 - checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" 1216 dependencies = [ 1217 "displaydoc", 1218 "potential_utf", ··· 1223 1224 [[package]] 1225 name = "icu_locale_core" 1226 - version = "2.0.0" 1227 source = "registry+https://github.com/rust-lang/crates.io-index" 1228 - checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" 1229 dependencies = [ 1230 "displaydoc", 1231 "litemap", ··· 1236 1237 [[package]] 1238 name = "icu_normalizer" 1239 - version = "2.0.0" 1240 source = "registry+https://github.com/rust-lang/crates.io-index" 1241 - checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" 1242 dependencies = [ 1243 - "displaydoc", 1244 "icu_collections", 1245 "icu_normalizer_data", 1246 "icu_properties", ··· 1251 1252 [[package]] 1253 name = "icu_normalizer_data" 1254 - version = "2.0.0" 1255 source = "registry+https://github.com/rust-lang/crates.io-index" 1256 - checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" 1257 1258 [[package]] 1259 name = "icu_properties" 1260 - version = "2.0.1" 1261 source = "registry+https://github.com/rust-lang/crates.io-index" 1262 - checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" 1263 dependencies = [ 1264 - "displaydoc", 1265 "icu_collections", 1266 "icu_locale_core", 1267 "icu_properties_data", 1268 "icu_provider", 1269 - "potential_utf", 1270 "zerotrie", 1271 "zerovec", 1272 ] 1273 1274 [[package]] 1275 name = "icu_properties_data" 1276 - version = "2.0.1" 1277 source = "registry+https://github.com/rust-lang/crates.io-index" 1278 - checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" 1279 1280 [[package]] 1281 name = "icu_provider" 1282 - version = "2.0.0" 1283 source = "registry+https://github.com/rust-lang/crates.io-index" 1284 - checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" 1285 dependencies = [ 1286 "displaydoc", 1287 "icu_locale_core", 1288 - "stable_deref_trait", 1289 - "tinystr", 1290 "writeable", 1291 "yoke", 1292 "zerofrom", ··· 1296 1297 [[package]] 1298 name = "idna" 1299 - version = "1.0.3" 1300 source = "registry+https://github.com/rust-lang/crates.io-index" 1301 - checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 1302 dependencies = [ 1303 "idna_adapter", 1304 "smallvec", ··· 1317 1318 [[package]] 1319 name = "indexmap" 1320 - version = "2.9.0" 1321 source = "registry+https://github.com/rust-lang/crates.io-index" 1322 - checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 1323 dependencies = [ 1324 "equivalent", 1325 - "hashbrown", 1326 ] 1327 1328 [[package]] ··· 1331 source = "registry+https://github.com/rust-lang/crates.io-index" 1332 checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1333 dependencies = [ 1334 - "socket2", 1335 "widestring", 1336 "windows-sys 0.48.0", 1337 "winreg", ··· 1356 1357 [[package]] 1358 name = "iri-string" 1359 - version = "0.7.8" 1360 source = "registry+https://github.com/rust-lang/crates.io-index" 1361 - checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1362 dependencies = [ 1363 "memchr", 1364 "serde", ··· 1366 1367 [[package]] 1368 name = "is_terminal_polyfill" 1369 - version = "1.70.1" 1370 source = "registry+https://github.com/rust-lang/crates.io-index" 1371 - checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 1372 1373 [[package]] 1374 name = "itoa" ··· 1378 1379 [[package]] 1380 name = "jobserver" 1381 - version = "0.1.33" 1382 source = "registry+https://github.com/rust-lang/crates.io-index" 1383 - checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" 1384 dependencies = [ 1385 - "getrandom 0.3.3", 1386 "libc", 1387 ] 1388 1389 [[package]] 1390 name = "js-sys" 1391 - version = "0.3.77" 1392 source = "registry+https://github.com/rust-lang/crates.io-index" 1393 - checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 1394 dependencies = [ 1395 "once_cell", 1396 "wasm-bindgen", ··· 1418 1419 [[package]] 1420 name = "libc" 1421 - version = "0.2.172" 1422 source = "registry+https://github.com/rust-lang/crates.io-index" 1423 - checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 1424 1425 [[package]] 1426 name = "litemap" 1427 - version = "0.8.0" 1428 source = "registry+https://github.com/rust-lang/crates.io-index" 1429 - checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" 1430 1431 [[package]] 1432 name = "lock_api" 1433 - version = "0.4.13" 1434 source = "registry+https://github.com/rust-lang/crates.io-index" 1435 - checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" 1436 dependencies = [ 1437 - "autocfg", 1438 "scopeguard", 1439 ] 1440 1441 [[package]] 1442 name = "log" 1443 - version = "0.4.27" 1444 source = "registry+https://github.com/rust-lang/crates.io-index" 1445 - checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 1446 - 1447 - [[package]] 1448 - name = "loom" 1449 - version = "0.7.2" 1450 - source = "registry+https://github.com/rust-lang/crates.io-index" 1451 - checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 1452 - dependencies = [ 1453 - "cfg-if", 1454 - "generator", 1455 - "scoped-tls", 1456 - "tracing", 1457 - "tracing-subscriber", 1458 - ] 1459 1460 [[package]] 1461 name = "lru" ··· 1463 source = "registry+https://github.com/rust-lang/crates.io-index" 1464 checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1465 dependencies = [ 1466 - "hashbrown", 1467 ] 1468 1469 [[package]] ··· 1473 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1474 1475 [[package]] 1476 name = "matchers" 1477 - version = "0.1.0" 1478 source = "registry+https://github.com/rust-lang/crates.io-index" 1479 - checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 1480 dependencies = [ 1481 - "regex-automata 0.1.10", 1482 ] 1483 1484 [[package]] ··· 1489 1490 [[package]] 1491 name = "memchr" 1492 - version = "2.7.4" 1493 source = "registry+https://github.com/rust-lang/crates.io-index" 1494 - checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 1495 1496 [[package]] 1497 name = "mime" ··· 1510 ] 1511 1512 [[package]] 1513 - name = "miniz_oxide" 1514 - version = "0.8.8" 1515 - source = "registry+https://github.com/rust-lang/crates.io-index" 1516 - checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" 1517 - dependencies = [ 1518 - "adler2", 1519 - ] 1520 - 1521 - [[package]] 1522 name = "mio" 1523 - version = "1.0.4" 1524 source = "registry+https://github.com/rust-lang/crates.io-index" 1525 - checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 1526 dependencies = [ 1527 "libc", 1528 - "wasi 0.11.0+wasi-snapshot-preview1", 1529 - "windows-sys 0.59.0", 1530 ] 1531 1532 [[package]] 1533 name = "moka" 1534 - version = "0.12.10" 1535 source = "registry+https://github.com/rust-lang/crates.io-index" 1536 - checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" 1537 dependencies = [ 1538 "crossbeam-channel", 1539 "crossbeam-epoch", 1540 "crossbeam-utils", 1541 - "loom", 1542 "parking_lot", 1543 "portable-atomic", 1544 "rustc_version", 1545 "smallvec", 1546 "tagptr", 1547 - "thiserror 1.0.69", 1548 "uuid", 1549 ] 1550 1551 [[package]] 1552 name = "multibase" 1553 - version = "0.9.1" 1554 source = "registry+https://github.com/rust-lang/crates.io-index" 1555 - checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404" 1556 dependencies = [ 1557 "base-x", 1558 "data-encoding", 1559 "data-encoding-macro", 1560 ] ··· 1572 1573 [[package]] 1574 name = "nu-ansi-term" 1575 - version = "0.46.0" 1576 source = "registry+https://github.com/rust-lang/crates.io-index" 1577 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1578 dependencies = [ 1579 - "overload", 1580 - "winapi", 1581 ] 1582 1583 [[package]] ··· 1590 ] 1591 1592 [[package]] 1593 - name = "object" 1594 - version = "0.36.7" 1595 - source = "registry+https://github.com/rust-lang/crates.io-index" 1596 - checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 1597 - dependencies = [ 1598 - "memchr", 1599 - ] 1600 - 1601 - [[package]] 1602 name = "once_cell" 1603 version = "1.21.3" 1604 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1610 1611 [[package]] 1612 name = "once_cell_polyfill" 1613 - version = "1.70.1" 1614 source = "registry+https://github.com/rust-lang/crates.io-index" 1615 - checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 1616 1617 [[package]] 1618 name = "openssl-probe" 1619 version = "0.1.6" 1620 source = "registry+https://github.com/rust-lang/crates.io-index" 1621 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1622 - 1623 - [[package]] 1624 - name = "overload" 1625 - version = "0.1.1" 1626 - source = "registry+https://github.com/rust-lang/crates.io-index" 1627 - checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1628 1629 [[package]] 1630 name = "p256" ··· 1654 1655 [[package]] 1656 name = "parking_lot" 1657 - version = "0.12.4" 1658 source = "registry+https://github.com/rust-lang/crates.io-index" 1659 - checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" 1660 dependencies = [ 1661 "lock_api", 1662 "parking_lot_core", ··· 1664 1665 [[package]] 1666 name = "parking_lot_core" 1667 - version = "0.9.11" 1668 source = "registry+https://github.com/rust-lang/crates.io-index" 1669 - checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" 1670 dependencies = [ 1671 "cfg-if", 1672 "libc", 1673 "redox_syscall", 1674 "smallvec", 1675 - "windows-targets 0.52.6", 1676 ] 1677 1678 [[package]] ··· 1686 1687 [[package]] 1688 name = "percent-encoding" 1689 - version = "2.3.1" 1690 source = "registry+https://github.com/rust-lang/crates.io-index" 1691 - checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1692 1693 [[package]] 1694 name = "pin-project-lite" ··· 1720 1721 [[package]] 1722 name = "portable-atomic" 1723 - version = "1.11.0" 1724 source = "registry+https://github.com/rust-lang/crates.io-index" 1725 - checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 1726 1727 [[package]] 1728 name = "potential_utf" 1729 - version = "0.1.2" 1730 source = "registry+https://github.com/rust-lang/crates.io-index" 1731 - checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" 1732 dependencies = [ 1733 "zerovec", 1734 ] ··· 1754 1755 [[package]] 1756 name = "proc-macro2" 1757 - version = "1.0.95" 1758 source = "registry+https://github.com/rust-lang/crates.io-index" 1759 - checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 1760 dependencies = [ 1761 "unicode-ident", 1762 ] 1763 1764 [[package]] 1765 name = "quinn" 1766 - version = "0.11.8" 1767 source = "registry+https://github.com/rust-lang/crates.io-index" 1768 - checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" 1769 dependencies = [ 1770 "bytes", 1771 "cfg_aliases", ··· 1774 "quinn-udp", 1775 "rustc-hash", 1776 "rustls", 1777 - "socket2", 1778 - "thiserror 2.0.12", 1779 "tokio", 1780 "tracing", 1781 "web-time", ··· 1783 1784 [[package]] 1785 name = "quinn-proto" 1786 - version = "0.11.12" 1787 source = "registry+https://github.com/rust-lang/crates.io-index" 1788 - checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" 1789 dependencies = [ 1790 "bytes", 1791 - "getrandom 0.3.3", 1792 "lru-slab", 1793 - "rand 0.9.1", 1794 "ring", 1795 "rustc-hash", 1796 "rustls", 1797 "rustls-pki-types", 1798 "slab", 1799 - "thiserror 2.0.12", 1800 "tinyvec", 1801 "tracing", 1802 "web-time", ··· 1804 1805 [[package]] 1806 name = "quinn-udp" 1807 - version = "0.5.12" 1808 source = "registry+https://github.com/rust-lang/crates.io-index" 1809 - checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" 1810 dependencies = [ 1811 "cfg_aliases", 1812 "libc", 1813 "once_cell", 1814 - "socket2", 1815 "tracing", 1816 - "windows-sys 0.59.0", 1817 ] 1818 1819 [[package]] 1820 name = "quote" 1821 - version = "1.0.40" 1822 source = "registry+https://github.com/rust-lang/crates.io-index" 1823 - checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 1824 dependencies = [ 1825 "proc-macro2", 1826 ] 1827 1828 [[package]] 1829 name = "r-efi" 1830 - version = "5.2.0" 1831 source = "registry+https://github.com/rust-lang/crates.io-index" 1832 - checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 1833 1834 [[package]] 1835 name = "rand" ··· 1844 1845 [[package]] 1846 name = "rand" 1847 - version = "0.9.1" 1848 source = "registry+https://github.com/rust-lang/crates.io-index" 1849 - checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" 1850 dependencies = [ 1851 "rand_chacha 0.9.0", 1852 "rand_core 0.9.3", ··· 1887 source = "registry+https://github.com/rust-lang/crates.io-index" 1888 checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1889 dependencies = [ 1890 - "getrandom 0.3.3", 1891 ] 1892 1893 [[package]] 1894 name = "redox_syscall" 1895 - version = "0.5.12" 1896 source = "registry+https://github.com/rust-lang/crates.io-index" 1897 - checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" 1898 dependencies = [ 1899 "bitflags", 1900 ] 1901 1902 [[package]] 1903 name = "regex" 1904 - version = "1.11.1" 1905 source = "registry+https://github.com/rust-lang/crates.io-index" 1906 - checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1907 dependencies = [ 1908 "aho-corasick", 1909 "memchr", 1910 - "regex-automata 0.4.9", 1911 - "regex-syntax 0.8.5", 1912 - ] 1913 - 1914 - [[package]] 1915 - name = "regex-automata" 1916 - version = "0.1.10" 1917 - source = "registry+https://github.com/rust-lang/crates.io-index" 1918 - checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 1919 - dependencies = [ 1920 - "regex-syntax 0.6.29", 1921 ] 1922 1923 [[package]] 1924 name = "regex-automata" 1925 - version = "0.4.9" 1926 source = "registry+https://github.com/rust-lang/crates.io-index" 1927 - checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1928 dependencies = [ 1929 "aho-corasick", 1930 "memchr", 1931 - "regex-syntax 0.8.5", 1932 ] 1933 1934 [[package]] 1935 name = "regex-syntax" 1936 - version = "0.6.29" 1937 source = "registry+https://github.com/rust-lang/crates.io-index" 1938 - checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 1939 - 1940 - [[package]] 1941 - name = "regex-syntax" 1942 - version = "0.8.5" 1943 - source = "registry+https://github.com/rust-lang/crates.io-index" 1944 - checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1945 1946 [[package]] 1947 name = "reqwest" 1948 - version = "0.12.18" 1949 source = "registry+https://github.com/rust-lang/crates.io-index" 1950 - checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5" 1951 dependencies = [ 1952 "base64", 1953 "bytes", ··· 1961 "hyper", 1962 "hyper-rustls", 1963 "hyper-util", 1964 - "ipnet", 1965 "js-sys", 1966 "log", 1967 "mime", 1968 "mime_guess", 1969 - "once_cell", 1970 "percent-encoding", 1971 "pin-project-lite", 1972 "quinn", ··· 2017 2018 [[package]] 2019 name = "resolv-conf" 2020 - version = "0.7.4" 2021 source = "registry+https://github.com/rust-lang/crates.io-index" 2022 - checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" 2023 2024 [[package]] 2025 name = "rfc6979" ··· 2067 ] 2068 2069 [[package]] 2070 - name = "rustc-demangle" 2071 - version = "0.1.24" 2072 - source = "registry+https://github.com/rust-lang/crates.io-index" 2073 - checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 2074 - 2075 - [[package]] 2076 name = "rustc-hash" 2077 version = "2.1.1" 2078 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2089 2090 [[package]] 2091 name = "rustls" 2092 - version = "0.23.27" 2093 source = "registry+https://github.com/rust-lang/crates.io-index" 2094 - checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" 2095 dependencies = [ 2096 "once_cell", 2097 "ring", ··· 2103 2104 [[package]] 2105 name = "rustls-native-certs" 2106 - version = "0.8.1" 2107 source = "registry+https://github.com/rust-lang/crates.io-index" 2108 - checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" 2109 dependencies = [ 2110 "openssl-probe", 2111 "rustls-pki-types", ··· 2115 2116 [[package]] 2117 name = "rustls-pki-types" 2118 - version = "1.12.0" 2119 source = "registry+https://github.com/rust-lang/crates.io-index" 2120 - checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" 2121 dependencies = [ 2122 "web-time", 2123 "zeroize", ··· 2125 2126 [[package]] 2127 name = "rustls-webpki" 2128 - version = "0.103.3" 2129 source = "registry+https://github.com/rust-lang/crates.io-index" 2130 - checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" 2131 dependencies = [ 2132 "ring", 2133 "rustls-pki-types", ··· 2136 2137 [[package]] 2138 name = "rustversion" 2139 - version = "1.0.21" 2140 source = "registry+https://github.com/rust-lang/crates.io-index" 2141 - checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" 2142 2143 [[package]] 2144 name = "ryu" ··· 2148 2149 [[package]] 2150 name = "schannel" 2151 - version = "0.1.27" 2152 source = "registry+https://github.com/rust-lang/crates.io-index" 2153 - checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 2154 dependencies = [ 2155 - "windows-sys 0.59.0", 2156 ] 2157 - 2158 - [[package]] 2159 - name = "scoped-tls" 2160 - version = "1.0.1" 2161 - source = "registry+https://github.com/rust-lang/crates.io-index" 2162 - checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 2163 2164 [[package]] 2165 name = "scopeguard" ··· 2194 2195 [[package]] 2196 name = "security-framework" 2197 - version = "3.2.0" 2198 source = "registry+https://github.com/rust-lang/crates.io-index" 2199 - checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" 2200 dependencies = [ 2201 "bitflags", 2202 "core-foundation 0.10.1", ··· 2207 2208 [[package]] 2209 name = "security-framework-sys" 2210 - version = "2.14.0" 2211 source = "registry+https://github.com/rust-lang/crates.io-index" 2212 - checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 2213 dependencies = [ 2214 "core-foundation-sys", 2215 "libc", ··· 2217 2218 [[package]] 2219 name = "semver" 2220 - version = "1.0.26" 2221 source = "registry+https://github.com/rust-lang/crates.io-index" 2222 - checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" 2223 2224 [[package]] 2225 name = "serde" 2226 - version = "1.0.219" 2227 source = "registry+https://github.com/rust-lang/crates.io-index" 2228 - checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 2229 dependencies = [ 2230 "serde_derive", 2231 ] 2232 2233 [[package]] 2234 name = "serde_bytes" 2235 - version = "0.11.17" 2236 source = "registry+https://github.com/rust-lang/crates.io-index" 2237 - checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" 2238 dependencies = [ 2239 "serde", 2240 ] 2241 2242 [[package]] 2243 name = "serde_derive" 2244 - version = "1.0.219" 2245 source = "registry+https://github.com/rust-lang/crates.io-index" 2246 - checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 2247 dependencies = [ 2248 "proc-macro2", 2249 "quote", 2250 - "syn", 2251 ] 2252 2253 [[package]] 2254 name = "serde_ipld_dagcbor" 2255 - version = "0.6.3" 2256 source = "registry+https://github.com/rust-lang/crates.io-index" 2257 - checksum = "99600723cf53fb000a66175555098db7e75217c415bdd9a16a65d52a19dcc4fc" 2258 dependencies = [ 2259 "cbor4ii", 2260 "ipld-core", ··· 2264 2265 [[package]] 2266 name = "serde_json" 2267 - version = "1.0.140" 2268 source = "registry+https://github.com/rust-lang/crates.io-index" 2269 - checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 2270 dependencies = [ 2271 "itoa", 2272 "memchr", 2273 "ryu", 2274 "serde", 2275 ] 2276 2277 [[package]] 2278 name = "serde_path_to_error" 2279 - version = "0.1.17" 2280 source = "registry+https://github.com/rust-lang/crates.io-index" 2281 - checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" 2282 dependencies = [ 2283 "itoa", 2284 "serde", 2285 ] 2286 2287 [[package]] ··· 2334 2335 [[package]] 2336 name = "signal-hook-registry" 2337 - version = "1.4.5" 2338 source = "registry+https://github.com/rust-lang/crates.io-index" 2339 - checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" 2340 dependencies = [ 2341 "libc", 2342 ] ··· 2359 2360 [[package]] 2361 name = "slab" 2362 - version = "0.4.9" 2363 source = "registry+https://github.com/rust-lang/crates.io-index" 2364 - checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 2365 - dependencies = [ 2366 - "autocfg", 2367 - ] 2368 2369 [[package]] 2370 name = "smallvec" 2371 - version = "1.15.0" 2372 source = "registry+https://github.com/rust-lang/crates.io-index" 2373 - checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" 2374 2375 [[package]] 2376 name = "socket2" ··· 2383 ] 2384 2385 [[package]] 2386 name = "spki" 2387 version = "0.7.3" 2388 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2394 2395 [[package]] 2396 name = "stable_deref_trait" 2397 - version = "1.2.0" 2398 source = "registry+https://github.com/rust-lang/crates.io-index" 2399 - checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 2400 2401 [[package]] 2402 name = "strsim" ··· 2412 2413 [[package]] 2414 name = "syn" 2415 - version = "2.0.101" 2416 source = "registry+https://github.com/rust-lang/crates.io-index" 2417 - checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 2418 dependencies = [ 2419 "proc-macro2", 2420 "quote", ··· 2438 dependencies = [ 2439 "proc-macro2", 2440 "quote", 2441 - "syn", 2442 ] 2443 2444 [[package]] ··· 2479 2480 [[package]] 2481 name = "thiserror" 2482 - version = "2.0.12" 2483 source = "registry+https://github.com/rust-lang/crates.io-index" 2484 - checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 2485 dependencies = [ 2486 - "thiserror-impl 2.0.12", 2487 ] 2488 2489 [[package]] ··· 2494 dependencies = [ 2495 "proc-macro2", 2496 "quote", 2497 - "syn", 2498 ] 2499 2500 [[package]] 2501 name = "thiserror-impl" 2502 - version = "2.0.12" 2503 source = "registry+https://github.com/rust-lang/crates.io-index" 2504 - checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 2505 dependencies = [ 2506 "proc-macro2", 2507 "quote", 2508 - "syn", 2509 ] 2510 2511 [[package]] 2512 name = "thread_local" 2513 - version = "1.1.8" 2514 source = "registry+https://github.com/rust-lang/crates.io-index" 2515 - checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 2516 dependencies = [ 2517 "cfg-if", 2518 - "once_cell", 2519 ] 2520 2521 [[package]] 2522 name = "tinystr" 2523 - version = "0.8.1" 2524 source = "registry+https://github.com/rust-lang/crates.io-index" 2525 - checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" 2526 dependencies = [ 2527 "displaydoc", 2528 "zerovec", ··· 2530 2531 [[package]] 2532 name = "tinyvec" 2533 - version = "1.9.0" 2534 source = "registry+https://github.com/rust-lang/crates.io-index" 2535 - checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" 2536 dependencies = [ 2537 "tinyvec_macros", 2538 ] ··· 2545 2546 [[package]] 2547 name = "tokio" 2548 - version = "1.45.1" 2549 source = "registry+https://github.com/rust-lang/crates.io-index" 2550 - checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" 2551 dependencies = [ 2552 - "backtrace", 2553 "bytes", 2554 "libc", 2555 "mio", 2556 "parking_lot", 2557 "pin-project-lite", 2558 "signal-hook-registry", 2559 - "socket2", 2560 "tokio-macros", 2561 - "windows-sys 0.52.0", 2562 ] 2563 2564 [[package]] 2565 name = "tokio-macros" 2566 - version = "2.5.0" 2567 source = "registry+https://github.com/rust-lang/crates.io-index" 2568 - checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 2569 dependencies = [ 2570 "proc-macro2", 2571 "quote", 2572 - "syn", 2573 ] 2574 2575 [[package]] 2576 name = "tokio-rustls" 2577 - version = "0.26.2" 2578 source = "registry+https://github.com/rust-lang/crates.io-index" 2579 - checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 2580 dependencies = [ 2581 "rustls", 2582 "tokio", 2583 ] 2584 2585 [[package]] 2586 name = "tokio-util" 2587 - version = "0.7.15" 2588 source = "registry+https://github.com/rust-lang/crates.io-index" 2589 - checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" 2590 dependencies = [ 2591 "bytes", 2592 "futures-core", ··· 2607 "futures-sink", 2608 "http", 2609 "httparse", 2610 - "rand 0.9.1", 2611 "ring", 2612 "rustls-native-certs", 2613 "rustls-pki-types", ··· 2635 2636 [[package]] 2637 name = "tower-http" 2638 - version = "0.6.4" 2639 source = "registry+https://github.com/rust-lang/crates.io-index" 2640 - checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" 2641 dependencies = [ 2642 "bitflags", 2643 "bytes", ··· 2677 2678 [[package]] 2679 name = "tracing-attributes" 2680 - version = "0.1.28" 2681 source = "registry+https://github.com/rust-lang/crates.io-index" 2682 - checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" 2683 dependencies = [ 2684 "proc-macro2", 2685 "quote", 2686 - "syn", 2687 ] 2688 2689 [[package]] 2690 name = "tracing-core" 2691 - version = "0.1.33" 2692 source = "registry+https://github.com/rust-lang/crates.io-index" 2693 - checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 2694 dependencies = [ 2695 "once_cell", 2696 "valuable", ··· 2709 2710 [[package]] 2711 name = "tracing-subscriber" 2712 - version = "0.3.19" 2713 source = "registry+https://github.com/rust-lang/crates.io-index" 2714 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2715 dependencies = [ 2716 "matchers", 2717 "nu-ansi-term", 2718 "once_cell", 2719 - "regex", 2720 "sharded-slab", 2721 "smallvec", 2722 "thread_local", ··· 2733 2734 [[package]] 2735 name = "typenum" 2736 - version = "1.18.0" 2737 source = "registry+https://github.com/rust-lang/crates.io-index" 2738 - checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 2739 2740 [[package]] 2741 name = "ulid" ··· 2743 source = "registry+https://github.com/rust-lang/crates.io-index" 2744 checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" 2745 dependencies = [ 2746 - "rand 0.9.1", 2747 "web-time", 2748 ] 2749 ··· 2755 2756 [[package]] 2757 name = "unicode-ident" 2758 - version = "1.0.18" 2759 source = "registry+https://github.com/rust-lang/crates.io-index" 2760 - checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 2761 2762 [[package]] 2763 name = "unsigned-varint" ··· 2773 2774 [[package]] 2775 name = "url" 2776 - version = "2.5.4" 2777 source = "registry+https://github.com/rust-lang/crates.io-index" 2778 - checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 2779 dependencies = [ 2780 "form_urlencoded", 2781 "idna", 2782 "percent-encoding", 2783 ] 2784 2785 [[package]] ··· 2802 2803 [[package]] 2804 name = "uuid" 2805 - version = "1.17.0" 2806 source = "registry+https://github.com/rust-lang/crates.io-index" 2807 - checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" 2808 dependencies = [ 2809 - "getrandom 0.3.3", 2810 "js-sys", 2811 "wasm-bindgen", 2812 ] ··· 2834 2835 [[package]] 2836 name = "wasi" 2837 - version = "0.11.0+wasi-snapshot-preview1" 2838 source = "registry+https://github.com/rust-lang/crates.io-index" 2839 - checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 2840 2841 [[package]] 2842 - name = "wasi" 2843 - version = "0.14.2+wasi-0.2.4" 2844 source = "registry+https://github.com/rust-lang/crates.io-index" 2845 - checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 2846 dependencies = [ 2847 - "wit-bindgen-rt", 2848 ] 2849 2850 [[package]] 2851 name = "wasm-bindgen" 2852 - version = "0.2.100" 2853 source = "registry+https://github.com/rust-lang/crates.io-index" 2854 - checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 2855 dependencies = [ 2856 "cfg-if", 2857 "once_cell", 2858 "rustversion", 2859 "wasm-bindgen-macro", 2860 - ] 2861 - 2862 - [[package]] 2863 - name = "wasm-bindgen-backend" 2864 - version = "0.2.100" 2865 - source = "registry+https://github.com/rust-lang/crates.io-index" 2866 - checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 2867 - dependencies = [ 2868 - "bumpalo", 2869 - "log", 2870 - "proc-macro2", 2871 - "quote", 2872 - "syn", 2873 "wasm-bindgen-shared", 2874 ] 2875 2876 [[package]] 2877 name = "wasm-bindgen-futures" 2878 - version = "0.4.50" 2879 source = "registry+https://github.com/rust-lang/crates.io-index" 2880 - checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 2881 dependencies = [ 2882 "cfg-if", 2883 "js-sys", ··· 2888 2889 [[package]] 2890 name = "wasm-bindgen-macro" 2891 - version = "0.2.100" 2892 source = "registry+https://github.com/rust-lang/crates.io-index" 2893 - checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 2894 dependencies = [ 2895 "quote", 2896 "wasm-bindgen-macro-support", ··· 2898 2899 [[package]] 2900 name = "wasm-bindgen-macro-support" 2901 - version = "0.2.100" 2902 source = "registry+https://github.com/rust-lang/crates.io-index" 2903 - checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 2904 dependencies = [ 2905 "proc-macro2", 2906 "quote", 2907 - "syn", 2908 - "wasm-bindgen-backend", 2909 "wasm-bindgen-shared", 2910 ] 2911 2912 [[package]] 2913 name = "wasm-bindgen-shared" 2914 - version = "0.2.100" 2915 source = "registry+https://github.com/rust-lang/crates.io-index" 2916 - checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 2917 dependencies = [ 2918 "unicode-ident", 2919 ] 2920 2921 [[package]] 2922 name = "web-sys" 2923 - version = "0.3.77" 2924 source = "registry+https://github.com/rust-lang/crates.io-index" 2925 - checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2926 dependencies = [ 2927 "js-sys", 2928 "wasm-bindgen", ··· 2940 2941 [[package]] 2942 name = "webpki-roots" 2943 - version = "1.0.0" 2944 source = "registry+https://github.com/rust-lang/crates.io-index" 2945 - checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" 2946 dependencies = [ 2947 "rustls-pki-types", 2948 ] 2949 2950 [[package]] 2951 name = "widestring" 2952 - version = "1.2.0" 2953 - source = "registry+https://github.com/rust-lang/crates.io-index" 2954 - checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" 2955 - 2956 - [[package]] 2957 - name = "winapi" 2958 - version = "0.3.9" 2959 - source = "registry+https://github.com/rust-lang/crates.io-index" 2960 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2961 - dependencies = [ 2962 - "winapi-i686-pc-windows-gnu", 2963 - "winapi-x86_64-pc-windows-gnu", 2964 - ] 2965 - 2966 - [[package]] 2967 - name = "winapi-i686-pc-windows-gnu" 2968 - version = "0.4.0" 2969 - source = "registry+https://github.com/rust-lang/crates.io-index" 2970 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2971 - 2972 - [[package]] 2973 - name = "winapi-x86_64-pc-windows-gnu" 2974 - version = "0.4.0" 2975 - source = "registry+https://github.com/rust-lang/crates.io-index" 2976 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2977 - 2978 - [[package]] 2979 - name = "windows" 2980 - version = "0.61.1" 2981 - source = "registry+https://github.com/rust-lang/crates.io-index" 2982 - checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" 2983 - dependencies = [ 2984 - "windows-collections", 2985 - "windows-core", 2986 - "windows-future", 2987 - "windows-link", 2988 - "windows-numerics", 2989 - ] 2990 - 2991 - [[package]] 2992 - name = "windows-collections" 2993 - version = "0.2.0" 2994 - source = "registry+https://github.com/rust-lang/crates.io-index" 2995 - checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 2996 - dependencies = [ 2997 - "windows-core", 2998 - ] 2999 - 3000 - [[package]] 3001 - name = "windows-core" 3002 - version = "0.61.2" 3003 source = "registry+https://github.com/rust-lang/crates.io-index" 3004 - checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 3005 - dependencies = [ 3006 - "windows-implement", 3007 - "windows-interface", 3008 - "windows-link", 3009 - "windows-result", 3010 - "windows-strings 0.4.2", 3011 - ] 3012 3013 [[package]] 3014 - name = "windows-future" 3015 - version = "0.2.1" 3016 source = "registry+https://github.com/rust-lang/crates.io-index" 3017 - checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 3018 - dependencies = [ 3019 - "windows-core", 3020 - "windows-link", 3021 - "windows-threading", 3022 - ] 3023 - 3024 - [[package]] 3025 - name = "windows-implement" 3026 - version = "0.60.0" 3027 - source = "registry+https://github.com/rust-lang/crates.io-index" 3028 - checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" 3029 - dependencies = [ 3030 - "proc-macro2", 3031 - "quote", 3032 - "syn", 3033 - ] 3034 - 3035 - [[package]] 3036 - name = "windows-interface" 3037 - version = "0.59.1" 3038 - source = "registry+https://github.com/rust-lang/crates.io-index" 3039 - checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" 3040 - dependencies = [ 3041 - "proc-macro2", 3042 - "quote", 3043 - "syn", 3044 - ] 3045 3046 [[package]] 3047 name = "windows-link" 3048 - version = "0.1.1" 3049 source = "registry+https://github.com/rust-lang/crates.io-index" 3050 - checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" 3051 - 3052 - [[package]] 3053 - name = "windows-numerics" 3054 - version = "0.2.0" 3055 - source = "registry+https://github.com/rust-lang/crates.io-index" 3056 - checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 3057 - dependencies = [ 3058 - "windows-core", 3059 - "windows-link", 3060 - ] 3061 3062 [[package]] 3063 name = "windows-registry" 3064 - version = "0.4.0" 3065 source = "registry+https://github.com/rust-lang/crates.io-index" 3066 - checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" 3067 dependencies = [ 3068 "windows-result", 3069 - "windows-strings 0.3.1", 3070 - "windows-targets 0.53.0", 3071 ] 3072 3073 [[package]] ··· 3076 source = "registry+https://github.com/rust-lang/crates.io-index" 3077 checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 3078 dependencies = [ 3079 - "windows-link", 3080 - ] 3081 - 3082 - [[package]] 3083 - name = "windows-strings" 3084 - version = "0.3.1" 3085 - source = "registry+https://github.com/rust-lang/crates.io-index" 3086 - checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" 3087 - dependencies = [ 3088 - "windows-link", 3089 ] 3090 3091 [[package]] ··· 3094 source = "registry+https://github.com/rust-lang/crates.io-index" 3095 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 3096 dependencies = [ 3097 - "windows-link", 3098 ] 3099 3100 [[package]] ··· 3125 ] 3126 3127 [[package]] 3128 name = "windows-targets" 3129 version = "0.48.5" 3130 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3157 3158 [[package]] 3159 name = "windows-targets" 3160 - version = "0.53.0" 3161 source = "registry+https://github.com/rust-lang/crates.io-index" 3162 - checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" 3163 dependencies = [ 3164 - "windows_aarch64_gnullvm 0.53.0", 3165 - "windows_aarch64_msvc 0.53.0", 3166 - "windows_i686_gnu 0.53.0", 3167 - "windows_i686_gnullvm 0.53.0", 3168 - "windows_i686_msvc 0.53.0", 3169 - "windows_x86_64_gnu 0.53.0", 3170 - "windows_x86_64_gnullvm 0.53.0", 3171 - "windows_x86_64_msvc 0.53.0", 3172 - ] 3173 - 3174 - [[package]] 3175 - name = "windows-threading" 3176 - version = "0.1.0" 3177 - source = "registry+https://github.com/rust-lang/crates.io-index" 3178 - checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" 3179 - dependencies = [ 3180 - "windows-link", 3181 ] 3182 3183 [[package]] ··· 3194 3195 [[package]] 3196 name = "windows_aarch64_gnullvm" 3197 - version = "0.53.0" 3198 source = "registry+https://github.com/rust-lang/crates.io-index" 3199 - checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" 3200 3201 [[package]] 3202 name = "windows_aarch64_msvc" ··· 3212 3213 [[package]] 3214 name = "windows_aarch64_msvc" 3215 - version = "0.53.0" 3216 source = "registry+https://github.com/rust-lang/crates.io-index" 3217 - checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" 3218 3219 [[package]] 3220 name = "windows_i686_gnu" ··· 3230 3231 [[package]] 3232 name = "windows_i686_gnu" 3233 - version = "0.53.0" 3234 source = "registry+https://github.com/rust-lang/crates.io-index" 3235 - checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" 3236 3237 [[package]] 3238 name = "windows_i686_gnullvm" ··· 3242 3243 [[package]] 3244 name = "windows_i686_gnullvm" 3245 - version = "0.53.0" 3246 source = "registry+https://github.com/rust-lang/crates.io-index" 3247 - checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" 3248 3249 [[package]] 3250 name = "windows_i686_msvc" ··· 3260 3261 [[package]] 3262 name = "windows_i686_msvc" 3263 - version = "0.53.0" 3264 source = "registry+https://github.com/rust-lang/crates.io-index" 3265 - checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" 3266 3267 [[package]] 3268 name = "windows_x86_64_gnu" ··· 3278 3279 [[package]] 3280 name = "windows_x86_64_gnu" 3281 - version = "0.53.0" 3282 source = "registry+https://github.com/rust-lang/crates.io-index" 3283 - checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" 3284 3285 [[package]] 3286 name = "windows_x86_64_gnullvm" ··· 3296 3297 [[package]] 3298 name = "windows_x86_64_gnullvm" 3299 - version = "0.53.0" 3300 source = "registry+https://github.com/rust-lang/crates.io-index" 3301 - checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" 3302 3303 [[package]] 3304 name = "windows_x86_64_msvc" ··· 3314 3315 [[package]] 3316 name = "windows_x86_64_msvc" 3317 - version = "0.53.0" 3318 source = "registry+https://github.com/rust-lang/crates.io-index" 3319 - checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 3320 3321 [[package]] 3322 name = "winreg" ··· 3329 ] 3330 3331 [[package]] 3332 - name = "wit-bindgen-rt" 3333 - version = "0.39.0" 3334 source = "registry+https://github.com/rust-lang/crates.io-index" 3335 - checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 3336 - dependencies = [ 3337 - "bitflags", 3338 - ] 3339 3340 [[package]] 3341 name = "writeable" 3342 - version = "0.6.1" 3343 source = "registry+https://github.com/rust-lang/crates.io-index" 3344 - checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" 3345 3346 [[package]] 3347 name = "yoke" 3348 - version = "0.8.0" 3349 source = "registry+https://github.com/rust-lang/crates.io-index" 3350 - checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" 3351 dependencies = [ 3352 - "serde", 3353 "stable_deref_trait", 3354 "yoke-derive", 3355 "zerofrom", ··· 3357 3358 [[package]] 3359 name = "yoke-derive" 3360 - version = "0.8.0" 3361 source = "registry+https://github.com/rust-lang/crates.io-index" 3362 - checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" 3363 dependencies = [ 3364 "proc-macro2", 3365 "quote", 3366 - "syn", 3367 "synstructure", 3368 ] 3369 3370 [[package]] 3371 name = "zerocopy" 3372 - version = "0.8.25" 3373 source = "registry+https://github.com/rust-lang/crates.io-index" 3374 - checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" 3375 dependencies = [ 3376 "zerocopy-derive", 3377 ] 3378 3379 [[package]] 3380 name = "zerocopy-derive" 3381 - version = "0.8.25" 3382 source = "registry+https://github.com/rust-lang/crates.io-index" 3383 - checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" 3384 dependencies = [ 3385 "proc-macro2", 3386 "quote", 3387 - "syn", 3388 ] 3389 3390 [[package]] ··· 3404 dependencies = [ 3405 "proc-macro2", 3406 "quote", 3407 - "syn", 3408 "synstructure", 3409 ] 3410 3411 [[package]] 3412 name = "zeroize" 3413 - version = "1.8.1" 3414 source = "registry+https://github.com/rust-lang/crates.io-index" 3415 - checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 3416 dependencies = [ 3417 "zeroize_derive", 3418 ] ··· 3425 dependencies = [ 3426 "proc-macro2", 3427 "quote", 3428 - "syn", 3429 ] 3430 3431 [[package]] 3432 name = "zerotrie" 3433 - version = "0.2.2" 3434 source = "registry+https://github.com/rust-lang/crates.io-index" 3435 - checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" 3436 dependencies = [ 3437 "displaydoc", 3438 "yoke", ··· 3441 3442 [[package]] 3443 name = "zerovec" 3444 - version = "0.11.2" 3445 source = "registry+https://github.com/rust-lang/crates.io-index" 3446 - checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" 3447 dependencies = [ 3448 "yoke", 3449 "zerofrom", ··· 3452 3453 [[package]] 3454 name = "zerovec-derive" 3455 - version = "0.11.1" 3456 source = "registry+https://github.com/rust-lang/crates.io-index" 3457 - checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" 3458 dependencies = [ 3459 "proc-macro2", 3460 "quote", 3461 - "syn", 3462 ] 3463 3464 [[package]] ··· 3481 3482 [[package]] 3483 name = "zstd-sys" 3484 - version = "2.0.15+zstd.1.5.7" 3485 source = "registry+https://github.com/rust-lang/crates.io-index" 3486 - checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" 3487 dependencies = [ 3488 "cc", 3489 "pkg-config",
··· 3 version = 4 4 5 [[package]] 6 name = "aho-corasick" 7 + version = "1.1.4" 8 source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 dependencies = [ 11 "memchr", 12 ] ··· 19 20 [[package]] 21 name = "anstream" 22 + version = "0.6.21" 23 source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" 25 dependencies = [ 26 "anstyle", 27 "anstyle-parse", ··· 34 35 [[package]] 36 name = "anstyle" 37 + version = "1.0.13" 38 source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" 40 41 [[package]] 42 name = "anstyle-parse" ··· 49 50 [[package]] 51 name = "anstyle-query" 52 + version = "1.1.4" 53 source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" 55 dependencies = [ 56 + "windows-sys 0.60.2", 57 ] 58 59 [[package]] 60 name = "anstyle-wincon" 61 + version = "3.0.10" 62 source = "registry+https://github.com/rust-lang/crates.io-index" 63 + checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" 64 dependencies = [ 65 "anstyle", 66 "once_cell_polyfill", 67 + "windows-sys 0.60.2", 68 ] 69 70 [[package]] 71 name = "anyhow" 72 + version = "1.0.100" 73 source = "registry+https://github.com/rust-lang/crates.io-index" 74 + checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 75 76 [[package]] 77 name = "async-trait" 78 + version = "0.1.89" 79 source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 81 dependencies = [ 82 "proc-macro2", 83 "quote", 84 + "syn 2.0.109", 85 ] 86 87 [[package]] ··· 91 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 92 93 [[package]] 94 + name = "atproto-attestation" 95 + version = "0.13.0" 96 + dependencies = [ 97 + "anyhow", 98 + "async-trait", 99 + "atproto-client", 100 + "atproto-identity", 101 + "atproto-record", 102 + "base64", 103 + "chrono", 104 + "cid", 105 + "clap", 106 + "elliptic-curve", 107 + "k256", 108 + "multihash", 109 + "p256", 110 + "reqwest", 111 + "serde", 112 + "serde_ipld_dagcbor", 113 + "serde_json", 114 + "sha2", 115 + "thiserror 2.0.17", 116 + "tokio", 117 + ] 118 + 119 + [[package]] 120 name = "atproto-client" 121 version = "0.13.0" 122 dependencies = [ 123 "anyhow", 124 + "async-trait", 125 "atproto-identity", 126 "atproto-oauth", 127 "atproto-record", ··· 134 "secrecy", 135 "serde", 136 "serde_json", 137 + "thiserror 2.0.17", 138 "tokio", 139 "tracing", 140 "urlencoding", 141 + ] 142 + 143 + [[package]] 144 + name = "atproto-extras" 145 + version = "0.13.0" 146 + dependencies = [ 147 + "anyhow", 148 + "async-trait", 149 + "atproto-identity", 150 + "atproto-record", 151 + "clap", 152 + "regex", 153 + "reqwest", 154 + "serde_json", 155 + "tokio", 156 ] 157 158 [[package]] ··· 175 "serde", 176 "serde_ipld_dagcbor", 177 "serde_json", 178 + "thiserror 2.0.17", 179 "tokio", 180 "tracing", 181 + "url", 182 "urlencoding", 183 "zeroize", 184 ] ··· 195 "http", 196 "serde", 197 "serde_json", 198 + "thiserror 2.0.17", 199 "tokio", 200 "tokio-util", 201 "tokio-websockets", ··· 218 "reqwest", 219 "serde", 220 "serde_json", 221 + "thiserror 2.0.17", 222 "tokio", 223 "tracing", 224 "zeroize", ··· 249 "serde_ipld_dagcbor", 250 "serde_json", 251 "sha2", 252 + "thiserror 2.0.17", 253 "tokio", 254 "tracing", 255 "ulid", ··· 267 "reqwest", 268 "serde", 269 "serde_json", 270 + "thiserror 2.0.17", 271 "zeroize", 272 ] 273 ··· 294 "secrecy", 295 "serde", 296 "serde_json", 297 + "thiserror 2.0.17", 298 "tokio", 299 "tracing", 300 "zeroize", ··· 305 version = "0.13.0" 306 dependencies = [ 307 "anyhow", 308 + "async-trait", 309 "atproto-identity", 310 "base64", 311 "chrono", 312 + "cid", 313 "clap", 314 + "multihash", 315 + "rand 0.8.5", 316 "serde", 317 "serde_ipld_dagcbor", 318 "serde_json", 319 + "sha2", 320 + "thiserror 2.0.17", 321 + "tokio", 322 + ] 323 + 324 + [[package]] 325 + name = "atproto-tap" 326 + version = "0.13.0" 327 + dependencies = [ 328 + "atproto-client", 329 + "atproto-identity", 330 + "base64", 331 + "clap", 332 + "compact_str", 333 + "futures", 334 + "http", 335 + "itoa", 336 + "reqwest", 337 + "serde", 338 + "serde_json", 339 + "thiserror 2.0.17", 340 "tokio", 341 + "tokio-stream", 342 + "tokio-websockets", 343 + "tracing", 344 + "tracing-subscriber", 345 ] 346 347 [[package]] ··· 365 "reqwest-middleware", 366 "serde", 367 "serde_json", 368 + "thiserror 2.0.17", 369 "tokio", 370 "tracing", 371 ] ··· 392 "reqwest-middleware", 393 "serde", 394 "serde_json", 395 + "thiserror 2.0.17", 396 "tokio", 397 "tracing", 398 ] 399 400 [[package]] 401 name = "autocfg" 402 + version = "1.5.0" 403 source = "registry+https://github.com/rust-lang/crates.io-index" 404 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 405 406 [[package]] 407 name = "axum" 408 + version = "0.8.6" 409 source = "registry+https://github.com/rust-lang/crates.io-index" 410 + checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" 411 dependencies = [ 412 "axum-core", 413 "axum-macros", ··· 425 "mime", 426 "percent-encoding", 427 "pin-project-lite", 428 + "serde_core", 429 "serde_json", 430 "serde_path_to_error", 431 "serde_urlencoded", ··· 439 440 [[package]] 441 name = "axum-core" 442 + version = "0.5.5" 443 source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" 445 dependencies = [ 446 "bytes", 447 "futures-core", ··· 450 "http-body-util", 451 "mime", 452 "pin-project-lite", 453 "sync_wrapper", 454 "tower-layer", 455 "tower-service", ··· 464 dependencies = [ 465 "proc-macro2", 466 "quote", 467 + "syn 2.0.109", 468 ] 469 470 [[package]] ··· 480 checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 481 482 [[package]] 483 + name = "base256emoji" 484 + version = "1.0.2" 485 + source = "registry+https://github.com/rust-lang/crates.io-index" 486 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 487 + dependencies = [ 488 + "const-str", 489 + "match-lookup", 490 + ] 491 + 492 + [[package]] 493 name = "base64" 494 version = "0.22.1" 495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 497 498 [[package]] 499 name = "base64ct" 500 + version = "1.8.0" 501 source = "registry+https://github.com/rust-lang/crates.io-index" 502 + checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 503 504 [[package]] 505 name = "bitflags" 506 + version = "2.10.0" 507 source = "registry+https://github.com/rust-lang/crates.io-index" 508 + checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 509 510 [[package]] 511 name = "block-buffer" ··· 518 519 [[package]] 520 name = "bumpalo" 521 + version = "3.19.0" 522 source = "registry+https://github.com/rust-lang/crates.io-index" 523 + checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 524 525 [[package]] 526 name = "bytes" ··· 529 checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 530 531 [[package]] 532 + name = "castaway" 533 + version = "0.2.4" 534 + source = "registry+https://github.com/rust-lang/crates.io-index" 535 + checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 536 + dependencies = [ 537 + "rustversion", 538 + ] 539 + 540 + [[package]] 541 name = "cbor4ii" 542 version = "0.2.14" 543 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 548 549 [[package]] 550 name = "cc" 551 + version = "1.2.44" 552 source = "registry+https://github.com/rust-lang/crates.io-index" 553 + checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" 554 dependencies = [ 555 + "find-msvc-tools", 556 "jobserver", 557 "libc", 558 "shlex", ··· 560 561 [[package]] 562 name = "cfg-if" 563 + version = "1.0.4" 564 source = "registry+https://github.com/rust-lang/crates.io-index" 565 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 566 567 [[package]] 568 name = "cfg_aliases" ··· 572 573 [[package]] 574 name = "chrono" 575 + version = "0.4.42" 576 source = "registry+https://github.com/rust-lang/crates.io-index" 577 + checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" 578 dependencies = [ 579 "num-traits", 580 "serde", ··· 596 597 [[package]] 598 name = "clap" 599 + version = "4.5.51" 600 source = "registry+https://github.com/rust-lang/crates.io-index" 601 + checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" 602 dependencies = [ 603 "clap_builder", 604 "clap_derive", ··· 606 607 [[package]] 608 name = "clap_builder" 609 + version = "4.5.51" 610 source = "registry+https://github.com/rust-lang/crates.io-index" 611 + checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" 612 dependencies = [ 613 "anstream", 614 "anstyle", ··· 618 619 [[package]] 620 name = "clap_derive" 621 + version = "4.5.49" 622 source = "registry+https://github.com/rust-lang/crates.io-index" 623 + checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" 624 dependencies = [ 625 "heck", 626 "proc-macro2", 627 "quote", 628 + "syn 2.0.109", 629 ] 630 631 [[package]] 632 name = "clap_lex" 633 + version = "0.7.6" 634 source = "registry+https://github.com/rust-lang/crates.io-index" 635 + checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 636 637 [[package]] 638 name = "colorchoice" ··· 641 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 642 643 [[package]] 644 + name = "compact_str" 645 + version = "0.8.1" 646 + source = "registry+https://github.com/rust-lang/crates.io-index" 647 + checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" 648 + dependencies = [ 649 + "castaway", 650 + "cfg-if", 651 + "itoa", 652 + "rustversion", 653 + "ryu", 654 + "serde", 655 + "static_assertions", 656 + ] 657 + 658 + [[package]] 659 name = "const-oid" 660 version = "0.9.6" 661 source = "registry+https://github.com/rust-lang/crates.io-index" 662 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 663 664 [[package]] 665 + name = "const-str" 666 + version = "0.4.3" 667 + source = "registry+https://github.com/rust-lang/crates.io-index" 668 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 669 + 670 + [[package]] 671 name = "core-foundation" 672 version = "0.9.4" 673 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 786 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 787 dependencies = [ 788 "data-encoding", 789 + "syn 2.0.109", 790 ] 791 792 [[package]] ··· 820 dependencies = [ 821 "proc-macro2", 822 "quote", 823 + "syn 2.0.109", 824 ] 825 826 [[package]] ··· 880 "heck", 881 "proc-macro2", 882 "quote", 883 + "syn 2.0.109", 884 ] 885 886 [[package]] ··· 900 ] 901 902 [[package]] 903 + name = "find-msvc-tools" 904 + version = "0.1.4" 905 + source = "registry+https://github.com/rust-lang/crates.io-index" 906 + checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" 907 + 908 + [[package]] 909 name = "fnv" 910 version = "1.0.7" 911 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 919 920 [[package]] 921 name = "form_urlencoded" 922 + version = "1.2.2" 923 source = "registry+https://github.com/rust-lang/crates.io-index" 924 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 925 dependencies = [ 926 "percent-encoding", 927 ] ··· 982 dependencies = [ 983 "proc-macro2", 984 "quote", 985 + "syn 2.0.109", 986 ] 987 988 [[package]] ··· 1016 ] 1017 1018 [[package]] 1019 name = "generic-array" 1020 + version = "0.14.9" 1021 source = "registry+https://github.com/rust-lang/crates.io-index" 1022 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 1023 dependencies = [ 1024 "typenum", 1025 "version_check", ··· 1035 "cfg-if", 1036 "js-sys", 1037 "libc", 1038 + "wasi", 1039 "wasm-bindgen", 1040 ] 1041 1042 [[package]] 1043 name = "getrandom" 1044 + version = "0.3.4" 1045 source = "registry+https://github.com/rust-lang/crates.io-index" 1046 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 1047 dependencies = [ 1048 "cfg-if", 1049 "js-sys", 1050 "libc", 1051 "r-efi", 1052 + "wasip2", 1053 "wasm-bindgen", 1054 ] 1055 1056 [[package]] 1057 name = "group" 1058 version = "0.13.0" 1059 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1066 1067 [[package]] 1068 name = "h2" 1069 + version = "0.4.12" 1070 source = "registry+https://github.com/rust-lang/crates.io-index" 1071 + checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1072 dependencies = [ 1073 "atomic-waker", 1074 "bytes", ··· 1085 1086 [[package]] 1087 name = "hashbrown" 1088 + version = "0.15.5" 1089 source = "registry+https://github.com/rust-lang/crates.io-index" 1090 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 1091 dependencies = [ 1092 "allocator-api2", 1093 "equivalent", 1094 "foldhash", 1095 ] 1096 + 1097 + [[package]] 1098 + name = "hashbrown" 1099 + version = "0.16.0" 1100 + source = "registry+https://github.com/rust-lang/crates.io-index" 1101 + checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1102 1103 [[package]] 1104 name = "heck" ··· 1122 "idna", 1123 "ipnet", 1124 "once_cell", 1125 + "rand 0.9.2", 1126 "ring", 1127 + "thiserror 2.0.17", 1128 "tinyvec", 1129 "tokio", 1130 "tracing", ··· 1144 "moka", 1145 "once_cell", 1146 "parking_lot", 1147 + "rand 0.9.2", 1148 "resolv-conf", 1149 "smallvec", 1150 + "thiserror 2.0.17", 1151 "tokio", 1152 "tracing", 1153 ] ··· 1218 1219 [[package]] 1220 name = "hyper" 1221 + version = "1.7.0" 1222 source = "registry+https://github.com/rust-lang/crates.io-index" 1223 + checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1224 dependencies = [ 1225 + "atomic-waker", 1226 "bytes", 1227 "futures-channel", 1228 + "futures-core", 1229 "h2", 1230 "http", 1231 "http-body", ··· 1233 "httpdate", 1234 "itoa", 1235 "pin-project-lite", 1236 + "pin-utils", 1237 "smallvec", 1238 "tokio", 1239 "want", ··· 1241 1242 [[package]] 1243 name = "hyper-rustls" 1244 + version = "0.27.7" 1245 source = "registry+https://github.com/rust-lang/crates.io-index" 1246 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1247 dependencies = [ 1248 "http", 1249 "hyper", ··· 1258 1259 [[package]] 1260 name = "hyper-util" 1261 + version = "0.1.17" 1262 source = "registry+https://github.com/rust-lang/crates.io-index" 1263 + checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" 1264 dependencies = [ 1265 "base64", 1266 "bytes", ··· 1274 "libc", 1275 "percent-encoding", 1276 "pin-project-lite", 1277 + "socket2 0.6.1", 1278 "system-configuration", 1279 "tokio", 1280 "tower-service", ··· 1284 1285 [[package]] 1286 name = "icu_collections" 1287 + version = "2.1.1" 1288 source = "registry+https://github.com/rust-lang/crates.io-index" 1289 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 1290 dependencies = [ 1291 "displaydoc", 1292 "potential_utf", ··· 1297 1298 [[package]] 1299 name = "icu_locale_core" 1300 + version = "2.1.1" 1301 source = "registry+https://github.com/rust-lang/crates.io-index" 1302 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 1303 dependencies = [ 1304 "displaydoc", 1305 "litemap", ··· 1310 1311 [[package]] 1312 name = "icu_normalizer" 1313 + version = "2.1.1" 1314 source = "registry+https://github.com/rust-lang/crates.io-index" 1315 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 1316 dependencies = [ 1317 "icu_collections", 1318 "icu_normalizer_data", 1319 "icu_properties", ··· 1324 1325 [[package]] 1326 name = "icu_normalizer_data" 1327 + version = "2.1.1" 1328 source = "registry+https://github.com/rust-lang/crates.io-index" 1329 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 1330 1331 [[package]] 1332 name = "icu_properties" 1333 + version = "2.1.1" 1334 source = "registry+https://github.com/rust-lang/crates.io-index" 1335 + checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" 1336 dependencies = [ 1337 "icu_collections", 1338 "icu_locale_core", 1339 "icu_properties_data", 1340 "icu_provider", 1341 "zerotrie", 1342 "zerovec", 1343 ] 1344 1345 [[package]] 1346 name = "icu_properties_data" 1347 + version = "2.1.1" 1348 source = "registry+https://github.com/rust-lang/crates.io-index" 1349 + checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" 1350 1351 [[package]] 1352 name = "icu_provider" 1353 + version = "2.1.1" 1354 source = "registry+https://github.com/rust-lang/crates.io-index" 1355 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 1356 dependencies = [ 1357 "displaydoc", 1358 "icu_locale_core", 1359 "writeable", 1360 "yoke", 1361 "zerofrom", ··· 1365 1366 [[package]] 1367 name = "idna" 1368 + version = "1.1.0" 1369 source = "registry+https://github.com/rust-lang/crates.io-index" 1370 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1371 dependencies = [ 1372 "idna_adapter", 1373 "smallvec", ··· 1386 1387 [[package]] 1388 name = "indexmap" 1389 + version = "2.12.0" 1390 source = "registry+https://github.com/rust-lang/crates.io-index" 1391 + checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" 1392 dependencies = [ 1393 "equivalent", 1394 + "hashbrown 0.16.0", 1395 ] 1396 1397 [[package]] ··· 1400 source = "registry+https://github.com/rust-lang/crates.io-index" 1401 checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1402 dependencies = [ 1403 + "socket2 0.5.10", 1404 "widestring", 1405 "windows-sys 0.48.0", 1406 "winreg", ··· 1425 1426 [[package]] 1427 name = "iri-string" 1428 + version = "0.7.9" 1429 source = "registry+https://github.com/rust-lang/crates.io-index" 1430 + checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" 1431 dependencies = [ 1432 "memchr", 1433 "serde", ··· 1435 1436 [[package]] 1437 name = "is_terminal_polyfill" 1438 + version = "1.70.2" 1439 source = "registry+https://github.com/rust-lang/crates.io-index" 1440 + checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" 1441 1442 [[package]] 1443 name = "itoa" ··· 1447 1448 [[package]] 1449 name = "jobserver" 1450 + version = "0.1.34" 1451 source = "registry+https://github.com/rust-lang/crates.io-index" 1452 + checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" 1453 dependencies = [ 1454 + "getrandom 0.3.4", 1455 "libc", 1456 ] 1457 1458 [[package]] 1459 name = "js-sys" 1460 + version = "0.3.82" 1461 source = "registry+https://github.com/rust-lang/crates.io-index" 1462 + checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" 1463 dependencies = [ 1464 "once_cell", 1465 "wasm-bindgen", ··· 1487 1488 [[package]] 1489 name = "libc" 1490 + version = "0.2.177" 1491 source = "registry+https://github.com/rust-lang/crates.io-index" 1492 + checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 1493 1494 [[package]] 1495 name = "litemap" 1496 + version = "0.8.1" 1497 source = "registry+https://github.com/rust-lang/crates.io-index" 1498 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 1499 1500 [[package]] 1501 name = "lock_api" 1502 + version = "0.4.14" 1503 source = "registry+https://github.com/rust-lang/crates.io-index" 1504 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1505 dependencies = [ 1506 "scopeguard", 1507 ] 1508 1509 [[package]] 1510 name = "log" 1511 + version = "0.4.28" 1512 source = "registry+https://github.com/rust-lang/crates.io-index" 1513 + checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 1514 1515 [[package]] 1516 name = "lru" ··· 1518 source = "registry+https://github.com/rust-lang/crates.io-index" 1519 checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 1520 dependencies = [ 1521 + "hashbrown 0.15.5", 1522 ] 1523 1524 [[package]] ··· 1528 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1529 1530 [[package]] 1531 + name = "match-lookup" 1532 + version = "0.1.1" 1533 + source = "registry+https://github.com/rust-lang/crates.io-index" 1534 + checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" 1535 + dependencies = [ 1536 + "proc-macro2", 1537 + "quote", 1538 + "syn 1.0.109", 1539 + ] 1540 + 1541 + [[package]] 1542 name = "matchers" 1543 + version = "0.2.0" 1544 source = "registry+https://github.com/rust-lang/crates.io-index" 1545 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1546 dependencies = [ 1547 + "regex-automata", 1548 ] 1549 1550 [[package]] ··· 1555 1556 [[package]] 1557 name = "memchr" 1558 + version = "2.7.6" 1559 source = "registry+https://github.com/rust-lang/crates.io-index" 1560 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1561 1562 [[package]] 1563 name = "mime" ··· 1576 ] 1577 1578 [[package]] 1579 name = "mio" 1580 + version = "1.1.0" 1581 source = "registry+https://github.com/rust-lang/crates.io-index" 1582 + checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 1583 dependencies = [ 1584 "libc", 1585 + "wasi", 1586 + "windows-sys 0.61.2", 1587 ] 1588 1589 [[package]] 1590 name = "moka" 1591 + version = "0.12.11" 1592 source = "registry+https://github.com/rust-lang/crates.io-index" 1593 + checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" 1594 dependencies = [ 1595 "crossbeam-channel", 1596 "crossbeam-epoch", 1597 "crossbeam-utils", 1598 + "equivalent", 1599 "parking_lot", 1600 "portable-atomic", 1601 "rustc_version", 1602 "smallvec", 1603 "tagptr", 1604 "uuid", 1605 ] 1606 1607 [[package]] 1608 name = "multibase" 1609 + version = "0.9.2" 1610 source = "registry+https://github.com/rust-lang/crates.io-index" 1611 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 1612 dependencies = [ 1613 "base-x", 1614 + "base256emoji", 1615 "data-encoding", 1616 "data-encoding-macro", 1617 ] ··· 1629 1630 [[package]] 1631 name = "nu-ansi-term" 1632 + version = "0.50.3" 1633 source = "registry+https://github.com/rust-lang/crates.io-index" 1634 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1635 dependencies = [ 1636 + "windows-sys 0.61.2", 1637 ] 1638 1639 [[package]] ··· 1646 ] 1647 1648 [[package]] 1649 name = "once_cell" 1650 version = "1.21.3" 1651 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1657 1658 [[package]] 1659 name = "once_cell_polyfill" 1660 + version = "1.70.2" 1661 source = "registry+https://github.com/rust-lang/crates.io-index" 1662 + checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 1663 1664 [[package]] 1665 name = "openssl-probe" 1666 version = "0.1.6" 1667 source = "registry+https://github.com/rust-lang/crates.io-index" 1668 checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1669 1670 [[package]] 1671 name = "p256" ··· 1695 1696 [[package]] 1697 name = "parking_lot" 1698 + version = "0.12.5" 1699 source = "registry+https://github.com/rust-lang/crates.io-index" 1700 + checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" 1701 dependencies = [ 1702 "lock_api", 1703 "parking_lot_core", ··· 1705 1706 [[package]] 1707 name = "parking_lot_core" 1708 + version = "0.9.12" 1709 source = "registry+https://github.com/rust-lang/crates.io-index" 1710 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 1711 dependencies = [ 1712 "cfg-if", 1713 "libc", 1714 "redox_syscall", 1715 "smallvec", 1716 + "windows-link 0.2.1", 1717 ] 1718 1719 [[package]] ··· 1727 1728 [[package]] 1729 name = "percent-encoding" 1730 + version = "2.3.2" 1731 source = "registry+https://github.com/rust-lang/crates.io-index" 1732 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 1733 1734 [[package]] 1735 name = "pin-project-lite" ··· 1761 1762 [[package]] 1763 name = "portable-atomic" 1764 + version = "1.11.1" 1765 source = "registry+https://github.com/rust-lang/crates.io-index" 1766 + checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 1767 1768 [[package]] 1769 name = "potential_utf" 1770 + version = "0.1.4" 1771 source = "registry+https://github.com/rust-lang/crates.io-index" 1772 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 1773 dependencies = [ 1774 "zerovec", 1775 ] ··· 1795 1796 [[package]] 1797 name = "proc-macro2" 1798 + version = "1.0.103" 1799 source = "registry+https://github.com/rust-lang/crates.io-index" 1800 + checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" 1801 dependencies = [ 1802 "unicode-ident", 1803 ] 1804 1805 [[package]] 1806 name = "quinn" 1807 + version = "0.11.9" 1808 source = "registry+https://github.com/rust-lang/crates.io-index" 1809 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 1810 dependencies = [ 1811 "bytes", 1812 "cfg_aliases", ··· 1815 "quinn-udp", 1816 "rustc-hash", 1817 "rustls", 1818 + "socket2 0.6.1", 1819 + "thiserror 2.0.17", 1820 "tokio", 1821 "tracing", 1822 "web-time", ··· 1824 1825 [[package]] 1826 name = "quinn-proto" 1827 + version = "0.11.13" 1828 source = "registry+https://github.com/rust-lang/crates.io-index" 1829 + checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" 1830 dependencies = [ 1831 "bytes", 1832 + "getrandom 0.3.4", 1833 "lru-slab", 1834 + "rand 0.9.2", 1835 "ring", 1836 "rustc-hash", 1837 "rustls", 1838 "rustls-pki-types", 1839 "slab", 1840 + "thiserror 2.0.17", 1841 "tinyvec", 1842 "tracing", 1843 "web-time", ··· 1845 1846 [[package]] 1847 name = "quinn-udp" 1848 + version = "0.5.14" 1849 source = "registry+https://github.com/rust-lang/crates.io-index" 1850 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 1851 dependencies = [ 1852 "cfg_aliases", 1853 "libc", 1854 "once_cell", 1855 + "socket2 0.6.1", 1856 "tracing", 1857 + "windows-sys 0.60.2", 1858 ] 1859 1860 [[package]] 1861 name = "quote" 1862 + version = "1.0.41" 1863 source = "registry+https://github.com/rust-lang/crates.io-index" 1864 + checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 1865 dependencies = [ 1866 "proc-macro2", 1867 ] 1868 1869 [[package]] 1870 name = "r-efi" 1871 + version = "5.3.0" 1872 source = "registry+https://github.com/rust-lang/crates.io-index" 1873 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 1874 1875 [[package]] 1876 name = "rand" ··· 1885 1886 [[package]] 1887 name = "rand" 1888 + version = "0.9.2" 1889 source = "registry+https://github.com/rust-lang/crates.io-index" 1890 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 1891 dependencies = [ 1892 "rand_chacha 0.9.0", 1893 "rand_core 0.9.3", ··· 1928 source = "registry+https://github.com/rust-lang/crates.io-index" 1929 checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 1930 dependencies = [ 1931 + "getrandom 0.3.4", 1932 ] 1933 1934 [[package]] 1935 name = "redox_syscall" 1936 + version = "0.5.18" 1937 source = "registry+https://github.com/rust-lang/crates.io-index" 1938 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1939 dependencies = [ 1940 "bitflags", 1941 ] 1942 1943 [[package]] 1944 name = "regex" 1945 + version = "1.12.2" 1946 source = "registry+https://github.com/rust-lang/crates.io-index" 1947 + checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 1948 dependencies = [ 1949 "aho-corasick", 1950 "memchr", 1951 + "regex-automata", 1952 + "regex-syntax", 1953 ] 1954 1955 [[package]] 1956 name = "regex-automata" 1957 + version = "0.4.13" 1958 source = "registry+https://github.com/rust-lang/crates.io-index" 1959 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 1960 dependencies = [ 1961 "aho-corasick", 1962 "memchr", 1963 + "regex-syntax", 1964 ] 1965 1966 [[package]] 1967 name = "regex-syntax" 1968 + version = "0.8.8" 1969 source = "registry+https://github.com/rust-lang/crates.io-index" 1970 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 1971 1972 [[package]] 1973 name = "reqwest" 1974 + version = "0.12.24" 1975 source = "registry+https://github.com/rust-lang/crates.io-index" 1976 + checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" 1977 dependencies = [ 1978 "base64", 1979 "bytes", ··· 1987 "hyper", 1988 "hyper-rustls", 1989 "hyper-util", 1990 "js-sys", 1991 "log", 1992 "mime", 1993 "mime_guess", 1994 "percent-encoding", 1995 "pin-project-lite", 1996 "quinn", ··· 2041 2042 [[package]] 2043 name = "resolv-conf" 2044 + version = "0.7.5" 2045 source = "registry+https://github.com/rust-lang/crates.io-index" 2046 + checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" 2047 2048 [[package]] 2049 name = "rfc6979" ··· 2091 ] 2092 2093 [[package]] 2094 name = "rustc-hash" 2095 version = "2.1.1" 2096 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2107 2108 [[package]] 2109 name = "rustls" 2110 + version = "0.23.35" 2111 source = "registry+https://github.com/rust-lang/crates.io-index" 2112 + checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" 2113 dependencies = [ 2114 "once_cell", 2115 "ring", ··· 2121 2122 [[package]] 2123 name = "rustls-native-certs" 2124 + version = "0.8.2" 2125 source = "registry+https://github.com/rust-lang/crates.io-index" 2126 + checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" 2127 dependencies = [ 2128 "openssl-probe", 2129 "rustls-pki-types", ··· 2133 2134 [[package]] 2135 name = "rustls-pki-types" 2136 + version = "1.13.0" 2137 source = "registry+https://github.com/rust-lang/crates.io-index" 2138 + checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" 2139 dependencies = [ 2140 "web-time", 2141 "zeroize", ··· 2143 2144 [[package]] 2145 name = "rustls-webpki" 2146 + version = "0.103.8" 2147 source = "registry+https://github.com/rust-lang/crates.io-index" 2148 + checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" 2149 dependencies = [ 2150 "ring", 2151 "rustls-pki-types", ··· 2154 2155 [[package]] 2156 name = "rustversion" 2157 + version = "1.0.22" 2158 source = "registry+https://github.com/rust-lang/crates.io-index" 2159 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2160 2161 [[package]] 2162 name = "ryu" ··· 2166 2167 [[package]] 2168 name = "schannel" 2169 + version = "0.1.28" 2170 source = "registry+https://github.com/rust-lang/crates.io-index" 2171 + checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 2172 dependencies = [ 2173 + "windows-sys 0.61.2", 2174 ] 2175 2176 [[package]] 2177 name = "scopeguard" ··· 2206 2207 [[package]] 2208 name = "security-framework" 2209 + version = "3.5.1" 2210 source = "registry+https://github.com/rust-lang/crates.io-index" 2211 + checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" 2212 dependencies = [ 2213 "bitflags", 2214 "core-foundation 0.10.1", ··· 2219 2220 [[package]] 2221 name = "security-framework-sys" 2222 + version = "2.15.0" 2223 source = "registry+https://github.com/rust-lang/crates.io-index" 2224 + checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 2225 dependencies = [ 2226 "core-foundation-sys", 2227 "libc", ··· 2229 2230 [[package]] 2231 name = "semver" 2232 + version = "1.0.27" 2233 source = "registry+https://github.com/rust-lang/crates.io-index" 2234 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 2235 2236 [[package]] 2237 name = "serde" 2238 + version = "1.0.228" 2239 source = "registry+https://github.com/rust-lang/crates.io-index" 2240 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2241 dependencies = [ 2242 + "serde_core", 2243 "serde_derive", 2244 ] 2245 2246 [[package]] 2247 name = "serde_bytes" 2248 + version = "0.11.19" 2249 source = "registry+https://github.com/rust-lang/crates.io-index" 2250 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 2251 dependencies = [ 2252 "serde", 2253 + "serde_core", 2254 + ] 2255 + 2256 + [[package]] 2257 + name = "serde_core" 2258 + version = "1.0.228" 2259 + source = "registry+https://github.com/rust-lang/crates.io-index" 2260 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2261 + dependencies = [ 2262 + "serde_derive", 2263 ] 2264 2265 [[package]] 2266 name = "serde_derive" 2267 + version = "1.0.228" 2268 source = "registry+https://github.com/rust-lang/crates.io-index" 2269 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 2270 dependencies = [ 2271 "proc-macro2", 2272 "quote", 2273 + "syn 2.0.109", 2274 ] 2275 2276 [[package]] 2277 name = "serde_ipld_dagcbor" 2278 + version = "0.6.4" 2279 source = "registry+https://github.com/rust-lang/crates.io-index" 2280 + checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" 2281 dependencies = [ 2282 "cbor4ii", 2283 "ipld-core", ··· 2287 2288 [[package]] 2289 name = "serde_json" 2290 + version = "1.0.145" 2291 source = "registry+https://github.com/rust-lang/crates.io-index" 2292 + checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 2293 dependencies = [ 2294 + "indexmap", 2295 "itoa", 2296 "memchr", 2297 "ryu", 2298 "serde", 2299 + "serde_core", 2300 ] 2301 2302 [[package]] 2303 name = "serde_path_to_error" 2304 + version = "0.1.20" 2305 source = "registry+https://github.com/rust-lang/crates.io-index" 2306 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 2307 dependencies = [ 2308 "itoa", 2309 "serde", 2310 + "serde_core", 2311 ] 2312 2313 [[package]] ··· 2360 2361 [[package]] 2362 name = "signal-hook-registry" 2363 + version = "1.4.6" 2364 source = "registry+https://github.com/rust-lang/crates.io-index" 2365 + checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 2366 dependencies = [ 2367 "libc", 2368 ] ··· 2385 2386 [[package]] 2387 name = "slab" 2388 + version = "0.4.11" 2389 source = "registry+https://github.com/rust-lang/crates.io-index" 2390 + checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" 2391 2392 [[package]] 2393 name = "smallvec" 2394 + version = "1.15.1" 2395 source = "registry+https://github.com/rust-lang/crates.io-index" 2396 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 2397 2398 [[package]] 2399 name = "socket2" ··· 2406 ] 2407 2408 [[package]] 2409 + name = "socket2" 2410 + version = "0.6.1" 2411 + source = "registry+https://github.com/rust-lang/crates.io-index" 2412 + checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" 2413 + dependencies = [ 2414 + "libc", 2415 + "windows-sys 0.60.2", 2416 + ] 2417 + 2418 + [[package]] 2419 name = "spki" 2420 version = "0.7.3" 2421 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2427 2428 [[package]] 2429 name = "stable_deref_trait" 2430 + version = "1.2.1" 2431 + source = "registry+https://github.com/rust-lang/crates.io-index" 2432 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 2433 + 2434 + [[package]] 2435 + name = "static_assertions" 2436 + version = "1.1.0" 2437 source = "registry+https://github.com/rust-lang/crates.io-index" 2438 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 2439 2440 [[package]] 2441 name = "strsim" ··· 2451 2452 [[package]] 2453 name = "syn" 2454 + version = "1.0.109" 2455 + source = "registry+https://github.com/rust-lang/crates.io-index" 2456 + checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 2457 + dependencies = [ 2458 + "proc-macro2", 2459 + "quote", 2460 + "unicode-ident", 2461 + ] 2462 + 2463 + [[package]] 2464 + name = "syn" 2465 + version = "2.0.109" 2466 source = "registry+https://github.com/rust-lang/crates.io-index" 2467 + checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" 2468 dependencies = [ 2469 "proc-macro2", 2470 "quote", ··· 2488 dependencies = [ 2489 "proc-macro2", 2490 "quote", 2491 + "syn 2.0.109", 2492 ] 2493 2494 [[package]] ··· 2529 2530 [[package]] 2531 name = "thiserror" 2532 + version = "2.0.17" 2533 source = "registry+https://github.com/rust-lang/crates.io-index" 2534 + checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 2535 dependencies = [ 2536 + "thiserror-impl 2.0.17", 2537 ] 2538 2539 [[package]] ··· 2544 dependencies = [ 2545 "proc-macro2", 2546 "quote", 2547 + "syn 2.0.109", 2548 ] 2549 2550 [[package]] 2551 name = "thiserror-impl" 2552 + version = "2.0.17" 2553 source = "registry+https://github.com/rust-lang/crates.io-index" 2554 + checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 2555 dependencies = [ 2556 "proc-macro2", 2557 "quote", 2558 + "syn 2.0.109", 2559 ] 2560 2561 [[package]] 2562 name = "thread_local" 2563 + version = "1.1.9" 2564 source = "registry+https://github.com/rust-lang/crates.io-index" 2565 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 2566 dependencies = [ 2567 "cfg-if", 2568 ] 2569 2570 [[package]] 2571 name = "tinystr" 2572 + version = "0.8.2" 2573 source = "registry+https://github.com/rust-lang/crates.io-index" 2574 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 2575 dependencies = [ 2576 "displaydoc", 2577 "zerovec", ··· 2579 2580 [[package]] 2581 name = "tinyvec" 2582 + version = "1.10.0" 2583 source = "registry+https://github.com/rust-lang/crates.io-index" 2584 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 2585 dependencies = [ 2586 "tinyvec_macros", 2587 ] ··· 2594 2595 [[package]] 2596 name = "tokio" 2597 + version = "1.48.0" 2598 source = "registry+https://github.com/rust-lang/crates.io-index" 2599 + checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" 2600 dependencies = [ 2601 "bytes", 2602 "libc", 2603 "mio", 2604 "parking_lot", 2605 "pin-project-lite", 2606 "signal-hook-registry", 2607 + "socket2 0.6.1", 2608 "tokio-macros", 2609 + "windows-sys 0.61.2", 2610 ] 2611 2612 [[package]] 2613 name = "tokio-macros" 2614 + version = "2.6.0" 2615 source = "registry+https://github.com/rust-lang/crates.io-index" 2616 + checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" 2617 dependencies = [ 2618 "proc-macro2", 2619 "quote", 2620 + "syn 2.0.109", 2621 ] 2622 2623 [[package]] 2624 name = "tokio-rustls" 2625 + version = "0.26.4" 2626 source = "registry+https://github.com/rust-lang/crates.io-index" 2627 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 2628 dependencies = [ 2629 "rustls", 2630 "tokio", 2631 ] 2632 2633 [[package]] 2634 + name = "tokio-stream" 2635 + version = "0.1.17" 2636 + source = "registry+https://github.com/rust-lang/crates.io-index" 2637 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 2638 + dependencies = [ 2639 + "futures-core", 2640 + "pin-project-lite", 2641 + "tokio", 2642 + ] 2643 + 2644 + [[package]] 2645 name = "tokio-util" 2646 + version = "0.7.17" 2647 source = "registry+https://github.com/rust-lang/crates.io-index" 2648 + checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" 2649 dependencies = [ 2650 "bytes", 2651 "futures-core", ··· 2666 "futures-sink", 2667 "http", 2668 "httparse", 2669 + "rand 0.9.2", 2670 "ring", 2671 "rustls-native-certs", 2672 "rustls-pki-types", ··· 2694 2695 [[package]] 2696 name = "tower-http" 2697 + version = "0.6.6" 2698 source = "registry+https://github.com/rust-lang/crates.io-index" 2699 + checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 2700 dependencies = [ 2701 "bitflags", 2702 "bytes", ··· 2736 2737 [[package]] 2738 name = "tracing-attributes" 2739 + version = "0.1.30" 2740 source = "registry+https://github.com/rust-lang/crates.io-index" 2741 + checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" 2742 dependencies = [ 2743 "proc-macro2", 2744 "quote", 2745 + "syn 2.0.109", 2746 ] 2747 2748 [[package]] 2749 name = "tracing-core" 2750 + version = "0.1.34" 2751 source = "registry+https://github.com/rust-lang/crates.io-index" 2752 + checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 2753 dependencies = [ 2754 "once_cell", 2755 "valuable", ··· 2768 2769 [[package]] 2770 name = "tracing-subscriber" 2771 + version = "0.3.20" 2772 source = "registry+https://github.com/rust-lang/crates.io-index" 2773 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 2774 dependencies = [ 2775 "matchers", 2776 "nu-ansi-term", 2777 "once_cell", 2778 + "regex-automata", 2779 "sharded-slab", 2780 "smallvec", 2781 "thread_local", ··· 2792 2793 [[package]] 2794 name = "typenum" 2795 + version = "1.19.0" 2796 source = "registry+https://github.com/rust-lang/crates.io-index" 2797 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2798 2799 [[package]] 2800 name = "ulid" ··· 2802 source = "registry+https://github.com/rust-lang/crates.io-index" 2803 checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" 2804 dependencies = [ 2805 + "rand 0.9.2", 2806 "web-time", 2807 ] 2808 ··· 2814 2815 [[package]] 2816 name = "unicode-ident" 2817 + version = "1.0.22" 2818 source = "registry+https://github.com/rust-lang/crates.io-index" 2819 + checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 2820 2821 [[package]] 2822 name = "unsigned-varint" ··· 2832 2833 [[package]] 2834 name = "url" 2835 + version = "2.5.7" 2836 source = "registry+https://github.com/rust-lang/crates.io-index" 2837 + checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" 2838 dependencies = [ 2839 "form_urlencoded", 2840 "idna", 2841 "percent-encoding", 2842 + "serde", 2843 ] 2844 2845 [[package]] ··· 2862 2863 [[package]] 2864 name = "uuid" 2865 + version = "1.18.1" 2866 source = "registry+https://github.com/rust-lang/crates.io-index" 2867 + checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" 2868 dependencies = [ 2869 + "getrandom 0.3.4", 2870 "js-sys", 2871 "wasm-bindgen", 2872 ] ··· 2894 2895 [[package]] 2896 name = "wasi" 2897 + version = "0.11.1+wasi-snapshot-preview1" 2898 source = "registry+https://github.com/rust-lang/crates.io-index" 2899 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 2900 2901 [[package]] 2902 + name = "wasip2" 2903 + version = "1.0.1+wasi-0.2.4" 2904 source = "registry+https://github.com/rust-lang/crates.io-index" 2905 + checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 2906 dependencies = [ 2907 + "wit-bindgen", 2908 ] 2909 2910 [[package]] 2911 name = "wasm-bindgen" 2912 + version = "0.2.105" 2913 source = "registry+https://github.com/rust-lang/crates.io-index" 2914 + checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" 2915 dependencies = [ 2916 "cfg-if", 2917 "once_cell", 2918 "rustversion", 2919 "wasm-bindgen-macro", 2920 "wasm-bindgen-shared", 2921 ] 2922 2923 [[package]] 2924 name = "wasm-bindgen-futures" 2925 + version = "0.4.55" 2926 source = "registry+https://github.com/rust-lang/crates.io-index" 2927 + checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" 2928 dependencies = [ 2929 "cfg-if", 2930 "js-sys", ··· 2935 2936 [[package]] 2937 name = "wasm-bindgen-macro" 2938 + version = "0.2.105" 2939 source = "registry+https://github.com/rust-lang/crates.io-index" 2940 + checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" 2941 dependencies = [ 2942 "quote", 2943 "wasm-bindgen-macro-support", ··· 2945 2946 [[package]] 2947 name = "wasm-bindgen-macro-support" 2948 + version = "0.2.105" 2949 source = "registry+https://github.com/rust-lang/crates.io-index" 2950 + checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" 2951 dependencies = [ 2952 + "bumpalo", 2953 "proc-macro2", 2954 "quote", 2955 + "syn 2.0.109", 2956 "wasm-bindgen-shared", 2957 ] 2958 2959 [[package]] 2960 name = "wasm-bindgen-shared" 2961 + version = "0.2.105" 2962 source = "registry+https://github.com/rust-lang/crates.io-index" 2963 + checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" 2964 dependencies = [ 2965 "unicode-ident", 2966 ] 2967 2968 [[package]] 2969 name = "web-sys" 2970 + version = "0.3.82" 2971 source = "registry+https://github.com/rust-lang/crates.io-index" 2972 + checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" 2973 dependencies = [ 2974 "js-sys", 2975 "wasm-bindgen", ··· 2987 2988 [[package]] 2989 name = "webpki-roots" 2990 + version = "1.0.4" 2991 source = "registry+https://github.com/rust-lang/crates.io-index" 2992 + checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" 2993 dependencies = [ 2994 "rustls-pki-types", 2995 ] 2996 2997 [[package]] 2998 name = "widestring" 2999 + version = "1.2.1" 3000 source = "registry+https://github.com/rust-lang/crates.io-index" 3001 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 3002 3003 [[package]] 3004 + name = "windows-link" 3005 + version = "0.1.3" 3006 source = "registry+https://github.com/rust-lang/crates.io-index" 3007 + checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 3008 3009 [[package]] 3010 name = "windows-link" 3011 + version = "0.2.1" 3012 source = "registry+https://github.com/rust-lang/crates.io-index" 3013 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 3014 3015 [[package]] 3016 name = "windows-registry" 3017 + version = "0.5.3" 3018 source = "registry+https://github.com/rust-lang/crates.io-index" 3019 + checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 3020 dependencies = [ 3021 + "windows-link 0.1.3", 3022 "windows-result", 3023 + "windows-strings", 3024 ] 3025 3026 [[package]] ··· 3029 source = "registry+https://github.com/rust-lang/crates.io-index" 3030 checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" 3031 dependencies = [ 3032 + "windows-link 0.1.3", 3033 ] 3034 3035 [[package]] ··· 3038 source = "registry+https://github.com/rust-lang/crates.io-index" 3039 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 3040 dependencies = [ 3041 + "windows-link 0.1.3", 3042 ] 3043 3044 [[package]] ··· 3069 ] 3070 3071 [[package]] 3072 + name = "windows-sys" 3073 + version = "0.60.2" 3074 + source = "registry+https://github.com/rust-lang/crates.io-index" 3075 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 3076 + dependencies = [ 3077 + "windows-targets 0.53.5", 3078 + ] 3079 + 3080 + [[package]] 3081 + name = "windows-sys" 3082 + version = "0.61.2" 3083 + source = "registry+https://github.com/rust-lang/crates.io-index" 3084 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 3085 + dependencies = [ 3086 + "windows-link 0.2.1", 3087 + ] 3088 + 3089 + [[package]] 3090 name = "windows-targets" 3091 version = "0.48.5" 3092 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3119 3120 [[package]] 3121 name = "windows-targets" 3122 + version = "0.53.5" 3123 source = "registry+https://github.com/rust-lang/crates.io-index" 3124 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 3125 dependencies = [ 3126 + "windows-link 0.2.1", 3127 + "windows_aarch64_gnullvm 0.53.1", 3128 + "windows_aarch64_msvc 0.53.1", 3129 + "windows_i686_gnu 0.53.1", 3130 + "windows_i686_gnullvm 0.53.1", 3131 + "windows_i686_msvc 0.53.1", 3132 + "windows_x86_64_gnu 0.53.1", 3133 + "windows_x86_64_gnullvm 0.53.1", 3134 + "windows_x86_64_msvc 0.53.1", 3135 ] 3136 3137 [[package]] ··· 3148 3149 [[package]] 3150 name = "windows_aarch64_gnullvm" 3151 + version = "0.53.1" 3152 source = "registry+https://github.com/rust-lang/crates.io-index" 3153 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 3154 3155 [[package]] 3156 name = "windows_aarch64_msvc" ··· 3166 3167 [[package]] 3168 name = "windows_aarch64_msvc" 3169 + version = "0.53.1" 3170 source = "registry+https://github.com/rust-lang/crates.io-index" 3171 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 3172 3173 [[package]] 3174 name = "windows_i686_gnu" ··· 3184 3185 [[package]] 3186 name = "windows_i686_gnu" 3187 + version = "0.53.1" 3188 source = "registry+https://github.com/rust-lang/crates.io-index" 3189 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 3190 3191 [[package]] 3192 name = "windows_i686_gnullvm" ··· 3196 3197 [[package]] 3198 name = "windows_i686_gnullvm" 3199 + version = "0.53.1" 3200 source = "registry+https://github.com/rust-lang/crates.io-index" 3201 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 3202 3203 [[package]] 3204 name = "windows_i686_msvc" ··· 3214 3215 [[package]] 3216 name = "windows_i686_msvc" 3217 + version = "0.53.1" 3218 source = "registry+https://github.com/rust-lang/crates.io-index" 3219 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 3220 3221 [[package]] 3222 name = "windows_x86_64_gnu" ··· 3232 3233 [[package]] 3234 name = "windows_x86_64_gnu" 3235 + version = "0.53.1" 3236 source = "registry+https://github.com/rust-lang/crates.io-index" 3237 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 3238 3239 [[package]] 3240 name = "windows_x86_64_gnullvm" ··· 3250 3251 [[package]] 3252 name = "windows_x86_64_gnullvm" 3253 + version = "0.53.1" 3254 source = "registry+https://github.com/rust-lang/crates.io-index" 3255 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 3256 3257 [[package]] 3258 name = "windows_x86_64_msvc" ··· 3268 3269 [[package]] 3270 name = "windows_x86_64_msvc" 3271 + version = "0.53.1" 3272 source = "registry+https://github.com/rust-lang/crates.io-index" 3273 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 3274 3275 [[package]] 3276 name = "winreg" ··· 3283 ] 3284 3285 [[package]] 3286 + name = "wit-bindgen" 3287 + version = "0.46.0" 3288 source = "registry+https://github.com/rust-lang/crates.io-index" 3289 + checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 3290 3291 [[package]] 3292 name = "writeable" 3293 + version = "0.6.2" 3294 source = "registry+https://github.com/rust-lang/crates.io-index" 3295 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 3296 3297 [[package]] 3298 name = "yoke" 3299 + version = "0.8.1" 3300 source = "registry+https://github.com/rust-lang/crates.io-index" 3301 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 3302 dependencies = [ 3303 "stable_deref_trait", 3304 "yoke-derive", 3305 "zerofrom", ··· 3307 3308 [[package]] 3309 name = "yoke-derive" 3310 + version = "0.8.1" 3311 source = "registry+https://github.com/rust-lang/crates.io-index" 3312 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 3313 dependencies = [ 3314 "proc-macro2", 3315 "quote", 3316 + "syn 2.0.109", 3317 "synstructure", 3318 ] 3319 3320 [[package]] 3321 name = "zerocopy" 3322 + version = "0.8.27" 3323 source = "registry+https://github.com/rust-lang/crates.io-index" 3324 + checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" 3325 dependencies = [ 3326 "zerocopy-derive", 3327 ] 3328 3329 [[package]] 3330 name = "zerocopy-derive" 3331 + version = "0.8.27" 3332 source = "registry+https://github.com/rust-lang/crates.io-index" 3333 + checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" 3334 dependencies = [ 3335 "proc-macro2", 3336 "quote", 3337 + "syn 2.0.109", 3338 ] 3339 3340 [[package]] ··· 3354 dependencies = [ 3355 "proc-macro2", 3356 "quote", 3357 + "syn 2.0.109", 3358 "synstructure", 3359 ] 3360 3361 [[package]] 3362 name = "zeroize" 3363 + version = "1.8.2" 3364 source = "registry+https://github.com/rust-lang/crates.io-index" 3365 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 3366 dependencies = [ 3367 "zeroize_derive", 3368 ] ··· 3375 dependencies = [ 3376 "proc-macro2", 3377 "quote", 3378 + "syn 2.0.109", 3379 ] 3380 3381 [[package]] 3382 name = "zerotrie" 3383 + version = "0.2.3" 3384 source = "registry+https://github.com/rust-lang/crates.io-index" 3385 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 3386 dependencies = [ 3387 "displaydoc", 3388 "yoke", ··· 3391 3392 [[package]] 3393 name = "zerovec" 3394 + version = "0.11.5" 3395 source = "registry+https://github.com/rust-lang/crates.io-index" 3396 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 3397 dependencies = [ 3398 "yoke", 3399 "zerofrom", ··· 3402 3403 [[package]] 3404 name = "zerovec-derive" 3405 + version = "0.11.2" 3406 source = "registry+https://github.com/rust-lang/crates.io-index" 3407 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 3408 dependencies = [ 3409 "proc-macro2", 3410 "quote", 3411 + "syn 2.0.109", 3412 ] 3413 3414 [[package]] ··· 3431 3432 [[package]] 3433 name = "zstd-sys" 3434 + version = "2.0.16+zstd.1.5.7" 3435 source = "registry+https://github.com/rust-lang/crates.io-index" 3436 + checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" 3437 dependencies = [ 3438 "cc", 3439 "pkg-config",
+29 -20
Cargo.toml
··· 1 [workspace] 2 members = [ 3 "crates/atproto-client", 4 "crates/atproto-identity", 5 "crates/atproto-jetstream", 6 "crates/atproto-oauth-aip", 7 "crates/atproto-oauth-axum", 8 "crates/atproto-oauth", 9 "crates/atproto-record", 10 "crates/atproto-xrpcs-helloworld", 11 "crates/atproto-xrpcs", 12 "crates/atproto-lexicon", 13 ] 14 resolver = "3" 15 ··· 23 categories = ["command-line-utilities", "web-programming"] 24 25 [workspace.dependencies] 26 atproto-client = { version = "0.13.0", path = "crates/atproto-client" } 27 atproto-identity = { version = "0.13.0", path = "crates/atproto-identity" } 28 atproto-oauth = { version = "0.13.0", path = "crates/atproto-oauth" } 29 atproto-oauth-axum = { version = "0.13.0", path = "crates/atproto-oauth-axum" } 30 - atproto-oauth-aip = { version = "0.13.0", path = "crates/atproto-oauth-aip" } 31 atproto-record = { version = "0.13.0", path = "crates/atproto-record" } 32 atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" } 33 - atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" } 34 35 anyhow = "1.0" 36 - async-trait = "0.1.88" 37 - base64 = "0.22.1" 38 - chrono = {version = "0.4.41", default-features = false, features = ["std", "now"]} 39 clap = { version = "4.5", features = ["derive", "env"] } 40 - ecdsa = { version = "0.16.9", features = ["std"] } 41 - elliptic-curve = { version = "0.13.8", features = ["jwk", "serde"] } 42 futures = "0.3" 43 hickory-resolver = { version = "0.25" } 44 - http = "1.3.1" 45 - k256 = "0.13.4" 46 lru = "0.12" 47 - multibase = "0.9.1" 48 - p256 = "0.13.2" 49 - p384 = "0.13.0" 50 rand = "0.8" 51 reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "system-proxy", "json", "rustls-tls"] } 52 - reqwest-chain = "1.0.0" 53 - reqwest-middleware = { version = "0.4.2", features = ["json", "multipart"]} 54 rpassword = "7.3" 55 secrecy = { version = "0.10", features = ["serde"] } 56 serde = { version = "1.0", features = ["derive"] } 57 - serde_ipld_dagcbor = "0.6.3" 58 - serde_json = "1.0" 59 - sha2 = "0.10.9" 60 thiserror = "2.0" 61 tokio = { version = "1.41", features = ["macros", "rt", "rt-multi-thread"] } 62 tokio-websockets = { version = "0.11.4", features = ["client", "rustls-native-roots", "rand", "ring"] } 63 tokio-util = "0.7" 64 tracing = { version = "0.1", features = ["async-await"] } 65 - ulid = "1.2.1" 66 - urlencoding = "2.1" 67 zstd = "0.13" 68 69 - zeroize = { version = "1.8.1", features = ["zeroize_derive"] } 70 71 [workspace.lints.rust] 72 unsafe_code = "forbid"
··· 1 [workspace] 2 members = [ 3 "crates/atproto-client", 4 + "crates/atproto-extras", 5 "crates/atproto-identity", 6 "crates/atproto-jetstream", 7 "crates/atproto-oauth-aip", 8 "crates/atproto-oauth-axum", 9 "crates/atproto-oauth", 10 "crates/atproto-record", 11 + "crates/atproto-tap", 12 "crates/atproto-xrpcs-helloworld", 13 "crates/atproto-xrpcs", 14 "crates/atproto-lexicon", 15 + "crates/atproto-attestation", 16 ] 17 resolver = "3" 18 ··· 26 categories = ["command-line-utilities", "web-programming"] 27 28 [workspace.dependencies] 29 + atproto-attestation = { version = "0.13.0", path = "crates/atproto-attestation" } 30 atproto-client = { version = "0.13.0", path = "crates/atproto-client" } 31 + atproto-extras = { version = "0.13.0", path = "crates/atproto-extras" } 32 atproto-identity = { version = "0.13.0", path = "crates/atproto-identity" } 33 + atproto-jetstream = { version = "0.13.0", path = "crates/atproto-jetstream" } 34 atproto-oauth = { version = "0.13.0", path = "crates/atproto-oauth" } 35 + atproto-oauth-aip = { version = "0.13.0", path = "crates/atproto-oauth-aip" } 36 atproto-oauth-axum = { version = "0.13.0", path = "crates/atproto-oauth-axum" } 37 atproto-record = { version = "0.13.0", path = "crates/atproto-record" } 38 + atproto-tap = { version = "0.13.0", path = "crates/atproto-tap" } 39 atproto-xrpcs = { version = "0.13.0", path = "crates/atproto-xrpcs" } 40 41 + ammonia = "4.0" 42 anyhow = "1.0" 43 + async-trait = "0.1" 44 + base64 = "0.22" 45 + chrono = {version = "0.4", default-features = false, features = ["std", "now"]} 46 clap = { version = "4.5", features = ["derive", "env"] } 47 + ecdsa = { version = "0.16", features = ["std"] } 48 + elliptic-curve = { version = "0.13", features = ["jwk", "serde"] } 49 futures = "0.3" 50 hickory-resolver = { version = "0.25" } 51 + http = "1.3" 52 + k256 = "0.13" 53 lru = "0.12" 54 + multibase = "0.9" 55 + p256 = "0.13" 56 + p384 = "0.13" 57 rand = "0.8" 58 + regex = "1.11" 59 reqwest = { version = "0.12", default-features = false, features = ["charset", "http2", "system-proxy", "json", "rustls-tls"] } 60 + reqwest-chain = "1.0" 61 + reqwest-middleware = { version = "0.4", features = ["json", "multipart"]} 62 rpassword = "7.3" 63 secrecy = { version = "0.10", features = ["serde"] } 64 serde = { version = "1.0", features = ["derive"] } 65 + serde_ipld_dagcbor = "0.6" 66 + serde_json = { version = "1.0", features = ["unbounded_depth"] } 67 + sha2 = "0.10" 68 thiserror = "2.0" 69 tokio = { version = "1.41", features = ["macros", "rt", "rt-multi-thread"] } 70 tokio-websockets = { version = "0.11.4", features = ["client", "rustls-native-roots", "rand", "ring"] } 71 tokio-util = "0.7" 72 tracing = { version = "0.1", features = ["async-await"] } 73 + ulid = "1.2" 74 zstd = "0.13" 75 + url = "2.5" 76 + urlencoding = "2.1" 77 78 + zeroize = { version = "1.8", features = ["zeroize_derive"] } 79 80 [workspace.lints.rust] 81 unsafe_code = "forbid"
+12 -8
Dockerfile
··· 1 # Multi-stage build for atproto-identity-rs workspace 2 - # Builds and installs all 13 binaries from the workspace 3 4 - # Build stage - use 1.89 to support resolver = "3" and edition = "2024" 5 FROM rust:1.90-slim-bookworm AS builder 6 7 # Install system dependencies needed for building ··· 19 # Build all binaries in release mode 20 # This will build all binaries defined in the workspace: 21 # - atproto-identity: 4 binaries (resolve, key, sign, validate) 22 - # - atproto-record: 2 binaries (sign, verify) 23 # - atproto-client: 3 binaries (auth, app-password, dpop) 24 # - atproto-oauth: 1 binary (service-token) 25 # - atproto-oauth-axum: 1 binary (oauth-tool) ··· 40 COPY --from=builder /usr/src/app/target/release/atproto-identity-key . 41 COPY --from=builder /usr/src/app/target/release/atproto-identity-sign . 42 COPY --from=builder /usr/src/app/target/release/atproto-identity-validate . 43 - COPY --from=builder /usr/src/app/target/release/atproto-record-sign . 44 - COPY --from=builder /usr/src/app/target/release/atproto-record-verify . 45 COPY --from=builder /usr/src/app/target/release/atproto-client-auth . 46 COPY --from=builder /usr/src/app/target/release/atproto-client-app-password . 47 COPY --from=builder /usr/src/app/target/release/atproto-client-dpop . ··· 53 54 # Default to the main resolution tool 55 # Users can override with specific binary: docker run <image> atproto-identity-resolve --help 56 - # Or run other tools: 57 # docker run <image> atproto-identity-key --help 58 - # docker run <image> atproto-record-sign --help 59 # docker run <image> atproto-client-auth --help 60 # docker run <image> atproto-oauth-service-token --help 61 # docker run <image> atproto-oauth-tool --help ··· 73 LABEL org.opencontainers.image.licenses="MIT" 74 75 # Document available binaries 76 - LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-record-sign,atproto-record-verify,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve"
··· 1 # Multi-stage build for atproto-identity-rs workspace 2 + # Builds and installs all 15 binaries from the workspace 3 4 + # Build stage - use 1.90 to support resolver = "3" and edition = "2024" 5 FROM rust:1.90-slim-bookworm AS builder 6 7 # Install system dependencies needed for building ··· 19 # Build all binaries in release mode 20 # This will build all binaries defined in the workspace: 21 # - atproto-identity: 4 binaries (resolve, key, sign, validate) 22 + # - atproto-attestation: 2 binaries (attestation-sign, attestation-verify) 23 + # - atproto-record: 1 binary (record-cid) 24 # - atproto-client: 3 binaries (auth, app-password, dpop) 25 # - atproto-oauth: 1 binary (service-token) 26 # - atproto-oauth-axum: 1 binary (oauth-tool) ··· 41 COPY --from=builder /usr/src/app/target/release/atproto-identity-key . 42 COPY --from=builder /usr/src/app/target/release/atproto-identity-sign . 43 COPY --from=builder /usr/src/app/target/release/atproto-identity-validate . 44 + COPY --from=builder /usr/src/app/target/release/atproto-attestation-sign . 45 + COPY --from=builder /usr/src/app/target/release/atproto-attestation-verify . 46 + COPY --from=builder /usr/src/app/target/release/atproto-record-cid . 47 COPY --from=builder /usr/src/app/target/release/atproto-client-auth . 48 COPY --from=builder /usr/src/app/target/release/atproto-client-app-password . 49 COPY --from=builder /usr/src/app/target/release/atproto-client-dpop . ··· 55 56 # Default to the main resolution tool 57 # Users can override with specific binary: docker run <image> atproto-identity-resolve --help 58 + # Or run other tools: 59 # docker run <image> atproto-identity-key --help 60 + # docker run <image> atproto-attestation-sign --help 61 + # docker run <image> atproto-attestation-verify --help 62 + # docker run <image> atproto-record-cid --help 63 # docker run <image> atproto-client-auth --help 64 # docker run <image> atproto-oauth-service-token --help 65 # docker run <image> atproto-oauth-tool --help ··· 77 LABEL org.opencontainers.image.licenses="MIT" 78 79 # Document available binaries 80 + LABEL binaries="atproto-identity-resolve,atproto-identity-key,atproto-identity-sign,atproto-identity-validate,atproto-attestation-sign,atproto-attestation-verify,atproto-record-cid,atproto-client-auth,atproto-client-app-password,atproto-client-dpop,atproto-oauth-service-token,atproto-oauth-tool,atproto-jetstream-consumer,atproto-xrpcs-helloworld,atproto-lexicon-resolve"
+34 -19
README.md
··· 11 ### Identity & Cryptography 12 13 - **[`atproto-identity`](crates/atproto-identity/)** - Core identity management with multi-method DID resolution (plc, web, key), DNS/HTTP handle resolution, and P-256/P-384/K-256 cryptographic operations. *Includes 4 CLI tools.* 14 - - **[`atproto-record`](crates/atproto-record/)** - Cryptographic signature operations for AT Protocol records using IPLD DAG-CBOR serialization with AT-URI parsing support. *Includes 2 CLI tools.* 15 - **[`atproto-lexicon`](crates/atproto-lexicon/)** - Lexicon schema resolution and validation for AT Protocol, supporting recursive resolution, NSID validation, and DNS-based lexicon discovery. *Includes 1 CLI tool.* 16 17 ### Authentication & Authorization ··· 37 ```toml 38 [dependencies] 39 atproto-identity = "0.13.0" 40 atproto-record = "0.13.0" 41 atproto-lexicon = "0.13.0" 42 atproto-oauth = "0.13.0" ··· 85 ### Record Signing 86 87 ```rust 88 - use atproto_identity::key::identify_key; 89 - use atproto_record::signature; 90 use serde_json::json; 91 92 #[tokio::main] 93 async fn main() -> anyhow::Result<()> { 94 - let signing_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 95 96 let record = json!({ 97 "$type": "app.bsky.feed.post", ··· 99 "createdAt": "2024-01-01T00:00:00.000Z" 100 }); 101 102 - let signature_object = json!({ 103 "issuer": "did:plc:issuer123", 104 "issuedAt": "2024-01-01T00:00:00.000Z" 105 }); 106 107 - let signed_record = signature::create( 108 - &signing_key, 109 - &record, 110 - "did:plc:user123", 111 - "app.bsky.feed.post", 112 - signature_object, 113 - ).await?; 114 115 Ok(()) 116 } ··· 119 ### XRPC Service 120 121 ```rust 122 - use atproto_xrpcs::authorization::ResolvingAuthorization; 123 use axum::{Json, Router, extract::Query, routing::get}; 124 use serde::Deserialize; 125 use serde_json::json; ··· 131 132 async fn handle_hello( 133 params: Query<HelloParams>, 134 - authorization: Option<ResolvingAuthorization>, 135 ) -> Json<serde_json::Value> { 136 let subject = params.subject.as_deref().unwrap_or("World"); 137 - 138 let message = if let Some(auth) = authorization { 139 format!("Hello, authenticated {}! (caller: {})", subject, auth.subject()) 140 } else { 141 format!("Hello, {}!", subject) 142 }; 143 - 144 Json(json!({ "message": message })) 145 } 146 ··· 212 cargo run --features clap --bin atproto-identity-sign -- did:key:... data.json 213 cargo run --features clap --bin atproto-identity-validate -- did:key:... data.json signature 214 215 # Record operations (atproto-record crate) 216 - cargo run --features clap --bin atproto-record-sign -- did:key:... did:plc:issuer record.json repository=did:plc:user collection=app.bsky.feed.post 217 - cargo run --features clap --bin atproto-record-verify -- did:plc:issuer did:key:... signed_record.json repository=did:plc:user collection=app.bsky.feed.post 218 219 # Lexicon operations (atproto-lexicon crate) 220 cargo run --features clap,hickory-dns --bin atproto-lexicon-resolve -- app.bsky.feed.post ··· 289 290 ## Acknowledgments 291 292 - Parts of this project were extracted from the [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source AT Protocol event and RSVP management application. This extraction enables broader community use and contribution to AT Protocol tooling in Rust.
··· 11 ### Identity & Cryptography 12 13 - **[`atproto-identity`](crates/atproto-identity/)** - Core identity management with multi-method DID resolution (plc, web, key), DNS/HTTP handle resolution, and P-256/P-384/K-256 cryptographic operations. *Includes 4 CLI tools.* 14 + - **[`atproto-attestation`](crates/atproto-attestation/)** - CID-first attestation utilities for creating and verifying cryptographic signatures on AT Protocol records, supporting both inline and remote attestation workflows. *Includes 2 CLI tools.* 15 + - **[`atproto-record`](crates/atproto-record/)** - Record utilities including TID generation, AT-URI parsing, datetime formatting, and CID generation using IPLD DAG-CBOR serialization. *Includes 1 CLI tool.* 16 - **[`atproto-lexicon`](crates/atproto-lexicon/)** - Lexicon schema resolution and validation for AT Protocol, supporting recursive resolution, NSID validation, and DNS-based lexicon discovery. *Includes 1 CLI tool.* 17 18 ### Authentication & Authorization ··· 38 ```toml 39 [dependencies] 40 atproto-identity = "0.13.0" 41 + atproto-attestation = "0.13.0" 42 atproto-record = "0.13.0" 43 atproto-lexicon = "0.13.0" 44 atproto-oauth = "0.13.0" ··· 87 ### Record Signing 88 89 ```rust 90 + use atproto_identity::key::{identify_key, to_public}; 91 + use atproto_attestation::{ 92 + create_inline_attestation, verify_all_signatures, VerificationStatus, 93 + input::{AnyInput, PhantomSignature} 94 + }; 95 use serde_json::json; 96 97 #[tokio::main] 98 async fn main() -> anyhow::Result<()> { 99 + let private_key = identify_key("did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA")?; 100 + let public_key = to_public(&private_key)?; 101 + let key_reference = format!("{}", &public_key); 102 + let repository_did = "did:plc:repo123"; 103 104 let record = json!({ 105 "$type": "app.bsky.feed.post", ··· 107 "createdAt": "2024-01-01T00:00:00.000Z" 108 }); 109 110 + let sig_metadata = json!({ 111 + "$type": "com.example.inlineSignature", 112 + "key": &key_reference, 113 "issuer": "did:plc:issuer123", 114 "issuedAt": "2024-01-01T00:00:00.000Z" 115 }); 116 117 + let signed_record = create_inline_attestation::<PhantomSignature, PhantomSignature>( 118 + AnyInput::Json(record), 119 + AnyInput::Json(sig_metadata), 120 + repository_did, 121 + &private_key 122 + )?; 123 + 124 + let reports = verify_all_signatures(&signed_record, repository_did, None).await?; 125 + assert!(reports.iter().all(|report| matches!(report.status, VerificationStatus::Valid { .. }))); 126 127 Ok(()) 128 } ··· 131 ### XRPC Service 132 133 ```rust 134 + use atproto_xrpcs::authorization::Authorization; 135 use axum::{Json, Router, extract::Query, routing::get}; 136 use serde::Deserialize; 137 use serde_json::json; ··· 143 144 async fn handle_hello( 145 params: Query<HelloParams>, 146 + authorization: Option<Authorization>, 147 ) -> Json<serde_json::Value> { 148 let subject = params.subject.as_deref().unwrap_or("World"); 149 + 150 let message = if let Some(auth) = authorization { 151 format!("Hello, authenticated {}! (caller: {})", subject, auth.subject()) 152 } else { 153 format!("Hello, {}!", subject) 154 }; 155 + 156 Json(json!({ "message": message })) 157 } 158 ··· 224 cargo run --features clap --bin atproto-identity-sign -- did:key:... data.json 225 cargo run --features clap --bin atproto-identity-validate -- did:key:... data.json signature 226 227 + # Attestation operations (atproto-attestation crate) 228 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- inline record.json did:key:... metadata.json 229 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- signed_record.json 230 + 231 # Record operations (atproto-record crate) 232 + cat record.json | cargo run --features clap --bin atproto-record-cid 233 234 # Lexicon operations (atproto-lexicon crate) 235 cargo run --features clap,hickory-dns --bin atproto-lexicon-resolve -- app.bsky.feed.post ··· 304 305 ## Acknowledgments 306 307 + Parts of this project were extracted from the [smokesignal.events](https://tangled.sh/@smokesignal.events/smokesignal) project, an open-source AT Protocol event and RSVP management application. This extraction enables broader community use and contribution to AT Protocol tooling in Rust.
+64
crates/atproto-attestation/Cargo.toml
···
··· 1 + [package] 2 + name = "atproto-attestation" 3 + version = "0.13.0" 4 + description = "AT Protocol attestation utilities for creating and verifying record signatures" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-attestation" 8 + edition.workspace = true 9 + rust-version.workspace = true 10 + repository.workspace = true 11 + authors.workspace = true 12 + license.workspace = true 13 + keywords.workspace = true 14 + categories.workspace = true 15 + 16 + [[bin]] 17 + name = "atproto-attestation-sign" 18 + test = false 19 + bench = false 20 + doc = true 21 + required-features = ["clap", "tokio"] 22 + 23 + [[bin]] 24 + name = "atproto-attestation-verify" 25 + test = false 26 + bench = false 27 + doc = true 28 + required-features = ["clap", "tokio"] 29 + 30 + [dependencies] 31 + atproto-client.workspace = true 32 + atproto-identity.workspace = true 33 + atproto-record.workspace = true 34 + anyhow.workspace = true 35 + base64.workspace = true 36 + serde.workspace = true 37 + serde_json = {workspace = true, features = ["preserve_order"]} 38 + serde_ipld_dagcbor.workspace = true 39 + sha2.workspace = true 40 + thiserror.workspace = true 41 + 42 + cid = "0.11" 43 + elliptic-curve = { version = "0.13", default-features = false, features = ["std"] } 44 + k256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } 45 + multihash = "0.19" 46 + p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } 47 + 48 + async-trait = { workspace = true, optional = true } 49 + clap = { workspace = true, optional = true } 50 + reqwest = { workspace = true, optional = true } 51 + tokio = { workspace = true, optional = true } 52 + 53 + [dev-dependencies] 54 + async-trait = "0.1" 55 + chrono = { workspace = true } 56 + tokio = { workspace = true, features = ["macros", "rt"] } 57 + 58 + [features] 59 + default = [] 60 + clap = ["dep:clap"] 61 + tokio = ["dep:tokio", "dep:reqwest", "dep:async-trait"] 62 + 63 + [lints] 64 + workspace = true
+398
crates/atproto-attestation/README.md
···
··· 1 + # atproto-attestation 2 + 3 + Utilities for creating and verifying AT Protocol record attestations using the CID-first workflow. 4 + 5 + ## Overview 6 + 7 + A Rust library implementing the CID-first attestation specification for AT Protocol records. This crate provides cryptographic signature creation and verification for records, supporting both inline attestations (signatures embedded directly in records) and remote attestations (separate proof records with strongRef references). 8 + 9 + The attestation workflow ensures deterministic signing payloads and prevents replay attacks by: 10 + 1. Automatically preparing records with `$sig` metadata containing `$type` and `repository` fields 11 + 2. Generating content identifiers (CIDs) using DAG-CBOR serialization 12 + 3. Signing CID bytes with elliptic curve cryptography (for inline attestations) 13 + 4. Normalizing signatures to low-S form to prevent malleability attacks 14 + 5. Embedding signatures or creating proof records with strongRef references 15 + 16 + **Critical Security Feature**: The `repository` field in `$sig` metadata binds attestations to specific repositories, preventing replay attacks where an attacker might attempt to clone records from one repository into their own. 17 + 18 + ## Features 19 + 20 + - **Inline attestations**: Embed cryptographic signatures directly in record structures 21 + - **Remote attestations**: Create separate proof records with CID-based strongRef references 22 + - **CID-first workflow**: Deterministic signing based on content identifiers 23 + - **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curves 24 + - **Signature normalization**: Automatic low-S normalization for ECDSA signatures to prevent malleability 25 + - **Flexible input types**: Accept records as JSON strings, JSON values, or typed lexicons 26 + - **Repository binding**: Automatic prevention of replay attacks 27 + 28 + ## CLI Tools 29 + 30 + The following command-line tools are available when built with the `clap` and `tokio` features: 31 + 32 + - **`atproto-attestation-sign`**: Sign AT Protocol records with inline or remote attestations 33 + - **`atproto-attestation-verify`**: Verify cryptographic signatures on AT Protocol records 34 + 35 + ## Library Usage 36 + 37 + ### Creating Inline Attestations 38 + 39 + Inline attestations embed the signature bytes directly in the record: 40 + 41 + ```rust 42 + use atproto_identity::key::{generate_key, to_public, KeyType}; 43 + use atproto_attestation::{create_inline_attestation, input::{AnyInput, PhantomSignature}}; 44 + use serde_json::json; 45 + 46 + fn main() -> anyhow::Result<()> { 47 + // Generate a signing key 48 + let private_key = generate_key(KeyType::K256Private)?; 49 + let public_key = to_public(&private_key)?; 50 + let key_reference = format!("{}", &public_key); 51 + 52 + // The record to sign 53 + let record = json!({ 54 + "$type": "app.bsky.feed.post", 55 + "text": "Hello world!", 56 + "createdAt": "2024-01-01T00:00:00.000Z" 57 + }); 58 + 59 + // Repository housing this record (for replay attack prevention) 60 + let repository_did = "did:plc:repo123"; 61 + 62 + // Attestation metadata (required: $type and key for inline attestations) 63 + // Note: repository field is automatically added during CID generation 64 + let sig_metadata = json!({ 65 + "$type": "com.example.inlineSignature", 66 + "key": &key_reference, 67 + "issuer": "did:plc:issuer123", 68 + "issuedAt": "2024-01-01T00:00:00.000Z" 69 + }); 70 + 71 + // Create inline attestation (repository_did is bound into the CID) 72 + // Signature is automatically normalized to low-S form 73 + let signed_record = create_inline_attestation::<PhantomSignature, PhantomSignature>( 74 + AnyInput::Json(record), 75 + AnyInput::Json(sig_metadata), 76 + repository_did, 77 + &private_key 78 + )?; 79 + 80 + println!("{}", serde_json::to_string_pretty(&signed_record)?); 81 + 82 + Ok(()) 83 + } 84 + ``` 85 + 86 + The resulting record will have a `signatures` array: 87 + 88 + ```json 89 + { 90 + "$type": "app.bsky.feed.post", 91 + "text": "Hello world!", 92 + "createdAt": "2024-01-01T00:00:00.000Z", 93 + "signatures": [ 94 + { 95 + "$type": "com.example.inlineSignature", 96 + "key": "did:key:zQ3sh...", 97 + "issuer": "did:plc:issuer123", 98 + "issuedAt": "2024-01-01T00:00:00.000Z", 99 + "cid": "bafyrei...", 100 + "signature": { 101 + "$bytes": "base64-encoded-normalized-signature-bytes" 102 + } 103 + } 104 + ] 105 + } 106 + ``` 107 + 108 + ### Creating Remote Attestations 109 + 110 + Remote attestations create a separate proof record that must be stored in a repository: 111 + 112 + ```rust 113 + use atproto_attestation::{create_remote_attestation, input::{AnyInput, PhantomSignature}}; 114 + use serde_json::json; 115 + 116 + fn main() -> anyhow::Result<()> { 117 + let record = json!({ 118 + "$type": "app.bsky.feed.post", 119 + "text": "Hello world!" 120 + }); 121 + 122 + // Repository housing the original record (for replay attack prevention) 123 + let repository_did = "did:plc:repo123"; 124 + 125 + // DID of the entity creating the attestation (will store the proof record) 126 + let attestor_did = "did:plc:attestor456"; 127 + 128 + let metadata = json!({ 129 + "$type": "com.example.attestation", 130 + "issuer": "did:plc:issuer123", 131 + "purpose": "verification" 132 + }); 133 + 134 + // Create both the attested record and proof record in one call 135 + // Returns: (attested_record_with_strongRef, proof_record) 136 + let (attested_record, proof_record) = create_remote_attestation::<PhantomSignature, PhantomSignature>( 137 + AnyInput::Json(record), 138 + AnyInput::Json(metadata), 139 + repository_did, // Repository housing the original record 140 + attestor_did // Repository that will store the proof record 141 + )?; 142 + 143 + // The proof_record should be stored in the attestor's repository 144 + // The attested_record contains the strongRef reference 145 + println!("Proof record:\n{}", serde_json::to_string_pretty(&proof_record)?); 146 + println!("Attested record:\n{}", serde_json::to_string_pretty(&attested_record)?); 147 + 148 + Ok(()) 149 + } 150 + ``` 151 + 152 + ### Verifying Signatures 153 + 154 + Verify all signatures in a record: 155 + 156 + ```rust 157 + use atproto_attestation::{verify_record, input::AnyInput}; 158 + use atproto_identity::key::IdentityDocumentKeyResolver; 159 + use atproto_client::record_resolver::HttpRecordResolver; 160 + 161 + #[tokio::main] 162 + async fn main() -> anyhow::Result<()> { 163 + // Signed record with signatures array 164 + let signed_record = /* ... */; 165 + 166 + // The repository DID where this record is stored 167 + // CRITICAL: This must match the repository used during signing to prevent replay attacks 168 + let repository_did = "did:plc:repo123"; 169 + 170 + // Create resolvers for key and record fetching 171 + let key_resolver = /* ... */; // IdentityDocumentKeyResolver 172 + let record_resolver = HttpRecordResolver::new(/* ... */); 173 + 174 + // Verify all signatures with repository validation 175 + verify_record( 176 + AnyInput::Json(signed_record), 177 + repository_did, 178 + key_resolver, 179 + record_resolver 180 + ).await?; 181 + 182 + println!("โœ“ All signatures verified successfully"); 183 + 184 + Ok(()) 185 + } 186 + ``` 187 + 188 + ## Command Line Usage 189 + 190 + ### Signing Records 191 + 192 + #### Inline Attestation 193 + 194 + ```bash 195 + # Sign with inline attestation (signature embedded in record) 196 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 197 + inline \ 198 + record.json \ 199 + did:key:zQ3shNzMp4oaaQ1gQRzCxMGXFrSW3NEM1M9T6KCY9eA7HhyEA \ 200 + metadata.json 201 + 202 + # Using JSON strings instead of files 203 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 204 + inline \ 205 + '{"$type":"app.bsky.feed.post","text":"Hello!"}' \ 206 + did:key:zQ3sh... \ 207 + '{"$type":"com.example.sig","key":"did:key:zQ3sh..."}' 208 + 209 + # Read record from stdin 210 + cat record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 211 + inline \ 212 + - \ 213 + did:key:zQ3sh... \ 214 + metadata.json 215 + ``` 216 + 217 + #### Remote Attestation 218 + 219 + ```bash 220 + # Create remote attestation (generates proof record + strongRef) 221 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-sign -- \ 222 + remote \ 223 + record.json \ 224 + did:plc:repo123... \ 225 + metadata.json 226 + 227 + # This outputs TWO JSON objects: 228 + # 1. Proof record (store this in the attestor's repository) 229 + # 2. Source record with strongRef attestation 230 + ``` 231 + 232 + ### Verifying Signatures 233 + 234 + ```bash 235 + # Verify all signatures in a record from file 236 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 237 + ./signed_record.json \ 238 + did:plc:repo123 239 + 240 + # Verify from stdin 241 + cat signed_record.json | cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 242 + - \ 243 + did:plc:repo123 244 + 245 + # Verify from inline JSON 246 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 247 + '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' \ 248 + did:plc:repo123 249 + 250 + # Verify specific attestation against record 251 + cargo run --package atproto-attestation --features clap,tokio --bin atproto-attestation-verify -- \ 252 + ./record.json \ 253 + did:plc:repo123 \ 254 + ./attestation.json 255 + ``` 256 + 257 + ## Public API 258 + 259 + The crate exposes the following public functions: 260 + 261 + ### Attestation Creation 262 + 263 + - **`create_inline_attestation`**: Create a signed record with embedded signature 264 + - Automatically normalizes signatures to low-S form 265 + - Binds attestation to repository DID 266 + - Returns signed record with `signatures` array 267 + 268 + - **`create_remote_attestation`**: Create separate proof record and strongRef 269 + - Returns tuple of (attested_record, proof_record) 270 + - Proof record must be stored in attestor's repository 271 + 272 + ### CID Generation 273 + 274 + - **`create_cid`**: Generate CID for a record with `$sig` metadata 275 + - **`create_dagbor_cid`**: Generate CID for any serializable data 276 + - **`create_attestation_cid`**: High-level CID generation with automatic `$sig` preparation 277 + 278 + ### Signature Operations 279 + 280 + - **`normalize_signature`**: Normalize raw signature bytes to low-S form 281 + - Prevents signature malleability attacks 282 + - Supports P-256, P-384, and K-256 curves 283 + 284 + ### Verification 285 + 286 + - **`verify_record`**: Verify all signatures in a record 287 + - Validates repository binding 288 + - Supports both inline and remote attestations 289 + - Requires key and record resolvers 290 + 291 + ### Input Types 292 + 293 + - **`AnyInput`**: Flexible input enum supporting: 294 + - `String`: JSON string to parse 295 + - `Json`: serde_json::Value 296 + - `TypedLexicon`: Strongly-typed lexicon records 297 + 298 + ## Attestation Specification 299 + 300 + This crate implements the CID-first attestation specification, which ensures: 301 + 302 + 1. **Deterministic signing**: Records are serialized to DAG-CBOR with `$sig` metadata, producing consistent CIDs 303 + 2. **Content addressing**: Signatures are over CID bytes, not the full record 304 + 3. **Repository binding**: Every attestation is bound to a specific repository DID to prevent replay attacks 305 + 4. **Signature normalization**: ECDSA signatures are normalized to low-S form to prevent malleability 306 + 5. **Flexible metadata**: Custom fields in `$sig` are preserved and included in the CID calculation 307 + 6. **Multiple attestations**: Records can have multiple signatures in the `signatures` array 308 + 309 + ### Signature Structure 310 + 311 + Inline attestation entry: 312 + ```json 313 + { 314 + "$type": "com.example.signature", 315 + "key": "did:key:z...", 316 + "issuer": "did:plc:...", 317 + "cid": "bafyrei...", 318 + "signature": { 319 + "$bytes": "base64-normalized-signature" 320 + } 321 + } 322 + ``` 323 + 324 + Remote attestation entry (strongRef): 325 + ```json 326 + { 327 + "$type": "com.atproto.repo.strongRef", 328 + "uri": "at://did:plc:repo/com.example.attestation/tid", 329 + "cid": "bafyrei..." 330 + } 331 + ``` 332 + 333 + ## Error Handling 334 + 335 + The crate provides structured error types via `AttestationError`: 336 + 337 + - `RecordMustBeObject`: Input must be a JSON object 338 + - `MetadataMustBeObject`: Attestation metadata must be a JSON object 339 + - `SigMetadataMissing`: No `$sig` field found in prepared record 340 + - `SignatureCreationFailed`: Key signing operation failed 341 + - `SignatureValidationFailed`: Signature verification failed 342 + - `SignatureNotNormalized`: ECDSA signature not in low-S form 343 + - `SignatureLengthInvalid`: Signature bytes have incorrect length 344 + - `KeyResolutionFailed`: Could not resolve verification key 345 + - `UnsupportedKeyType`: Key type not supported for signing/verification 346 + - `RemoteAttestationFetchFailed`: Failed to fetch remote proof record 347 + 348 + ## Security Considerations 349 + 350 + ### Repository Binding and Replay Attack Prevention 351 + 352 + The most critical security feature of this attestation framework is **repository binding**. Every attestation includes the repository DID in the `$sig` metadata during CID generation, which: 353 + 354 + - **Prevents replay attacks**: An attacker cannot copy a signed record from one repository to another because the signature is bound to the original repository DID 355 + - **Ensures context integrity**: Attestations are only valid within their intended repository context 356 + - **Automatic enforcement**: The library automatically adds the repository field during CID generation 357 + 358 + **Important**: Always verify signatures with the correct repository DID. Verifying with a different repository DID will (correctly) fail validation, as this would indicate a potential replay attack. 359 + 360 + ### Signature Normalization 361 + 362 + All ECDSA signatures are automatically normalized to low-S form to prevent signature malleability attacks: 363 + 364 + - The library enforces low-S normalization during signature creation 365 + - Verification accepts only normalized signatures 366 + - This prevents attackers from creating alternate valid signatures for the same content 367 + 368 + ### Key Management Best Practices 369 + 370 + - **Private keys**: Never log, transmit, or store private keys in plaintext 371 + - **Key rotation**: Plan for key rotation by using verification method references that can be updated in DID documents 372 + - **Key types**: The library supports P-256, P-384, and K-256 elliptic curves 373 + - **did:key**: For testing and simple use cases, did:key identifiers provide self-contained key references 374 + 375 + ### CID Verification 376 + 377 + - **Always verify against CIDs**: Signatures are over CID bytes, not the original record content 378 + - **Deterministic generation**: The same record with the same `$sig` metadata always produces the same CID 379 + - **Content integrity**: Any modification to the record will produce a different CID and invalidate signatures 380 + 381 + ### Metadata Validation 382 + 383 + When creating attestations: 384 + 385 + - The `$type` field is always required in metadata to scope the attestation 386 + - The `repository` field is automatically added and must not be manually set 387 + - Custom metadata fields are preserved and included in CID calculation 388 + - The `cid` field is automatically added to inline attestation metadata 389 + 390 + ### Remote Attestation Considerations 391 + 392 + - **Proof record storage**: Store proof records in the attestor's repository with appropriate access controls 393 + - **CID matching**: Verify that the CID in the proof record matches the computed CID of the attested content 394 + - **Record resolution**: Use trusted record resolvers when fetching remote proof records 395 + 396 + ## License 397 + 398 + MIT License
+787
crates/atproto-attestation/src/attestation.rs
···
··· 1 + //! Core attestation creation functions. 2 + //! 3 + //! This module provides functions for creating inline and remote attestations 4 + //! and attaching attestation references. 5 + 6 + use crate::cid::{create_attestation_cid, create_dagbor_cid}; 7 + use crate::errors::AttestationError; 8 + pub use crate::input::AnyInput; 9 + use crate::signature::normalize_signature; 10 + use crate::utils::BASE64; 11 + use atproto_identity::key::{KeyData, KeyResolver, sign, validate}; 12 + use atproto_record::lexicon::com::atproto::repo::STRONG_REF_NSID; 13 + use atproto_record::tid::Tid; 14 + use base64::Engine; 15 + use serde::Serialize; 16 + use serde_json::{Value, json, Map}; 17 + use std::convert::TryInto; 18 + 19 + /// Helper function to extract and validate signatures array from a record 20 + fn extract_signatures(record_obj: &Map<String, Value>) -> Result<Vec<Value>, AttestationError> { 21 + match record_obj.get("signatures") { 22 + Some(value) => value 23 + .as_array() 24 + .ok_or(AttestationError::SignaturesFieldInvalid) 25 + .cloned(), 26 + None => Ok(vec![]), 27 + } 28 + } 29 + 30 + /// Helper function to append a signature to a record and return the modified record 31 + fn append_signature_to_record( 32 + mut record_obj: Map<String, Value>, 33 + signature: Value, 34 + ) -> Result<Value, AttestationError> { 35 + let mut signatures = extract_signatures(&record_obj)?; 36 + signatures.push(signature); 37 + 38 + record_obj.insert( 39 + "signatures".to_string(), 40 + Value::Array(signatures), 41 + ); 42 + 43 + Ok(Value::Object(record_obj)) 44 + } 45 + 46 + /// Creates a cryptographic signature for a record with attestation metadata. 47 + /// 48 + /// This is a low-level function that generates just the signature bytes without 49 + /// embedding them in a record structure. It's useful when you need to create 50 + /// signatures independently or for custom attestation workflows. 51 + /// 52 + /// The signature is created over a content CID that binds together: 53 + /// - The record content 54 + /// - The attestation metadata 55 + /// - The repository DID (to prevent replay attacks) 56 + /// 57 + /// # Arguments 58 + /// 59 + /// * `record_input` - The record to sign (as AnyInput: String, Json, or TypedLexicon) 60 + /// * `attestation_input` - The attestation metadata (as AnyInput) 61 + /// * `repository` - The repository DID where this record will be stored 62 + /// * `private_key_data` - The private key to use for signing 63 + /// 64 + /// # Returns 65 + /// 66 + /// A byte vector containing the normalized ECDSA signature that can be verified 67 + /// against the same content CID. 68 + /// 69 + /// # Errors 70 + /// 71 + /// Returns an error if: 72 + /// - CID generation fails 73 + /// - Signature creation fails 74 + /// - Signature normalization fails 75 + /// 76 + /// # Example 77 + /// 78 + /// ```rust 79 + /// use atproto_attestation::{create_signature, input::AnyInput}; 80 + /// use atproto_identity::key::{KeyType, generate_key}; 81 + /// use serde_json::json; 82 + /// 83 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 84 + /// let private_key = generate_key(KeyType::K256Private)?; 85 + /// 86 + /// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 87 + /// let metadata = json!({"$type": "com.example.signature"}); 88 + /// 89 + /// let signature_bytes = create_signature( 90 + /// AnyInput::Serialize(record), 91 + /// AnyInput::Serialize(metadata), 92 + /// "did:plc:repo123", 93 + /// &private_key 94 + /// )?; 95 + /// 96 + /// // signature_bytes can now be base64-encoded or used as needed 97 + /// # Ok(()) 98 + /// # } 99 + /// ``` 100 + pub fn create_signature<R, M>( 101 + record_input: AnyInput<R>, 102 + attestation_input: AnyInput<M>, 103 + repository: &str, 104 + private_key_data: &KeyData, 105 + ) -> Result<Vec<u8>, AttestationError> 106 + where 107 + R: Serialize + Clone, 108 + M: Serialize + Clone, 109 + { 110 + // Step 1: Create a content CID from record + attestation + repository 111 + let content_cid = create_attestation_cid(record_input, attestation_input, repository)?; 112 + 113 + // Step 2: Sign the CID bytes 114 + let raw_signature = sign(private_key_data, &content_cid.to_bytes()) 115 + .map_err(|error| AttestationError::SignatureCreationFailed { error })?; 116 + 117 + // Step 3: Normalize the signature to ensure consistent format 118 + normalize_signature(raw_signature, private_key_data.key_type()) 119 + } 120 + 121 + /// Creates a remote attestation with both the attested record and proof record. 122 + /// 123 + /// This is the recommended way to create remote attestations. It generates both: 124 + /// 1. The attested record with a strongRef in the signatures array 125 + /// 2. The proof record containing the CID to be stored in the attestation repository 126 + /// 127 + /// The CID is generated with the repository DID included in the `$sig` metadata 128 + /// to bind the attestation to a specific repository and prevent replay attacks. 129 + /// 130 + /// # Arguments 131 + /// 132 + /// * `record_input` - The record to attest (as AnyInput: String, Json, or TypedLexicon) 133 + /// * `metadata_input` - The attestation metadata (must include `$type`) 134 + /// * `repository` - The DID of the repository housing the original record 135 + /// * `attestation_repository` - The DID of the repository that will store the proof record 136 + /// 137 + /// # Returns 138 + /// 139 + /// A tuple containing: 140 + /// * `(attested_record, proof_record)` - Both records needed for remote attestation 141 + /// 142 + /// # Errors 143 + /// 144 + /// Returns an error if: 145 + /// - The record or metadata are not valid JSON objects 146 + /// - The metadata is missing the required `$type` field 147 + /// - CID generation fails 148 + /// 149 + /// # Example 150 + /// 151 + /// ```rust 152 + /// use atproto_attestation::{create_remote_attestation, input::AnyInput}; 153 + /// use serde_json::json; 154 + /// 155 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 156 + /// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 157 + /// let metadata = json!({"$type": "com.example.attestation"}); 158 + /// 159 + /// let (attested_record, proof_record) = create_remote_attestation( 160 + /// AnyInput::Serialize(record), 161 + /// AnyInput::Serialize(metadata), 162 + /// "did:plc:repo123", // Source repository 163 + /// "did:plc:attestor456" // Attestation repository 164 + /// )?; 165 + /// # Ok(()) 166 + /// # } 167 + /// ``` 168 + pub fn create_remote_attestation< 169 + R: Serialize + Clone, 170 + M: Serialize + Clone, 171 + >( 172 + record_input: AnyInput<R>, 173 + metadata_input: AnyInput<M>, 174 + repository: &str, 175 + attestation_repository: &str, 176 + ) -> Result<(Value, Value), AttestationError> { 177 + // Step 1: Create a content CID 178 + let content_cid = 179 + create_attestation_cid(record_input.clone(), metadata_input.clone(), repository)?; 180 + 181 + let record_obj: Map<String, Value> = record_input 182 + .try_into() 183 + .map_err(|_| AttestationError::RecordMustBeObject)?; 184 + 185 + // Step 2: Create the remote attestation record 186 + let (remote_attestation_record, remote_attestation_type) = { 187 + let mut metadata_obj: Map<String, Value> = metadata_input 188 + .try_into() 189 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 190 + 191 + // Extract the type from metadata before modifying it 192 + let remote_type = metadata_obj 193 + .get("$type") 194 + .and_then(Value::as_str) 195 + .ok_or(AttestationError::MetadataMissingType)? 196 + .to_string(); 197 + 198 + metadata_obj.insert("cid".to_string(), Value::String(content_cid.to_string())); 199 + (serde_json::Value::Object(metadata_obj), remote_type) 200 + }; 201 + 202 + // Step 3: Create the remote attestation reference (type, AT-URI, and CID) 203 + let remote_attestation_record_key = Tid::new(); 204 + let remote_attestation_cid = create_dagbor_cid(&remote_attestation_record)?; 205 + 206 + let attestation_reference = json!({ 207 + "$type": STRONG_REF_NSID, 208 + "uri": format!("at://{attestation_repository}/{remote_attestation_type}/{remote_attestation_record_key}"), 209 + "cid": remote_attestation_cid.to_string() 210 + }); 211 + 212 + // Step 4: Append the attestation reference to the record "signatures" array 213 + let attested_record = append_signature_to_record(record_obj, attestation_reference)?; 214 + 215 + Ok((attested_record, remote_attestation_record)) 216 + } 217 + 218 + /// Creates an inline attestation with signature embedded in the record. 219 + /// 220 + /// This is the v2 API that supports flexible input types (String, Json, TypedLexicon) 221 + /// and provides a more streamlined interface for creating inline attestations. 222 + /// 223 + /// The CID is generated with the repository DID included in the `$sig` metadata 224 + /// to bind the attestation to a specific repository and prevent replay attacks. 225 + /// 226 + /// # Arguments 227 + /// 228 + /// * `record_input` - The record to sign (as AnyInput: String, Json, or TypedLexicon) 229 + /// * `metadata_input` - The attestation metadata (must include `$type` and `key`) 230 + /// * `repository` - The DID of the repository that will house this record 231 + /// * `private_key_data` - The private key to use for signing 232 + /// 233 + /// # Returns 234 + /// 235 + /// The record with an inline attestation embedded in the signatures array 236 + /// 237 + /// # Errors 238 + /// 239 + /// Returns an error if: 240 + /// - The record or metadata are not valid JSON objects 241 + /// - The metadata is missing required fields 242 + /// - Signature creation fails 243 + /// - CID generation fails 244 + /// 245 + /// # Example 246 + /// 247 + /// ```rust 248 + /// use atproto_attestation::{create_inline_attestation, input::AnyInput}; 249 + /// use atproto_identity::key::{KeyType, generate_key, to_public}; 250 + /// use serde_json::json; 251 + /// 252 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 253 + /// let private_key = generate_key(KeyType::K256Private)?; 254 + /// let public_key = to_public(&private_key)?; 255 + /// 256 + /// let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 257 + /// let metadata = json!({ 258 + /// "$type": "com.example.signature", 259 + /// "key": format!("{}", public_key) 260 + /// }); 261 + /// 262 + /// let signed_record = create_inline_attestation( 263 + /// AnyInput::Serialize(record), 264 + /// AnyInput::Serialize(metadata), 265 + /// "did:plc:repo123", 266 + /// &private_key 267 + /// )?; 268 + /// # Ok(()) 269 + /// # } 270 + /// ``` 271 + pub fn create_inline_attestation< 272 + R: Serialize + Clone, 273 + M: Serialize + Clone, 274 + >( 275 + record_input: AnyInput<R>, 276 + metadata_input: AnyInput<M>, 277 + repository: &str, 278 + private_key_data: &KeyData, 279 + ) -> Result<Value, AttestationError> { 280 + // Step 1: Create a content CID 281 + let content_cid = 282 + create_attestation_cid(record_input.clone(), metadata_input.clone(), repository)?; 283 + 284 + let record_obj: Map<String, Value> = record_input 285 + .try_into() 286 + .map_err(|_| AttestationError::RecordMustBeObject)?; 287 + 288 + // Step 2: Create the inline attestation record 289 + let inline_attestation_record = { 290 + let mut metadata_obj: Map<String, Value> = metadata_input 291 + .try_into() 292 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 293 + 294 + metadata_obj.insert("cid".to_string(), Value::String(content_cid.to_string())); 295 + 296 + let raw_signature = sign(private_key_data, &content_cid.to_bytes()) 297 + .map_err(|error| AttestationError::SignatureCreationFailed { error })?; 298 + let signature_bytes = normalize_signature(raw_signature, private_key_data.key_type())?; 299 + 300 + metadata_obj.insert( 301 + "signature".to_string(), 302 + json!({"$bytes": BASE64.encode(signature_bytes)}), 303 + ); 304 + 305 + serde_json::Value::Object(metadata_obj) 306 + }; 307 + 308 + // Step 4: Append the attestation reference to the record "signatures" array 309 + append_signature_to_record(record_obj, inline_attestation_record) 310 + } 311 + 312 + /// Validates an existing proof record and appends a strongRef to it in the record's signatures array. 313 + /// 314 + /// This function validates that an existing proof record (attestation metadata with CID) 315 + /// is valid for the given record and repository, then creates and appends a strongRef to it. 316 + /// 317 + /// Unlike `create_remote_attestation` which creates a new proof record, this function validates 318 + /// an existing proof record that was already created and stored in an attestor's repository. 319 + /// 320 + /// # Security 321 + /// 322 + /// - **Repository binding validation**: Ensures the attestation was created for the specified repository DID 323 + /// - **CID verification**: Validates the proof record's CID matches the computed CID 324 + /// - **Content validation**: Ensures the proof record content matches what should be attested 325 + /// 326 + /// # Workflow 327 + /// 328 + /// 1. Compute the content CID from record + metadata + repository (same as attestation creation) 329 + /// 2. Extract the claimed CID from the proof record metadata 330 + /// 3. Verify the claimed CID matches the computed CID 331 + /// 4. Extract the proof record's storage CID (DAG-CBOR CID of the full proof record) 332 + /// 5. Create a strongRef with the AT-URI and proof record CID 333 + /// 6. Append the strongRef to the record's signatures array 334 + /// 335 + /// # Arguments 336 + /// 337 + /// * `record_input` - The record to append the attestation to (as AnyInput) 338 + /// * `metadata_input` - The proof record metadata (must include `$type`, `cid`, and attestation fields) 339 + /// * `repository` - The repository DID where the source record is stored (for replay attack prevention) 340 + /// * `attestation_uri` - The AT-URI where the proof record is stored (e.g., "at://did:plc:attestor/com.example.attestation/abc123") 341 + /// 342 + /// # Returns 343 + /// 344 + /// The modified record with the strongRef appended to its `signatures` array 345 + /// 346 + /// # Errors 347 + /// 348 + /// Returns an error if: 349 + /// - The record or metadata are not valid JSON objects 350 + /// - The metadata is missing the `cid` field 351 + /// - The computed CID doesn't match the claimed CID in the metadata 352 + /// - The metadata is missing required attestation fields 353 + /// 354 + /// # Type Parameters 355 + /// 356 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 357 + /// * `A` - The attestation type (must implement Serialize + LexiconType + PartialEq + Clone) 358 + /// 359 + /// # Example 360 + /// 361 + /// ```ignore 362 + /// use atproto_attestation::{append_remote_attestation, input::AnyInput}; 363 + /// use serde_json::json; 364 + /// 365 + /// let record = json!({ 366 + /// "$type": "app.bsky.feed.post", 367 + /// "text": "Hello world!" 368 + /// }); 369 + /// 370 + /// // This is the proof record that was previously created and stored 371 + /// let proof_metadata = json!({ 372 + /// "$type": "com.example.attestation", 373 + /// "issuer": "did:plc:issuer", 374 + /// "cid": "bafyrei...", // Content CID computed from record+metadata+repository 375 + /// // ... other attestation fields 376 + /// }); 377 + /// 378 + /// let repository_did = "did:plc:repo123"; 379 + /// let attestation_uri = "at://did:plc:attestor456/com.example.attestation/abc123"; 380 + /// 381 + /// let signed_record = append_remote_attestation( 382 + /// AnyInput::Serialize(record), 383 + /// AnyInput::Serialize(proof_metadata), 384 + /// repository_did, 385 + /// attestation_uri 386 + /// )?; 387 + /// ``` 388 + pub fn append_remote_attestation<R, A>( 389 + record_input: AnyInput<R>, 390 + metadata_input: AnyInput<A>, 391 + repository: &str, 392 + attestation_uri: &str, 393 + ) -> Result<Value, AttestationError> 394 + where 395 + R: Serialize + Clone, 396 + A: Serialize + Clone, 397 + { 398 + // Step 1: Compute the content CID (same as create_remote_attestation) 399 + let content_cid = 400 + create_attestation_cid(record_input.clone(), metadata_input.clone(), repository)?; 401 + 402 + // Step 2: Convert metadata to JSON and extract the claimed CID 403 + let metadata_obj: Map<String, Value> = metadata_input 404 + .try_into() 405 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 406 + 407 + let claimed_cid = metadata_obj 408 + .get("cid") 409 + .and_then(Value::as_str) 410 + .filter(|value| !value.is_empty()) 411 + .ok_or(AttestationError::SignatureMissingField { 412 + field: "cid".to_string(), 413 + })?; 414 + 415 + // Step 3: Verify the claimed CID matches the computed content CID 416 + if content_cid.to_string() != claimed_cid { 417 + return Err(AttestationError::RemoteAttestationCidMismatch { 418 + expected: claimed_cid.to_string(), 419 + actual: content_cid.to_string(), 420 + }); 421 + } 422 + 423 + // Step 4: Compute the proof record's DAG-CBOR CID 424 + let proof_record_cid = create_dagbor_cid(&metadata_obj)?; 425 + 426 + // Step 5: Create the strongRef 427 + let strongref = json!({ 428 + "$type": STRONG_REF_NSID, 429 + "uri": attestation_uri, 430 + "cid": proof_record_cid.to_string() 431 + }); 432 + 433 + // Step 6: Convert record to JSON object and append the strongRef 434 + let record_obj: Map<String, Value> = record_input 435 + .try_into() 436 + .map_err(|_| AttestationError::RecordMustBeObject)?; 437 + 438 + append_signature_to_record(record_obj, strongref) 439 + } 440 + 441 + /// Validates an inline attestation and appends it to a record's signatures array. 442 + /// 443 + /// Inline attestations contain cryptographic signatures embedded directly in the record. 444 + /// This function validates the attestation signature against the record and repository, 445 + /// then appends it if validation succeeds. 446 + /// 447 + /// # Security 448 + /// 449 + /// - **Repository binding validation**: Ensures the attestation was created for the specified repository DID 450 + /// - **CID verification**: Validates the CID in the attestation matches the computed CID 451 + /// - **Signature verification**: Cryptographically verifies the ECDSA signature 452 + /// - **Key resolution**: Resolves and validates the verification key 453 + /// 454 + /// # Arguments 455 + /// 456 + /// * `record_input` - The record to append the attestation to (as AnyInput) 457 + /// * `attestation_input` - The inline attestation to validate and append (as AnyInput) 458 + /// * `repository` - The repository DID where this record is stored (for replay attack prevention) 459 + /// * `key_resolver` - Resolver for looking up verification keys from DIDs 460 + /// 461 + /// # Returns 462 + /// 463 + /// The modified record with the validated attestation appended to its `signatures` array 464 + /// 465 + /// # Errors 466 + /// 467 + /// Returns an error if: 468 + /// - The record or attestation are not valid JSON objects 469 + /// - The attestation is missing required fields (`$type`, `key`, `cid`, `signature`) 470 + /// - The attestation CID doesn't match the computed CID for the record 471 + /// - The signature bytes are invalid or not base64-encoded 472 + /// - Signature verification fails 473 + /// - Key resolution fails 474 + /// 475 + /// # Type Parameters 476 + /// 477 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 478 + /// * `A` - The attestation type (must implement Serialize + LexiconType + PartialEq + Clone) 479 + /// * `KR` - The key resolver type (must implement KeyResolver) 480 + /// 481 + /// # Example 482 + /// 483 + /// ```ignore 484 + /// use atproto_attestation::{append_inline_attestation, input::AnyInput}; 485 + /// use serde_json::json; 486 + /// 487 + /// let record = json!({ 488 + /// "$type": "app.bsky.feed.post", 489 + /// "text": "Hello world!" 490 + /// }); 491 + /// 492 + /// let attestation = json!({ 493 + /// "$type": "com.example.inlineSignature", 494 + /// "key": "did:key:zQ3sh...", 495 + /// "cid": "bafyrei...", 496 + /// "signature": {"$bytes": "base64-signature-bytes"} 497 + /// }); 498 + /// 499 + /// let repository_did = "did:plc:repo123"; 500 + /// let key_resolver = /* your KeyResolver implementation */; 501 + /// 502 + /// let signed_record = append_inline_attestation( 503 + /// AnyInput::Serialize(record), 504 + /// AnyInput::Serialize(attestation), 505 + /// repository_did, 506 + /// key_resolver 507 + /// ).await?; 508 + /// ``` 509 + pub async fn append_inline_attestation<R, A, KR>( 510 + record_input: AnyInput<R>, 511 + attestation_input: AnyInput<A>, 512 + repository: &str, 513 + key_resolver: KR, 514 + ) -> Result<Value, AttestationError> 515 + where 516 + R: Serialize + Clone, 517 + A: Serialize + Clone, 518 + KR: KeyResolver, 519 + { 520 + // Step 1: Create a content CID 521 + let content_cid = 522 + create_attestation_cid(record_input.clone(), attestation_input.clone(), repository)?; 523 + 524 + let record_obj: Map<String, Value> = record_input 525 + .try_into() 526 + .map_err(|_| AttestationError::RecordMustBeObject)?; 527 + 528 + let attestation_obj: Map<String, Value> = attestation_input 529 + .try_into() 530 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 531 + 532 + let key = attestation_obj 533 + .get("key") 534 + .and_then(Value::as_str) 535 + .filter(|value| !value.is_empty()) 536 + .ok_or(AttestationError::SignatureMissingField { 537 + field: "key".to_string(), 538 + })?; 539 + let key_data = 540 + key_resolver 541 + .resolve(key) 542 + .await 543 + .map_err(|error| AttestationError::KeyResolutionFailed { 544 + key: key.to_string(), 545 + error, 546 + })?; 547 + 548 + let signature_bytes = attestation_obj 549 + .get("signature") 550 + .and_then(Value::as_object) 551 + .and_then(|object| object.get("$bytes")) 552 + .and_then(Value::as_str) 553 + .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 554 + 555 + let signature_bytes = BASE64 556 + .decode(signature_bytes) 557 + .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 558 + 559 + let computed_cid_bytes = content_cid.to_bytes(); 560 + 561 + validate(&key_data, &signature_bytes, &computed_cid_bytes) 562 + .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 563 + 564 + // Step 6: Append the validated attestation to the signatures array 565 + append_signature_to_record(record_obj, json!(attestation_obj)) 566 + } 567 + 568 + #[cfg(test)] 569 + mod tests { 570 + use super::*; 571 + use atproto_identity::key::{KeyType, generate_key, to_public}; 572 + use serde_json::json; 573 + 574 + #[test] 575 + fn create_remote_attestation_produces_both_records() -> Result<(), Box<dyn std::error::Error>> { 576 + 577 + let record = json!({ 578 + "$type": "app.example.record", 579 + "body": "remote attestation" 580 + }); 581 + 582 + let metadata = json!({ 583 + "$type": "com.example.attestation" 584 + }); 585 + 586 + let source_repository = "did:plc:test"; 587 + let attestation_repository = "did:plc:attestor"; 588 + 589 + let (attested_record, proof_record) = 590 + create_remote_attestation( 591 + AnyInput::Serialize(record.clone()), 592 + AnyInput::Serialize(metadata), 593 + source_repository, 594 + attestation_repository, 595 + )?; 596 + 597 + // Verify proof record structure 598 + let proof_object = proof_record.as_object().expect("proof should be an object"); 599 + assert_eq!( 600 + proof_object.get("$type").and_then(Value::as_str), 601 + Some("com.example.attestation") 602 + ); 603 + assert!( 604 + proof_object.get("cid").and_then(Value::as_str).is_some(), 605 + "proof must contain a cid" 606 + ); 607 + assert!( 608 + proof_object.get("repository").is_none(), 609 + "repository should not be in final proof record" 610 + ); 611 + 612 + // Verify attested record has strongRef 613 + let attested_object = attested_record 614 + .as_object() 615 + .expect("attested record should be an object"); 616 + let signatures = attested_object 617 + .get("signatures") 618 + .and_then(Value::as_array) 619 + .expect("attested record should have signatures array"); 620 + assert_eq!(signatures.len(), 1, "should have one signature"); 621 + 622 + let signature = &signatures[0]; 623 + assert_eq!( 624 + signature.get("$type").and_then(Value::as_str), 625 + Some("com.atproto.repo.strongRef"), 626 + "signature should be a strongRef" 627 + ); 628 + assert!( 629 + signature.get("uri").and_then(Value::as_str).is_some(), 630 + "strongRef must contain a uri" 631 + ); 632 + assert!( 633 + signature.get("cid").and_then(Value::as_str).is_some(), 634 + "strongRef must contain a cid" 635 + ); 636 + 637 + Ok(()) 638 + } 639 + 640 + #[tokio::test] 641 + async fn create_inline_attestation_full_workflow() -> Result<(), Box<dyn std::error::Error>> { 642 + let private_key = generate_key(KeyType::K256Private)?; 643 + let public_key = to_public(&private_key)?; 644 + let key_reference = format!("{}", &public_key); 645 + let repository_did = "did:plc:testrepository123"; 646 + 647 + let base_record = json!({ 648 + "$type": "app.example.record", 649 + "body": "Sign me" 650 + }); 651 + 652 + let sig_metadata = json!({ 653 + "$type": "com.example.inlineSignature", 654 + "key": key_reference, 655 + "purpose": "unit-test" 656 + }); 657 + 658 + let signed = create_inline_attestation( 659 + AnyInput::Serialize(base_record), 660 + AnyInput::Serialize(sig_metadata), 661 + repository_did, 662 + &private_key, 663 + )?; 664 + 665 + // Verify structure 666 + let signatures = signed 667 + .get("signatures") 668 + .and_then(Value::as_array) 669 + .expect("should have signatures array"); 670 + assert_eq!(signatures.len(), 1); 671 + 672 + let sig = &signatures[0]; 673 + assert_eq!( 674 + sig.get("$type").and_then(Value::as_str), 675 + Some("com.example.inlineSignature") 676 + ); 677 + assert!(sig.get("signature").is_some()); 678 + assert!(sig.get("key").is_some()); 679 + assert!(sig.get("repository").is_none()); // Should not be in final signature 680 + 681 + Ok(()) 682 + } 683 + 684 + #[test] 685 + fn create_signature_returns_valid_bytes() -> Result<(), Box<dyn std::error::Error>> { 686 + let private_key = generate_key(KeyType::K256Private)?; 687 + let public_key = to_public(&private_key)?; 688 + 689 + let record = json!({ 690 + "$type": "app.example.record", 691 + "body": "Test signature creation" 692 + }); 693 + 694 + let metadata = json!({ 695 + "$type": "com.example.signature", 696 + "key": format!("{}", public_key) 697 + }); 698 + 699 + let repository = "did:plc:test123"; 700 + 701 + // Create signature 702 + let signature_bytes = create_signature( 703 + AnyInput::Serialize(record.clone()), 704 + AnyInput::Serialize(metadata.clone()), 705 + repository, 706 + &private_key, 707 + )?; 708 + 709 + // Verify signature is not empty 710 + assert!(!signature_bytes.is_empty(), "Signature bytes should not be empty"); 711 + 712 + // Verify signature length is reasonable for ECDSA (typically 64-72 bytes for secp256k1) 713 + assert!( 714 + signature_bytes.len() >= 64 && signature_bytes.len() <= 73, 715 + "Signature length should be between 64 and 73 bytes, got {}", 716 + signature_bytes.len() 717 + ); 718 + 719 + // Verify we can validate the signature 720 + let content_cid = create_attestation_cid( 721 + AnyInput::Serialize(record), 722 + AnyInput::Serialize(metadata), 723 + repository, 724 + )?; 725 + 726 + validate(&public_key, &signature_bytes, &content_cid.to_bytes())?; 727 + 728 + Ok(()) 729 + } 730 + 731 + #[test] 732 + fn create_signature_different_inputs_produce_different_signatures() -> Result<(), Box<dyn std::error::Error>> { 733 + let private_key = generate_key(KeyType::K256Private)?; 734 + 735 + let record1 = json!({"$type": "app.example.record", "body": "First message"}); 736 + let record2 = json!({"$type": "app.example.record", "body": "Second message"}); 737 + let metadata = json!({"$type": "com.example.signature"}); 738 + let repository = "did:plc:test123"; 739 + 740 + let sig1 = create_signature( 741 + AnyInput::Serialize(record1), 742 + AnyInput::Serialize(metadata.clone()), 743 + repository, 744 + &private_key, 745 + )?; 746 + 747 + let sig2 = create_signature( 748 + AnyInput::Serialize(record2), 749 + AnyInput::Serialize(metadata), 750 + repository, 751 + &private_key, 752 + )?; 753 + 754 + assert_ne!(sig1, sig2, "Different records should produce different signatures"); 755 + 756 + Ok(()) 757 + } 758 + 759 + #[test] 760 + fn create_signature_different_repositories_produce_different_signatures() -> Result<(), Box<dyn std::error::Error>> { 761 + let private_key = generate_key(KeyType::K256Private)?; 762 + 763 + let record = json!({"$type": "app.example.record", "body": "Message"}); 764 + let metadata = json!({"$type": "com.example.signature"}); 765 + 766 + let sig1 = create_signature( 767 + AnyInput::Serialize(record.clone()), 768 + AnyInput::Serialize(metadata.clone()), 769 + "did:plc:repo1", 770 + &private_key, 771 + )?; 772 + 773 + let sig2 = create_signature( 774 + AnyInput::Serialize(record), 775 + AnyInput::Serialize(metadata), 776 + "did:plc:repo2", 777 + &private_key, 778 + )?; 779 + 780 + assert_ne!( 781 + sig1, sig2, 782 + "Different repository DIDs should produce different signatures" 783 + ); 784 + 785 + Ok(()) 786 + } 787 + }
+354
crates/atproto-attestation/src/bin/atproto-attestation-sign.rs
···
··· 1 + //! Command-line tool for signing AT Protocol records with inline or remote attestations. 2 + //! 3 + //! This tool creates cryptographic signatures for AT Protocol records using the CID-first 4 + //! attestation specification. It supports both inline attestations (embedding signatures 5 + //! directly in records) and remote attestations (creating separate proof records). 6 + //! 7 + //! ## Usage Patterns 8 + //! 9 + //! ### Remote Attestation 10 + //! ```bash 11 + //! atproto-attestation-sign remote <source_repository_did> <source_record> <attestation_repository_did> <metadata_record> 12 + //! ``` 13 + //! 14 + //! ### Inline Attestation 15 + //! ```bash 16 + //! atproto-attestation-sign inline <source_record> <repository_did> <signing_key> <metadata_record> 17 + //! ``` 18 + //! 19 + //! ## Arguments 20 + //! 21 + //! - `source_repository_did`: (Remote mode) DID of the repository housing the source record (prevents replay attacks) 22 + //! - `source_record`: JSON string or path to JSON file containing the record being attested 23 + //! - `attestation_repository_did`: (Remote mode) DID of the repository where the attestation proof will be stored 24 + //! - `repository_did`: (Inline mode) DID of the repository that will house the record (prevents replay attacks) 25 + //! - `signing_key`: (Inline mode) Private key string (did:key format) used to sign the attestation 26 + //! - `metadata_record`: JSON string or path to JSON file with attestation metadata used during CID creation 27 + //! 28 + //! ## Examples 29 + //! 30 + //! ```bash 31 + //! # Remote attestation - creates proof record and strongRef 32 + //! atproto-attestation-sign remote \ 33 + //! did:plc:sourceRepo... \ 34 + //! record.json \ 35 + //! did:plc:attestationRepo... \ 36 + //! metadata.json 37 + //! 38 + //! # Inline attestation - embeds signature in record 39 + //! atproto-attestation-sign inline \ 40 + //! record.json \ 41 + //! did:plc:xyz123... \ 42 + //! did:key:z42tv1pb3... \ 43 + //! '{"$type":"com.example.attestation","purpose":"demo"}' 44 + //! 45 + //! # Read from stdin 46 + //! cat record.json | atproto-attestation-sign remote \ 47 + //! did:plc:sourceRepo... \ 48 + //! - \ 49 + //! did:plc:attestationRepo... \ 50 + //! metadata.json 51 + //! ``` 52 + 53 + use anyhow::{Context, Result, anyhow}; 54 + use atproto_attestation::{ 55 + create_inline_attestation, create_remote_attestation, 56 + input::AnyInput, 57 + }; 58 + use atproto_identity::key::identify_key; 59 + use clap::{Parser, Subcommand}; 60 + use serde_json::Value; 61 + use std::{ 62 + fs, 63 + io::{self, Read}, 64 + path::Path, 65 + }; 66 + 67 + /// Command-line tool for signing AT Protocol records with cryptographic attestations. 68 + /// 69 + /// Creates inline or remote attestations following the CID-first specification. 70 + /// Inline attestations embed signatures directly in records, while remote attestations 71 + /// generate separate proof records with strongRef references. 72 + #[derive(Parser)] 73 + #[command( 74 + name = "atproto-attestation-sign", 75 + version, 76 + about = "Sign AT Protocol records with cryptographic attestations", 77 + long_about = " 78 + A command-line tool for signing AT Protocol records using the CID-first attestation 79 + specification. Supports both inline attestations (signatures embedded in the record) 80 + and remote attestations (separate proof records with CID references). 81 + 82 + MODES: 83 + remote Creates a separate proof record with strongRef reference 84 + Syntax: remote <source_repository_did> <source_record> <attestation_repository_did> <metadata_record> 85 + 86 + inline Embeds signature bytes directly in the record 87 + Syntax: inline <source_record> <repository_did> <signing_key> <metadata_record> 88 + 89 + ARGUMENTS: 90 + source_repository_did (Remote) DID of repository housing the source record (for replay prevention) 91 + source_record JSON string or file path to the record being attested 92 + attestation_repository_did (Remote) DID of repository where attestation proof will be stored 93 + repository_did (Inline) DID of repository that will house the record (for replay prevention) 94 + signing_key (Inline) Private key in did:key format for signing 95 + metadata_record JSON string or file path with attestation metadata 96 + 97 + EXAMPLES: 98 + # Remote attestation (creates proof record + strongRef): 99 + atproto-attestation-sign remote \\ 100 + did:plc:sourceRepo... \\ 101 + record.json \\ 102 + did:plc:attestationRepo... \\ 103 + metadata.json 104 + 105 + # Inline attestation (embeds signature): 106 + atproto-attestation-sign inline \\ 107 + record.json \\ 108 + did:plc:xyz123abc... \\ 109 + did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 110 + '{\"$type\":\"com.example.attestation\",\"purpose\":\"demo\"}' 111 + 112 + # Read source record from stdin: 113 + cat record.json | atproto-attestation-sign remote \\ 114 + did:plc:sourceRepo... \\ 115 + - \\ 116 + did:plc:attestationRepo... \\ 117 + metadata.json 118 + 119 + OUTPUT: 120 + Remote mode outputs TWO JSON objects: 121 + 1. The proof record (to be stored in the repository) 122 + 2. The source record with strongRef attestation appended 123 + 124 + Inline mode outputs ONE JSON object: 125 + - The source record with inline attestation embedded 126 + " 127 + )] 128 + struct Args { 129 + #[command(subcommand)] 130 + command: Commands, 131 + } 132 + 133 + #[derive(Subcommand)] 134 + enum Commands { 135 + /// Create a remote attestation with separate proof record 136 + /// 137 + /// Generates a proof record containing the CID and returns both the proof 138 + /// record (to be stored in the attestation repository) and the source record 139 + /// with a strongRef attestation reference. 140 + #[command(visible_alias = "r")] 141 + Remote { 142 + /// DID of the repository housing the source record (for replay attack prevention) 143 + source_repository_did: String, 144 + 145 + /// Source record JSON string or file path (use '-' for stdin) 146 + source_record: String, 147 + 148 + /// DID of the repository where the attestation proof will be stored 149 + attestation_repository_did: String, 150 + 151 + /// Attestation metadata JSON string or file path 152 + metadata_record: String, 153 + }, 154 + 155 + /// Create an inline attestation with embedded signature 156 + /// 157 + /// Signs the record with the provided private key and embeds the signature 158 + /// directly in the record's attestation structure. 159 + #[command(visible_alias = "i")] 160 + Inline { 161 + /// Source record JSON string or file path (use '-' for stdin) 162 + source_record: String, 163 + 164 + /// Repository DID that will house the record (for replay attack prevention) 165 + repository_did: String, 166 + 167 + /// Private signing key in did:key format (e.g., did:key:z...) 168 + signing_key: String, 169 + 170 + /// Attestation metadata JSON string or file path 171 + metadata_record: String, 172 + }, 173 + } 174 + 175 + #[tokio::main] 176 + async fn main() -> Result<()> { 177 + let args = Args::parse(); 178 + 179 + match args.command { 180 + Commands::Remote { 181 + source_repository_did, 182 + source_record, 183 + attestation_repository_did, 184 + metadata_record, 185 + } => handle_remote_attestation( 186 + &source_record, 187 + &source_repository_did, 188 + &metadata_record, 189 + &attestation_repository_did, 190 + )?, 191 + 192 + Commands::Inline { 193 + source_record, 194 + repository_did, 195 + signing_key, 196 + metadata_record, 197 + } => handle_inline_attestation( 198 + &source_record, 199 + &repository_did, 200 + &signing_key, 201 + &metadata_record, 202 + )?, 203 + } 204 + 205 + Ok(()) 206 + } 207 + 208 + /// Handle remote attestation mode. 209 + /// 210 + /// Creates a proof record and appends a strongRef to the source record. 211 + /// Outputs both the proof record and the updated source record. 212 + /// 213 + /// - `source_repository_did`: Used for signature binding (prevents replay attacks) 214 + /// - `attestation_repository_did`: Where the attestation proof record will be stored 215 + fn handle_remote_attestation( 216 + source_record: &str, 217 + source_repository_did: &str, 218 + metadata_record: &str, 219 + attestation_repository_did: &str, 220 + ) -> Result<()> { 221 + // Load source record and metadata 222 + let record_json = load_json_input(source_record)?; 223 + let metadata_json = load_json_input(metadata_record)?; 224 + 225 + // Validate inputs 226 + if !record_json.is_object() { 227 + return Err(anyhow!("Source record must be a JSON object")); 228 + } 229 + 230 + if !metadata_json.is_object() { 231 + return Err(anyhow!("Metadata record must be a JSON object")); 232 + } 233 + 234 + // Validate repository DIDs 235 + if !source_repository_did.starts_with("did:") { 236 + return Err(anyhow!( 237 + "Source repository DID must start with 'did:' prefix, got: {}", 238 + source_repository_did 239 + )); 240 + } 241 + 242 + if !attestation_repository_did.starts_with("did:") { 243 + return Err(anyhow!( 244 + "Attestation repository DID must start with 'did:' prefix, got: {}", 245 + attestation_repository_did 246 + )); 247 + } 248 + 249 + // Create the remote attestation using v2 API 250 + // This creates both the attested record with strongRef and the proof record in one call 251 + let (attested_record, proof_record) = 252 + create_remote_attestation( 253 + AnyInput::Serialize(record_json), 254 + AnyInput::Serialize(metadata_json), 255 + source_repository_did, 256 + attestation_repository_did, 257 + ) 258 + .context("Failed to create remote attestation")?; 259 + 260 + // Output both records 261 + println!("=== Proof Record (store in repository) ==="); 262 + println!("{}", serde_json::to_string_pretty(&proof_record)?); 263 + println!(); 264 + println!("=== Attested Record (with strongRef) ==="); 265 + println!("{}", serde_json::to_string_pretty(&attested_record)?); 266 + 267 + Ok(()) 268 + } 269 + 270 + /// Handle inline attestation mode. 271 + /// 272 + /// Signs the record with the provided key and embeds the signature. 273 + /// Outputs the record with inline attestation. 274 + fn handle_inline_attestation( 275 + source_record: &str, 276 + repository_did: &str, 277 + signing_key: &str, 278 + metadata_record: &str, 279 + ) -> Result<()> { 280 + // Load source record and metadata 281 + let record_json = load_json_input(source_record)?; 282 + let metadata_json = load_json_input(metadata_record)?; 283 + 284 + // Validate inputs 285 + if !record_json.is_object() { 286 + return Err(anyhow!("Source record must be a JSON object")); 287 + } 288 + 289 + if !metadata_json.is_object() { 290 + return Err(anyhow!("Metadata record must be a JSON object")); 291 + } 292 + 293 + // Validate repository DID 294 + if !repository_did.starts_with("did:") { 295 + return Err(anyhow!( 296 + "Repository DID must start with 'did:' prefix, got: {}", 297 + repository_did 298 + )); 299 + } 300 + 301 + // Parse the signing key 302 + let key_data = identify_key(signing_key) 303 + .with_context(|| format!("Failed to parse signing key: {}", signing_key))?; 304 + 305 + // Create inline attestation with repository binding using v2 API 306 + let signed_record = create_inline_attestation( 307 + AnyInput::Serialize(record_json), 308 + AnyInput::Serialize(metadata_json), 309 + repository_did, 310 + &key_data, 311 + ) 312 + .context("Failed to create inline attestation")?; 313 + 314 + // Output the signed record 315 + println!("{}", serde_json::to_string_pretty(&signed_record)?); 316 + 317 + Ok(()) 318 + } 319 + 320 + /// Load JSON input from various sources. 321 + /// 322 + /// Accepts: 323 + /// - "-" for stdin 324 + /// - File paths (if the file exists) 325 + /// - Direct JSON strings 326 + /// 327 + /// Returns the parsed JSON value or an error. 328 + fn load_json_input(argument: &str) -> Result<Value> { 329 + // Handle stdin input 330 + if argument == "-" { 331 + let mut input = String::new(); 332 + io::stdin() 333 + .read_to_string(&mut input) 334 + .context("Failed to read from stdin")?; 335 + return serde_json::from_str(&input).context("Failed to parse JSON from stdin"); 336 + } 337 + 338 + // Try as file path first 339 + let path = Path::new(argument); 340 + if path.is_file() { 341 + let file_content = fs::read_to_string(path) 342 + .with_context(|| format!("Failed to read file: {}", argument))?; 343 + return serde_json::from_str(&file_content) 344 + .with_context(|| format!("Failed to parse JSON from file: {}", argument)); 345 + } 346 + 347 + // Try as direct JSON string 348 + serde_json::from_str(argument).with_context(|| { 349 + format!( 350 + "Argument is neither valid JSON nor a readable file: {}", 351 + argument 352 + ) 353 + }) 354 + }
+363
crates/atproto-attestation/src/bin/atproto-attestation-verify.rs
···
··· 1 + //! Command-line tool for verifying cryptographic signatures on AT Protocol records. 2 + //! 3 + //! This tool validates attestation signatures on AT Protocol records by reconstructing 4 + //! the signed content and verifying ECDSA signatures against public keys embedded in the 5 + //! attestation metadata. 6 + //! 7 + //! ## Usage Patterns 8 + //! 9 + //! ### Verify all signatures in a record 10 + //! ```bash 11 + //! atproto-attestation-verify <record> <repository_did> 12 + //! ``` 13 + //! 14 + //! ### Verify a specific attestation against a record 15 + //! ```bash 16 + //! atproto-attestation-verify <record> <repository_did> <attestation> 17 + //! ``` 18 + //! 19 + //! ## Parameter Formats 20 + //! 21 + //! Both `record` and `attestation` parameters accept: 22 + //! - **JSON string**: Direct JSON payload (e.g., `'{"$type":"...","text":"..."}'`) 23 + //! - **File path**: Path to a JSON file (e.g., `./record.json`) 24 + //! - **AT-URI**: AT Protocol URI to fetch the record (e.g., `at://did:plc:abc/app.bsky.feed.post/123`) 25 + //! 26 + //! ## Examples 27 + //! 28 + //! ```bash 29 + //! # Verify all signatures in a record from file 30 + //! atproto-attestation-verify ./signed_post.json did:plc:repo123 31 + //! 32 + //! # Verify all signatures in a record from AT-URI 33 + //! atproto-attestation-verify at://did:plc:abc123/app.bsky.feed.post/3k2k4j5h6g did:plc:abc123 34 + //! 35 + //! # Verify specific attestation against a record (both from files) 36 + //! atproto-attestation-verify ./record.json did:plc:repo123 ./attestation.json 37 + //! 38 + //! # Verify specific attestation (from AT-URI) against record (from file) 39 + //! atproto-attestation-verify ./record.json did:plc:repo123 at://did:plc:xyz/com.example.attestation/abc123 40 + //! 41 + //! # Read record from stdin, verify all signatures 42 + //! cat signed.json | atproto-attestation-verify - did:plc:repo123 43 + //! 44 + //! # Verify inline JSON 45 + //! atproto-attestation-verify '{"$type":"app.bsky.feed.post","text":"Hello","signatures":[...]}' did:plc:repo123 46 + //! ``` 47 + 48 + use anyhow::{Context, Result, anyhow}; 49 + use atproto_attestation::AnyInput; 50 + use atproto_identity::key::{KeyData, KeyResolver, identify_key}; 51 + use clap::Parser; 52 + use serde_json::Value; 53 + use std::{ 54 + fs, 55 + io::{self, Read}, 56 + path::Path, 57 + }; 58 + 59 + /// Command-line tool for verifying cryptographic signatures on AT Protocol records. 60 + /// 61 + /// Validates attestation signatures by reconstructing signed content and checking 62 + /// ECDSA signatures against embedded public keys. Supports verifying all signatures 63 + /// in a record or validating a specific attestation record. 64 + /// 65 + /// The repository DID parameter is now REQUIRED to prevent replay attacks where 66 + /// attestations might be copied to different repositories. 67 + #[derive(Parser)] 68 + #[command( 69 + name = "atproto-attestation-verify", 70 + version, 71 + about = "Verify cryptographic signatures of AT Protocol records", 72 + long_about = " 73 + A command-line tool for verifying cryptographic signatures of AT Protocol records. 74 + 75 + USAGE: 76 + atproto-attestation-verify <record> <repository_did> Verify all signatures 77 + 78 + PARAMETER FORMATS: 79 + Each parameter accepts JSON strings, file paths, or AT-URIs: 80 + - JSON string: '{\"$type\":\"...\",\"text\":\"...\"}' 81 + - File path: ./record.json 82 + - AT-URI: at://did:plc:abc/app.bsky.feed.post/123 83 + - Stdin: - (for record parameter only) 84 + 85 + EXAMPLES: 86 + # Verify all signatures in a record: 87 + atproto-attestation-verify ./signed_post.json did:plc:repo123 88 + atproto-attestation-verify at://did:plc:abc/app.bsky.feed.post/123 did:plc:abc 89 + 90 + # Verify specific attestation: 91 + atproto-attestation-verify ./record.json did:plc:repo123 ./attestation.json 92 + atproto-attestation-verify ./record.json did:plc:repo123 at://did:plc:xyz/com.example.attestation/abc 93 + 94 + # Read from stdin: 95 + cat signed.json | atproto-attestation-verify - did:plc:repo123 96 + 97 + OUTPUT: 98 + Single record mode: Reports each signature with โœ“ (valid), โœ— (invalid), or ? (unverified) 99 + Attestation mode: Outputs 'OK' on success, error message on failure 100 + 101 + VERIFICATION: 102 + - Inline signatures are verified by reconstructing $sig and validating against embedded keys 103 + - Remote attestations (strongRef) are reported as unverified (require fetching proof record) 104 + - Keys are resolved from did:key identifiers or require a key resolver for DID document keys 105 + " 106 + )] 107 + struct Args { 108 + /// Record to verify - JSON string, file path, AT-URI, or '-' for stdin 109 + record: String, 110 + 111 + /// Repository DID that houses the record (required for replay attack prevention) 112 + repository_did: String, 113 + 114 + /// Optional attestation record to verify against the record - JSON string, file path, or AT-URI 115 + attestation: Option<String>, 116 + } 117 + 118 + /// A key resolver that supports `did:key:` identifiers directly. 119 + /// 120 + /// This resolver handles key references that are encoded as `did:key:` strings, 121 + /// parsing them to extract the cryptographic key data. For other DID methods, 122 + /// it returns an error since those would require fetching DID documents. 123 + struct DidKeyResolver {} 124 + 125 + #[async_trait::async_trait] 126 + impl KeyResolver for DidKeyResolver { 127 + async fn resolve(&self, subject: &str) -> Result<KeyData> { 128 + if subject.starts_with("did:key:") { 129 + identify_key(subject) 130 + .map_err(|e| anyhow!("Failed to parse did:key '{}': {}", subject, e)) 131 + } else { 132 + Err(anyhow!( 133 + "Subject '{}' is not a did:key: identifier. Only did:key: subjects are supported by this resolver.", 134 + subject 135 + )) 136 + } 137 + } 138 + } 139 + 140 + #[tokio::main] 141 + async fn main() -> Result<()> { 142 + let args = Args::parse(); 143 + 144 + // Load the record 145 + let record = load_input(&args.record, true) 146 + .await 147 + .context("Failed to load record")?; 148 + 149 + if !record.is_object() { 150 + return Err(anyhow!("Record must be a JSON object")); 151 + } 152 + 153 + // Validate repository DID 154 + if !args.repository_did.starts_with("did:") { 155 + return Err(anyhow!( 156 + "Repository DID must start with 'did:' prefix, got: {}", 157 + args.repository_did 158 + )); 159 + } 160 + 161 + // Determine verification mode 162 + verify_all_mode(&record, &args.repository_did).await 163 + } 164 + 165 + /// Mode 1: Verify all signatures contained in the record. 166 + /// 167 + /// Reports each signature with status indicators: 168 + /// - โœ“ Valid signature 169 + /// - โœ— Invalid signature 170 + /// - ? Unverified (e.g., remote attestations requiring proof record fetch) 171 + async fn verify_all_mode(record: &Value, repository_did: &str) -> Result<()> { 172 + // Create an identity resolver for fetching remote attestations 173 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 174 + use std::sync::Arc; 175 + 176 + let http_client = reqwest::Client::new(); 177 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 178 + 179 + let identity_resolver = InnerIdentityResolver { 180 + http_client: http_client.clone(), 181 + dns_resolver: Arc::new(dns_resolver), 182 + plc_hostname: "plc.directory".to_string(), 183 + }; 184 + 185 + // Create record resolver that can fetch remote attestation proof records 186 + let record_resolver = RemoteAttestationResolver { 187 + http_client, 188 + identity_resolver, 189 + }; 190 + 191 + let key_resolver = DidKeyResolver {}; 192 + 193 + atproto_attestation::verify_record( 194 + AnyInput::Serialize(record.clone()), 195 + repository_did, 196 + key_resolver, 197 + record_resolver, 198 + ) 199 + .await 200 + .context("Failed to verify signatures") 201 + } 202 + 203 + /// Load input from various sources: JSON string, file path, AT-URI, or stdin. 204 + /// 205 + /// The `allow_stdin` parameter controls whether "-" is interpreted as stdin. 206 + async fn load_input(input: &str, allow_stdin: bool) -> Result<Value> { 207 + // Handle stdin 208 + if input == "-" { 209 + if !allow_stdin { 210 + return Err(anyhow!( 211 + "Stdin ('-') is only supported for the record parameter" 212 + )); 213 + } 214 + 215 + let mut buffer = String::new(); 216 + io::stdin() 217 + .read_to_string(&mut buffer) 218 + .context("Failed to read from stdin")?; 219 + 220 + return serde_json::from_str(&buffer).context("Failed to parse JSON from stdin"); 221 + } 222 + 223 + // Check if it's an AT-URI 224 + if input.starts_with("at://") { 225 + return load_from_aturi(input) 226 + .await 227 + .with_context(|| format!("Failed to fetch record from AT-URI: {}", input)); 228 + } 229 + 230 + // Try as file path 231 + let path = Path::new(input); 232 + if path.exists() && path.is_file() { 233 + let content = 234 + fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", input))?; 235 + 236 + return serde_json::from_str(&content) 237 + .with_context(|| format!("Failed to parse JSON from file: {}", input)); 238 + } 239 + 240 + // Try as direct JSON string 241 + serde_json::from_str(input).with_context(|| { 242 + format!( 243 + "Input is not valid JSON, an existing file, or an AT-URI: {}", 244 + input 245 + ) 246 + }) 247 + } 248 + 249 + /// Load a record from an AT-URI by fetching it from a PDS. 250 + /// 251 + /// This requires resolving the DID to find the PDS endpoint, then fetching the record. 252 + async fn load_from_aturi(aturi: &str) -> Result<Value> { 253 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 254 + use atproto_record::aturi::ATURI; 255 + use std::str::FromStr; 256 + use std::sync::Arc; 257 + 258 + // Parse the AT-URI 259 + let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?; 260 + 261 + // Create resolver components 262 + let http_client = reqwest::Client::new(); 263 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 264 + 265 + // Create identity resolver 266 + let identity_resolver = InnerIdentityResolver { 267 + http_client: http_client.clone(), 268 + dns_resolver: Arc::new(dns_resolver), 269 + plc_hostname: "plc.directory".to_string(), 270 + }; 271 + 272 + // Resolve the DID to get the PDS endpoint 273 + let document = identity_resolver 274 + .resolve(&parsed.authority) 275 + .await 276 + .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?; 277 + 278 + // Find the PDS endpoint 279 + let pds_endpoint = document 280 + .service 281 + .iter() 282 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 283 + .map(|s| s.service_endpoint.as_str()) 284 + .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?; 285 + 286 + // Fetch the record using the XRPC client 287 + let response = atproto_client::com::atproto::repo::get_record( 288 + &http_client, 289 + &atproto_client::client::Auth::None, 290 + pds_endpoint, 291 + &parsed.authority, 292 + &parsed.collection, 293 + &parsed.record_key, 294 + None, 295 + ) 296 + .await 297 + .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?; 298 + 299 + match response { 300 + atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => Ok(value), 301 + atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 302 + Err(anyhow!("Failed to fetch record: {}", error.error_message())) 303 + } 304 + } 305 + } 306 + 307 + /// Record resolver for remote attestations that resolves DIDs to find PDS endpoints. 308 + struct RemoteAttestationResolver { 309 + http_client: reqwest::Client, 310 + identity_resolver: atproto_identity::resolve::InnerIdentityResolver, 311 + } 312 + 313 + #[async_trait::async_trait] 314 + impl atproto_client::record_resolver::RecordResolver for RemoteAttestationResolver { 315 + async fn resolve<T>(&self, aturi: &str) -> anyhow::Result<T> 316 + where 317 + T: serde::de::DeserializeOwned + Send, 318 + { 319 + use atproto_record::aturi::ATURI; 320 + use std::str::FromStr; 321 + 322 + // Parse the AT-URI 323 + let parsed = ATURI::from_str(aturi).map_err(|e| anyhow!("Invalid AT-URI: {}", e))?; 324 + 325 + // Resolve the DID to get the PDS endpoint 326 + let document = self 327 + .identity_resolver 328 + .resolve(&parsed.authority) 329 + .await 330 + .with_context(|| format!("Failed to resolve DID: {}", parsed.authority))?; 331 + 332 + // Find the PDS endpoint 333 + let pds_endpoint = document 334 + .service 335 + .iter() 336 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 337 + .map(|s| s.service_endpoint.as_str()) 338 + .ok_or_else(|| anyhow!("No PDS endpoint found for DID: {}", parsed.authority))?; 339 + 340 + // Fetch the record using the XRPC client 341 + let response = atproto_client::com::atproto::repo::get_record( 342 + &self.http_client, 343 + &atproto_client::client::Auth::None, 344 + pds_endpoint, 345 + &parsed.authority, 346 + &parsed.collection, 347 + &parsed.record_key, 348 + None, 349 + ) 350 + .await 351 + .with_context(|| format!("Failed to fetch record from PDS: {}", pds_endpoint))?; 352 + 353 + match response { 354 + atproto_client::com::atproto::repo::GetRecordResponse::Record { value, .. } => { 355 + serde_json::from_value(value) 356 + .map_err(|e| anyhow!("Failed to deserialize record: {}", e)) 357 + } 358 + atproto_client::com::atproto::repo::GetRecordResponse::Error(error) => { 359 + Err(anyhow!("Failed to fetch record: {}", error.error_message())) 360 + } 361 + } 362 + } 363 + }
+532
crates/atproto-attestation/src/cid.rs
···
··· 1 + //! CID (Content Identifier) generation for AT Protocol records. 2 + //! 3 + //! This module implements the CID-first attestation workflow, generating 4 + //! deterministic content identifiers using DAG-CBOR serialization and SHA-256 hashing. 5 + 6 + use crate::{errors::AttestationError, input::AnyInput}; 7 + #[cfg(test)] 8 + use atproto_record::typed::LexiconType; 9 + use cid::Cid; 10 + use multihash::Multihash; 11 + use serde::Serialize; 12 + use serde_json::{Value, Map}; 13 + use sha2::{Digest, Sha256}; 14 + use std::convert::TryInto; 15 + 16 + /// DAG-CBOR codec identifier used in AT Protocol CIDs. 17 + /// 18 + /// This codec (0x71) indicates that the data is encoded using DAG-CBOR, 19 + /// a deterministic subset of CBOR designed for content-addressable systems. 20 + pub const DAG_CBOR_CODEC: u64 = 0x71; 21 + 22 + /// SHA-256 multihash code used in AT Protocol CIDs. 23 + /// 24 + /// This code (0x12) identifies SHA-256 as the hash function used to generate 25 + /// the content identifier. SHA-256 provides 256-bit cryptographic security. 26 + pub const MULTIHASH_SHA256: u64 = 0x12; 27 + 28 + /// Create a CID from any serializable data using DAG-CBOR encoding. 29 + /// 30 + /// This function generates a content identifier (CID) for arbitrary data by: 31 + /// 1. Serializing the input to DAG-CBOR format 32 + /// 2. Computing a SHA-256 hash of the serialized bytes 33 + /// 3. Creating a CIDv1 with dag-cbor codec (0x71) 34 + /// 35 + /// # Arguments 36 + /// 37 + /// * `record` - The data to generate a CID for (must implement `Serialize`) 38 + /// 39 + /// # Returns 40 + /// 41 + /// The generated CID for the data using CIDv1 with dag-cbor codec (0x71) and sha2-256 hash 42 + /// 43 + /// # Type Parameters 44 + /// 45 + /// * `T` - Any type that implements `Serialize` and is compatible with DAG-CBOR encoding 46 + /// 47 + /// # Errors 48 + /// 49 + /// Returns an error if: 50 + /// - DAG-CBOR serialization fails 51 + /// - Multihash wrapping fails 52 + /// 53 + /// # Example 54 + /// 55 + /// ```rust 56 + /// use atproto_attestation::cid::create_dagbor_cid; 57 + /// use serde_json::json; 58 + /// 59 + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 60 + /// let data = json!({"text": "Hello, world!"}); 61 + /// let cid = create_dagbor_cid(&data)?; 62 + /// assert_eq!(cid.codec(), 0x71); // dag-cbor codec 63 + /// # Ok(()) 64 + /// # } 65 + /// ``` 66 + pub fn create_dagbor_cid<T: Serialize>(record: &T) -> Result<Cid, AttestationError> { 67 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(record)?; 68 + let digest = Sha256::digest(&dag_cbor_bytes); 69 + let multihash = Multihash::wrap(MULTIHASH_SHA256, &digest) 70 + .map_err(|error| AttestationError::MultihashWrapFailed { error })?; 71 + 72 + Ok(Cid::new_v1(DAG_CBOR_CODEC, multihash)) 73 + } 74 + 75 + /// Create a CID for an attestation with automatic `$sig` metadata preparation. 76 + /// 77 + /// This is the high-level function used internally by attestation creation functions. 78 + /// It handles the full workflow of preparing a signing record with `$sig` metadata 79 + /// and generating the CID. 80 + /// 81 + /// # Arguments 82 + /// 83 + /// * `record_input` - The record to attest (as AnyInput: String, Json, or TypedLexicon) 84 + /// * `metadata_input` - The attestation metadata (must include `$type`) 85 + /// * `repository` - The repository DID to bind the attestation to (prevents replay attacks) 86 + /// 87 + /// # Returns 88 + /// 89 + /// The generated CID for the prepared attestation record 90 + /// 91 + /// # Errors 92 + /// 93 + /// Returns an error if: 94 + /// - The record or metadata are not valid JSON objects 95 + /// - The record is missing the required `$type` field 96 + /// - The metadata is missing the required `$type` field 97 + /// - DAG-CBOR serialization fails 98 + pub fn create_attestation_cid< 99 + R: Serialize + Clone, 100 + M: Serialize + Clone, 101 + >( 102 + record_input: AnyInput<R>, 103 + metadata_input: AnyInput<M>, 104 + repository: &str, 105 + ) -> Result<Cid, AttestationError> { 106 + let mut record_obj: Map<String, Value> = record_input 107 + .try_into() 108 + .map_err(|_| AttestationError::RecordMustBeObject)?; 109 + 110 + if record_obj 111 + .get("$type") 112 + .and_then(Value::as_str) 113 + .filter(|value| !value.is_empty()) 114 + .is_none() 115 + { 116 + return Err(AttestationError::RecordMissingType); 117 + } 118 + 119 + let mut metadata_obj: Map<String, Value> = metadata_input 120 + .try_into() 121 + .map_err(|_| AttestationError::MetadataMustBeObject)?; 122 + 123 + if metadata_obj 124 + .get("$type") 125 + .and_then(Value::as_str) 126 + .filter(|value| !value.is_empty()) 127 + .is_none() 128 + { 129 + return Err(AttestationError::MetadataMissingSigType); 130 + } 131 + 132 + record_obj.remove("signatures"); 133 + 134 + metadata_obj.remove("cid"); 135 + metadata_obj.remove("signature"); 136 + metadata_obj.insert( 137 + "repository".to_string(), 138 + Value::String(repository.to_string()), 139 + ); 140 + 141 + record_obj.insert("$sig".to_string(), Value::Object(metadata_obj.clone())); 142 + 143 + // Directly pass the Map<String, Value> - no need to wrap in Value::Object 144 + create_dagbor_cid(&record_obj) 145 + } 146 + 147 + /// Validates that a CID string is a valid DAG-CBOR CID for AT Protocol attestations. 148 + /// 149 + /// This function performs strict validation to ensure the CID meets the exact 150 + /// specifications required for AT Protocol attestations: 151 + /// 152 + /// 1. **Valid format**: The string must be a parseable CID 153 + /// 2. **Version**: Must be CIDv1 (not CIDv0) 154 + /// 3. **Codec**: Must use DAG-CBOR codec (0x71) 155 + /// 4. **Hash algorithm**: Must use SHA-256 (multihash code 0x12) 156 + /// 5. **Hash length**: Must have exactly 32 bytes (SHA-256 standard) 157 + /// 158 + /// These requirements ensure consistency and security across the AT Protocol 159 + /// ecosystem, particularly for content addressing and attestation verification. 160 + /// 161 + /// # Arguments 162 + /// 163 + /// * `cid` - A string slice containing the CID to validate 164 + /// 165 + /// # Returns 166 + /// 167 + /// * `true` if the CID is a valid DAG-CBOR CID with SHA-256 hash 168 + /// * `false` if the CID is invalid or doesn't meet any requirement 169 + /// 170 + /// # Examples 171 + /// 172 + /// ```rust 173 + /// use atproto_attestation::cid::validate_dagcbor_cid; 174 + /// 175 + /// // Valid AT Protocol CID (CIDv1, DAG-CBOR, SHA-256) 176 + /// let valid_cid = "bafyreigw5bqvbz6m3c3zjpqhxwl4njlnbbnw5xvptbx6dzfxjqcde6lt3y"; 177 + /// assert!(validate_dagcbor_cid(valid_cid)); 178 + /// 179 + /// // Invalid: Empty string 180 + /// assert!(!validate_dagcbor_cid("")); 181 + /// 182 + /// // Invalid: Not a CID 183 + /// assert!(!validate_dagcbor_cid("not-a-cid")); 184 + /// 185 + /// // Invalid: CIDv0 (starts with Qm) 186 + /// let cid_v0 = "QmYwAPJzv5CZsnA625ub3XtLxT3Tz5Lno5Wqv9eKewWKjE"; 187 + /// assert!(!validate_dagcbor_cid(cid_v0)); 188 + /// ``` 189 + pub fn validate_dagcbor_cid(cid: &str) -> bool { 190 + if cid.is_empty() { 191 + return false 192 + } 193 + 194 + // Parse the CID using the cid crate for proper validation 195 + let parsed_cid = match Cid::try_from(cid) { 196 + Ok(value) => value, 197 + Err(_) => return false, 198 + }; 199 + 200 + // Verify it's CIDv1 (version 1) 201 + if parsed_cid.version() != cid::Version::V1 { 202 + return false; 203 + } 204 + 205 + // Verify it uses DAG-CBOR codec (0x71) 206 + if parsed_cid.codec() != DAG_CBOR_CODEC { 207 + return false; 208 + } 209 + 210 + // Get the multihash and verify it uses SHA-256 211 + let multihash = parsed_cid.hash(); 212 + 213 + // SHA-256 code is 0x12 214 + if multihash.code() != MULTIHASH_SHA256 { 215 + return false; 216 + } 217 + 218 + // Verify the hash digest is 32 bytes (SHA-256 standard) 219 + if multihash.digest().len() != 32 { 220 + return false; 221 + } 222 + 223 + true 224 + } 225 + 226 + #[cfg(test)] 227 + mod tests { 228 + use super::*; 229 + use atproto_record::typed::TypedLexicon; 230 + use serde::Deserialize; 231 + 232 + 233 + #[tokio::test] 234 + async fn test_create_attestation_cid() -> Result<(), AttestationError> { 235 + use atproto_record::datetime::format as datetime_format; 236 + use chrono::{DateTime, Utc}; 237 + 238 + // Define test record type with createdAt and text fields 239 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 240 + #[cfg_attr(debug_assertions, derive(Debug))] 241 + struct TestRecord { 242 + #[serde(rename = "createdAt", with = "datetime_format")] 243 + created_at: DateTime<Utc>, 244 + text: String, 245 + } 246 + 247 + impl LexiconType for TestRecord { 248 + fn lexicon_type() -> &'static str { 249 + "com.example.testrecord" 250 + } 251 + } 252 + 253 + // Define test metadata type 254 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 255 + #[cfg_attr(debug_assertions, derive(Debug))] 256 + struct TestMetadata { 257 + #[serde(rename = "createdAt", with = "datetime_format")] 258 + created_at: DateTime<Utc>, 259 + purpose: String, 260 + } 261 + 262 + impl LexiconType for TestMetadata { 263 + fn lexicon_type() -> &'static str { 264 + "com.example.testmetadata" 265 + } 266 + } 267 + 268 + // Create test data 269 + let created_at = DateTime::parse_from_rfc3339("2025-01-15T14:00:00.000Z") 270 + .unwrap() 271 + .with_timezone(&Utc); 272 + 273 + let record = TestRecord { 274 + created_at, 275 + text: "Hello, AT Protocol!".to_string(), 276 + }; 277 + 278 + let metadata_created_at = DateTime::parse_from_rfc3339("2025-01-15T14:05:00.000Z") 279 + .unwrap() 280 + .with_timezone(&Utc); 281 + 282 + let metadata = TestMetadata { 283 + created_at: metadata_created_at, 284 + purpose: "attestation".to_string(), 285 + }; 286 + 287 + let repository = "did:plc:test123"; 288 + 289 + // Create typed lexicons 290 + let typed_record = TypedLexicon::new(record); 291 + let typed_metadata = TypedLexicon::new(metadata); 292 + 293 + // Call the function 294 + let cid = create_attestation_cid( 295 + AnyInput::Serialize(typed_record), 296 + AnyInput::Serialize(typed_metadata), 297 + repository, 298 + )?; 299 + 300 + // Verify CID properties 301 + assert_eq!(cid.codec(), 0x71, "CID should use dag-cbor codec"); 302 + assert_eq!(cid.hash().code(), 0x12, "CID should use sha2-256 hash"); 303 + assert_eq!( 304 + cid.hash().digest().len(), 305 + 32, 306 + "Hash digest should be 32 bytes" 307 + ); 308 + assert_eq!(cid.to_bytes().len(), 36, "CID should be 36 bytes total"); 309 + 310 + Ok(()) 311 + } 312 + 313 + #[tokio::test] 314 + async fn test_create_attestation_cid_deterministic() -> Result<(), AttestationError> { 315 + use atproto_record::datetime::format as datetime_format; 316 + use chrono::{DateTime, Utc}; 317 + 318 + // Define simple test types 319 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 320 + struct SimpleRecord { 321 + #[serde(rename = "createdAt", with = "datetime_format")] 322 + created_at: DateTime<Utc>, 323 + text: String, 324 + } 325 + 326 + impl LexiconType for SimpleRecord { 327 + fn lexicon_type() -> &'static str { 328 + "com.example.simple" 329 + } 330 + } 331 + 332 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 333 + struct SimpleMetadata { 334 + #[serde(rename = "createdAt", with = "datetime_format")] 335 + created_at: DateTime<Utc>, 336 + } 337 + 338 + impl LexiconType for SimpleMetadata { 339 + fn lexicon_type() -> &'static str { 340 + "com.example.meta" 341 + } 342 + } 343 + 344 + let created_at = DateTime::parse_from_rfc3339("2025-01-01T00:00:00.000Z") 345 + .unwrap() 346 + .with_timezone(&Utc); 347 + 348 + let record1 = SimpleRecord { 349 + created_at, 350 + text: "test".to_string(), 351 + }; 352 + let record2 = SimpleRecord { 353 + created_at, 354 + text: "test".to_string(), 355 + }; 356 + 357 + let metadata1 = SimpleMetadata { created_at }; 358 + let metadata2 = SimpleMetadata { created_at }; 359 + 360 + let repository = "did:plc:same"; 361 + 362 + // Create CIDs for identical records 363 + let cid1 = create_attestation_cid( 364 + AnyInput::Serialize(TypedLexicon::new(record1)), 365 + AnyInput::Serialize(TypedLexicon::new(metadata1)), 366 + repository, 367 + )?; 368 + 369 + let cid2 = create_attestation_cid( 370 + AnyInput::Serialize(TypedLexicon::new(record2)), 371 + AnyInput::Serialize(TypedLexicon::new(metadata2)), 372 + repository, 373 + )?; 374 + 375 + // Verify determinism: identical inputs produce identical CIDs 376 + assert_eq!( 377 + cid1, cid2, 378 + "Identical records should produce identical CIDs" 379 + ); 380 + 381 + Ok(()) 382 + } 383 + 384 + #[tokio::test] 385 + async fn test_create_attestation_cid_different_repositories() -> Result<(), AttestationError> { 386 + use atproto_record::datetime::format as datetime_format; 387 + use chrono::{DateTime, Utc}; 388 + 389 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 390 + struct RepoRecord { 391 + #[serde(rename = "createdAt", with = "datetime_format")] 392 + created_at: DateTime<Utc>, 393 + text: String, 394 + } 395 + 396 + impl LexiconType for RepoRecord { 397 + fn lexicon_type() -> &'static str { 398 + "com.example.repo" 399 + } 400 + } 401 + 402 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 403 + struct RepoMetadata { 404 + #[serde(rename = "createdAt", with = "datetime_format")] 405 + created_at: DateTime<Utc>, 406 + } 407 + 408 + impl LexiconType for RepoMetadata { 409 + fn lexicon_type() -> &'static str { 410 + "com.example.repometa" 411 + } 412 + } 413 + 414 + let created_at = DateTime::parse_from_rfc3339("2025-01-01T12:00:00.000Z") 415 + .unwrap() 416 + .with_timezone(&Utc); 417 + 418 + let record = RepoRecord { 419 + created_at, 420 + text: "content".to_string(), 421 + }; 422 + let metadata = RepoMetadata { created_at }; 423 + 424 + // Same record and metadata, different repositories 425 + let cid1 = create_attestation_cid( 426 + AnyInput::Serialize(TypedLexicon::new(record.clone())), 427 + AnyInput::Serialize(TypedLexicon::new(metadata.clone())), 428 + "did:plc:repo1", 429 + )?; 430 + 431 + let cid2 = create_attestation_cid( 432 + AnyInput::Serialize(TypedLexicon::new(record)), 433 + AnyInput::Serialize(TypedLexicon::new(metadata)), 434 + "did:plc:repo2", 435 + )?; 436 + 437 + // Different repositories should produce different CIDs (prevents replay attacks) 438 + assert_ne!( 439 + cid1, cid2, 440 + "Different repository DIDs should produce different CIDs" 441 + ); 442 + 443 + Ok(()) 444 + } 445 + 446 + #[test] 447 + fn test_validate_dagcbor_cid() { 448 + // Test valid CID (generated from our own create_dagbor_cid function) 449 + let valid_data = serde_json::json!({"test": "data"}); 450 + let valid_cid = create_dagbor_cid(&valid_data).unwrap(); 451 + let valid_cid_str = valid_cid.to_string(); 452 + assert!(validate_dagcbor_cid(&valid_cid_str), "Valid CID should pass validation"); 453 + 454 + // Test empty string 455 + assert!(!validate_dagcbor_cid(""), "Empty string should fail validation"); 456 + 457 + // Test invalid CID string 458 + assert!(!validate_dagcbor_cid("not-a-cid"), "Invalid string should fail validation"); 459 + assert!(!validate_dagcbor_cid("abc123"), "Invalid string should fail validation"); 460 + 461 + // Test CIDv0 (starts with Qm, uses different format) 462 + let cid_v0 = "QmYwAPJzv5CZsnA625ub3XtLxT3Tz5Lno5Wqv9eKewWKjE"; 463 + assert!(!validate_dagcbor_cid(cid_v0), "CIDv0 should fail validation"); 464 + 465 + // Test valid CID base32 format but wrong codec (not DAG-CBOR) 466 + // This is a valid CID but uses raw codec (0x55) instead of DAG-CBOR (0x71) 467 + let wrong_codec = "bafkreigw5bqvbz6m3c3zjpqhxwl4njlnbbnw5xvptbx6dzfxjqcde6lt3y"; 468 + assert!(!validate_dagcbor_cid(wrong_codec), "CID with wrong codec should fail"); 469 + 470 + // Test that our constants match what we're checking 471 + assert_eq!(DAG_CBOR_CODEC, 0x71, "DAG-CBOR codec constant should be 0x71"); 472 + assert_eq!(MULTIHASH_SHA256, 0x12, "SHA-256 multihash code should be 0x12"); 473 + } 474 + 475 + #[tokio::test] 476 + async fn phantom_data_test() -> Result<(), AttestationError> { 477 + let repository = "did:web:example.com"; 478 + 479 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 480 + struct FooRecord { 481 + text: String, 482 + } 483 + 484 + impl LexiconType for FooRecord { 485 + fn lexicon_type() -> &'static str { 486 + "com.example.foo" 487 + } 488 + } 489 + 490 + #[derive(Serialize, Deserialize, PartialEq, Clone)] 491 + struct BarRecord { 492 + text: String, 493 + } 494 + 495 + impl LexiconType for BarRecord { 496 + fn lexicon_type() -> &'static str { 497 + "com.example.bar" 498 + } 499 + } 500 + 501 + let foo = FooRecord { 502 + text: "foo".to_string(), 503 + }; 504 + let typed_foo = TypedLexicon::new(foo); 505 + 506 + let bar = BarRecord { 507 + text: "bar".to_string(), 508 + }; 509 + let typed_bar = TypedLexicon::new(bar); 510 + 511 + let cid1 = create_attestation_cid( 512 + AnyInput::Serialize(typed_foo.clone()), 513 + AnyInput::Serialize(typed_bar.clone()), 514 + repository, 515 + )?; 516 + 517 + let value_bar = serde_json::to_value(typed_bar).expect("bar serde_json::Value conversion"); 518 + 519 + let cid2 = create_attestation_cid( 520 + AnyInput::Serialize(typed_foo), 521 + AnyInput::Serialize(value_bar), 522 + repository, 523 + )?; 524 + 525 + assert_eq!( 526 + cid1, cid2, 527 + "Different repository DIDs should produce different CIDs" 528 + ); 529 + 530 + Ok(()) 531 + } 532 + }
+202
crates/atproto-attestation/src/errors.rs
···
··· 1 + //! Errors that can occur during attestation preparation and verification. 2 + //! 3 + //! Covers CID construction, `$sig` metadata validation, inline attestation 4 + //! structure checks, and identity/key resolution failures. 5 + 6 + use thiserror::Error; 7 + 8 + /// Errors that can occur during attestation preparation and verification. 9 + #[derive(Debug, Error)] 10 + pub enum AttestationError { 11 + /// Error when the record value is not a JSON object. 12 + #[error("error-atproto-attestation-1 Record must be a JSON object")] 13 + RecordMustBeObject, 14 + 15 + /// Error when the record omits the `$type` discriminator. 16 + #[error("error-atproto-attestation-1 Record must include a string `$type` field")] 17 + RecordMissingType, 18 + 19 + /// Error when attestation metadata is not a JSON object. 20 + #[error("error-atproto-attestation-2 Attestation metadata must be a JSON object")] 21 + MetadataMustBeObject, 22 + 23 + /// Error when attestation metadata is missing a required field. 24 + #[error("error-atproto-attestation-3 Attestation metadata missing required field: {field}")] 25 + MetadataMissingField { 26 + /// Name of the missing field. 27 + field: String, 28 + }, 29 + 30 + /// Error when attestation metadata omits the `$type` discriminator. 31 + #[error("error-atproto-attestation-4 Attestation metadata must include a string `$type` field")] 32 + MetadataMissingSigType, 33 + 34 + /// Error when the record does not contain a signatures array. 35 + #[error("error-atproto-attestation-5 Signatures array not found on record")] 36 + SignaturesArrayMissing, 37 + 38 + /// Error when the signatures field exists but is not an array. 39 + #[error("error-atproto-attestation-6 Signatures field must be an array")] 40 + SignaturesFieldInvalid, 41 + 42 + /// Error when attempting to verify a signature at an invalid index. 43 + #[error("error-atproto-attestation-7 Signature index {index} out of bounds")] 44 + SignatureIndexOutOfBounds { 45 + /// Index that was requested. 46 + index: usize, 47 + }, 48 + 49 + /// Error when a signature object is missing a required field. 50 + #[error("error-atproto-attestation-8 Signature object missing required field: {field}")] 51 + SignatureMissingField { 52 + /// Field name that was expected. 53 + field: String, 54 + }, 55 + 56 + /// Error when a signature object uses an invalid `$type` for inline attestations. 57 + #[error( 58 + "error-atproto-attestation-9 Inline attestation `$type` cannot be `com.atproto.repo.strongRef`" 59 + )] 60 + InlineAttestationTypeInvalid, 61 + 62 + /// Error when a remote attestation entry does not use the strongRef type. 63 + #[error( 64 + "error-atproto-attestation-10 Remote attestation entries must use `com.atproto.repo.strongRef`" 65 + )] 66 + RemoteAttestationTypeInvalid, 67 + 68 + /// Error when a remote attestation entry is missing a CID. 69 + #[error( 70 + "error-atproto-attestation-11 Remote attestation entries must include a string `cid` field" 71 + )] 72 + RemoteAttestationMissingCid, 73 + 74 + /// Error when signature bytes are not provided using the `$bytes` wrapper. 75 + #[error( 76 + "error-atproto-attestation-12 Signature bytes must be encoded as `{{\"$bytes\": \"...\"}}`" 77 + )] 78 + SignatureBytesFormatInvalid, 79 + 80 + /// Error when record serialization to DAG-CBOR fails. 81 + #[error("error-atproto-attestation-13 Record serialization failed: {error}")] 82 + RecordSerializationFailed { 83 + /// Underlying serialization error. 84 + #[from] 85 + error: serde_ipld_dagcbor::EncodeError<std::collections::TryReserveError>, 86 + }, 87 + 88 + /// Error when `$sig` metadata is missing from the record before CID creation. 89 + #[error("error-atproto-attestation-14 `$sig` metadata must be present before generating a CID")] 90 + SigMetadataMissing, 91 + 92 + /// Error when `$sig` metadata is not an object. 93 + #[error("error-atproto-attestation-15 `$sig` metadata must be a JSON object")] 94 + SigMetadataNotObject, 95 + 96 + /// Error when `$sig` metadata omits the `$type` discriminator. 97 + #[error("error-atproto-attestation-16 `$sig` metadata must include a string `$type` field")] 98 + SigMetadataMissingType, 99 + 100 + /// Error when metadata omits the `$type` discriminator. 101 + #[error("error-atproto-attestation-18 Metadata must include a string `$type` field")] 102 + MetadataMissingType, 103 + 104 + /// Error when a key resolver is required but not provided. 105 + #[error("error-atproto-attestation-17 Key resolver required to resolve key reference: {key}")] 106 + KeyResolverRequired { 107 + /// Key reference that required resolution. 108 + key: String, 109 + }, 110 + 111 + /// Error when key resolution using the provided resolver fails. 112 + #[error("error-atproto-attestation-18 Failed to resolve key reference {key}: {error}")] 113 + KeyResolutionFailed { 114 + /// Key reference that was being resolved. 115 + key: String, 116 + /// Underlying resolution error. 117 + #[source] 118 + error: anyhow::Error, 119 + }, 120 + 121 + /// Error when the key type is unsupported for inline attestations. 122 + #[error("error-atproto-attestation-21 Unsupported key type for attestation: {key_type}")] 123 + UnsupportedKeyType { 124 + /// Unsupported key type. 125 + key_type: atproto_identity::key::KeyType, 126 + }, 127 + 128 + /// Error when signature decoding fails. 129 + #[error("error-atproto-attestation-22 Signature decoding failed: {error}")] 130 + SignatureDecodingFailed { 131 + /// Underlying base64 decoding error. 132 + #[from] 133 + error: base64::DecodeError, 134 + }, 135 + 136 + /// Error when signature length does not match the expected size. 137 + #[error( 138 + "error-atproto-attestation-23 Signature length invalid: expected {expected} bytes, found {actual}" 139 + )] 140 + SignatureLengthInvalid { 141 + /// Expected signature length. 142 + expected: usize, 143 + /// Actual signature length. 144 + actual: usize, 145 + }, 146 + 147 + /// Error when signature is not normalized to low-S form. 148 + #[error("error-atproto-attestation-24 Signature must be normalized to low-S form")] 149 + SignatureNotNormalized, 150 + 151 + /// Error when cryptographic verification fails. 152 + #[error("error-atproto-attestation-25 Signature verification failed: {error}")] 153 + SignatureValidationFailed { 154 + /// Underlying key validation error. 155 + #[source] 156 + error: atproto_identity::errors::KeyError, 157 + }, 158 + 159 + /// Error when multihash construction for CID generation fails. 160 + #[error("error-atproto-attestation-26 Failed to construct CID multihash: {error}")] 161 + MultihashWrapFailed { 162 + /// Underlying multihash error. 163 + #[source] 164 + error: multihash::Error, 165 + }, 166 + 167 + /// Error when signature creation fails during inline attestation. 168 + #[error("error-atproto-attestation-27 Signature creation failed: {error}")] 169 + SignatureCreationFailed { 170 + /// Underlying signing error. 171 + #[source] 172 + error: atproto_identity::errors::KeyError, 173 + }, 174 + 175 + /// Error when fetching a remote attestation proof record fails. 176 + #[error("error-atproto-attestation-28 Failed to fetch remote attestation from {uri}: {error}")] 177 + RemoteAttestationFetchFailed { 178 + /// AT-URI that failed to resolve. 179 + uri: String, 180 + /// Underlying fetch error. 181 + #[source] 182 + error: anyhow::Error, 183 + }, 184 + 185 + /// Error when the CID of a remote attestation proof record doesn't match expected. 186 + #[error( 187 + "error-atproto-attestation-29 Remote attestation CID mismatch: expected {expected}, got {actual}" 188 + )] 189 + RemoteAttestationCidMismatch { 190 + /// Expected CID. 191 + expected: String, 192 + /// Actual CID. 193 + actual: String, 194 + }, 195 + 196 + /// Error when parsing a CID string fails. 197 + #[error("error-atproto-attestation-30 Invalid CID format: {cid}")] 198 + InvalidCid { 199 + /// Invalid CID string. 200 + cid: String, 201 + }, 202 + }
+384
crates/atproto-attestation/src/input.rs
···
··· 1 + //! Input types for attestation functions supporting multiple input formats. 2 + 3 + use serde::Serialize; 4 + use serde_json::{Map, Value}; 5 + use std::convert::TryFrom; 6 + use std::str::FromStr; 7 + use thiserror::Error; 8 + 9 + /// Flexible input type for attestation functions. 10 + /// 11 + /// Allows passing records and metadata as JSON strings or any serde serializable types. 12 + #[derive(Clone)] 13 + pub enum AnyInput<S: Serialize + Clone> { 14 + /// JSON string representation 15 + String(String), 16 + /// Serializable types 17 + Serialize(S), 18 + } 19 + 20 + /// Error types for AnyInput parsing and transformation operations. 21 + /// 22 + /// This enum provides specific error types for various failure modes when working 23 + /// with `AnyInput`, including JSON parsing errors, type conversion errors, and 24 + /// serialization failures. 25 + #[derive(Debug, Error)] 26 + pub enum AnyInputError { 27 + /// Error when parsing JSON from a string fails. 28 + #[error("Failed to parse JSON from string: {0}")] 29 + JsonParseError(#[from] serde_json::Error), 30 + 31 + /// Error when the value is not a JSON object. 32 + #[error("Expected JSON object, but got {value_type}")] 33 + NotAnObject { 34 + /// The actual type of the value. 35 + value_type: String, 36 + }, 37 + 38 + /// Error when the string contains invalid JSON. 39 + #[error("Invalid JSON string: {message}")] 40 + InvalidJson { 41 + /// Error message describing what's wrong with the JSON. 42 + message: String, 43 + }, 44 + } 45 + 46 + impl AnyInputError { 47 + /// Creates a new `NotAnObject` error with the actual type information. 48 + pub fn not_an_object(value: &Value) -> Self { 49 + let value_type = match value { 50 + Value::Null => "null".to_string(), 51 + Value::Bool(_) => "boolean".to_string(), 52 + Value::Number(_) => "number".to_string(), 53 + Value::String(_) => "string".to_string(), 54 + Value::Array(_) => "array".to_string(), 55 + Value::Object(_) => "object".to_string(), // Should not happen 56 + }; 57 + 58 + AnyInputError::NotAnObject { value_type } 59 + } 60 + } 61 + 62 + /// Implementation of `FromStr` for `AnyInput` that deserializes JSON strings. 63 + /// 64 + /// This allows parsing JSON strings directly into `AnyInput<serde_json::Value>` using 65 + /// the standard `FromStr` trait. The string is deserialized using `serde_json::from_str` 66 + /// and wrapped in `AnyInput::Serialize`. 67 + /// 68 + /// # Errors 69 + /// 70 + /// Returns `AnyInputError::JsonParseError` if the string contains invalid JSON. 71 + /// 72 + /// # Example 73 + /// 74 + /// ``` 75 + /// use atproto_attestation::input::AnyInput; 76 + /// use std::str::FromStr; 77 + /// 78 + /// let input: AnyInput<serde_json::Value> = r#"{"type": "post", "text": "Hello"}"#.parse().unwrap(); 79 + /// ``` 80 + impl FromStr for AnyInput<serde_json::Value> { 81 + type Err = AnyInputError; 82 + 83 + fn from_str(s: &str) -> Result<Self, Self::Err> { 84 + let value = serde_json::from_str(s)?; 85 + Ok(AnyInput::Serialize(value)) 86 + } 87 + } 88 + 89 + impl<S: Serialize + Clone> From<S> for AnyInput<S> { 90 + fn from(value: S) -> Self { 91 + AnyInput::Serialize(value) 92 + } 93 + } 94 + 95 + /// Implementation of `TryFrom` for converting `AnyInput` into a JSON object map. 96 + /// 97 + /// This allows converting any `AnyInput` into a `serde_json::Map<String, Value>`, which 98 + /// represents a JSON object. Both string and serializable inputs are converted to JSON 99 + /// objects, with appropriate error handling for non-object values. 100 + /// 101 + /// # Example 102 + /// 103 + /// ``` 104 + /// use atproto_attestation::input::AnyInput; 105 + /// use serde_json::{json, Map, Value}; 106 + /// use std::convert::TryInto; 107 + /// 108 + /// let input = AnyInput::Serialize(json!({"type": "post", "text": "Hello"})); 109 + /// let map: Map<String, Value> = input.try_into().unwrap(); 110 + /// assert_eq!(map.get("type").unwrap(), "post"); 111 + /// ``` 112 + impl<S: Serialize + Clone> TryFrom<AnyInput<S>> for Map<String, Value> { 113 + type Error = AnyInputError; 114 + 115 + fn try_from(input: AnyInput<S>) -> Result<Self, Self::Error> { 116 + match input { 117 + AnyInput::String(value) => { 118 + // Parse string as JSON 119 + let json_value = serde_json::from_str::<Value>(&value)?; 120 + 121 + // Extract as object 122 + json_value 123 + .as_object() 124 + .cloned() 125 + .ok_or_else(|| AnyInputError::not_an_object(&json_value)) 126 + } 127 + AnyInput::Serialize(value) => { 128 + // Convert to JSON value 129 + let json_value = serde_json::to_value(value)?; 130 + 131 + // Extract as object 132 + json_value 133 + .as_object() 134 + .cloned() 135 + .ok_or_else(|| AnyInputError::not_an_object(&json_value)) 136 + } 137 + } 138 + } 139 + } 140 + 141 + /// Default phantom type for AnyInput when no specific lexicon type is needed. 142 + /// 143 + /// This type serves as the default generic parameter for `AnyInput`, allowing 144 + /// for simpler usage when working with untyped JSON values. 145 + #[derive(Serialize, PartialEq, Clone)] 146 + pub struct PhantomSignature {} 147 + 148 + #[cfg(test)] 149 + mod tests { 150 + use super::*; 151 + 152 + #[test] 153 + fn test_from_str_valid_json() { 154 + let json_str = r#"{"type": "post", "text": "Hello", "count": 42}"#; 155 + let result: Result<AnyInput<serde_json::Value>, _> = json_str.parse(); 156 + 157 + assert!(result.is_ok()); 158 + 159 + let input = result.unwrap(); 160 + match input { 161 + AnyInput::Serialize(value) => { 162 + assert_eq!(value["type"], "post"); 163 + assert_eq!(value["text"], "Hello"); 164 + assert_eq!(value["count"], 42); 165 + } 166 + _ => panic!("Expected AnyInput::Serialize variant"), 167 + } 168 + } 169 + 170 + #[test] 171 + fn test_from_str_invalid_json() { 172 + let invalid_json = r#"{"type": "post", "text": "Hello" invalid json"#; 173 + let result: Result<AnyInput<serde_json::Value>, _> = invalid_json.parse(); 174 + 175 + assert!(result.is_err()); 176 + } 177 + 178 + #[test] 179 + fn test_from_str_array() { 180 + let json_array = r#"[1, 2, 3, "four"]"#; 181 + let result: Result<AnyInput<serde_json::Value>, _> = json_array.parse(); 182 + 183 + assert!(result.is_ok()); 184 + 185 + let input = result.unwrap(); 186 + match input { 187 + AnyInput::Serialize(value) => { 188 + assert!(value.is_array()); 189 + let array = value.as_array().unwrap(); 190 + assert_eq!(array.len(), 4); 191 + assert_eq!(array[0], 1); 192 + assert_eq!(array[3], "four"); 193 + } 194 + _ => panic!("Expected AnyInput::Serialize variant"), 195 + } 196 + } 197 + 198 + #[test] 199 + fn test_from_str_null() { 200 + let null_str = "null"; 201 + let result: Result<AnyInput<serde_json::Value>, _> = null_str.parse(); 202 + 203 + assert!(result.is_ok()); 204 + 205 + let input = result.unwrap(); 206 + match input { 207 + AnyInput::Serialize(value) => { 208 + assert!(value.is_null()); 209 + } 210 + _ => panic!("Expected AnyInput::Serialize variant"), 211 + } 212 + } 213 + 214 + #[test] 215 + fn test_from_str_with_use() { 216 + // Test using the parse method directly with type inference 217 + let input: AnyInput<serde_json::Value> = r#"{"$type": "app.bsky.feed.post"}"# 218 + .parse() 219 + .expect("Failed to parse JSON"); 220 + 221 + match input { 222 + AnyInput::Serialize(value) => { 223 + assert_eq!(value["$type"], "app.bsky.feed.post"); 224 + } 225 + _ => panic!("Expected AnyInput::Serialize variant"), 226 + } 227 + } 228 + 229 + #[test] 230 + fn test_try_into_from_string() { 231 + use std::convert::TryInto; 232 + 233 + let input = AnyInput::<Value>::String(r#"{"type": "post", "text": "Hello"}"#.to_string()); 234 + let result: Result<Map<String, Value>, _> = input.try_into(); 235 + 236 + assert!(result.is_ok()); 237 + let map = result.unwrap(); 238 + assert_eq!(map.get("type").unwrap(), "post"); 239 + assert_eq!(map.get("text").unwrap(), "Hello"); 240 + } 241 + 242 + #[test] 243 + fn test_try_into_from_serialize() { 244 + use serde_json::json; 245 + use std::convert::TryInto; 246 + 247 + let input = AnyInput::Serialize(json!({"$type": "app.bsky.feed.post", "count": 42})); 248 + let result: Result<Map<String, Value>, _> = input.try_into(); 249 + 250 + assert!(result.is_ok()); 251 + let map = result.unwrap(); 252 + assert_eq!(map.get("$type").unwrap(), "app.bsky.feed.post"); 253 + assert_eq!(map.get("count").unwrap(), 42); 254 + } 255 + 256 + #[test] 257 + fn test_try_into_string_not_object() { 258 + use std::convert::TryInto; 259 + 260 + let input = AnyInput::<Value>::String(r#"["array", "not", "object"]"#.to_string()); 261 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 262 + 263 + assert!(result.is_err()); 264 + match result.unwrap_err() { 265 + AnyInputError::NotAnObject { value_type } => { 266 + assert_eq!(value_type, "array"); 267 + } 268 + _ => panic!("Expected NotAnObject error"), 269 + } 270 + } 271 + 272 + #[test] 273 + fn test_try_into_serialize_not_object() { 274 + use serde_json::json; 275 + use std::convert::TryInto; 276 + 277 + let input = AnyInput::Serialize(json!([1, 2, 3])); 278 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 279 + 280 + assert!(result.is_err()); 281 + match result.unwrap_err() { 282 + AnyInputError::NotAnObject { value_type } => { 283 + assert_eq!(value_type, "array"); 284 + } 285 + _ => panic!("Expected NotAnObject error"), 286 + } 287 + } 288 + 289 + #[test] 290 + fn test_try_into_invalid_json_string() { 291 + use std::convert::TryInto; 292 + 293 + let input = AnyInput::<Value>::String("not valid json".to_string()); 294 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 295 + 296 + assert!(result.is_err()); 297 + match result.unwrap_err() { 298 + AnyInputError::JsonParseError(_) => {} 299 + _ => panic!("Expected JsonParseError"), 300 + } 301 + } 302 + 303 + #[test] 304 + fn test_try_into_null() { 305 + use serde_json::json; 306 + use std::convert::TryInto; 307 + 308 + let input = AnyInput::Serialize(json!(null)); 309 + let result: Result<Map<String, Value>, AnyInputError> = input.try_into(); 310 + 311 + assert!(result.is_err()); 312 + match result.unwrap_err() { 313 + AnyInputError::NotAnObject { value_type } => { 314 + assert_eq!(value_type, "null"); 315 + } 316 + _ => panic!("Expected NotAnObject error"), 317 + } 318 + } 319 + 320 + #[test] 321 + fn test_any_input_error_not_an_object() { 322 + use serde_json::json; 323 + 324 + // Test null 325 + let err = AnyInputError::not_an_object(&json!(null)); 326 + match err { 327 + AnyInputError::NotAnObject { value_type } => { 328 + assert_eq!(value_type, "null"); 329 + } 330 + _ => panic!("Expected NotAnObject error"), 331 + } 332 + 333 + // Test boolean 334 + let err = AnyInputError::not_an_object(&json!(true)); 335 + match err { 336 + AnyInputError::NotAnObject { value_type } => { 337 + assert_eq!(value_type, "boolean"); 338 + } 339 + _ => panic!("Expected NotAnObject error"), 340 + } 341 + 342 + // Test number 343 + let err = AnyInputError::not_an_object(&json!(42)); 344 + match err { 345 + AnyInputError::NotAnObject { value_type } => { 346 + assert_eq!(value_type, "number"); 347 + } 348 + _ => panic!("Expected NotAnObject error"), 349 + } 350 + 351 + // Test string 352 + let err = AnyInputError::not_an_object(&json!("hello")); 353 + match err { 354 + AnyInputError::NotAnObject { value_type } => { 355 + assert_eq!(value_type, "string"); 356 + } 357 + _ => panic!("Expected NotAnObject error"), 358 + } 359 + 360 + // Test array 361 + let err = AnyInputError::not_an_object(&json!([1, 2, 3])); 362 + match err { 363 + AnyInputError::NotAnObject { value_type } => { 364 + assert_eq!(value_type, "array"); 365 + } 366 + _ => panic!("Expected NotAnObject error"), 367 + } 368 + } 369 + 370 + #[test] 371 + fn test_error_display() { 372 + use serde_json::json; 373 + 374 + // Test NotAnObject error display 375 + let err = AnyInputError::not_an_object(&json!(42)); 376 + assert_eq!(err.to_string(), "Expected JSON object, but got number"); 377 + 378 + // Test InvalidJson display 379 + let err = AnyInputError::InvalidJson { 380 + message: "unexpected token".to_string() 381 + }; 382 + assert_eq!(err.to_string(), "Invalid JSON string: unexpected token"); 383 + } 384 + }
+74
crates/atproto-attestation/src/lib.rs
···
··· 1 + //! AT Protocol record attestation utilities based on the CID-first specification. 2 + //! 3 + //! This crate implements helpers for creating inline and remote attestations 4 + //! and verifying signatures against DID verification methods. It follows the 5 + //! requirements documented in `bluesky-attestation-tee/documentation/spec/attestation.md`. 6 + //! 7 + //! ## Inline Attestations 8 + //! 9 + //! Use `create_inline_attestation` to create a signed record with an embedded signature: 10 + //! 11 + //! ```no_run 12 + //! use atproto_attestation::{create_inline_attestation, AnyInput}; 13 + //! use atproto_identity::key::{generate_key, KeyType}; 14 + //! use serde_json::json; 15 + //! 16 + //! # fn main() -> Result<(), Box<dyn std::error::Error>> { 17 + //! let key = generate_key(KeyType::P256Private)?; 18 + //! let record = json!({"$type": "app.example.post", "text": "Hello!"}); 19 + //! let metadata = json!({"$type": "com.example.sig", "key": "did:key:..."}); 20 + //! 21 + //! let signed = create_inline_attestation( 22 + //! AnyInput::Serialize(record), 23 + //! AnyInput::Serialize(metadata), 24 + //! "did:plc:repository", 25 + //! &key 26 + //! )?; 27 + //! # Ok(()) 28 + //! # } 29 + //! ``` 30 + //! 31 + //! ## Remote Attestations 32 + //! 33 + //! Use `create_remote_attestation` to generate both the proof record and the 34 + //! attested record with strongRef in a single call. 35 + 36 + #![forbid(unsafe_code)] 37 + #![warn(missing_docs)] 38 + 39 + // Public modules 40 + pub mod cid; 41 + pub mod errors; 42 + pub mod input; 43 + 44 + // Internal modules 45 + mod attestation; 46 + mod signature; 47 + mod utils; 48 + mod verification; 49 + 50 + // Re-export error type 51 + pub use errors::AttestationError; 52 + 53 + // Re-export CID generation functions 54 + pub use cid::{create_dagbor_cid}; 55 + 56 + // Re-export signature normalization 57 + pub use signature::normalize_signature; 58 + 59 + // Re-export attestation functions 60 + pub use attestation::{ 61 + append_inline_attestation, append_remote_attestation, create_inline_attestation, 62 + create_remote_attestation, create_signature, 63 + }; 64 + 65 + // Re-export input types 66 + pub use input::{AnyInput, AnyInputError}; 67 + 68 + // Re-export verification functions 69 + pub use verification::verify_record; 70 + 71 + /// Resolver trait for retrieving remote attestation records by AT URI. 72 + /// 73 + /// This trait is re-exported from atproto_client for convenience. 74 + pub use atproto_client::record_resolver::RecordResolver;
+98
crates/atproto-attestation/src/signature.rs
···
··· 1 + //! ECDSA signature normalization. 2 + //! 3 + //! This module handles signature normalization to the low-S form required by 4 + //! the AT Protocol attestation specification, preventing signature malleability attacks. 5 + 6 + use crate::errors::AttestationError; 7 + use atproto_identity::key::KeyType; 8 + use k256::ecdsa::Signature as K256Signature; 9 + use p256::ecdsa::Signature as P256Signature; 10 + 11 + /// Normalize raw signature bytes to the required low-S form. 12 + /// 13 + /// This helper ensures signatures produced by signing APIs comply with the 14 + /// specification requirements before embedding them in attestation objects. 15 + /// 16 + /// # Arguments 17 + /// 18 + /// * `signature` - The raw signature bytes to normalize 19 + /// * `key_type` - The type of key used to create the signature 20 + /// 21 + /// # Returns 22 + /// 23 + /// The normalized signature bytes in low-S form 24 + /// 25 + /// # Errors 26 + /// 27 + /// Returns an error if: 28 + /// - The signature length is invalid for the key type 29 + /// - The key type is not supported 30 + pub fn normalize_signature( 31 + signature: Vec<u8>, 32 + key_type: &KeyType, 33 + ) -> Result<Vec<u8>, AttestationError> { 34 + match key_type { 35 + KeyType::P256Private | KeyType::P256Public => normalize_p256(signature), 36 + KeyType::K256Private | KeyType::K256Public => normalize_k256(signature), 37 + other => Err(AttestationError::UnsupportedKeyType { 38 + key_type: (*other).clone(), 39 + }), 40 + } 41 + } 42 + 43 + /// Normalize a P-256 signature to low-S form. 44 + fn normalize_p256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 45 + if signature.len() != 64 { 46 + return Err(AttestationError::SignatureLengthInvalid { 47 + expected: 64, 48 + actual: signature.len(), 49 + }); 50 + } 51 + 52 + let parsed = P256Signature::from_slice(&signature).map_err(|_| { 53 + AttestationError::SignatureLengthInvalid { 54 + expected: 64, 55 + actual: signature.len(), 56 + } 57 + })?; 58 + 59 + let normalized = parsed.normalize_s().unwrap_or(parsed); 60 + 61 + Ok(normalized.to_vec()) 62 + } 63 + 64 + /// Normalize a K-256 signature to low-S form. 65 + fn normalize_k256(signature: Vec<u8>) -> Result<Vec<u8>, AttestationError> { 66 + if signature.len() != 64 { 67 + return Err(AttestationError::SignatureLengthInvalid { 68 + expected: 64, 69 + actual: signature.len(), 70 + }); 71 + } 72 + 73 + let parsed = K256Signature::from_slice(&signature).map_err(|_| { 74 + AttestationError::SignatureLengthInvalid { 75 + expected: 64, 76 + actual: signature.len(), 77 + } 78 + })?; 79 + 80 + let normalized = parsed.normalize_s().unwrap_or(parsed); 81 + 82 + Ok(normalized.to_vec()) 83 + } 84 + 85 + #[cfg(test)] 86 + mod tests { 87 + use super::*; 88 + 89 + #[test] 90 + fn reject_invalid_signature_length() { 91 + let short_signature = vec![0u8; 32]; 92 + let result = normalize_p256(short_signature); 93 + assert!(matches!( 94 + result, 95 + Err(AttestationError::SignatureLengthInvalid { expected: 64, .. }) 96 + )); 97 + } 98 + }
+22
crates/atproto-attestation/src/utils.rs
···
··· 1 + //! Utility functions and constants for attestation operations. 2 + //! 3 + //! This module provides common utilities used throughout the attestation framework, 4 + //! including base64 encoding/decoding with flexible padding support. 5 + 6 + use base64::{ 7 + alphabet::STANDARD as STANDARD_ALPHABET, 8 + engine::{ 9 + DecodePaddingMode, 10 + general_purpose::{GeneralPurpose, GeneralPurposeConfig}, 11 + }, 12 + }; 13 + 14 + /// Base64 engine that accepts both padded and unpadded input for maximum compatibility 15 + /// with various AT Protocol implementations. Uses standard encoding with padding for output, 16 + /// but accepts any padding format for decoding. 17 + pub(crate) const BASE64: GeneralPurpose = GeneralPurpose::new( 18 + &STANDARD_ALPHABET, 19 + GeneralPurposeConfig::new() 20 + .with_encode_padding(true) 21 + .with_decode_padding_mode(DecodePaddingMode::Indifferent), 22 + );
+160
crates/atproto-attestation/src/verification.rs
···
··· 1 + //! Signature verification for AT Protocol attestations. 2 + //! 3 + //! This module provides verification functions for AT Protocol record attestations. 4 + 5 + use crate::cid::create_attestation_cid; 6 + use crate::errors::AttestationError; 7 + use crate::input::AnyInput; 8 + use crate::utils::BASE64; 9 + use atproto_identity::key::{KeyResolver, validate}; 10 + use atproto_record::lexicon::com::atproto::repo::STRONG_REF_NSID; 11 + use base64::Engine; 12 + use serde::Serialize; 13 + use serde_json::{Value, Map}; 14 + use std::convert::TryInto; 15 + 16 + /// Helper function to extract and validate signatures array from a record 17 + fn extract_signatures(record_object: &Map<String, Value>) -> Result<Vec<Value>, AttestationError> { 18 + match record_object.get("signatures") { 19 + Some(value) => value 20 + .as_array() 21 + .ok_or(AttestationError::SignaturesFieldInvalid) 22 + .cloned(), 23 + None => Ok(vec![]), 24 + } 25 + } 26 + 27 + /// Verify all signatures in a record with flexible input types. 28 + /// 29 + /// This is a high-level verification function that accepts records in multiple formats 30 + /// (String, Json, or TypedLexicon) and verifies all signatures with custom resolvers. 31 + /// 32 + /// # Arguments 33 + /// 34 + /// * `verify_input` - The record to verify (as AnyInput: String, Json, or TypedLexicon) 35 + /// * `repository` - The repository DID to validate against (prevents replay attacks) 36 + /// * `key_resolver` - Resolver for looking up verification keys from DIDs 37 + /// * `record_resolver` - Resolver for fetching remote attestation proof records 38 + /// 39 + /// # Returns 40 + /// 41 + /// Returns `Ok(())` if all signatures are valid, or an error if any verification fails. 42 + /// 43 + /// # Errors 44 + /// 45 + /// Returns an error if: 46 + /// - The input is not a valid record object 47 + /// - Any signature verification fails 48 + /// - Key or record resolution fails 49 + /// 50 + /// # Type Parameters 51 + /// 52 + /// * `R` - The record type (must implement Serialize + LexiconType + PartialEq + Clone) 53 + /// * `RR` - The record resolver type (must implement RecordResolver) 54 + /// * `KR` - The key resolver type (must implement KeyResolver) 55 + pub async fn verify_record<R, RR, KR>( 56 + verify_input: AnyInput<R>, 57 + repository: &str, 58 + key_resolver: KR, 59 + record_resolver: RR, 60 + ) -> Result<(), AttestationError> 61 + where 62 + R: Serialize + Clone, 63 + RR: atproto_client::record_resolver::RecordResolver, 64 + KR: KeyResolver, 65 + { 66 + let record_object: Map<String, Value> = verify_input 67 + .clone() 68 + .try_into() 69 + .map_err(|_| AttestationError::RecordMustBeObject)?; 70 + 71 + let signatures = extract_signatures(&record_object)?; 72 + 73 + if signatures.is_empty() { 74 + return Ok(()); 75 + } 76 + 77 + for signature in signatures { 78 + let signature_refernce_type = signature 79 + .get("$type") 80 + .and_then(Value::as_str) 81 + .filter(|value| !value.is_empty()) 82 + .ok_or(AttestationError::SigMetadataMissingType)?; 83 + 84 + let metadata = if signature_refernce_type == STRONG_REF_NSID { 85 + let aturi = signature 86 + .get("uri") 87 + .and_then(Value::as_str) 88 + .filter(|value| !value.is_empty()) 89 + .ok_or(AttestationError::SignatureMissingField { 90 + field: "uri".to_string(), 91 + })?; 92 + 93 + record_resolver 94 + .resolve::<serde_json::Value>(aturi) 95 + .await 96 + .map_err(|error| AttestationError::RemoteAttestationFetchFailed { 97 + uri: aturi.to_string(), 98 + error, 99 + })? 100 + } else { 101 + signature.clone() 102 + }; 103 + 104 + let computed_cid = create_attestation_cid( 105 + verify_input.clone(), 106 + AnyInput::Serialize(metadata.clone()), 107 + repository, 108 + )?; 109 + 110 + if signature_refernce_type == STRONG_REF_NSID { 111 + let attestation_cid = metadata 112 + .get("cid") 113 + .and_then(Value::as_str) 114 + .filter(|value| !value.is_empty()) 115 + .ok_or(AttestationError::SignatureMissingField { 116 + field: "cid".to_string(), 117 + })?; 118 + 119 + if computed_cid.to_string() != attestation_cid { 120 + return Err(AttestationError::RemoteAttestationCidMismatch { 121 + expected: attestation_cid.to_string(), 122 + actual: computed_cid.to_string(), 123 + }); 124 + } 125 + continue; 126 + } 127 + 128 + let key = metadata 129 + .get("key") 130 + .and_then(Value::as_str) 131 + .filter(|value| !value.is_empty()) 132 + .ok_or(AttestationError::SignatureMissingField { 133 + field: "key".to_string(), 134 + })?; 135 + let key_data = key_resolver.resolve(key).await.map_err(|error| { 136 + AttestationError::KeyResolutionFailed { 137 + key: key.to_string(), 138 + error, 139 + } 140 + })?; 141 + 142 + let signature_bytes = metadata 143 + .get("signature") 144 + .and_then(Value::as_object) 145 + .and_then(|object| object.get("$bytes")) 146 + .and_then(Value::as_str) 147 + .ok_or(AttestationError::SignatureBytesFormatInvalid)?; 148 + 149 + let signature_bytes = BASE64 150 + .decode(signature_bytes) 151 + .map_err(|error| AttestationError::SignatureDecodingFailed { error })?; 152 + 153 + let computed_cid_bytes = computed_cid.to_bytes(); 154 + 155 + validate(&key_data, &signature_bytes, &computed_cid_bytes) 156 + .map_err(|error| AttestationError::SignatureValidationFailed { error })?; 157 + } 158 + 159 + Ok(()) 160 + }
+8 -1
crates/atproto-client/Cargo.toml
··· 35 doc = true 36 required-features = ["clap"] 37 38 [dependencies] 39 atproto-identity.workspace = true 40 - atproto-record.workspace = true 41 atproto-oauth.workspace = true 42 43 anyhow.workspace = true 44 reqwest-chain.workspace = true ··· 50 tokio.workspace = true 51 tracing.workspace = true 52 urlencoding = "2.1.3" 53 bytes = "1.10.1" 54 clap = { workspace = true, optional = true } 55 rpassword = { workspace = true, optional = true }
··· 35 doc = true 36 required-features = ["clap"] 37 38 + [[bin]] 39 + name = "atproto-client-put-record" 40 + test = false 41 + bench = false 42 + doc = true 43 + 44 [dependencies] 45 atproto-identity.workspace = true 46 atproto-oauth.workspace = true 47 + atproto-record.workspace = true 48 49 anyhow.workspace = true 50 reqwest-chain.workspace = true ··· 56 tokio.workspace = true 57 tracing.workspace = true 58 urlencoding = "2.1.3" 59 + async-trait.workspace = true 60 bytes = "1.10.1" 61 clap = { workspace = true, optional = true } 62 rpassword = { workspace = true, optional = true }
+165
crates/atproto-client/src/bin/atproto-client-put-record.rs
···
··· 1 + //! AT Protocol client tool for writing records to a repository. 2 + //! 3 + //! This binary tool creates or updates records in an AT Protocol repository 4 + //! using app password authentication. It resolves the subject to a DID, 5 + //! creates a session, and writes the record using the putRecord XRPC method. 6 + //! 7 + //! # Usage 8 + //! 9 + //! ```text 10 + //! ATPROTO_PASSWORD=<password> atproto-client-put-record <subject> <record_key> <record_json> 11 + //! ``` 12 + //! 13 + //! # Environment Variables 14 + //! 15 + //! - `ATPROTO_PASSWORD` - Required. App password for authentication. 16 + //! - `CERTIFICATE_BUNDLES` - Custom CA certificate bundles. 17 + //! - `USER_AGENT` - Custom user agent string. 18 + //! - `DNS_NAMESERVERS` - Custom DNS nameservers. 19 + //! - `PLC_HOSTNAME` - Override PLC hostname (default: plc.directory). 20 + 21 + use anyhow::Result; 22 + use atproto_client::{ 23 + client::{AppPasswordAuth, Auth}, 24 + com::atproto::{ 25 + repo::{put_record, PutRecordRequest, PutRecordResponse}, 26 + server::create_session, 27 + }, 28 + errors::CliError, 29 + }; 30 + use atproto_identity::{ 31 + config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 32 + plc, 33 + resolve::{HickoryDnsResolver, resolve_subject}, 34 + web, 35 + }; 36 + use std::env; 37 + 38 + fn print_usage() { 39 + eprintln!("Usage: atproto-client-put-record <subject> <record_key> <record_json>"); 40 + eprintln!(); 41 + eprintln!("Arguments:"); 42 + eprintln!(" <subject> Handle or DID of the repository owner"); 43 + eprintln!(" <record_key> Record key (rkey) for the record"); 44 + eprintln!(" <record_json> JSON record data (must include $type field)"); 45 + eprintln!(); 46 + eprintln!("Environment Variables:"); 47 + eprintln!(" ATPROTO_PASSWORD Required. App password for authentication."); 48 + eprintln!(" CERTIFICATE_BUNDLES Custom CA certificate bundles."); 49 + eprintln!(" USER_AGENT Custom user agent string."); 50 + eprintln!(" DNS_NAMESERVERS Custom DNS nameservers."); 51 + eprintln!(" PLC_HOSTNAME Override PLC hostname (default: plc.directory)."); 52 + } 53 + 54 + #[tokio::main] 55 + async fn main() -> Result<()> { 56 + let args: Vec<String> = env::args().collect(); 57 + 58 + if args.len() != 4 { 59 + print_usage(); 60 + std::process::exit(1); 61 + } 62 + 63 + let subject = &args[1]; 64 + let record_key = &args[2]; 65 + let record_json = &args[3]; 66 + 67 + // Get password from environment variable 68 + let password = env::var("ATPROTO_PASSWORD").map_err(|_| { 69 + anyhow::anyhow!("ATPROTO_PASSWORD environment variable is required") 70 + })?; 71 + 72 + // Set up HTTP client configuration 73 + let certificate_bundles: CertificateBundles = optional_env("CERTIFICATE_BUNDLES").try_into()?; 74 + let default_user_agent = format!( 75 + "atproto-identity-rs ({}; +https://tangled.sh/@smokesignal.events/atproto-identity-rs)", 76 + version()? 77 + ); 78 + let user_agent = default_env("USER_AGENT", &default_user_agent); 79 + let dns_nameservers: DnsNameservers = optional_env("DNS_NAMESERVERS").try_into()?; 80 + let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); 81 + 82 + let mut client_builder = reqwest::Client::builder(); 83 + for ca_certificate in certificate_bundles.as_ref() { 84 + let cert = std::fs::read(ca_certificate)?; 85 + let cert = reqwest::Certificate::from_pem(&cert)?; 86 + client_builder = client_builder.add_root_certificate(cert); 87 + } 88 + 89 + client_builder = client_builder.user_agent(user_agent); 90 + let http_client = client_builder.build()?; 91 + 92 + let dns_resolver = HickoryDnsResolver::create_resolver(dns_nameservers.as_ref()); 93 + 94 + // Parse the record JSON 95 + let record: serde_json::Value = serde_json::from_str(record_json).map_err(|err| { 96 + tracing::error!(error = ?err, "Failed to parse record JSON"); 97 + anyhow::anyhow!("Failed to parse record JSON: {}", err) 98 + })?; 99 + 100 + // Extract collection from $type field 101 + let collection = record 102 + .get("$type") 103 + .and_then(|v| v.as_str()) 104 + .ok_or_else(|| anyhow::anyhow!("Record must contain a $type field for the collection"))? 105 + .to_string(); 106 + 107 + // Resolve subject to DID 108 + let did = resolve_subject(&http_client, &dns_resolver, subject).await?; 109 + 110 + // Get DID document to find PDS endpoint 111 + let document = if did.starts_with("did:plc:") { 112 + plc::query(&http_client, &plc_hostname, &did).await? 113 + } else if did.starts_with("did:web:") { 114 + web::query(&http_client, &did).await? 115 + } else { 116 + anyhow::bail!("Unsupported DID method: {}", did); 117 + }; 118 + 119 + // Get PDS endpoint from the DID document 120 + let pds_endpoints = document.pds_endpoints(); 121 + let pds_endpoint = pds_endpoints 122 + .first() 123 + .ok_or_else(|| CliError::NoPdsEndpointFound { did: did.clone() })?; 124 + 125 + // Create session 126 + let session = create_session(&http_client, pds_endpoint, &did, &password, None).await?; 127 + 128 + // Set up app password authentication 129 + let auth = Auth::AppPassword(AppPasswordAuth { 130 + access_token: session.access_jwt.clone(), 131 + }); 132 + 133 + // Create put record request 134 + let put_request = PutRecordRequest { 135 + repo: session.did.clone(), 136 + collection, 137 + record_key: record_key.clone(), 138 + validate: true, 139 + record, 140 + swap_commit: None, 141 + swap_record: None, 142 + }; 143 + 144 + // Execute put record 145 + let response = put_record(&http_client, &auth, pds_endpoint, put_request).await?; 146 + 147 + match response { 148 + PutRecordResponse::StrongRef { uri, cid, .. } => { 149 + println!( 150 + "{}", 151 + serde_json::to_string_pretty(&serde_json::json!({ 152 + "uri": uri, 153 + "cid": cid 154 + }))? 155 + ); 156 + } 157 + PutRecordResponse::Error(err) => { 158 + let error_message = err.error_message(); 159 + tracing::error!(error = %error_message, "putRecord failed"); 160 + anyhow::bail!("putRecord failed: {}", error_message); 161 + } 162 + } 163 + 164 + Ok(()) 165 + }
+112 -3
crates/atproto-client/src/client.rs
··· 17 /// 18 /// Contains the private key for DPoP proof generation and OAuth access token 19 /// for Authorization header. 20 pub struct DPoPAuth { 21 /// Private key data for generating DPoP proof tokens 22 pub dpop_private_key_data: KeyData, ··· 27 /// App password authentication credentials for authenticated HTTP requests. 28 /// 29 /// Contains the JWT access token for Bearer token authentication. 30 pub struct AppPasswordAuth { 31 /// JWT access token for the Authorization header 32 pub access_token: String, ··· 36 /// 37 /// Supports multiple authentication schemes including unauthenticated requests, 38 /// DPoP (Demonstration of Proof-of-Possession) tokens, and app password bearer tokens. 39 pub enum Auth { 40 /// No authentication - for public endpoints that don't require authentication 41 None, ··· 394 Ok(value) 395 } 396 397 /// Performs an unauthenticated HTTP POST request with JSON body and parses the response as JSON. 398 /// 399 /// # Arguments ··· 690 http_client: &reqwest::Client, 691 app_auth: &AppPasswordAuth, 692 url: &str, 693 - record: serde_json::Value, 694 additional_headers: &HeaderMap, 695 ) -> Result<Bytes> { 696 let mut headers = additional_headers.clone(); ··· 701 let http_response = http_client 702 .post(url) 703 .headers(headers) 704 - .json(&record) 705 .send() 706 - .instrument(tracing::info_span!("get_apppassword_bytes_with_headers", url = %url)) 707 .await 708 .map_err(|error| ClientError::HttpRequestFailed { 709 url: url.to_string(),
··· 17 /// 18 /// Contains the private key for DPoP proof generation and OAuth access token 19 /// for Authorization header. 20 + #[derive(Clone)] 21 pub struct DPoPAuth { 22 /// Private key data for generating DPoP proof tokens 23 pub dpop_private_key_data: KeyData, ··· 28 /// App password authentication credentials for authenticated HTTP requests. 29 /// 30 /// Contains the JWT access token for Bearer token authentication. 31 + #[derive(Clone)] 32 pub struct AppPasswordAuth { 33 /// JWT access token for the Authorization header 34 pub access_token: String, ··· 38 /// 39 /// Supports multiple authentication schemes including unauthenticated requests, 40 /// DPoP (Demonstration of Proof-of-Possession) tokens, and app password bearer tokens. 41 + #[derive(Clone)] 42 pub enum Auth { 43 /// No authentication - for public endpoints that don't require authentication 44 None, ··· 397 Ok(value) 398 } 399 400 + /// Performs a DPoP-authenticated HTTP POST request with raw bytes body and additional headers, and parses the response as JSON. 401 + /// 402 + /// This function is similar to `post_dpop_json_with_headers` but accepts a raw bytes payload 403 + /// instead of JSON. Useful for sending pre-serialized data or binary payloads while maintaining 404 + /// DPoP authentication and custom headers. 405 + /// 406 + /// # Arguments 407 + /// 408 + /// * `http_client` - The HTTP client to use for the request 409 + /// * `dpop_auth` - DPoP authentication credentials 410 + /// * `url` - The URL to request 411 + /// * `payload` - The raw bytes to send in the request body 412 + /// * `additional_headers` - Additional HTTP headers to include in the request 413 + /// 414 + /// # Returns 415 + /// 416 + /// The parsed JSON response as a `serde_json::Value` 417 + /// 418 + /// # Errors 419 + /// 420 + /// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails, 421 + /// `DPoPError::HttpRequestFailed` if the HTTP request fails, 422 + /// or `DPoPError::JsonParseFailed` if JSON parsing fails. 423 + /// 424 + /// # Example 425 + /// 426 + /// ```no_run 427 + /// use atproto_client::client::{DPoPAuth, post_dpop_bytes_with_headers}; 428 + /// use atproto_identity::key::identify_key; 429 + /// use reqwest::{Client, header::{HeaderMap, CONTENT_TYPE}}; 430 + /// use bytes::Bytes; 431 + /// 432 + /// # async fn example() -> anyhow::Result<()> { 433 + /// let client = Client::new(); 434 + /// let dpop_auth = DPoPAuth { 435 + /// dpop_private_key_data: identify_key("did:key:zQ3sh...")?, 436 + /// oauth_access_token: "access_token".to_string(), 437 + /// }; 438 + /// 439 + /// let mut headers = HeaderMap::new(); 440 + /// headers.insert(CONTENT_TYPE, "application/json".parse()?); 441 + /// 442 + /// let payload = Bytes::from(r#"{"text": "Hello!"}"#); 443 + /// let response = post_dpop_bytes_with_headers( 444 + /// &client, 445 + /// &dpop_auth, 446 + /// "https://pds.example.com/xrpc/com.atproto.repo.createRecord", 447 + /// payload, 448 + /// &headers 449 + /// ).await?; 450 + /// # Ok(()) 451 + /// # } 452 + /// ``` 453 + pub async fn post_dpop_bytes_with_headers( 454 + http_client: &reqwest::Client, 455 + dpop_auth: &DPoPAuth, 456 + url: &str, 457 + payload: Bytes, 458 + additional_headers: &HeaderMap, 459 + ) -> Result<serde_json::Value> { 460 + let (dpop_proof_token, dpop_proof_header, dpop_proof_claim) = request_dpop( 461 + &dpop_auth.dpop_private_key_data, 462 + "POST", 463 + url, 464 + &dpop_auth.oauth_access_token, 465 + ) 466 + .map_err(|error| DPoPError::ProofGenerationFailed { error })?; 467 + 468 + let dpop_retry = DpopRetry::new( 469 + dpop_proof_header.clone(), 470 + dpop_proof_claim.clone(), 471 + dpop_auth.dpop_private_key_data.clone(), 472 + true, 473 + ); 474 + 475 + let dpop_retry_client = ClientBuilder::new(http_client.clone()) 476 + .with(ChainMiddleware::new(dpop_retry.clone())) 477 + .build(); 478 + 479 + let http_response = dpop_retry_client 480 + .post(url) 481 + .headers(additional_headers.clone()) 482 + .header( 483 + "Authorization", 484 + &format!("DPoP {}", dpop_auth.oauth_access_token), 485 + ) 486 + .header("DPoP", &dpop_proof_token) 487 + .body(payload) 488 + .send() 489 + .await 490 + .map_err(|error| DPoPError::HttpRequestFailed { 491 + url: url.to_string(), 492 + error, 493 + })?; 494 + 495 + let value = http_response 496 + .json::<serde_json::Value>() 497 + .await 498 + .map_err(|error| DPoPError::JsonParseFailed { 499 + url: url.to_string(), 500 + error, 501 + })?; 502 + 503 + Ok(value) 504 + } 505 + 506 /// Performs an unauthenticated HTTP POST request with JSON body and parses the response as JSON. 507 /// 508 /// # Arguments ··· 799 http_client: &reqwest::Client, 800 app_auth: &AppPasswordAuth, 801 url: &str, 802 + payload: Bytes, 803 additional_headers: &HeaderMap, 804 ) -> Result<Bytes> { 805 let mut headers = additional_headers.clone(); ··· 810 let http_response = http_client 811 .post(url) 812 .headers(headers) 813 + .body(payload) 814 .send() 815 + .instrument(tracing::info_span!("post_apppassword_bytes_with_headers", url = %url)) 816 .await 817 .map_err(|error| ClientError::HttpRequestFailed { 818 url: url.to_string(),
+7 -7
crates/atproto-client/src/com_atproto_identity.rs
··· 6 use std::collections::HashMap; 7 8 use anyhow::Result; 9 - use atproto_identity::url::URLBuilder; 10 use serde::{Deserialize, de::DeserializeOwned}; 11 12 use crate::{ ··· 58 base_url: &str, 59 handle: String, 60 ) -> Result<ResolveHandleResponse> { 61 - let mut url_builder = URLBuilder::new(base_url); 62 - url_builder.path("/xrpc/com.atproto.identity.resolveHandle"); 63 - 64 - url_builder.param("handle", &handle); 65 - 66 - let url = url_builder.build(); 67 68 match auth { 69 Auth::None => get_json(http_client, &url)
··· 6 use std::collections::HashMap; 7 8 use anyhow::Result; 9 + use atproto_identity::url::build_url; 10 use serde::{Deserialize, de::DeserializeOwned}; 11 12 use crate::{ ··· 58 base_url: &str, 59 handle: String, 60 ) -> Result<ResolveHandleResponse> { 61 + let url = build_url( 62 + base_url, 63 + "/xrpc/com.atproto.identity.resolveHandle", 64 + [("handle", handle.as_str())], 65 + )? 66 + .to_string(); 67 68 match auth { 69 Auth::None => get_json(http_client, &url)
+48 -41
crates/atproto-client/src/com_atproto_repo.rs
··· 23 //! OAuth access tokens and private keys for proof generation. 24 25 use std::collections::HashMap; 26 27 use anyhow::Result; 28 - use atproto_identity::url::URLBuilder; 29 use bytes::Bytes; 30 use serde::{Deserialize, Serialize, de::DeserializeOwned}; 31 ··· 77 did: &str, 78 cid: &str, 79 ) -> Result<Bytes> { 80 - let mut url_builder = URLBuilder::new(base_url); 81 - url_builder.path("/xrpc/com.atproto.sync.getBlob"); 82 - 83 - url_builder.param("did", did); 84 - url_builder.param("cid", cid); 85 - 86 - let url = url_builder.build(); 87 88 get_bytes(http_client, &url).await 89 } ··· 112 rkey: &str, 113 cid: Option<&str>, 114 ) -> Result<GetRecordResponse> { 115 - let mut url_builder = URLBuilder::new(base_url); 116 - url_builder.path("/xrpc/com.atproto.repo.getRecord"); 117 - 118 - url_builder.param("repo", repo); 119 - url_builder.param("collection", collection); 120 - url_builder.param("rkey", rkey); 121 - 122 if let Some(cid) = cid { 123 - url_builder.param("cid", cid); 124 } 125 126 - let url = url_builder.build(); 127 128 match auth { 129 Auth::None => get_json(http_client, &url) ··· 218 collection: String, 219 params: ListRecordsParams, 220 ) -> Result<ListRecordsResponse<T>> { 221 - let mut url_builder = URLBuilder::new(base_url); 222 - url_builder.path("/xrpc/com.atproto.repo.listRecords"); 223 224 - // Add query parameters 225 - url_builder.param("repo", &repo); 226 - url_builder.param("collection", &collection); 227 228 - if let Some(limit) = params.limit { 229 - url_builder.param("limit", &limit.to_string()); 230 - } 231 232 - if let Some(cursor) = params.cursor { 233 - url_builder.param("cursor", &cursor); 234 } 235 236 - if let Some(reverse) = params.reverse { 237 - url_builder.param("reverse", &reverse.to_string()); 238 - } 239 - 240 - let url = url_builder.build(); 241 242 match auth { 243 Auth::None => get_json(http_client, &url) ··· 319 base_url: &str, 320 record: CreateRecordRequest<T>, 321 ) -> Result<CreateRecordResponse> { 322 - let mut url_builder = URLBuilder::new(base_url); 323 - url_builder.path("/xrpc/com.atproto.repo.createRecord"); 324 - let url = url_builder.build(); 325 326 let value = serde_json::to_value(record)?; 327 ··· 413 base_url: &str, 414 record: PutRecordRequest<T>, 415 ) -> Result<PutRecordResponse> { 416 - let mut url_builder = URLBuilder::new(base_url); 417 - url_builder.path("/xrpc/com.atproto.repo.putRecord"); 418 - let url = url_builder.build(); 419 420 let value = serde_json::to_value(record)?; 421 ··· 496 base_url: &str, 497 record: DeleteRecordRequest, 498 ) -> Result<DeleteRecordResponse> { 499 - let mut url_builder = URLBuilder::new(base_url); 500 - url_builder.path("/xrpc/com.atproto.repo.deleteRecord"); 501 - let url = url_builder.build(); 502 503 let value = serde_json::to_value(record)?; 504
··· 23 //! OAuth access tokens and private keys for proof generation. 24 25 use std::collections::HashMap; 26 + use std::iter; 27 28 use anyhow::Result; 29 + use atproto_identity::url::build_url; 30 use bytes::Bytes; 31 use serde::{Deserialize, Serialize, de::DeserializeOwned}; 32 ··· 78 did: &str, 79 cid: &str, 80 ) -> Result<Bytes> { 81 + let url = build_url( 82 + base_url, 83 + "/xrpc/com.atproto.sync.getBlob", 84 + [("did", did), ("cid", cid)], 85 + )? 86 + .to_string(); 87 88 get_bytes(http_client, &url).await 89 } ··· 112 rkey: &str, 113 cid: Option<&str>, 114 ) -> Result<GetRecordResponse> { 115 + let mut params = vec![("repo", repo), ("collection", collection), ("rkey", rkey)]; 116 if let Some(cid) = cid { 117 + params.push(("cid", cid)); 118 } 119 120 + let url = build_url(base_url, "/xrpc/com.atproto.repo.getRecord", params)?.to_string(); 121 122 match auth { 123 Auth::None => get_json(http_client, &url) ··· 212 collection: String, 213 params: ListRecordsParams, 214 ) -> Result<ListRecordsResponse<T>> { 215 + let mut url = build_url( 216 + base_url, 217 + "/xrpc/com.atproto.repo.listRecords", 218 + iter::empty::<(&str, &str)>(), 219 + )?; 220 + { 221 + let mut pairs = url.query_pairs_mut(); 222 + pairs.append_pair("repo", &repo); 223 + pairs.append_pair("collection", &collection); 224 225 + if let Some(limit) = params.limit { 226 + pairs.append_pair("limit", &limit.to_string()); 227 + } 228 229 + if let Some(cursor) = params.cursor { 230 + pairs.append_pair("cursor", &cursor); 231 + } 232 233 + if let Some(reverse) = params.reverse { 234 + pairs.append_pair("reverse", &reverse.to_string()); 235 + } 236 } 237 238 + let url = url.to_string(); 239 240 match auth { 241 Auth::None => get_json(http_client, &url) ··· 317 base_url: &str, 318 record: CreateRecordRequest<T>, 319 ) -> Result<CreateRecordResponse> { 320 + let url = build_url( 321 + base_url, 322 + "/xrpc/com.atproto.repo.createRecord", 323 + iter::empty::<(&str, &str)>(), 324 + )? 325 + .to_string(); 326 327 let value = serde_json::to_value(record)?; 328 ··· 414 base_url: &str, 415 record: PutRecordRequest<T>, 416 ) -> Result<PutRecordResponse> { 417 + let url = build_url( 418 + base_url, 419 + "/xrpc/com.atproto.repo.putRecord", 420 + iter::empty::<(&str, &str)>(), 421 + )? 422 + .to_string(); 423 424 let value = serde_json::to_value(record)?; 425 ··· 500 base_url: &str, 501 record: DeleteRecordRequest, 502 ) -> Result<DeleteRecordResponse> { 503 + let url = build_url( 504 + base_url, 505 + "/xrpc/com.atproto.repo.deleteRecord", 506 + iter::empty::<(&str, &str)>(), 507 + )? 508 + .to_string(); 509 510 let value = serde_json::to_value(record)?; 511
+26 -13
crates/atproto-client/src/com_atproto_server.rs
··· 19 //! an access JWT token from an authenticated session. 20 21 use anyhow::Result; 22 - use atproto_identity::url::URLBuilder; 23 use serde::{Deserialize, Serialize}; 24 25 use crate::{ 26 client::{Auth, post_json}, ··· 118 password: &str, 119 auth_factor_token: Option<&str>, 120 ) -> Result<AppPasswordSession> { 121 - let mut url_builder = URLBuilder::new(base_url); 122 - url_builder.path("/xrpc/com.atproto.server.createSession"); 123 - let url = url_builder.build(); 124 125 let request = CreateSessionRequest { 126 identifier: identifier.to_string(), ··· 156 base_url: &str, 157 refresh_token: &str, 158 ) -> Result<RefreshSessionResponse> { 159 - let mut url_builder = URLBuilder::new(base_url); 160 - url_builder.path("/xrpc/com.atproto.server.refreshSession"); 161 - let url = url_builder.build(); 162 163 // Create a new client with the refresh token in Authorization header 164 let mut headers = reqwest::header::HeaderMap::new(); ··· 197 access_token: &str, 198 name: &str, 199 ) -> Result<AppPasswordResponse> { 200 - let mut url_builder = URLBuilder::new(base_url); 201 - url_builder.path("/xrpc/com.atproto.server.createAppPassword"); 202 - let url = url_builder.build(); 203 204 let request_body = serde_json::json!({ 205 "name": name ··· 260 } 261 }; 262 263 - let mut url_builder = URLBuilder::new(base_url); 264 - url_builder.path("/xrpc/com.atproto.server.deleteSession"); 265 - let url = url_builder.build(); 266 267 // Create headers with the Bearer token 268 let mut headers = reqwest::header::HeaderMap::new();
··· 19 //! an access JWT token from an authenticated session. 20 21 use anyhow::Result; 22 + use atproto_identity::url::build_url; 23 use serde::{Deserialize, Serialize}; 24 + use std::iter; 25 26 use crate::{ 27 client::{Auth, post_json}, ··· 119 password: &str, 120 auth_factor_token: Option<&str>, 121 ) -> Result<AppPasswordSession> { 122 + let url = build_url( 123 + base_url, 124 + "/xrpc/com.atproto.server.createSession", 125 + iter::empty::<(&str, &str)>(), 126 + )? 127 + .to_string(); 128 129 let request = CreateSessionRequest { 130 identifier: identifier.to_string(), ··· 160 base_url: &str, 161 refresh_token: &str, 162 ) -> Result<RefreshSessionResponse> { 163 + let url = build_url( 164 + base_url, 165 + "/xrpc/com.atproto.server.refreshSession", 166 + iter::empty::<(&str, &str)>(), 167 + )? 168 + .to_string(); 169 170 // Create a new client with the refresh token in Authorization header 171 let mut headers = reqwest::header::HeaderMap::new(); ··· 204 access_token: &str, 205 name: &str, 206 ) -> Result<AppPasswordResponse> { 207 + let url = build_url( 208 + base_url, 209 + "/xrpc/com.atproto.server.createAppPassword", 210 + iter::empty::<(&str, &str)>(), 211 + )? 212 + .to_string(); 213 214 let request_body = serde_json::json!({ 215 "name": name ··· 270 } 271 }; 272 273 + let url = build_url( 274 + base_url, 275 + "/xrpc/com.atproto.server.deleteSession", 276 + iter::empty::<(&str, &str)>(), 277 + )? 278 + .to_string(); 279 280 // Create headers with the Bearer token 281 let mut headers = reqwest::header::HeaderMap::new();
+3
crates/atproto-client/src/lib.rs
··· 20 21 pub mod client; 22 pub mod errors; 23 24 mod com_atproto_identity; 25 mod com_atproto_repo;
··· 20 21 pub mod client; 22 pub mod errors; 23 + pub mod record_resolver; 24 + 25 + pub use record_resolver::{HttpRecordResolver, RecordResolver}; 26 27 mod com_atproto_identity; 28 mod com_atproto_repo;
+103
crates/atproto-client/src/record_resolver.rs
···
··· 1 + //! Helpers for resolving AT Protocol records referenced by URI. 2 + 3 + use std::str::FromStr; 4 + use std::sync::Arc; 5 + 6 + use anyhow::{Result, anyhow, bail}; 7 + use async_trait::async_trait; 8 + use atproto_identity::traits::IdentityResolver; 9 + use atproto_record::aturi::ATURI; 10 + 11 + use crate::{ 12 + client::Auth, 13 + com::atproto::repo::{GetRecordResponse, get_record}, 14 + }; 15 + 16 + /// Trait for resolving AT Protocol records by `at://` URI. 17 + /// 18 + /// Implementations perform the network lookup and deserialize the response into 19 + /// the requested type. 20 + #[async_trait] 21 + pub trait RecordResolver: Send + Sync { 22 + /// Resolve an AT URI to a typed record. 23 + async fn resolve<T>(&self, aturi: &str) -> Result<T> 24 + where 25 + T: serde::de::DeserializeOwned + Send; 26 + } 27 + 28 + /// Resolver that fetches records using public XRPC endpoints. 29 + /// 30 + /// Uses an identity resolver to dynamically determine the PDS endpoint for each record. 31 + #[derive(Clone)] 32 + pub struct HttpRecordResolver { 33 + http_client: reqwest::Client, 34 + identity_resolver: Arc<dyn IdentityResolver>, 35 + } 36 + 37 + impl HttpRecordResolver { 38 + /// Create a new resolver using the provided HTTP client and identity resolver. 39 + /// 40 + /// The identity resolver is used to dynamically determine the PDS endpoint for each record 41 + /// based on the authority (DID or handle) in the AT URI. 42 + pub fn new( 43 + http_client: reqwest::Client, 44 + identity_resolver: Arc<dyn IdentityResolver>, 45 + ) -> Self { 46 + Self { 47 + http_client, 48 + identity_resolver, 49 + } 50 + } 51 + } 52 + 53 + #[async_trait] 54 + impl RecordResolver for HttpRecordResolver { 55 + async fn resolve<T>(&self, aturi: &str) -> Result<T> 56 + where 57 + T: serde::de::DeserializeOwned + Send, 58 + { 59 + let parsed = ATURI::from_str(aturi).map_err(|error| anyhow!(error))?; 60 + 61 + // Resolve the authority (DID or handle) to get the DID document 62 + let document = self 63 + .identity_resolver 64 + .resolve(&parsed.authority) 65 + .await 66 + .map_err(|error| { 67 + anyhow!("Failed to resolve identity for {}: {}", parsed.authority, error) 68 + })?; 69 + 70 + // Extract PDS endpoint from the DID document 71 + let pds_endpoints = document.pds_endpoints(); 72 + let base_url = pds_endpoints 73 + .first() 74 + .ok_or_else(|| anyhow!("No PDS endpoint found for {}", parsed.authority))?; 75 + 76 + let auth = Auth::None; 77 + 78 + let response = get_record( 79 + &self.http_client, 80 + &auth, 81 + base_url, 82 + &parsed.authority, 83 + &parsed.collection, 84 + &parsed.record_key, 85 + None, 86 + ) 87 + .await?; 88 + 89 + match response { 90 + GetRecordResponse::Record { value, .. } => { 91 + serde_json::from_value(value).map_err(|error| anyhow!(error)) 92 + } 93 + GetRecordResponse::Error(error) => { 94 + let message = error.error_message(); 95 + if message.is_empty() { 96 + bail!("Record resolution failed without additional error details"); 97 + } 98 + 99 + bail!(message); 100 + } 101 + } 102 + } 103 + }
+43
crates/atproto-extras/Cargo.toml
···
··· 1 + [package] 2 + name = "atproto-extras" 3 + version = "0.13.0" 4 + description = "AT Protocol extras - facet parsing and rich text utilities" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-extras" 8 + 9 + edition.workspace = true 10 + rust-version.workspace = true 11 + authors.workspace = true 12 + repository.workspace = true 13 + license.workspace = true 14 + keywords.workspace = true 15 + categories.workspace = true 16 + 17 + [dependencies] 18 + atproto-identity.workspace = true 19 + atproto-record.workspace = true 20 + 21 + anyhow.workspace = true 22 + async-trait.workspace = true 23 + clap = { workspace = true, optional = true } 24 + regex.workspace = true 25 + reqwest = { workspace = true, optional = true } 26 + serde_json = { workspace = true, optional = true } 27 + tokio = { workspace = true, optional = true } 28 + 29 + [dev-dependencies] 30 + tokio = { workspace = true, features = ["macros", "rt"] } 31 + 32 + [features] 33 + default = ["hickory-dns"] 34 + hickory-dns = ["atproto-identity/hickory-dns"] 35 + clap = ["dep:clap"] 36 + cli = ["dep:clap", "dep:serde_json", "dep:tokio", "dep:reqwest"] 37 + 38 + [[bin]] 39 + name = "atproto-extras-parse-facets" 40 + required-features = ["clap", "cli", "hickory-dns"] 41 + 42 + [lints] 43 + workspace = true
+128
crates/atproto-extras/README.md
···
··· 1 + # atproto-extras 2 + 3 + Extra utilities for AT Protocol applications, including rich text facet parsing. 4 + 5 + ## Features 6 + 7 + - **Facet Parsing**: Extract mentions (`@handle`), URLs, and hashtags (`#tag`) from plain text with correct UTF-8 byte offset calculation 8 + - **Identity Integration**: Resolve mention handles to DIDs during parsing 9 + 10 + ## Installation 11 + 12 + Add to your `Cargo.toml`: 13 + 14 + ```toml 15 + [dependencies] 16 + atproto-extras = "0.13" 17 + ``` 18 + 19 + ## Usage 20 + 21 + ### Parsing Text for Facets 22 + 23 + ```rust 24 + use atproto_extras::{parse_urls, parse_tags}; 25 + use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 26 + 27 + let text = "Check out https://example.com #rust"; 28 + 29 + // Parse URLs and tags - returns Vec<Facet> directly 30 + let url_facets = parse_urls(text); 31 + let tag_facets = parse_tags(text); 32 + 33 + // Each facet includes byte positions and typed features 34 + for facet in url_facets { 35 + if let Some(FacetFeature::Link(link)) = facet.features.first() { 36 + println!("URL at bytes {}..{}: {}", 37 + facet.index.byte_start, facet.index.byte_end, link.uri); 38 + } 39 + } 40 + 41 + for facet in tag_facets { 42 + if let Some(FacetFeature::Tag(tag)) = facet.features.first() { 43 + println!("Tag at bytes {}..{}: #{}", 44 + facet.index.byte_start, facet.index.byte_end, tag.tag); 45 + } 46 + } 47 + ``` 48 + 49 + ### Parsing Mentions 50 + 51 + Mention parsing requires an `IdentityResolver` to convert handles to DIDs: 52 + 53 + ```rust 54 + use atproto_extras::{parse_mentions, FacetLimits}; 55 + use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 56 + 57 + let text = "Hello @alice.bsky.social!"; 58 + let limits = FacetLimits::default(); 59 + 60 + // Requires an async context and IdentityResolver 61 + let facets = parse_mentions(text, &resolver, &limits).await; 62 + 63 + for facet in facets { 64 + if let Some(FacetFeature::Mention(mention)) = facet.features.first() { 65 + println!("Mention at bytes {}..{} resolved to {}", 66 + facet.index.byte_start, facet.index.byte_end, mention.did); 67 + } 68 + } 69 + ``` 70 + 71 + Mentions that cannot be resolved to a valid DID are automatically skipped. Mentions appearing within URLs are also excluded. 72 + 73 + ### Creating AT Protocol Facets 74 + 75 + ```rust 76 + use atproto_extras::{parse_facets_from_text, FacetLimits}; 77 + 78 + let text = "Hello @alice.bsky.social! Check https://rust-lang.org #rust"; 79 + let limits = FacetLimits::default(); 80 + 81 + // Requires an async context and IdentityResolver 82 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 83 + 84 + if let Some(facets) = facets { 85 + for facet in &facets { 86 + println!("Facet at {}..{}", facet.index.byte_start, facet.index.byte_end); 87 + } 88 + } 89 + ``` 90 + 91 + ## Byte Offset Handling 92 + 93 + AT Protocol facets use UTF-8 byte offsets, not character indices. This is critical for correct handling of multi-byte characters like emojis or non-ASCII text. 94 + 95 + ```rust 96 + use atproto_extras::parse_urls; 97 + 98 + // Text with emojis (multi-byte UTF-8 characters) 99 + let text = "โœจ Check https://example.com โœจ"; 100 + 101 + let facets = parse_urls(text); 102 + // Byte positions correctly account for the 4-byte emoji 103 + assert_eq!(facets[0].index.byte_start, 11); // After "โœจ Check " (4 + 1 + 6 = 11 bytes) 104 + ``` 105 + 106 + ## Facet Limits 107 + 108 + Use `FacetLimits` to control the maximum number of facets processed: 109 + 110 + ```rust 111 + use atproto_extras::FacetLimits; 112 + 113 + // Default limits 114 + let limits = FacetLimits::default(); 115 + // mentions_max: 5, tags_max: 5, links_max: 5, max: 10 116 + 117 + // Custom limits 118 + let custom = FacetLimits { 119 + mentions_max: 10, 120 + tags_max: 10, 121 + links_max: 10, 122 + max: 20, 123 + }; 124 + ``` 125 + 126 + ## License 127 + 128 + MIT
+176
crates/atproto-extras/src/bin/atproto-extras-parse-facets.rs
···
··· 1 + //! Command-line tool for generating AT Protocol facet arrays from text. 2 + //! 3 + //! This tool parses a string and outputs the facet array in JSON format. 4 + //! Facets include mentions (@handle), URLs (https://...), and hashtags (#tag). 5 + //! 6 + //! By default, mentions are detected but output with placeholder DIDs. Use 7 + //! `--resolve-mentions` to resolve handles to actual DIDs (requires network access). 8 + //! 9 + //! # Usage 10 + //! 11 + //! ```bash 12 + //! # Parse facets without resolving mentions 13 + //! cargo run --features clap,serde_json,tokio,hickory-dns --bin atproto-extras-parse-facets -- "Check out https://example.com and #rust" 14 + //! 15 + //! # Resolve mentions to DIDs 16 + //! cargo run --features clap,serde_json,tokio,hickory-dns --bin atproto-extras-parse-facets -- --resolve-mentions "Hello @bsky.app!" 17 + //! ``` 18 + 19 + use atproto_extras::{FacetLimits, parse_mentions, parse_tags, parse_urls}; 20 + use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 21 + use atproto_record::lexicon::app::bsky::richtext::facet::{ 22 + ByteSlice, Facet, FacetFeature, Mention, 23 + }; 24 + use clap::Parser; 25 + use regex::bytes::Regex; 26 + use std::sync::Arc; 27 + 28 + /// Parse text and output AT Protocol facets as JSON. 29 + #[derive(Parser)] 30 + #[command( 31 + name = "atproto-extras-parse-facets", 32 + version, 33 + about = "Parse text and output AT Protocol facets as JSON", 34 + long_about = "This tool parses a string for mentions, URLs, and hashtags,\n\ 35 + then outputs the corresponding AT Protocol facet array in JSON format.\n\n\ 36 + By default, mentions are detected but output with placeholder DIDs.\n\ 37 + Use --resolve-mentions to resolve handles to actual DIDs (requires network)." 38 + )] 39 + struct Args { 40 + /// The text to parse for facets 41 + text: String, 42 + 43 + /// Resolve mention handles to DIDs (requires network access) 44 + #[arg(long)] 45 + resolve_mentions: bool, 46 + 47 + /// Show debug information on stderr 48 + #[arg(long, short = 'd')] 49 + debug: bool, 50 + } 51 + 52 + /// Parse mention spans from text without resolution (returns placeholder DIDs). 53 + fn parse_mention_spans(text: &str) -> Vec<Facet> { 54 + let mut facets = Vec::new(); 55 + 56 + // Get URL ranges to exclude mentions within URLs 57 + let url_facets = parse_urls(text); 58 + 59 + // Same regex pattern as parse_mentions 60 + let mention_regex = Regex::new( 61 + r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)", 62 + ) 63 + .expect("Invalid mention regex"); 64 + 65 + let text_bytes = text.as_bytes(); 66 + 67 + for capture in mention_regex.captures_iter(text_bytes) { 68 + if let Some(mention_match) = capture.get(1) { 69 + let start = mention_match.start(); 70 + let end = mention_match.end(); 71 + 72 + // Check if this mention overlaps with any URL 73 + let overlaps_url = url_facets.iter().any(|facet| { 74 + (start >= facet.index.byte_start && start < facet.index.byte_end) 75 + || (end > facet.index.byte_start && end <= facet.index.byte_end) 76 + }); 77 + 78 + if !overlaps_url { 79 + let handle = std::str::from_utf8(&mention_match.as_bytes()[1..]) 80 + .unwrap_or_default() 81 + .to_string(); 82 + 83 + facets.push(Facet { 84 + index: ByteSlice { 85 + byte_start: start, 86 + byte_end: end, 87 + }, 88 + features: vec![FacetFeature::Mention(Mention { 89 + did: format!("did:plc:<unresolved:{}>", handle), 90 + })], 91 + }); 92 + } 93 + } 94 + } 95 + 96 + facets 97 + } 98 + 99 + #[tokio::main] 100 + async fn main() { 101 + let args = Args::parse(); 102 + let text = &args.text; 103 + let mut facets: Vec<Facet> = Vec::new(); 104 + let limits = FacetLimits::default(); 105 + 106 + // Parse mentions (either resolved or with placeholders) 107 + if args.resolve_mentions { 108 + let http_client = reqwest::Client::new(); 109 + let dns_resolver = HickoryDnsResolver::create_resolver(&[]); 110 + let resolver = InnerIdentityResolver { 111 + http_client, 112 + dns_resolver: Arc::new(dns_resolver), 113 + plc_hostname: "plc.directory".to_string(), 114 + }; 115 + let mention_facets = parse_mentions(text, &resolver, &limits).await; 116 + facets.extend(mention_facets); 117 + } else { 118 + let mention_facets = parse_mention_spans(text); 119 + facets.extend(mention_facets); 120 + } 121 + 122 + // Parse URLs 123 + let url_facets = parse_urls(text); 124 + facets.extend(url_facets); 125 + 126 + // Parse hashtags 127 + let tag_facets = parse_tags(text); 128 + facets.extend(tag_facets); 129 + 130 + // Sort facets by byte_start for consistent output 131 + facets.sort_by_key(|f| f.index.byte_start); 132 + 133 + // Output as JSON 134 + if facets.is_empty() { 135 + println!("null"); 136 + } else { 137 + match serde_json::to_string_pretty(&facets) { 138 + Ok(json) => println!("{}", json), 139 + Err(e) => { 140 + eprintln!( 141 + "error-atproto-extras-parse-facets-1 Error serializing facets: {}", 142 + e 143 + ); 144 + std::process::exit(1); 145 + } 146 + } 147 + } 148 + 149 + // Show debug info if requested 150 + if args.debug { 151 + eprintln!(); 152 + eprintln!("--- Debug Info ---"); 153 + eprintln!("Input text: {:?}", text); 154 + eprintln!("Text length: {} bytes", text.len()); 155 + eprintln!("Facets found: {}", facets.len()); 156 + eprintln!("Mentions resolved: {}", args.resolve_mentions); 157 + 158 + // Show byte slice verification 159 + let text_bytes = text.as_bytes(); 160 + for (i, facet) in facets.iter().enumerate() { 161 + let start = facet.index.byte_start; 162 + let end = facet.index.byte_end; 163 + let slice_text = 164 + std::str::from_utf8(&text_bytes[start..end]).unwrap_or("<invalid utf8>"); 165 + let feature_type = match &facet.features[0] { 166 + FacetFeature::Mention(_) => "mention", 167 + FacetFeature::Link(_) => "link", 168 + FacetFeature::Tag(_) => "tag", 169 + }; 170 + eprintln!( 171 + " [{}] {} @ bytes {}..{}: {:?}", 172 + i, feature_type, start, end, slice_text 173 + ); 174 + } 175 + } 176 + }
+942
crates/atproto-extras/src/facets.rs
···
··· 1 + //! Rich text facet parsing for AT Protocol. 2 + //! 3 + //! This module provides functionality for extracting semantic annotations (facets) 4 + //! from plain text. Facets include mentions, links (URLs), and hashtags. 5 + //! 6 + //! # Overview 7 + //! 8 + //! AT Protocol rich text uses "facets" to annotate specific byte ranges within text with 9 + //! semantic meaning. This module handles: 10 + //! 11 + //! - **Parsing**: Extract mentions, URLs, and hashtags from plain text 12 + //! - **Facet Creation**: Build proper AT Protocol facet structures with resolved DIDs 13 + //! 14 + //! # Byte Offset Calculation 15 + //! 16 + //! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol. 17 + //! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done 18 + //! using `regex::bytes::Regex` which operates on byte slices and returns byte positions, 19 + //! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars). 20 + //! 21 + //! # Example 22 + //! 23 + //! ```ignore 24 + //! use atproto_extras::facets::{parse_urls, parse_tags, FacetLimits}; 25 + //! use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 26 + //! 27 + //! let text = "Check out https://example.com #rust"; 28 + //! 29 + //! // Parse URLs and tags as Facet objects 30 + //! let url_facets = parse_urls(text); 31 + //! let tag_facets = parse_tags(text); 32 + //! 33 + //! // Access facet data directly 34 + //! for facet in url_facets { 35 + //! if let Some(FacetFeature::Link(link)) = facet.features.first() { 36 + //! println!("URL at bytes {}..{}: {}", 37 + //! facet.index.byte_start, facet.index.byte_end, link.uri); 38 + //! } 39 + //! } 40 + //! ``` 41 + 42 + use atproto_identity::resolve::IdentityResolver; 43 + use atproto_record::lexicon::app::bsky::richtext::facet::{ 44 + ByteSlice, Facet, FacetFeature, Link, Mention, Tag, 45 + }; 46 + use regex::bytes::Regex; 47 + 48 + /// Configuration for facet parsing limits. 49 + /// 50 + /// These limits protect against abuse by capping the number of facets 51 + /// that will be processed. This is important for both performance and 52 + /// security when handling user-generated content. 53 + /// 54 + /// # Example 55 + /// 56 + /// ``` 57 + /// use atproto_extras::FacetLimits; 58 + /// 59 + /// // Use defaults 60 + /// let limits = FacetLimits::default(); 61 + /// 62 + /// // Or customize 63 + /// let custom = FacetLimits { 64 + /// mentions_max: 10, 65 + /// tags_max: 10, 66 + /// links_max: 10, 67 + /// max: 20, 68 + /// }; 69 + /// ``` 70 + #[derive(Debug, Clone, Copy)] 71 + pub struct FacetLimits { 72 + /// Maximum number of mention facets to process (default: 5) 73 + pub mentions_max: usize, 74 + /// Maximum number of tag facets to process (default: 5) 75 + pub tags_max: usize, 76 + /// Maximum number of link facets to process (default: 5) 77 + pub links_max: usize, 78 + /// Maximum total number of facets to process (default: 10) 79 + pub max: usize, 80 + } 81 + 82 + impl Default for FacetLimits { 83 + fn default() -> Self { 84 + Self { 85 + mentions_max: 5, 86 + tags_max: 5, 87 + links_max: 5, 88 + max: 10, 89 + } 90 + } 91 + } 92 + 93 + /// Parse mentions from text and return them as Facet objects with resolved DIDs. 94 + /// 95 + /// This function extracts AT Protocol handle mentions (e.g., `@alice.bsky.social`) 96 + /// from text, resolves each handle to a DID using the provided identity resolver, 97 + /// and returns AT Protocol Facet objects with Mention features. 98 + /// 99 + /// Mentions that cannot be resolved to a valid DID are skipped. Mentions that 100 + /// appear within URLs are also excluded to avoid false positives. 101 + /// 102 + /// # Arguments 103 + /// 104 + /// * `text` - The text to parse for mentions 105 + /// * `identity_resolver` - Resolver for converting handles to DIDs 106 + /// * `limits` - Configuration for maximum mentions to process 107 + /// 108 + /// # Returns 109 + /// 110 + /// A vector of Facet objects for successfully resolved mentions. 111 + /// 112 + /// # Example 113 + /// 114 + /// ```ignore 115 + /// use atproto_extras::{parse_mentions, FacetLimits}; 116 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 117 + /// 118 + /// let text = "Hello @alice.bsky.social!"; 119 + /// let limits = FacetLimits::default(); 120 + /// 121 + /// // Requires an async context and identity resolver 122 + /// let facets = parse_mentions(text, &resolver, &limits).await; 123 + /// 124 + /// for facet in facets { 125 + /// if let Some(FacetFeature::Mention(mention)) = facet.features.first() { 126 + /// println!("Mention {} resolved to {}", 127 + /// &text[facet.index.byte_start..facet.index.byte_end], 128 + /// mention.did); 129 + /// } 130 + /// } 131 + /// ``` 132 + pub async fn parse_mentions( 133 + text: &str, 134 + identity_resolver: &dyn IdentityResolver, 135 + limits: &FacetLimits, 136 + ) -> Vec<Facet> { 137 + let mut facets = Vec::new(); 138 + 139 + // First, parse all URLs to exclude mention matches within them 140 + let url_facets = parse_urls(text); 141 + 142 + // Regex based on: https://atproto.com/specs/handle#handle-identifier-syntax 143 + // Pattern: [$|\W](@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?) 144 + let mention_regex = Regex::new( 145 + r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)", 146 + ) 147 + .unwrap(); 148 + 149 + let text_bytes = text.as_bytes(); 150 + let mut mention_count = 0; 151 + 152 + for capture in mention_regex.captures_iter(text_bytes) { 153 + if mention_count >= limits.mentions_max { 154 + break; 155 + } 156 + 157 + if let Some(mention_match) = capture.get(1) { 158 + let start = mention_match.start(); 159 + let end = mention_match.end(); 160 + 161 + // Check if this mention overlaps with any URL 162 + let overlaps_url = url_facets.iter().any(|facet| { 163 + // Check if mention is within or overlaps the URL span 164 + (start >= facet.index.byte_start && start < facet.index.byte_end) 165 + || (end > facet.index.byte_start && end <= facet.index.byte_end) 166 + }); 167 + 168 + // Only process the mention if it doesn't overlap with a URL 169 + if !overlaps_url { 170 + let handle = std::str::from_utf8(&mention_match.as_bytes()[1..]) 171 + .unwrap_or_default() 172 + .to_string(); 173 + 174 + // Try to resolve the handle to a DID 175 + // First try with at:// prefix, then without 176 + let at_uri = format!("at://{}", handle); 177 + let did_result = match identity_resolver.resolve(&at_uri).await { 178 + Ok(doc) => Ok(doc), 179 + Err(_) => identity_resolver.resolve(&handle).await, 180 + }; 181 + 182 + // Only add the mention facet if we successfully resolved the DID 183 + if let Ok(did_doc) = did_result { 184 + facets.push(Facet { 185 + index: ByteSlice { 186 + byte_start: start, 187 + byte_end: end, 188 + }, 189 + features: vec![FacetFeature::Mention(Mention { 190 + did: did_doc.id.to_string(), 191 + })], 192 + }); 193 + mention_count += 1; 194 + } 195 + } 196 + } 197 + } 198 + 199 + facets 200 + } 201 + 202 + /// Parse URLs from text and return them as Facet objects. 203 + /// 204 + /// This function extracts HTTP and HTTPS URLs from text with correct 205 + /// byte position tracking for UTF-8 text, returning AT Protocol Facet objects 206 + /// with Link features. 207 + /// 208 + /// # Supported URL Patterns 209 + /// 210 + /// - HTTP URLs: `http://example.com` 211 + /// - HTTPS URLs: `https://example.com` 212 + /// - URLs with paths, query strings, and fragments 213 + /// - URLs with subdomains: `https://www.example.com` 214 + /// 215 + /// # Example 216 + /// 217 + /// ``` 218 + /// use atproto_extras::parse_urls; 219 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 220 + /// 221 + /// let text = "Visit https://example.com/path?query=1 for more info"; 222 + /// let facets = parse_urls(text); 223 + /// 224 + /// assert_eq!(facets.len(), 1); 225 + /// assert_eq!(facets[0].index.byte_start, 6); 226 + /// assert_eq!(facets[0].index.byte_end, 38); 227 + /// if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 228 + /// assert_eq!(link.uri, "https://example.com/path?query=1"); 229 + /// } 230 + /// ``` 231 + /// 232 + /// # Multi-byte Character Handling 233 + /// 234 + /// Byte positions are correctly calculated even with emojis and other 235 + /// multi-byte UTF-8 characters: 236 + /// 237 + /// ``` 238 + /// use atproto_extras::parse_urls; 239 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 240 + /// 241 + /// let text = "Check out https://example.com now!"; 242 + /// let facets = parse_urls(text); 243 + /// let text_bytes = text.as_bytes(); 244 + /// 245 + /// // The byte slice matches the URL 246 + /// let url_bytes = &text_bytes[facets[0].index.byte_start..facets[0].index.byte_end]; 247 + /// assert_eq!(std::str::from_utf8(url_bytes).unwrap(), "https://example.com"); 248 + /// ``` 249 + pub fn parse_urls(text: &str) -> Vec<Facet> { 250 + let mut facets = Vec::new(); 251 + 252 + // Partial/naive URL regex based on: https://stackoverflow.com/a/3809435 253 + // Pattern: [$|\W](https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?) 254 + // Modified to use + instead of {1,6} to support longer TLDs and multi-level subdomains 255 + let url_regex = Regex::new( 256 + r"(?:^|[^\w])(https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]+\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)" 257 + ).unwrap(); 258 + 259 + let text_bytes = text.as_bytes(); 260 + for capture in url_regex.captures_iter(text_bytes) { 261 + if let Some(url_match) = capture.get(1) { 262 + let url = std::str::from_utf8(url_match.as_bytes()) 263 + .unwrap_or_default() 264 + .to_string(); 265 + 266 + facets.push(Facet { 267 + index: ByteSlice { 268 + byte_start: url_match.start(), 269 + byte_end: url_match.end(), 270 + }, 271 + features: vec![FacetFeature::Link(Link { uri: url })], 272 + }); 273 + } 274 + } 275 + 276 + facets 277 + } 278 + 279 + /// Parse hashtags from text and return them as Facet objects. 280 + /// 281 + /// This function extracts hashtags (e.g., `#rust`, `#ATProto`) from text, 282 + /// returning AT Protocol Facet objects with Tag features. 283 + /// It supports both standard `#` and full-width `๏ผƒ` (U+FF03) hash symbols. 284 + /// 285 + /// # Tag Syntax 286 + /// 287 + /// - Tags must start with `#` or `๏ผƒ` (full-width) 288 + /// - Tag content follows word character rules (`\w`) 289 + /// - Purely numeric tags (e.g., `#123`) are excluded 290 + /// 291 + /// # Example 292 + /// 293 + /// ``` 294 + /// use atproto_extras::parse_tags; 295 + /// use atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature; 296 + /// 297 + /// let text = "Learning #rust and #golang today! #100DaysOfCode"; 298 + /// let facets = parse_tags(text); 299 + /// 300 + /// assert_eq!(facets.len(), 3); 301 + /// if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 302 + /// assert_eq!(tag.tag, "rust"); 303 + /// } 304 + /// if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() { 305 + /// assert_eq!(tag.tag, "golang"); 306 + /// } 307 + /// if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() { 308 + /// assert_eq!(tag.tag, "100DaysOfCode"); 309 + /// } 310 + /// ``` 311 + /// 312 + /// # Numeric Tags 313 + /// 314 + /// Purely numeric tags are excluded: 315 + /// 316 + /// ``` 317 + /// use atproto_extras::parse_tags; 318 + /// 319 + /// let text = "Item #42 is special"; 320 + /// let facets = parse_tags(text); 321 + /// 322 + /// // #42 is not extracted because it's purely numeric 323 + /// assert_eq!(facets.len(), 0); 324 + /// ``` 325 + pub fn parse_tags(text: &str) -> Vec<Facet> { 326 + let mut facets = Vec::new(); 327 + 328 + // Regex based on: https://github.com/bluesky-social/atproto/blob/d91988fe79030b61b556dd6f16a46f0c3b9d0b44/packages/api/src/rich-text/util.ts 329 + // Simplified for Rust - matches hashtags at word boundaries 330 + // Pattern matches: start of string or non-word char, then # or ๏ผƒ, then tag content 331 + let tag_regex = Regex::new(r"(?:^|[^\w])([#\xEF\xBC\x83])([\w]+(?:[\w]*)*)").unwrap(); 332 + 333 + let text_bytes = text.as_bytes(); 334 + 335 + // Work with bytes for proper position tracking 336 + for capture in tag_regex.captures_iter(text_bytes) { 337 + if let (Some(full_match), Some(hash_match), Some(tag_match)) = 338 + (capture.get(0), capture.get(1), capture.get(2)) 339 + { 340 + // Calculate the absolute byte position of the hash symbol 341 + // The full match includes the preceding character (if any) 342 + // so we need to adjust for that 343 + let match_start = full_match.start(); 344 + let hash_offset = hash_match.start() - full_match.start(); 345 + let start = match_start + hash_offset; 346 + let end = match_start + hash_offset + hash_match.len() + tag_match.len(); 347 + 348 + // Extract just the tag text (without the hash symbol) 349 + let tag = std::str::from_utf8(tag_match.as_bytes()).unwrap_or_default(); 350 + 351 + // Only include tags that are not purely numeric 352 + if !tag.chars().all(|c| c.is_ascii_digit()) { 353 + facets.push(Facet { 354 + index: ByteSlice { 355 + byte_start: start, 356 + byte_end: end, 357 + }, 358 + features: vec![FacetFeature::Tag(Tag { 359 + tag: tag.to_string(), 360 + })], 361 + }); 362 + } 363 + } 364 + } 365 + 366 + facets 367 + } 368 + 369 + /// Parse facets from text and return a vector of Facet objects. 370 + /// 371 + /// This function extracts mentions, URLs, and hashtags from the provided text 372 + /// and creates AT Protocol facets with proper byte indices. 373 + /// 374 + /// Mentions are resolved to actual DIDs using the provided identity resolver. 375 + /// If a handle cannot be resolved to a DID, the mention facet is skipped. 376 + /// 377 + /// # Arguments 378 + /// 379 + /// * `text` - The text to extract facets from 380 + /// * `identity_resolver` - Resolver for converting handles to DIDs 381 + /// * `limits` - Configuration for maximum facets per type and total 382 + /// 383 + /// # Returns 384 + /// 385 + /// Optional vector of facets. Returns `None` if no facets were found. 386 + /// 387 + /// # Example 388 + /// 389 + /// ```ignore 390 + /// use atproto_extras::{parse_facets_from_text, FacetLimits}; 391 + /// 392 + /// let text = "Hello @alice.bsky.social! Check #rust at https://rust-lang.org"; 393 + /// let limits = FacetLimits::default(); 394 + /// 395 + /// // Requires an async context and identity resolver 396 + /// let facets = parse_facets_from_text(text, &resolver, &limits).await; 397 + /// 398 + /// if let Some(facets) = facets { 399 + /// for facet in &facets { 400 + /// println!("Facet at {}..{}", facet.index.byte_start, facet.index.byte_end); 401 + /// } 402 + /// } 403 + /// ``` 404 + /// 405 + /// # Mention Resolution 406 + /// 407 + /// Mentions are only included if the handle resolves to a valid DID: 408 + /// 409 + /// ```ignore 410 + /// let text = "@valid.handle.com and @invalid.handle.xyz"; 411 + /// let facets = parse_facets_from_text(text, &resolver, &limits).await; 412 + /// 413 + /// // Only @valid.handle.com appears as a facet if @invalid.handle.xyz 414 + /// // cannot be resolved to a DID 415 + /// ``` 416 + pub async fn parse_facets_from_text( 417 + text: &str, 418 + identity_resolver: &dyn IdentityResolver, 419 + limits: &FacetLimits, 420 + ) -> Option<Vec<Facet>> { 421 + let mut facets = Vec::new(); 422 + 423 + // Parse mentions (already limited by mentions_max in parse_mentions) 424 + let mention_facets = parse_mentions(text, identity_resolver, limits).await; 425 + facets.extend(mention_facets); 426 + 427 + // Parse URLs (limited by links_max) 428 + let url_facets = parse_urls(text); 429 + for (idx, facet) in url_facets.into_iter().enumerate() { 430 + if idx >= limits.links_max { 431 + break; 432 + } 433 + facets.push(facet); 434 + } 435 + 436 + // Parse hashtags (limited by tags_max) 437 + let tag_facets = parse_tags(text); 438 + for (idx, facet) in tag_facets.into_iter().enumerate() { 439 + if idx >= limits.tags_max { 440 + break; 441 + } 442 + facets.push(facet); 443 + } 444 + 445 + // Apply global facet limit (truncate if exceeds max) 446 + if facets.len() > limits.max { 447 + facets.truncate(limits.max); 448 + } 449 + 450 + // Only return facets if we found any 451 + if !facets.is_empty() { 452 + Some(facets) 453 + } else { 454 + None 455 + } 456 + } 457 + 458 + #[cfg(test)] 459 + mod tests { 460 + use async_trait::async_trait; 461 + use atproto_identity::model::Document; 462 + use std::collections::HashMap; 463 + 464 + use super::*; 465 + 466 + /// Mock identity resolver for testing 467 + struct MockIdentityResolver { 468 + handles_to_dids: HashMap<String, String>, 469 + } 470 + 471 + impl MockIdentityResolver { 472 + fn new() -> Self { 473 + let mut handles_to_dids = HashMap::new(); 474 + handles_to_dids.insert( 475 + "alice.bsky.social".to_string(), 476 + "did:plc:alice123".to_string(), 477 + ); 478 + handles_to_dids.insert( 479 + "at://alice.bsky.social".to_string(), 480 + "did:plc:alice123".to_string(), 481 + ); 482 + Self { handles_to_dids } 483 + } 484 + 485 + fn add_identity(&mut self, handle: &str, did: &str) { 486 + self.handles_to_dids 487 + .insert(handle.to_string(), did.to_string()); 488 + self.handles_to_dids 489 + .insert(format!("at://{}", handle), did.to_string()); 490 + } 491 + } 492 + 493 + #[async_trait] 494 + impl IdentityResolver for MockIdentityResolver { 495 + async fn resolve(&self, handle: &str) -> anyhow::Result<Document> { 496 + let handle_key = handle.to_string(); 497 + 498 + if let Some(did) = self.handles_to_dids.get(&handle_key) { 499 + Ok(Document { 500 + context: vec![], 501 + id: did.clone(), 502 + also_known_as: vec![format!("at://{}", handle_key.trim_start_matches("at://"))], 503 + verification_method: vec![], 504 + service: vec![], 505 + extra: HashMap::new(), 506 + }) 507 + } else { 508 + Err(anyhow::anyhow!("Handle not found")) 509 + } 510 + } 511 + } 512 + 513 + #[tokio::test] 514 + async fn test_parse_facets_from_text_comprehensive() { 515 + let mut resolver = MockIdentityResolver::new(); 516 + resolver.add_identity("bob.test.com", "did:plc:bob456"); 517 + 518 + let limits = FacetLimits::default(); 519 + let text = "Join @alice.bsky.social and @bob.test.com at https://example.com #rust #golang"; 520 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 521 + 522 + assert!(facets.is_some()); 523 + let facets = facets.unwrap(); 524 + assert_eq!(facets.len(), 5); // 2 mentions, 1 URL, 2 hashtags 525 + 526 + // Check first mention 527 + assert_eq!(facets[0].index.byte_start, 5); 528 + assert_eq!(facets[0].index.byte_end, 23); 529 + if let FacetFeature::Mention(ref mention) = facets[0].features[0] { 530 + assert_eq!(mention.did, "did:plc:alice123"); 531 + } else { 532 + panic!("Expected Mention feature"); 533 + } 534 + 535 + // Check second mention 536 + assert_eq!(facets[1].index.byte_start, 28); 537 + assert_eq!(facets[1].index.byte_end, 41); 538 + if let FacetFeature::Mention(mention) = &facets[1].features[0] { 539 + assert_eq!(mention.did, "did:plc:bob456"); 540 + } else { 541 + panic!("Expected Mention feature"); 542 + } 543 + 544 + // Check URL 545 + assert_eq!(facets[2].index.byte_start, 45); 546 + assert_eq!(facets[2].index.byte_end, 64); 547 + if let FacetFeature::Link(link) = &facets[2].features[0] { 548 + assert_eq!(link.uri, "https://example.com"); 549 + } else { 550 + panic!("Expected Link feature"); 551 + } 552 + 553 + // Check first hashtag 554 + assert_eq!(facets[3].index.byte_start, 65); 555 + assert_eq!(facets[3].index.byte_end, 70); 556 + if let FacetFeature::Tag(tag) = &facets[3].features[0] { 557 + assert_eq!(tag.tag, "rust"); 558 + } else { 559 + panic!("Expected Tag feature"); 560 + } 561 + 562 + // Check second hashtag 563 + assert_eq!(facets[4].index.byte_start, 71); 564 + assert_eq!(facets[4].index.byte_end, 78); 565 + if let FacetFeature::Tag(tag) = &facets[4].features[0] { 566 + assert_eq!(tag.tag, "golang"); 567 + } else { 568 + panic!("Expected Tag feature"); 569 + } 570 + } 571 + 572 + #[tokio::test] 573 + async fn test_parse_facets_from_text_with_unresolvable_mention() { 574 + let resolver = MockIdentityResolver::new(); 575 + let limits = FacetLimits::default(); 576 + 577 + // Only alice.bsky.social is in the resolver, not unknown.handle.com 578 + let text = "Contact @unknown.handle.com for details #rust"; 579 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 580 + 581 + assert!(facets.is_some()); 582 + let facets = facets.unwrap(); 583 + // Should only have 1 facet (the hashtag) since the mention couldn't be resolved 584 + assert_eq!(facets.len(), 1); 585 + 586 + // Check that it's the hashtag facet 587 + if let FacetFeature::Tag(tag) = &facets[0].features[0] { 588 + assert_eq!(tag.tag, "rust"); 589 + } else { 590 + panic!("Expected Tag feature"); 591 + } 592 + } 593 + 594 + #[tokio::test] 595 + async fn test_parse_facets_from_text_empty() { 596 + let resolver = MockIdentityResolver::new(); 597 + let limits = FacetLimits::default(); 598 + let text = "No mentions, URLs, or hashtags here"; 599 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 600 + assert!(facets.is_none()); 601 + } 602 + 603 + #[tokio::test] 604 + async fn test_parse_facets_from_text_url_with_at_mention() { 605 + let resolver = MockIdentityResolver::new(); 606 + let limits = FacetLimits::default(); 607 + 608 + // URLs with @ should not create mention facets 609 + let text = "Tangled https://tangled.org/@smokesignal.events"; 610 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 611 + 612 + assert!(facets.is_some()); 613 + let facets = facets.unwrap(); 614 + 615 + // Should have exactly 1 facet (the URL), not 2 (URL + mention) 616 + assert_eq!( 617 + facets.len(), 618 + 1, 619 + "Expected 1 facet (URL only), got {}", 620 + facets.len() 621 + ); 622 + 623 + // Verify it's a link facet, not a mention 624 + if let FacetFeature::Link(link) = &facets[0].features[0] { 625 + assert_eq!(link.uri, "https://tangled.org/@smokesignal.events"); 626 + } else { 627 + panic!("Expected Link feature, got Mention or Tag instead"); 628 + } 629 + } 630 + 631 + #[tokio::test] 632 + async fn test_parse_facets_with_mention_limit() { 633 + let mut resolver = MockIdentityResolver::new(); 634 + resolver.add_identity("bob.test.com", "did:plc:bob456"); 635 + resolver.add_identity("charlie.test.com", "did:plc:charlie789"); 636 + 637 + // Limit to 2 mentions 638 + let limits = FacetLimits { 639 + mentions_max: 2, 640 + tags_max: 5, 641 + links_max: 5, 642 + max: 10, 643 + }; 644 + 645 + let text = "Join @alice.bsky.social @bob.test.com @charlie.test.com"; 646 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 647 + 648 + assert!(facets.is_some()); 649 + let facets = facets.unwrap(); 650 + // Should only have 2 mentions (alice and bob), charlie should be skipped 651 + assert_eq!(facets.len(), 2); 652 + 653 + // Verify they're both mentions 654 + for facet in &facets { 655 + assert!(matches!(facet.features[0], FacetFeature::Mention(_))); 656 + } 657 + } 658 + 659 + #[tokio::test] 660 + async fn test_parse_facets_with_global_limit() { 661 + let mut resolver = MockIdentityResolver::new(); 662 + resolver.add_identity("bob.test.com", "did:plc:bob456"); 663 + 664 + // Very restrictive global limit 665 + let limits = FacetLimits { 666 + mentions_max: 5, 667 + tags_max: 5, 668 + links_max: 5, 669 + max: 3, // Only allow 3 total facets 670 + }; 671 + 672 + let text = 673 + "Join @alice.bsky.social @bob.test.com at https://example.com #rust #golang #python"; 674 + let facets = parse_facets_from_text(text, &resolver, &limits).await; 675 + 676 + assert!(facets.is_some()); 677 + let facets = facets.unwrap(); 678 + // Should be truncated to 3 facets total 679 + assert_eq!(facets.len(), 3); 680 + } 681 + 682 + #[test] 683 + fn test_parse_urls_multiple_links() { 684 + let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd & Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"; 685 + 686 + let facets = parse_urls(text); 687 + 688 + // Should find both URLs 689 + assert_eq!( 690 + facets.len(), 691 + 2, 692 + "Expected 2 URLs but found {}", 693 + facets.len() 694 + ); 695 + 696 + // Check first URL 697 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 698 + assert_eq!(link.uri, "https://www.ietf.org/meeting/124/"); 699 + } else { 700 + panic!("Expected Link feature"); 701 + } 702 + 703 + // Check second URL 704 + if let Some(FacetFeature::Link(link)) = facets[1].features.first() { 705 + assert_eq!( 706 + link.uri, 707 + "https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164" 708 + ); 709 + } else { 710 + panic!("Expected Link feature"); 711 + } 712 + } 713 + 714 + #[test] 715 + fn test_parse_urls_with_html_entity() { 716 + // Test with the HTML entity &amp; in the text 717 + let text = "IETF124 is happening in Montreal, Nov 1st to 7th https://www.ietf.org/meeting/124/\n\nWe're confirmed for two days of ATProto community sessions on Monday, Nov 3rd &amp; Tuesday, Mov 4th at ECTO Co-Op. Many of us will also be participating in the free-to-attend IETF hackathon on Sunday, Nov 2nd.\n\nLatest updates and attendees in the forum https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164"; 718 + 719 + let facets = parse_urls(text); 720 + 721 + // Should find both URLs 722 + assert_eq!( 723 + facets.len(), 724 + 2, 725 + "Expected 2 URLs but found {}", 726 + facets.len() 727 + ); 728 + 729 + // Check first URL 730 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 731 + assert_eq!(link.uri, "https://www.ietf.org/meeting/124/"); 732 + } else { 733 + panic!("Expected Link feature"); 734 + } 735 + 736 + // Check second URL 737 + if let Some(FacetFeature::Link(link)) = facets[1].features.first() { 738 + assert_eq!( 739 + link.uri, 740 + "https://discourse.atprotocol.community/t/update-on-timing-and-plan-for-montreal/164" 741 + ); 742 + } else { 743 + panic!("Expected Link feature"); 744 + } 745 + } 746 + 747 + #[test] 748 + fn test_byte_offset_with_html_entities() { 749 + // This test demonstrates that HTML entity escaping shifts byte positions. 750 + // The byte positions shift: 751 + // In original: '&' is at byte 8 (1 byte) 752 + // In escaped: '&amp;' starts at byte 8 (5 bytes) 753 + // This causes facet byte offsets to be misaligned if text is escaped before rendering. 754 + 755 + // If we have a URL after the ampersand in the original: 756 + let original_with_url = "Nov 3rd & Tuesday https://example.com"; 757 + let escaped_with_url = "Nov 3rd &amp; Tuesday https://example.com"; 758 + 759 + // Parse URLs from both versions 760 + let original_facets = parse_urls(original_with_url); 761 + let escaped_facets = parse_urls(escaped_with_url); 762 + 763 + // Both should find the URL, but at different byte positions 764 + assert_eq!(original_facets.len(), 1); 765 + assert_eq!(escaped_facets.len(), 1); 766 + 767 + // The byte positions will be different 768 + assert_eq!(original_facets[0].index.byte_start, 18); // After "Nov 3rd & Tuesday " 769 + assert_eq!(escaped_facets[0].index.byte_start, 22); // After "Nov 3rd &amp; Tuesday " (4 extra bytes for &amp;) 770 + } 771 + 772 + #[test] 773 + fn test_parse_urls_from_atproto_record_text() { 774 + // Test parsing URLs from real AT Protocol record description text. 775 + // This demonstrates the correct byte positions that should be used for facets. 776 + let text = "Dev, Power Users, and Generally inquisitive folks get a completely unprofessionally amateur interview. Just a yap sesh where chat is part of the call!\n\nโœจthe danielโœจ & I will be on a Zoom call and I will stream out to https://stream.place/psingletary.com\n\nSubscribe to the publications! https://atprotocalls.leaflet.pub/"; 777 + 778 + let facets = parse_urls(text); 779 + 780 + assert_eq!(facets.len(), 2, "Should find 2 URLs"); 781 + 782 + // First URL: https://stream.place/psingletary.com 783 + assert_eq!(facets[0].index.byte_start, 221); 784 + assert_eq!(facets[0].index.byte_end, 257); 785 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 786 + assert_eq!(link.uri, "https://stream.place/psingletary.com"); 787 + } 788 + 789 + // Second URL: https://atprotocalls.leaflet.pub/ 790 + assert_eq!(facets[1].index.byte_start, 290); 791 + assert_eq!(facets[1].index.byte_end, 323); 792 + if let Some(FacetFeature::Link(link)) = facets[1].features.first() { 793 + assert_eq!(link.uri, "https://atprotocalls.leaflet.pub/"); 794 + } 795 + 796 + // Verify the byte slices match the expected text 797 + let text_bytes = text.as_bytes(); 798 + assert_eq!( 799 + std::str::from_utf8(&text_bytes[221..257]).unwrap(), 800 + "https://stream.place/psingletary.com" 801 + ); 802 + assert_eq!( 803 + std::str::from_utf8(&text_bytes[290..323]).unwrap(), 804 + "https://atprotocalls.leaflet.pub/" 805 + ); 806 + } 807 + 808 + #[tokio::test] 809 + async fn test_parse_mentions_basic() { 810 + let resolver = MockIdentityResolver::new(); 811 + let limits = FacetLimits::default(); 812 + let text = "Hello @alice.bsky.social!"; 813 + let facets = parse_mentions(text, &resolver, &limits).await; 814 + 815 + assert_eq!(facets.len(), 1); 816 + assert_eq!(facets[0].index.byte_start, 6); 817 + assert_eq!(facets[0].index.byte_end, 24); 818 + if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() { 819 + assert_eq!(mention.did, "did:plc:alice123"); 820 + } else { 821 + panic!("Expected Mention feature"); 822 + } 823 + } 824 + 825 + #[tokio::test] 826 + async fn test_parse_mentions_multiple() { 827 + let mut resolver = MockIdentityResolver::new(); 828 + resolver.add_identity("bob.example.com", "did:plc:bob456"); 829 + let limits = FacetLimits::default(); 830 + let text = "CC @alice.bsky.social and @bob.example.com"; 831 + let facets = parse_mentions(text, &resolver, &limits).await; 832 + 833 + assert_eq!(facets.len(), 2); 834 + if let Some(FacetFeature::Mention(mention)) = facets[0].features.first() { 835 + assert_eq!(mention.did, "did:plc:alice123"); 836 + } 837 + if let Some(FacetFeature::Mention(mention)) = facets[1].features.first() { 838 + assert_eq!(mention.did, "did:plc:bob456"); 839 + } 840 + } 841 + 842 + #[tokio::test] 843 + async fn test_parse_mentions_unresolvable() { 844 + let resolver = MockIdentityResolver::new(); 845 + let limits = FacetLimits::default(); 846 + // unknown.handle.com is not in the resolver 847 + let text = "Hello @unknown.handle.com!"; 848 + let facets = parse_mentions(text, &resolver, &limits).await; 849 + 850 + // Should be empty since the handle can't be resolved 851 + assert_eq!(facets.len(), 0); 852 + } 853 + 854 + #[tokio::test] 855 + async fn test_parse_mentions_in_url_excluded() { 856 + let resolver = MockIdentityResolver::new(); 857 + let limits = FacetLimits::default(); 858 + // The @smokesignal.events is inside a URL and should not be parsed as a mention 859 + let text = "Check https://tangled.org/@smokesignal.events"; 860 + let facets = parse_mentions(text, &resolver, &limits).await; 861 + 862 + // Should be empty since the mention is inside a URL 863 + assert_eq!(facets.len(), 0); 864 + } 865 + 866 + #[test] 867 + fn test_parse_tags_basic() { 868 + let text = "Learning #rust today!"; 869 + let facets = parse_tags(text); 870 + 871 + assert_eq!(facets.len(), 1); 872 + assert_eq!(facets[0].index.byte_start, 9); 873 + assert_eq!(facets[0].index.byte_end, 14); 874 + if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 875 + assert_eq!(tag.tag, "rust"); 876 + } else { 877 + panic!("Expected Tag feature"); 878 + } 879 + } 880 + 881 + #[test] 882 + fn test_parse_tags_multiple() { 883 + let text = "#rust #golang #python are great!"; 884 + let facets = parse_tags(text); 885 + 886 + assert_eq!(facets.len(), 3); 887 + if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 888 + assert_eq!(tag.tag, "rust"); 889 + } 890 + if let Some(FacetFeature::Tag(tag)) = facets[1].features.first() { 891 + assert_eq!(tag.tag, "golang"); 892 + } 893 + if let Some(FacetFeature::Tag(tag)) = facets[2].features.first() { 894 + assert_eq!(tag.tag, "python"); 895 + } 896 + } 897 + 898 + #[test] 899 + fn test_parse_tags_excludes_numeric() { 900 + let text = "Item #42 is special #test123"; 901 + let facets = parse_tags(text); 902 + 903 + // #42 should be excluded (purely numeric), #test123 should be included 904 + assert_eq!(facets.len(), 1); 905 + if let Some(FacetFeature::Tag(tag)) = facets[0].features.first() { 906 + assert_eq!(tag.tag, "test123"); 907 + } 908 + } 909 + 910 + #[test] 911 + fn test_parse_urls_basic() { 912 + let text = "Visit https://example.com today!"; 913 + let facets = parse_urls(text); 914 + 915 + assert_eq!(facets.len(), 1); 916 + assert_eq!(facets[0].index.byte_start, 6); 917 + assert_eq!(facets[0].index.byte_end, 25); 918 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 919 + assert_eq!(link.uri, "https://example.com"); 920 + } 921 + } 922 + 923 + #[test] 924 + fn test_parse_urls_with_path() { 925 + let text = "Check https://example.com/path/to/page?query=1#section"; 926 + let facets = parse_urls(text); 927 + 928 + assert_eq!(facets.len(), 1); 929 + if let Some(FacetFeature::Link(link)) = facets[0].features.first() { 930 + assert_eq!(link.uri, "https://example.com/path/to/page?query=1#section"); 931 + } 932 + } 933 + 934 + #[test] 935 + fn test_facet_limits_default() { 936 + let limits = FacetLimits::default(); 937 + assert_eq!(limits.mentions_max, 5); 938 + assert_eq!(limits.tags_max, 5); 939 + assert_eq!(limits.links_max, 5); 940 + assert_eq!(limits.max, 10); 941 + } 942 + }
+50
crates/atproto-extras/src/lib.rs
···
··· 1 + //! Extra utilities for AT Protocol applications. 2 + //! 3 + //! This crate provides additional utilities that complement the core AT Protocol 4 + //! identity and record crates. Currently, it focuses on rich text facet parsing. 5 + //! 6 + //! ## Features 7 + //! 8 + //! - **Facet Parsing**: Extract mentions, URLs, and hashtags from plain text 9 + //! with correct UTF-8 byte offset calculation 10 + //! - **Identity Integration**: Resolve mention handles to DIDs during parsing 11 + //! 12 + //! ## Example 13 + //! 14 + //! ```ignore 15 + //! use atproto_extras::{parse_facets_from_text, FacetLimits}; 16 + //! 17 + //! // Parse facets from text (requires an IdentityResolver) 18 + //! let text = "Hello @alice.bsky.social! Check out https://example.com #rust"; 19 + //! let limits = FacetLimits::default(); 20 + //! let facets = parse_facets_from_text(text, &resolver, &limits).await; 21 + //! ``` 22 + //! 23 + //! ## Byte Offset Calculation 24 + //! 25 + //! This implementation correctly uses UTF-8 byte offsets as required by AT Protocol. 26 + //! The facets use "inclusive start and exclusive end" byte ranges. All parsing is done 27 + //! using `regex::bytes::Regex` which operates on byte slices and returns byte positions, 28 + //! ensuring correct handling of multi-byte UTF-8 characters (emojis, CJK, accented chars). 29 + 30 + #![forbid(unsafe_code)] 31 + #![warn(missing_docs)] 32 + 33 + /// Rich text facet parsing for AT Protocol. 34 + /// 35 + /// This module provides functionality for extracting semantic annotations (facets) 36 + /// from plain text. Facets include: 37 + /// 38 + /// - **Mentions**: User handles prefixed with `@` (e.g., `@alice.bsky.social`) 39 + /// - **Links**: HTTP/HTTPS URLs 40 + /// - **Tags**: Hashtags prefixed with `#` or `๏ผƒ` (e.g., `#rust`) 41 + /// 42 + /// ## Byte Offsets 43 + /// 44 + /// All facet indices use UTF-8 byte offsets, not character indices. This is 45 + /// critical for correct handling of multi-byte characters like emojis or 46 + /// non-ASCII text. 47 + pub mod facets; 48 + 49 + /// Re-export commonly used types for convenience. 50 + pub use facets::{FacetLimits, parse_facets_from_text, parse_mentions, parse_tags, parse_urls};
+1
crates/atproto-identity/Cargo.toml
··· 62 thiserror.workspace = true 63 tokio.workspace = true 64 tracing.workspace = true 65 urlencoding.workspace = true 66 zeroize = { workspace = true, optional = true } 67
··· 62 thiserror.workspace = true 63 tokio.workspace = true 64 tracing.workspace = true 65 + url.workspace = true 66 urlencoding.workspace = true 67 zeroize = { workspace = true, optional = true } 68
+70 -21
crates/atproto-identity/src/key.rs
··· 47 //! } 48 //! ``` 49 50 - use anyhow::Result; 51 use ecdsa::signature::Signer; 52 use elliptic_curve::JwkEcKey; 53 use elliptic_curve::sec1::ToEncodedPoint; 54 55 use crate::errors::KeyError; 56 57 #[cfg(feature = "zeroize")] 58 use zeroize::{Zeroize, ZeroizeOnDrop}; 59 60 /// Cryptographic key types supported for AT Protocol identity. 61 - #[derive(Clone, PartialEq)] 62 - #[cfg_attr(debug_assertions, derive(Debug))] 63 #[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))] 64 pub enum KeyType { 65 /// A p256 (P-256 / secp256r1 / ES256) public key. ··· 160 // Add DID key prefix 161 write!(f, "did:key:{}", multibase_encoded) 162 } 163 - } 164 - 165 - /// Trait for providing cryptographic keys by identifier. 166 - /// 167 - /// This trait defines the interface for key providers that can retrieve private keys 168 - /// by their identifier. Implementations must be thread-safe to support concurrent access. 169 - #[async_trait::async_trait] 170 - pub trait KeyProvider: Send + Sync { 171 - /// Retrieves a private key by its identifier. 172 - /// 173 - /// # Arguments 174 - /// * `key_id` - The identifier of the key to retrieve 175 - /// 176 - /// # Returns 177 - /// * `Ok(Some(KeyData))` - If the key was found and successfully retrieved 178 - /// * `Ok(None)` - If no key exists for the given identifier 179 - /// * `Err(anyhow::Error)` - If an error occurred during key retrieval 180 - async fn get_private_key_by_id(&self, key_id: &str) -> Result<Option<KeyData>>; 181 } 182 183 /// DID key method prefix. ··· 362 .map_err(|error| KeyError::ECDSAError { error })?; 363 Ok(signature.to_vec()) 364 } 365 } 366 } 367
··· 47 //! } 48 //! ``` 49 50 + use anyhow::{Context, Result, anyhow}; 51 use ecdsa::signature::Signer; 52 use elliptic_curve::JwkEcKey; 53 use elliptic_curve::sec1::ToEncodedPoint; 54 55 + use crate::model::VerificationMethod; 56 + use crate::traits::IdentityResolver; 57 + 58 + pub use crate::traits::KeyResolver; 59 + use std::sync::Arc; 60 + 61 use crate::errors::KeyError; 62 63 #[cfg(feature = "zeroize")] 64 use zeroize::{Zeroize, ZeroizeOnDrop}; 65 66 /// Cryptographic key types supported for AT Protocol identity. 67 + #[derive(Clone, PartialEq, Debug)] 68 #[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))] 69 pub enum KeyType { 70 /// A p256 (P-256 / secp256r1 / ES256) public key. ··· 165 // Add DID key prefix 166 write!(f, "did:key:{}", multibase_encoded) 167 } 168 } 169 170 /// DID key method prefix. ··· 349 .map_err(|error| KeyError::ECDSAError { error })?; 350 Ok(signature.to_vec()) 351 } 352 + } 353 + } 354 + 355 + /// Key resolver implementation that fetches DID documents using an [`IdentityResolver`]. 356 + #[derive(Clone)] 357 + pub struct IdentityDocumentKeyResolver { 358 + identity_resolver: Arc<dyn IdentityResolver>, 359 + } 360 + 361 + impl IdentityDocumentKeyResolver { 362 + /// Creates a new key resolver backed by an [`IdentityResolver`]. 363 + pub fn new(identity_resolver: Arc<dyn IdentityResolver>) -> Self { 364 + Self { identity_resolver } 365 + } 366 + } 367 + 368 + #[async_trait::async_trait] 369 + impl KeyResolver for IdentityDocumentKeyResolver { 370 + async fn resolve(&self, key: &str) -> Result<KeyData> { 371 + if let Some(did_key) = key.split('#').next() { 372 + if let Ok(key_data) = identify_key(did_key) { 373 + return Ok(key_data); 374 + } 375 + } else if let Ok(key_data) = identify_key(key) { 376 + return Ok(key_data); 377 + } 378 + 379 + let (did, fragment) = key 380 + .split_once('#') 381 + .context("Key reference must contain a DID fragment (e.g., did:example#key)")?; 382 + 383 + if did.is_empty() || fragment.is_empty() { 384 + return Err(anyhow!( 385 + "Key reference must include both DID and fragment (received `{key}`)" 386 + )); 387 + } 388 + 389 + let document = self.identity_resolver.resolve(did).await?; 390 + let fragment_with_hash = format!("#{fragment}"); 391 + 392 + let public_key_multibase = document 393 + .verification_method 394 + .iter() 395 + .find_map(|method| match method { 396 + VerificationMethod::Multikey { 397 + id, 398 + public_key_multibase, 399 + .. 400 + } if id == key || *id == fragment_with_hash => Some(public_key_multibase.clone()), 401 + _ => None, 402 + }) 403 + .context(format!( 404 + "Verification method `{key}` not found in DID document `{did}`" 405 + ))?; 406 + 407 + let full_key = if public_key_multibase.starts_with("did:key:") { 408 + public_key_multibase 409 + } else { 410 + format!("did:key:{}", public_key_multibase) 411 + }; 412 + 413 + identify_key(&full_key).context("Failed to parse key data from verification method") 414 } 415 } 416
+1 -1
crates/atproto-identity/src/lib.rs
··· 19 pub mod model; 20 pub mod plc; 21 pub mod resolve; 22 - pub mod storage; 23 #[cfg(feature = "lru")] 24 pub mod storage_lru; 25 pub mod url; 26 pub mod validation; 27 pub mod web;
··· 19 pub mod model; 20 pub mod plc; 21 pub mod resolve; 22 #[cfg(feature = "lru")] 23 pub mod storage_lru; 24 + pub mod traits; 25 pub mod url; 26 pub mod validation; 27 pub mod web;
+19 -1
crates/atproto-identity/src/model.rs
··· 70 /// The DID identifier (e.g., "did:plc:abc123"). 71 pub id: String, 72 /// Alternative identifiers like handles and domains. 73 pub also_known_as: Vec<String>, 74 /// Available services for this identity. 75 pub service: Vec<Service>, 76 77 /// Cryptographic verification methods. 78 - #[serde(alias = "verificationMethod")] 79 pub verification_method: Vec<VerificationMethod>, 80 81 /// Additional document properties not explicitly defined. ··· 402 let document = document.unwrap(); 403 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 404 } 405 } 406 }
··· 70 /// The DID identifier (e.g., "did:plc:abc123"). 71 pub id: String, 72 /// Alternative identifiers like handles and domains. 73 + #[serde(default)] 74 pub also_known_as: Vec<String>, 75 /// Available services for this identity. 76 + #[serde(default)] 77 pub service: Vec<Service>, 78 79 /// Cryptographic verification methods. 80 + #[serde(alias = "verificationMethod", default)] 81 pub verification_method: Vec<VerificationMethod>, 82 83 /// Additional document properties not explicitly defined. ··· 404 let document = document.unwrap(); 405 assert_eq!(document.id, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 406 } 407 + } 408 + 409 + #[test] 410 + fn test_deserialize_service_did_document() { 411 + // DID document from api.bsky.app - a service DID without alsoKnownAs 412 + let document = serde_json::from_str::<Document>( 413 + r##"{"@context":["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"],"id":"did:web:api.bsky.app","verificationMethod":[{"id":"did:web:api.bsky.app#atproto","type":"Multikey","controller":"did:web:api.bsky.app","publicKeyMultibase":"zQ3shpRzb2NDriwCSSsce6EqGxG23kVktHZc57C3NEcuNy1jg"}],"service":[{"id":"#bsky_notif","type":"BskyNotificationService","serviceEndpoint":"https://api.bsky.app"},{"id":"#bsky_appview","type":"BskyAppView","serviceEndpoint":"https://api.bsky.app"}]}"##, 414 + ); 415 + assert!(document.is_ok(), "Failed to parse: {:?}", document.err()); 416 + 417 + let document = document.unwrap(); 418 + assert_eq!(document.id, "did:web:api.bsky.app"); 419 + assert!(document.also_known_as.is_empty()); 420 + assert_eq!(document.service.len(), 2); 421 + assert_eq!(document.service[0].id, "#bsky_notif"); 422 + assert_eq!(document.service[1].id, "#bsky_appview"); 423 } 424 }
+95 -29
crates/atproto-identity/src/resolve.rs
··· 32 use crate::validation::{is_valid_did_method_plc, is_valid_handle}; 33 use crate::web::query as web_query; 34 35 - /// Trait for AT Protocol identity resolution. 36 - /// 37 - /// Implementations must be thread-safe (Send + Sync) and usable in async environments. 38 - /// This trait provides the core functionality for resolving AT Protocol subjects 39 - /// (handles or DIDs) to their corresponding DID documents. 40 - #[async_trait::async_trait] 41 - pub trait IdentityResolver: Send + Sync { 42 - /// Resolves an AT Protocol subject to its DID document. 43 - /// 44 - /// Takes a handle or DID, resolves it to a canonical DID, then retrieves 45 - /// the corresponding DID document from the appropriate source (PLC directory or web). 46 - /// 47 - /// # Arguments 48 - /// * `subject` - The AT Protocol handle or DID to resolve 49 - /// 50 - /// # Returns 51 - /// * `Ok(Document)` - The resolved DID document 52 - /// * `Err(anyhow::Error)` - Resolution error with detailed context 53 - async fn resolve(&self, subject: &str) -> Result<Document>; 54 - } 55 - 56 - /// Trait for DNS resolution operations. 57 - /// Provides async DNS TXT record lookups for handle resolution. 58 - #[async_trait::async_trait] 59 - pub trait DnsResolver: Send + Sync { 60 - /// Resolves TXT records for a given domain name. 61 - /// Returns a vector of strings representing the TXT record values. 62 - async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>; 63 - } 64 65 /// Hickory DNS implementation of the DnsResolver trait. 66 /// Wraps hickory_resolver::TokioResolver for TXT record resolution. ··· 196 is_valid_handle(trimmed) 197 .map(InputType::Handle) 198 .ok_or(ResolveError::InvalidInput) 199 } 200 } 201
··· 32 use crate::validation::{is_valid_did_method_plc, is_valid_handle}; 33 use crate::web::query as web_query; 34 35 + pub use crate::traits::{DnsResolver, IdentityResolver}; 36 37 /// Hickory DNS implementation of the DnsResolver trait. 38 /// Wraps hickory_resolver::TokioResolver for TXT record resolution. ··· 168 is_valid_handle(trimmed) 169 .map(InputType::Handle) 170 .ok_or(ResolveError::InvalidInput) 171 + } 172 + } 173 + 174 + #[cfg(test)] 175 + mod tests { 176 + use super::*; 177 + use crate::key::{ 178 + IdentityDocumentKeyResolver, KeyResolver, KeyType, generate_key, identify_key, to_public, 179 + }; 180 + use crate::model::{DocumentBuilder, VerificationMethod}; 181 + use std::collections::HashMap; 182 + 183 + struct StubIdentityResolver { 184 + expected: String, 185 + document: Document, 186 + } 187 + 188 + #[async_trait::async_trait] 189 + impl IdentityResolver for StubIdentityResolver { 190 + async fn resolve(&self, subject: &str) -> Result<Document> { 191 + if !self.expected.is_empty() { 192 + assert_eq!(self.expected, subject); 193 + } 194 + Ok(self.document.clone()) 195 + } 196 + } 197 + 198 + #[tokio::test] 199 + async fn resolves_direct_did_key() -> Result<()> { 200 + let private_key = generate_key(KeyType::K256Private)?; 201 + let public_key = to_public(&private_key)?; 202 + let key_reference = format!("{}", &public_key); 203 + 204 + let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 205 + expected: String::new(), 206 + document: Document::builder() 207 + .id("did:plc:placeholder") 208 + .build() 209 + .unwrap(), 210 + })); 211 + 212 + let key_data = resolver.resolve(&key_reference).await?; 213 + assert_eq!(key_data.bytes(), public_key.bytes()); 214 + Ok(()) 215 + } 216 + 217 + #[tokio::test] 218 + async fn resolves_literal_did_key_reference() -> Result<()> { 219 + let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 220 + expected: String::new(), 221 + document: Document::builder() 222 + .id("did:example:unused".to_string()) 223 + .build() 224 + .unwrap(), 225 + })); 226 + 227 + let sample = "did:key:zDnaezRmyM3NKx9NCphGiDFNBEMyR2sTZhhMGTseXCU2iXn53"; 228 + let expected = identify_key(sample)?; 229 + let resolved = resolver.resolve(sample).await?; 230 + assert_eq!(resolved.bytes(), expected.bytes()); 231 + Ok(()) 232 + } 233 + 234 + #[tokio::test] 235 + async fn resolves_via_identity_document() -> Result<()> { 236 + let private_key = generate_key(KeyType::P256Private)?; 237 + let public_key = to_public(&private_key)?; 238 + let public_key_multibase = format!("{}", &public_key) 239 + .strip_prefix("did:key:") 240 + .unwrap() 241 + .to_string(); 242 + 243 + let did = "did:web:example.com"; 244 + let method_id = format!("{did}#atproto"); 245 + 246 + let document = DocumentBuilder::new() 247 + .id(did.to_string()) 248 + .add_verification_method(VerificationMethod::Multikey { 249 + id: method_id.clone(), 250 + controller: did.to_string(), 251 + public_key_multibase, 252 + extra: HashMap::new(), 253 + }) 254 + .build() 255 + .unwrap(); 256 + 257 + let resolver = IdentityDocumentKeyResolver::new(Arc::new(StubIdentityResolver { 258 + expected: did.to_string(), 259 + document, 260 + })); 261 + 262 + let key_data = resolver.resolve(&method_id).await?; 263 + assert_eq!(key_data.bytes(), public_key.bytes()); 264 + Ok(()) 265 } 266 } 267
-212
crates/atproto-identity/src/storage.rs
··· 1 - //! DID document storage abstraction. 2 - //! 3 - //! Storage trait for DID document CRUD operations supporting multiple 4 - //! backends (database, file system, memory) with consistent interface. 5 - 6 - use anyhow::Result; 7 - 8 - use crate::model::Document; 9 - 10 - /// Trait for implementing DID document CRUD operations across different storage backends. 11 - /// 12 - /// This trait provides an abstraction layer for storing and retrieving DID documents, 13 - /// allowing different implementations for various storage systems such as databases, file systems, 14 - /// in-memory stores, or cloud storage services. 15 - /// 16 - /// All methods return `anyhow::Result` to allow implementations to use their own error types 17 - /// while providing a consistent interface for callers. Implementations should handle their 18 - /// specific error conditions and convert them to appropriate error messages. 19 - /// 20 - /// ## Thread Safety 21 - /// 22 - /// This trait requires implementations to be thread-safe (`Send + Sync`), meaning: 23 - /// - `Send`: The storage implementation can be moved between threads 24 - /// - `Sync`: The storage implementation can be safely accessed from multiple threads simultaneously 25 - /// 26 - /// This is essential for async applications where the storage might be accessed from different 27 - /// async tasks running on different threads. Implementations should use appropriate 28 - /// synchronization primitives (like `Arc<Mutex<>>`, `RwLock`, or database connection pools) 29 - /// to ensure thread safety. 30 - /// 31 - /// ## Usage 32 - /// 33 - /// Implementors of this trait can provide storage for AT Protocol DID documents in any backend: 34 - /// 35 - /// ```rust,ignore 36 - /// use atproto_identity::storage::DidDocumentStorage; 37 - /// use atproto_identity::model::Document; 38 - /// use anyhow::Result; 39 - /// use std::sync::Arc; 40 - /// use tokio::sync::RwLock; 41 - /// use std::collections::HashMap; 42 - /// 43 - /// // Thread-safe in-memory storage using Arc<RwLock<>> 44 - /// #[derive(Clone)] 45 - /// struct InMemoryStorage { 46 - /// data: Arc<RwLock<HashMap<String, Document>>>, // DID -> Document mapping 47 - /// } 48 - /// 49 - /// #[async_trait::async_trait] 50 - /// impl DidDocumentStorage for InMemoryStorage { 51 - /// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> { 52 - /// let data = self.data.read().await; 53 - /// Ok(data.get(did).cloned()) 54 - /// } 55 - /// 56 - /// async fn store_document(&self, document: Document) -> Result<()> { 57 - /// let mut data = self.data.write().await; 58 - /// data.insert(document.id.clone(), document); 59 - /// Ok(()) 60 - /// } 61 - /// 62 - /// async fn delete_document_by_did(&self, did: &str) -> Result<()> { 63 - /// let mut data = self.data.write().await; 64 - /// data.remove(did); 65 - /// Ok(()) 66 - /// } 67 - /// } 68 - /// 69 - /// // Database storage with thread-safe connection pool 70 - /// struct DatabaseStorage { 71 - /// pool: sqlx::Pool<sqlx::Postgres>, // Thread-safe connection pool 72 - /// } 73 - /// 74 - /// #[async_trait::async_trait] 75 - /// impl DidDocumentStorage for DatabaseStorage { 76 - /// async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> { 77 - /// // Database connection pools are thread-safe 78 - /// let row: Option<(serde_json::Value,)> = sqlx::query_as( 79 - /// "SELECT document FROM did_documents WHERE did = $1" 80 - /// ) 81 - /// .bind(did) 82 - /// .fetch_optional(&self.pool) 83 - /// .await?; 84 - /// 85 - /// if let Some((doc_json,)) = row { 86 - /// let document: Document = serde_json::from_value(doc_json)?; 87 - /// Ok(Some(document)) 88 - /// } else { 89 - /// Ok(None) 90 - /// } 91 - /// } 92 - /// 93 - /// async fn store_document(&self, document: Document) -> Result<()> { 94 - /// let doc_json = serde_json::to_value(&document)?; 95 - /// sqlx::query("INSERT INTO did_documents (did, document) VALUES ($1, $2) ON CONFLICT (did) DO UPDATE SET document = $2") 96 - /// .bind(&document.id) 97 - /// .bind(doc_json) 98 - /// .execute(&self.pool) 99 - /// .await?; 100 - /// Ok(()) 101 - /// } 102 - /// 103 - /// async fn delete_document_by_did(&self, did: &str) -> Result<()> { 104 - /// sqlx::query("DELETE FROM did_documents WHERE did = $1") 105 - /// .bind(did) 106 - /// .execute(&self.pool) 107 - /// .await?; 108 - /// Ok(()) 109 - /// } 110 - /// } 111 - /// ``` 112 - #[async_trait::async_trait] 113 - pub trait DidDocumentStorage: Send + Sync { 114 - /// Retrieves a DID document associated with the given DID. 115 - /// 116 - /// This method looks up the complete DID document that is currently stored for the provided 117 - /// DID (Decentralized Identifier). The document contains services, verification methods, 118 - /// and other identity information for the DID. 119 - /// 120 - /// # Arguments 121 - /// * `did` - The DID (Decentralized Identifier) to look up. Should be in the format 122 - /// `did:method:identifier` (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal") 123 - /// 124 - /// # Returns 125 - /// * `Ok(Some(document))` - If a document is found for the given DID 126 - /// * `Ok(None)` - If no document is currently stored for the DID 127 - /// * `Err(error)` - If an error occurs during retrieval (storage failure, invalid DID format, etc.) 128 - /// 129 - /// # Examples 130 - /// 131 - /// ```rust,ignore 132 - /// let storage = MyStorage::new(); 133 - /// let document = storage.get_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?; 134 - /// match document { 135 - /// Some(doc) => { 136 - /// println!("Found document for DID: {}", doc.id); 137 - /// if let Some(handle) = doc.handles() { 138 - /// println!("Primary handle: {}", handle); 139 - /// } 140 - /// }, 141 - /// None => println!("No document found for this DID"), 142 - /// } 143 - /// ``` 144 - async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>; 145 - 146 - /// Stores or updates a DID document. 147 - /// 148 - /// This method creates a new DID document entry or updates an existing one. 149 - /// In the AT Protocol ecosystem, this operation typically occurs when a DID document 150 - /// is resolved from the network, updated by the identity owner, or cached for performance. 151 - /// 152 - /// Implementations should ensure that: 153 - /// - The document's DID (`document.id`) is used as the key for storage 154 - /// - The operation is atomic (either fully succeeds or fully fails) 155 - /// - Any existing document for the same DID is properly replaced 156 - /// - The complete document structure is preserved 157 - /// 158 - /// # Arguments 159 - /// * `document` - The complete DID document to store. The document's `id` field 160 - /// will be used as the storage key. 161 - /// 162 - /// # Returns 163 - /// * `Ok(())` - If the document was successfully stored or updated 164 - /// * `Err(error)` - If an error occurs during the operation (storage failure, 165 - /// serialization failure, constraint violation, etc.) 166 - /// 167 - /// # Examples 168 - /// 169 - /// ```rust,ignore 170 - /// let storage = MyStorage::new(); 171 - /// let document = Document { 172 - /// id: "did:plc:bv6ggog3tya2z3vxsub7hnal".to_string(), 173 - /// also_known_as: vec!["at://alice.bsky.social".to_string()], 174 - /// service: vec![/* services */], 175 - /// verification_method: vec![/* verification methods */], 176 - /// extra: HashMap::new(), 177 - /// }; 178 - /// storage.store_document(document).await?; 179 - /// println!("Document successfully stored"); 180 - /// ``` 181 - async fn store_document(&self, document: Document) -> Result<()>; 182 - 183 - /// Deletes a DID document by its DID. 184 - /// 185 - /// This method removes a DID document from storage using the DID as the identifier. 186 - /// This operation is typically used when cleaning up expired cache entries, removing 187 - /// invalid documents, or when an identity is deactivated. 188 - /// 189 - /// Implementations should: 190 - /// - Handle the case where the DID doesn't exist gracefully (return Ok(())) 191 - /// - Ensure the deletion is atomic 192 - /// - Clean up any related data or indexes 193 - /// - Preserve referential integrity if applicable 194 - /// 195 - /// # Arguments 196 - /// * `did` - The DID identifying the document to delete. 197 - /// Should be in the format `did:method:identifier` 198 - /// (e.g., "did:plc:bv6ggog3tya2z3vxsub7hnal") 199 - /// 200 - /// # Returns 201 - /// * `Ok(())` - If the document was successfully deleted or didn't exist 202 - /// * `Err(error)` - If an error occurs during deletion (storage failure, etc.) 203 - /// 204 - /// # Examples 205 - /// 206 - /// ```rust,ignore 207 - /// let storage = MyStorage::new(); 208 - /// storage.delete_document_by_did("did:plc:bv6ggog3tya2z3vxsub7hnal").await?; 209 - /// println!("Document deleted"); 210 - /// ``` 211 - async fn delete_document_by_did(&self, did: &str) -> Result<()>; 212 - }
···
+8 -7
crates/atproto-identity/src/storage_lru.rs
··· 11 12 use crate::errors::StorageError; 13 use crate::model::Document; 14 - use crate::storage::DidDocumentStorage; 15 16 /// An LRU-based implementation of `DidDocumentStorage` that maintains a fixed-size cache of DID documents. 17 /// ··· 54 /// 55 /// ```rust 56 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 57 - /// use atproto_identity::storage::DidDocumentStorage; 58 /// use atproto_identity::model::Document; 59 /// use std::num::NonZeroUsize; 60 /// use std::collections::HashMap; ··· 164 /// 165 /// ```rust 166 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 167 - /// use atproto_identity::storage::DidDocumentStorage; 168 /// use atproto_identity::model::Document; 169 /// use std::num::NonZeroUsize; 170 /// use std::collections::HashMap; ··· 251 /// 252 /// ```rust 253 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 254 - /// use atproto_identity::storage::DidDocumentStorage; 255 /// use atproto_identity::model::Document; 256 /// use std::num::NonZeroUsize; 257 /// use std::collections::HashMap; ··· 305 /// 306 /// ```rust 307 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 308 - /// use atproto_identity::storage::DidDocumentStorage; 309 /// use atproto_identity::model::Document; 310 /// use std::num::NonZeroUsize; 311 /// use std::collections::HashMap; ··· 370 /// 371 /// ```rust 372 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 373 - /// use atproto_identity::storage::DidDocumentStorage; 374 /// use atproto_identity::model::Document; 375 /// use std::num::NonZeroUsize; 376 /// use std::collections::HashMap; ··· 460 /// 461 /// ```rust 462 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 463 - /// use atproto_identity::storage::DidDocumentStorage; 464 /// use atproto_identity::model::Document; 465 /// use std::num::NonZeroUsize; 466 /// use std::collections::HashMap; ··· 507 #[cfg(test)] 508 mod tests { 509 use super::*; 510 use std::collections::HashMap; 511 use std::num::NonZeroUsize; 512
··· 11 12 use crate::errors::StorageError; 13 use crate::model::Document; 14 + use crate::traits::DidDocumentStorage; 15 16 /// An LRU-based implementation of `DidDocumentStorage` that maintains a fixed-size cache of DID documents. 17 /// ··· 54 /// 55 /// ```rust 56 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 57 + /// use atproto_identity::traits::DidDocumentStorage; 58 /// use atproto_identity::model::Document; 59 /// use std::num::NonZeroUsize; 60 /// use std::collections::HashMap; ··· 164 /// 165 /// ```rust 166 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 167 + /// use atproto_identity::traits::DidDocumentStorage; 168 /// use atproto_identity::model::Document; 169 /// use std::num::NonZeroUsize; 170 /// use std::collections::HashMap; ··· 251 /// 252 /// ```rust 253 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 254 + /// use atproto_identity::traits::DidDocumentStorage; 255 /// use atproto_identity::model::Document; 256 /// use std::num::NonZeroUsize; 257 /// use std::collections::HashMap; ··· 305 /// 306 /// ```rust 307 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 308 + /// use atproto_identity::traits::DidDocumentStorage; 309 /// use atproto_identity::model::Document; 310 /// use std::num::NonZeroUsize; 311 /// use std::collections::HashMap; ··· 370 /// 371 /// ```rust 372 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 373 + /// use atproto_identity::traits::DidDocumentStorage; 374 /// use atproto_identity::model::Document; 375 /// use std::num::NonZeroUsize; 376 /// use std::collections::HashMap; ··· 460 /// 461 /// ```rust 462 /// use atproto_identity::storage_lru::LruDidDocumentStorage; 463 + /// use atproto_identity::traits::DidDocumentStorage; 464 /// use atproto_identity::model::Document; 465 /// use std::num::NonZeroUsize; 466 /// use std::collections::HashMap; ··· 507 #[cfg(test)] 508 mod tests { 509 use super::*; 510 + use crate::traits::DidDocumentStorage; 511 use std::collections::HashMap; 512 use std::num::NonZeroUsize; 513
+49
crates/atproto-identity/src/traits.rs
···
··· 1 + //! Shared trait definitions for AT Protocol identity operations. 2 + //! 3 + //! This module centralizes async traits used across the identity crate so they can 4 + //! be implemented without introducing circular module dependencies. 5 + 6 + use anyhow::Result; 7 + use async_trait::async_trait; 8 + 9 + use crate::errors::ResolveError; 10 + use crate::key::KeyData; 11 + use crate::model::Document; 12 + 13 + /// Trait for AT Protocol identity resolution. 14 + /// 15 + /// Implementations must resolve handles or DIDs to canonical DID documents. 16 + #[async_trait] 17 + pub trait IdentityResolver: Send + Sync { 18 + /// Resolves an AT Protocol subject to its DID document. 19 + async fn resolve(&self, subject: &str) -> Result<Document>; 20 + } 21 + 22 + /// Trait for DNS resolution operations used during handle lookups. 23 + #[async_trait] 24 + pub trait DnsResolver: Send + Sync { 25 + /// Resolves TXT records for a given domain name. 26 + async fn resolve_txt(&self, domain: &str) -> Result<Vec<String>, ResolveError>; 27 + } 28 + 29 + /// Trait for retrieving private keys by identifier. 30 + #[async_trait] 31 + /// Trait for resolving key references (e.g., DID verification methods) to [`KeyData`]. 32 + #[async_trait] 33 + pub trait KeyResolver: Send + Sync { 34 + /// Resolves a key reference string into key material. 35 + async fn resolve(&self, key: &str) -> Result<KeyData>; 36 + } 37 + 38 + /// Trait for DID document storage backends. 39 + #[async_trait] 40 + pub trait DidDocumentStorage: Send + Sync { 41 + /// Retrieves a DID document if present. 42 + async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>>; 43 + 44 + /// Stores or updates a DID document. 45 + async fn store_document(&self, document: Document) -> Result<()>; 46 + 47 + /// Deletes a DID document by DID. 48 + async fn delete_document_by_did(&self, did: &str) -> Result<()>; 49 + }
+48 -119
crates/atproto-identity/src/url.rs
··· 1 - //! URL construction utilities for HTTP endpoints. 2 //! 3 - //! Build well-formed HTTP request URLs with parameter encoding 4 - //! and query string generation. 5 - 6 - /// A single query parameter as a key-value pair. 7 - pub type QueryParam<'a> = (&'a str, &'a str); 8 - /// A collection of query parameters. 9 - pub type QueryParams<'a> = Vec<QueryParam<'a>>; 10 - 11 - /// Builds a query string from a collection of query parameters. 12 - /// 13 - /// # Arguments 14 - /// 15 - /// * `query` - Collection of key-value pairs to build into a query string 16 - /// 17 - /// # Returns 18 - /// 19 - /// A formatted query string with URL-encoded parameters 20 - pub fn build_querystring(query: QueryParams) -> String { 21 - query.iter().fold(String::new(), |acc, &tuple| { 22 - acc + tuple.0 + "=" + tuple.1 + "&" 23 - }) 24 - } 25 26 - /// Builder for constructing URLs with host, path, and query parameters. 27 - pub struct URLBuilder { 28 - host: String, 29 - path: String, 30 - params: Vec<(String, String)>, 31 - } 32 33 - /// Convenience function to build a URL with optional parameters. 34 - /// 35 - /// # Arguments 36 - /// 37 - /// * `host` - The hostname (will be prefixed with https:// if needed) 38 - /// * `path` - The URL path 39 - /// * `params` - Vector of optional key-value pairs for query parameters 40 - /// 41 - /// # Returns 42 - /// 43 - /// A fully constructed URL string 44 - pub fn build_url(host: &str, path: &str, params: Vec<Option<(&str, &str)>>) -> String { 45 - let mut url_builder = URLBuilder::new(host); 46 - url_builder.path(path); 47 48 - for (key, value) in params.iter().filter_map(|x| *x) { 49 - url_builder.param(key, value); 50 } 51 52 - url_builder.build() 53 - } 54 - 55 - impl URLBuilder { 56 - /// Creates a new URLBuilder with the specified host. 57 - /// 58 - /// # Arguments 59 - /// 60 - /// * `host` - The hostname (will be prefixed with https:// if needed and trailing slash removed) 61 - /// 62 - /// # Returns 63 - /// 64 - /// A new URLBuilder instance 65 - pub fn new(host: &str) -> URLBuilder { 66 - let host = if host.starts_with("https://") { 67 - host.to_string() 68 - } else { 69 - format!("https://{}", host) 70 - }; 71 - 72 - let host = if let Some(trimmed) = host.strip_suffix('/') { 73 - trimmed.to_string() 74 - } else { 75 - host 76 - }; 77 - 78 - URLBuilder { 79 - host: host.to_string(), 80 - params: vec![], 81 - path: "/".to_string(), 82 } 83 } 84 85 - /// Adds a query parameter to the URL. 86 - /// 87 - /// # Arguments 88 - /// 89 - /// * `key` - The parameter key 90 - /// * `value` - The parameter value (will be URL-encoded) 91 - /// 92 - /// # Returns 93 - /// 94 - /// A mutable reference to self for method chaining 95 - pub fn param(&mut self, key: &str, value: &str) -> &mut Self { 96 - self.params 97 - .push((key.to_owned(), urlencoding::encode(value).to_string())); 98 - self 99 - } 100 101 - /// Sets the URL path. 102 - /// 103 - /// # Arguments 104 - /// 105 - /// * `path` - The URL path 106 - /// 107 - /// # Returns 108 - /// 109 - /// A mutable reference to self for method chaining 110 - pub fn path(&mut self, path: &str) -> &mut Self { 111 - path.clone_into(&mut self.path); 112 - self 113 - } 114 115 - /// Constructs the final URL string. 116 - /// 117 - /// # Returns 118 - /// 119 - /// The complete URL with host, path, and query parameters 120 - pub fn build(self) -> String { 121 - let mut url_params = String::new(); 122 - 123 - if !self.params.is_empty() { 124 - url_params.push('?'); 125 - 126 - let qs_args = self.params.iter().map(|(k, v)| (&**k, &**v)).collect(); 127 - url_params.push_str(build_querystring(qs_args).as_str()); 128 - } 129 - 130 - format!("{}{}{}", self.host, self.path, url_params) 131 } 132 }
··· 1 + //! URL construction utilities leveraging the `url` crate. 2 //! 3 + //! Provides helpers for building URLs and appending query parameters 4 + //! without manual string concatenation. 5 6 + use url::{ParseError, Url}; 7 8 + /// Builds a URL from the provided components. 9 + /// Returns `Result<Url, ParseError>` to surface parsing errors. 10 + pub fn build_url<K, V, I>(host: &str, path: &str, params: I) -> Result<Url, ParseError> 11 + where 12 + I: IntoIterator<Item = (K, V)>, 13 + K: AsRef<str>, 14 + V: AsRef<str>, 15 + { 16 + let mut base = if host.starts_with("http://") || host.starts_with("https://") { 17 + Url::parse(host)? 18 + } else { 19 + Url::parse(&format!("https://{}", host))? 20 + }; 21 22 + if !base.path().ends_with('/') { 23 + let mut new_path = base.path().to_string(); 24 + if !new_path.ends_with('/') { 25 + new_path.push('/'); 26 + } 27 + if new_path.is_empty() { 28 + new_path.push('/'); 29 + } 30 + base.set_path(&new_path); 31 } 32 33 + let mut url = base.join(path.trim_start_matches('/'))?; 34 + { 35 + let mut pairs = url.query_pairs_mut(); 36 + for (key, value) in params { 37 + pairs.append_pair(key.as_ref(), value.as_ref()); 38 } 39 } 40 + Ok(url) 41 + } 42 43 + #[cfg(test)] 44 + mod tests { 45 + use super::*; 46 47 + #[test] 48 + fn builds_url_with_params() { 49 + let url = build_url( 50 + "example.com/api", 51 + "resource", 52 + [("id", "123"), ("status", "active")], 53 + ) 54 + .expect("url build failed"); 55 56 + assert_eq!( 57 + url.as_str(), 58 + "https://example.com/api/resource?id=123&status=active" 59 + ); 60 } 61 }
+75 -24
crates/atproto-jetstream/src/consumer.rs
··· 2 //! 3 //! WebSocket event consumption with background processing and 4 //! customizable event handler dispatch. 5 6 use crate::errors::ConsumerError; 7 use anyhow::Result; ··· 133 #[async_trait] 134 pub trait EventHandler: Send + Sync { 135 /// Handle a received event 136 - async fn handle_event(&self, event: JetstreamEvent) -> Result<()>; 137 138 /// Get the handler's identifier 139 - fn handler_id(&self) -> String; 140 } 141 142 #[cfg_attr(debug_assertions, derive(Debug))] ··· 167 pub struct Consumer { 168 config: ConsumerTaskConfig, 169 handlers: Arc<RwLock<HashMap<String, Arc<dyn EventHandler>>>>, 170 - event_sender: Arc<RwLock<Option<broadcast::Sender<JetstreamEvent>>>>, 171 } 172 173 impl Consumer { ··· 185 let handler_id = handler.handler_id(); 186 let mut handlers = self.handlers.write().await; 187 188 - if handlers.contains_key(&handler_id) { 189 return Err(ConsumerError::HandlerRegistrationFailed(format!( 190 "Handler with ID '{}' already registered", 191 handler_id ··· 193 .into()); 194 } 195 196 - handlers.insert(handler_id.clone(), handler); 197 Ok(()) 198 } 199 ··· 205 } 206 207 /// Get a broadcast receiver for events 208 - pub async fn get_event_receiver(&self) -> Result<broadcast::Receiver<JetstreamEvent>> { 209 let sender_guard = self.event_sender.read().await; 210 match sender_guard.as_ref() { 211 Some(sender) => Ok(sender.subscribe()), ··· 249 tracing::info!("Starting Jetstream consumer"); 250 251 // Build WebSocket URL with query parameters 252 - let mut query_params = vec![]; 253 254 // Add compression parameter 255 - query_params.push(format!("compress={}", self.config.compression)); 256 257 // Add requireHello parameter 258 - query_params.push(format!("requireHello={}", self.config.require_hello)); 259 260 // Add wantedCollections if specified (each collection as a separate query parameter) 261 if !self.config.collections.is_empty() && !self.config.require_hello { 262 for collection in &self.config.collections { 263 - query_params.push(format!( 264 - "wantedCollections={}", 265 - urlencoding::encode(collection) 266 - )); 267 } 268 } 269 270 // Add wantedDids if specified (each DID as a separate query parameter) 271 if !self.config.dids.is_empty() && !self.config.require_hello { 272 for did in &self.config.dids { 273 - query_params.push(format!("wantedDids={}", urlencoding::encode(did))); 274 } 275 } 276 277 // Add maxMessageSizeBytes if specified 278 if let Some(max_size) = self.config.max_message_size_bytes { 279 - query_params.push(format!("maxMessageSizeBytes={}", max_size)); 280 } 281 282 // Add cursor if specified 283 if let Some(cursor) = self.config.cursor { 284 - query_params.push(format!("cursor={}", cursor)); 285 } 286 - 287 - let query_string = query_params.join("&"); 288 let ws_url = Uri::from_str(&format!( 289 "wss://{}/subscribe?{}", 290 self.config.jetstream_hostname, query_string ··· 335 break; 336 }, 337 () = &mut sleeper => { 338 - // consumer_control_insert(&self.pool, &self.config.jetstream_hostname, time_usec).await?; 339 - 340 sleeper.as_mut().reset(Instant::now() + interval); 341 }, 342 item = client.next() => { ··· 404 } 405 406 /// Dispatch event to all registered handlers 407 async fn dispatch_to_handlers(&self, event: JetstreamEvent) -> Result<()> { 408 let handlers = self.handlers.read().await; 409 410 for (handler_id, handler) in handlers.iter() { 411 let handler_span = tracing::debug_span!("handler_dispatch", handler_id = %handler_id); 412 async { 413 - if let Err(err) = handler.handle_event(event.clone()).await { 414 tracing::error!( 415 error = ?err, 416 handler_id = %handler_id, ··· 440 441 #[async_trait] 442 impl EventHandler for LoggingHandler { 443 - async fn handle_event(&self, _event: JetstreamEvent) -> Result<()> { 444 Ok(()) 445 } 446 447 - fn handler_id(&self) -> String { 448 - self.id.clone() 449 } 450 } 451
··· 2 //! 3 //! WebSocket event consumption with background processing and 4 //! customizable event handler dispatch. 5 + //! 6 + //! ## Memory Efficiency 7 + //! 8 + //! This module is optimized for high-throughput event processing with minimal allocations: 9 + //! 10 + //! - **Arc-based event sharing**: Events are wrapped in `Arc` and shared across all handlers, 11 + //! avoiding expensive clones of event data structures. 12 + //! - **Zero-copy handler IDs**: Handler identifiers use string slices to avoid allocations 13 + //! during registration and dispatch. 14 + //! - **Optimized query building**: WebSocket query strings are built with pre-allocated 15 + //! capacity to minimize reallocations. 16 + //! 17 + //! ## Usage 18 + //! 19 + //! Implement the `EventHandler` trait to process events: 20 + //! 21 + //! ```rust 22 + //! use atproto_jetstream::{EventHandler, JetstreamEvent}; 23 + //! use async_trait::async_trait; 24 + //! use std::sync::Arc; 25 + //! use anyhow::Result; 26 + //! 27 + //! struct MyHandler; 28 + //! 29 + //! #[async_trait] 30 + //! impl EventHandler for MyHandler { 31 + //! async fn handle_event(&self, event: Arc<JetstreamEvent>) -> Result<()> { 32 + //! // Process event without cloning 33 + //! Ok(()) 34 + //! } 35 + //! 36 + //! fn handler_id(&self) -> &str { 37 + //! "my-handler" 38 + //! } 39 + //! } 40 + //! ``` 41 42 use crate::errors::ConsumerError; 43 use anyhow::Result; ··· 169 #[async_trait] 170 pub trait EventHandler: Send + Sync { 171 /// Handle a received event 172 + /// 173 + /// Events are wrapped in Arc to enable efficient sharing across multiple handlers 174 + /// without cloning the entire event data structure. 175 + async fn handle_event(&self, event: Arc<JetstreamEvent>) -> Result<()>; 176 177 /// Get the handler's identifier 178 + /// 179 + /// Returns a string slice to avoid unnecessary allocations. 180 + fn handler_id(&self) -> &str; 181 } 182 183 #[cfg_attr(debug_assertions, derive(Debug))] ··· 208 pub struct Consumer { 209 config: ConsumerTaskConfig, 210 handlers: Arc<RwLock<HashMap<String, Arc<dyn EventHandler>>>>, 211 + event_sender: Arc<RwLock<Option<broadcast::Sender<Arc<JetstreamEvent>>>>>, 212 } 213 214 impl Consumer { ··· 226 let handler_id = handler.handler_id(); 227 let mut handlers = self.handlers.write().await; 228 229 + if handlers.contains_key(handler_id) { 230 return Err(ConsumerError::HandlerRegistrationFailed(format!( 231 "Handler with ID '{}' already registered", 232 handler_id ··· 234 .into()); 235 } 236 237 + handlers.insert(handler_id.to_string(), handler); 238 Ok(()) 239 } 240 ··· 246 } 247 248 /// Get a broadcast receiver for events 249 + /// 250 + /// Events are wrapped in Arc to enable efficient sharing without cloning. 251 + pub async fn get_event_receiver(&self) -> Result<broadcast::Receiver<Arc<JetstreamEvent>>> { 252 let sender_guard = self.event_sender.read().await; 253 match sender_guard.as_ref() { 254 Some(sender) => Ok(sender.subscribe()), ··· 292 tracing::info!("Starting Jetstream consumer"); 293 294 // Build WebSocket URL with query parameters 295 + // Pre-allocate capacity to avoid reallocations during string building 296 + let capacity = 50 // Base parameters 297 + + self.config.collections.len() * 30 // Estimate per collection 298 + + self.config.dids.len() * 60; // Estimate per DID 299 + let mut query_string = String::with_capacity(capacity); 300 301 // Add compression parameter 302 + query_string.push_str("compress="); 303 + query_string.push_str(if self.config.compression { "true" } else { "false" }); 304 305 // Add requireHello parameter 306 + query_string.push_str("&requireHello="); 307 + query_string.push_str(if self.config.require_hello { "true" } else { "false" }); 308 309 // Add wantedCollections if specified (each collection as a separate query parameter) 310 if !self.config.collections.is_empty() && !self.config.require_hello { 311 for collection in &self.config.collections { 312 + query_string.push_str("&wantedCollections="); 313 + query_string.push_str(&urlencoding::encode(collection)); 314 } 315 } 316 317 // Add wantedDids if specified (each DID as a separate query parameter) 318 if !self.config.dids.is_empty() && !self.config.require_hello { 319 for did in &self.config.dids { 320 + query_string.push_str("&wantedDids="); 321 + query_string.push_str(&urlencoding::encode(did)); 322 } 323 } 324 325 // Add maxMessageSizeBytes if specified 326 if let Some(max_size) = self.config.max_message_size_bytes { 327 + use std::fmt::Write; 328 + write!(&mut query_string, "&maxMessageSizeBytes={}", max_size).unwrap(); 329 } 330 331 // Add cursor if specified 332 if let Some(cursor) = self.config.cursor { 333 + use std::fmt::Write; 334 + write!(&mut query_string, "&cursor={}", cursor).unwrap(); 335 } 336 let ws_url = Uri::from_str(&format!( 337 "wss://{}/subscribe?{}", 338 self.config.jetstream_hostname, query_string ··· 383 break; 384 }, 385 () = &mut sleeper => { 386 sleeper.as_mut().reset(Instant::now() + interval); 387 }, 388 item = client.next() => { ··· 450 } 451 452 /// Dispatch event to all registered handlers 453 + /// 454 + /// Wraps the event in Arc once and shares it across all handlers, 455 + /// avoiding expensive clones of the event data structure. 456 async fn dispatch_to_handlers(&self, event: JetstreamEvent) -> Result<()> { 457 let handlers = self.handlers.read().await; 458 + let event = Arc::new(event); 459 460 for (handler_id, handler) in handlers.iter() { 461 let handler_span = tracing::debug_span!("handler_dispatch", handler_id = %handler_id); 462 + let event_ref = Arc::clone(&event); 463 async { 464 + if let Err(err) = handler.handle_event(event_ref).await { 465 tracing::error!( 466 error = ?err, 467 handler_id = %handler_id, ··· 491 492 #[async_trait] 493 impl EventHandler for LoggingHandler { 494 + async fn handle_event(&self, _event: Arc<JetstreamEvent>) -> Result<()> { 495 Ok(()) 496 } 497 498 + fn handler_id(&self) -> &str { 499 + &self.id 500 } 501 } 502
+2 -2
crates/atproto-oauth/src/dpop.rs
··· 183 /// * `false` if no DPoP error is found or the header format is invalid 184 /// 185 /// # Examples 186 - /// ``` 187 /// use atproto_oauth::dpop::is_dpop_error; 188 /// 189 /// // Valid DPoP error: invalid_dpop_proof ··· 516 /// - HTTP method or URI don't match expected values 517 /// 518 /// # Examples 519 - /// ``` 520 /// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig}; 521 /// 522 /// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
··· 183 /// * `false` if no DPoP error is found or the header format is invalid 184 /// 185 /// # Examples 186 + /// ```no_run 187 /// use atproto_oauth::dpop::is_dpop_error; 188 /// 189 /// // Valid DPoP error: invalid_dpop_proof ··· 516 /// - HTTP method or URI don't match expected values 517 /// 518 /// # Examples 519 + /// ```no_run 520 /// use atproto_oauth::dpop::{validate_dpop_jwt, DpopValidationConfig}; 521 /// 522 /// let dpop_jwt = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia2lkIjoiZGlkOmtleTp6RG5hZVpVeEFhZDJUbkRYTjFaZWprcFV4TWVvMW9mNzF6NGVackxLRFRtaEQzOEQ3Iiwia3R5IjoiRUMiLCJ1c2UiOiJzaWciLCJ4IjoiaG56dDlSSGppUDBvMFJJTEZacEdjX0phenJUb1pHUzF1d0d5R3JleUNNbyIsInkiOiJzaXJhU2FGU09md3FrYTZRdnR3aUJhM0FKUi14eEhQaWVWZkFhZEhQQ0JRIn0sInR5cCI6ImRwb3Arand0In0.eyJqdGkiOiI2NDM0ZmFlNC00ZTYxLTQ1NDEtOTNlZC1kMzQ5ZjRiMTQ1NjEiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9haXBkZXYudHVubi5kZXYvb2F1dGgvdG9rZW4iLCJpYXQiOjE3NDk3NjQ1MTl9.GkoB00Y-68djRHLhO5-PayNV8PWcQI1pwZaAUL3Hzppj-ga6SKMyGpPwY4kcGdHM7lvvisNkzvd7RjEmdDtnjQ";
+374 -5
crates/atproto-oauth/src/scopes.rs
··· 38 Atproto, 39 /// Transition scope for migration operations 40 Transition(TransitionScope), 41 /// OpenID Connect scope - required for OpenID Connect authentication 42 OpenId, 43 /// Profile scope - access to user profile information ··· 91 Generic, 92 /// Email transition operations 93 Email, 94 } 95 96 /// Blob scope with mime type constraints ··· 310 "rpc", 311 "atproto", 312 "transition", 313 "openid", 314 "profile", 315 "email", ··· 349 "rpc" => Self::parse_rpc(suffix), 350 "atproto" => Self::parse_atproto(suffix), 351 "transition" => Self::parse_transition(suffix), 352 "openid" => Self::parse_openid(suffix), 353 "profile" => Self::parse_profile(suffix), 354 "email" => Self::parse_email(suffix), ··· 573 Ok(Scope::Transition(scope)) 574 } 575 576 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 577 if suffix.is_some() { 578 return Err(ParseError::InvalidResource( ··· 677 if let Some(lxm) = scope.lxm.iter().next() { 678 match lxm { 679 RpcLexicon::All => "rpc:*".to_string(), 680 - RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), 681 } 682 } else { 683 "rpc:*".to_string() ··· 713 TransitionScope::Generic => "transition:generic".to_string(), 714 TransitionScope::Email => "transition:email".to_string(), 715 }, 716 Scope::OpenId => "openid".to_string(), 717 Scope::Profile => "profile".to_string(), 718 Scope::Email => "email".to_string(), ··· 732 // Other scopes don't grant transition scopes 733 (_, Scope::Transition(_)) => false, 734 (Scope::Transition(_), _) => false, 735 // OpenID Connect scopes only grant themselves 736 (Scope::OpenId, Scope::OpenId) => true, 737 (Scope::OpenId, _) => false, ··· 873 params 874 } 875 876 /// Error type for scope parsing 877 #[derive(Debug, Clone, PartialEq, Eq)] 878 pub enum ParseError { ··· 1056 ("repo:foo.bar", "repo:foo.bar"), 1057 ("repo:foo.bar?action=create", "repo:foo.bar?action=create"), 1058 ("rpc:*", "rpc:*"), 1059 ]; 1060 1061 for (input, expected) in tests { ··· 1677 1678 // Test with complex scopes including query parameters 1679 let scopes = vec![ 1680 - Scope::parse("rpc:com.example.service?aud=did:example:123&lxm=com.example.method") 1681 - .unwrap(), 1682 Scope::parse("repo:foo.bar?action=create&action=update").unwrap(), 1683 Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1684 ]; 1685 let result = Scope::serialize_multiple(&scopes); 1686 // The result should be sorted alphabetically 1687 - // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 1688 assert!(result.starts_with("blob:")); 1689 assert!(result.contains(" repo:")); 1690 - assert!(result.contains("rpc?aud=did:example:123&lxm=com.example.service")); 1691 1692 // Test with transition scopes 1693 let scopes = vec![ ··· 1835 assert!(!result.contains(&Scope::parse("account:email").unwrap())); 1836 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 1837 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 1838 } 1839 }
··· 38 Atproto, 39 /// Transition scope for migration operations 40 Transition(TransitionScope), 41 + /// Include scope for referencing permission sets by NSID 42 + Include(IncludeScope), 43 /// OpenID Connect scope - required for OpenID Connect authentication 44 OpenId, 45 /// Profile scope - access to user profile information ··· 93 Generic, 94 /// Email transition operations 95 Email, 96 + } 97 + 98 + /// Include scope for referencing permission sets by NSID 99 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 100 + pub struct IncludeScope { 101 + /// The permission set NSID (e.g., "app.example.authFull") 102 + pub nsid: String, 103 + /// Optional audience DID for inherited RPC permissions 104 + pub aud: Option<String>, 105 } 106 107 /// Blob scope with mime type constraints ··· 321 "rpc", 322 "atproto", 323 "transition", 324 + "include", 325 "openid", 326 "profile", 327 "email", ··· 361 "rpc" => Self::parse_rpc(suffix), 362 "atproto" => Self::parse_atproto(suffix), 363 "transition" => Self::parse_transition(suffix), 364 + "include" => Self::parse_include(suffix), 365 "openid" => Self::parse_openid(suffix), 366 "profile" => Self::parse_profile(suffix), 367 "email" => Self::parse_email(suffix), ··· 586 Ok(Scope::Transition(scope)) 587 } 588 589 + fn parse_include(suffix: Option<&str>) -> Result<Self, ParseError> { 590 + let (nsid, params) = match suffix { 591 + Some(s) => { 592 + if let Some(pos) = s.find('?') { 593 + (&s[..pos], Some(&s[pos + 1..])) 594 + } else { 595 + (s, None) 596 + } 597 + } 598 + None => return Err(ParseError::MissingResource), 599 + }; 600 + 601 + if nsid.is_empty() { 602 + return Err(ParseError::MissingResource); 603 + } 604 + 605 + let aud = if let Some(params) = params { 606 + let parsed_params = parse_query_string(params); 607 + parsed_params 608 + .get("aud") 609 + .and_then(|v| v.first()) 610 + .map(|s| url_decode(s)) 611 + } else { 612 + None 613 + }; 614 + 615 + Ok(Scope::Include(IncludeScope { 616 + nsid: nsid.to_string(), 617 + aud, 618 + })) 619 + } 620 + 621 fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 622 if suffix.is_some() { 623 return Err(ParseError::InvalidResource( ··· 722 if let Some(lxm) = scope.lxm.iter().next() { 723 match lxm { 724 RpcLexicon::All => "rpc:*".to_string(), 725 + RpcLexicon::Nsid(nsid) => format!("rpc:{}?aud=*", nsid), 726 + } 727 + } else { 728 + "rpc:*".to_string() 729 + } 730 + } else if scope.lxm.len() == 1 && scope.aud.len() == 1 { 731 + // Single lxm and single aud (aud is not All, handled above) 732 + if let (Some(lxm), Some(aud)) = 733 + (scope.lxm.iter().next(), scope.aud.iter().next()) 734 + { 735 + match (lxm, aud) { 736 + (RpcLexicon::Nsid(nsid), RpcAudience::Did(did)) => { 737 + format!("rpc:{}?aud={}", nsid, did) 738 + } 739 + (RpcLexicon::All, RpcAudience::Did(did)) => { 740 + format!("rpc:*?aud={}", did) 741 + } 742 + _ => "rpc:*".to_string(), 743 } 744 } else { 745 "rpc:*".to_string() ··· 775 TransitionScope::Generic => "transition:generic".to_string(), 776 TransitionScope::Email => "transition:email".to_string(), 777 }, 778 + Scope::Include(scope) => { 779 + if let Some(ref aud) = scope.aud { 780 + format!("include:{}?aud={}", scope.nsid, url_encode(aud)) 781 + } else { 782 + format!("include:{}", scope.nsid) 783 + } 784 + } 785 Scope::OpenId => "openid".to_string(), 786 Scope::Profile => "profile".to_string(), 787 Scope::Email => "email".to_string(), ··· 801 // Other scopes don't grant transition scopes 802 (_, Scope::Transition(_)) => false, 803 (Scope::Transition(_), _) => false, 804 + // Include scopes only grant themselves (exact match including aud) 805 + (Scope::Include(a), Scope::Include(b)) => a == b, 806 + // Other scopes don't grant include scopes 807 + (_, Scope::Include(_)) => false, 808 + (Scope::Include(_), _) => false, 809 // OpenID Connect scopes only grant themselves 810 (Scope::OpenId, Scope::OpenId) => true, 811 (Scope::OpenId, _) => false, ··· 947 params 948 } 949 950 + /// Decode a percent-encoded string 951 + fn url_decode(s: &str) -> String { 952 + let mut result = String::with_capacity(s.len()); 953 + let mut chars = s.chars().peekable(); 954 + 955 + while let Some(c) = chars.next() { 956 + if c == '%' { 957 + let hex: String = chars.by_ref().take(2).collect(); 958 + if hex.len() == 2 { 959 + if let Ok(byte) = u8::from_str_radix(&hex, 16) { 960 + result.push(byte as char); 961 + continue; 962 + } 963 + } 964 + result.push('%'); 965 + result.push_str(&hex); 966 + } else { 967 + result.push(c); 968 + } 969 + } 970 + 971 + result 972 + } 973 + 974 + /// Encode a string for use in a URL query parameter 975 + fn url_encode(s: &str) -> String { 976 + let mut result = String::with_capacity(s.len() * 3); 977 + 978 + for c in s.chars() { 979 + match c { 980 + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | ':' => { 981 + result.push(c); 982 + } 983 + _ => { 984 + for byte in c.to_string().as_bytes() { 985 + result.push_str(&format!("%{:02X}", byte)); 986 + } 987 + } 988 + } 989 + } 990 + 991 + result 992 + } 993 + 994 /// Error type for scope parsing 995 #[derive(Debug, Clone, PartialEq, Eq)] 996 pub enum ParseError { ··· 1174 ("repo:foo.bar", "repo:foo.bar"), 1175 ("repo:foo.bar?action=create", "repo:foo.bar?action=create"), 1176 ("rpc:*", "rpc:*"), 1177 + ("rpc:com.example.service", "rpc:com.example.service?aud=*"), 1178 + ( 1179 + "rpc:com.example.service?aud=did:example:123", 1180 + "rpc:com.example.service?aud=did:example:123", 1181 + ), 1182 ]; 1183 1184 for (input, expected) in tests { ··· 1800 1801 // Test with complex scopes including query parameters 1802 let scopes = vec![ 1803 + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 1804 Scope::parse("repo:foo.bar?action=create&action=update").unwrap(), 1805 Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1806 ]; 1807 let result = Scope::serialize_multiple(&scopes); 1808 // The result should be sorted alphabetically 1809 + // Single lxm + single aud is serialized as "rpc:[lxm]?aud=[aud]" 1810 assert!(result.starts_with("blob:")); 1811 assert!(result.contains(" repo:")); 1812 + assert!(result.contains("rpc:com.example.service?aud=did:example:123")); 1813 1814 // Test with transition scopes 1815 let scopes = vec![ ··· 1957 assert!(!result.contains(&Scope::parse("account:email").unwrap())); 1958 assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 1959 assert!(result.contains(&Scope::parse("account:repo").unwrap())); 1960 + } 1961 + 1962 + #[test] 1963 + fn test_repo_nsid_with_wildcard_suffix() { 1964 + // Test parsing "repo:app.bsky.feed.*" - the asterisk is treated as a literal part of the NSID, 1965 + // not as a wildcard pattern. Only "repo:*" has special wildcard behavior for ALL collections. 1966 + let scope = Scope::parse("repo:app.bsky.feed.*").unwrap(); 1967 + 1968 + // Verify it parses as a specific NSID, not as a wildcard 1969 + assert_eq!( 1970 + scope, 1971 + Scope::Repo(RepoScope { 1972 + collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()), 1973 + actions: { 1974 + let mut actions = BTreeSet::new(); 1975 + actions.insert(RepoAction::Create); 1976 + actions.insert(RepoAction::Update); 1977 + actions.insert(RepoAction::Delete); 1978 + actions 1979 + } 1980 + }) 1981 + ); 1982 + 1983 + // Verify normalization preserves the literal NSID 1984 + assert_eq!(scope.to_string_normalized(), "repo:app.bsky.feed.*"); 1985 + 1986 + // Test that it does NOT grant access to "app.bsky.feed.post" 1987 + // (because "app.bsky.feed.*" is a literal NSID, not a pattern) 1988 + let specific_feed = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1989 + assert!(!scope.grants(&specific_feed)); 1990 + 1991 + // Test that only "repo:*" grants access to "app.bsky.feed.*" 1992 + let repo_all = Scope::parse("repo:*").unwrap(); 1993 + assert!(repo_all.grants(&scope)); 1994 + 1995 + // Test that "repo:app.bsky.feed.*" only grants itself 1996 + assert!(scope.grants(&scope)); 1997 + 1998 + // Test with actions 1999 + let scope_with_create = Scope::parse("repo:app.bsky.feed.*?action=create").unwrap(); 2000 + assert_eq!( 2001 + scope_with_create, 2002 + Scope::Repo(RepoScope { 2003 + collection: RepoCollection::Nsid("app.bsky.feed.*".to_string()), 2004 + actions: { 2005 + let mut actions = BTreeSet::new(); 2006 + actions.insert(RepoAction::Create); 2007 + actions 2008 + } 2009 + }) 2010 + ); 2011 + 2012 + // The full scope (with all actions) grants the create-only scope 2013 + assert!(scope.grants(&scope_with_create)); 2014 + // But the create-only scope does NOT grant the full scope 2015 + assert!(!scope_with_create.grants(&scope)); 2016 + 2017 + // Test parsing multiple scopes with NSID wildcards 2018 + let scopes = Scope::parse_multiple("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap(); 2019 + assert_eq!(scopes.len(), 3); 2020 + 2021 + // Test that parse_multiple_reduced properly reduces when "repo:*" is present 2022 + let reduced = Scope::parse_multiple_reduced("repo:app.bsky.feed.* repo:app.bsky.graph.* repo:*").unwrap(); 2023 + assert_eq!(reduced.len(), 1); 2024 + assert_eq!(reduced[0], repo_all); 2025 + } 2026 + 2027 + #[test] 2028 + fn test_include_scope_parsing() { 2029 + // Test basic include scope 2030 + let scope = Scope::parse("include:app.example.authFull").unwrap(); 2031 + assert_eq!( 2032 + scope, 2033 + Scope::Include(IncludeScope { 2034 + nsid: "app.example.authFull".to_string(), 2035 + aud: None, 2036 + }) 2037 + ); 2038 + 2039 + // Test include scope with audience 2040 + let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com").unwrap(); 2041 + assert_eq!( 2042 + scope, 2043 + Scope::Include(IncludeScope { 2044 + nsid: "app.example.authFull".to_string(), 2045 + aud: Some("did:web:api.example.com".to_string()), 2046 + }) 2047 + ); 2048 + 2049 + // Test include scope with URL-encoded audience (with fragment) 2050 + let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap(); 2051 + assert_eq!( 2052 + scope, 2053 + Scope::Include(IncludeScope { 2054 + nsid: "app.example.authFull".to_string(), 2055 + aud: Some("did:web:api.example.com#svc_chat".to_string()), 2056 + }) 2057 + ); 2058 + 2059 + // Test missing NSID 2060 + assert!(matches!( 2061 + Scope::parse("include"), 2062 + Err(ParseError::MissingResource) 2063 + )); 2064 + 2065 + // Test empty NSID with query params 2066 + assert!(matches!( 2067 + Scope::parse("include:?aud=did:example:123"), 2068 + Err(ParseError::MissingResource) 2069 + )); 2070 + } 2071 + 2072 + #[test] 2073 + fn test_include_scope_normalization() { 2074 + // Test normalization without audience 2075 + let scope = Scope::parse("include:com.example.authBasic").unwrap(); 2076 + assert_eq!(scope.to_string_normalized(), "include:com.example.authBasic"); 2077 + 2078 + // Test normalization with audience (no special chars) 2079 + let scope = Scope::parse("include:com.example.authBasic?aud=did:plc:xyz123").unwrap(); 2080 + assert_eq!( 2081 + scope.to_string_normalized(), 2082 + "include:com.example.authBasic?aud=did:plc:xyz123" 2083 + ); 2084 + 2085 + // Test normalization with URL encoding (fragment needs encoding) 2086 + let scope = Scope::parse("include:app.example.authFull?aud=did:web:api.example.com%23svc_chat").unwrap(); 2087 + let normalized = scope.to_string_normalized(); 2088 + assert_eq!( 2089 + normalized, 2090 + "include:app.example.authFull?aud=did:web:api.example.com%23svc_chat" 2091 + ); 2092 + } 2093 + 2094 + #[test] 2095 + fn test_include_scope_grants() { 2096 + let include1 = Scope::parse("include:app.example.authFull").unwrap(); 2097 + let include2 = Scope::parse("include:app.example.authBasic").unwrap(); 2098 + let include1_with_aud = Scope::parse("include:app.example.authFull?aud=did:plc:xyz").unwrap(); 2099 + let account = Scope::parse("account:email").unwrap(); 2100 + 2101 + // Include scopes only grant themselves (exact match) 2102 + assert!(include1.grants(&include1)); 2103 + assert!(!include1.grants(&include2)); 2104 + assert!(!include1.grants(&include1_with_aud)); // Different because aud differs 2105 + assert!(include1_with_aud.grants(&include1_with_aud)); 2106 + 2107 + // Include scopes don't grant other scope types 2108 + assert!(!include1.grants(&account)); 2109 + assert!(!account.grants(&include1)); 2110 + 2111 + // Include scopes don't grant atproto or transition 2112 + let atproto = Scope::parse("atproto").unwrap(); 2113 + let transition = Scope::parse("transition:generic").unwrap(); 2114 + assert!(!include1.grants(&atproto)); 2115 + assert!(!include1.grants(&transition)); 2116 + assert!(!atproto.grants(&include1)); 2117 + assert!(!transition.grants(&include1)); 2118 + } 2119 + 2120 + #[test] 2121 + fn test_parse_multiple_with_include() { 2122 + let scopes = Scope::parse_multiple("atproto include:app.example.auth repo:*").unwrap(); 2123 + assert_eq!(scopes.len(), 3); 2124 + assert_eq!(scopes[0], Scope::Atproto); 2125 + assert!(matches!(scopes[1], Scope::Include(_))); 2126 + assert!(matches!(scopes[2], Scope::Repo(_))); 2127 + 2128 + // Test with URL-encoded audience 2129 + let scopes = Scope::parse_multiple( 2130 + "include:app.example.auth?aud=did:web:api.example.com%23svc account:email" 2131 + ).unwrap(); 2132 + assert_eq!(scopes.len(), 2); 2133 + if let Scope::Include(inc) = &scopes[0] { 2134 + assert_eq!(inc.nsid, "app.example.auth"); 2135 + assert_eq!(inc.aud, Some("did:web:api.example.com#svc".to_string())); 2136 + } else { 2137 + panic!("Expected Include scope"); 2138 + } 2139 + } 2140 + 2141 + #[test] 2142 + fn test_parse_multiple_reduced_with_include() { 2143 + // Include scopes don't reduce each other (each is distinct) 2144 + let scopes = Scope::parse_multiple_reduced( 2145 + "include:app.example.auth include:app.example.other include:app.example.auth" 2146 + ).unwrap(); 2147 + assert_eq!(scopes.len(), 2); // Duplicates are removed 2148 + assert!(scopes.contains(&Scope::Include(IncludeScope { 2149 + nsid: "app.example.auth".to_string(), 2150 + aud: None, 2151 + }))); 2152 + assert!(scopes.contains(&Scope::Include(IncludeScope { 2153 + nsid: "app.example.other".to_string(), 2154 + aud: None, 2155 + }))); 2156 + 2157 + // Include scopes with different audiences are not duplicates 2158 + let scopes = Scope::parse_multiple_reduced( 2159 + "include:app.example.auth include:app.example.auth?aud=did:plc:xyz" 2160 + ).unwrap(); 2161 + assert_eq!(scopes.len(), 2); 2162 + } 2163 + 2164 + #[test] 2165 + fn test_serialize_multiple_with_include() { 2166 + let scopes = vec![ 2167 + Scope::parse("repo:*").unwrap(), 2168 + Scope::parse("include:app.example.authFull").unwrap(), 2169 + Scope::Atproto, 2170 + ]; 2171 + let result = Scope::serialize_multiple(&scopes); 2172 + assert_eq!(result, "atproto include:app.example.authFull repo:*"); 2173 + 2174 + // Test with URL-encoded audience 2175 + let scopes = vec![ 2176 + Scope::Include(IncludeScope { 2177 + nsid: "app.example.auth".to_string(), 2178 + aud: Some("did:web:api.example.com#svc".to_string()), 2179 + }), 2180 + ]; 2181 + let result = Scope::serialize_multiple(&scopes); 2182 + assert_eq!(result, "include:app.example.auth?aud=did:web:api.example.com%23svc"); 2183 + } 2184 + 2185 + #[test] 2186 + fn test_remove_scope_with_include() { 2187 + let scopes = vec![ 2188 + Scope::Atproto, 2189 + Scope::parse("include:app.example.auth").unwrap(), 2190 + Scope::parse("account:email").unwrap(), 2191 + ]; 2192 + let to_remove = Scope::parse("include:app.example.auth").unwrap(); 2193 + let result = Scope::remove_scope(&scopes, &to_remove); 2194 + assert_eq!(result.len(), 2); 2195 + assert!(!result.contains(&to_remove)); 2196 + assert!(result.contains(&Scope::Atproto)); 2197 + } 2198 + 2199 + #[test] 2200 + fn test_include_scope_roundtrip() { 2201 + // Test that parse and serialize are inverses 2202 + let original = "include:com.example.authBasicFeatures?aud=did:web:api.example.com%23svc_appview"; 2203 + let scope = Scope::parse(original).unwrap(); 2204 + let serialized = scope.to_string_normalized(); 2205 + let reparsed = Scope::parse(&serialized).unwrap(); 2206 + assert_eq!(scope, reparsed); 2207 } 2208 }
+17 -11
crates/atproto-oauth-aip/src/workflow.rs
··· 112 //! and protocol violations. 113 114 use anyhow::Result; 115 - use atproto_identity::url::URLBuilder; 116 use atproto_oauth::{ 117 jwk::WrappedJsonWebKey, 118 workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse}, 119 }; 120 use serde::Deserialize; 121 122 use crate::errors::OAuthWorkflowError; 123 ··· 522 access_token_type: &Option<&str>, 523 subject: &Option<&str>, 524 ) -> Result<ATProtocolSession> { 525 - let mut url_builder = URLBuilder::new(protected_resource_base); 526 - url_builder.path("/api/atprotocol/session"); 527 528 - if let Some(value) = access_token_type { 529 - url_builder.param("access_token_type", value); 530 - } 531 - 532 - if let Some(value) = subject { 533 - url_builder.param("sub", value); 534 } 535 536 - let url = url_builder.build(); 537 538 let response = http_client 539 - .get(url) 540 .bearer_auth(access_token) 541 .send() 542 .await
··· 112 //! and protocol violations. 113 114 use anyhow::Result; 115 + use atproto_identity::url::build_url; 116 use atproto_oauth::{ 117 jwk::WrappedJsonWebKey, 118 workflow::{OAuthRequest, OAuthRequestState, ParResponse, TokenResponse}, 119 }; 120 use serde::Deserialize; 121 + use std::iter; 122 123 use crate::errors::OAuthWorkflowError; 124 ··· 523 access_token_type: &Option<&str>, 524 subject: &Option<&str>, 525 ) -> Result<ATProtocolSession> { 526 + let mut url = build_url( 527 + protected_resource_base, 528 + "/api/atprotocol/session", 529 + iter::empty::<(&str, &str)>(), 530 + )?; 531 + { 532 + let mut pairs = url.query_pairs_mut(); 533 + if let Some(value) = access_token_type { 534 + pairs.append_pair("access_token_type", value); 535 + } 536 537 + if let Some(value) = subject { 538 + pairs.append_pair("sub", value); 539 + } 540 } 541 542 + let url: String = url.into(); 543 544 let response = http_client 545 + .get(&url) 546 .bearer_auth(access_token) 547 .send() 548 .await
+15 -12
crates/atproto-oauth-axum/src/bin/atproto-oauth-tool.rs
··· 30 use async_trait::async_trait; 31 use atproto_identity::{ 32 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 33 - key::{KeyData, KeyProvider, KeyType, generate_key, identify_key, to_public}, 34 - storage::DidDocumentStorage, 35 storage_lru::LruDidDocumentStorage, 36 }; 37 38 #[cfg(feature = "hickory-dns")] ··· 66 }; 67 68 #[derive(Clone)] 69 - pub struct SimpleKeyProvider { 70 keys: HashMap<String, KeyData>, 71 } 72 73 - impl Default for SimpleKeyProvider { 74 fn default() -> Self { 75 Self::new() 76 } 77 } 78 79 - impl SimpleKeyProvider { 80 pub fn new() -> Self { 81 Self { 82 keys: HashMap::new(), ··· 85 } 86 87 #[async_trait] 88 - impl KeyProvider for SimpleKeyProvider { 89 - async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> { 90 - Ok(self.keys.get(key_id).cloned()) 91 } 92 } 93 ··· 97 pub oauth_client_config: OAuthClientConfig, 98 pub oauth_storage: Arc<dyn OAuthRequestStorage + Send + Sync>, 99 pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 100 - pub key_provider: Arc<dyn KeyProvider + Send + Sync>, 101 } 102 103 #[derive(Clone, FromRef)] ··· 135 } 136 } 137 138 - impl FromRef<WebContext> for Arc<dyn KeyProvider> { 139 fn from_ref(context: &WebContext) -> Self { 140 - context.0.key_provider.clone() 141 } 142 } 143 ··· 305 oauth_client_config: oauth_client_config.clone(), 306 oauth_storage: Arc::new(LruOAuthRequestStorage::new(NonZeroUsize::new(256).unwrap())), 307 document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 308 - key_provider: Arc::new(SimpleKeyProvider { 309 keys: signing_key_storage, 310 }), 311 }));
··· 30 use async_trait::async_trait; 31 use atproto_identity::{ 32 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 33 + key::{KeyData, KeyResolver, KeyType, generate_key, identify_key, to_public}, 34 storage_lru::LruDidDocumentStorage, 35 + traits::DidDocumentStorage, 36 }; 37 38 #[cfg(feature = "hickory-dns")] ··· 66 }; 67 68 #[derive(Clone)] 69 + pub struct SimpleKeyResolver { 70 keys: HashMap<String, KeyData>, 71 } 72 73 + impl Default for SimpleKeyResolver { 74 fn default() -> Self { 75 Self::new() 76 } 77 } 78 79 + impl SimpleKeyResolver { 80 pub fn new() -> Self { 81 Self { 82 keys: HashMap::new(), ··· 85 } 86 87 #[async_trait] 88 + impl KeyResolver for SimpleKeyResolver { 89 + async fn resolve(&self, key_id: &str) -> anyhow::Result<KeyData> { 90 + self.keys 91 + .get(key_id) 92 + .cloned() 93 + .ok_or_else(|| anyhow::anyhow!("Key not found: {}", key_id)) 94 } 95 } 96 ··· 100 pub oauth_client_config: OAuthClientConfig, 101 pub oauth_storage: Arc<dyn OAuthRequestStorage + Send + Sync>, 102 pub document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 103 + pub key_resolver: Arc<dyn KeyResolver + Send + Sync>, 104 } 105 106 #[derive(Clone, FromRef)] ··· 138 } 139 } 140 141 + impl FromRef<WebContext> for Arc<dyn KeyResolver> { 142 fn from_ref(context: &WebContext) -> Self { 143 + context.0.key_resolver.clone() 144 } 145 } 146 ··· 308 oauth_client_config: oauth_client_config.clone(), 309 oauth_storage: Arc::new(LruOAuthRequestStorage::new(NonZeroUsize::new(256).unwrap())), 310 document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 311 + key_resolver: Arc::new(SimpleKeyResolver { 312 keys: signing_key_storage, 313 }), 314 }));
+7 -9
crates/atproto-oauth-axum/src/handle_complete.rs
··· 7 8 use anyhow::Result; 9 use atproto_identity::{ 10 - key::{KeyProvider, identify_key}, 11 - storage::DidDocumentStorage, 12 }; 13 use atproto_oauth::{ 14 resources::pds_resources, ··· 61 client: HttpClient, 62 oauth_request_storage: State<Arc<dyn OAuthRequestStorage>>, 63 did_document_storage: State<Arc<dyn DidDocumentStorage>>, 64 - key_provider: State<Arc<dyn KeyProvider>>, 65 Form(callback_form): Form<OAuthCallbackForm>, 66 ) -> Result<impl IntoResponse, OAuthCallbackError> { 67 let oauth_request = oauth_request_storage ··· 77 }); 78 } 79 80 - let private_signing_key_data = key_provider 81 - .get_private_key_by_id(&oauth_request.signing_public_key) 82 - .await?; 83 - 84 - let private_signing_key_data = 85 - private_signing_key_data.ok_or(OAuthCallbackError::NoSigningKeyFound)?; 86 87 let private_dpop_key_data = identify_key(&oauth_request.dpop_private_key)?; 88
··· 7 8 use anyhow::Result; 9 use atproto_identity::{ 10 + key::{KeyResolver, identify_key}, 11 + traits::DidDocumentStorage, 12 }; 13 use atproto_oauth::{ 14 resources::pds_resources, ··· 61 client: HttpClient, 62 oauth_request_storage: State<Arc<dyn OAuthRequestStorage>>, 63 did_document_storage: State<Arc<dyn DidDocumentStorage>>, 64 + key_resolver: State<Arc<dyn KeyResolver>>, 65 Form(callback_form): Form<OAuthCallbackForm>, 66 ) -> Result<impl IntoResponse, OAuthCallbackError> { 67 let oauth_request = oauth_request_storage ··· 77 }); 78 } 79 80 + let private_signing_key_data = key_resolver 81 + .resolve(&oauth_request.signing_public_key) 82 + .await 83 + .map_err(|_| OAuthCallbackError::NoSigningKeyFound)?; 84 85 let private_dpop_key_data = identify_key(&oauth_request.dpop_private_key)?; 86
+11 -10
crates/atproto-record/Cargo.toml
··· 14 keywords.workspace = true 15 categories.workspace = true 16 17 - [[bin]] 18 - name = "atproto-record-sign" 19 test = false 20 bench = false 21 doc = true 22 - required-features = ["clap", "tokio"] 23 - 24 - [[bin]] 25 - name = "atproto-record-verify" 26 - test = false 27 - bench = false 28 - doc = true 29 - required-features = ["clap", "tokio"] 30 31 [dependencies] 32 atproto-identity.workspace = true 33 34 anyhow.workspace = true 35 base64.workspace = true 36 serde_ipld_dagcbor.workspace = true 37 serde_json.workspace = true 38 serde.workspace = true ··· 41 tokio = { workspace = true, optional = true } 42 chrono = {version = "0.4.41", default-features = false, features = ["std", "now", "serde"]} 43 clap = { workspace = true, optional = true } 44 45 [features] 46 default = ["hickory-dns"]
··· 14 keywords.workspace = true 15 categories.workspace = true 16 17 + [[bin]] 18 + name = "atproto-record-cid" 19 test = false 20 bench = false 21 doc = true 22 + required-features = ["clap"] 23 24 [dependencies] 25 atproto-identity.workspace = true 26 27 anyhow.workspace = true 28 base64.workspace = true 29 + rand.workspace = true 30 serde_ipld_dagcbor.workspace = true 31 serde_json.workspace = true 32 serde.workspace = true ··· 35 tokio = { workspace = true, optional = true } 36 chrono = {version = "0.4.41", default-features = false, features = ["std", "now", "serde"]} 37 clap = { workspace = true, optional = true } 38 + cid = "0.11" 39 + multihash = "0.19" 40 + sha2 = { workspace = true } 41 + 42 + [dev-dependencies] 43 + async-trait = "0.1" 44 + tokio = { workspace = true, features = ["macros", "rt"] } 45 46 [features] 47 default = ["hickory-dns"]
+51 -68
crates/atproto-record/README.md
··· 1 # atproto-record 2 3 - Cryptographic signature operations and utilities for AT Protocol records. 4 5 ## Overview 6 7 - A comprehensive Rust library for working with AT Protocol records, providing cryptographic signature creation and verification, AT-URI parsing, and datetime utilities. Built on IPLD DAG-CBOR serialization with support for P-256, P-384, and K-256 elliptic curve cryptography. 8 9 ## Features 10 11 - - **Record signing**: Create cryptographic signatures on AT Protocol records following community.lexicon.attestation.signature specification 12 - - **Signature verification**: Verify record signatures against public keys with issuer validation 13 - **AT-URI parsing**: Parse and validate AT Protocol URIs (at://authority/collection/record_key) with robust error handling 14 - - **IPLD serialization**: DAG-CBOR serialization ensuring deterministic and verifiable record encoding 15 - - **Multi-curve support**: Full support for P-256, P-384, and K-256 elliptic curve signatures 16 - **DateTime utilities**: RFC 3339 datetime serialization with millisecond precision for consistent timestamp handling 17 - **Structured errors**: Type-safe error handling following project conventions with detailed error messages 18 19 ## CLI Tools 20 21 - The following command-line tools are available when built with the `clap` feature: 22 23 - - **`atproto-record-sign`**: Sign AT Protocol records with private keys, supporting flexible argument ordering 24 - - **`atproto-record-verify`**: Verify AT Protocol record signatures by validating cryptographic signatures against issuer DIDs and public keys 25 26 ## Library Usage 27 28 - ### Creating Signatures 29 30 ```rust 31 - use atproto_record::signature; 32 - use atproto_identity::key::identify_key; 33 use serde_json::json; 34 - 35 - // Parse the signing key from a did:key 36 - let key_data = identify_key("did:key:zQ3sh...")?; 37 - 38 - // The record to sign 39 - let record = json!({"$type": "app.bsky.feed.post", "text": "Hello world!"}); 40 41 - // Signature metadata (issuer is required, other fields are optional) 42 - let signature_object = json!({ 43 - "issuer": "did:plc:issuer" 44 - // Optional: "issuedAt", "purpose", "expiry", etc. 45 }); 46 47 - // Create the signed record with embedded signatures array 48 - let signed_record = signature::create( 49 - &key_data, 50 - &record, 51 - "did:plc:repository", 52 - "app.bsky.feed.post", 53 - signature_object 54 - ).await?; 55 ``` 56 57 - ### Verifying Signatures 58 59 ```rust 60 - use atproto_record::signature; 61 - use atproto_identity::key::identify_key; 62 63 - // Parse the public key for verification 64 - let issuer_key = identify_key("did:key:zQ3sh...")?; 65 66 - // Verify the signature (throws error if invalid) 67 - signature::verify( 68 - "did:plc:issuer", // Expected issuer DID 69 - &issuer_key, // Public key for verification 70 - signed_record, // The signed record 71 - "did:plc:repository", // Repository context 72 - "app.bsky.feed.post" // Collection context 73 - ).await?; 74 ``` 75 76 ### AT-URI Parsing ··· 110 111 ## Command Line Usage 112 113 - All CLI tools require the `clap` feature: 114 115 ```bash 116 # Build with CLI support 117 cargo build --features clap --bins 118 119 - # Sign a record 120 - cargo run --features clap --bin atproto-record-sign -- \ 121 - did:key:zQ3sh... # Signing key (did:key format) 122 - did:plc:issuer # Issuer DID 123 - record.json # Record file (or use -- for stdin) 124 - repository=did:plc:repo # Repository context 125 - collection=app.bsky.feed.post # Collection type 126 127 - # Sign with custom fields (e.g., issuedAt, purpose, expiry) 128 - cargo run --features clap --bin atproto-record-sign -- \ 129 - did:key:zQ3sh... did:plc:issuer record.json \ 130 - repository=did:plc:repo collection=app.bsky.feed.post \ 131 - issuedAt="2024-01-01T00:00:00.000Z" purpose="attestation" 132 133 - # Verify a signature 134 - cargo run --features clap --bin atproto-record-verify -- \ 135 - did:plc:issuer # Expected issuer DID 136 - did:key:zQ3sh... # Verification key 137 - signed.json # Signed record file 138 - repository=did:plc:repo # Repository context (must match signing) 139 - collection=app.bsky.feed.post # Collection type (must match signing) 140 141 - # Read from stdin 142 - echo '{"text":"Hello"}' | cargo run --features clap --bin atproto-record-sign -- \ 143 - did:key:zQ3sh... did:plc:issuer -- \ 144 - repository=did:plc:repo collection=app.bsky.feed.post 145 ``` 146 147 ## License 148 149 - MIT License
··· 1 # atproto-record 2 3 + Utilities for working with AT Protocol records. 4 5 ## Overview 6 7 + A Rust library for working with AT Protocol records, providing AT-URI parsing, TID generation, datetime formatting, and CID generation. Built on IPLD DAG-CBOR serialization for deterministic content addressing. 8 9 ## Features 10 11 - **AT-URI parsing**: Parse and validate AT Protocol URIs (at://authority/collection/record_key) with robust error handling 12 + - **TID generation**: Timestamp-based identifiers for AT Protocol records with microsecond precision 13 + - **CID generation**: Content Identifier generation using DAG-CBOR serialization and SHA-256 hashing 14 - **DateTime utilities**: RFC 3339 datetime serialization with millisecond precision for consistent timestamp handling 15 + - **Typed records**: Type-safe record handling with lexicon type validation 16 + - **Bytes handling**: Base64 encoding/decoding for binary data in AT Protocol records 17 - **Structured errors**: Type-safe error handling following project conventions with detailed error messages 18 19 ## CLI Tools 20 21 + The following command-line tool is available when built with the `clap` feature: 22 23 + - **`atproto-record-cid`**: Generate CID (Content Identifier) for AT Protocol records from JSON input 24 25 ## Library Usage 26 27 + ### Generating CIDs 28 29 ```rust 30 use serde_json::json; 31 + use cid::Cid; 32 + use sha2::{Digest, Sha256}; 33 + use multihash::Multihash; 34 35 + // Serialize a record to DAG-CBOR and generate its CID 36 + let record = json!({ 37 + "$type": "app.bsky.feed.post", 38 + "text": "Hello world!", 39 + "createdAt": "2024-01-01T00:00:00.000Z" 40 }); 41 42 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(&record)?; 43 + let hash = Sha256::digest(&dag_cbor_bytes); 44 + let multihash = Multihash::wrap(0x12, &hash)?; 45 + let cid = Cid::new_v1(0x71, multihash); 46 + 47 + println!("Record CID: {}", cid); 48 ``` 49 50 + ### Generating TIDs 51 52 ```rust 53 + use atproto_record::tid::Tid; 54 55 + // Generate a new timestamp-based identifier 56 + let tid = Tid::new(); 57 + println!("TID: {}", tid); // e.g., "3l2k4j5h6g7f8d9s" 58 59 + // TIDs are sortable by creation time 60 + let tid1 = Tid::new(); 61 + std::thread::sleep(std::time::Duration::from_millis(1)); 62 + let tid2 = Tid::new(); 63 + assert!(tid1 < tid2); 64 ``` 65 66 ### AT-URI Parsing ··· 100 101 ## Command Line Usage 102 103 + The CLI tool requires the `clap` feature: 104 105 ```bash 106 # Build with CLI support 107 cargo build --features clap --bins 108 109 + # Generate CID from JSON file 110 + cat record.json | cargo run --features clap --bin atproto-record-cid 111 112 + # Generate CID from inline JSON 113 + echo '{"$type":"app.bsky.feed.post","text":"Hello!"}' | cargo run --features clap --bin atproto-record-cid 114 115 + # Example with a complete AT Protocol record 116 + cat <<EOF | cargo run --features clap --bin atproto-record-cid 117 + { 118 + "$type": "app.bsky.feed.post", 119 + "text": "Hello AT Protocol!", 120 + "createdAt": "2024-01-01T00:00:00.000Z" 121 + } 122 + EOF 123 + ``` 124 125 + The tool outputs the CID in base32 format: 126 + ``` 127 + bafyreibjzlvhtyxnhbvvzl3gj4qmg2ufl2jbhh5qr3gvvxlm7ksf3qwxqq 128 ``` 129 130 ## License 131 132 + MIT License
+150
crates/atproto-record/src/bin/atproto-record-cid.rs
···
··· 1 + //! Command-line tool for generating CIDs from JSON records. 2 + //! 3 + //! This tool reads JSON from stdin, serializes it using IPLD DAG-CBOR format, 4 + //! and outputs the corresponding CID (Content Identifier) using CIDv1 with 5 + //! SHA-256 hashing. This matches the AT Protocol specification for content 6 + //! addressing of records. 7 + //! 8 + //! # AT Protocol CID Format 9 + //! 10 + //! The tool generates CIDs that follow the AT Protocol specification: 11 + //! - **CID Version**: CIDv1 12 + //! - **Codec**: DAG-CBOR (0x71) 13 + //! - **Hash Function**: SHA-256 (0x12) 14 + //! - **Encoding**: Base32 (default for CIDv1) 15 + //! 16 + //! # Example Usage 17 + //! 18 + //! ```bash 19 + //! # Generate CID from a simple JSON object 20 + //! echo '{"text":"Hello, AT Protocol!"}' | cargo run --features clap --bin atproto-record-cid 21 + //! 22 + //! # Generate CID from a file 23 + //! cat post.json | cargo run --features clap --bin atproto-record-cid 24 + //! 25 + //! # Generate CID from a complex record 26 + //! echo '{ 27 + //! "$type": "app.bsky.feed.post", 28 + //! "text": "Hello world", 29 + //! "createdAt": "2025-01-19T10:00:00.000Z" 30 + //! }' | cargo run --features clap --bin atproto-record-cid 31 + //! ``` 32 + //! 33 + //! # Output Format 34 + //! 35 + //! The tool outputs the CID as a single line string in the format: 36 + //! ```text 37 + //! bafyreibjzlvhtyxnhbvvzl3gj4qmg2ufl2jbhh5qr3gvvxlm7ksf3qwxqq 38 + //! ``` 39 + //! 40 + //! # Error Handling 41 + //! 42 + //! The tool will return an error if: 43 + //! - Input is not valid JSON 44 + //! - JSON cannot be serialized to DAG-CBOR 45 + //! - CID generation fails 46 + //! 47 + //! # Technical Details 48 + //! 49 + //! The CID generation process: 50 + //! 1. Read JSON from stdin 51 + //! 2. Parse JSON into serde_json::Value 52 + //! 3. Serialize to DAG-CBOR bytes using serde_ipld_dagcbor 53 + //! 4. Hash the bytes using SHA-256 54 + //! 5. Create CIDv1 with DAG-CBOR codec 55 + //! 6. Output the CID string 56 + 57 + use anyhow::Result; 58 + use atproto_record::errors::CliError; 59 + use cid::Cid; 60 + use clap::Parser; 61 + use multihash::Multihash; 62 + use sha2::{Digest, Sha256}; 63 + use std::io::{self, Read}; 64 + 65 + /// AT Protocol Record CID Generator 66 + #[derive(Parser)] 67 + #[command( 68 + name = "atproto-record-cid", 69 + version, 70 + about = "Generate CID for AT Protocol DAG-CBOR records from JSON", 71 + long_about = " 72 + A command-line tool for generating Content Identifiers (CIDs) from JSON records 73 + using the AT Protocol DAG-CBOR serialization format. 74 + 75 + The tool reads JSON from stdin, serializes it using IPLD DAG-CBOR format, and 76 + outputs the corresponding CID using CIDv1 with SHA-256 hashing. This matches 77 + the AT Protocol specification for content addressing of records. 78 + 79 + CID FORMAT: 80 + Version: CIDv1 81 + Codec: DAG-CBOR (0x71) 82 + Hash: SHA-256 (0x12) 83 + Encoding: Base32 (default for CIDv1) 84 + 85 + EXAMPLES: 86 + # Generate CID from stdin: 87 + echo '{\"text\":\"Hello!\"}' | atproto-record-cid 88 + 89 + # Generate CID from a file: 90 + cat post.json | atproto-record-cid 91 + 92 + # Complex record with AT Protocol fields: 93 + echo '{ 94 + \"$type\": \"app.bsky.feed.post\", 95 + \"text\": \"Hello world\", 96 + \"createdAt\": \"2025-01-19T10:00:00.000Z\" 97 + }' | atproto-record-cid 98 + 99 + OUTPUT: 100 + The tool outputs a single line containing the CID: 101 + bafyreibjzlvhtyxnhbvvzl3gj4qmg2ufl2jbhh5qr3gvvxlm7ksf3qwxqq 102 + 103 + NOTES: 104 + - Input must be valid JSON 105 + - The same JSON input will always produce the same CID 106 + - Field order in JSON objects may affect the CID due to DAG-CBOR serialization 107 + - Special AT Protocol fields like $type, $sig, and $link are preserved 108 + " 109 + )] 110 + struct Args {} 111 + 112 + fn main() -> Result<()> { 113 + let _args = Args::parse(); 114 + 115 + // Read JSON from stdin 116 + let mut stdin_content = String::new(); 117 + io::stdin() 118 + .read_to_string(&mut stdin_content) 119 + .map_err(|_| CliError::StdinReadFailed)?; 120 + 121 + // Parse JSON 122 + let json_value: serde_json::Value = 123 + serde_json::from_str(&stdin_content).map_err(|_| CliError::StdinJsonParseFailed)?; 124 + 125 + // Serialize to DAG-CBOR 126 + let dag_cbor_bytes = serde_ipld_dagcbor::to_vec(&json_value).map_err(|error| { 127 + CliError::RecordSerializationFailed { 128 + error: error.to_string(), 129 + } 130 + })?; 131 + 132 + // Hash the bytes using SHA-256 133 + // Code 0x12 is SHA-256, size 32 bytes 134 + let mut hasher = Sha256::new(); 135 + hasher.update(&dag_cbor_bytes); 136 + let hash_result = hasher.finalize(); 137 + 138 + let multihash = 139 + Multihash::wrap(0x12, &hash_result).map_err(|error| CliError::CidGenerationFailed { 140 + error: error.to_string(), 141 + })?; 142 + 143 + // Create CIDv1 with DAG-CBOR codec (0x71) 144 + let cid = Cid::new_v1(0x71, multihash); 145 + 146 + // Output the CID 147 + println!("{}", cid); 148 + 149 + Ok(()) 150 + }
-192
crates/atproto-record/src/bin/atproto-record-sign.rs
··· 1 - //! Command-line tool for signing AT Protocol records with cryptographic signatures. 2 - //! 3 - //! This tool creates cryptographic signatures on AT Protocol records using ECDSA 4 - //! signatures with IPLD DAG-CBOR serialization. It supports flexible argument 5 - //! ordering and customizable signature metadata. 6 - 7 - use anyhow::Result; 8 - use atproto_identity::{ 9 - key::{KeyData, identify_key}, 10 - resolve::{InputType, parse_input}, 11 - }; 12 - use atproto_record::errors::CliError; 13 - use atproto_record::signature::create; 14 - use clap::Parser; 15 - use serde_json::json; 16 - use std::{ 17 - collections::HashMap, 18 - fs, 19 - io::{self, Read}, 20 - }; 21 - 22 - /// AT Protocol Record Signing CLI 23 - #[derive(Parser)] 24 - #[command( 25 - name = "atproto-record-sign", 26 - version, 27 - about = "Sign AT Protocol records with cryptographic signatures", 28 - long_about = " 29 - A command-line tool for signing AT Protocol records using DID keys. Reads a JSON 30 - record from a file or stdin, applies a cryptographic signature, and outputs the 31 - signed record with embedded signature metadata. 32 - 33 - The tool accepts flexible argument ordering with DID keys, issuer DIDs, record 34 - inputs, and key=value parameters for repository, collection, and custom metadata. 35 - 36 - REQUIRED PARAMETERS: 37 - repository=<DID> Repository context for the signature 38 - collection=<name> Collection type context for the signature 39 - 40 - OPTIONAL PARAMETERS: 41 - Any additional key=value pairs are included in the signature metadata 42 - (e.g., issuedAt=<timestamp>, purpose=<string>, expiry=<timestamp>) 43 - 44 - EXAMPLES: 45 - # Basic usage: 46 - atproto-record-sign \\ 47 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 48 - ./post.json \\ 49 - did:plc:tgudj2fjm77pzkuawquqhsxm \\ 50 - repository=did:plc:4zutorghlchjxzgceklue4la \\ 51 - collection=app.bsky.feed.post 52 - 53 - # With custom metadata: 54 - atproto-record-sign \\ 55 - did:key:z42tv1pb3... ./post.json did:plc:issuer... \\ 56 - repository=did:plc:repo... collection=app.bsky.feed.post \\ 57 - issuedAt=\"2024-01-01T00:00:00.000Z\" purpose=\"attestation\" 58 - 59 - # Reading from stdin: 60 - echo '{\"text\":\"Hello!\"}' | atproto-record-sign \\ 61 - did:key:z42tv1pb3... -- did:plc:issuer... \\ 62 - repository=did:plc:repo... collection=app.bsky.feed.post 63 - 64 - SIGNATURE PROCESS: 65 - - Creates $sig object with repository, collection, and custom metadata 66 - - Serializes record using IPLD DAG-CBOR format 67 - - Generates ECDSA signatures using P-256, P-384, or K-256 curves 68 - - Embeds signatures with issuer and any provided metadata 69 - " 70 - )] 71 - struct Args { 72 - /// All arguments - flexible parsing handles DID keys, issuer DIDs, files, and key=value pairs 73 - args: Vec<String>, 74 - } 75 - #[tokio::main] 76 - async fn main() -> Result<()> { 77 - let args = Args::parse(); 78 - 79 - let arguments = args.args.into_iter(); 80 - 81 - let mut collection: Option<String> = None; 82 - let mut repository: Option<String> = None; 83 - let mut record: Option<serde_json::Value> = None; 84 - let mut issuer: Option<String> = None; 85 - let mut key_data: Option<KeyData> = None; 86 - let mut signature_extras: HashMap<String, String> = HashMap::default(); 87 - 88 - for argument in arguments { 89 - if let Some((key, value)) = argument.split_once("=") { 90 - match key { 91 - "collection" => { 92 - collection = Some(value.to_string()); 93 - } 94 - "repository" => { 95 - repository = Some(value.to_string()); 96 - } 97 - _ => { 98 - signature_extras.insert(key.to_string(), value.to_string()); 99 - } 100 - } 101 - } else if argument.starts_with("did:key:") { 102 - // Parse the did:key to extract key data for signing 103 - key_data = Some(identify_key(&argument)?); 104 - } else if argument.starts_with("did:") { 105 - match parse_input(&argument) { 106 - Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => { 107 - issuer = Some(did); 108 - } 109 - Ok(_) => { 110 - return Err(CliError::UnsupportedDidMethod { 111 - method: argument.clone(), 112 - } 113 - .into()); 114 - } 115 - Err(_) => { 116 - return Err(CliError::DidParseFailed { 117 - did: argument.clone(), 118 - } 119 - .into()); 120 - } 121 - } 122 - } else if argument == "--" { 123 - // Read record from stdin 124 - if record.is_none() { 125 - let mut stdin_content = String::new(); 126 - io::stdin() 127 - .read_to_string(&mut stdin_content) 128 - .map_err(|_| CliError::StdinReadFailed)?; 129 - record = Some( 130 - serde_json::from_str(&stdin_content) 131 - .map_err(|_| CliError::StdinJsonParseFailed)?, 132 - ); 133 - } else { 134 - return Err(CliError::UnexpectedArgument { 135 - argument: argument.clone(), 136 - } 137 - .into()); 138 - } 139 - } else { 140 - // Assume it's a file path to read the record from 141 - if record.is_none() { 142 - let file_content = 143 - fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed { 144 - path: argument.clone(), 145 - })?; 146 - record = Some(serde_json::from_str(&file_content).map_err(|_| { 147 - CliError::FileJsonParseFailed { 148 - path: argument.clone(), 149 - } 150 - })?); 151 - } else { 152 - return Err(CliError::UnexpectedArgument { 153 - argument: argument.clone(), 154 - } 155 - .into()); 156 - } 157 - } 158 - } 159 - 160 - let collection = collection.ok_or(CliError::MissingRequiredValue { 161 - name: "collection".to_string(), 162 - })?; 163 - let repository = repository.ok_or(CliError::MissingRequiredValue { 164 - name: "repository".to_string(), 165 - })?; 166 - let record = record.ok_or(CliError::MissingRequiredValue { 167 - name: "record".to_string(), 168 - })?; 169 - let issuer = issuer.ok_or(CliError::MissingRequiredValue { 170 - name: "issuer".to_string(), 171 - })?; 172 - let key_data = key_data.ok_or(CliError::MissingRequiredValue { 173 - name: "signing_key".to_string(), 174 - })?; 175 - 176 - // Write "issuer" key to signature_extras 177 - signature_extras.insert("issuer".to_string(), issuer); 178 - 179 - let signature_object = json!(signature_extras); 180 - let signed_record = create( 181 - &key_data, 182 - &record, 183 - &repository, 184 - &collection, 185 - signature_object, 186 - )?; 187 - 188 - let pretty_signed_record = serde_json::to_string_pretty(&signed_record); 189 - println!("{}", pretty_signed_record.unwrap()); 190 - 191 - Ok(()) 192 - }
···
-166
crates/atproto-record/src/bin/atproto-record-verify.rs
··· 1 - //! Command-line tool for verifying cryptographic signatures on AT Protocol records. 2 - //! 3 - //! This tool validates signatures on AT Protocol records by reconstructing the 4 - //! signed content and verifying ECDSA signatures against public keys. It ensures 5 - //! that records have valid signatures from specified issuers. 6 - 7 - use anyhow::Result; 8 - use atproto_identity::{ 9 - key::{KeyData, identify_key}, 10 - resolve::{InputType, parse_input}, 11 - }; 12 - use atproto_record::errors::CliError; 13 - use atproto_record::signature::verify; 14 - use clap::Parser; 15 - use std::{ 16 - fs, 17 - io::{self, Read}, 18 - }; 19 - 20 - /// AT Protocol Record Verification CLI 21 - #[derive(Parser)] 22 - #[command( 23 - name = "atproto-record-verify", 24 - version, 25 - about = "Verify cryptographic signatures of AT Protocol records", 26 - long_about = " 27 - A command-line tool for verifying cryptographic signatures of AT Protocol records. 28 - Reads a signed JSON record from a file or stdin, validates the embedded signatures 29 - using a public key, and reports verification success or failure. 30 - 31 - The tool accepts flexible argument ordering with issuer DIDs, verification keys, 32 - record inputs, and key=value parameters for repository and collection context. 33 - 34 - REQUIRED PARAMETERS: 35 - repository=<DID> Repository context used during signing 36 - collection=<name> Collection type context used during signing 37 - 38 - EXAMPLES: 39 - # Basic verification: 40 - atproto-record-verify \\ 41 - did:plc:tgudj2fjm77pzkuawquqhsxm \\ 42 - did:key:z42tv1pb3Dzog28Q1udyieg1YJP3x1Un5vraE1bttXeCDSpW \\ 43 - ./signed_post.json \\ 44 - repository=did:plc:4zutorghlchjxzgceklue4la \\ 45 - collection=app.bsky.feed.post 46 - 47 - # Verify from stdin: 48 - echo '{\"signatures\":[...]}' | atproto-record-verify \\ 49 - did:plc:issuer... did:key:z42tv1pb3... -- \\ 50 - repository=did:plc:repo... collection=app.bsky.feed.post 51 - 52 - VERIFICATION PROCESS: 53 - - Extracts signatures from the signatures array 54 - - Finds signatures matching the specified issuer DID 55 - - Reconstructs $sig object with repository and collection context 56 - - Validates ECDSA signatures using P-256 or K-256 curves 57 - " 58 - )] 59 - struct Args { 60 - /// All arguments - flexible parsing handles issuer DIDs, verification keys, files, and key=value pairs 61 - args: Vec<String>, 62 - } 63 - #[tokio::main] 64 - async fn main() -> Result<()> { 65 - let args = Args::parse(); 66 - 67 - let arguments = args.args.into_iter(); 68 - 69 - let mut collection: Option<String> = None; 70 - let mut repository: Option<String> = None; 71 - let mut record: Option<serde_json::Value> = None; 72 - let mut issuer: Option<String> = None; 73 - let mut key_data: Option<KeyData> = None; 74 - 75 - for argument in arguments { 76 - if let Some((key, value)) = argument.split_once("=") { 77 - match key { 78 - "collection" => { 79 - collection = Some(value.to_string()); 80 - } 81 - "repository" => { 82 - repository = Some(value.to_string()); 83 - } 84 - _ => {} 85 - } 86 - } else if argument.starts_with("did:key:") { 87 - // Parse the did:key to extract key data for verification 88 - key_data = Some(identify_key(&argument)?); 89 - } else if argument.starts_with("did:") { 90 - match parse_input(&argument) { 91 - Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => { 92 - issuer = Some(did); 93 - } 94 - Ok(_) => { 95 - return Err(CliError::UnsupportedDidMethod { 96 - method: argument.clone(), 97 - } 98 - .into()); 99 - } 100 - Err(_) => { 101 - return Err(CliError::DidParseFailed { 102 - did: argument.clone(), 103 - } 104 - .into()); 105 - } 106 - } 107 - } else if argument == "--" { 108 - // Read record from stdin 109 - if record.is_none() { 110 - let mut stdin_content = String::new(); 111 - io::stdin() 112 - .read_to_string(&mut stdin_content) 113 - .map_err(|_| CliError::StdinReadFailed)?; 114 - record = Some( 115 - serde_json::from_str(&stdin_content) 116 - .map_err(|_| CliError::StdinJsonParseFailed)?, 117 - ); 118 - } else { 119 - return Err(CliError::UnexpectedArgument { 120 - argument: argument.clone(), 121 - } 122 - .into()); 123 - } 124 - } else { 125 - // Assume it's a file path to read the record from 126 - if record.is_none() { 127 - let file_content = 128 - fs::read_to_string(&argument).map_err(|_| CliError::FileReadFailed { 129 - path: argument.clone(), 130 - })?; 131 - record = Some(serde_json::from_str(&file_content).map_err(|_| { 132 - CliError::FileJsonParseFailed { 133 - path: argument.clone(), 134 - } 135 - })?); 136 - } else { 137 - return Err(CliError::UnexpectedArgument { 138 - argument: argument.clone(), 139 - } 140 - .into()); 141 - } 142 - } 143 - } 144 - 145 - let collection = collection.ok_or(CliError::MissingRequiredValue { 146 - name: "collection".to_string(), 147 - })?; 148 - let repository = repository.ok_or(CliError::MissingRequiredValue { 149 - name: "repository".to_string(), 150 - })?; 151 - let record = record.ok_or(CliError::MissingRequiredValue { 152 - name: "record".to_string(), 153 - })?; 154 - let issuer = issuer.ok_or(CliError::MissingRequiredValue { 155 - name: "issuer".to_string(), 156 - })?; 157 - let key_data = key_data.ok_or(CliError::MissingRequiredValue { 158 - name: "key".to_string(), 159 - })?; 160 - 161 - verify(&issuer, &key_data, record, &repository, &collection)?; 162 - 163 - println!("OK"); 164 - 165 - Ok(()) 166 - }
···
+60 -1
crates/atproto-record/src/errors.rs
··· 14 //! Errors occurring during AT-URI parsing and validation. 15 //! Error codes: aturi-1 through aturi-9 16 //! 17 //! ### `CliError` (Domain: cli) 18 //! Command-line interface specific errors for file I/O, argument parsing, and DID validation. 19 - //! Error codes: cli-1 through cli-8 20 //! 21 //! ## Error Format 22 //! ··· 222 EmptyRecordKey, 223 } 224 225 /// Errors specific to command-line interface operations. 226 /// 227 /// This enum covers failures in CLI argument parsing, file I/O operations, ··· 276 MissingRequiredValue { 277 /// The name of the missing value 278 name: String, 279 }, 280 }
··· 14 //! Errors occurring during AT-URI parsing and validation. 15 //! Error codes: aturi-1 through aturi-9 16 //! 17 + //! ### `TidError` (Domain: tid) 18 + //! Errors occurring during TID (Timestamp Identifier) parsing and decoding. 19 + //! Error codes: tid-1 through tid-3 20 + //! 21 //! ### `CliError` (Domain: cli) 22 //! Command-line interface specific errors for file I/O, argument parsing, and DID validation. 23 + //! Error codes: cli-1 through cli-10 24 //! 25 //! ## Error Format 26 //! ··· 226 EmptyRecordKey, 227 } 228 229 + /// Errors that can occur during TID (Timestamp Identifier) operations. 230 + /// 231 + /// This enum covers all validation failures when parsing and decoding TIDs, 232 + /// including format violations, invalid characters, and encoding errors. 233 + #[derive(Debug, Error)] 234 + pub enum TidError { 235 + /// Error when TID string length is invalid. 236 + /// 237 + /// This error occurs when a TID string is not exactly 13 characters long, 238 + /// which is required by the TID specification. 239 + #[error("error-atproto-record-tid-1 Invalid TID length: expected {expected}, got {actual}")] 240 + InvalidLength { 241 + /// Expected length (always 13) 242 + expected: usize, 243 + /// Actual length of the provided string 244 + actual: usize, 245 + }, 246 + 247 + /// Error when TID contains an invalid character. 248 + /// 249 + /// This error occurs when a TID string contains a character outside the 250 + /// base32-sortable character set (234567abcdefghijklmnopqrstuvwxyz). 251 + #[error("error-atproto-record-tid-2 Invalid character '{character}' at position {position}")] 252 + InvalidCharacter { 253 + /// The invalid character 254 + character: char, 255 + /// Position in the string (0-indexed) 256 + position: usize, 257 + }, 258 + 259 + /// Error when TID format is invalid. 260 + /// 261 + /// This error occurs when the TID violates structural requirements, 262 + /// such as having the top bit set (which must always be 0). 263 + #[error("error-atproto-record-tid-3 Invalid TID format: {reason}")] 264 + InvalidFormat { 265 + /// Reason for the format violation 266 + reason: String, 267 + }, 268 + } 269 + 270 /// Errors specific to command-line interface operations. 271 /// 272 /// This enum covers failures in CLI argument parsing, file I/O operations, ··· 321 MissingRequiredValue { 322 /// The name of the missing value 323 name: String, 324 + }, 325 + 326 + /// Occurs when record serialization to DAG-CBOR fails 327 + #[error("error-atproto-record-cli-9 Failed to serialize record to DAG-CBOR: {error}")] 328 + RecordSerializationFailed { 329 + /// The underlying serialization error 330 + error: String, 331 + }, 332 + 333 + /// Occurs when CID generation fails 334 + #[error("error-atproto-record-cli-10 Failed to generate CID: {error}")] 335 + CidGenerationFailed { 336 + /// The underlying CID generation error 337 + error: String, 338 }, 339 }
+205
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
···
··· 1 + //! AT Protocol rich text facet types. 2 + //! 3 + //! This module provides types for annotating rich text content with semantic 4 + //! meaning, based on the `app.bsky.richtext.facet` lexicon. Facets enable 5 + //! mentions, links, hashtags, and other structured metadata to be attached 6 + //! to specific byte ranges within text content. 7 + //! 8 + //! # Overview 9 + //! 10 + //! Facets consist of: 11 + //! - A byte range (start/end indices in UTF-8 encoded text) 12 + //! - One or more features (mention, link, tag) that apply to that range 13 + //! 14 + //! # Example 15 + //! 16 + //! ```ignore 17 + //! use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, ByteSlice, FacetFeature, Mention}; 18 + //! 19 + //! // Create a mention facet for "@alice.bsky.social" 20 + //! let facet = Facet { 21 + //! index: ByteSlice { byte_start: 0, byte_end: 19 }, 22 + //! features: vec![ 23 + //! FacetFeature::Mention(Mention { 24 + //! did: "did:plc:alice123".to_string(), 25 + //! }) 26 + //! ], 27 + //! }; 28 + //! ``` 29 + 30 + use serde::{Deserialize, Serialize}; 31 + 32 + /// Byte range specification for facet features. 33 + /// 34 + /// Specifies the sub-string range a facet feature applies to using 35 + /// zero-indexed byte offsets in UTF-8 encoded text. Start index is 36 + /// inclusive, end index is exclusive. 37 + /// 38 + /// # Example 39 + /// 40 + /// ```ignore 41 + /// use atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice; 42 + /// 43 + /// // Represents bytes 0-5 of the text 44 + /// let slice = ByteSlice { 45 + /// byte_start: 0, 46 + /// byte_end: 5, 47 + /// }; 48 + /// ``` 49 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 50 + #[serde(rename_all = "camelCase")] 51 + pub struct ByteSlice { 52 + /// Starting byte index (inclusive) 53 + pub byte_start: usize, 54 + 55 + /// Ending byte index (exclusive) 56 + pub byte_end: usize, 57 + } 58 + 59 + /// Mention facet feature for referencing another account. 60 + /// 61 + /// The text content typically displays a handle with '@' prefix (e.g., "@alice.bsky.social"), 62 + /// but the facet reference must use the account's DID for stable identification. 63 + /// 64 + /// # Example 65 + /// 66 + /// ```ignore 67 + /// use atproto_record::lexicon::app::bsky::richtext::facet::Mention; 68 + /// 69 + /// let mention = Mention { 70 + /// did: "did:plc:alice123".to_string(), 71 + /// }; 72 + /// ``` 73 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 74 + pub struct Mention { 75 + /// DID of the mentioned account 76 + pub did: String, 77 + } 78 + 79 + /// Link facet feature for URL references. 80 + /// 81 + /// The text content may be simplified or truncated for display purposes, 82 + /// but the facet reference should contain the complete, valid URL. 83 + /// 84 + /// # Example 85 + /// 86 + /// ```ignore 87 + /// use atproto_record::lexicon::app::bsky::richtext::facet::Link; 88 + /// 89 + /// let link = Link { 90 + /// uri: "https://example.com/full/path".to_string(), 91 + /// }; 92 + /// ``` 93 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 94 + pub struct Link { 95 + /// Complete URI/URL for the link 96 + pub uri: String, 97 + } 98 + 99 + /// Tag facet feature for hashtags. 100 + /// 101 + /// The text content typically includes a '#' prefix for display, 102 + /// but the facet reference should contain only the tag text without the prefix. 103 + /// 104 + /// # Example 105 + /// 106 + /// ```ignore 107 + /// use atproto_record::lexicon::app::bsky::richtext::facet::Tag; 108 + /// 109 + /// // For text "#atproto", store just "atproto" 110 + /// let tag = Tag { 111 + /// tag: "atproto".to_string(), 112 + /// }; 113 + /// ``` 114 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 115 + pub struct Tag { 116 + /// Tag text without '#' prefix 117 + pub tag: String, 118 + } 119 + 120 + /// Discriminated union of facet feature types. 121 + /// 122 + /// Represents the different types of semantic annotations that can be 123 + /// applied to text ranges. Each variant corresponds to a specific lexicon 124 + /// type in the `app.bsky.richtext.facet` namespace. 125 + /// 126 + /// # Example 127 + /// 128 + /// ```ignore 129 + /// use atproto_record::lexicon::app::bsky::richtext::facet::{FacetFeature, Mention, Link, Tag}; 130 + /// 131 + /// // Create different feature types 132 + /// let mention = FacetFeature::Mention(Mention { 133 + /// did: "did:plc:alice123".to_string(), 134 + /// }); 135 + /// 136 + /// let link = FacetFeature::Link(Link { 137 + /// uri: "https://example.com".to_string(), 138 + /// }); 139 + /// 140 + /// let tag = FacetFeature::Tag(Tag { 141 + /// tag: "rust".to_string(), 142 + /// }); 143 + /// ``` 144 + #[derive(Serialize, Deserialize, Clone, PartialEq)] 145 + #[cfg_attr(debug_assertions, derive(Debug))] 146 + #[serde(tag = "$type")] 147 + pub enum FacetFeature { 148 + /// Account mention feature 149 + #[serde(rename = "app.bsky.richtext.facet#mention")] 150 + Mention(Mention), 151 + 152 + /// URL link feature 153 + #[serde(rename = "app.bsky.richtext.facet#link")] 154 + Link(Link), 155 + 156 + /// Hashtag feature 157 + #[serde(rename = "app.bsky.richtext.facet#tag")] 158 + Tag(Tag), 159 + } 160 + 161 + /// Rich text facet annotation. 162 + /// 163 + /// Associates one or more semantic features with a specific byte range 164 + /// within text content. Multiple features can apply to the same range 165 + /// (e.g., a URL that is also a hashtag). 166 + /// 167 + /// # Example 168 + /// 169 + /// ```ignore 170 + /// use atproto_record::lexicon::app::bsky::richtext::facet::{ 171 + /// Facet, ByteSlice, FacetFeature, Mention, Link 172 + /// }; 173 + /// 174 + /// // Annotate "@alice.bsky.social" at bytes 0-19 175 + /// let facet = Facet { 176 + /// index: ByteSlice { byte_start: 0, byte_end: 19 }, 177 + /// features: vec![ 178 + /// FacetFeature::Mention(Mention { 179 + /// did: "did:plc:alice123".to_string(), 180 + /// }), 181 + /// ], 182 + /// }; 183 + /// 184 + /// // Multiple features for the same range 185 + /// let multi_facet = Facet { 186 + /// index: ByteSlice { byte_start: 20, byte_end: 35 }, 187 + /// features: vec![ 188 + /// FacetFeature::Link(Link { 189 + /// uri: "https://example.com".to_string(), 190 + /// }), 191 + /// FacetFeature::Tag(Tag { 192 + /// tag: "example".to_string(), 193 + /// }), 194 + /// ], 195 + /// }; 196 + /// ``` 197 + #[derive(Serialize, Deserialize, Clone, PartialEq)] 198 + #[cfg_attr(debug_assertions, derive(Debug))] 199 + pub struct Facet { 200 + /// Byte range this facet applies to 201 + pub index: ByteSlice, 202 + 203 + /// Semantic features applied to this range 204 + pub features: Vec<FacetFeature>, 205 + }
+19 -68
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
··· 30 /// 31 /// // Inline signature 32 /// let inline = SignatureOrRef::Inline(create_typed_signature( 33 - /// "did:plc:issuer".to_string(), 34 /// Bytes { bytes: b"signature".to_vec() }, 35 /// )); 36 /// ··· 55 56 /// Cryptographic signature structure. 57 /// 58 - /// Represents a signature created by an issuer (identified by DID) over 59 - /// some data. The signature can be used to verify authenticity, authorization, 60 - /// or other properties of the signed content. 61 /// 62 /// # Fields 63 /// 64 - /// - `issuer`: DID of the entity that created the signature 65 /// - `signature`: The actual signature bytes 66 /// - `extra`: Additional fields that may be present in the signature 67 /// ··· 73 /// use std::collections::HashMap; 74 /// 75 /// let sig = Signature { 76 - /// issuer: "did:plc:example".to_string(), 77 /// signature: Bytes { bytes: b"signature_bytes".to_vec() }, 78 /// extra: HashMap::new(), 79 /// }; ··· 81 #[derive(Deserialize, Serialize, Clone, PartialEq)] 82 #[cfg_attr(debug_assertions, derive(Debug))] 83 pub struct Signature { 84 - /// DID of the entity that created this signature 85 - pub issuer: String, 86 - 87 /// The cryptographic signature bytes 88 pub signature: Bytes, 89 ··· 116 /// 117 /// # Arguments 118 /// 119 - /// * `issuer` - DID of the signature issuer 120 /// * `signature` - The signature bytes 121 /// 122 /// # Example ··· 126 /// use atproto_record::lexicon::Bytes; 127 /// 128 /// let sig = create_typed_signature( 129 - /// "did:plc:issuer".to_string(), 130 /// Bytes { bytes: b"sig_data".to_vec() }, 131 /// ); 132 /// ``` 133 - pub fn create_typed_signature(issuer: String, signature: Bytes) -> TypedSignature { 134 TypedLexicon::new(Signature { 135 - issuer, 136 signature, 137 extra: HashMap::new(), 138 }) ··· 150 let json_str = r#"{ 151 "$type": "community.lexicon.attestation.signature", 152 "issuedAt": "2025-08-19T20:17:17.133Z", 153 - "issuer": "did:web:acudo-dev.smokesignal.tools", 154 "signature": { 155 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 156 } ··· 160 let typed_sig_result: Result<TypedSignature, _> = serde_json::from_str(json_str); 161 match &typed_sig_result { 162 Ok(sig) => { 163 - println!("TypedSignature OK: issuer={}", sig.inner.issuer); 164 - assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 165 } 166 Err(e) => { 167 eprintln!("TypedSignature deserialization error: {}", e); ··· 172 let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str); 173 match &sig_or_ref_result { 174 Ok(SignatureOrRef::Inline(sig)) => { 175 - println!("SignatureOrRef OK (Inline): issuer={}", sig.inner.issuer); 176 - assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 177 } 178 Ok(SignatureOrRef::Reference(_)) => { 179 panic!("Expected Inline signature, got Reference"); ··· 186 // Try without $type field 187 let json_no_type = r#"{ 188 "issuedAt": "2025-08-19T20:17:17.133Z", 189 - "issuer": "did:web:acudo-dev.smokesignal.tools", 190 "signature": { 191 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 192 } ··· 195 let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type); 196 match &no_type_result { 197 Ok(sig) => { 198 - println!("Signature (no type) OK: issuer={}", sig.issuer); 199 - assert_eq!(sig.issuer, "did:web:acudo-dev.smokesignal.tools"); 200 assert_eq!(sig.signature.bytes.len(), 64); 201 202 // Now wrap it in TypedLexicon and try as SignatureOrRef ··· 220 fn test_signature_deserialization() { 221 let json_str = r#"{ 222 "$type": "community.lexicon.attestation.signature", 223 - "issuer": "did:plc:test123", 224 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 225 }"#; 226 227 let signature: Signature = serde_json::from_str(json_str).unwrap(); 228 229 - assert_eq!(signature.issuer, "did:plc:test123"); 230 assert_eq!(signature.signature.bytes, b"test signature"); 231 // The $type field will be captured in extra due to #[serde(flatten)] 232 assert_eq!(signature.extra.len(), 1); ··· 237 fn test_signature_deserialization_with_extra_fields() { 238 let json_str = r#"{ 239 "$type": "community.lexicon.attestation.signature", 240 - "issuer": "did:plc:test123", 241 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}, 242 "issuedAt": "2024-01-01T00:00:00.000Z", 243 "purpose": "verification" ··· 245 246 let signature: Signature = serde_json::from_str(json_str).unwrap(); 247 248 - assert_eq!(signature.issuer, "did:plc:test123"); 249 assert_eq!(signature.signature.bytes, b"test signature"); 250 // 3 extra fields: $type, issuedAt, purpose 251 assert_eq!(signature.extra.len(), 3); ··· 263 extra.insert("custom_field".to_string(), json!("custom_value")); 264 265 let signature = Signature { 266 - issuer: "did:plc:serializer".to_string(), 267 signature: Bytes { 268 bytes: b"hello world".to_vec(), 269 }, ··· 274 275 // Without custom Serialize impl, $type is not automatically added 276 assert!(!json.as_object().unwrap().contains_key("$type")); 277 - assert_eq!(json["issuer"], "did:plc:serializer"); 278 // "hello world" base64 encoded is "aGVsbG8gd29ybGQ=" 279 assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ="); 280 assert_eq!(json["custom_field"], "custom_value"); ··· 283 #[test] 284 fn test_signature_round_trip() { 285 let original = Signature { 286 - issuer: "did:plc:roundtrip".to_string(), 287 signature: Bytes { 288 bytes: b"round trip test".to_vec(), 289 }, ··· 296 // Deserialize back 297 let deserialized: Signature = serde_json::from_str(&json).unwrap(); 298 299 - assert_eq!(original.issuer, deserialized.issuer); 300 assert_eq!(original.signature.bytes, deserialized.signature.bytes); 301 // Without the custom Serialize impl, no $type is added 302 // so the round-trip preserves the empty extra map ··· 317 extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"])); 318 319 let signature = Signature { 320 - issuer: "did:plc:complex".to_string(), 321 signature: Bytes { 322 bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA], 323 }, ··· 328 329 // Without custom Serialize impl, $type is not automatically added 330 assert!(!json.as_object().unwrap().contains_key("$type")); 331 - assert_eq!(json["issuer"], "did:plc:complex"); 332 assert_eq!(json["timestamp"], 1234567890); 333 assert_eq!(json["metadata"]["version"], "1.0"); 334 assert_eq!(json["metadata"]["algorithm"], "ES256"); ··· 338 #[test] 339 fn test_empty_signature() { 340 let signature = Signature { 341 - issuer: String::new(), 342 signature: Bytes { bytes: Vec::new() }, 343 extra: HashMap::new(), 344 }; ··· 347 348 // Without custom Serialize impl, $type is not automatically added 349 assert!(!json.as_object().unwrap().contains_key("$type")); 350 - assert_eq!(json["issuer"], ""); 351 assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string 352 } 353 ··· 356 // Test with plain Vec<Signature> for basic signature serialization 357 let signatures: Vec<Signature> = vec![ 358 Signature { 359 - issuer: "did:plc:first".to_string(), 360 signature: Bytes { 361 bytes: b"first".to_vec(), 362 }, 363 extra: HashMap::new(), 364 }, 365 Signature { 366 - issuer: "did:plc:second".to_string(), 367 signature: Bytes { 368 bytes: b"second".to_vec(), 369 }, ··· 375 376 assert!(json.is_array()); 377 assert_eq!(json.as_array().unwrap().len(), 2); 378 - assert_eq!(json[0]["issuer"], "did:plc:first"); 379 - assert_eq!(json[1]["issuer"], "did:plc:second"); 380 } 381 382 #[test] ··· 384 // Test the new Signatures type with inline signatures 385 let signatures: Signatures = vec![ 386 SignatureOrRef::Inline(create_typed_signature( 387 - "did:plc:first".to_string(), 388 Bytes { 389 bytes: b"first".to_vec(), 390 }, 391 )), 392 SignatureOrRef::Inline(create_typed_signature( 393 - "did:plc:second".to_string(), 394 Bytes { 395 bytes: b"second".to_vec(), 396 }, ··· 402 assert!(json.is_array()); 403 assert_eq!(json.as_array().unwrap().len(), 2); 404 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 405 - assert_eq!(json[0]["issuer"], "did:plc:first"); 406 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 407 - assert_eq!(json[1]["issuer"], "did:plc:second"); 408 } 409 410 #[test] 411 fn test_typed_signature_serialization() { 412 let typed_sig = create_typed_signature( 413 - "did:plc:typed".to_string(), 414 Bytes { 415 bytes: b"typed signature".to_vec(), 416 }, ··· 419 let json = serde_json::to_value(&typed_sig).unwrap(); 420 421 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 422 - assert_eq!(json["issuer"], "did:plc:typed"); 423 // "typed signature" base64 encoded 424 assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl"); 425 } ··· 428 fn test_typed_signature_deserialization() { 429 let json = json!({ 430 "$type": "community.lexicon.attestation.signature", 431 - "issuer": "did:plc:typed", 432 "signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"} 433 }); 434 435 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 436 437 - assert_eq!(typed_sig.inner.issuer, "did:plc:typed"); 438 assert_eq!(typed_sig.inner.signature.bytes, b"typed signature"); 439 assert!(typed_sig.has_type_field()); 440 assert!(typed_sig.validate().is_ok()); ··· 443 #[test] 444 fn test_typed_signature_without_type_field() { 445 let json = json!({ 446 - "issuer": "did:plc:notype", 447 "signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64 448 }); 449 450 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 451 452 - assert_eq!(typed_sig.inner.issuer, "did:plc:notype"); 453 assert_eq!(typed_sig.inner.signature.bytes, b"no type"); 454 assert!(!typed_sig.has_type_field()); 455 // Validation should still pass because type_required() returns false for Signature ··· 459 #[test] 460 fn test_typed_signature_with_extra_fields() { 461 let mut sig = Signature { 462 - issuer: "did:plc:extra".to_string(), 463 signature: Bytes { 464 bytes: b"extra test".to_vec(), 465 }, ··· 474 let json = serde_json::to_value(&typed_sig).unwrap(); 475 476 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 477 - assert_eq!(json["issuer"], "did:plc:extra"); 478 assert_eq!(json["customField"], "customValue"); 479 assert_eq!(json["timestamp"], 1234567890); 480 } ··· 482 #[test] 483 fn test_typed_signature_round_trip() { 484 let original = Signature { 485 - issuer: "did:plc:roundtrip2".to_string(), 486 signature: Bytes { 487 bytes: b"round trip typed".to_vec(), 488 }, ··· 494 let json = serde_json::to_string(&typed).unwrap(); 495 let deserialized: TypedSignature = serde_json::from_str(&json).unwrap(); 496 497 - assert_eq!(deserialized.inner.issuer, original.issuer); 498 assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes); 499 assert!(deserialized.has_type_field()); 500 } ··· 503 fn test_typed_signatures_vec() { 504 let typed_sigs: Vec<TypedSignature> = vec![ 505 create_typed_signature( 506 - "did:plc:first".to_string(), 507 Bytes { 508 bytes: b"first".to_vec(), 509 }, 510 ), 511 create_typed_signature( 512 - "did:plc:second".to_string(), 513 Bytes { 514 bytes: b"second".to_vec(), 515 }, ··· 520 521 assert!(json.is_array()); 522 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 523 - assert_eq!(json[0]["issuer"], "did:plc:first"); 524 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 525 - assert_eq!(json[1]["issuer"], "did:plc:second"); 526 } 527 528 #[test] 529 fn test_plain_vs_typed_signature() { 530 // Plain Signature doesn't include $type field 531 let plain_sig = Signature { 532 - issuer: "did:plc:plain".to_string(), 533 signature: Bytes { 534 bytes: b"plain sig".to_vec(), 535 }, ··· 548 ); 549 550 // Both have the same core data 551 - assert_eq!(plain_json["issuer"], typed_json["issuer"]); 552 assert_eq!(plain_json["signature"], typed_json["signature"]); 553 } 554 ··· 556 fn test_signature_or_ref_inline() { 557 // Test inline signature 558 let inline_sig = create_typed_signature( 559 - "did:plc:inline".to_string(), 560 Bytes { 561 bytes: b"inline signature".to_vec(), 562 }, ··· 567 // Serialize 568 let json = serde_json::to_value(&sig_or_ref).unwrap(); 569 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 570 - assert_eq!(json["issuer"], "did:plc:inline"); 571 assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64 572 573 // Deserialize 574 let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap(); 575 match deserialized { 576 SignatureOrRef::Inline(sig) => { 577 - assert_eq!(sig.inner.issuer, "did:plc:inline"); 578 assert_eq!(sig.inner.signature.bytes, b"inline signature"); 579 } 580 _ => panic!("Expected inline signature"), ··· 621 let signatures: Signatures = vec![ 622 // Inline signature 623 SignatureOrRef::Inline(create_typed_signature( 624 - "did:plc:signer1".to_string(), 625 Bytes { 626 bytes: b"sig1".to_vec(), 627 }, ··· 633 })), 634 // Another inline signature 635 SignatureOrRef::Inline(create_typed_signature( 636 - "did:plc:signer3".to_string(), 637 Bytes { 638 bytes: b"sig3".to_vec(), 639 }, ··· 648 649 // First element should be inline signature 650 assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature"); 651 - assert_eq!(array[0]["issuer"], "did:plc:signer1"); 652 653 // Second element should be reference 654 assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef"); ··· 659 660 // Third element should be inline signature 661 assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature"); 662 - assert_eq!(array[2]["issuer"], "did:plc:signer3"); 663 664 // Deserialize back 665 let deserialized: Signatures = serde_json::from_value(json).unwrap(); ··· 667 668 // Verify each element 669 match &deserialized[0] { 670 - SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer1"), 671 _ => panic!("Expected inline signature at index 0"), 672 } 673 ··· 682 } 683 684 match &deserialized[2] { 685 - SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.issuer, "did:plc:signer3"), 686 _ => panic!("Expected inline signature at index 2"), 687 } 688 } ··· 694 // Inline signature JSON 695 let inline_json = r#"{ 696 "$type": "community.lexicon.attestation.signature", 697 - "issuer": "did:plc:testinline", 698 "signature": {"$bytes": "aGVsbG8="} 699 }"#; 700 701 let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap(); 702 match inline_deser { 703 SignatureOrRef::Inline(sig) => { 704 - assert_eq!(sig.inner.issuer, "did:plc:testinline"); 705 assert_eq!(sig.inner.signature.bytes, b"hello"); 706 } 707 _ => panic!("Expected inline signature"),
··· 30 /// 31 /// // Inline signature 32 /// let inline = SignatureOrRef::Inline(create_typed_signature( 33 /// Bytes { bytes: b"signature".to_vec() }, 34 /// )); 35 /// ··· 54 55 /// Cryptographic signature structure. 56 /// 57 + /// Represents a cryptographic signature over some data. The signature can be 58 + /// used to verify authenticity, authorization, or other properties of the 59 + /// signed content. 60 /// 61 /// # Fields 62 /// 63 /// - `signature`: The actual signature bytes 64 /// - `extra`: Additional fields that may be present in the signature 65 /// ··· 71 /// use std::collections::HashMap; 72 /// 73 /// let sig = Signature { 74 /// signature: Bytes { bytes: b"signature_bytes".to_vec() }, 75 /// extra: HashMap::new(), 76 /// }; ··· 78 #[derive(Deserialize, Serialize, Clone, PartialEq)] 79 #[cfg_attr(debug_assertions, derive(Debug))] 80 pub struct Signature { 81 /// The cryptographic signature bytes 82 pub signature: Bytes, 83 ··· 110 /// 111 /// # Arguments 112 /// 113 /// * `signature` - The signature bytes 114 /// 115 /// # Example ··· 119 /// use atproto_record::lexicon::Bytes; 120 /// 121 /// let sig = create_typed_signature( 122 /// Bytes { bytes: b"sig_data".to_vec() }, 123 /// ); 124 /// ``` 125 + pub fn create_typed_signature(signature: Bytes) -> TypedSignature { 126 TypedLexicon::new(Signature { 127 signature, 128 extra: HashMap::new(), 129 }) ··· 141 let json_str = r#"{ 142 "$type": "community.lexicon.attestation.signature", 143 "issuedAt": "2025-08-19T20:17:17.133Z", 144 "signature": { 145 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 146 } ··· 150 let typed_sig_result: Result<TypedSignature, _> = serde_json::from_str(json_str); 151 match &typed_sig_result { 152 Ok(sig) => { 153 + println!("TypedSignature OK: signature bytes len={}", sig.inner.signature.bytes.len()); 154 + assert_eq!(sig.inner.signature.bytes.len(), 64); 155 } 156 Err(e) => { 157 eprintln!("TypedSignature deserialization error: {}", e); ··· 162 let sig_or_ref_result: Result<SignatureOrRef, _> = serde_json::from_str(json_str); 163 match &sig_or_ref_result { 164 Ok(SignatureOrRef::Inline(sig)) => { 165 + println!("SignatureOrRef OK (Inline): signature bytes len={}", sig.inner.signature.bytes.len()); 166 + assert_eq!(sig.inner.signature.bytes.len(), 64); 167 } 168 Ok(SignatureOrRef::Reference(_)) => { 169 panic!("Expected Inline signature, got Reference"); ··· 176 // Try without $type field 177 let json_no_type = r#"{ 178 "issuedAt": "2025-08-19T20:17:17.133Z", 179 "signature": { 180 "$bytes": "mr9c0MCu3g6SXNQ25JFhzfX1ecYgK9k1Kf6OZI2p2AlQRoQu09dOE7J5uaeilIx/UFCjJErO89C/uBBb9ANmUA" 181 } ··· 184 let no_type_result: Result<Signature, _> = serde_json::from_str(json_no_type); 185 match &no_type_result { 186 Ok(sig) => { 187 + println!("Signature (no type) OK: signature bytes len={}", sig.signature.bytes.len()); 188 assert_eq!(sig.signature.bytes.len(), 64); 189 190 // Now wrap it in TypedLexicon and try as SignatureOrRef ··· 208 fn test_signature_deserialization() { 209 let json_str = r#"{ 210 "$type": "community.lexicon.attestation.signature", 211 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="} 212 }"#; 213 214 let signature: Signature = serde_json::from_str(json_str).unwrap(); 215 216 assert_eq!(signature.signature.bytes, b"test signature"); 217 // The $type field will be captured in extra due to #[serde(flatten)] 218 assert_eq!(signature.extra.len(), 1); ··· 223 fn test_signature_deserialization_with_extra_fields() { 224 let json_str = r#"{ 225 "$type": "community.lexicon.attestation.signature", 226 "signature": {"$bytes": "dGVzdCBzaWduYXR1cmU="}, 227 "issuedAt": "2024-01-01T00:00:00.000Z", 228 "purpose": "verification" ··· 230 231 let signature: Signature = serde_json::from_str(json_str).unwrap(); 232 233 assert_eq!(signature.signature.bytes, b"test signature"); 234 // 3 extra fields: $type, issuedAt, purpose 235 assert_eq!(signature.extra.len(), 3); ··· 247 extra.insert("custom_field".to_string(), json!("custom_value")); 248 249 let signature = Signature { 250 signature: Bytes { 251 bytes: b"hello world".to_vec(), 252 }, ··· 257 258 // Without custom Serialize impl, $type is not automatically added 259 assert!(!json.as_object().unwrap().contains_key("$type")); 260 // "hello world" base64 encoded is "aGVsbG8gd29ybGQ=" 261 assert_eq!(json["signature"]["$bytes"], "aGVsbG8gd29ybGQ="); 262 assert_eq!(json["custom_field"], "custom_value"); ··· 265 #[test] 266 fn test_signature_round_trip() { 267 let original = Signature { 268 signature: Bytes { 269 bytes: b"round trip test".to_vec(), 270 }, ··· 277 // Deserialize back 278 let deserialized: Signature = serde_json::from_str(&json).unwrap(); 279 280 assert_eq!(original.signature.bytes, deserialized.signature.bytes); 281 // Without the custom Serialize impl, no $type is added 282 // so the round-trip preserves the empty extra map ··· 297 extra.insert("tags".to_string(), json!(["tag1", "tag2", "tag3"])); 298 299 let signature = Signature { 300 signature: Bytes { 301 bytes: vec![0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA], 302 }, ··· 307 308 // Without custom Serialize impl, $type is not automatically added 309 assert!(!json.as_object().unwrap().contains_key("$type")); 310 assert_eq!(json["timestamp"], 1234567890); 311 assert_eq!(json["metadata"]["version"], "1.0"); 312 assert_eq!(json["metadata"]["algorithm"], "ES256"); ··· 316 #[test] 317 fn test_empty_signature() { 318 let signature = Signature { 319 signature: Bytes { bytes: Vec::new() }, 320 extra: HashMap::new(), 321 }; ··· 324 325 // Without custom Serialize impl, $type is not automatically added 326 assert!(!json.as_object().unwrap().contains_key("$type")); 327 assert_eq!(json["signature"]["$bytes"], ""); // Empty bytes encode to empty string 328 } 329 ··· 332 // Test with plain Vec<Signature> for basic signature serialization 333 let signatures: Vec<Signature> = vec![ 334 Signature { 335 signature: Bytes { 336 bytes: b"first".to_vec(), 337 }, 338 extra: HashMap::new(), 339 }, 340 Signature { 341 signature: Bytes { 342 bytes: b"second".to_vec(), 343 }, ··· 349 350 assert!(json.is_array()); 351 assert_eq!(json.as_array().unwrap().len(), 2); 352 + assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64 353 + assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64 354 } 355 356 #[test] ··· 358 // Test the new Signatures type with inline signatures 359 let signatures: Signatures = vec![ 360 SignatureOrRef::Inline(create_typed_signature( 361 Bytes { 362 bytes: b"first".to_vec(), 363 }, 364 )), 365 SignatureOrRef::Inline(create_typed_signature( 366 Bytes { 367 bytes: b"second".to_vec(), 368 }, ··· 374 assert!(json.is_array()); 375 assert_eq!(json.as_array().unwrap().len(), 2); 376 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 377 + assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64 378 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 379 + assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64 380 } 381 382 #[test] 383 fn test_typed_signature_serialization() { 384 let typed_sig = create_typed_signature( 385 Bytes { 386 bytes: b"typed signature".to_vec(), 387 }, ··· 390 let json = serde_json::to_value(&typed_sig).unwrap(); 391 392 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 393 // "typed signature" base64 encoded 394 assert_eq!(json["signature"]["$bytes"], "dHlwZWQgc2lnbmF0dXJl"); 395 } ··· 398 fn test_typed_signature_deserialization() { 399 let json = json!({ 400 "$type": "community.lexicon.attestation.signature", 401 "signature": {"$bytes": "dHlwZWQgc2lnbmF0dXJl"} 402 }); 403 404 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 405 406 assert_eq!(typed_sig.inner.signature.bytes, b"typed signature"); 407 assert!(typed_sig.has_type_field()); 408 assert!(typed_sig.validate().is_ok()); ··· 411 #[test] 412 fn test_typed_signature_without_type_field() { 413 let json = json!({ 414 "signature": {"$bytes": "bm8gdHlwZQ=="} // "no type" in base64 415 }); 416 417 let typed_sig: TypedSignature = serde_json::from_value(json).unwrap(); 418 419 assert_eq!(typed_sig.inner.signature.bytes, b"no type"); 420 assert!(!typed_sig.has_type_field()); 421 // Validation should still pass because type_required() returns false for Signature ··· 425 #[test] 426 fn test_typed_signature_with_extra_fields() { 427 let mut sig = Signature { 428 signature: Bytes { 429 bytes: b"extra test".to_vec(), 430 }, ··· 439 let json = serde_json::to_value(&typed_sig).unwrap(); 440 441 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 442 assert_eq!(json["customField"], "customValue"); 443 assert_eq!(json["timestamp"], 1234567890); 444 } ··· 446 #[test] 447 fn test_typed_signature_round_trip() { 448 let original = Signature { 449 signature: Bytes { 450 bytes: b"round trip typed".to_vec(), 451 }, ··· 457 let json = serde_json::to_string(&typed).unwrap(); 458 let deserialized: TypedSignature = serde_json::from_str(&json).unwrap(); 459 460 assert_eq!(deserialized.inner.signature.bytes, original.signature.bytes); 461 assert!(deserialized.has_type_field()); 462 } ··· 465 fn test_typed_signatures_vec() { 466 let typed_sigs: Vec<TypedSignature> = vec![ 467 create_typed_signature( 468 Bytes { 469 bytes: b"first".to_vec(), 470 }, 471 ), 472 create_typed_signature( 473 Bytes { 474 bytes: b"second".to_vec(), 475 }, ··· 480 481 assert!(json.is_array()); 482 assert_eq!(json[0]["$type"], "community.lexicon.attestation.signature"); 483 + assert_eq!(json[0]["signature"]["$bytes"], "Zmlyc3Q="); // "first" in base64 484 assert_eq!(json[1]["$type"], "community.lexicon.attestation.signature"); 485 + assert_eq!(json[1]["signature"]["$bytes"], "c2Vjb25k"); // "second" in base64 486 } 487 488 #[test] 489 fn test_plain_vs_typed_signature() { 490 // Plain Signature doesn't include $type field 491 let plain_sig = Signature { 492 signature: Bytes { 493 bytes: b"plain sig".to_vec(), 494 }, ··· 507 ); 508 509 // Both have the same core data 510 assert_eq!(plain_json["signature"], typed_json["signature"]); 511 } 512 ··· 514 fn test_signature_or_ref_inline() { 515 // Test inline signature 516 let inline_sig = create_typed_signature( 517 Bytes { 518 bytes: b"inline signature".to_vec(), 519 }, ··· 524 // Serialize 525 let json = serde_json::to_value(&sig_or_ref).unwrap(); 526 assert_eq!(json["$type"], "community.lexicon.attestation.signature"); 527 assert_eq!(json["signature"]["$bytes"], "aW5saW5lIHNpZ25hdHVyZQ=="); // "inline signature" in base64 528 529 // Deserialize 530 let deserialized: SignatureOrRef = serde_json::from_value(json.clone()).unwrap(); 531 match deserialized { 532 SignatureOrRef::Inline(sig) => { 533 assert_eq!(sig.inner.signature.bytes, b"inline signature"); 534 } 535 _ => panic!("Expected inline signature"), ··· 576 let signatures: Signatures = vec![ 577 // Inline signature 578 SignatureOrRef::Inline(create_typed_signature( 579 Bytes { 580 bytes: b"sig1".to_vec(), 581 }, ··· 587 })), 588 // Another inline signature 589 SignatureOrRef::Inline(create_typed_signature( 590 Bytes { 591 bytes: b"sig3".to_vec(), 592 }, ··· 601 602 // First element should be inline signature 603 assert_eq!(array[0]["$type"], "community.lexicon.attestation.signature"); 604 + assert_eq!(array[0]["signature"]["$bytes"], "c2lnMQ=="); // "sig1" in base64 605 606 // Second element should be reference 607 assert_eq!(array[1]["$type"], "com.atproto.repo.strongRef"); ··· 612 613 // Third element should be inline signature 614 assert_eq!(array[2]["$type"], "community.lexicon.attestation.signature"); 615 + assert_eq!(array[2]["signature"]["$bytes"], "c2lnMw=="); // "sig3" in base64 616 617 // Deserialize back 618 let deserialized: Signatures = serde_json::from_value(json).unwrap(); ··· 620 621 // Verify each element 622 match &deserialized[0] { 623 + SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig1"), 624 _ => panic!("Expected inline signature at index 0"), 625 } 626 ··· 635 } 636 637 match &deserialized[2] { 638 + SignatureOrRef::Inline(sig) => assert_eq!(sig.inner.signature.bytes, b"sig3"), 639 _ => panic!("Expected inline signature at index 2"), 640 } 641 } ··· 647 // Inline signature JSON 648 let inline_json = r#"{ 649 "$type": "community.lexicon.attestation.signature", 650 "signature": {"$bytes": "aGVsbG8="} 651 }"#; 652 653 let inline_deser: SignatureOrRef = serde_json::from_str(inline_json).unwrap(); 654 match inline_deser { 655 SignatureOrRef::Inline(sig) => { 656 assert_eq!(sig.inner.signature.bytes, b"hello"); 657 } 658 _ => panic!("Expected inline signature"),
+1 -2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
··· 311 // The signature should be inline in this test 312 match sig_or_ref { 313 crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => { 314 - assert_eq!(sig.issuer, "did:plc:issuer"); 315 // The bytes should match the decoded base64 value 316 // "dGVzdCBzaWduYXR1cmU=" decodes to "test signature" 317 - assert_eq!(sig.signature.bytes, b"test signature".to_vec()); 318 } 319 _ => panic!("Expected inline signature"), 320 }
··· 311 // The signature should be inline in this test 312 match sig_or_ref { 313 crate::lexicon::community_lexicon_attestation::SignatureOrRef::Inline(sig) => { 314 // The bytes should match the decoded base64 value 315 // "dGVzdCBzaWduYXR1cmU=" decodes to "test signature" 316 + assert_eq!(sig.inner.signature.bytes, b"test signature".to_vec()); 317 } 318 _ => panic!("Expected inline signature"), 319 }
+43 -9
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
··· 10 11 use crate::datetime::format as datetime_format; 12 use crate::datetime::optional_format as optional_datetime_format; 13 use crate::lexicon::TypedBlob; 14 use crate::lexicon::community::lexicon::location::Locations; 15 use crate::typed::{LexiconType, TypedLexicon}; 16 17 - /// The namespace identifier for events 18 pub const NSID: &str = "community.lexicon.calendar.event"; 19 20 /// Event status enumeration. ··· 65 Hybrid, 66 } 67 68 - /// The namespace identifier for named URIs 69 pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri"; 70 71 /// Named URI structure. ··· 89 } 90 } 91 92 - /// Type alias for NamedUri with automatic $type field handling 93 pub type TypedNamedUri = TypedLexicon<NamedUri>; 94 95 - /// The namespace identifier for event links 96 pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri"; 97 98 /// Event link structure. ··· 116 } 117 } 118 119 - /// Type alias for EventLink with automatic $type field handling 120 pub type TypedEventLink = TypedLexicon<EventLink>; 121 122 - /// A vector of typed event links 123 pub type EventLinks = Vec<TypedEventLink>; 124 125 /// Aspect ratio for media content. ··· 134 pub height: u64, 135 } 136 137 - /// The namespace identifier for media 138 pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media"; 139 140 /// Media structure for event-related visual content. ··· 163 } 164 } 165 166 - /// Type alias for Media with automatic $type field handling 167 pub type TypedMedia = TypedLexicon<Media>; 168 169 - /// A vector of typed media items 170 pub type MediaList = Vec<TypedMedia>; 171 172 /// Calendar event structure. ··· 248 #[serde(skip_serializing_if = "Vec::is_empty", default)] 249 pub media: MediaList, 250 251 /// Extension fields for forward compatibility. 252 /// This catch-all allows unknown fields to be preserved and indexed 253 /// for potential future use without requiring re-indexing. ··· 312 locations: vec![], 313 uris: vec![], 314 media: vec![], 315 extra: HashMap::new(), 316 }; 317 ··· 466 locations: vec![], 467 uris: vec![TypedLexicon::new(event_link)], 468 media: vec![TypedLexicon::new(media)], 469 extra: HashMap::new(), 470 }; 471
··· 10 11 use crate::datetime::format as datetime_format; 12 use crate::datetime::optional_format as optional_datetime_format; 13 + use crate::lexicon::app::bsky::richtext::facet::Facet; 14 use crate::lexicon::TypedBlob; 15 use crate::lexicon::community::lexicon::location::Locations; 16 use crate::typed::{LexiconType, TypedLexicon}; 17 18 + /// Lexicon namespace identifier for calendar events. 19 + /// 20 + /// Used as the `$type` field value for event records in the AT Protocol. 21 pub const NSID: &str = "community.lexicon.calendar.event"; 22 23 /// Event status enumeration. ··· 68 Hybrid, 69 } 70 71 + /// Lexicon namespace identifier for named URIs in calendar events. 72 + /// 73 + /// Used as the `$type` field value for URI references associated with events. 74 pub const NAMED_URI_NSID: &str = "community.lexicon.calendar.event#uri"; 75 76 /// Named URI structure. ··· 94 } 95 } 96 97 + /// Type alias for NamedUri with automatic $type field handling. 98 + /// 99 + /// Wraps `NamedUri` in `TypedLexicon` to ensure proper serialization 100 + /// and deserialization of the `$type` field. 101 pub type TypedNamedUri = TypedLexicon<NamedUri>; 102 103 + /// Lexicon namespace identifier for event links. 104 + /// 105 + /// Used as the `$type` field value for event link references. 106 + /// Note: This shares the same NSID as `NAMED_URI_NSID` for compatibility. 107 pub const EVENT_LINK_NSID: &str = "community.lexicon.calendar.event#uri"; 108 109 /// Event link structure. ··· 127 } 128 } 129 130 + /// Type alias for EventLink with automatic $type field handling. 131 + /// 132 + /// Wraps `EventLink` in `TypedLexicon` to ensure proper serialization 133 + /// and deserialization of the `$type` field. 134 pub type TypedEventLink = TypedLexicon<EventLink>; 135 136 + /// Collection of typed event links. 137 + /// 138 + /// Represents multiple URI references associated with an event, 139 + /// such as registration pages, live streams, or related content. 140 pub type EventLinks = Vec<TypedEventLink>; 141 142 /// Aspect ratio for media content. ··· 151 pub height: u64, 152 } 153 154 + /// Lexicon namespace identifier for event media. 155 + /// 156 + /// Used as the `$type` field value for media attachments associated with events. 157 pub const MEDIA_NSID: &str = "community.lexicon.calendar.event#media"; 158 159 /// Media structure for event-related visual content. ··· 182 } 183 } 184 185 + /// Type alias for Media with automatic $type field handling. 186 + /// 187 + /// Wraps `Media` in `TypedLexicon` to ensure proper serialization 188 + /// and deserialization of the `$type` field. 189 pub type TypedMedia = TypedLexicon<Media>; 190 191 + /// Collection of typed media items. 192 + /// 193 + /// Represents multiple media attachments for an event, such as banners, 194 + /// posters, thumbnails, or promotional images. 195 pub type MediaList = Vec<TypedMedia>; 196 197 /// Calendar event structure. ··· 273 #[serde(skip_serializing_if = "Vec::is_empty", default)] 274 pub media: MediaList, 275 276 + /// Rich text facets for semantic annotations in description field. 277 + /// 278 + /// Enables mentions, links, and hashtags to be embedded in the event 279 + /// description text with proper semantic metadata. 280 + #[serde(skip_serializing_if = "Option::is_none")] 281 + pub facets: Option<Vec<Facet>>, 282 + 283 /// Extension fields for forward compatibility. 284 /// This catch-all allows unknown fields to be preserved and indexed 285 /// for potential future use without requiring re-indexing. ··· 344 locations: vec![], 345 uris: vec![], 346 media: vec![], 347 + facets: None, 348 extra: HashMap::new(), 349 }; 350 ··· 499 locations: vec![], 500 uris: vec![TypedLexicon::new(event_link)], 501 media: vec![TypedLexicon::new(media)], 502 + facets: None, 503 extra: HashMap::new(), 504 }; 505
-3
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
··· 294 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 295 match &typed_rsvp.inner.signatures[0] { 296 SignatureOrRef::Inline(sig) => { 297 - assert_eq!(sig.inner.issuer, "did:plc:issuer"); 298 assert_eq!(sig.inner.signature.bytes, b"test signature"); 299 } 300 _ => panic!("Expected inline signature"), ··· 364 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 365 match &typed_rsvp.inner.signatures[0] { 366 SignatureOrRef::Inline(sig) => { 367 - assert_eq!(sig.inner.issuer, "did:web:acudo-dev.smokesignal.tools"); 368 - 369 // Verify the issuedAt field if present 370 if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") { 371 assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
··· 294 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 295 match &typed_rsvp.inner.signatures[0] { 296 SignatureOrRef::Inline(sig) => { 297 assert_eq!(sig.inner.signature.bytes, b"test signature"); 298 } 299 _ => panic!("Expected inline signature"), ··· 363 assert_eq!(typed_rsvp.inner.signatures.len(), 1); 364 match &typed_rsvp.inner.signatures[0] { 365 SignatureOrRef::Inline(sig) => { 366 // Verify the issuedAt field if present 367 if let Some(issued_at_value) = sig.inner.extra.get("issuedAt") { 368 assert_eq!(issued_at_value, "2025-08-19T20:17:17.133Z");
+22
crates/atproto-record/src/lexicon/mod.rs
··· 37 mod community_lexicon_calendar_event; 38 mod community_lexicon_calendar_rsvp; 39 mod community_lexicon_location; 40 mod primatives; 41 42 pub use primatives::*; 43 44 /// AT Protocol core types namespace 45 pub mod com {
··· 37 mod community_lexicon_calendar_event; 38 mod community_lexicon_calendar_rsvp; 39 mod community_lexicon_location; 40 + mod app_bsky_richtext_facet; 41 mod primatives; 42 43 + // Re-export primitive types for convenience 44 pub use primatives::*; 45 + 46 + /// Bluesky application namespace. 47 + /// 48 + /// Contains lexicon types specific to the Bluesky application, 49 + /// including rich text formatting and social features. 50 + pub mod app { 51 + /// Bluesky namespace. 52 + pub mod bsky { 53 + /// Rich text formatting types. 54 + pub mod richtext { 55 + /// Facet types for semantic text annotations. 56 + /// 57 + /// Provides types for mentions, links, hashtags, and other 58 + /// structured metadata that can be attached to text content. 59 + pub mod facet { 60 + pub use crate::lexicon::app_bsky_richtext_facet::*; 61 + } 62 + } 63 + } 64 + } 65 66 /// AT Protocol core types namespace 67 pub mod com {
+40 -19
crates/atproto-record/src/lib.rs
··· 16 //! ## Example Usage 17 //! 18 //! ```ignore 19 - //! use atproto_record::signature; 20 - //! use atproto_identity::key::identify_key; 21 //! use serde_json::json; 22 //! 23 - //! // Sign a record 24 - //! let key_data = identify_key("did:key:...")?; 25 - //! let record = json!({"$type": "app.bsky.feed.post", "text": "Hello!"}); 26 - //! let sig_obj = json!({"issuer": "did:plc:..."}); 27 //! 28 - //! let signed = signature::create(&key_data, &record, "did:plc:repo", 29 - //! "app.bsky.feed.post", sig_obj).await?; 30 //! 31 - //! // Verify a signature 32 - //! signature::verify("did:plc:issuer", &key_data, signed, 33 - //! "did:plc:repo", "app.bsky.feed.post").await?; 34 //! ``` 35 36 #![forbid(unsafe_code)] ··· 42 /// and CLI operations. All errors follow the project's standardized format: 43 /// `error-atproto-record-{domain}-{number} {message}: {details}` 44 pub mod errors; 45 - 46 - /// Core signature creation and verification. 47 - /// 48 - /// Provides functions for creating and verifying cryptographic signatures on 49 - /// AT Protocol records using IPLD DAG-CBOR serialization. Supports the 50 - /// community.lexicon.attestation.signature specification with proper $sig 51 - /// object handling and multiple signature support. 52 - pub mod signature; 53 54 /// AT-URI parsing and validation. 55 /// ··· 84 /// in many AT Protocol lexicon structures. The wrapper can automatically add type 85 /// fields during serialization and validate them during deserialization. 86 pub mod typed;
··· 16 //! ## Example Usage 17 //! 18 //! ```ignore 19 + //! use atproto_record::attestation; 20 + //! use atproto_identity::key::{identify_key, sign, to_public}; 21 + //! use base64::engine::general_purpose::STANDARD; 22 //! use serde_json::json; 23 //! 24 + //! let private_key = identify_key("did:key:zPrivate...")?; 25 + //! let public_key = to_public(&private_key)?; 26 + //! let key_reference = format!("{}", &public_key); 27 //! 28 + //! let record = json!({ 29 + //! "$type": "app.example.record", 30 + //! "text": "Hello from attestation helpers!" 31 + //! }); 32 + //! 33 + //! let sig_metadata = json!({ 34 + //! "$type": "com.example.inlineSignature", 35 + //! "key": &key_reference, 36 + //! "purpose": "demo" 37 + //! }); 38 + //! 39 + //! let signing_record = attestation::prepare_signing_record(&record, &sig_metadata)?; 40 + //! let cid = attestation::create_cid(&signing_record)?; 41 + //! let signature_bytes = sign(&private_key, &cid.to_bytes())?; 42 + //! 43 + //! let inline_attestation = json!({ 44 + //! "$type": "com.example.inlineSignature", 45 + //! "key": key_reference, 46 + //! "purpose": "demo", 47 + //! "signature": {"$bytes": STANDARD.encode(signature_bytes)} 48 + //! }); 49 //! 50 + //! let signed = attestation::create_inline_attestation_reference(&record, &inline_attestation)?; 51 + //! let reports = tokio_test::block_on(async { 52 + //! attestation::verify_all_signatures(&signed, None).await 53 + //! })?; 54 + //! assert!(matches!(reports[0].status, attestation::VerificationStatus::Valid { .. })); 55 //! ``` 56 57 #![forbid(unsafe_code)] ··· 63 /// and CLI operations. All errors follow the project's standardized format: 64 /// `error-atproto-record-{domain}-{number} {message}: {details}` 65 pub mod errors; 66 67 /// AT-URI parsing and validation. 68 /// ··· 97 /// in many AT Protocol lexicon structures. The wrapper can automatically add type 98 /// fields during serialization and validate them during deserialization. 99 pub mod typed; 100 + 101 + /// Timestamp Identifier (TID) generation and parsing. 102 + /// 103 + /// TIDs are sortable, distributed identifiers combining microsecond timestamps 104 + /// with random clock identifiers. They provide a collision-resistant, monotonically 105 + /// increasing identifier scheme for AT Protocol records encoded as 13-character 106 + /// base32-sortable strings. 107 + pub mod tid;
-672
crates/atproto-record/src/signature.rs
··· 1 - //! AT Protocol record signature creation and verification. 2 - //! 3 - //! This module provides comprehensive functionality for creating and verifying 4 - //! cryptographic signatures on AT Protocol records following the 5 - //! community.lexicon.attestation.signature specification. 6 - //! 7 - //! ## Signature Process 8 - //! 9 - //! 1. **Signing**: Records are augmented with a `$sig` object containing issuer, 10 - //! timestamp, and context information, then serialized using IPLD DAG-CBOR 11 - //! for deterministic encoding before signing with ECDSA. 12 - //! 13 - //! 2. **Storage**: Signatures are stored in a `signatures` array within the record, 14 - //! allowing multiple signatures from different issuers. 15 - //! 16 - //! 3. **Verification**: The original signed content is reconstructed by replacing 17 - //! the signatures array with the appropriate `$sig` object, then verified 18 - //! using the issuer's public key. 19 - //! 20 - //! ## Supported Curves 21 - //! 22 - //! - P-256 (NIST P-256 / secp256r1) 23 - //! - P-384 (NIST P-384 / secp384r1) 24 - //! - K-256 (secp256k1) 25 - //! 26 - //! ## Example 27 - //! 28 - //! ```ignore 29 - //! use atproto_record::signature::{create, verify}; 30 - //! use atproto_identity::key::identify_key; 31 - //! use serde_json::json; 32 - //! 33 - //! // Create a signature 34 - //! let key = identify_key("did:key:...")?; 35 - //! let record = json!({"text": "Hello!"}); 36 - //! let sig_obj = json!({ 37 - //! "issuer": "did:plc:issuer" 38 - //! // Optional: any additional fields like "issuedAt", "purpose", etc. 39 - //! }); 40 - //! 41 - //! let signed = create(&key, &record, "did:plc:repo", 42 - //! "app.bsky.feed.post", sig_obj)?; 43 - //! 44 - //! // Verify the signature 45 - //! verify("did:plc:issuer", &key, signed, 46 - //! "did:plc:repo", "app.bsky.feed.post")?; 47 - //! ``` 48 - 49 - use atproto_identity::key::{KeyData, sign, validate}; 50 - use base64::{Engine, engine::general_purpose::STANDARD}; 51 - use serde_json::json; 52 - 53 - use crate::errors::VerificationError; 54 - 55 - /// Creates a cryptographic signature for an AT Protocol record. 56 - /// 57 - /// This function generates a signature following the community.lexicon.attestation.signature 58 - /// specification. The record is augmented with a `$sig` object containing context information, 59 - /// serialized using IPLD DAG-CBOR, signed with the provided key, and the signature is added 60 - /// to a `signatures` array in the returned record. 61 - /// 62 - /// # Parameters 63 - /// 64 - /// * `key_data` - The signing key (private key) wrapped in KeyData 65 - /// * `record` - The JSON record to be signed (will not be modified) 66 - /// * `repository` - The repository DID where this record will be stored 67 - /// * `collection` - The collection type (NSID) for this record 68 - /// * `signature_object` - Metadata for the signature, must include: 69 - /// - `issuer`: The DID of the entity creating the signature (required) 70 - /// - Additional custom fields are preserved in the signature (optional) 71 - /// 72 - /// # Returns 73 - /// 74 - /// Returns a new record containing: 75 - /// - All original record fields 76 - /// - A `signatures` array with the new signature appended 77 - /// - No `$sig` field (only used during signing) 78 - /// 79 - /// # Errors 80 - /// 81 - /// Returns [`VerificationError`] if: 82 - /// - Required field `issuer` is missing from signature_object 83 - /// - IPLD DAG-CBOR serialization fails 84 - /// - Cryptographic signing operation fails 85 - /// - JSON structure manipulation fails 86 - pub fn create( 87 - key_data: &KeyData, 88 - record: &serde_json::Value, 89 - repository: &str, 90 - collection: &str, 91 - signature_object: serde_json::Value, 92 - ) -> Result<serde_json::Value, VerificationError> { 93 - if let Some(record_map) = signature_object.as_object() { 94 - if !record_map.contains_key("issuer") { 95 - return Err(VerificationError::SignatureObjectMissingField { 96 - field: "issuer".to_string(), 97 - }); 98 - } 99 - } else { 100 - return Err(VerificationError::InvalidSignatureObjectType); 101 - }; 102 - 103 - // Prepare the $sig object. 104 - let mut sig = signature_object.clone(); 105 - if let Some(record_map) = sig.as_object_mut() { 106 - record_map.insert("repository".to_string(), json!(repository)); 107 - record_map.insert("collection".to_string(), json!(collection)); 108 - record_map.insert( 109 - "$type".to_string(), 110 - json!("community.lexicon.attestation.signature"), 111 - ); 112 - } 113 - 114 - // Create a copy of the record with the $sig object for signing. 115 - let mut signing_record = record.clone(); 116 - if let Some(record_map) = signing_record.as_object_mut() { 117 - record_map.remove("signatures"); 118 - record_map.remove("$sig"); 119 - record_map.insert("$sig".to_string(), sig); 120 - } 121 - 122 - // Create a signature. 123 - let serialized_signing_record = serde_ipld_dagcbor::to_vec(&signing_record)?; 124 - 125 - let signature: Vec<u8> = sign(key_data, &serialized_signing_record)?; 126 - let encoded_signature = STANDARD.encode(&signature); 127 - 128 - // Compose the proof object 129 - let mut proof = signature_object.clone(); 130 - if let Some(record_map) = proof.as_object_mut() { 131 - record_map.remove("repository"); 132 - record_map.remove("collection"); 133 - record_map.insert( 134 - "signature".to_string(), 135 - json!({"$bytes": json!(encoded_signature)}), 136 - ); 137 - record_map.insert( 138 - "$type".to_string(), 139 - json!("community.lexicon.attestation.signature"), 140 - ); 141 - } 142 - 143 - // Add the signature to the original record 144 - let mut signed_record = record.clone(); 145 - 146 - if let Some(record_map) = signed_record.as_object_mut() { 147 - let mut signatures: Vec<serde_json::Value> = record 148 - .get("signatures") 149 - .and_then(|v| v.as_array().cloned()) 150 - .unwrap_or_default(); 151 - 152 - signatures.push(proof); 153 - 154 - record_map.remove("$sig"); 155 - record_map.remove("signatures"); 156 - 157 - // Add the $sig field 158 - record_map.insert("signatures".to_string(), json!(signatures)); 159 - } 160 - 161 - Ok(signed_record) 162 - } 163 - 164 - /// Verifies a cryptographic signature on an AT Protocol record. 165 - /// 166 - /// This function validates signatures by reconstructing the original signed content 167 - /// (record with `$sig` object) and verifying the ECDSA signature against it. 168 - /// It searches through all signatures in the record to find one matching the 169 - /// specified issuer, then verifies it with the provided public key. 170 - /// 171 - /// # Parameters 172 - /// 173 - /// * `issuer` - The DID of the expected signature issuer to verify 174 - /// * `key_data` - The public key for signature verification 175 - /// * `record` - The signed record containing a `signatures` or `sigs` array 176 - /// * `repository` - The repository DID used during signing (must match) 177 - /// * `collection` - The collection type used during signing (must match) 178 - /// 179 - /// # Returns 180 - /// 181 - /// Returns `Ok(())` if a valid signature from the specified issuer is found 182 - /// and successfully verified against the reconstructed signed content. 183 - /// 184 - /// # Errors 185 - /// 186 - /// Returns [`VerificationError`] if: 187 - /// - No `signatures` or `sigs` field exists in the record 188 - /// - No signature from the specified issuer is found 189 - /// - The issuer's signature is malformed or missing required fields 190 - /// - The signature is not in the expected `{"$bytes": "..."}` format 191 - /// - Base64 decoding of the signature fails 192 - /// - IPLD DAG-CBOR serialization of reconstructed content fails 193 - /// - Cryptographic verification fails (invalid signature) 194 - /// 195 - /// # Note 196 - /// 197 - /// This function supports both `signatures` and `sigs` field names for 198 - /// backward compatibility with different AT Protocol implementations. 199 - pub fn verify( 200 - issuer: &str, 201 - key_data: &KeyData, 202 - record: serde_json::Value, 203 - repository: &str, 204 - collection: &str, 205 - ) -> Result<(), VerificationError> { 206 - let signatures = record 207 - .get("sigs") 208 - .or_else(|| record.get("signatures")) 209 - .and_then(|v| v.as_array()) 210 - .ok_or(VerificationError::NoSignaturesField)?; 211 - 212 - for sig_obj in signatures { 213 - // Extract the issuer from the signature object 214 - let signature_issuer = sig_obj 215 - .get("issuer") 216 - .and_then(|v| v.as_str()) 217 - .ok_or(VerificationError::MissingIssuerField)?; 218 - 219 - let signature_value = sig_obj 220 - .get("signature") 221 - .and_then(|v| v.as_object()) 222 - .and_then(|obj| obj.get("$bytes")) 223 - .and_then(|b| b.as_str()) 224 - .ok_or(VerificationError::MissingSignatureField)?; 225 - 226 - if issuer != signature_issuer { 227 - continue; 228 - } 229 - 230 - let mut sig_variable = sig_obj.clone(); 231 - 232 - if let Some(sig_map) = sig_variable.as_object_mut() { 233 - sig_map.remove("signature"); 234 - sig_map.insert("repository".to_string(), json!(repository)); 235 - sig_map.insert("collection".to_string(), json!(collection)); 236 - } 237 - 238 - let mut signed_record = record.clone(); 239 - if let Some(record_map) = signed_record.as_object_mut() { 240 - record_map.remove("signatures"); 241 - record_map.remove("sigs"); 242 - record_map.insert("$sig".to_string(), sig_variable); 243 - } 244 - 245 - let serialized_record = serde_ipld_dagcbor::to_vec(&signed_record) 246 - .map_err(|error| VerificationError::RecordSerializationFailed { error })?; 247 - 248 - let signature_bytes = STANDARD 249 - .decode(signature_value) 250 - .map_err(|error| VerificationError::SignatureDecodingFailed { error })?; 251 - 252 - validate(key_data, &signature_bytes, &serialized_record) 253 - .map_err(|error| VerificationError::CryptographicValidationFailed { error })?; 254 - 255 - return Ok(()); 256 - } 257 - 258 - Err(VerificationError::NoValidSignatureForIssuer { 259 - issuer: issuer.to_string(), 260 - }) 261 - } 262 - 263 - #[cfg(test)] 264 - mod tests { 265 - use super::*; 266 - use atproto_identity::key::{KeyType, generate_key, to_public}; 267 - use serde_json::json; 268 - 269 - #[test] 270 - fn test_create_sign_and_verify_record_p256() -> Result<(), Box<dyn std::error::Error>> { 271 - // Step 1: Generate a P-256 key pair 272 - let private_key = generate_key(KeyType::P256Private)?; 273 - let public_key = to_public(&private_key)?; 274 - 275 - // Step 2: Create a sample record 276 - let record = json!({ 277 - "text": "Hello AT Protocol!", 278 - "createdAt": "2025-01-19T10:00:00Z", 279 - "langs": ["en"] 280 - }); 281 - 282 - // Step 3: Define signature metadata 283 - let issuer_did = "did:plc:test123"; 284 - let repository = "did:plc:repo456"; 285 - let collection = "app.bsky.feed.post"; 286 - 287 - let signature_object = json!({ 288 - "issuer": issuer_did, 289 - "issuedAt": "2025-01-19T10:00:00Z", 290 - "purpose": "attestation" 291 - }); 292 - 293 - // Step 4: Sign the record 294 - let signed_record = create( 295 - &private_key, 296 - &record, 297 - repository, 298 - collection, 299 - signature_object.clone(), 300 - )?; 301 - 302 - // Verify that the signed record contains signatures array 303 - assert!(signed_record.get("signatures").is_some()); 304 - let signatures = signed_record 305 - .get("signatures") 306 - .and_then(|v| v.as_array()) 307 - .expect("signatures should be an array"); 308 - assert_eq!(signatures.len(), 1); 309 - 310 - // Verify signature object structure 311 - let sig = &signatures[0]; 312 - assert_eq!(sig.get("issuer").and_then(|v| v.as_str()), Some(issuer_did)); 313 - assert!(sig.get("signature").is_some()); 314 - assert_eq!( 315 - sig.get("$type").and_then(|v| v.as_str()), 316 - Some("community.lexicon.attestation.signature") 317 - ); 318 - 319 - // Step 5: Verify the signature 320 - verify( 321 - issuer_did, 322 - &public_key, 323 - signed_record.clone(), 324 - repository, 325 - collection, 326 - )?; 327 - 328 - Ok(()) 329 - } 330 - 331 - #[test] 332 - fn test_create_sign_and_verify_record_k256() -> Result<(), Box<dyn std::error::Error>> { 333 - // Test with K-256 curve 334 - let private_key = generate_key(KeyType::K256Private)?; 335 - let public_key = to_public(&private_key)?; 336 - 337 - let record = json!({ 338 - "subject": "at://did:plc:example/app.bsky.feed.post/123", 339 - "likedAt": "2025-01-19T10:00:00Z" 340 - }); 341 - 342 - let issuer_did = "did:plc:issuer789"; 343 - let repository = "did:plc:repo789"; 344 - let collection = "app.bsky.feed.like"; 345 - 346 - let signature_object = json!({ 347 - "issuer": issuer_did, 348 - "issuedAt": "2025-01-19T10:00:00Z" 349 - }); 350 - 351 - let signed_record = create( 352 - &private_key, 353 - &record, 354 - repository, 355 - collection, 356 - signature_object, 357 - )?; 358 - 359 - verify( 360 - issuer_did, 361 - &public_key, 362 - signed_record, 363 - repository, 364 - collection, 365 - )?; 366 - 367 - Ok(()) 368 - } 369 - 370 - #[test] 371 - fn test_create_sign_and_verify_record_p384() -> Result<(), Box<dyn std::error::Error>> { 372 - // Test with P-384 curve 373 - let private_key = generate_key(KeyType::P384Private)?; 374 - let public_key = to_public(&private_key)?; 375 - 376 - let record = json!({ 377 - "displayName": "Test User", 378 - "description": "Testing P-384 signatures" 379 - }); 380 - 381 - let issuer_did = "did:web:example.com"; 382 - let repository = "did:plc:profile123"; 383 - let collection = "app.bsky.actor.profile"; 384 - 385 - let signature_object = json!({ 386 - "issuer": issuer_did, 387 - "issuedAt": "2025-01-19T10:00:00Z", 388 - "expiresAt": "2025-01-20T10:00:00Z", 389 - "customField": "custom value" 390 - }); 391 - 392 - let signed_record = create( 393 - &private_key, 394 - &record, 395 - repository, 396 - collection, 397 - signature_object.clone(), 398 - )?; 399 - 400 - // Verify custom fields are preserved in signature 401 - let signatures = signed_record 402 - .get("signatures") 403 - .and_then(|v| v.as_array()) 404 - .expect("signatures should exist"); 405 - let sig = &signatures[0]; 406 - assert_eq!( 407 - sig.get("customField").and_then(|v| v.as_str()), 408 - Some("custom value") 409 - ); 410 - 411 - verify( 412 - issuer_did, 413 - &public_key, 414 - signed_record, 415 - repository, 416 - collection, 417 - )?; 418 - 419 - Ok(()) 420 - } 421 - 422 - #[test] 423 - fn test_multiple_signatures() -> Result<(), Box<dyn std::error::Error>> { 424 - // Create a record with multiple signatures from different issuers 425 - let private_key1 = generate_key(KeyType::P256Private)?; 426 - let public_key1 = to_public(&private_key1)?; 427 - 428 - let private_key2 = generate_key(KeyType::K256Private)?; 429 - let public_key2 = to_public(&private_key2)?; 430 - 431 - let record = json!({ 432 - "text": "Multi-signed content", 433 - "important": true 434 - }); 435 - 436 - let repository = "did:plc:repo_multi"; 437 - let collection = "app.example.document"; 438 - 439 - // First signature 440 - let issuer1 = "did:plc:issuer1"; 441 - let sig_obj1 = json!({ 442 - "issuer": issuer1, 443 - "issuedAt": "2025-01-19T09:00:00Z", 444 - "role": "author" 445 - }); 446 - 447 - let signed_once = create(&private_key1, &record, repository, collection, sig_obj1)?; 448 - 449 - // Second signature on already signed record 450 - let issuer2 = "did:plc:issuer2"; 451 - let sig_obj2 = json!({ 452 - "issuer": issuer2, 453 - "issuedAt": "2025-01-19T10:00:00Z", 454 - "role": "reviewer" 455 - }); 456 - 457 - let signed_twice = create( 458 - &private_key2, 459 - &signed_once, 460 - repository, 461 - collection, 462 - sig_obj2, 463 - )?; 464 - 465 - // Verify we have two signatures 466 - let signatures = signed_twice 467 - .get("signatures") 468 - .and_then(|v| v.as_array()) 469 - .expect("signatures should exist"); 470 - assert_eq!(signatures.len(), 2); 471 - 472 - // Verify both signatures independently 473 - verify( 474 - issuer1, 475 - &public_key1, 476 - signed_twice.clone(), 477 - repository, 478 - collection, 479 - )?; 480 - verify( 481 - issuer2, 482 - &public_key2, 483 - signed_twice.clone(), 484 - repository, 485 - collection, 486 - )?; 487 - 488 - Ok(()) 489 - } 490 - 491 - #[test] 492 - fn test_verify_wrong_issuer_fails() -> Result<(), Box<dyn std::error::Error>> { 493 - let private_key = generate_key(KeyType::P256Private)?; 494 - let public_key = to_public(&private_key)?; 495 - 496 - let record = json!({"test": "data"}); 497 - let repository = "did:plc:repo"; 498 - let collection = "app.test"; 499 - 500 - let sig_obj = json!({ 501 - "issuer": "did:plc:correct_issuer" 502 - }); 503 - 504 - let signed = create(&private_key, &record, repository, collection, sig_obj)?; 505 - 506 - // Try to verify with wrong issuer 507 - let result = verify( 508 - "did:plc:wrong_issuer", 509 - &public_key, 510 - signed, 511 - repository, 512 - collection, 513 - ); 514 - 515 - assert!(result.is_err()); 516 - assert!(matches!( 517 - result.unwrap_err(), 518 - VerificationError::NoValidSignatureForIssuer { .. } 519 - )); 520 - 521 - Ok(()) 522 - } 523 - 524 - #[test] 525 - fn test_verify_wrong_key_fails() -> Result<(), Box<dyn std::error::Error>> { 526 - let private_key = generate_key(KeyType::P256Private)?; 527 - let wrong_private_key = generate_key(KeyType::P256Private)?; 528 - let wrong_public_key = to_public(&wrong_private_key)?; 529 - 530 - let record = json!({"test": "data"}); 531 - let repository = "did:plc:repo"; 532 - let collection = "app.test"; 533 - let issuer = "did:plc:issuer"; 534 - 535 - let sig_obj = json!({ "issuer": issuer }); 536 - 537 - let signed = create(&private_key, &record, repository, collection, sig_obj)?; 538 - 539 - // Try to verify with wrong key 540 - let result = verify(issuer, &wrong_public_key, signed, repository, collection); 541 - 542 - assert!(result.is_err()); 543 - assert!(matches!( 544 - result.unwrap_err(), 545 - VerificationError::CryptographicValidationFailed { .. } 546 - )); 547 - 548 - Ok(()) 549 - } 550 - 551 - #[test] 552 - fn test_verify_tampered_record_fails() -> Result<(), Box<dyn std::error::Error>> { 553 - let private_key = generate_key(KeyType::P256Private)?; 554 - let public_key = to_public(&private_key)?; 555 - 556 - let record = json!({"text": "original"}); 557 - let repository = "did:plc:repo"; 558 - let collection = "app.test"; 559 - let issuer = "did:plc:issuer"; 560 - 561 - let sig_obj = json!({ "issuer": issuer }); 562 - 563 - let mut signed = create(&private_key, &record, repository, collection, sig_obj)?; 564 - 565 - // Tamper with the record content 566 - if let Some(obj) = signed.as_object_mut() { 567 - obj.insert("text".to_string(), json!("tampered")); 568 - } 569 - 570 - // Verification should fail 571 - let result = verify(issuer, &public_key, signed, repository, collection); 572 - 573 - assert!(result.is_err()); 574 - assert!(matches!( 575 - result.unwrap_err(), 576 - VerificationError::CryptographicValidationFailed { .. } 577 - )); 578 - 579 - Ok(()) 580 - } 581 - 582 - #[test] 583 - fn test_create_missing_issuer_fails() -> Result<(), Box<dyn std::error::Error>> { 584 - let private_key = generate_key(KeyType::P256Private)?; 585 - 586 - let record = json!({"test": "data"}); 587 - let repository = "did:plc:repo"; 588 - let collection = "app.test"; 589 - 590 - // Signature object without issuer field 591 - let sig_obj = json!({ 592 - "issuedAt": "2025-01-19T10:00:00Z" 593 - }); 594 - 595 - let result = create(&private_key, &record, repository, collection, sig_obj); 596 - 597 - assert!(result.is_err()); 598 - assert!(matches!( 599 - result.unwrap_err(), 600 - VerificationError::SignatureObjectMissingField { field } if field == "issuer" 601 - )); 602 - 603 - Ok(()) 604 - } 605 - 606 - #[test] 607 - fn test_verify_supports_sigs_field() -> Result<(), Box<dyn std::error::Error>> { 608 - // Test backward compatibility with "sigs" field name 609 - let private_key = generate_key(KeyType::P256Private)?; 610 - let public_key = to_public(&private_key)?; 611 - 612 - let record = json!({"test": "data"}); 613 - let repository = "did:plc:repo"; 614 - let collection = "app.test"; 615 - let issuer = "did:plc:issuer"; 616 - 617 - let sig_obj = json!({ "issuer": issuer }); 618 - 619 - let mut signed = create(&private_key, &record, repository, collection, sig_obj)?; 620 - 621 - // Rename "signatures" to "sigs" 622 - if let Some(obj) = signed.as_object_mut() 623 - && let Some(signatures) = obj.remove("signatures") 624 - { 625 - obj.insert("sigs".to_string(), signatures); 626 - } 627 - 628 - // Should still verify successfully 629 - verify(issuer, &public_key, signed, repository, collection)?; 630 - 631 - Ok(()) 632 - } 633 - 634 - #[test] 635 - fn test_signature_preserves_original_record() -> Result<(), Box<dyn std::error::Error>> { 636 - let private_key = generate_key(KeyType::P256Private)?; 637 - 638 - let original_record = json!({ 639 - "text": "Original content", 640 - "metadata": { 641 - "author": "Test", 642 - "version": 1 643 - }, 644 - "tags": ["test", "sample"] 645 - }); 646 - 647 - let repository = "did:plc:repo"; 648 - let collection = "app.test"; 649 - 650 - let sig_obj = json!({ 651 - "issuer": "did:plc:issuer" 652 - }); 653 - 654 - let signed = create( 655 - &private_key, 656 - &original_record, 657 - repository, 658 - collection, 659 - sig_obj, 660 - )?; 661 - 662 - // All original fields should be preserved 663 - assert_eq!(signed.get("text"), original_record.get("text")); 664 - assert_eq!(signed.get("metadata"), original_record.get("metadata")); 665 - assert_eq!(signed.get("tags"), original_record.get("tags")); 666 - 667 - // Plus the new signatures field 668 - assert!(signed.get("signatures").is_some()); 669 - 670 - Ok(()) 671 - } 672 - }
···
+492
crates/atproto-record/src/tid.rs
···
··· 1 + //! Timestamp Identifier (TID) generation and parsing. 2 + //! 3 + //! TIDs are 64-bit integers encoded as 13-character base32-sortable strings, combining 4 + //! a microsecond timestamp with a random clock identifier for collision resistance. 5 + //! They provide a sortable, distributed identifier scheme for AT Protocol records. 6 + //! 7 + //! ## Format 8 + //! 9 + //! - **Length**: Always 13 ASCII characters 10 + //! - **Encoding**: Base32-sortable character set (`234567abcdefghijklmnopqrstuvwxyz`) 11 + //! - **Structure**: 64-bit big-endian integer with: 12 + //! - Bit 0 (top): Always 0 13 + //! - Bits 1-53: Microseconds since UNIX epoch 14 + //! - Bits 54-63: Random 10-bit clock identifier 15 + //! 16 + //! ## Example 17 + //! 18 + //! ``` 19 + //! use atproto_record::tid::Tid; 20 + //! 21 + //! // Generate a new TID 22 + //! let tid = Tid::new(); 23 + //! let tid_str = tid.to_string(); 24 + //! assert_eq!(tid_str.len(), 13); 25 + //! 26 + //! // Parse a TID string 27 + //! let parsed = tid_str.parse::<Tid>().unwrap(); 28 + //! assert_eq!(tid, parsed); 29 + //! 30 + //! // TIDs are sortable by timestamp 31 + //! let tid1 = Tid::new(); 32 + //! std::thread::sleep(std::time::Duration::from_micros(10)); 33 + //! let tid2 = Tid::new(); 34 + //! assert!(tid1 < tid2); 35 + //! ``` 36 + 37 + use std::fmt; 38 + use std::str::FromStr; 39 + use std::sync::Mutex; 40 + use std::time::{SystemTime, UNIX_EPOCH}; 41 + 42 + use crate::errors::TidError; 43 + 44 + /// Base32-sortable character set for TID encoding. 45 + /// 46 + /// This character set maintains lexicographic sort order when encoded TIDs 47 + /// are compared as strings, ensuring timestamp ordering is preserved. 48 + const BASE32_SORTABLE: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; 49 + 50 + /// Reverse lookup table for base32-sortable decoding. 51 + /// 52 + /// Maps ASCII character values to their corresponding 5-bit values. 53 + /// Invalid characters are marked with 0xFF. 54 + const BASE32_DECODE: [u8; 256] = { 55 + let mut table = [0xFF; 256]; 56 + table[b'2' as usize] = 0; 57 + table[b'3' as usize] = 1; 58 + table[b'4' as usize] = 2; 59 + table[b'5' as usize] = 3; 60 + table[b'6' as usize] = 4; 61 + table[b'7' as usize] = 5; 62 + table[b'a' as usize] = 6; 63 + table[b'b' as usize] = 7; 64 + table[b'c' as usize] = 8; 65 + table[b'd' as usize] = 9; 66 + table[b'e' as usize] = 10; 67 + table[b'f' as usize] = 11; 68 + table[b'g' as usize] = 12; 69 + table[b'h' as usize] = 13; 70 + table[b'i' as usize] = 14; 71 + table[b'j' as usize] = 15; 72 + table[b'k' as usize] = 16; 73 + table[b'l' as usize] = 17; 74 + table[b'm' as usize] = 18; 75 + table[b'n' as usize] = 19; 76 + table[b'o' as usize] = 20; 77 + table[b'p' as usize] = 21; 78 + table[b'q' as usize] = 22; 79 + table[b'r' as usize] = 23; 80 + table[b's' as usize] = 24; 81 + table[b't' as usize] = 25; 82 + table[b'u' as usize] = 26; 83 + table[b'v' as usize] = 27; 84 + table[b'w' as usize] = 28; 85 + table[b'x' as usize] = 29; 86 + table[b'y' as usize] = 30; 87 + table[b'z' as usize] = 31; 88 + table 89 + }; 90 + 91 + /// Timestamp Identifier (TID) for AT Protocol records. 92 + /// 93 + /// A TID combines a microsecond-precision timestamp with a random clock identifier 94 + /// to create a sortable, collision-resistant identifier. TIDs are represented as 95 + /// 13-character base32-sortable strings. 96 + /// 97 + /// ## Monotonicity 98 + /// 99 + /// The TID generator ensures monotonically increasing values even when the system 100 + /// clock moves backwards or multiple TIDs are generated within the same microsecond. 101 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 102 + pub struct Tid(u64); 103 + 104 + /// Thread-local state for monotonic TID generation. 105 + /// 106 + /// Tracks the last generated timestamp and clock identifier to ensure 107 + /// monotonically increasing TID values. 108 + static LAST_TID: Mutex<Option<(u64, u16)>> = Mutex::new(None); 109 + 110 + impl Tid { 111 + /// The length of a TID string in characters. 112 + pub const LENGTH: usize = 13; 113 + 114 + /// Maximum valid timestamp value (53 bits). 115 + const MAX_TIMESTAMP: u64 = (1u64 << 53) - 1; 116 + 117 + /// Bitmask for extracting the 10-bit clock identifier. 118 + const CLOCK_ID_MASK: u64 = 0x3FF; 119 + 120 + /// Creates a new TID with the current timestamp and a random clock identifier. 121 + /// 122 + /// This function ensures monotonically increasing TID values by tracking the 123 + /// last generated TID and incrementing the clock identifier when necessary. 124 + /// 125 + /// # Example 126 + /// 127 + /// ``` 128 + /// use atproto_record::tid::Tid; 129 + /// 130 + /// let tid = Tid::new(); 131 + /// println!("Generated TID: {}", tid); 132 + /// ``` 133 + pub fn new() -> Self { 134 + Self::new_with_time(Self::current_timestamp_micros()) 135 + } 136 + 137 + /// Creates a new TID with a specific timestamp (for testing). 138 + /// 139 + /// # Arguments 140 + /// 141 + /// * `timestamp_micros` - Microseconds since UNIX epoch 142 + /// 143 + /// # Panics 144 + /// 145 + /// Panics if the timestamp exceeds 53 bits (year 2255+). 146 + pub fn new_with_time(timestamp_micros: u64) -> Self { 147 + assert!( 148 + timestamp_micros <= Self::MAX_TIMESTAMP, 149 + "Timestamp exceeds 53-bit maximum" 150 + ); 151 + 152 + let mut last = LAST_TID.lock().unwrap(); 153 + 154 + let clock_id = if let Some((last_timestamp, last_clock)) = *last { 155 + if timestamp_micros > last_timestamp { 156 + // New timestamp, generate random clock ID 157 + Self::random_clock_id() 158 + } else if timestamp_micros == last_timestamp { 159 + // Same timestamp, increment clock ID 160 + if last_clock == Self::CLOCK_ID_MASK as u16 { 161 + // Clock ID overflow, use random 162 + Self::random_clock_id() 163 + } else { 164 + last_clock + 1 165 + } 166 + } else { 167 + // Clock moved backwards, use last timestamp + 1 168 + let adjusted_timestamp = last_timestamp + 1; 169 + let adjusted_clock = Self::random_clock_id(); 170 + *last = Some((adjusted_timestamp, adjusted_clock)); 171 + return Self::from_parts(adjusted_timestamp, adjusted_clock); 172 + } 173 + } else { 174 + // First TID, generate random clock ID 175 + Self::random_clock_id() 176 + }; 177 + 178 + *last = Some((timestamp_micros, clock_id)); 179 + Self::from_parts(timestamp_micros, clock_id) 180 + } 181 + 182 + /// Creates a TID from timestamp and clock identifier components. 183 + /// 184 + /// # Arguments 185 + /// 186 + /// * `timestamp_micros` - Microseconds since UNIX epoch (53 bits max) 187 + /// * `clock_id` - Random clock identifier (10 bits max) 188 + /// 189 + /// # Panics 190 + /// 191 + /// Panics if timestamp exceeds 53 bits or clock_id exceeds 10 bits. 192 + pub fn from_parts(timestamp_micros: u64, clock_id: u16) -> Self { 193 + assert!( 194 + timestamp_micros <= Self::MAX_TIMESTAMP, 195 + "Timestamp exceeds 53-bit maximum" 196 + ); 197 + assert!( 198 + clock_id <= Self::CLOCK_ID_MASK as u16, 199 + "Clock ID exceeds 10-bit maximum" 200 + ); 201 + 202 + // Combine: top bit 0, 53 bits timestamp, 10 bits clock ID 203 + let value = (timestamp_micros << 10) | (clock_id as u64); 204 + Tid(value) 205 + } 206 + 207 + /// Returns the timestamp component in microseconds since UNIX epoch. 208 + /// 209 + /// # Example 210 + /// 211 + /// ``` 212 + /// use atproto_record::tid::Tid; 213 + /// 214 + /// let tid = Tid::new(); 215 + /// let timestamp = tid.timestamp_micros(); 216 + /// println!("Timestamp: {} ฮผs", timestamp); 217 + /// ``` 218 + pub fn timestamp_micros(&self) -> u64 { 219 + self.0 >> 10 220 + } 221 + 222 + /// Returns the clock identifier component (10 bits). 223 + /// 224 + /// # Example 225 + /// 226 + /// ``` 227 + /// use atproto_record::tid::Tid; 228 + /// 229 + /// let tid = Tid::new(); 230 + /// let clock_id = tid.clock_id(); 231 + /// println!("Clock ID: {}", clock_id); 232 + /// ``` 233 + pub fn clock_id(&self) -> u16 { 234 + (self.0 & Self::CLOCK_ID_MASK) as u16 235 + } 236 + 237 + /// Returns the raw 64-bit integer value. 238 + pub fn as_u64(&self) -> u64 { 239 + self.0 240 + } 241 + 242 + /// Encodes the TID as a 13-character base32-sortable string. 243 + /// 244 + /// # Example 245 + /// 246 + /// ``` 247 + /// use atproto_record::tid::Tid; 248 + /// 249 + /// let tid = Tid::new(); 250 + /// let encoded = tid.encode(); 251 + /// assert_eq!(encoded.len(), 13); 252 + /// ``` 253 + pub fn encode(&self) -> String { 254 + let mut chars = [0u8; Self::LENGTH]; 255 + let mut value = self.0; 256 + 257 + // Encode from right to left (least significant to most significant) 258 + for i in (0..Self::LENGTH).rev() { 259 + chars[i] = BASE32_SORTABLE[(value & 0x1F) as usize]; 260 + value >>= 5; 261 + } 262 + 263 + // BASE32_SORTABLE only contains valid UTF-8 ASCII characters 264 + String::from_utf8(chars.to_vec()).expect("base32-sortable encoding is always valid UTF-8") 265 + } 266 + 267 + /// Decodes a base32-sortable string into a TID. 268 + /// 269 + /// # Errors 270 + /// 271 + /// Returns [`TidError::InvalidLength`] if the string is not exactly 13 characters. 272 + /// Returns [`TidError::InvalidCharacter`] if the string contains invalid characters. 273 + /// Returns [`TidError::InvalidFormat`] if the decoded value has the top bit set. 274 + /// 275 + /// # Example 276 + /// 277 + /// ``` 278 + /// use atproto_record::tid::Tid; 279 + /// 280 + /// let tid_str = "3jzfcijpj2z2a"; 281 + /// let tid = Tid::decode(tid_str).unwrap(); 282 + /// assert_eq!(tid.to_string(), tid_str); 283 + /// ``` 284 + pub fn decode(s: &str) -> Result<Self, TidError> { 285 + if s.len() != Self::LENGTH { 286 + return Err(TidError::InvalidLength { 287 + expected: Self::LENGTH, 288 + actual: s.len(), 289 + }); 290 + } 291 + 292 + let bytes = s.as_bytes(); 293 + let mut value: u64 = 0; 294 + 295 + for (i, &byte) in bytes.iter().enumerate() { 296 + let decoded = BASE32_DECODE[byte as usize]; 297 + if decoded == 0xFF { 298 + return Err(TidError::InvalidCharacter { 299 + character: byte as char, 300 + position: i, 301 + }); 302 + } 303 + value = (value << 5) | (decoded as u64); 304 + } 305 + 306 + // Verify top bit is 0 307 + if value & (1u64 << 63) != 0 { 308 + return Err(TidError::InvalidFormat { 309 + reason: "Top bit must be 0".to_string(), 310 + }); 311 + } 312 + 313 + Ok(Tid(value)) 314 + } 315 + 316 + /// Gets the current timestamp in microseconds since UNIX epoch. 317 + fn current_timestamp_micros() -> u64 { 318 + SystemTime::now() 319 + .duration_since(UNIX_EPOCH) 320 + .expect("System time before UNIX epoch") 321 + .as_micros() as u64 322 + } 323 + 324 + /// Generates a random 10-bit clock identifier. 325 + fn random_clock_id() -> u16 { 326 + use rand::RngCore; 327 + let mut rng = rand::thread_rng(); 328 + (rng.next_u32() as u16) & (Self::CLOCK_ID_MASK as u16) 329 + } 330 + } 331 + 332 + impl Default for Tid { 333 + fn default() -> Self { 334 + Self::new() 335 + } 336 + } 337 + 338 + impl fmt::Display for Tid { 339 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 340 + write!(f, "{}", self.encode()) 341 + } 342 + } 343 + 344 + impl FromStr for Tid { 345 + type Err = TidError; 346 + 347 + fn from_str(s: &str) -> Result<Self, Self::Err> { 348 + Self::decode(s) 349 + } 350 + } 351 + 352 + impl serde::Serialize for Tid { 353 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 354 + where 355 + S: serde::Serializer, 356 + { 357 + serializer.serialize_str(&self.encode()) 358 + } 359 + } 360 + 361 + impl<'de> serde::Deserialize<'de> for Tid { 362 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 363 + where 364 + D: serde::Deserializer<'de>, 365 + { 366 + let s = String::deserialize(deserializer)?; 367 + Self::decode(&s).map_err(serde::de::Error::custom) 368 + } 369 + } 370 + 371 + #[cfg(test)] 372 + mod tests { 373 + use super::*; 374 + 375 + #[test] 376 + fn test_tid_encode_decode() { 377 + let tid = Tid::new(); 378 + let encoded = tid.encode(); 379 + assert_eq!(encoded.len(), Tid::LENGTH); 380 + 381 + let decoded = Tid::decode(&encoded).unwrap(); 382 + assert_eq!(tid, decoded); 383 + } 384 + 385 + #[test] 386 + fn test_tid_from_parts() { 387 + let timestamp = 1234567890123456u64; 388 + let clock_id = 42u16; 389 + let tid = Tid::from_parts(timestamp, clock_id); 390 + 391 + assert_eq!(tid.timestamp_micros(), timestamp); 392 + assert_eq!(tid.clock_id(), clock_id); 393 + } 394 + 395 + #[test] 396 + fn test_tid_monotonic() { 397 + let tid1 = Tid::new(); 398 + std::thread::sleep(std::time::Duration::from_micros(10)); 399 + let tid2 = Tid::new(); 400 + 401 + assert!(tid1 < tid2); 402 + } 403 + 404 + #[test] 405 + fn test_tid_same_timestamp() { 406 + let timestamp = 1234567890123456u64; 407 + let tid1 = Tid::new_with_time(timestamp); 408 + let tid2 = Tid::new_with_time(timestamp); 409 + 410 + // Should have different clock IDs or incremented clock ID 411 + assert!(tid1 < tid2 || tid1.clock_id() + 1 == tid2.clock_id()); 412 + } 413 + 414 + #[test] 415 + fn test_tid_string_roundtrip() { 416 + let tid = Tid::new(); 417 + let s = tid.to_string(); 418 + let parsed: Tid = s.parse().unwrap(); 419 + assert_eq!(tid, parsed); 420 + } 421 + 422 + #[test] 423 + fn test_tid_serde() { 424 + let tid = Tid::new(); 425 + let json = serde_json::to_string(&tid).unwrap(); 426 + let parsed: Tid = serde_json::from_str(&json).unwrap(); 427 + assert_eq!(tid, parsed); 428 + } 429 + 430 + #[test] 431 + fn test_tid_valid_examples() { 432 + // Examples from the specification 433 + let examples = ["3jzfcijpj2z2a", "7777777777777", "2222222222222"]; 434 + 435 + for example in &examples { 436 + let tid = Tid::decode(example).unwrap(); 437 + assert_eq!(&tid.encode(), example); 438 + } 439 + } 440 + 441 + #[test] 442 + fn test_tid_invalid_length() { 443 + let result = Tid::decode("123"); 444 + assert!(matches!(result, Err(TidError::InvalidLength { .. }))); 445 + } 446 + 447 + #[test] 448 + fn test_tid_invalid_character() { 449 + let result = Tid::decode("123456789012!"); 450 + assert!(matches!(result, Err(TidError::InvalidCharacter { .. }))); 451 + } 452 + 453 + #[test] 454 + fn test_tid_first_char_range() { 455 + // First character must be in valid range per spec 456 + let tid = Tid::new(); 457 + let encoded = tid.encode(); 458 + let first_char = encoded.chars().next().unwrap(); 459 + 460 + // First char must be 234567abcdefghij (values 0-15 in base32-sortable) 461 + assert!("234567abcdefghij".contains(first_char)); 462 + } 463 + 464 + #[test] 465 + fn test_tid_sortability() { 466 + // TIDs with increasing timestamps should sort correctly as strings 467 + let tid1 = Tid::from_parts(1000000, 0); 468 + let tid2 = Tid::from_parts(2000000, 0); 469 + let tid3 = Tid::from_parts(3000000, 0); 470 + 471 + let s1 = tid1.to_string(); 472 + let s2 = tid2.to_string(); 473 + let s3 = tid3.to_string(); 474 + 475 + assert!(s1 < s2); 476 + assert!(s2 < s3); 477 + assert!(s1 < s3); 478 + } 479 + 480 + #[test] 481 + fn test_tid_clock_backward() { 482 + // Simulate clock moving backwards 483 + let timestamp1 = 2000000u64; 484 + let tid1 = Tid::new_with_time(timestamp1); 485 + 486 + let timestamp2 = 1000000u64; // Earlier timestamp 487 + let tid2 = Tid::new_with_time(timestamp2); 488 + 489 + // TID should still be monotonically increasing 490 + assert!(tid2 > tid1); 491 + } 492 + }
+53
crates/atproto-tap/Cargo.toml
···
··· 1 + [package] 2 + name = "atproto-tap" 3 + version = "0.13.0" 4 + description = "AT Protocol TAP (Trusted Attestation Protocol) service consumer" 5 + readme = "README.md" 6 + homepage = "https://tangled.sh/@smokesignal.events/atproto-identity-rs" 7 + documentation = "https://docs.rs/atproto-tap" 8 + 9 + edition.workspace = true 10 + rust-version.workspace = true 11 + authors.workspace = true 12 + repository.workspace = true 13 + license.workspace = true 14 + keywords.workspace = true 15 + categories.workspace = true 16 + 17 + [dependencies] 18 + tokio = { workspace = true, features = ["sync", "time"] } 19 + tokio-stream = "0.1" 20 + tokio-websockets = { workspace = true } 21 + futures = { workspace = true } 22 + reqwest = { workspace = true } 23 + serde = { workspace = true } 24 + serde_json = { workspace = true } 25 + thiserror = { workspace = true } 26 + tracing = { workspace = true } 27 + http = { workspace = true } 28 + base64 = { workspace = true } 29 + atproto-identity.workspace = true 30 + atproto-client = { workspace = true, optional = true } 31 + 32 + # Memory efficiency 33 + compact_str = { version = "0.8", features = ["serde"] } 34 + itoa = "1.0" 35 + 36 + # Optional for CLI 37 + clap = { workspace = true, optional = true } 38 + tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } 39 + 40 + [features] 41 + default = [] 42 + clap = ["dep:clap", "dep:tracing-subscriber", "dep:atproto-client", "tokio/rt-multi-thread", "tokio/macros", "tokio/signal"] 43 + 44 + [[bin]] 45 + name = "atproto-tap-client" 46 + required-features = ["clap"] 47 + 48 + [[bin]] 49 + name = "atproto-tap-extras" 50 + required-features = ["clap"] 51 + 52 + [lints] 53 + workspace = true
+351
crates/atproto-tap/src/bin/atproto-tap-client.rs
···
··· 1 + //! Command-line client for TAP services. 2 + //! 3 + //! This tool provides commands for consuming TAP events and managing tracked repositories. 4 + //! 5 + //! # Usage 6 + //! 7 + //! ```bash 8 + //! # Stream events from a TAP service 9 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 read 10 + //! 11 + //! # Stream with authentication and filters 12 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret read --live-only 13 + //! 14 + //! # Add repositories to track 15 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret repos add did:plc:xyz did:plc:abc 16 + //! 17 + //! # Remove repositories from tracking 18 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 -p secret repos remove did:plc:xyz 19 + //! 20 + //! # Resolve a DID to its DID document 21 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 resolve did:plc:xyz 22 + //! 23 + //! # Resolve a DID and only output the handle 24 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 resolve did:plc:xyz --handle-only 25 + //! 26 + //! # Get repository tracking info 27 + //! cargo run --features cli --bin atproto-tap-client -- localhost:2480 info did:plc:xyz 28 + //! ``` 29 + 30 + use atproto_tap::{TapClient, TapConfig, TapEvent, connect}; 31 + use clap::{Parser, Subcommand}; 32 + use std::time::Duration; 33 + use tokio_stream::StreamExt; 34 + 35 + /// TAP service client for consuming events and managing repositories. 36 + #[derive(Parser)] 37 + #[command( 38 + name = "atproto-tap-client", 39 + version, 40 + about = "TAP service client for AT Protocol", 41 + long_about = "Connect to a TAP service to stream repository/identity events or manage tracked repositories.\n\n\ 42 + Events are printed to stdout as JSON, one per line.\n\ 43 + Use Ctrl+C to gracefully stop the consumer." 44 + )] 45 + struct Args { 46 + /// TAP service hostname (e.g., localhost:2480) 47 + hostname: String, 48 + 49 + /// Admin password for authentication 50 + #[arg(short, long, global = true)] 51 + password: Option<String>, 52 + 53 + #[command(subcommand)] 54 + command: Command, 55 + } 56 + 57 + #[derive(Subcommand)] 58 + enum Command { 59 + /// Connect to TAP and stream events as JSON 60 + Read { 61 + /// Disable acknowledgments 62 + #[arg(long)] 63 + no_acks: bool, 64 + 65 + /// Maximum reconnection attempts (0 = unlimited) 66 + #[arg(long, default_value = "0")] 67 + max_reconnects: u32, 68 + 69 + /// Print debug information to stderr 70 + #[arg(short, long)] 71 + debug: bool, 72 + 73 + /// Filter to specific collections (comma-separated) 74 + #[arg(long)] 75 + collections: Option<String>, 76 + 77 + /// Only show live events (skip backfill) 78 + #[arg(long)] 79 + live_only: bool, 80 + }, 81 + 82 + /// Manage tracked repositories 83 + Repos { 84 + #[command(subcommand)] 85 + action: ReposAction, 86 + }, 87 + 88 + /// Resolve a DID to its DID document 89 + Resolve { 90 + /// DID to resolve (e.g., did:plc:xyz123) 91 + did: String, 92 + 93 + /// Only output the handle (instead of full DID document) 94 + #[arg(long)] 95 + handle_only: bool, 96 + }, 97 + 98 + /// Get tracking info for a repository 99 + Info { 100 + /// DID to get info for (e.g., did:plc:xyz123) 101 + did: String, 102 + }, 103 + } 104 + 105 + #[derive(Subcommand)] 106 + enum ReposAction { 107 + /// Add repositories to track 108 + Add { 109 + /// DIDs to add (e.g., did:plc:xyz123) 110 + #[arg(required = true)] 111 + dids: Vec<String>, 112 + }, 113 + 114 + /// Remove repositories from tracking 115 + Remove { 116 + /// DIDs to remove 117 + #[arg(required = true)] 118 + dids: Vec<String>, 119 + }, 120 + } 121 + 122 + #[tokio::main] 123 + async fn main() { 124 + let args = Args::parse(); 125 + 126 + match args.command { 127 + Command::Read { 128 + no_acks, 129 + max_reconnects, 130 + debug, 131 + collections, 132 + live_only, 133 + } => { 134 + run_read( 135 + &args.hostname, 136 + args.password, 137 + no_acks, 138 + max_reconnects, 139 + debug, 140 + collections, 141 + live_only, 142 + ) 143 + .await; 144 + } 145 + Command::Repos { action } => { 146 + run_repos(&args.hostname, args.password, action).await; 147 + } 148 + Command::Resolve { did, handle_only } => { 149 + run_resolve(&args.hostname, args.password, &did, handle_only).await; 150 + } 151 + Command::Info { did } => { 152 + run_info(&args.hostname, args.password, &did).await; 153 + } 154 + } 155 + } 156 + 157 + async fn run_read( 158 + hostname: &str, 159 + password: Option<String>, 160 + no_acks: bool, 161 + max_reconnects: u32, 162 + debug: bool, 163 + collections: Option<String>, 164 + live_only: bool, 165 + ) { 166 + // Initialize tracing if debug mode 167 + if debug { 168 + tracing_subscriber::fmt() 169 + .with_env_filter("atproto_tap=debug") 170 + .with_writer(std::io::stderr) 171 + .init(); 172 + } 173 + 174 + // Build configuration 175 + let mut config_builder = TapConfig::builder() 176 + .hostname(hostname) 177 + .send_acks(!no_acks); 178 + 179 + if let Some(password) = password { 180 + config_builder = config_builder.admin_password(password); 181 + } 182 + 183 + if max_reconnects > 0 { 184 + config_builder = config_builder.max_reconnect_attempts(Some(max_reconnects)); 185 + } 186 + 187 + // Set reasonable defaults for CLI usage 188 + config_builder = config_builder 189 + .initial_reconnect_delay(Duration::from_secs(1)) 190 + .max_reconnect_delay(Duration::from_secs(30)); 191 + 192 + let config = config_builder.build(); 193 + 194 + eprintln!("Connecting to TAP service at {}...", hostname); 195 + 196 + let mut stream = connect(config); 197 + 198 + // Parse collection filters 199 + let collection_filters: Vec<String> = collections 200 + .map(|c| c.split(',').map(|s| s.trim().to_string()).collect()) 201 + .unwrap_or_default(); 202 + 203 + // Handle Ctrl+C 204 + let ctrl_c = tokio::signal::ctrl_c(); 205 + tokio::pin!(ctrl_c); 206 + 207 + loop { 208 + tokio::select! { 209 + Some(result) = stream.next() => { 210 + match result { 211 + Ok(event) => { 212 + // Apply filters 213 + let should_print = match event.as_ref() { 214 + TapEvent::Record { record, .. } => { 215 + // Filter by live flag 216 + if live_only && !record.live { 217 + false 218 + } 219 + // Filter by collection 220 + else if !collection_filters.is_empty() { 221 + collection_filters.iter().any(|c| record.collection.as_ref() == c) 222 + } else { 223 + true 224 + } 225 + } 226 + TapEvent::Identity { .. } => !live_only, // Always show identity unless live_only 227 + }; 228 + 229 + if should_print { 230 + // Print as JSON to stdout 231 + match serde_json::to_string(event.as_ref()) { 232 + Ok(json) => println!("{}", json), 233 + Err(e) => { 234 + eprintln!("Failed to serialize event: {}", e); 235 + } 236 + } 237 + } 238 + } 239 + Err(e) => { 240 + eprintln!("Error: {}", e); 241 + 242 + // Exit on fatal errors 243 + if e.is_fatal() { 244 + eprintln!("Fatal error, exiting"); 245 + std::process::exit(1); 246 + } 247 + } 248 + } 249 + } 250 + _ = &mut ctrl_c => { 251 + eprintln!("\nReceived Ctrl+C, shutting down..."); 252 + stream.close().await; 253 + break; 254 + } 255 + } 256 + } 257 + 258 + eprintln!("Client stopped"); 259 + } 260 + 261 + async fn run_repos(hostname: &str, password: Option<String>, action: ReposAction) { 262 + let client = TapClient::new(hostname, password); 263 + 264 + match action { 265 + ReposAction::Add { dids } => { 266 + let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect(); 267 + 268 + match client.add_repos(&did_refs).await { 269 + Ok(()) => { 270 + eprintln!("Added {} repository(ies) to tracking", dids.len()); 271 + for did in &dids { 272 + println!("{}", did); 273 + } 274 + } 275 + Err(e) => { 276 + eprintln!("Failed to add repositories: {}", e); 277 + std::process::exit(1); 278 + } 279 + } 280 + } 281 + ReposAction::Remove { dids } => { 282 + let did_refs: Vec<&str> = dids.iter().map(|s| s.as_str()).collect(); 283 + 284 + match client.remove_repos(&did_refs).await { 285 + Ok(()) => { 286 + eprintln!("Removed {} repository(ies) from tracking", dids.len()); 287 + for did in &dids { 288 + println!("{}", did); 289 + } 290 + } 291 + Err(e) => { 292 + eprintln!("Failed to remove repositories: {}", e); 293 + std::process::exit(1); 294 + } 295 + } 296 + } 297 + } 298 + } 299 + 300 + async fn run_resolve(hostname: &str, password: Option<String>, did: &str, handle_only: bool) { 301 + let client = TapClient::new(hostname, password); 302 + 303 + match client.resolve(did).await { 304 + Ok(doc) => { 305 + if handle_only { 306 + // Use the handles() method from atproto_identity::model::Document 307 + match doc.handles() { 308 + Some(handle) => println!("{}", handle), 309 + None => { 310 + eprintln!("No handle found in DID document"); 311 + std::process::exit(1); 312 + } 313 + } 314 + } else { 315 + // Print full DID document as JSON 316 + match serde_json::to_string_pretty(&doc) { 317 + Ok(json) => println!("{}", json), 318 + Err(e) => { 319 + eprintln!("Failed to serialize DID document: {}", e); 320 + std::process::exit(1); 321 + } 322 + } 323 + } 324 + } 325 + Err(e) => { 326 + eprintln!("Failed to resolve DID: {}", e); 327 + std::process::exit(1); 328 + } 329 + } 330 + } 331 + 332 + async fn run_info(hostname: &str, password: Option<String>, did: &str) { 333 + let client = TapClient::new(hostname, password); 334 + 335 + match client.info(did).await { 336 + Ok(info) => { 337 + // Print as JSON for easy parsing 338 + match serde_json::to_string_pretty(&info) { 339 + Ok(json) => println!("{}", json), 340 + Err(e) => { 341 + eprintln!("Failed to serialize info: {}", e); 342 + std::process::exit(1); 343 + } 344 + } 345 + } 346 + Err(e) => { 347 + eprintln!("Failed to get repository info: {}", e); 348 + std::process::exit(1); 349 + } 350 + } 351 + }
+214
crates/atproto-tap/src/bin/atproto-tap-extras.rs
···
··· 1 + //! Additional TAP client utilities for AT Protocol. 2 + //! 3 + //! This tool provides extra commands for managing TAP tracked repositories 4 + //! based on social graph data. 5 + //! 6 + //! # Usage 7 + //! 8 + //! ```bash 9 + //! # Add all accounts followed by a DID to TAP tracking 10 + //! cargo run --features cli --bin atproto-tap-extras -- localhost:2480 repos-add-followers did:plc:xyz 11 + //! 12 + //! # With authentication 13 + //! cargo run --features cli --bin atproto-tap-extras -- localhost:2480 -p secret repos-add-followers did:plc:xyz 14 + //! ``` 15 + 16 + use atproto_client::client::Auth; 17 + use atproto_client::com::atproto::repo::{ListRecordsParams, list_records}; 18 + use atproto_identity::plc::query as plc_query; 19 + use atproto_tap::TapClient; 20 + use clap::{Parser, Subcommand}; 21 + use serde::Deserialize; 22 + 23 + /// TAP extras utility for managing tracked repositories. 24 + #[derive(Parser)] 25 + #[command( 26 + name = "atproto-tap-extras", 27 + version, 28 + about = "TAP extras utility for AT Protocol", 29 + long_about = "Additional utilities for managing TAP tracked repositories based on social graph data." 30 + )] 31 + struct Args { 32 + /// TAP service hostname (e.g., localhost:2480) 33 + hostname: String, 34 + 35 + /// Admin password for TAP authentication 36 + #[arg(short, long, global = true)] 37 + password: Option<String>, 38 + 39 + /// PLC directory hostname for DID resolution 40 + #[arg(long, default_value = "plc.directory", global = true)] 41 + plc_hostname: String, 42 + 43 + #[command(subcommand)] 44 + command: Command, 45 + } 46 + 47 + #[derive(Subcommand)] 48 + enum Command { 49 + /// Add accounts followed by a DID to TAP tracking. 50 + /// 51 + /// Fetches all app.bsky.graph.follow records from the specified DID's repository 52 + /// and adds the followed DIDs to TAP for tracking. 53 + ReposAddFollowers { 54 + /// DID to read followers from (e.g., did:plc:xyz123) 55 + did: String, 56 + 57 + /// Batch size for adding repos to TAP 58 + #[arg(long, default_value = "100")] 59 + batch_size: usize, 60 + 61 + /// Dry run - print DIDs without adding to TAP 62 + #[arg(long)] 63 + dry_run: bool, 64 + }, 65 + } 66 + 67 + /// Follow record structure from app.bsky.graph.follow. 68 + #[derive(Debug, Deserialize)] 69 + struct FollowRecord { 70 + /// The DID of the account being followed. 71 + subject: String, 72 + } 73 + 74 + #[tokio::main] 75 + async fn main() { 76 + let args = Args::parse(); 77 + 78 + match args.command { 79 + Command::ReposAddFollowers { 80 + did, 81 + batch_size, 82 + dry_run, 83 + } => { 84 + run_repos_add_followers( 85 + &args.hostname, 86 + args.password, 87 + &args.plc_hostname, 88 + &did, 89 + batch_size, 90 + dry_run, 91 + ) 92 + .await; 93 + } 94 + } 95 + } 96 + 97 + async fn run_repos_add_followers( 98 + tap_hostname: &str, 99 + tap_password: Option<String>, 100 + plc_hostname: &str, 101 + did: &str, 102 + batch_size: usize, 103 + dry_run: bool, 104 + ) { 105 + let http_client = reqwest::Client::new(); 106 + 107 + // Resolve the DID to get the PDS endpoint 108 + eprintln!("Resolving DID: {}", did); 109 + let document = match plc_query(&http_client, plc_hostname, did).await { 110 + Ok(doc) => doc, 111 + Err(e) => { 112 + eprintln!("Failed to resolve DID: {}", e); 113 + std::process::exit(1); 114 + } 115 + }; 116 + 117 + let pds_endpoints = document.pds_endpoints(); 118 + if pds_endpoints.is_empty() { 119 + eprintln!("No PDS endpoint found in DID document"); 120 + std::process::exit(1); 121 + } 122 + let pds_url = pds_endpoints[0]; 123 + eprintln!("Using PDS: {}", pds_url); 124 + 125 + // Collect all followed DIDs 126 + let mut followed_dids: Vec<String> = Vec::new(); 127 + let mut cursor: Option<String> = None; 128 + let collection = "app.bsky.graph.follow".to_string(); 129 + 130 + eprintln!("Fetching follow records..."); 131 + 132 + loop { 133 + let params = if let Some(c) = cursor.take() { 134 + ListRecordsParams::new().limit(100).cursor(c) 135 + } else { 136 + ListRecordsParams::new().limit(100) 137 + }; 138 + 139 + let response = match list_records::<FollowRecord>( 140 + &http_client, 141 + &Auth::None, 142 + pds_url, 143 + did.to_string(), 144 + collection.clone(), 145 + params, 146 + ) 147 + .await 148 + { 149 + Ok(resp) => resp, 150 + Err(e) => { 151 + eprintln!("Failed to list records: {}", e); 152 + std::process::exit(1); 153 + } 154 + }; 155 + 156 + for record in &response.records { 157 + followed_dids.push(record.value.subject.clone()); 158 + } 159 + 160 + eprintln!( 161 + " Fetched {} records (total: {})", 162 + response.records.len(), 163 + followed_dids.len() 164 + ); 165 + 166 + match response.cursor { 167 + Some(c) if !response.records.is_empty() => { 168 + cursor = Some(c); 169 + } 170 + _ => break, 171 + } 172 + } 173 + 174 + if followed_dids.is_empty() { 175 + eprintln!("No follow records found"); 176 + return; 177 + } 178 + 179 + eprintln!("Found {} followed accounts", followed_dids.len()); 180 + 181 + if dry_run { 182 + eprintln!("\nDry run - would add these DIDs to TAP:"); 183 + for did in &followed_dids { 184 + println!("{}", did); 185 + } 186 + return; 187 + } 188 + 189 + // Add to TAP in batches 190 + let tap_client = TapClient::new(tap_hostname, tap_password); 191 + let mut added = 0; 192 + 193 + for chunk in followed_dids.chunks(batch_size) { 194 + let did_refs: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect(); 195 + 196 + match tap_client.add_repos(&did_refs).await { 197 + Ok(()) => { 198 + added += chunk.len(); 199 + eprintln!("Added {} DIDs to TAP (total: {})", chunk.len(), added); 200 + } 201 + Err(e) => { 202 + eprintln!("Failed to add repos to TAP: {}", e); 203 + std::process::exit(1); 204 + } 205 + } 206 + } 207 + 208 + eprintln!("Successfully added {} DIDs to TAP", added); 209 + 210 + // Print all added DIDs 211 + for did in &followed_dids { 212 + println!("{}", did); 213 + } 214 + }
+371
crates/atproto-tap/src/client.rs
···
··· 1 + //! HTTP client for TAP management API. 2 + //! 3 + //! This module provides [`TapClient`] for interacting with the TAP service's 4 + //! HTTP management endpoints, including adding/removing tracked repositories. 5 + 6 + use crate::errors::TapError; 7 + use atproto_identity::model::Document; 8 + use base64::Engine; 9 + use base64::engine::general_purpose::STANDARD as BASE64; 10 + use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + /// HTTP client for TAP management API. 14 + /// 15 + /// Provides methods for managing which repositories the TAP service tracks, 16 + /// checking service health, and querying repository status. 17 + /// 18 + /// # Example 19 + /// 20 + /// ```ignore 21 + /// use atproto_tap::TapClient; 22 + /// 23 + /// let client = TapClient::new("localhost:2480", Some("admin_password".to_string())); 24 + /// 25 + /// // Add repositories to track 26 + /// client.add_repos(&["did:plc:xyz123", "did:plc:abc456"]).await?; 27 + /// 28 + /// // Check health 29 + /// if client.health().await? { 30 + /// println!("TAP service is healthy"); 31 + /// } 32 + /// ``` 33 + #[derive(Debug, Clone)] 34 + pub struct TapClient { 35 + http_client: reqwest::Client, 36 + base_url: String, 37 + auth_header: Option<HeaderValue>, 38 + } 39 + 40 + impl TapClient { 41 + /// Create a new TAP management client. 42 + /// 43 + /// # Arguments 44 + /// 45 + /// * `hostname` - TAP service hostname (e.g., "localhost:2480") 46 + /// * `admin_password` - Optional admin password for authentication 47 + pub fn new(hostname: &str, admin_password: Option<String>) -> Self { 48 + let auth_header = admin_password.map(|password| { 49 + let credentials = format!("admin:{}", password); 50 + let encoded = BASE64.encode(credentials.as_bytes()); 51 + HeaderValue::from_str(&format!("Basic {}", encoded)) 52 + .expect("Invalid auth header value") 53 + }); 54 + 55 + Self { 56 + http_client: reqwest::Client::new(), 57 + base_url: format!("http://{}", hostname), 58 + auth_header, 59 + } 60 + } 61 + 62 + /// Create default headers for requests. 63 + fn default_headers(&self) -> HeaderMap { 64 + let mut headers = HeaderMap::new(); 65 + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 66 + if let Some(auth) = &self.auth_header { 67 + headers.insert(AUTHORIZATION, auth.clone()); 68 + } 69 + headers 70 + } 71 + 72 + /// Add repositories to track. 73 + /// 74 + /// Sends a POST request to `/repos/add` with the list of DIDs. 75 + /// 76 + /// # Arguments 77 + /// 78 + /// * `dids` - Slice of DID strings to track 79 + /// 80 + /// # Example 81 + /// 82 + /// ```ignore 83 + /// client.add_repos(&[ 84 + /// "did:plc:z72i7hdynmk6r22z27h6tvur", 85 + /// "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 86 + /// ]).await?; 87 + /// ``` 88 + pub async fn add_repos(&self, dids: &[&str]) -> Result<(), TapError> { 89 + let url = format!("{}/repos/add", self.base_url); 90 + let body = AddReposRequest { 91 + dids: dids.iter().map(|s| s.to_string()).collect(), 92 + }; 93 + 94 + let response = self 95 + .http_client 96 + .post(&url) 97 + .headers(self.default_headers()) 98 + .json(&body) 99 + .send() 100 + .await?; 101 + 102 + if response.status().is_success() { 103 + tracing::debug!(count = dids.len(), "Added repositories to TAP"); 104 + Ok(()) 105 + } else { 106 + let status = response.status().as_u16(); 107 + let message = response.text().await.unwrap_or_default(); 108 + Err(TapError::HttpResponseError { status, message }) 109 + } 110 + } 111 + 112 + /// Remove repositories from tracking. 113 + /// 114 + /// Sends a POST request to `/repos/remove` with the list of DIDs. 115 + /// 116 + /// # Arguments 117 + /// 118 + /// * `dids` - Slice of DID strings to stop tracking 119 + pub async fn remove_repos(&self, dids: &[&str]) -> Result<(), TapError> { 120 + let url = format!("{}/repos/remove", self.base_url); 121 + let body = AddReposRequest { 122 + dids: dids.iter().map(|s| s.to_string()).collect(), 123 + }; 124 + 125 + let response = self 126 + .http_client 127 + .post(&url) 128 + .headers(self.default_headers()) 129 + .json(&body) 130 + .send() 131 + .await?; 132 + 133 + if response.status().is_success() { 134 + tracing::debug!(count = dids.len(), "Removed repositories from TAP"); 135 + Ok(()) 136 + } else { 137 + let status = response.status().as_u16(); 138 + let message = response.text().await.unwrap_or_default(); 139 + Err(TapError::HttpResponseError { status, message }) 140 + } 141 + } 142 + 143 + /// Check service health. 144 + /// 145 + /// Sends a GET request to `/health`. 146 + /// 147 + /// # Returns 148 + /// 149 + /// `true` if the service is healthy, `false` otherwise. 150 + pub async fn health(&self) -> Result<bool, TapError> { 151 + let url = format!("{}/health", self.base_url); 152 + 153 + let response = self 154 + .http_client 155 + .get(&url) 156 + .headers(self.default_headers()) 157 + .send() 158 + .await?; 159 + 160 + Ok(response.status().is_success()) 161 + } 162 + 163 + /// Resolve a DID to its DID document. 164 + /// 165 + /// Sends a GET request to `/resolve/:did`. 166 + /// 167 + /// # Arguments 168 + /// 169 + /// * `did` - The DID to resolve 170 + /// 171 + /// # Returns 172 + /// 173 + /// The DID document for the identity. 174 + pub async fn resolve(&self, did: &str) -> Result<Document, TapError> { 175 + let url = format!("{}/resolve/{}", self.base_url, did); 176 + 177 + let response = self 178 + .http_client 179 + .get(&url) 180 + .headers(self.default_headers()) 181 + .send() 182 + .await?; 183 + 184 + if response.status().is_success() { 185 + let doc: Document = response.json().await?; 186 + Ok(doc) 187 + } else { 188 + let status = response.status().as_u16(); 189 + let message = response.text().await.unwrap_or_default(); 190 + Err(TapError::HttpResponseError { status, message }) 191 + } 192 + } 193 + 194 + /// Get info about a tracked repository. 195 + /// 196 + /// Sends a GET request to `/info/:did`. 197 + /// 198 + /// # Arguments 199 + /// 200 + /// * `did` - The DID to get info for 201 + /// 202 + /// # Returns 203 + /// 204 + /// Repository tracking information. 205 + pub async fn info(&self, did: &str) -> Result<RepoInfo, TapError> { 206 + let url = format!("{}/info/{}", self.base_url, did); 207 + 208 + let response = self 209 + .http_client 210 + .get(&url) 211 + .headers(self.default_headers()) 212 + .send() 213 + .await?; 214 + 215 + if response.status().is_success() { 216 + let info: RepoInfo = response.json().await?; 217 + Ok(info) 218 + } else { 219 + let status = response.status().as_u16(); 220 + let message = response.text().await.unwrap_or_default(); 221 + Err(TapError::HttpResponseError { status, message }) 222 + } 223 + } 224 + } 225 + 226 + /// Request body for adding/removing repositories. 227 + #[derive(Debug, Serialize)] 228 + struct AddReposRequest { 229 + dids: Vec<String>, 230 + } 231 + 232 + /// Repository tracking information. 233 + #[derive(Debug, Clone, Serialize, Deserialize)] 234 + pub struct RepoInfo { 235 + /// The repository DID. 236 + pub did: Box<str>, 237 + /// Current sync state. 238 + pub state: RepoState, 239 + /// The handle for the repository. 240 + #[serde(default)] 241 + pub handle: Option<Box<str>>, 242 + /// Number of records in the repository. 243 + #[serde(default)] 244 + pub records: u64, 245 + /// Current repository revision. 246 + #[serde(default)] 247 + pub rev: Option<Box<str>>, 248 + /// Number of retries for syncing. 249 + #[serde(default)] 250 + pub retries: u32, 251 + /// Error message if any. 252 + #[serde(default)] 253 + pub error: Option<Box<str>>, 254 + /// Additional fields may be present depending on TAP version. 255 + #[serde(flatten)] 256 + pub extra: serde_json::Value, 257 + } 258 + 259 + /// Repository sync state. 260 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 261 + #[serde(rename_all = "lowercase")] 262 + pub enum RepoState { 263 + /// Repository is active and synced. 264 + Active, 265 + /// Repository is currently syncing. 266 + Syncing, 267 + /// Repository is fully synced. 268 + Synced, 269 + /// Sync failed for this repository. 270 + Failed, 271 + /// Repository is queued for sync. 272 + Queued, 273 + /// Unknown state. 274 + #[serde(other)] 275 + Unknown, 276 + } 277 + 278 + /// Deprecated alias for RepoState. 279 + #[deprecated(since = "0.13.0", note = "Use RepoState instead")] 280 + pub type RepoStatus = RepoState; 281 + 282 + impl std::fmt::Display for RepoState { 283 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 284 + match self { 285 + RepoState::Active => write!(f, "active"), 286 + RepoState::Syncing => write!(f, "syncing"), 287 + RepoState::Synced => write!(f, "synced"), 288 + RepoState::Failed => write!(f, "failed"), 289 + RepoState::Queued => write!(f, "queued"), 290 + RepoState::Unknown => write!(f, "unknown"), 291 + } 292 + } 293 + } 294 + 295 + #[cfg(test)] 296 + mod tests { 297 + use super::*; 298 + 299 + #[test] 300 + fn test_client_creation() { 301 + let client = TapClient::new("localhost:2480", None); 302 + assert_eq!(client.base_url, "http://localhost:2480"); 303 + assert!(client.auth_header.is_none()); 304 + 305 + let client = TapClient::new("localhost:2480", Some("secret".to_string())); 306 + assert!(client.auth_header.is_some()); 307 + } 308 + 309 + #[test] 310 + fn test_repo_state_display() { 311 + assert_eq!(RepoState::Active.to_string(), "active"); 312 + assert_eq!(RepoState::Syncing.to_string(), "syncing"); 313 + assert_eq!(RepoState::Synced.to_string(), "synced"); 314 + assert_eq!(RepoState::Failed.to_string(), "failed"); 315 + assert_eq!(RepoState::Queued.to_string(), "queued"); 316 + assert_eq!(RepoState::Unknown.to_string(), "unknown"); 317 + } 318 + 319 + #[test] 320 + fn test_repo_state_deserialize() { 321 + let json = r#""active""#; 322 + let state: RepoState = serde_json::from_str(json).unwrap(); 323 + assert_eq!(state, RepoState::Active); 324 + 325 + let json = r#""syncing""#; 326 + let state: RepoState = serde_json::from_str(json).unwrap(); 327 + assert_eq!(state, RepoState::Syncing); 328 + 329 + let json = r#""some_new_state""#; 330 + let state: RepoState = serde_json::from_str(json).unwrap(); 331 + assert_eq!(state, RepoState::Unknown); 332 + } 333 + 334 + #[test] 335 + fn test_repo_info_deserialize() { 336 + let json = r#"{"did":"did:plc:cbkjy5n7bk3ax2wplmtjofq2","error":"","handle":"ngerakines.me","records":21382,"retries":0,"rev":"3mam4aazabs2m","state":"active"}"#; 337 + let info: RepoInfo = serde_json::from_str(json).unwrap(); 338 + assert_eq!(&*info.did, "did:plc:cbkjy5n7bk3ax2wplmtjofq2"); 339 + assert_eq!(info.state, RepoState::Active); 340 + assert_eq!(info.handle.as_deref(), Some("ngerakines.me")); 341 + assert_eq!(info.records, 21382); 342 + assert_eq!(info.retries, 0); 343 + assert_eq!(info.rev.as_deref(), Some("3mam4aazabs2m")); 344 + // Empty string deserializes as Some("") 345 + assert_eq!(info.error.as_deref(), Some("")); 346 + } 347 + 348 + #[test] 349 + fn test_repo_info_deserialize_minimal() { 350 + // Test with only required fields 351 + let json = r#"{"did":"did:plc:test","state":"syncing"}"#; 352 + let info: RepoInfo = serde_json::from_str(json).unwrap(); 353 + assert_eq!(&*info.did, "did:plc:test"); 354 + assert_eq!(info.state, RepoState::Syncing); 355 + assert_eq!(info.handle, None); 356 + assert_eq!(info.records, 0); 357 + assert_eq!(info.retries, 0); 358 + assert_eq!(info.rev, None); 359 + assert_eq!(info.error, None); 360 + } 361 + 362 + #[test] 363 + fn test_add_repos_request_serialize() { 364 + let req = AddReposRequest { 365 + dids: vec!["did:plc:xyz".to_string(), "did:plc:abc".to_string()], 366 + }; 367 + let json = serde_json::to_string(&req).unwrap(); 368 + assert!(json.contains("dids")); 369 + assert!(json.contains("did:plc:xyz")); 370 + } 371 + }
+220
crates/atproto-tap/src/config.rs
···
··· 1 + //! Configuration for TAP stream connections. 2 + //! 3 + //! This module provides the [`TapConfig`] struct for configuring TAP stream 4 + //! connections, including hostname, authentication, and reconnection behavior. 5 + 6 + use std::time::Duration; 7 + 8 + /// Configuration for a TAP stream connection. 9 + /// 10 + /// Use [`TapConfig::builder()`] for ergonomic construction with defaults. 11 + /// 12 + /// # Example 13 + /// 14 + /// ``` 15 + /// use atproto_tap::TapConfig; 16 + /// use std::time::Duration; 17 + /// 18 + /// let config = TapConfig::builder() 19 + /// .hostname("localhost:2480") 20 + /// .admin_password("secret") 21 + /// .send_acks(true) 22 + /// .max_reconnect_attempts(Some(10)) 23 + /// .build(); 24 + /// ``` 25 + #[derive(Debug, Clone)] 26 + pub struct TapConfig { 27 + /// TAP service hostname (e.g., "localhost:2480"). 28 + /// 29 + /// The WebSocket URL is constructed as `ws://{hostname}/channel`. 30 + pub hostname: String, 31 + 32 + /// Optional admin password for authentication. 33 + /// 34 + /// If set, HTTP Basic Auth is used with username "admin". 35 + pub admin_password: Option<String>, 36 + 37 + /// Whether to send acknowledgments for received messages. 38 + /// 39 + /// Default: `true`. Set to `false` if the TAP service has acks disabled. 40 + pub send_acks: bool, 41 + 42 + /// User-Agent header value for WebSocket connections. 43 + pub user_agent: String, 44 + 45 + /// Maximum reconnection attempts before giving up. 46 + /// 47 + /// `None` means unlimited reconnection attempts (default). 48 + pub max_reconnect_attempts: Option<u32>, 49 + 50 + /// Initial delay before first reconnection attempt. 51 + /// 52 + /// Default: 1 second. 53 + pub initial_reconnect_delay: Duration, 54 + 55 + /// Maximum delay between reconnection attempts. 56 + /// 57 + /// Default: 60 seconds. 58 + pub max_reconnect_delay: Duration, 59 + 60 + /// Multiplier for exponential backoff between reconnections. 61 + /// 62 + /// Default: 2.0 (doubles the delay each attempt). 63 + pub reconnect_backoff_multiplier: f64, 64 + } 65 + 66 + impl Default for TapConfig { 67 + fn default() -> Self { 68 + Self { 69 + hostname: "localhost:2480".to_string(), 70 + admin_password: None, 71 + send_acks: true, 72 + user_agent: format!("atproto-tap/{}", env!("CARGO_PKG_VERSION")), 73 + max_reconnect_attempts: None, 74 + initial_reconnect_delay: Duration::from_secs(1), 75 + max_reconnect_delay: Duration::from_secs(60), 76 + reconnect_backoff_multiplier: 2.0, 77 + } 78 + } 79 + } 80 + 81 + impl TapConfig { 82 + /// Create a new configuration builder with defaults. 83 + pub fn builder() -> TapConfigBuilder { 84 + TapConfigBuilder::default() 85 + } 86 + 87 + /// Create a minimal configuration for the given hostname. 88 + pub fn new(hostname: impl Into<String>) -> Self { 89 + Self { 90 + hostname: hostname.into(), 91 + ..Default::default() 92 + } 93 + } 94 + 95 + /// Returns the WebSocket URL for the TAP channel. 96 + pub fn ws_url(&self) -> String { 97 + format!("ws://{}/channel", self.hostname) 98 + } 99 + 100 + /// Returns the HTTP base URL for the TAP management API. 101 + pub fn http_base_url(&self) -> String { 102 + format!("http://{}", self.hostname) 103 + } 104 + } 105 + 106 + /// Builder for [`TapConfig`]. 107 + #[derive(Debug, Clone, Default)] 108 + pub struct TapConfigBuilder { 109 + config: TapConfig, 110 + } 111 + 112 + impl TapConfigBuilder { 113 + /// Set the TAP service hostname. 114 + pub fn hostname(mut self, hostname: impl Into<String>) -> Self { 115 + self.config.hostname = hostname.into(); 116 + self 117 + } 118 + 119 + /// Set the admin password for authentication. 120 + pub fn admin_password(mut self, password: impl Into<String>) -> Self { 121 + self.config.admin_password = Some(password.into()); 122 + self 123 + } 124 + 125 + /// Set whether to send acknowledgments. 126 + pub fn send_acks(mut self, send_acks: bool) -> Self { 127 + self.config.send_acks = send_acks; 128 + self 129 + } 130 + 131 + /// Set the User-Agent header value. 132 + pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self { 133 + self.config.user_agent = user_agent.into(); 134 + self 135 + } 136 + 137 + /// Set the maximum reconnection attempts. 138 + /// 139 + /// `None` means unlimited attempts. 140 + pub fn max_reconnect_attempts(mut self, max: Option<u32>) -> Self { 141 + self.config.max_reconnect_attempts = max; 142 + self 143 + } 144 + 145 + /// Set the initial reconnection delay. 146 + pub fn initial_reconnect_delay(mut self, delay: Duration) -> Self { 147 + self.config.initial_reconnect_delay = delay; 148 + self 149 + } 150 + 151 + /// Set the maximum reconnection delay. 152 + pub fn max_reconnect_delay(mut self, delay: Duration) -> Self { 153 + self.config.max_reconnect_delay = delay; 154 + self 155 + } 156 + 157 + /// Set the reconnection backoff multiplier. 158 + pub fn reconnect_backoff_multiplier(mut self, multiplier: f64) -> Self { 159 + self.config.reconnect_backoff_multiplier = multiplier; 160 + self 161 + } 162 + 163 + /// Build the configuration. 164 + pub fn build(self) -> TapConfig { 165 + self.config 166 + } 167 + } 168 + 169 + #[cfg(test)] 170 + mod tests { 171 + use super::*; 172 + 173 + #[test] 174 + fn test_default_config() { 175 + let config = TapConfig::default(); 176 + assert_eq!(config.hostname, "localhost:2480"); 177 + assert!(config.admin_password.is_none()); 178 + assert!(config.send_acks); 179 + assert!(config.max_reconnect_attempts.is_none()); 180 + assert_eq!(config.initial_reconnect_delay, Duration::from_secs(1)); 181 + assert_eq!(config.max_reconnect_delay, Duration::from_secs(60)); 182 + assert!((config.reconnect_backoff_multiplier - 2.0).abs() < f64::EPSILON); 183 + } 184 + 185 + #[test] 186 + fn test_builder() { 187 + let config = TapConfig::builder() 188 + .hostname("tap.example.com:2480") 189 + .admin_password("secret123") 190 + .send_acks(false) 191 + .max_reconnect_attempts(Some(5)) 192 + .initial_reconnect_delay(Duration::from_millis(500)) 193 + .max_reconnect_delay(Duration::from_secs(30)) 194 + .reconnect_backoff_multiplier(1.5) 195 + .build(); 196 + 197 + assert_eq!(config.hostname, "tap.example.com:2480"); 198 + assert_eq!(config.admin_password, Some("secret123".to_string())); 199 + assert!(!config.send_acks); 200 + assert_eq!(config.max_reconnect_attempts, Some(5)); 201 + assert_eq!(config.initial_reconnect_delay, Duration::from_millis(500)); 202 + assert_eq!(config.max_reconnect_delay, Duration::from_secs(30)); 203 + assert!((config.reconnect_backoff_multiplier - 1.5).abs() < f64::EPSILON); 204 + } 205 + 206 + #[test] 207 + fn test_ws_url() { 208 + let config = TapConfig::new("localhost:2480"); 209 + assert_eq!(config.ws_url(), "ws://localhost:2480/channel"); 210 + 211 + let config = TapConfig::new("tap.example.com:8080"); 212 + assert_eq!(config.ws_url(), "ws://tap.example.com:8080/channel"); 213 + } 214 + 215 + #[test] 216 + fn test_http_base_url() { 217 + let config = TapConfig::new("localhost:2480"); 218 + assert_eq!(config.http_base_url(), "http://localhost:2480"); 219 + } 220 + }
+168
crates/atproto-tap/src/connection.rs
···
··· 1 + //! WebSocket connection management for TAP streams. 2 + //! 3 + //! This module handles the low-level WebSocket connection to a TAP service, 4 + //! including authentication and message sending/receiving. 5 + 6 + use crate::config::TapConfig; 7 + use crate::errors::TapError; 8 + use base64::Engine; 9 + use base64::engine::general_purpose::STANDARD as BASE64; 10 + use futures::{SinkExt, StreamExt}; 11 + use http::Uri; 12 + use std::str::FromStr; 13 + use tokio_websockets::{ClientBuilder, Message, WebSocketStream}; 14 + use tokio_websockets::MaybeTlsStream; 15 + use tokio::net::TcpStream; 16 + 17 + /// WebSocket connection to a TAP service. 18 + pub(crate) struct TapConnection { 19 + /// The underlying WebSocket stream. 20 + ws: WebSocketStream<MaybeTlsStream<TcpStream>>, 21 + /// Pre-allocated buffer for acknowledgment messages. 22 + ack_buffer: Vec<u8>, 23 + } 24 + 25 + impl TapConnection { 26 + /// Establish a new WebSocket connection to the TAP service. 27 + pub async fn connect(config: &TapConfig) -> Result<Self, TapError> { 28 + let uri = Uri::from_str(&config.ws_url()) 29 + .map_err(|e| TapError::InvalidUrl(e.to_string()))?; 30 + 31 + let mut builder = ClientBuilder::from_uri(uri); 32 + 33 + // Add User-Agent header 34 + builder = builder 35 + .add_header( 36 + http::header::USER_AGENT, 37 + http::HeaderValue::from_str(&config.user_agent) 38 + .map_err(|e| TapError::ConnectionFailed(format!("Invalid user agent: {}", e)))?, 39 + ) 40 + .map_err(|e| TapError::ConnectionFailed(format!("Failed to add header: {}", e)))?; 41 + 42 + // Add Basic Auth header if password is configured 43 + if let Some(password) = &config.admin_password { 44 + let credentials = format!("admin:{}", password); 45 + let encoded = BASE64.encode(credentials.as_bytes()); 46 + let auth_value = format!("Basic {}", encoded); 47 + 48 + builder = builder 49 + .add_header( 50 + http::header::AUTHORIZATION, 51 + http::HeaderValue::from_str(&auth_value) 52 + .map_err(|e| TapError::ConnectionFailed(format!("Invalid auth header: {}", e)))?, 53 + ) 54 + .map_err(|e| TapError::ConnectionFailed(format!("Failed to add auth header: {}", e)))?; 55 + } 56 + 57 + // Connect 58 + let (ws, _response) = builder 59 + .connect() 60 + .await 61 + .map_err(|e| TapError::ConnectionFailed(e.to_string()))?; 62 + 63 + tracing::debug!(hostname = %config.hostname, "Connected to TAP service"); 64 + 65 + Ok(Self { 66 + ws, 67 + ack_buffer: Vec::with_capacity(48), // {"type":"ack","id":18446744073709551615} is 40 bytes max 68 + }) 69 + } 70 + 71 + /// Receive the next message from the WebSocket. 72 + /// 73 + /// Returns `None` if the connection was closed cleanly. 74 + pub async fn recv(&mut self) -> Result<Option<String>, TapError> { 75 + match self.ws.next().await { 76 + Some(Ok(msg)) => { 77 + if msg.is_text() { 78 + msg.as_text() 79 + .map(|s| Some(s.to_string())) 80 + .ok_or_else(|| TapError::ParseError("Failed to get text from message".into())) 81 + } else if msg.is_close() { 82 + tracing::debug!("Received close frame from TAP service"); 83 + Ok(None) 84 + } else { 85 + // Ignore ping/pong and binary messages 86 + tracing::trace!("Received non-text message, ignoring"); 87 + // Recurse to get the next text message 88 + Box::pin(self.recv()).await 89 + } 90 + } 91 + Some(Err(e)) => Err(TapError::ConnectionFailed(e.to_string())), 92 + None => { 93 + tracing::debug!("WebSocket stream ended"); 94 + Ok(None) 95 + } 96 + } 97 + } 98 + 99 + /// Send an acknowledgment for the given event ID. 100 + /// 101 + /// Uses a pre-allocated buffer and itoa for allocation-free formatting. 102 + /// Format: `{"type":"ack","id":12345}` 103 + pub async fn send_ack(&mut self, id: u64) -> Result<(), TapError> { 104 + self.ack_buffer.clear(); 105 + self.ack_buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":"); 106 + let mut itoa_buf = itoa::Buffer::new(); 107 + self.ack_buffer.extend_from_slice(itoa_buf.format(id).as_bytes()); 108 + self.ack_buffer.push(b'}'); 109 + 110 + // All bytes are ASCII so this is always valid UTF-8 111 + let msg = std::str::from_utf8(&self.ack_buffer) 112 + .expect("ack buffer contains only ASCII"); 113 + 114 + self.ws 115 + .send(Message::text(msg.to_string())) 116 + .await 117 + .map_err(|e| TapError::AckFailed(e.to_string()))?; 118 + 119 + // Flush to ensure the ack is sent immediately 120 + self.ws 121 + .flush() 122 + .await 123 + .map_err(|e| TapError::AckFailed(format!("Failed to flush ack: {}", e)))?; 124 + 125 + tracing::trace!(id, "Sent ack"); 126 + Ok(()) 127 + } 128 + 129 + /// Close the WebSocket connection gracefully. 130 + pub async fn close(&mut self) -> Result<(), TapError> { 131 + self.ws 132 + .close() 133 + .await 134 + .map_err(|e| TapError::ConnectionFailed(format!("Failed to close: {}", e)))?; 135 + Ok(()) 136 + } 137 + } 138 + 139 + #[cfg(test)] 140 + mod tests { 141 + #[test] 142 + fn test_ack_buffer_format() { 143 + // Test that our manual JSON formatting is correct 144 + // Format: {"type":"ack","id":12345} 145 + let mut buffer = Vec::with_capacity(64); 146 + 147 + let id: u64 = 12345; 148 + buffer.clear(); 149 + buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":"); 150 + let mut itoa_buf = itoa::Buffer::new(); 151 + buffer.extend_from_slice(itoa_buf.format(id).as_bytes()); 152 + buffer.push(b'}'); 153 + 154 + let result = std::str::from_utf8(&buffer).unwrap(); 155 + assert_eq!(result, r#"{"type":"ack","id":12345}"#); 156 + 157 + // Test max u64 158 + let id: u64 = u64::MAX; 159 + buffer.clear(); 160 + buffer.extend_from_slice(b"{\"type\":\"ack\",\"id\":"); 161 + buffer.extend_from_slice(itoa_buf.format(id).as_bytes()); 162 + buffer.push(b'}'); 163 + 164 + let result = std::str::from_utf8(&buffer).unwrap(); 165 + assert_eq!(result, r#"{"type":"ack","id":18446744073709551615}"#); 166 + assert!(buffer.len() <= 64); // Fits in our pre-allocated buffer 167 + } 168 + }
+143
crates/atproto-tap/src/errors.rs
···
··· 1 + //! Error types for TAP operations. 2 + //! 3 + //! This module defines the error types returned by TAP stream and client operations. 4 + 5 + use thiserror::Error; 6 + 7 + /// Errors that can occur during TAP operations. 8 + #[derive(Debug, Error)] 9 + pub enum TapError { 10 + /// WebSocket connection failed. 11 + #[error("error-atproto-tap-connection-1 WebSocket connection failed: {0}")] 12 + ConnectionFailed(String), 13 + 14 + /// Connection was closed unexpectedly. 15 + #[error("error-atproto-tap-connection-2 Connection closed unexpectedly")] 16 + ConnectionClosed, 17 + 18 + /// Maximum reconnection attempts exceeded. 19 + #[error("error-atproto-tap-connection-3 Maximum reconnection attempts exceeded after {0} attempts")] 20 + MaxReconnectAttemptsExceeded(u32), 21 + 22 + /// Authentication failed. 23 + #[error("error-atproto-tap-auth-1 Authentication failed: {0}")] 24 + AuthenticationFailed(String), 25 + 26 + /// Failed to parse a message from the server. 27 + #[error("error-atproto-tap-parse-1 Failed to parse message: {0}")] 28 + ParseError(String), 29 + 30 + /// Failed to send an acknowledgment. 31 + #[error("error-atproto-tap-ack-1 Failed to send acknowledgment: {0}")] 32 + AckFailed(String), 33 + 34 + /// HTTP request failed. 35 + #[error("error-atproto-tap-http-1 HTTP request failed: {0}")] 36 + HttpError(String), 37 + 38 + /// HTTP response indicated an error. 39 + #[error("error-atproto-tap-http-2 HTTP error response: {status} - {message}")] 40 + HttpResponseError { 41 + /// HTTP status code. 42 + status: u16, 43 + /// Error message from response. 44 + message: String, 45 + }, 46 + 47 + /// Invalid URL. 48 + #[error("error-atproto-tap-url-1 Invalid URL: {0}")] 49 + InvalidUrl(String), 50 + 51 + /// I/O error. 52 + #[error("error-atproto-tap-io-1 I/O error: {0}")] 53 + IoError(#[from] std::io::Error), 54 + 55 + /// JSON serialization/deserialization error. 56 + #[error("error-atproto-tap-json-1 JSON error: {0}")] 57 + JsonError(#[from] serde_json::Error), 58 + 59 + /// Stream has been closed and cannot be used. 60 + #[error("error-atproto-tap-stream-1 Stream is closed")] 61 + StreamClosed, 62 + 63 + /// Operation timed out. 64 + #[error("error-atproto-tap-timeout-1 Operation timed out")] 65 + Timeout, 66 + } 67 + 68 + impl TapError { 69 + /// Returns true if this error indicates a connection issue that may be recoverable. 70 + pub fn is_connection_error(&self) -> bool { 71 + matches!( 72 + self, 73 + TapError::ConnectionFailed(_) 74 + | TapError::ConnectionClosed 75 + | TapError::IoError(_) 76 + | TapError::Timeout 77 + ) 78 + } 79 + 80 + /// Returns true if this error is a parse error that doesn't affect connection state. 81 + pub fn is_parse_error(&self) -> bool { 82 + matches!(self, TapError::ParseError(_) | TapError::JsonError(_)) 83 + } 84 + 85 + /// Returns true if this error is fatal and the stream should not attempt recovery. 86 + pub fn is_fatal(&self) -> bool { 87 + matches!( 88 + self, 89 + TapError::MaxReconnectAttemptsExceeded(_) 90 + | TapError::AuthenticationFailed(_) 91 + | TapError::StreamClosed 92 + ) 93 + } 94 + } 95 + 96 + impl From<reqwest::Error> for TapError { 97 + fn from(err: reqwest::Error) -> Self { 98 + if err.is_timeout() { 99 + TapError::Timeout 100 + } else if err.is_connect() { 101 + TapError::ConnectionFailed(err.to_string()) 102 + } else { 103 + TapError::HttpError(err.to_string()) 104 + } 105 + } 106 + } 107 + 108 + #[cfg(test)] 109 + mod tests { 110 + use super::*; 111 + 112 + #[test] 113 + fn test_error_classification() { 114 + assert!(TapError::ConnectionFailed("test".into()).is_connection_error()); 115 + assert!(TapError::ConnectionClosed.is_connection_error()); 116 + assert!(TapError::Timeout.is_connection_error()); 117 + 118 + assert!(TapError::ParseError("test".into()).is_parse_error()); 119 + assert!(TapError::JsonError(serde_json::from_str::<()>("invalid").unwrap_err()).is_parse_error()); 120 + 121 + assert!(TapError::MaxReconnectAttemptsExceeded(5).is_fatal()); 122 + assert!(TapError::AuthenticationFailed("test".into()).is_fatal()); 123 + assert!(TapError::StreamClosed.is_fatal()); 124 + 125 + // Non-fatal errors 126 + assert!(!TapError::ConnectionFailed("test".into()).is_fatal()); 127 + assert!(!TapError::ParseError("test".into()).is_fatal()); 128 + } 129 + 130 + #[test] 131 + fn test_error_display() { 132 + let err = TapError::ConnectionFailed("refused".to_string()); 133 + assert!(err.to_string().contains("error-atproto-tap-connection-1")); 134 + assert!(err.to_string().contains("refused")); 135 + 136 + let err = TapError::HttpResponseError { 137 + status: 404, 138 + message: "Not Found".to_string(), 139 + }; 140 + assert!(err.to_string().contains("404")); 141 + assert!(err.to_string().contains("Not Found")); 142 + } 143 + }
+488
crates/atproto-tap/src/events.rs
···
··· 1 + //! TAP event types for AT Protocol record and identity events. 2 + //! 3 + //! This module defines the event structures received from a TAP service. 4 + //! Events are optimized for memory efficiency using: 5 + //! - `CompactString` for small strings (SSO for โ‰ค24 bytes) 6 + //! - `Box<str>` for immutable strings (no capacity overhead) 7 + //! - `serde_json::Value` for record payloads (allows lazy access) 8 + 9 + use compact_str::CompactString; 10 + use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor}; 11 + use serde::{Deserialize, Serialize, de::DeserializeOwned}; 12 + use std::fmt; 13 + 14 + /// A TAP event received from the stream. 15 + /// 16 + /// TAP delivers two types of events: 17 + /// - `Record`: Repository record changes (create, update, delete) 18 + /// - `Identity`: Identity/handle changes for accounts 19 + #[derive(Debug, Clone, Serialize, Deserialize)] 20 + #[serde(tag = "type", rename_all = "lowercase")] 21 + pub enum TapEvent { 22 + /// A repository record event (create, update, or delete). 23 + Record { 24 + /// Sequential event identifier. 25 + id: u64, 26 + /// The record event data. 27 + record: RecordEvent, 28 + }, 29 + /// An identity change event. 30 + Identity { 31 + /// Sequential event identifier. 32 + id: u64, 33 + /// The identity event data. 34 + identity: IdentityEvent, 35 + }, 36 + } 37 + 38 + impl TapEvent { 39 + /// Returns the event ID. 40 + pub fn id(&self) -> u64 { 41 + match self { 42 + TapEvent::Record { id, .. } => *id, 43 + TapEvent::Identity { id, .. } => *id, 44 + } 45 + } 46 + } 47 + 48 + /// Extract only the event ID from a JSON string without fully parsing it. 49 + /// 50 + /// This is a fallback parser used when full `TapEvent` parsing fails (e.g., due to 51 + /// deeply nested records hitting serde_json's recursion limit). It uses `IgnoredAny` 52 + /// to efficiently skip over nested content without building data structures, allowing 53 + /// us to extract the ID for acknowledgment even when full parsing fails. 54 + /// 55 + /// # Example 56 + /// 57 + /// ``` 58 + /// use atproto_tap::extract_event_id; 59 + /// 60 + /// let json = r#"{"type":"record","id":12345,"record":{"deeply":"nested"}}"#; 61 + /// assert_eq!(extract_event_id(json), Some(12345)); 62 + /// ``` 63 + pub fn extract_event_id(json: &str) -> Option<u64> { 64 + let mut deserializer = serde_json::Deserializer::from_str(json); 65 + deserializer.disable_recursion_limit(); 66 + EventIdOnly::deserialize(&mut deserializer).ok().map(|e| e.id) 67 + } 68 + 69 + /// Internal struct for extracting only the "id" field from a TAP event. 70 + #[derive(Debug)] 71 + struct EventIdOnly { 72 + id: u64, 73 + } 74 + 75 + impl<'de> Deserialize<'de> for EventIdOnly { 76 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 77 + where 78 + D: Deserializer<'de>, 79 + { 80 + deserializer.deserialize_map(EventIdOnlyVisitor) 81 + } 82 + } 83 + 84 + struct EventIdOnlyVisitor; 85 + 86 + impl<'de> Visitor<'de> for EventIdOnlyVisitor { 87 + type Value = EventIdOnly; 88 + 89 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 90 + formatter.write_str("a map with an 'id' field") 91 + } 92 + 93 + fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error> 94 + where 95 + M: MapAccess<'de>, 96 + { 97 + let mut id: Option<u64> = None; 98 + 99 + while let Some(key) = map.next_key::<&str>()? { 100 + if key == "id" { 101 + id = Some(map.next_value()?); 102 + // Found what we need - skip the rest efficiently using IgnoredAny 103 + // which handles deeply nested structures without recursion issues 104 + while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {} 105 + break; 106 + } else { 107 + // Skip this value without fully parsing it 108 + map.next_value::<IgnoredAny>()?; 109 + } 110 + } 111 + 112 + id.map(|id| EventIdOnly { id }) 113 + .ok_or_else(|| de::Error::missing_field("id")) 114 + } 115 + } 116 + 117 + /// A repository record event from TAP. 118 + /// 119 + /// Contains information about a record change in a user's repository, 120 + /// including the action taken and the record data (for creates/updates). 121 + #[derive(Debug, Clone, Serialize, Deserialize)] 122 + pub struct RecordEvent { 123 + /// True if from live firehose, false if from backfill/resync. 124 + /// 125 + /// During initial sync or recovery, TAP delivers historical events 126 + /// with `live: false`. Once caught up, live events have `live: true`. 127 + pub live: bool, 128 + 129 + /// Repository revision identifier. 130 + /// 131 + /// Typically 13 characters, stored inline via CompactString SSO. 132 + pub rev: CompactString, 133 + 134 + /// Actor DID (e.g., "did:plc:xyz123"). 135 + pub did: Box<str>, 136 + 137 + /// Collection NSID (e.g., "app.bsky.feed.post"). 138 + pub collection: Box<str>, 139 + 140 + /// Record key within the collection. 141 + /// 142 + /// Typically a TID (13 characters), stored inline via CompactString SSO. 143 + pub rkey: CompactString, 144 + 145 + /// The action performed on the record. 146 + pub action: RecordAction, 147 + 148 + /// Content identifier (CID) of the record. 149 + /// 150 + /// Present for create and update actions, absent for delete. 151 + #[serde(skip_serializing_if = "Option::is_none")] 152 + pub cid: Option<CompactString>, 153 + 154 + /// Record data as JSON value. 155 + /// 156 + /// Present for create and update actions, absent for delete. 157 + /// Use [`parse_record`](Self::parse_record) to deserialize on demand. 158 + #[serde(skip_serializing_if = "Option::is_none")] 159 + pub record: Option<serde_json::Value>, 160 + } 161 + 162 + impl RecordEvent { 163 + /// Parse the record payload into a typed structure. 164 + /// 165 + /// This method deserializes the raw JSON on demand, avoiding 166 + /// unnecessary allocations when the record data isn't needed. 167 + /// 168 + /// # Errors 169 + /// 170 + /// Returns an error if the record is absent (delete events) or 171 + /// if deserialization fails. 172 + /// 173 + /// # Example 174 + /// 175 + /// ```ignore 176 + /// use serde::Deserialize; 177 + /// 178 + /// #[derive(Deserialize)] 179 + /// struct Post { 180 + /// text: String, 181 + /// #[serde(rename = "createdAt")] 182 + /// created_at: String, 183 + /// } 184 + /// 185 + /// let post: Post = record_event.parse_record()?; 186 + /// println!("Post text: {}", post.text); 187 + /// ``` 188 + pub fn parse_record<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> { 189 + match &self.record { 190 + Some(value) => serde_json::from_value(value.clone()), 191 + None => Err(serde::de::Error::custom("no record data (delete event)")), 192 + } 193 + } 194 + 195 + /// Returns the record as a JSON Value reference, if present. 196 + pub fn record_value(&self) -> Option<&serde_json::Value> { 197 + self.record.as_ref() 198 + } 199 + 200 + /// Returns true if this is a delete event. 201 + pub fn is_delete(&self) -> bool { 202 + self.action == RecordAction::Delete 203 + } 204 + 205 + /// Returns the AT-URI for this record. 206 + /// 207 + /// Format: `at://{did}/{collection}/{rkey}` 208 + pub fn at_uri(&self) -> String { 209 + format!("at://{}/{}/{}", self.did, self.collection, self.rkey) 210 + } 211 + } 212 + 213 + /// The action performed on a record. 214 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 215 + #[serde(rename_all = "lowercase")] 216 + pub enum RecordAction { 217 + /// A new record was created. 218 + Create, 219 + /// An existing record was updated. 220 + Update, 221 + /// A record was deleted. 222 + Delete, 223 + } 224 + 225 + impl std::fmt::Display for RecordAction { 226 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 227 + match self { 228 + RecordAction::Create => write!(f, "create"), 229 + RecordAction::Update => write!(f, "update"), 230 + RecordAction::Delete => write!(f, "delete"), 231 + } 232 + } 233 + } 234 + 235 + /// An identity change event from TAP. 236 + /// 237 + /// Contains information about handle or account status changes. 238 + #[derive(Debug, Clone, Serialize, Deserialize)] 239 + pub struct IdentityEvent { 240 + /// Actor DID. 241 + pub did: Box<str>, 242 + 243 + /// Current handle for the account. 244 + pub handle: Box<str>, 245 + 246 + /// Whether the account is currently active. 247 + #[serde(default)] 248 + pub is_active: bool, 249 + 250 + /// Account status. 251 + #[serde(default)] 252 + pub status: IdentityStatus, 253 + } 254 + 255 + /// Account status in an identity event. 256 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] 257 + #[serde(rename_all = "lowercase")] 258 + pub enum IdentityStatus { 259 + /// Account is active and in good standing. 260 + #[default] 261 + Active, 262 + /// Account has been deactivated by the user. 263 + Deactivated, 264 + /// Account has been suspended. 265 + Suspended, 266 + /// Account has been deleted. 267 + Deleted, 268 + /// Account has been taken down. 269 + Takendown, 270 + } 271 + 272 + impl std::fmt::Display for IdentityStatus { 273 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 274 + match self { 275 + IdentityStatus::Active => write!(f, "active"), 276 + IdentityStatus::Deactivated => write!(f, "deactivated"), 277 + IdentityStatus::Suspended => write!(f, "suspended"), 278 + IdentityStatus::Deleted => write!(f, "deleted"), 279 + IdentityStatus::Takendown => write!(f, "takendown"), 280 + } 281 + } 282 + } 283 + 284 + #[cfg(test)] 285 + mod tests { 286 + use super::*; 287 + 288 + #[test] 289 + fn test_parse_record_event() { 290 + let json = r#"{ 291 + "id": 12345, 292 + "type": "record", 293 + "record": { 294 + "live": true, 295 + "rev": "3lyileto4q52k", 296 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 297 + "collection": "app.bsky.feed.post", 298 + "rkey": "3lyiletddxt2c", 299 + "action": "create", 300 + "cid": "bafyreigroo6vhxt62ufcndhaxzas6btq4jmniuz4egszbwuqgiyisqwqoy", 301 + "record": {"$type": "app.bsky.feed.post", "text": "Hello world!", "createdAt": "2025-01-01T00:00:00Z"} 302 + } 303 + }"#; 304 + 305 + let event: TapEvent = serde_json::from_str(json).expect("Failed to parse"); 306 + 307 + match event { 308 + TapEvent::Record { id, record } => { 309 + assert_eq!(id, 12345); 310 + assert!(record.live); 311 + assert_eq!(record.rev.as_str(), "3lyileto4q52k"); 312 + assert_eq!(&*record.did, "did:plc:z72i7hdynmk6r22z27h6tvur"); 313 + assert_eq!(&*record.collection, "app.bsky.feed.post"); 314 + assert_eq!(record.rkey.as_str(), "3lyiletddxt2c"); 315 + assert_eq!(record.action, RecordAction::Create); 316 + assert!(record.cid.is_some()); 317 + assert!(record.record.is_some()); 318 + 319 + // Test lazy parsing 320 + #[derive(Deserialize)] 321 + struct Post { 322 + text: String, 323 + } 324 + let post: Post = record.parse_record().expect("Failed to parse record"); 325 + assert_eq!(post.text, "Hello world!"); 326 + } 327 + _ => panic!("Expected Record event"), 328 + } 329 + } 330 + 331 + #[test] 332 + fn test_parse_delete_event() { 333 + let json = r#"{ 334 + "id": 12346, 335 + "type": "record", 336 + "record": { 337 + "live": true, 338 + "rev": "3lyileto4q52k", 339 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 340 + "collection": "app.bsky.feed.post", 341 + "rkey": "3lyiletddxt2c", 342 + "action": "delete" 343 + } 344 + }"#; 345 + 346 + let event: TapEvent = serde_json::from_str(json).expect("Failed to parse"); 347 + 348 + match event { 349 + TapEvent::Record { id, record } => { 350 + assert_eq!(id, 12346); 351 + assert_eq!(record.action, RecordAction::Delete); 352 + assert!(record.is_delete()); 353 + assert!(record.cid.is_none()); 354 + assert!(record.record.is_none()); 355 + } 356 + _ => panic!("Expected Record event"), 357 + } 358 + } 359 + 360 + #[test] 361 + fn test_parse_identity_event() { 362 + let json = r#"{ 363 + "id": 12347, 364 + "type": "identity", 365 + "identity": { 366 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 367 + "handle": "user.bsky.social", 368 + "is_active": true, 369 + "status": "active" 370 + } 371 + }"#; 372 + 373 + let event: TapEvent = serde_json::from_str(json).expect("Failed to parse"); 374 + 375 + match event { 376 + TapEvent::Identity { id, identity } => { 377 + assert_eq!(id, 12347); 378 + assert_eq!(&*identity.did, "did:plc:z72i7hdynmk6r22z27h6tvur"); 379 + assert_eq!(&*identity.handle, "user.bsky.social"); 380 + assert!(identity.is_active); 381 + assert_eq!(identity.status, IdentityStatus::Active); 382 + } 383 + _ => panic!("Expected Identity event"), 384 + } 385 + } 386 + 387 + #[test] 388 + fn test_record_action_display() { 389 + assert_eq!(RecordAction::Create.to_string(), "create"); 390 + assert_eq!(RecordAction::Update.to_string(), "update"); 391 + assert_eq!(RecordAction::Delete.to_string(), "delete"); 392 + } 393 + 394 + #[test] 395 + fn test_identity_status_display() { 396 + assert_eq!(IdentityStatus::Active.to_string(), "active"); 397 + assert_eq!(IdentityStatus::Deactivated.to_string(), "deactivated"); 398 + assert_eq!(IdentityStatus::Suspended.to_string(), "suspended"); 399 + assert_eq!(IdentityStatus::Deleted.to_string(), "deleted"); 400 + assert_eq!(IdentityStatus::Takendown.to_string(), "takendown"); 401 + } 402 + 403 + #[test] 404 + fn test_at_uri() { 405 + let record = RecordEvent { 406 + live: true, 407 + rev: "3lyileto4q52k".into(), 408 + did: "did:plc:xyz".into(), 409 + collection: "app.bsky.feed.post".into(), 410 + rkey: "abc123".into(), 411 + action: RecordAction::Create, 412 + cid: None, 413 + record: None, 414 + }; 415 + 416 + assert_eq!(record.at_uri(), "at://did:plc:xyz/app.bsky.feed.post/abc123"); 417 + } 418 + 419 + #[test] 420 + fn test_event_id() { 421 + let record_event = TapEvent::Record { 422 + id: 100, 423 + record: RecordEvent { 424 + live: true, 425 + rev: "rev".into(), 426 + did: "did".into(), 427 + collection: "col".into(), 428 + rkey: "rkey".into(), 429 + action: RecordAction::Create, 430 + cid: None, 431 + record: None, 432 + }, 433 + }; 434 + assert_eq!(record_event.id(), 100); 435 + 436 + let identity_event = TapEvent::Identity { 437 + id: 200, 438 + identity: IdentityEvent { 439 + did: "did".into(), 440 + handle: "handle".into(), 441 + is_active: true, 442 + status: IdentityStatus::Active, 443 + }, 444 + }; 445 + assert_eq!(identity_event.id(), 200); 446 + } 447 + 448 + #[test] 449 + fn test_extract_event_id_simple() { 450 + let json = r#"{"type":"record","id":12345,"record":{"deeply":"nested"}}"#; 451 + assert_eq!(extract_event_id(json), Some(12345)); 452 + } 453 + 454 + #[test] 455 + fn test_extract_event_id_at_end() { 456 + let json = r#"{"type":"record","record":{"deeply":"nested"},"id":99999}"#; 457 + assert_eq!(extract_event_id(json), Some(99999)); 458 + } 459 + 460 + #[test] 461 + fn test_extract_event_id_missing() { 462 + let json = r#"{"type":"record","record":{"deeply":"nested"}}"#; 463 + assert_eq!(extract_event_id(json), None); 464 + } 465 + 466 + #[test] 467 + fn test_extract_event_id_invalid_json() { 468 + let json = r#"{"type":"record","id":123"#; // Truncated JSON 469 + assert_eq!(extract_event_id(json), None); 470 + } 471 + 472 + #[test] 473 + fn test_extract_event_id_deeply_nested() { 474 + // Create a deeply nested JSON that would exceed serde_json's default recursion limit 475 + let mut json = String::from(r#"{"id":42,"record":{"nested":"#); 476 + for _ in 0..200 { 477 + json.push_str("["); 478 + } 479 + json.push_str("1"); 480 + for _ in 0..200 { 481 + json.push_str("]"); 482 + } 483 + json.push_str("}}"); 484 + 485 + // extract_event_id should still work because it uses IgnoredAny with disabled recursion limit 486 + assert_eq!(extract_event_id(&json), Some(42)); 487 + } 488 + }
+119
crates/atproto-tap/src/lib.rs
···
··· 1 + //! TAP (Trusted Attestation Protocol) service consumer for AT Protocol. 2 + //! 3 + //! This crate provides a client for consuming events from a TAP service, 4 + //! which delivers filtered, verified AT Protocol repository events. 5 + //! 6 + //! # Overview 7 + //! 8 + //! TAP is a single-tenant service that subscribes to an AT Protocol Relay and 9 + //! outputs filtered, verified events. Key features include: 10 + //! 11 + //! - **Verified Events**: MST integrity checks and signature verification 12 + //! - **Automatic Backfill**: Historical events delivered with `live: false` 13 + //! - **Repository Filtering**: Track specific DIDs or collections 14 + //! - **Acknowledgment Protocol**: At-least-once delivery semantics 15 + //! 16 + //! # Quick Start 17 + //! 18 + //! ```ignore 19 + //! use atproto_tap::{connect_to, TapEvent}; 20 + //! use tokio_stream::StreamExt; 21 + //! 22 + //! #[tokio::main] 23 + //! async fn main() { 24 + //! let mut stream = connect_to("localhost:2480"); 25 + //! 26 + //! while let Some(result) = stream.next().await { 27 + //! match result { 28 + //! Ok(event) => match event.as_ref() { 29 + //! TapEvent::Record { record, .. } => { 30 + //! println!("{} {} {}", record.action, record.collection, record.did); 31 + //! } 32 + //! TapEvent::Identity { identity, .. } => { 33 + //! println!("Identity: {} = {}", identity.did, identity.handle); 34 + //! } 35 + //! }, 36 + //! Err(e) => eprintln!("Error: {}", e), 37 + //! } 38 + //! } 39 + //! } 40 + //! ``` 41 + //! 42 + //! # Using with `tokio::select!` 43 + //! 44 + //! The stream integrates naturally with Tokio's select macro: 45 + //! 46 + //! ```ignore 47 + //! use atproto_tap::{connect, TapConfig}; 48 + //! use tokio_stream::StreamExt; 49 + //! use tokio::signal; 50 + //! 51 + //! #[tokio::main] 52 + //! async fn main() { 53 + //! let config = TapConfig::builder() 54 + //! .hostname("localhost:2480") 55 + //! .admin_password("secret") 56 + //! .build(); 57 + //! 58 + //! let mut stream = connect(config); 59 + //! 60 + //! loop { 61 + //! tokio::select! { 62 + //! Some(result) = stream.next() => { 63 + //! // Process event 64 + //! } 65 + //! _ = signal::ctrl_c() => { 66 + //! break; 67 + //! } 68 + //! } 69 + //! } 70 + //! } 71 + //! ``` 72 + //! 73 + //! # Management API 74 + //! 75 + //! Use [`TapClient`] to manage tracked repositories: 76 + //! 77 + //! ```ignore 78 + //! use atproto_tap::TapClient; 79 + //! 80 + //! let client = TapClient::new("localhost:2480", Some("password".to_string())); 81 + //! 82 + //! // Add repositories to track 83 + //! client.add_repos(&["did:plc:xyz123"]).await?; 84 + //! 85 + //! // Check service health 86 + //! if client.health().await? { 87 + //! println!("TAP service is healthy"); 88 + //! } 89 + //! ``` 90 + //! 91 + //! # Memory Efficiency 92 + //! 93 + //! This crate is optimized for high-throughput event processing: 94 + //! 95 + //! - **Arc-wrapped events**: Events are shared via `Arc` for zero-cost sharing 96 + //! - **CompactString**: Small strings use inline storage (no heap allocation) 97 + //! - **Box<str>**: Immutable strings without capacity overhead 98 + //! - **RawValue**: Record payloads are lazily parsed on demand 99 + //! - **Pre-allocated buffers**: Ack messages avoid per-message allocations 100 + 101 + #![forbid(unsafe_code)] 102 + #![warn(missing_docs)] 103 + 104 + mod client; 105 + mod config; 106 + mod connection; 107 + mod errors; 108 + mod events; 109 + mod stream; 110 + 111 + // Re-export public types 112 + pub use atproto_identity::model::{Document, Service, VerificationMethod}; 113 + pub use client::{RepoInfo, RepoState, TapClient}; 114 + #[allow(deprecated)] 115 + pub use client::RepoStatus; 116 + pub use config::{TapConfig, TapConfigBuilder}; 117 + pub use errors::TapError; 118 + pub use events::{IdentityEvent, IdentityStatus, RecordAction, RecordEvent, TapEvent, extract_event_id}; 119 + pub use stream::{TapStream, connect, connect_to};
+330
crates/atproto-tap/src/stream.rs
···
··· 1 + //! TAP event stream implementation. 2 + //! 3 + //! This module provides [`TapStream`], an async stream that yields TAP events 4 + //! with automatic connection management and reconnection handling. 5 + //! 6 + //! # Design 7 + //! 8 + //! The stream encapsulates all connection logic, allowing consumers to simply 9 + //! iterate over events using standard stream combinators or `tokio::select!`. 10 + //! 11 + //! Reconnection is handled automatically with exponential backoff. Parse errors 12 + //! are yielded as `Err` items but don't affect connection state - only connection 13 + //! errors trigger reconnection attempts. 14 + 15 + use crate::config::TapConfig; 16 + use crate::connection::TapConnection; 17 + use crate::errors::TapError; 18 + use crate::events::{TapEvent, extract_event_id}; 19 + use futures::Stream; 20 + use std::pin::Pin; 21 + use std::sync::Arc; 22 + use std::task::{Context, Poll}; 23 + use std::time::Duration; 24 + use tokio::sync::mpsc; 25 + 26 + /// An async stream of TAP events with automatic reconnection. 27 + /// 28 + /// `TapStream` implements [`Stream`] and yields `Result<Arc<TapEvent>, TapError>`. 29 + /// Events are wrapped in `Arc` for efficient zero-cost sharing across consumers. 30 + /// 31 + /// # Connection Management 32 + /// 33 + /// The stream automatically: 34 + /// - Connects on first poll 35 + /// - Reconnects with exponential backoff on connection errors 36 + /// - Sends acknowledgments after parsing each message (if enabled) 37 + /// - Yields parse errors without affecting connection state 38 + /// 39 + /// # Example 40 + /// 41 + /// ```ignore 42 + /// use atproto_tap::{TapConfig, TapStream}; 43 + /// use tokio_stream::StreamExt; 44 + /// 45 + /// let config = TapConfig::builder() 46 + /// .hostname("localhost:2480") 47 + /// .build(); 48 + /// 49 + /// let mut stream = TapStream::new(config); 50 + /// 51 + /// while let Some(result) = stream.next().await { 52 + /// match result { 53 + /// Ok(event) => println!("Event: {:?}", event), 54 + /// Err(e) => eprintln!("Error: {}", e), 55 + /// } 56 + /// } 57 + /// ``` 58 + pub struct TapStream { 59 + /// Receiver for events from the background task. 60 + receiver: mpsc::Receiver<Result<Arc<TapEvent>, TapError>>, 61 + /// Handle to request stream closure. 62 + close_sender: Option<mpsc::Sender<()>>, 63 + /// Whether the stream has been closed. 64 + closed: bool, 65 + } 66 + 67 + impl TapStream { 68 + /// Create a new TAP stream with the given configuration. 69 + /// 70 + /// The stream will start connecting immediately in a background task. 71 + pub fn new(config: TapConfig) -> Self { 72 + // Channel for events - buffer a few to handle bursts 73 + let (event_tx, event_rx) = mpsc::channel(32); 74 + // Channel for close signal 75 + let (close_tx, close_rx) = mpsc::channel(1); 76 + 77 + // Spawn background task to manage connection 78 + tokio::spawn(connection_task(config, event_tx, close_rx)); 79 + 80 + Self { 81 + receiver: event_rx, 82 + close_sender: Some(close_tx), 83 + closed: false, 84 + } 85 + } 86 + 87 + /// Close the stream and release resources. 88 + /// 89 + /// After calling this, the stream will yield `None` on the next poll. 90 + pub async fn close(&mut self) { 91 + if let Some(sender) = self.close_sender.take() { 92 + // Signal the background task to close 93 + let _ = sender.send(()).await; 94 + } 95 + self.closed = true; 96 + } 97 + 98 + /// Returns true if the stream is closed. 99 + pub fn is_closed(&self) -> bool { 100 + self.closed 101 + } 102 + } 103 + 104 + impl Stream for TapStream { 105 + type Item = Result<Arc<TapEvent>, TapError>; 106 + 107 + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { 108 + if self.closed { 109 + return Poll::Ready(None); 110 + } 111 + 112 + self.receiver.poll_recv(cx) 113 + } 114 + } 115 + 116 + impl Drop for TapStream { 117 + fn drop(&mut self) { 118 + // Drop the close_sender to signal the background task 119 + self.close_sender.take(); 120 + tracing::debug!("TapStream dropped"); 121 + } 122 + } 123 + 124 + /// Background task that manages the WebSocket connection. 125 + async fn connection_task( 126 + config: TapConfig, 127 + event_tx: mpsc::Sender<Result<Arc<TapEvent>, TapError>>, 128 + mut close_rx: mpsc::Receiver<()>, 129 + ) { 130 + let mut current_reconnect_delay = config.initial_reconnect_delay; 131 + let mut attempt: u32 = 0; 132 + 133 + loop { 134 + // Check for close signal 135 + if close_rx.try_recv().is_ok() { 136 + tracing::debug!("Connection task received close signal"); 137 + break; 138 + } 139 + 140 + // Try to connect 141 + tracing::debug!(attempt, hostname = %config.hostname, "Connecting to TAP service"); 142 + let conn_result = TapConnection::connect(&config).await; 143 + 144 + match conn_result { 145 + Ok(mut conn) => { 146 + tracing::info!(hostname = %config.hostname, "TAP stream connected"); 147 + // Reset reconnection state on successful connect 148 + current_reconnect_delay = config.initial_reconnect_delay; 149 + attempt = 0; 150 + 151 + // Event loop for this connection 152 + loop { 153 + tokio::select! { 154 + biased; 155 + 156 + _ = close_rx.recv() => { 157 + tracing::debug!("Connection task received close signal during receive"); 158 + let _ = conn.close().await; 159 + return; 160 + } 161 + 162 + recv_result = conn.recv() => { 163 + match recv_result { 164 + Ok(Some(msg)) => { 165 + // Parse the message 166 + match serde_json::from_str::<TapEvent>(&msg) { 167 + Ok(event) => { 168 + let event_id = event.id(); 169 + 170 + // Send ack if enabled (before sending event to channel) 171 + if config.send_acks 172 + && let Err(err) = conn.send_ack(event_id).await 173 + { 174 + tracing::warn!(error = %err, "Failed to send ack"); 175 + // Don't break connection for ack errors 176 + } 177 + 178 + // Send event to channel 179 + let event = Arc::new(event); 180 + if event_tx.send(Ok(event)).await.is_err() { 181 + // Receiver dropped, exit task 182 + tracing::debug!("Event receiver dropped, closing connection"); 183 + let _ = conn.close().await; 184 + return; 185 + } 186 + } 187 + Err(err) => { 188 + // Parse errors don't affect connection 189 + tracing::warn!(error = %err, "Failed to parse TAP message"); 190 + 191 + // Try to extract just the ID using fallback parser 192 + // so we can still ack the message even if full parsing fails 193 + if config.send_acks { 194 + if let Some(event_id) = extract_event_id(&msg) { 195 + tracing::debug!(event_id, "Extracted event ID via fallback parser"); 196 + if let Err(ack_err) = conn.send_ack(event_id).await { 197 + tracing::warn!(error = %ack_err, "Failed to send ack for unparseable message"); 198 + } 199 + } else { 200 + tracing::warn!("Could not extract event ID from unparseable message"); 201 + } 202 + } 203 + 204 + if event_tx.send(Err(TapError::ParseError(err.to_string()))).await.is_err() { 205 + tracing::debug!("Event receiver dropped, closing connection"); 206 + let _ = conn.close().await; 207 + return; 208 + } 209 + } 210 + } 211 + } 212 + Ok(None) => { 213 + // Connection closed by server 214 + tracing::debug!("TAP connection closed by server"); 215 + break; 216 + } 217 + Err(err) => { 218 + // Connection error 219 + tracing::warn!(error = %err, "TAP connection error"); 220 + break; 221 + } 222 + } 223 + } 224 + } 225 + } 226 + } 227 + Err(err) => { 228 + tracing::warn!(error = %err, attempt, "Failed to connect to TAP service"); 229 + } 230 + } 231 + 232 + // Increment attempt counter 233 + attempt += 1; 234 + 235 + // Check if we've exceeded max attempts 236 + if let Some(max) = config.max_reconnect_attempts 237 + && attempt >= max 238 + { 239 + tracing::error!(attempts = attempt, "Max reconnection attempts exceeded"); 240 + let _ = event_tx 241 + .send(Err(TapError::MaxReconnectAttemptsExceeded(attempt))) 242 + .await; 243 + break; 244 + } 245 + 246 + // Wait before reconnecting with exponential backoff 247 + tracing::debug!( 248 + delay_ms = current_reconnect_delay.as_millis(), 249 + attempt, 250 + "Waiting before reconnection" 251 + ); 252 + 253 + tokio::select! { 254 + _ = close_rx.recv() => { 255 + tracing::debug!("Connection task received close signal during backoff"); 256 + return; 257 + } 258 + _ = tokio::time::sleep(current_reconnect_delay) => { 259 + // Update delay for next attempt 260 + current_reconnect_delay = Duration::from_secs_f64( 261 + (current_reconnect_delay.as_secs_f64() * config.reconnect_backoff_multiplier) 262 + .min(config.max_reconnect_delay.as_secs_f64()), 263 + ); 264 + } 265 + } 266 + } 267 + 268 + tracing::debug!("Connection task exiting"); 269 + } 270 + 271 + /// Create a new TAP stream with the given configuration. 272 + pub fn connect(config: TapConfig) -> TapStream { 273 + TapStream::new(config) 274 + } 275 + 276 + /// Create a new TAP stream connected to the given hostname. 277 + /// 278 + /// Uses default configuration values. 279 + pub fn connect_to(hostname: &str) -> TapStream { 280 + TapStream::new(TapConfig::new(hostname)) 281 + } 282 + 283 + #[cfg(test)] 284 + mod tests { 285 + use super::*; 286 + 287 + #[test] 288 + fn test_stream_initial_state() { 289 + // Note: This test doesn't actually poll the stream, just checks initial state 290 + // Creating a TapStream requires a tokio runtime for the spawn 291 + } 292 + 293 + #[tokio::test] 294 + async fn test_stream_close() { 295 + let mut stream = TapStream::new(TapConfig::new("localhost:9999")); 296 + assert!(!stream.is_closed()); 297 + stream.close().await; 298 + assert!(stream.is_closed()); 299 + } 300 + 301 + #[test] 302 + fn test_connect_functions() { 303 + // These just create configs, actual connection happens in background task 304 + // We can't test without a runtime, so just verify the types compile 305 + let _ = TapConfig::new("localhost:2480"); 306 + } 307 + 308 + #[test] 309 + fn test_reconnect_delay_calculation() { 310 + // Test the delay calculation logic 311 + let initial = Duration::from_secs(1); 312 + let max = Duration::from_secs(10); 313 + let multiplier = 2.0; 314 + 315 + let mut delay = initial; 316 + assert_eq!(delay, Duration::from_secs(1)); 317 + 318 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 319 + assert_eq!(delay, Duration::from_secs(2)); 320 + 321 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 322 + assert_eq!(delay, Duration::from_secs(4)); 323 + 324 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 325 + assert_eq!(delay, Duration::from_secs(8)); 326 + 327 + delay = Duration::from_secs_f64((delay.as_secs_f64() * multiplier).min(max.as_secs_f64())); 328 + assert_eq!(delay, Duration::from_secs(10)); // Capped at max 329 + } 330 + }
+13 -13
crates/atproto-xrpcs/README.md
··· 23 ### Basic XRPC Service 24 25 ```rust 26 - use atproto_xrpcs::authorization::ResolvingAuthorization; 27 use axum::{Json, Router, extract::Query, routing::get}; 28 use serde::Deserialize; 29 use serde_json::json; ··· 35 36 async fn handle_hello( 37 params: Query<HelloParams>, 38 - authorization: Option<ResolvingAuthorization>, 39 ) -> Json<serde_json::Value> { 40 let name = params.name.as_deref().unwrap_or("World"); 41 - 42 let message = if authorization.is_some() { 43 format!("Hello, authenticated {}!", name) 44 } else { 45 format!("Hello, {}!", name) 46 }; 47 - 48 Json(json!({ "message": message })) 49 } 50 ··· 56 ### JWT Authorization 57 58 ```rust 59 - use atproto_xrpcs::authorization::ResolvingAuthorization; 60 61 async fn handle_secure_endpoint( 62 - authorization: ResolvingAuthorization, // Required authorization 63 ) -> Json<serde_json::Value> { 64 - // The ResolvingAuthorization extractor automatically: 65 // 1. Validates the JWT token 66 - // 2. Resolves the caller's DID document 67 // 3. Verifies the signature against the DID document 68 // 4. Provides access to caller identity information 69 - 70 let caller_did = authorization.subject(); 71 Json(json!({"caller": caller_did, "status": "authenticated"})) 72 } ··· 79 use axum::{response::IntoResponse, http::StatusCode}; 80 81 async fn protected_handler( 82 - authorization: Result<ResolvingAuthorization, AuthorizationError>, 83 ) -> impl IntoResponse { 84 match authorization { 85 Ok(auth) => (StatusCode::OK, "Access granted").into_response(), 86 - Err(AuthorizationError::InvalidJWTToken { .. }) => { 87 (StatusCode::UNAUTHORIZED, "Invalid token").into_response() 88 } 89 - Err(AuthorizationError::DIDDocumentResolutionFailed { .. }) => { 90 (StatusCode::FORBIDDEN, "Identity verification failed").into_response() 91 } 92 Err(_) => { ··· 98 99 ## Authorization Flow 100 101 - The `ResolvingAuthorization` extractor implements: 102 103 1. JWT extraction from HTTP Authorization headers 104 2. Token validation (signature and claims structure)
··· 23 ### Basic XRPC Service 24 25 ```rust 26 + use atproto_xrpcs::authorization::Authorization; 27 use axum::{Json, Router, extract::Query, routing::get}; 28 use serde::Deserialize; 29 use serde_json::json; ··· 35 36 async fn handle_hello( 37 params: Query<HelloParams>, 38 + authorization: Option<Authorization>, 39 ) -> Json<serde_json::Value> { 40 let name = params.name.as_deref().unwrap_or("World"); 41 + 42 let message = if authorization.is_some() { 43 format!("Hello, authenticated {}!", name) 44 } else { 45 format!("Hello, {}!", name) 46 }; 47 + 48 Json(json!({ "message": message })) 49 } 50 ··· 56 ### JWT Authorization 57 58 ```rust 59 + use atproto_xrpcs::authorization::Authorization; 60 61 async fn handle_secure_endpoint( 62 + authorization: Authorization, // Required authorization 63 ) -> Json<serde_json::Value> { 64 + // The Authorization extractor automatically: 65 // 1. Validates the JWT token 66 + // 2. Resolves the caller's DID document 67 // 3. Verifies the signature against the DID document 68 // 4. Provides access to caller identity information 69 + 70 let caller_did = authorization.subject(); 71 Json(json!({"caller": caller_did, "status": "authenticated"})) 72 } ··· 79 use axum::{response::IntoResponse, http::StatusCode}; 80 81 async fn protected_handler( 82 + authorization: Result<Authorization, AuthorizationError>, 83 ) -> impl IntoResponse { 84 match authorization { 85 Ok(auth) => (StatusCode::OK, "Access granted").into_response(), 86 + Err(AuthorizationError::InvalidJWTFormat) => { 87 (StatusCode::UNAUTHORIZED, "Invalid token").into_response() 88 } 89 + Err(AuthorizationError::SubjectResolutionFailed { .. }) => { 90 (StatusCode::FORBIDDEN, "Identity verification failed").into_response() 91 } 92 Err(_) => { ··· 98 99 ## Authorization Flow 100 101 + The `Authorization` extractor implements: 102 103 1. JWT extraction from HTTP Authorization headers 104 2. Token validation (signature and claims structure)
+42 -108
crates/atproto-xrpcs/src/authorization.rs
··· 1 //! JWT authorization extractors for XRPC services. 2 //! 3 - //! Axum extractors for JWT validation against DID documents with 4 - //! cached and resolving authorization modes. 5 6 use anyhow::Result; 7 use atproto_identity::key::identify_key; 8 - use atproto_identity::resolve::IdentityResolver; 9 - use atproto_identity::storage::DidDocumentStorage; 10 use atproto_oauth::jwt::{Claims, Header}; 11 use axum::extract::{FromRef, OptionalFromRequestParts}; 12 use axum::http::request::Parts; ··· 17 18 use crate::errors::AuthorizationError; 19 20 - /// JWT authorization extractor that validates tokens against cached DID documents. 21 /// 22 /// Contains JWT header, validated claims, original token, and validation status. 23 - /// Only validates against DID documents already present in storage. 24 pub struct Authorization(pub Header, pub Claims, pub String, pub bool); 25 26 - /// JWT authorization extractor with automatic DID document resolution. 27 - /// 28 - /// Contains JWT header, validated claims, original token, and validation status. 29 - /// Attempts to resolve missing DID documents from authoritative sources when needed. 30 - pub struct ResolvingAuthorization(pub Header, pub Claims, pub String, pub bool); 31 - 32 - impl<S> OptionalFromRequestParts<S> for Authorization 33 - where 34 - S: Send + Sync, 35 - Arc<dyn DidDocumentStorage>: FromRef<S>, 36 - { 37 - type Rejection = Infallible; 38 - 39 - async fn from_request_parts( 40 - parts: &mut Parts, 41 - state: &S, 42 - ) -> Result<Option<Self>, Self::Rejection> { 43 - let auth_header = parts 44 - .headers 45 - .get("authorization") 46 - .and_then(|value| value.to_str().ok()) 47 - .and_then(|s| s.strip_prefix("Bearer ")); 48 - 49 - let token = match auth_header { 50 - Some(token) => token.to_string(), 51 - None => { 52 - return Ok(None); 53 - } 54 - }; 55 - 56 - let did_document_storage = Arc::<dyn DidDocumentStorage>::from_ref(state); 57 - 58 - match validate_jwt(&token, did_document_storage, None).await { 59 - Ok((header, claims)) => Ok(Some(Authorization(header, claims, token, true))), 60 - Err(_) => { 61 - // Return unvalidated authorization so the handler can decide what to do 62 - let header = Header::default(); 63 - let claims = Claims::default(); 64 - Ok(Some(Authorization(header, claims, token, false))) 65 - } 66 } 67 } 68 } 69 70 - impl<S> OptionalFromRequestParts<S> for ResolvingAuthorization 71 where 72 S: Send + Sync, 73 - Arc<dyn DidDocumentStorage>: FromRef<S>, 74 Arc<dyn IdentityResolver>: FromRef<S>, 75 { 76 type Rejection = Infallible; ··· 92 } 93 }; 94 95 - let did_document_storage = Arc::<dyn DidDocumentStorage>::from_ref(state); 96 let identity_resolver = Arc::<dyn IdentityResolver>::from_ref(state); 97 98 - match validate_jwt(&token, did_document_storage, Some(identity_resolver)).await { 99 - Ok((header, claims)) => Ok(Some(ResolvingAuthorization(header, claims, token, true))), 100 Err(_) => { 101 // Return unvalidated authorization so the handler can decide what to do 102 let header = Header::default(); 103 let claims = Claims::default(); 104 - Ok(Some(ResolvingAuthorization(header, claims, token, false))) 105 } 106 } 107 } ··· 109 110 async fn validate_jwt( 111 token: &str, 112 - storage: Arc<dyn DidDocumentStorage + Send + Sync>, 113 - identity_resolver: Option<Arc<dyn IdentityResolver>>, 114 ) -> Result<(Header, Claims)> { 115 // Split and decode JWT 116 let parts: Vec<&str> = token.split('.').collect(); ··· 134 .as_ref() 135 .ok_or_else(|| AuthorizationError::NoIssuerInClaims)?; 136 137 - // Try to look up DID document directly first 138 - let mut did_document = storage.get_document_by_did(issuer).await?; 139 - 140 - // If not found, try to resolve the subject 141 - if did_document.is_none() 142 - && let Some(identity_resolver) = identity_resolver 143 - { 144 - did_document = match identity_resolver.resolve(issuer).await { 145 - Ok(value) => { 146 - storage 147 - .store_document(value.clone()) 148 - .await 149 - .map_err(|err| AuthorizationError::DocumentStorageFailed { error: err })?; 150 - 151 - Some(value) 152 - } 153 - Err(err) => { 154 - return Err(AuthorizationError::SubjectResolutionFailed { 155 - issuer: issuer.to_string(), 156 - error: err, 157 - } 158 - .into()); 159 - } 160 - }; 161 - } 162 - 163 - let did_document = did_document.ok_or_else(|| AuthorizationError::DIDDocumentNotFound { 164 - issuer: issuer.to_string(), 165 })?; 166 167 // Extract keys from DID document ··· 206 mod tests { 207 use super::*; 208 use atproto_identity::model::{Document, VerificationMethod}; 209 - use atproto_identity::storage::DidDocumentStorage; 210 use axum::extract::FromRef; 211 use axum::http::{Method, Request}; 212 use std::collections::HashMap; 213 214 #[derive(Clone)] 215 - struct MockStorage { 216 document: Document, 217 } 218 219 #[async_trait::async_trait] 220 - impl DidDocumentStorage for MockStorage { 221 - async fn get_document_by_did(&self, did: &str) -> Result<Option<Document>> { 222 - if did == self.document.id { 223 - Ok(Some(self.document.clone())) 224 } else { 225 - Ok(None) 226 } 227 } 228 - 229 - async fn store_document(&self, _document: Document) -> Result<()> { 230 - Ok(()) 231 - } 232 - 233 - async fn delete_document_by_did(&self, _did: &str) -> Result<()> { 234 - Ok(()) 235 - } 236 } 237 238 #[derive(Clone)] 239 struct TestState { 240 - storage: Arc<dyn DidDocumentStorage + Send + Sync>, 241 } 242 243 - impl FromRef<TestState> for Arc<dyn DidDocumentStorage> { 244 fn from_ref(state: &TestState) -> Self { 245 - state.storage.clone() 246 } 247 } 248 ··· 266 extra: HashMap::new(), 267 }; 268 269 - // Create mock storage 270 - let storage = 271 - Arc::new(MockStorage { document }) as Arc<dyn DidDocumentStorage + Send + Sync>; 272 - let state = TestState { storage }; 273 274 // Create request with Authorization header 275 let request = Request::builder() ··· 307 308 #[tokio::test] 309 async fn test_authorization_no_header() { 310 - // Create mock storage 311 - let storage = Arc::new(MockStorage { 312 document: Document { 313 context: vec![], 314 id: "did:plc:test".to_string(), ··· 317 verification_method: vec![], 318 extra: HashMap::new(), 319 }, 320 - }) as Arc<dyn DidDocumentStorage + Send + Sync>; 321 - let state = TestState { storage }; 322 323 // Create request without Authorization header 324 let request = Request::builder()
··· 1 //! JWT authorization extractors for XRPC services. 2 //! 3 + //! Axum extractors for JWT validation against DID documents resolved 4 + //! via an identity resolver. 5 6 use anyhow::Result; 7 use atproto_identity::key::identify_key; 8 + use atproto_identity::traits::IdentityResolver; 9 use atproto_oauth::jwt::{Claims, Header}; 10 use axum::extract::{FromRef, OptionalFromRequestParts}; 11 use axum::http::request::Parts; ··· 16 17 use crate::errors::AuthorizationError; 18 19 + /// JWT authorization extractor that validates tokens against DID documents. 20 /// 21 /// Contains JWT header, validated claims, original token, and validation status. 22 + /// Resolves DID documents via the configured identity resolver. 23 + #[derive(Clone)] 24 pub struct Authorization(pub Header, pub Claims, pub String, pub bool); 25 26 + impl Authorization { 27 + /// identity returns the optional issuer claim of the authorization structure. 28 + pub fn identity(&self) -> Option<&str> { 29 + if self.3 { 30 + return self.1.jose.issuer.as_deref(); 31 } 32 + None 33 } 34 } 35 36 + impl<S> OptionalFromRequestParts<S> for Authorization 37 where 38 S: Send + Sync, 39 Arc<dyn IdentityResolver>: FromRef<S>, 40 { 41 type Rejection = Infallible; ··· 57 } 58 }; 59 60 let identity_resolver = Arc::<dyn IdentityResolver>::from_ref(state); 61 62 + match validate_jwt(&token, identity_resolver).await { 63 + Ok((header, claims)) => Ok(Some(Authorization(header, claims, token, true))), 64 Err(_) => { 65 // Return unvalidated authorization so the handler can decide what to do 66 let header = Header::default(); 67 let claims = Claims::default(); 68 + Ok(Some(Authorization(header, claims, token, false))) 69 } 70 } 71 } ··· 73 74 async fn validate_jwt( 75 token: &str, 76 + identity_resolver: Arc<dyn IdentityResolver>, 77 ) -> Result<(Header, Claims)> { 78 // Split and decode JWT 79 let parts: Vec<&str> = token.split('.').collect(); ··· 97 .as_ref() 98 .ok_or_else(|| AuthorizationError::NoIssuerInClaims)?; 99 100 + // Resolve the DID document via identity resolver 101 + let did_document = identity_resolver.resolve(issuer).await.map_err(|err| { 102 + AuthorizationError::SubjectResolutionFailed { 103 + issuer: issuer.to_string(), 104 + error: err, 105 + } 106 })?; 107 108 // Extract keys from DID document ··· 147 mod tests { 148 use super::*; 149 use atproto_identity::model::{Document, VerificationMethod}; 150 use axum::extract::FromRef; 151 use axum::http::{Method, Request}; 152 use std::collections::HashMap; 153 154 #[derive(Clone)] 155 + struct MockResolver { 156 document: Document, 157 } 158 159 #[async_trait::async_trait] 160 + impl IdentityResolver for MockResolver { 161 + async fn resolve(&self, subject: &str) -> Result<Document> { 162 + if subject == self.document.id { 163 + Ok(self.document.clone()) 164 } else { 165 + Err(anyhow::anyhow!( 166 + "error-atproto-xrpcs-authorization-1 DID not found: {}", 167 + subject 168 + )) 169 } 170 } 171 } 172 173 #[derive(Clone)] 174 struct TestState { 175 + resolver: Arc<dyn IdentityResolver>, 176 } 177 178 + impl FromRef<TestState> for Arc<dyn IdentityResolver> { 179 fn from_ref(state: &TestState) -> Self { 180 + state.resolver.clone() 181 } 182 } 183 ··· 201 extra: HashMap::new(), 202 }; 203 204 + // Create mock resolver 205 + let resolver = Arc::new(MockResolver { document }) as Arc<dyn IdentityResolver>; 206 + let state = TestState { resolver }; 207 208 // Create request with Authorization header 209 let request = Request::builder() ··· 241 242 #[tokio::test] 243 async fn test_authorization_no_header() { 244 + // Create mock resolver 245 + let resolver = Arc::new(MockResolver { 246 document: Document { 247 context: vec![], 248 id: "did:plc:test".to_string(), ··· 251 verification_method: vec![], 252 extra: HashMap::new(), 253 }, 254 + }) as Arc<dyn IdentityResolver>; 255 + let state = TestState { resolver }; 256 257 // Create request without Authorization header 258 let request = Request::builder()
+5 -49
crates/atproto-xrpcs/src/errors.rs
··· 42 #[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")] 43 NoIssuerInClaims, 44 45 - /// Occurs when DID document is not found for the issuer 46 - #[error("error-atproto-xrpcs-authorization-5 DID document not found for issuer: {issuer}")] 47 - DIDDocumentNotFound { 48 - /// The issuer DID that was not found 49 - issuer: String, 50 - }, 51 - 52 /// Occurs when no verification keys are found in DID document 53 - #[error("error-atproto-xrpcs-authorization-6 No verification keys found in DID document")] 54 NoVerificationKeys, 55 56 /// Occurs when JWT header cannot be base64 decoded 57 - #[error("error-atproto-xrpcs-authorization-7 Failed to decode JWT header: {error}")] 58 HeaderDecodeError { 59 /// The underlying base64 decode error 60 error: base64::DecodeError, 61 }, 62 63 /// Occurs when JWT header cannot be parsed as JSON 64 - #[error("error-atproto-xrpcs-authorization-8 Failed to parse JWT header: {error}")] 65 HeaderParseError { 66 /// The underlying JSON parse error 67 error: serde_json::Error, 68 }, 69 70 /// Occurs when JWT validation fails with all available keys 71 - #[error("error-atproto-xrpcs-authorization-9 JWT validation failed with all available keys")] 72 ValidationFailedAllKeys, 73 74 /// Occurs when subject resolution fails during DID document lookup 75 - #[error("error-atproto-xrpcs-authorization-10 Subject resolution failed: {issuer} {error}")] 76 SubjectResolutionFailed { 77 /// The issuer that failed to resolve 78 issuer: String, 79 /// The underlying resolution error 80 - error: anyhow::Error, 81 - }, 82 - 83 - /// Occurs when DID document lookup fails after successful resolution 84 - #[error( 85 - "error-atproto-xrpcs-authorization-11 DID document not found for resolved issuer: {resolved_did}" 86 - )] 87 - ResolvedDIDDocumentNotFound { 88 - /// The resolved DID that was not found in storage 89 - resolved_did: String, 90 - }, 91 - 92 - /// Occurs when PLC directory query fails 93 - #[error("error-atproto-xrpcs-authorization-12 PLC directory query failed: {error}")] 94 - PLCQueryFailed { 95 - /// The underlying PLC query error 96 - error: anyhow::Error, 97 - }, 98 - 99 - /// Occurs when web DID query fails 100 - #[error("error-atproto-xrpcs-authorization-13 Web DID query failed: {error}")] 101 - WebDIDQueryFailed { 102 - /// The underlying web DID query error 103 - error: anyhow::Error, 104 - }, 105 - 106 - /// Occurs when DID document storage operation fails 107 - #[error("error-atproto-xrpcs-authorization-14 DID document storage failed: {error}")] 108 - DocumentStorageFailed { 109 - /// The underlying storage error 110 - error: anyhow::Error, 111 - }, 112 - 113 - /// Occurs when input parsing fails for resolved DID 114 - #[error("error-atproto-xrpcs-authorization-15 Input parsing failed for resolved DID: {error}")] 115 - InputParsingFailed { 116 - /// The underlying parsing error 117 error: anyhow::Error, 118 }, 119 }
··· 42 #[error("error-atproto-xrpcs-authorization-4 No issuer found in JWT claims")] 43 NoIssuerInClaims, 44 45 /// Occurs when no verification keys are found in DID document 46 + #[error("error-atproto-xrpcs-authorization-5 No verification keys found in DID document")] 47 NoVerificationKeys, 48 49 /// Occurs when JWT header cannot be base64 decoded 50 + #[error("error-atproto-xrpcs-authorization-6 Failed to decode JWT header: {error}")] 51 HeaderDecodeError { 52 /// The underlying base64 decode error 53 error: base64::DecodeError, 54 }, 55 56 /// Occurs when JWT header cannot be parsed as JSON 57 + #[error("error-atproto-xrpcs-authorization-7 Failed to parse JWT header: {error}")] 58 HeaderParseError { 59 /// The underlying JSON parse error 60 error: serde_json::Error, 61 }, 62 63 /// Occurs when JWT validation fails with all available keys 64 + #[error("error-atproto-xrpcs-authorization-8 JWT validation failed with all available keys")] 65 ValidationFailedAllKeys, 66 67 /// Occurs when subject resolution fails during DID document lookup 68 + #[error("error-atproto-xrpcs-authorization-9 Subject resolution failed: {issuer} {error}")] 69 SubjectResolutionFailed { 70 /// The issuer that failed to resolve 71 issuer: String, 72 /// The underlying resolution error 73 error: anyhow::Error, 74 }, 75 }
+18 -24
crates/atproto-xrpcs-helloworld/src/main.rs
··· 5 use atproto_identity::resolve::SharedIdentityResolver; 6 use atproto_identity::{ 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 8 - key::{KeyData, KeyProvider, identify_key, to_public}, 9 resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver}, 10 - storage::DidDocumentStorage, 11 - storage_lru::LruDidDocumentStorage, 12 }; 13 - use atproto_xrpcs::authorization::ResolvingAuthorization; 14 use axum::{ 15 Json, Router, 16 extract::{FromRef, Query, State}, ··· 21 use http::{HeaderMap, StatusCode}; 22 use serde::Deserialize; 23 use serde_json::json; 24 - use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc}; 25 26 #[derive(Clone)] 27 - pub struct SimpleKeyProvider { 28 keys: HashMap<String, KeyData>, 29 } 30 31 - impl Default for SimpleKeyProvider { 32 fn default() -> Self { 33 Self::new() 34 } 35 } 36 37 - impl SimpleKeyProvider { 38 pub fn new() -> Self { 39 Self { 40 keys: HashMap::new(), ··· 43 } 44 45 #[async_trait] 46 - impl KeyProvider for SimpleKeyProvider { 47 - async fn get_private_key_by_id(&self, key_id: &str) -> anyhow::Result<Option<KeyData>> { 48 - Ok(self.keys.get(key_id).cloned()) 49 } 50 } 51 ··· 57 58 pub struct InnerWebContext { 59 pub http_client: reqwest::Client, 60 - pub document_storage: Arc<dyn DidDocumentStorage>, 61 - pub key_provider: Arc<dyn KeyProvider>, 62 pub service_document: ServiceDocument, 63 pub service_did: ServiceDID, 64 pub identity_resolver: Arc<dyn IdentityResolver>, ··· 93 } 94 } 95 96 - impl FromRef<WebContext> for Arc<dyn DidDocumentStorage> { 97 fn from_ref(context: &WebContext) -> Self { 98 - context.0.document_storage.clone() 99 - } 100 - } 101 - 102 - impl FromRef<WebContext> for Arc<dyn KeyProvider> { 103 - fn from_ref(context: &WebContext) -> Self { 104 - context.0.key_provider.clone() 105 } 106 } 107 ··· 212 213 let web_context = WebContext(Arc::new(InnerWebContext { 214 http_client: http_client.clone(), 215 - document_storage: Arc::new(LruDidDocumentStorage::new(NonZeroUsize::new(255).unwrap())), 216 - key_provider: Arc::new(SimpleKeyProvider { 217 keys: signing_key_storage, 218 }), 219 service_document, ··· 280 async fn handle_xrpc_hello_world( 281 parameters: Query<HelloParameters>, 282 headers: HeaderMap, 283 - authorization: Option<ResolvingAuthorization>, 284 ) -> Json<serde_json::Value> { 285 println!("headers {headers:?}"); 286 let subject = parameters.subject.as_deref().unwrap_or("World");
··· 5 use atproto_identity::resolve::SharedIdentityResolver; 6 use atproto_identity::{ 7 config::{CertificateBundles, DnsNameservers, default_env, optional_env, require_env, version}, 8 + key::{KeyData, KeyResolver, identify_key, to_public}, 9 resolve::{HickoryDnsResolver, IdentityResolver, InnerIdentityResolver}, 10 }; 11 + use atproto_xrpcs::authorization::Authorization; 12 use axum::{ 13 Json, Router, 14 extract::{FromRef, Query, State}, ··· 19 use http::{HeaderMap, StatusCode}; 20 use serde::Deserialize; 21 use serde_json::json; 22 + use std::{collections::HashMap, ops::Deref, sync::Arc}; 23 24 #[derive(Clone)] 25 + pub struct SimpleKeyResolver { 26 keys: HashMap<String, KeyData>, 27 } 28 29 + impl Default for SimpleKeyResolver { 30 fn default() -> Self { 31 Self::new() 32 } 33 } 34 35 + impl SimpleKeyResolver { 36 pub fn new() -> Self { 37 Self { 38 keys: HashMap::new(), ··· 41 } 42 43 #[async_trait] 44 + impl KeyResolver for SimpleKeyResolver { 45 + async fn resolve(&self, key: &str) -> anyhow::Result<KeyData> { 46 + if let Some(key_data) = self.keys.get(key) { 47 + Ok(key_data.clone()) 48 + } else { 49 + identify_key(key).map_err(Into::into) 50 + } 51 } 52 } 53 ··· 59 60 pub struct InnerWebContext { 61 pub http_client: reqwest::Client, 62 + pub key_resolver: Arc<dyn KeyResolver>, 63 pub service_document: ServiceDocument, 64 pub service_did: ServiceDID, 65 pub identity_resolver: Arc<dyn IdentityResolver>, ··· 94 } 95 } 96 97 + impl FromRef<WebContext> for Arc<dyn KeyResolver> { 98 fn from_ref(context: &WebContext) -> Self { 99 + context.0.key_resolver.clone() 100 } 101 } 102 ··· 207 208 let web_context = WebContext(Arc::new(InnerWebContext { 209 http_client: http_client.clone(), 210 + key_resolver: Arc::new(SimpleKeyResolver { 211 keys: signing_key_storage, 212 }), 213 service_document, ··· 274 async fn handle_xrpc_hello_world( 275 parameters: Query<HelloParameters>, 276 headers: HeaderMap, 277 + authorization: Option<Authorization>, 278 ) -> Json<serde_json::Value> { 279 println!("headers {headers:?}"); 280 let subject = parameters.subject.as_deref().unwrap_or("World");