this repo has no description
20
fork

Configure Feed

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

feature: experimental webvh support

+4621 -69
+69
Cargo.lock
··· 235 235 "clap", 236 236 "data-encoding", 237 237 "ecdsa", 238 + "ed25519-dalek", 238 239 "elliptic-curve", 239 240 "hickory-resolver", 241 + "idna", 240 242 "k256", 241 243 "lru", 242 244 "multibase", ··· 991 993 ] 992 994 993 995 [[package]] 996 + name = "curve25519-dalek" 997 + version = "4.1.3" 998 + source = "registry+https://github.com/rust-lang/crates.io-index" 999 + checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" 1000 + dependencies = [ 1001 + "cfg-if", 1002 + "cpufeatures", 1003 + "curve25519-dalek-derive", 1004 + "digest", 1005 + "fiat-crypto", 1006 + "rustc_version", 1007 + "subtle", 1008 + "zeroize", 1009 + ] 1010 + 1011 + [[package]] 1012 + name = "curve25519-dalek-derive" 1013 + version = "0.1.1" 1014 + source = "registry+https://github.com/rust-lang/crates.io-index" 1015 + checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" 1016 + dependencies = [ 1017 + "proc-macro2", 1018 + "quote", 1019 + "syn", 1020 + ] 1021 + 1022 + [[package]] 994 1023 name = "data-encoding" 995 1024 version = "2.10.0" 996 1025 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1087 1116 ] 1088 1117 1089 1118 [[package]] 1119 + name = "ed25519" 1120 + version = "2.2.3" 1121 + source = "registry+https://github.com/rust-lang/crates.io-index" 1122 + checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" 1123 + dependencies = [ 1124 + "pkcs8", 1125 + "signature", 1126 + ] 1127 + 1128 + [[package]] 1129 + name = "ed25519-dalek" 1130 + version = "2.2.0" 1131 + source = "registry+https://github.com/rust-lang/crates.io-index" 1132 + checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" 1133 + dependencies = [ 1134 + "curve25519-dalek", 1135 + "ed25519", 1136 + "rand_core 0.6.4", 1137 + "serde", 1138 + "sha2", 1139 + "subtle", 1140 + "zeroize", 1141 + ] 1142 + 1143 + [[package]] 1090 1144 name = "either" 1091 1145 version = "1.15.0" 1092 1146 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1168 1222 "rand_core 0.6.4", 1169 1223 "subtle", 1170 1224 ] 1225 + 1226 + [[package]] 1227 + name = "fiat-crypto" 1228 + version = "0.2.9" 1229 + source = "registry+https://github.com/rust-lang/crates.io-index" 1230 + checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 1171 1231 1172 1232 [[package]] 1173 1233 name = "find-msvc-tools" ··· 2555 2615 version = "2.1.1" 2556 2616 source = "registry+https://github.com/rust-lang/crates.io-index" 2557 2617 checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2618 + 2619 + [[package]] 2620 + name = "rustc_version" 2621 + version = "0.4.1" 2622 + source = "registry+https://github.com/rust-lang/crates.io-index" 2623 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 2624 + dependencies = [ 2625 + "semver", 2626 + ] 2558 2627 2559 2628 [[package]] 2560 2629 name = "rustix"
+2
Cargo.toml
··· 65 65 data-encoding = "2.5" 66 66 dirs = "6" 67 67 ecdsa = { version = "0.16", features = ["std"] } 68 + ed25519-dalek = { version = "2.1", features = ["std", "rand_core"] } 68 69 elliptic-curve = { version = "0.13", features = ["jwk", "serde"] } 69 70 futures = "0.3" 70 71 hickory-resolver = { version = "0.25" } 72 + idna = "1.0" 71 73 http = "1.3" 72 74 indexmap = { version = "2", features = ["serde"] } 73 75 itoa = "1.0"
+2 -2
crates/atproto-attestation/src/cid.rs
··· 226 226 227 227 // Define test record type with createdAt and text fields 228 228 #[derive(Serialize, Deserialize, PartialEq, Clone)] 229 - #[cfg_attr(debug_assertions, derive(Debug))] 229 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 230 230 struct TestRecord { 231 231 #[serde(rename = "createdAt", with = "datetime_format")] 232 232 created_at: DateTime<Utc>, ··· 241 241 242 242 // Define test metadata type 243 243 #[derive(Serialize, Deserialize, PartialEq, Clone)] 244 - #[cfg_attr(debug_assertions, derive(Debug))] 244 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 245 245 struct TestMetadata { 246 246 #[serde(rename = "createdAt", with = "datetime_format")] 247 247 created_at: DateTime<Utc>,
+1 -1
crates/atproto-client/src/com_atproto_identity.rs
··· 18 18 /// 19 19 /// This enum represents either a successful handle resolution containing a DID, 20 20 /// or an error response from the server. 21 - #[cfg_attr(debug_assertions, derive(Debug))] 21 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 22 22 #[derive(Deserialize, Clone)] 23 23 #[serde(untagged)] 24 24 pub enum ResolveHandleResponse {
+9 -9
crates/atproto-client/src/com_atproto_repo.rs
··· 39 39 }; 40 40 41 41 /// Response from getting a record from an AT Protocol repository. 42 - #[cfg_attr(debug_assertions, derive(Debug))] 42 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 43 43 #[derive(Deserialize, Clone)] 44 44 #[serde(untagged)] 45 45 pub enum GetRecordResponse { ··· 133 133 } 134 134 135 135 /// A single record in a list records response. 136 - #[cfg_attr(debug_assertions, derive(Debug))] 136 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 137 137 #[derive(Deserialize, Clone)] 138 138 pub struct ListRecord<T> { 139 139 /// AT-URI of the record ··· 145 145 } 146 146 147 147 /// Response from listing records in an AT Protocol repository. 148 - #[cfg_attr(debug_assertions, derive(Debug))] 148 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 149 149 #[derive(Deserialize, Clone)] 150 150 pub struct ListRecordsResponse<T> { 151 151 /// Pagination cursor for retrieving more records ··· 251 251 } 252 252 253 253 /// Request to create a new record in an AT Protocol repository. 254 - #[cfg_attr(debug_assertions, derive(Debug))] 254 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 255 255 #[derive(Serialize, Deserialize, Clone)] 256 256 #[serde(bound = "T: Serialize + DeserializeOwned")] 257 257 pub struct CreateRecordRequest<T: DeserializeOwned> { ··· 280 280 } 281 281 282 282 /// Response from creating a record in an AT Protocol repository. 283 - #[cfg_attr(debug_assertions, derive(Debug))] 283 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 284 284 #[derive(Deserialize, Clone)] 285 285 #[serde(untagged)] 286 286 pub enum CreateRecordResponse { ··· 340 340 } 341 341 342 342 /// Request to update an existing record in an AT Protocol repository. 343 - #[cfg_attr(debug_assertions, derive(Debug))] 343 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 344 344 #[derive(Serialize, Deserialize, Clone)] 345 345 #[serde(bound = "T: Serialize + DeserializeOwned")] 346 346 pub struct PutRecordRequest<T: DeserializeOwned> { ··· 377 377 } 378 378 379 379 /// Response from updating a record in an AT Protocol repository. 380 - #[cfg_attr(debug_assertions, derive(Debug))] 380 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 381 381 #[derive(Serialize, Deserialize, Clone)] 382 382 #[serde(untagged)] 383 383 pub enum PutRecordResponse { ··· 437 437 } 438 438 439 439 /// Request to delete a record from an AT Protocol repository. 440 - #[cfg_attr(debug_assertions, derive(Debug))] 440 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 441 441 #[derive(Serialize, Deserialize, Clone)] 442 442 pub struct DeleteRecordRequest { 443 443 /// Repository identifier (DID) ··· 467 467 } 468 468 469 469 /// Response from deleting a record in an AT Protocol repository. 470 - #[cfg_attr(debug_assertions, derive(Debug))] 470 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 471 471 #[derive(Deserialize, Clone)] 472 472 #[serde(untagged)] 473 473 pub enum DeleteRecordResponse {
+4 -4
crates/atproto-client/src/com_atproto_server.rs
··· 29 29 }; 30 30 31 31 /// Request to create a new authentication session. 32 - #[cfg_attr(debug_assertions, derive(Debug))] 32 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 33 33 #[derive(Serialize, Deserialize, Clone)] 34 34 pub struct CreateSessionRequest { 35 35 /// Handle or other identifier supported by the server for the authenticating user ··· 42 42 } 43 43 44 44 /// App password session data returned from successful authentication. 45 - #[cfg_attr(debug_assertions, derive(Debug))] 45 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 46 46 #[derive(Deserialize, Clone)] 47 47 pub struct AppPasswordSession { 48 48 /// Distributed identifier for the authenticated account ··· 60 60 } 61 61 62 62 /// Response from refreshing an authentication session. 63 - #[cfg_attr(debug_assertions, derive(Debug))] 63 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 64 64 #[derive(Deserialize, Clone)] 65 65 pub struct RefreshSessionResponse { 66 66 /// Distributed identifier for the authenticated account ··· 82 82 } 83 83 84 84 /// Response from creating a new app password. 85 - #[cfg_attr(debug_assertions, derive(Debug))] 85 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 86 86 #[derive(Deserialize, Clone)] 87 87 pub struct AppPasswordResponse { 88 88 /// Name of the app password
+1 -1
crates/atproto-client/src/errors.rs
··· 24 24 /// This structure represents the standard error response format used by AT Protocol 25 25 /// services, allowing for flexible error reporting with optional fields and 26 26 /// extension points for additional error context. 27 - #[cfg_attr(debug_assertions, derive(Debug))] 27 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 28 28 #[derive(Clone, Serialize, Deserialize)] 29 29 pub struct SimpleError { 30 30 /// The error code identifier
+2
crates/atproto-identity/Cargo.toml
··· 64 64 clap = { workspace = true, optional = true } 65 65 data-encoding.workspace = true 66 66 ecdsa.workspace = true 67 + ed25519-dalek.workspace = true 67 68 elliptic-curve.workspace = true 68 69 hickory-resolver = { workspace = true, optional = true } 70 + idna.workspace = true 69 71 k256 = { workspace = true, features = ["jwk"] } 70 72 lru = { workspace = true, optional = true } 71 73 multibase.workspace = true
+3
crates/atproto-identity/src/bin/atproto-identity-resolve.rs
··· 92 92 .await 93 93 .map_err(Into::into), 94 94 Ok(InputType::Web(did)) => web_query(&http_client, &did).await.map_err(Into::into), 95 + Ok(InputType::WebVH(did)) => atproto_identity::webvh::query(&http_client, &did) 96 + .await 97 + .map_err(Into::into), 95 98 Ok(InputType::Handle(_)) => { 96 99 eprintln!("error: subject resolved to handle"); 97 100 continue;
+258
crates/atproto-identity/src/errors.rs
··· 17 17 //! 18 18 //! All errors use the standardized format: `error-atproto-identity-{domain}-{number} {message}: {details}` 19 19 20 + use serde::{Deserialize, Serialize}; 20 21 use thiserror::Error; 21 22 22 23 /// Error types that can occur when working with Web DIDs ··· 383 384 /// The underlying conversion error 384 385 error: String, 385 386 }, 387 + 388 + /// Occurs when Ed25519 key operations fail 389 + #[error("error-atproto-identity-key-13 Ed25519 key operation failed: {error}")] 390 + Ed25519Error { 391 + /// Description of the Ed25519 error 392 + error: String, 393 + }, 394 + } 395 + 396 + /// Error types that can occur when working with WebVH DIDs 397 + #[derive(Debug, Error)] 398 + pub enum WebVHDIDError { 399 + /// Occurs when the DID is missing the 'did:webvh:' prefix 400 + #[error("error-atproto-identity-webvh-1 Invalid DID format: missing 'did:webvh:' prefix")] 401 + InvalidDIDPrefix, 402 + 403 + /// Occurs when the DID is missing a SCID component 404 + #[error("error-atproto-identity-webvh-2 Invalid DID format: missing SCID component")] 405 + MissingSCID, 406 + 407 + /// Occurs when the DID is missing a hostname component 408 + #[error("error-atproto-identity-webvh-3 Invalid DID format: missing hostname component")] 409 + MissingHostname, 410 + 411 + /// Occurs when the HTTP request to fetch the DID log fails 412 + #[error("error-atproto-identity-webvh-4 HTTP request failed: {url} {error}")] 413 + HttpRequestFailed { 414 + /// The URL that was requested 415 + url: String, 416 + /// The underlying HTTP error 417 + error: reqwest::Error, 418 + }, 419 + 420 + /// Occurs when a log entry cannot be parsed from the JSONL response 421 + #[error("error-atproto-identity-webvh-5 Failed to parse log entry at line {line}: {details}")] 422 + LogEntryParseFailed { 423 + /// The line number (1-indexed) that failed to parse 424 + line: usize, 425 + /// Details about the parse failure 426 + details: String, 427 + }, 428 + 429 + /// Occurs when the DID log file is empty 430 + #[error("error-atproto-identity-webvh-6 Empty log file")] 431 + EmptyLog, 432 + 433 + /// Occurs when a version ID is invalid or has incorrect format 434 + #[error("error-atproto-identity-webvh-7 Invalid version ID at entry {entry}: {details}")] 435 + InvalidVersionId { 436 + /// The entry number (1-indexed) with the invalid version ID 437 + entry: usize, 438 + /// Details about the validation failure 439 + details: String, 440 + }, 441 + 442 + /// Occurs when version times are not monotonically increasing 443 + #[error( 444 + "error-atproto-identity-webvh-8 Version time not monotonically increasing at entry {entry}" 445 + )] 446 + VersionTimeNotMonotonic { 447 + /// The entry number (1-indexed) with the non-monotonic timestamp 448 + entry: usize, 449 + }, 450 + 451 + /// Occurs when the SCID does not match the computed value from the genesis entry 452 + #[error("error-atproto-identity-webvh-9 SCID verification failed on genesis entry")] 453 + SCIDVerificationFailed, 454 + 455 + /// Occurs when an entry's hash does not match the expected value in the version ID 456 + #[error("error-atproto-identity-webvh-10 Entry hash verification failed at entry {entry}")] 457 + EntryHashVerificationFailed { 458 + /// The entry number (1-indexed) with the hash mismatch 459 + entry: usize, 460 + }, 461 + 462 + /// Occurs when a Data Integrity proof cannot be verified 463 + #[error( 464 + "error-atproto-identity-webvh-11 Proof verification failed at entry {entry}: {details}" 465 + )] 466 + ProofVerificationFailed { 467 + /// The entry number (1-indexed) with the failed proof 468 + entry: usize, 469 + /// Details about the verification failure 470 + details: String, 471 + }, 472 + 473 + /// Occurs when pre-rotation key hashes do not match 474 + #[error("error-atproto-identity-webvh-12 Pre-rotation key hash mismatch at entry {entry}")] 475 + PreRotationKeyMismatch { 476 + /// The entry number (1-indexed) with the key mismatch 477 + entry: usize, 478 + }, 479 + 480 + /// Occurs when log entry parameters are invalid 481 + #[error("error-atproto-identity-webvh-13 Invalid parameters at entry {entry}: {details}")] 482 + InvalidParameters { 483 + /// The entry number (1-indexed) with the invalid parameters 484 + entry: usize, 485 + /// Details about the validation failure 486 + details: String, 487 + }, 488 + 489 + /// Occurs when witness verification fails 490 + #[error( 491 + "error-atproto-identity-webvh-14 Witness verification failed at entry {entry}: {details}" 492 + )] 493 + WitnessVerificationFailed { 494 + /// The entry number (1-indexed) with the failed witness verification 495 + entry: usize, 496 + /// Details about the verification failure 497 + details: String, 498 + }, 499 + 500 + /// Occurs when the DID document cannot be extracted from the log state 501 + #[error("error-atproto-identity-webvh-15 DID document extraction failed: {details}")] 502 + DocumentExtractionFailed { 503 + /// Details about the extraction failure 504 + details: String, 505 + }, 506 + 507 + /// Occurs when the SCID format is invalid 508 + #[error("error-atproto-identity-webvh-16 Invalid SCID format: {details}")] 509 + InvalidSCIDFormat { 510 + /// Details about the format violation 511 + details: String, 512 + }, 513 + 514 + /// Occurs when the resolved DID has been deactivated 515 + #[error("error-atproto-identity-webvh-17 Deactivated DID: {did}")] 516 + DeactivatedDID { 517 + /// The deactivated DID 518 + did: String, 519 + }, 520 + 521 + /// Occurs when a requested version is not found in the log 522 + #[error("error-atproto-identity-webvh-18 Version not found: {details}")] 523 + VersionNotFound { 524 + /// Details about the lookup that failed 525 + details: String, 526 + }, 527 + 528 + /// Occurs when a version time is in the future 529 + #[error( 530 + "error-atproto-identity-webvh-19 Version time is in the future at entry {entry}: {version_time}" 531 + )] 532 + VersionTimeInFuture { 533 + /// The entry number (1-indexed) with the future timestamp 534 + entry: usize, 535 + /// The future timestamp value 536 + version_time: String, 537 + }, 538 + 539 + /// Occurs when the DIDDoc id does not match the DID 540 + #[error( 541 + "error-atproto-identity-webvh-20 DIDDoc id mismatch at entry {entry}: expected '{expected}', found '{found}'" 542 + )] 543 + DIDDocIdMismatch { 544 + /// The entry number (1-indexed) with the mismatch 545 + entry: usize, 546 + /// The expected DID 547 + expected: String, 548 + /// The actual id found in the DIDDoc 549 + found: String, 550 + }, 551 + 552 + /// Occurs when the hash algorithm in the SCID is not supported 553 + #[error("error-atproto-identity-webvh-21 Unsupported hash algorithm in SCID: {details}")] 554 + UnsupportedHashAlgorithm { 555 + /// Details about the unsupported algorithm 556 + details: String, 557 + }, 558 + 559 + /// Occurs when an unknown parameter is found in a log entry 560 + #[error("error-atproto-identity-webvh-22 Unknown parameter at entry {entry}: {parameter}")] 561 + UnknownParameter { 562 + /// The entry number (1-indexed) with the unknown parameter 563 + entry: usize, 564 + /// The unknown parameter name 565 + parameter: String, 566 + }, 567 + 568 + /// Occurs when hostname normalization fails 569 + #[error("error-atproto-identity-webvh-23 Hostname normalization failed: {details}")] 570 + HostnameNormalizationFailed { 571 + /// Details about the normalization failure 572 + details: String, 573 + }, 574 + } 575 + 576 + /// Resolution error codes as defined by the did:webvh specification. 577 + #[derive(Debug, Clone, Serialize, Deserialize)] 578 + #[serde(rename_all = "camelCase")] 579 + pub struct ResolutionError { 580 + /// Error code: "notFound" or "invalidDid". 581 + pub error: ResolutionErrorCode, 582 + /// Optional RFC 9457 problem details. 583 + #[serde(skip_serializing_if = "Option::is_none")] 584 + pub problem_details: Option<ProblemDetails>, 585 + } 586 + 587 + /// Resolution error code per the did:webvh specification. 588 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 589 + pub enum ResolutionErrorCode { 590 + /// The DID was not found. 591 + #[serde(rename = "notFound")] 592 + NotFound, 593 + /// The DID is invalid or failed verification. 594 + #[serde(rename = "invalidDid")] 595 + InvalidDid, 596 + } 597 + 598 + /// RFC 9457 Problem Details structure. 599 + #[derive(Debug, Clone, Serialize, Deserialize)] 600 + pub struct ProblemDetails { 601 + /// Problem type URI. 602 + #[serde(skip_serializing_if = "Option::is_none")] 603 + pub r#type: Option<String>, 604 + /// Short human-readable summary. 605 + pub title: String, 606 + /// Detailed explanation. 607 + #[serde(skip_serializing_if = "Option::is_none")] 608 + pub detail: Option<String>, 609 + } 610 + 611 + impl WebVHDIDError { 612 + /// Converts this error to a spec-compliant resolution error. 613 + pub fn to_resolution_error(&self) -> ResolutionError { 614 + match self { 615 + WebVHDIDError::HttpRequestFailed { .. } | WebVHDIDError::EmptyLog => ResolutionError { 616 + error: ResolutionErrorCode::NotFound, 617 + problem_details: Some(ProblemDetails { 618 + r#type: None, 619 + title: "DID not found".to_string(), 620 + detail: Some(self.to_string()), 621 + }), 622 + }, 623 + WebVHDIDError::InvalidDIDPrefix 624 + | WebVHDIDError::MissingSCID 625 + | WebVHDIDError::MissingHostname 626 + | WebVHDIDError::InvalidSCIDFormat { .. } => ResolutionError { 627 + error: ResolutionErrorCode::InvalidDid, 628 + problem_details: Some(ProblemDetails { 629 + r#type: None, 630 + title: "Invalid DID format".to_string(), 631 + detail: Some(self.to_string()), 632 + }), 633 + }, 634 + _ => ResolutionError { 635 + error: ResolutionErrorCode::InvalidDid, 636 + problem_details: Some(ProblemDetails { 637 + r#type: None, 638 + title: "DID verification failed".to_string(), 639 + detail: Some(self.to_string()), 640 + }), 641 + }, 642 + } 643 + } 386 644 } 387 645 388 646 /// Error types that can occur when working with storage operations
+379 -7
crates/atproto-identity/src/key.rs
··· 90 90 /// A k256 (K-256 / secp256k1 / ES256K) private key. 91 91 /// The multibase / multicodec prefix is 8126. 92 92 K256Private, 93 + 94 + /// An Ed25519 public key. 95 + /// The multibase / multicodec prefix is ed01. 96 + Ed25519Public, 97 + 98 + /// An Ed25519 private key. 99 + /// The multibase / multicodec prefix is 8026. 100 + Ed25519Private, 93 101 } 94 102 95 103 impl std::fmt::Display for KeyType { ··· 101 109 KeyType::P384Private => write!(f, "P384Private"), 102 110 KeyType::K256Public => write!(f, "K256Public"), 103 111 KeyType::K256Private => write!(f, "K256Private"), 112 + KeyType::Ed25519Public => write!(f, "Ed25519Public"), 113 + KeyType::Ed25519Private => write!(f, "Ed25519Private"), 104 114 } 105 115 } 106 116 } ··· 152 162 KeyType::P384Public => [0x12, 0x00], 153 163 KeyType::K256Private => [0x81, 0x26], 154 164 KeyType::K256Public => [0xe7, 0x01], 165 + KeyType::Ed25519Private => [0x80, 0x26], 166 + KeyType::Ed25519Public => [0xed, 0x01], 155 167 }; 156 168 157 169 // Combine prefix and key bytes ··· 240 252 decoded_multibase_key[2..].to_vec(), 241 253 )), 242 254 255 + // Ed25519 public key 256 + [0xed, 0x01] => Ok(KeyData::new( 257 + KeyType::Ed25519Public, 258 + decoded_multibase_key[2..].to_vec(), 259 + )), 260 + 261 + // Ed25519 private key 262 + [0x80, 0x26] => Ok(KeyData::new( 263 + KeyType::Ed25519Private, 264 + decoded_multibase_key[2..].to_vec(), 265 + )), 266 + 243 267 _ => Err(KeyError::InvalidMultibaseKeyType { 244 268 prefix: decoded_multibase_key[..2].to_vec(), 245 269 }), ··· 308 332 ecdsa::signature::Verifier::verify(&verifying_key, content, &signature) 309 333 .map_err(|error| KeyError::ECDSAError { error }) 310 334 } 335 + KeyType::Ed25519Public => { 336 + let key_bytes: &[u8; 32] = 337 + key_data 338 + .bytes() 339 + .try_into() 340 + .map_err(|_| KeyError::Ed25519Error { 341 + error: format!( 342 + "invalid public key length: expected 32, got {}", 343 + key_data.bytes().len() 344 + ), 345 + })?; 346 + let verifying_key = 347 + ed25519_dalek::VerifyingKey::from_bytes(key_bytes).map_err(|error| { 348 + KeyError::Ed25519Error { 349 + error: format!("invalid public key: {}", error), 350 + } 351 + })?; 352 + let sig_bytes: &[u8; 64] = 353 + signature.try_into().map_err(|_| KeyError::Ed25519Error { 354 + error: format!( 355 + "invalid signature length: expected 64, got {}", 356 + signature.len() 357 + ), 358 + })?; 359 + let sig = ed25519_dalek::Signature::from_bytes(sig_bytes); 360 + ed25519_dalek::Verifier::verify(&verifying_key, content, &sig).map_err(|error| { 361 + KeyError::Ed25519Error { 362 + error: format!("signature verification failed: {}", error), 363 + } 364 + }) 365 + } 366 + KeyType::Ed25519Private => { 367 + let key_bytes: &[u8; 32] = 368 + key_data 369 + .bytes() 370 + .try_into() 371 + .map_err(|_| KeyError::Ed25519Error { 372 + error: format!( 373 + "invalid private key length: expected 32, got {}", 374 + key_data.bytes().len() 375 + ), 376 + })?; 377 + let signing_key = ed25519_dalek::SigningKey::from_bytes(key_bytes); 378 + let verifying_key = signing_key.verifying_key(); 379 + let sig_bytes: &[u8; 64] = 380 + signature.try_into().map_err(|_| KeyError::Ed25519Error { 381 + error: format!( 382 + "invalid signature length: expected 64, got {}", 383 + signature.len() 384 + ), 385 + })?; 386 + let sig = ed25519_dalek::Signature::from_bytes(sig_bytes); 387 + ed25519_dalek::Verifier::verify(&verifying_key, content, &sig).map_err(|error| { 388 + KeyError::Ed25519Error { 389 + error: format!("signature verification failed: {}", error), 390 + } 391 + }) 392 + } 311 393 } 312 394 } 313 395 ··· 325 407 /// Returns an error if a public key is provided instead of a private key. 326 408 pub fn sign(key_data: &KeyData, content: &[u8]) -> Result<Vec<u8>, KeyError> { 327 409 match *key_data.key_type() { 328 - KeyType::K256Public | KeyType::P256Public | KeyType::P384Public => { 329 - Err(KeyError::PrivateKeyRequiredForSignature) 330 - } 410 + KeyType::K256Public 411 + | KeyType::P256Public 412 + | KeyType::P384Public 413 + | KeyType::Ed25519Public => Err(KeyError::PrivateKeyRequiredForSignature), 331 414 KeyType::P256Private => { 332 415 let secret_key: p256::SecretKey = 333 416 ecdsa::elliptic_curve::SecretKey::from_slice(key_data.bytes()) ··· 358 441 .map_err(|error| KeyError::ECDSAError { error })?; 359 442 Ok(signature.to_vec()) 360 443 } 444 + KeyType::Ed25519Private => { 445 + let key_bytes: &[u8; 32] = 446 + key_data 447 + .bytes() 448 + .try_into() 449 + .map_err(|_| KeyError::Ed25519Error { 450 + error: format!( 451 + "invalid private key length: expected 32, got {}", 452 + key_data.bytes().len() 453 + ), 454 + })?; 455 + let signing_key = ed25519_dalek::SigningKey::from_bytes(key_bytes); 456 + let signature = ed25519_dalek::Signer::sign(&signing_key, content); 457 + Ok(signature.to_bytes().to_vec()) 458 + } 361 459 } 362 460 } 363 461 ··· 391 489 /// assert!(encoded.starts_with("z")); // base58btc prefix 392 490 /// # Ok::<(), atproto_identity::errors::KeyError>(()) 393 491 /// ``` 492 + /// Multicodec prefix for EdDSA (Ed25519) signatures (0xd002 varint-encoded). 493 + const EDDSA_SIGNATURE_MULTICODEC: [u8; 3] = [0x02, 0xa0, 0x03]; 494 + 495 + /// Encodes a signature with its multicodec prefix and returns a multibase base58btc string. 394 496 pub fn multiformat_encode(key_type: &KeyType, signature: &[u8]) -> String { 395 497 let prefix: &[u8] = match key_type { 396 498 KeyType::P256Private | KeyType::P256Public => &ES256_SIGNATURE_MULTICODEC, 397 499 KeyType::P384Private | KeyType::P384Public => &ES384_SIGNATURE_MULTICODEC, 398 500 KeyType::K256Private | KeyType::K256Public => &ES256K_SIGNATURE_MULTICODEC, 501 + KeyType::Ed25519Private | KeyType::Ed25519Public => &EDDSA_SIGNATURE_MULTICODEC, 399 502 }; 400 503 401 504 // Combine prefix and signature bytes ··· 512 615 let secret_key = k256::SecretKey::from_slice(self.bytes()) 513 616 .map_err(|error| KeyError::SecretKeyError { error })?; 514 617 Ok(secret_key.to_jwk()) 618 + } 619 + KeyType::Ed25519Public | KeyType::Ed25519Private => { 620 + Err(KeyError::JWKConversionFailed { 621 + error: "Ed25519 keys use JWK OKP key type, not EC".to_string(), 622 + }) 515 623 } 516 624 } 517 625 } ··· 560 668 secret_key.to_bytes().to_vec(), 561 669 )) 562 670 } 563 - KeyType::P256Public => Err(KeyError::PublicKeyGenerationNotSupported), 564 - KeyType::P384Public => Err(KeyError::PublicKeyGenerationNotSupported), 565 - KeyType::K256Public => Err(KeyError::PublicKeyGenerationNotSupported), 671 + KeyType::Ed25519Private => { 672 + let signing_key = ed25519_dalek::SigningKey::generate(&mut rand::thread_rng()); 673 + Ok(KeyData::new( 674 + KeyType::Ed25519Private, 675 + signing_key.to_bytes().to_vec(), 676 + )) 677 + } 678 + KeyType::P256Public 679 + | KeyType::P384Public 680 + | KeyType::K256Public 681 + | KeyType::Ed25519Public => Err(KeyError::PublicKeyGenerationNotSupported), 566 682 } 567 683 } 568 684 ··· 618 734 let public_key_bytes = public_key.to_sec1_bytes(); 619 735 Ok(KeyData::new(KeyType::K256Public, public_key_bytes.to_vec())) 620 736 } 621 - KeyType::P256Public | KeyType::P384Public | KeyType::K256Public => { 737 + KeyType::Ed25519Private => { 738 + let key_bytes: &[u8; 32] = 739 + key_data 740 + .bytes() 741 + .try_into() 742 + .map_err(|_| KeyError::Ed25519Error { 743 + error: format!( 744 + "invalid private key length: expected 32, got {}", 745 + key_data.bytes().len() 746 + ), 747 + })?; 748 + let signing_key = ed25519_dalek::SigningKey::from_bytes(key_bytes); 749 + let verifying_key = signing_key.verifying_key(); 750 + Ok(KeyData::new( 751 + KeyType::Ed25519Public, 752 + verifying_key.to_bytes().to_vec(), 753 + )) 754 + } 755 + KeyType::P256Public 756 + | KeyType::P384Public 757 + | KeyType::K256Public 758 + | KeyType::Ed25519Public => { 622 759 // Return a clone of the existing public key 623 760 Ok(key_data.clone()) 624 761 } ··· 1728 1865 assert_ne!(p256_encoded, p384_encoded); 1729 1866 assert_ne!(p256_encoded, k256_encoded); 1730 1867 assert_ne!(p384_encoded, k256_encoded); 1868 + 1869 + Ok(()) 1870 + } 1871 + 1872 + // ===== ED25519 SPECIFIC TESTS ===== 1873 + 1874 + #[test] 1875 + fn test_generate_key_ed25519_private() -> Result<()> { 1876 + let key_data = generate_key(KeyType::Ed25519Private)?; 1877 + assert_eq!(*key_data.key_type(), KeyType::Ed25519Private); 1878 + assert_eq!(key_data.bytes().len(), 32); 1879 + 1880 + let content = "test content for ed25519".as_bytes(); 1881 + let signature = sign(&key_data, content)?; 1882 + assert_eq!(signature.len(), 64); 1883 + validate(&key_data, &signature, content)?; 1884 + 1885 + Ok(()) 1886 + } 1887 + 1888 + #[test] 1889 + fn test_generate_key_ed25519_public_not_supported() { 1890 + let result = generate_key(KeyType::Ed25519Public); 1891 + assert!(matches!( 1892 + result, 1893 + Err(KeyError::PublicKeyGenerationNotSupported) 1894 + )); 1895 + } 1896 + 1897 + #[test] 1898 + fn test_generate_key_ed25519_uniqueness() -> Result<()> { 1899 + let key1 = generate_key(KeyType::Ed25519Private)?; 1900 + let key2 = generate_key(KeyType::Ed25519Private)?; 1901 + assert_ne!(key1.bytes(), key2.bytes()); 1902 + 1903 + Ok(()) 1904 + } 1905 + 1906 + #[test] 1907 + fn test_sign_and_validate_ed25519() -> Result<()> { 1908 + let private_key = generate_key(KeyType::Ed25519Private)?; 1909 + let public_key = to_public(&private_key)?; 1910 + assert_eq!(*public_key.key_type(), KeyType::Ed25519Public); 1911 + assert_eq!(public_key.bytes().len(), 32); 1912 + 1913 + let content = "hello world ed25519 test".as_bytes(); 1914 + 1915 + let signature = sign(&private_key, content)?; 1916 + assert_eq!(signature.len(), 64); 1917 + 1918 + // Verify with public key 1919 + validate(&public_key, &signature, content)?; 1920 + 1921 + // Verify with private key 1922 + validate(&private_key, &signature, content)?; 1923 + 1924 + // Wrong content should fail 1925 + assert!(validate(&public_key, &signature, b"wrong content").is_err()); 1926 + 1927 + Ok(()) 1928 + } 1929 + 1930 + #[test] 1931 + fn test_ed25519_tampered_signature() -> Result<()> { 1932 + let private_key = generate_key(KeyType::Ed25519Private)?; 1933 + let public_key = to_public(&private_key)?; 1934 + let content = b"test content"; 1935 + 1936 + let mut signature = sign(&private_key, content)?; 1937 + // Tamper with the signature 1938 + signature[0] ^= 0xff; 1939 + 1940 + assert!(validate(&public_key, &signature, content).is_err()); 1941 + 1942 + Ok(()) 1943 + } 1944 + 1945 + #[test] 1946 + fn test_ed25519_keydata_display_round_trip() -> Result<()> { 1947 + let original_key = generate_key(KeyType::Ed25519Private)?; 1948 + 1949 + let key_string = format!("{}", original_key); 1950 + assert!(key_string.starts_with("did:key:z")); 1951 + 1952 + let parsed_key = identify_key(&key_string)?; 1953 + assert_eq!(original_key.key_type(), parsed_key.key_type()); 1954 + assert_eq!(original_key.bytes(), parsed_key.bytes()); 1955 + 1956 + // Sign with original, verify with parsed 1957 + let content = b"ed25519 round trip test"; 1958 + let signature = sign(&original_key, content)?; 1959 + validate(&parsed_key, &signature, content)?; 1960 + 1961 + // Sign with parsed, verify with original 1962 + let signature2 = sign(&parsed_key, content)?; 1963 + validate(&original_key, &signature2, content)?; 1964 + 1965 + Ok(()) 1966 + } 1967 + 1968 + #[test] 1969 + fn test_ed25519_to_public_key_derivation() -> Result<()> { 1970 + let private_key = generate_key(KeyType::Ed25519Private)?; 1971 + let public_key = to_public(&private_key)?; 1972 + 1973 + assert_eq!(*public_key.key_type(), KeyType::Ed25519Public); 1974 + assert_eq!(public_key.bytes().len(), 32); 1975 + 1976 + let public_key_did = format!("{}", public_key); 1977 + assert!(public_key_did.starts_with("did:key:")); 1978 + 1979 + let parsed_public_key = identify_key(&public_key_did)?; 1980 + assert_eq!(*parsed_public_key.key_type(), KeyType::Ed25519Public); 1981 + assert_eq!(public_key.bytes(), parsed_public_key.bytes()); 1982 + 1983 + // Calling to_public on a public key should return the same key 1984 + let result = to_public(&public_key)?; 1985 + assert_eq!(*result.key_type(), KeyType::Ed25519Public); 1986 + assert_eq!(public_key.bytes(), result.bytes()); 1987 + 1988 + Ok(()) 1989 + } 1990 + 1991 + #[test] 1992 + fn test_ed25519_multicodec_prefix_identification() -> Result<()> { 1993 + let private_key = generate_key(KeyType::Ed25519Private)?; 1994 + let public_key = to_public(&private_key)?; 1995 + 1996 + let private_did = format!("{}", private_key); 1997 + let public_did = format!("{}", public_key); 1998 + 1999 + let private_value = did_method_key_value(&private_did); 2000 + let public_value = did_method_key_value(&public_did); 2001 + 2002 + let (_, private_decoded) = multibase::decode(private_value)?; 2003 + let (_, public_decoded) = multibase::decode(public_value)?; 2004 + 2005 + assert_eq!(&private_decoded[..2], &[0x80, 0x26]); 2006 + assert_eq!(&public_decoded[..2], &[0xed, 0x01]); 2007 + 2008 + Ok(()) 2009 + } 2010 + 2011 + #[test] 2012 + fn test_ed25519_sign_with_public_key_fails() { 2013 + let private_key = generate_key(KeyType::Ed25519Private).unwrap(); 2014 + let public_key = to_public(&private_key).unwrap(); 2015 + 2016 + let result = sign(&public_key, b"test content"); 2017 + assert!(matches!( 2018 + result, 2019 + Err(KeyError::PrivateKeyRequiredForSignature) 2020 + )); 2021 + } 2022 + 2023 + #[test] 2024 + fn test_ed25519_jwk_conversion_fails() -> Result<()> { 2025 + let private_key = generate_key(KeyType::Ed25519Private)?; 2026 + let public_key = to_public(&private_key)?; 2027 + 2028 + let private_jwk: Result<elliptic_curve::JwkEcKey, _> = (&private_key).try_into(); 2029 + assert!(matches!( 2030 + private_jwk, 2031 + Err(KeyError::JWKConversionFailed { .. }) 2032 + )); 2033 + 2034 + let public_jwk: Result<elliptic_curve::JwkEcKey, _> = (&public_key).try_into(); 2035 + assert!(matches!( 2036 + public_jwk, 2037 + Err(KeyError::JWKConversionFailed { .. }) 2038 + )); 2039 + 2040 + Ok(()) 2041 + } 2042 + 2043 + #[test] 2044 + fn test_ed25519_cross_curve_verification_fails() -> Result<()> { 2045 + let ed25519_key = generate_key(KeyType::Ed25519Private)?; 2046 + let p256_key = generate_key(KeyType::P256Private)?; 2047 + let k256_key = generate_key(KeyType::K256Private)?; 2048 + 2049 + let ed25519_public = to_public(&ed25519_key)?; 2050 + let p256_public = to_public(&p256_key)?; 2051 + let k256_public = to_public(&k256_key)?; 2052 + 2053 + let content = b"cross curve ed25519 test"; 2054 + 2055 + let ed25519_sig = sign(&ed25519_key, content)?; 2056 + let p256_sig = sign(&p256_key, content)?; 2057 + let k256_sig = sign(&k256_key, content)?; 2058 + 2059 + // Each signature verifies with its own key 2060 + validate(&ed25519_public, &ed25519_sig, content)?; 2061 + validate(&p256_public, &p256_sig, content)?; 2062 + validate(&k256_public, &k256_sig, content)?; 2063 + 2064 + // Cross-verification should fail 2065 + assert!(validate(&ed25519_public, &p256_sig, content).is_err()); 2066 + assert!(validate(&ed25519_public, &k256_sig, content).is_err()); 2067 + assert!(validate(&p256_public, &ed25519_sig, content).is_err()); 2068 + assert!(validate(&k256_public, &ed25519_sig, content).is_err()); 2069 + 2070 + Ok(()) 2071 + } 2072 + 2073 + #[test] 2074 + fn test_ed25519_deterministic_signatures() -> Result<()> { 2075 + // Ed25519 signatures are deterministic (unlike ECDSA) 2076 + let private_key = generate_key(KeyType::Ed25519Private)?; 2077 + let content = b"deterministic signature test"; 2078 + 2079 + let sig1 = sign(&private_key, content)?; 2080 + let sig2 = sign(&private_key, content)?; 2081 + assert_eq!(sig1, sig2); 2082 + 2083 + Ok(()) 2084 + } 2085 + 2086 + #[test] 2087 + fn test_multiformat_encode_ed25519() -> Result<()> { 2088 + let key = generate_key(KeyType::Ed25519Private)?; 2089 + let message = b"test message for ed25519 multiformat"; 2090 + let signature = sign(&key, message)?; 2091 + 2092 + let encoded = multiformat_encode(key.key_type(), &signature); 2093 + 2094 + // Verify base58btc prefix 2095 + assert!(encoded.starts_with('z')); 2096 + 2097 + // Decode and verify EdDSA multicodec prefix 2098 + let (_, decoded) = multibase::decode(&encoded)?; 2099 + assert_eq!(&decoded[..3], &EDDSA_SIGNATURE_MULTICODEC); 2100 + 2101 + // Verify signature bytes are preserved 2102 + assert_eq!(&decoded[3..], &signature[..]); 1731 2103 1732 2104 Ok(()) 1733 2105 }
+1
crates/atproto-identity/src/lib.rs
··· 25 25 pub mod url; 26 26 pub mod validation; 27 27 pub mod web; 28 + pub mod webvh;
+4 -4
crates/atproto-identity/src/model.rs
··· 9 9 10 10 /// AT Protocol service configuration from a DID document. 11 11 /// Represents services like Personal Data Servers (PDS). 12 - #[cfg_attr(debug_assertions, derive(Debug))] 12 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 13 13 #[derive(Clone, Serialize, Deserialize, PartialEq)] 14 14 #[serde(rename_all = "camelCase")] 15 15 pub struct Service { ··· 27 27 28 28 /// Cryptographic verification method from a DID document. 29 29 /// Used to verify signatures and authenticate identity operations. 30 - #[cfg_attr(debug_assertions, derive(Debug))] 30 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 31 31 #[derive(Clone, Serialize, Deserialize, PartialEq)] 32 32 #[serde(tag = "type")] 33 33 pub enum VerificationMethod { ··· 58 58 59 59 /// Complete DID document containing identity information. 60 60 /// Contains services, verification methods, and aliases for a DID. 61 - #[cfg_attr(debug_assertions, derive(Debug))] 61 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 62 62 #[derive(Clone, Serialize, Deserialize, PartialEq)] 63 63 #[serde(rename_all = "camelCase")] 64 64 pub struct Document { ··· 269 269 270 270 /// Resolved handle information linking DID to human-readable identifier. 271 271 /// Contains the complete identity resolution result. 272 - #[cfg_attr(debug_assertions, derive(Debug))] 272 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 273 273 #[derive(Clone, Deserialize, Serialize)] 274 274 pub struct Handle { 275 275 /// The resolved DID identifier.
+58 -3
crates/atproto-identity/src/resolve.rs
··· 29 29 use crate::errors::ResolveError; 30 30 use crate::model::Document; 31 31 use crate::plc::query as plc_query; 32 - use crate::validation::{is_valid_did_method_plc, is_valid_handle}; 32 + use crate::validation::{is_valid_did_method_plc, is_valid_did_method_webvh, is_valid_handle}; 33 33 use crate::web::query as web_query; 34 + use crate::webvh::query as webvh_query; 34 35 35 36 pub use crate::traits::{DnsResolver, IdentityResolver}; 36 37 ··· 91 92 Plc(String), 92 93 /// Web DID identifier (e.g., "did:web:example.com"). 93 94 Web(String), 95 + /// Web Verifiable History DID identifier (e.g., "did:webvh:SCID:example.com"). 96 + WebVH(String), 94 97 } 95 98 96 99 /// Resolves a handle to DID using DNS TXT records. ··· 160 163 if trimmed.is_empty() { 161 164 return Err(ResolveError::InvalidInput); 162 165 } 163 - if trimmed.starts_with("did:web:") { 166 + if trimmed.starts_with("did:webvh:") && is_valid_did_method_webvh(trimmed, false) { 167 + Ok(InputType::WebVH(trimmed.to_string())) 168 + } else if trimmed.starts_with("did:web:") { 164 169 Ok(InputType::Web(trimmed.to_string())) 165 170 } else if trimmed.starts_with("did:plc:") && is_valid_did_method_plc(trimmed) { 166 171 Ok(InputType::Plc(trimmed.to_string())) ··· 195 200 } 196 201 } 197 202 203 + #[test] 204 + fn test_parse_input_webvh() { 205 + let result = parse_input("did:webvh:z6MkTest123:example.com"); 206 + assert!(result.is_ok()); 207 + assert!( 208 + matches!(result.unwrap(), InputType::WebVH(did) if did == "did:webvh:z6MkTest123:example.com") 209 + ); 210 + } 211 + 212 + #[test] 213 + fn test_parse_input_webvh_with_path() { 214 + let result = parse_input("did:webvh:z6MkTest123:example.com:path:sub"); 215 + assert!(result.is_ok()); 216 + assert!( 217 + matches!(result.unwrap(), InputType::WebVH(did) if did == "did:webvh:z6MkTest123:example.com:path:sub") 218 + ); 219 + } 220 + 221 + #[test] 222 + fn test_parse_input_webvh_simple_hostname() { 223 + let result = parse_input("did:webvh:z6MkTest123:example.com"); 224 + assert!(result.is_ok()); 225 + assert!(matches!(result.unwrap(), InputType::WebVH(_))); 226 + } 227 + 228 + #[test] 229 + fn test_parse_input_webvh_with_at_prefix() { 230 + let result = parse_input("at://did:webvh:z6MkTest123:example.com"); 231 + assert!(result.is_ok()); 232 + assert!(matches!(result.unwrap(), InputType::WebVH(_))); 233 + } 234 + 235 + #[test] 236 + fn test_parse_input_web_not_webvh() { 237 + // did:web should not be parsed as did:webvh 238 + let result = parse_input("did:web:example.com"); 239 + assert!(result.is_ok()); 240 + assert!(matches!(result.unwrap(), InputType::Web(_))); 241 + } 242 + 243 + #[test] 244 + fn test_parse_input_plc_not_webvh() { 245 + let result = parse_input("did:plc:ewvi7nxzyoun6zhxrhs64oiz"); 246 + assert!(result.is_ok()); 247 + assert!(matches!(result.unwrap(), InputType::Plc(_))); 248 + } 249 + 198 250 #[tokio::test] 199 251 async fn resolves_direct_did_key() -> Result<()> { 200 252 let private_key = generate_key(KeyType::K256Private)?; ··· 313 365 ) -> Result<String, ResolveError> { 314 366 match parse_input(subject)? { 315 367 InputType::Handle(handle) => resolve_handle(http_client, dns_resolver, &handle).await, 316 - InputType::Plc(did) | InputType::Web(did) => Ok(did), 368 + InputType::Plc(did) | InputType::Web(did) | InputType::WebVH(did) => Ok(did), 317 369 } 318 370 } 319 371 ··· 362 414 .await 363 415 .map_err(Into::into), 364 416 Ok(InputType::Web(did)) => web_query(&self.http_client, &did).await.map_err(Into::into), 417 + Ok(InputType::WebVH(did)) => webvh_query(&self.http_client, &did) 418 + .await 419 + .map_err(Into::into), 365 420 Ok(InputType::Handle(_)) => Err(ResolveError::SubjectResolvedToHandle.into()), 366 421 Err(err) => Err(err.into()), 367 422 }
+251
crates/atproto-identity/src/webvh/jcs.rs
··· 1 + //! JSON Canonicalization Scheme (JCS) implementation per RFC 8785. 2 + //! 3 + //! Produces deterministic JSON serialization with lexicographically sorted 4 + //! object keys, no extraneous whitespace, and ES6-compliant number formatting. 5 + //! Used by did:webvh for computing entry hashes and SCID values. 6 + 7 + use serde_json::Value; 8 + 9 + /// Produces a JCS-canonicalized JSON string from a serde_json::Value. 10 + /// 11 + /// Implements RFC 8785 JSON Canonicalization Scheme: 12 + /// - Object keys sorted lexicographically by UTF-16 code units 13 + /// - No whitespace between tokens 14 + /// - Numbers formatted per ES6 (via serde_json's ryu-based formatting) 15 + /// - Standard JSON string escaping 16 + pub fn canonicalize(value: &Value) -> String { 17 + let mut output = String::new(); 18 + write_canonical(value, &mut output); 19 + output 20 + } 21 + 22 + fn write_canonical(value: &Value, output: &mut String) { 23 + match value { 24 + Value::Null => output.push_str("null"), 25 + Value::Bool(b) => { 26 + if *b { 27 + output.push_str("true"); 28 + } else { 29 + output.push_str("false"); 30 + } 31 + } 32 + Value::Number(n) => { 33 + // serde_json's Number Display uses ryu which produces ES6-compliant output 34 + output.push_str(&n.to_string()); 35 + } 36 + Value::String(s) => { 37 + write_json_string(s, output); 38 + } 39 + Value::Array(arr) => { 40 + output.push('['); 41 + for (i, item) in arr.iter().enumerate() { 42 + if i > 0 { 43 + output.push(','); 44 + } 45 + write_canonical(item, output); 46 + } 47 + output.push(']'); 48 + } 49 + Value::Object(map) => { 50 + // RFC 8785: sort keys by UTF-16 code units 51 + let mut keys: Vec<&String> = map.keys().collect(); 52 + keys.sort_by(|a, b| { 53 + let a_utf16: Vec<u16> = a.encode_utf16().collect(); 54 + let b_utf16: Vec<u16> = b.encode_utf16().collect(); 55 + a_utf16.cmp(&b_utf16) 56 + }); 57 + 58 + output.push('{'); 59 + for (i, key) in keys.iter().enumerate() { 60 + if i > 0 { 61 + output.push(','); 62 + } 63 + write_json_string(key, output); 64 + output.push(':'); 65 + write_canonical(&map[*key], output); 66 + } 67 + output.push('}'); 68 + } 69 + } 70 + } 71 + 72 + /// Writes a JSON-escaped string with surrounding quotes. 73 + fn write_json_string(s: &str, output: &mut String) { 74 + output.push('"'); 75 + for ch in s.chars() { 76 + match ch { 77 + '"' => output.push_str("\\\""), 78 + '\\' => output.push_str("\\\\"), 79 + '\u{0008}' => output.push_str("\\b"), 80 + '\u{000C}' => output.push_str("\\f"), 81 + '\n' => output.push_str("\\n"), 82 + '\r' => output.push_str("\\r"), 83 + '\t' => output.push_str("\\t"), 84 + c if c < '\u{0020}' => { 85 + // Control characters use \u00XX notation 86 + output.push_str(&format!("\\u{:04x}", c as u32)); 87 + } 88 + c => output.push(c), 89 + } 90 + } 91 + output.push('"'); 92 + } 93 + 94 + #[cfg(test)] 95 + mod tests { 96 + use super::*; 97 + use serde_json::json; 98 + 99 + #[test] 100 + fn test_null() { 101 + assert_eq!(canonicalize(&json!(null)), "null"); 102 + } 103 + 104 + #[test] 105 + fn test_booleans() { 106 + assert_eq!(canonicalize(&json!(true)), "true"); 107 + assert_eq!(canonicalize(&json!(false)), "false"); 108 + } 109 + 110 + #[test] 111 + fn test_integers() { 112 + assert_eq!(canonicalize(&json!(0)), "0"); 113 + assert_eq!(canonicalize(&json!(1)), "1"); 114 + assert_eq!(canonicalize(&json!(-1)), "-1"); 115 + assert_eq!(canonicalize(&json!(42)), "42"); 116 + assert_eq!(canonicalize(&json!(999999999)), "999999999"); 117 + } 118 + 119 + #[test] 120 + fn test_floats() { 121 + assert_eq!(canonicalize(&json!(1.0)), "1.0"); 122 + assert_eq!(canonicalize(&json!(0.5)), "0.5"); 123 + assert_eq!(canonicalize(&json!(-0.5)), "-0.5"); 124 + assert_eq!(canonicalize(&json!(1.23e2)), "123.0"); 125 + } 126 + 127 + #[test] 128 + fn test_empty_string() { 129 + assert_eq!(canonicalize(&json!("")), r#""""#); 130 + } 131 + 132 + #[test] 133 + fn test_simple_string() { 134 + assert_eq!(canonicalize(&json!("hello")), r#""hello""#); 135 + } 136 + 137 + #[test] 138 + fn test_string_escaping() { 139 + assert_eq!(canonicalize(&json!("a\"b")), r#""a\"b""#); 140 + assert_eq!(canonicalize(&json!("a\\b")), r#""a\\b""#); 141 + assert_eq!(canonicalize(&json!("a\nb")), r#""a\nb""#); 142 + assert_eq!(canonicalize(&json!("a\rb")), r#""a\rb""#); 143 + assert_eq!(canonicalize(&json!("a\tb")), r#""a\tb""#); 144 + } 145 + 146 + #[test] 147 + fn test_control_characters() { 148 + // BEL character (U+0007) 149 + assert_eq!( 150 + canonicalize(&Value::String("\u{0007}".to_string())), 151 + r#""\u0007""# 152 + ); 153 + // NUL character (U+0000) 154 + assert_eq!( 155 + canonicalize(&Value::String("\u{0000}".to_string())), 156 + r#""\u0000""# 157 + ); 158 + } 159 + 160 + #[test] 161 + fn test_empty_array() { 162 + assert_eq!(canonicalize(&json!([])), "[]"); 163 + } 164 + 165 + #[test] 166 + fn test_array_with_elements() { 167 + assert_eq!(canonicalize(&json!([1, 2, 3])), "[1,2,3]"); 168 + assert_eq!(canonicalize(&json!(["a", "b"])), r#"["a","b"]"#); 169 + assert_eq!(canonicalize(&json!([true, null, 1])), "[true,null,1]"); 170 + } 171 + 172 + #[test] 173 + fn test_nested_arrays() { 174 + assert_eq!(canonicalize(&json!([[1], [2, 3]])), "[[1],[2,3]]"); 175 + } 176 + 177 + #[test] 178 + fn test_empty_object() { 179 + assert_eq!(canonicalize(&json!({})), "{}"); 180 + } 181 + 182 + #[test] 183 + fn test_object_key_sorting() { 184 + // Keys must be sorted lexicographically 185 + let val: Value = serde_json::from_str(r#"{"z":1,"a":2,"m":3}"#).unwrap(); 186 + assert_eq!(canonicalize(&val), r#"{"a":2,"m":3,"z":1}"#); 187 + } 188 + 189 + #[test] 190 + fn test_nested_objects() { 191 + let val: Value = serde_json::from_str(r#"{"b":{"z":1,"a":2},"a":3}"#).unwrap(); 192 + assert_eq!(canonicalize(&val), r#"{"a":3,"b":{"a":2,"z":1}}"#); 193 + } 194 + 195 + #[test] 196 + fn test_mixed_types_in_object() { 197 + let val: Value = 198 + serde_json::from_str(r#"{"str":"hello","num":42,"bool":true,"null":null,"arr":[1,2]}"#) 199 + .unwrap(); 200 + assert_eq!( 201 + canonicalize(&val), 202 + r#"{"arr":[1,2],"bool":true,"null":null,"num":42,"str":"hello"}"# 203 + ); 204 + } 205 + 206 + #[test] 207 + fn test_rfc8785_example_structure() { 208 + // Based on RFC 8785 Section 3.2 test structure 209 + let val: Value = serde_json::from_str( 210 + r#"{"literals":[null,true,false],"numbers":[333333333.33333329,1e30,4.50,2e-3,0.000000000000000000000000001],"string":"\u20ac$\u000f\u000aA'\u0042\u0022\u005c\\\""}"#, 211 + ) 212 + .unwrap(); 213 + let result = canonicalize(&val); 214 + // Verify it's valid JSON and keys are sorted 215 + let reparsed: Value = serde_json::from_str(&result).unwrap(); 216 + assert!(reparsed.is_object()); 217 + // "literals" < "numbers" < "string" in lexicographic order 218 + assert!(result.starts_with(r#"{"literals":[null,true,false],"numbers":"#)); 219 + } 220 + 221 + #[test] 222 + fn test_unicode_sorting() { 223 + // UTF-16 code unit ordering 224 + let val: Value = serde_json::from_str(r#"{"b":"2","a":"1"}"#).unwrap(); 225 + assert_eq!(canonicalize(&val), r#"{"a":"1","b":"2"}"#); 226 + } 227 + 228 + #[test] 229 + fn test_deeply_nested() { 230 + let val = json!({"a": {"b": {"c": {"d": "deep"}}}}); 231 + assert_eq!(canonicalize(&val), r#"{"a":{"b":{"c":{"d":"deep"}}}}"#); 232 + } 233 + 234 + #[test] 235 + fn test_no_trailing_whitespace() { 236 + let val = json!({"key": "value"}); 237 + let result = canonicalize(&val); 238 + assert!(!result.contains(' ')); 239 + assert!(!result.contains('\n')); 240 + assert!(!result.contains('\t')); 241 + } 242 + 243 + #[test] 244 + fn test_idempotent() { 245 + let val: Value = serde_json::from_str(r#"{"z":1,"a":2,"m":[3,4,{"x":5}]}"#).unwrap(); 246 + let first = canonicalize(&val); 247 + let reparsed: Value = serde_json::from_str(&first).unwrap(); 248 + let second = canonicalize(&reparsed); 249 + assert_eq!(first, second); 250 + } 251 + }
+1666
crates/atproto-identity/src/webvh/log.rs
··· 1 + //! JSONL log parsing and sequential entry validation for did:webvh. 2 + //! 3 + //! Processes the did.jsonl log file by parsing each line as a JSON log entry, 4 + //! merging parameters across entries, and verifying hash chains, SCID integrity, 5 + //! cryptographic proofs, pre-rotation constraints, and timestamp ordering. 6 + 7 + use std::collections::HashMap; 8 + 9 + use crate::errors::WebVHDIDError; 10 + use crate::model::Document; 11 + 12 + use super::model::{ 13 + LogEntry, MergedParameters, QueryParams, ResolutionMetadata, ResolvedLog, WitnessConfig, 14 + WitnessProofEntry, 15 + }; 16 + use super::proof::{verify_any_proof, verify_prerotation, verify_witness_proofs}; 17 + use super::scid::{validate_hash_algorithm, validate_scid_format, verify_scid, verify_version_id}; 18 + 19 + /// Known parameter keys recognized by did:webvh v1.0. 20 + const KNOWN_PARAMETERS: &[&str] = &[ 21 + "method", 22 + "scid", 23 + "updateKeys", 24 + "nextKeyHashes", 25 + "portable", 26 + "deactivated", 27 + "ttl", 28 + "witness", 29 + "watchers", 30 + ]; 31 + 32 + /// Processes a complete did:webvh log and returns the resolved DID document. 33 + /// 34 + /// Parses the JSONL body, validates each entry sequentially, and returns 35 + /// the final resolved state including the DID document and merged parameters. 36 + /// 37 + /// This is the simple entry point that does not verify witness proofs. 38 + /// Use [`process_log_with_witnesses`] when witness verification is required. 39 + pub fn process_log(did: &str, scid: &str, body: &str) -> Result<ResolvedLog, WebVHDIDError> { 40 + process_log_with_witnesses(did, scid, body, None) 41 + } 42 + 43 + /// Processes a complete did:webvh log with optional witness proof verification. 44 + /// 45 + /// When `witness_proofs` is `Some`, entries whose active parameters include a 46 + /// witness configuration will be verified against the provided witness proofs. 47 + /// Each witness proof entry is matched to a log entry by `versionId`. 48 + /// 49 + /// When `witness_proofs` is `None`, witness verification is skipped entirely. 50 + pub fn process_log_with_witnesses( 51 + did: &str, 52 + scid: &str, 53 + body: &str, 54 + witness_proofs: Option<&[WitnessProofEntry]>, 55 + ) -> Result<ResolvedLog, WebVHDIDError> { 56 + process_log_full(did, scid, body, witness_proofs, None) 57 + } 58 + 59 + /// Processes a complete did:webvh log with query parameters for historical resolution. 60 + /// 61 + /// Validates ALL entries in the chain, but returns the document and metadata 62 + /// from the entry matching the query parameters instead of the last entry. 63 + pub fn process_log_with_params( 64 + did: &str, 65 + scid: &str, 66 + body: &str, 67 + witness_proofs: Option<&[WitnessProofEntry]>, 68 + query_params: &QueryParams, 69 + ) -> Result<ResolvedLog, WebVHDIDError> { 70 + process_log_full(did, scid, body, witness_proofs, Some(query_params)) 71 + } 72 + 73 + /// Core log processing with all verification, witness support, and query params. 74 + /// 75 + /// Implements partial log validity: if later entries fail verification, earlier 76 + /// valid entries can still be returned via query parameters. The genesis entry 77 + /// must always be valid; failures there abort entirely. 78 + fn process_log_full( 79 + did: &str, 80 + scid: &str, 81 + body: &str, 82 + witness_proofs: Option<&[WitnessProofEntry]>, 83 + query_params: Option<&QueryParams>, 84 + ) -> Result<ResolvedLog, WebVHDIDError> { 85 + let entries = parse_log_entries(body)?; 86 + 87 + if entries.is_empty() { 88 + return Err(WebVHDIDError::EmptyLog); 89 + } 90 + 91 + let mut params = MergedParameters::default(); 92 + let mut prev_version_time: Option<&str> = None; 93 + let mut prev_next_key_hashes: Vec<String> = Vec::new(); 94 + let entry_count = entries.len(); 95 + let mut did_id_matched = false; 96 + // Track the last successfully validated entry index (0-based) 97 + let mut last_valid_index: Option<usize> = None; 98 + // Track the first validation error for entries after genesis 99 + let mut first_error: Option<WebVHDIDError> = None; 100 + // Track params snapshot at each valid entry for partial validity 101 + let mut last_valid_params: Option<MergedParameters> = None; 102 + 103 + for (i, entry) in entries.iter().enumerate() { 104 + let entry_number = i + 1; 105 + 106 + // Normalize null parameter values to defaults (spec: deprecated but SHOULD accept) 107 + let raw_entry = parse_raw_entry(body, i)?; 108 + let normalized_entry = normalize_null_parameters(entry); 109 + let active_entry = normalized_entry.as_ref().unwrap_or(entry); 110 + 111 + // Validate unknown parameters 112 + validate_known_parameters(&active_entry.parameters, entry_number)?; 113 + 114 + // Save witness config BEFORE merging this entry's params 115 + let witness_before_merge = params.witness.clone(); 116 + 117 + let entry_result: Result<(), WebVHDIDError> = (|| { 118 + if i == 0 { 119 + process_genesis_entry(did, scid, active_entry, &raw_entry, &mut params)?; 120 + did_id_matched = true; // genesis already verifies state.id == did 121 + } else { 122 + process_subsequent_entry( 123 + did, 124 + active_entry, 125 + &raw_entry, 126 + entry_number, 127 + &mut params, 128 + &prev_next_key_hashes, 129 + )?; 130 + 131 + // DIDDoc id match across versions 132 + let state_id = active_entry.state.get("id").and_then(|v| v.as_str()); 133 + if let Some(id) = state_id { 134 + if id == did { 135 + did_id_matched = true; 136 + } else if !params.portable { 137 + return Err(WebVHDIDError::DIDDocIdMismatch { 138 + entry: entry_number, 139 + expected: did.to_string(), 140 + found: id.to_string(), 141 + }); 142 + } 143 + } 144 + } 145 + 146 + // Verify witness proofs using the config that was active BEFORE this entry 147 + let effective_witness = if i == 0 { 148 + &params.witness 149 + } else { 150 + &witness_before_merge 151 + }; 152 + 153 + if let Some(witness_config) = effective_witness 154 + && let Some(wp) = witness_proofs 155 + { 156 + let matching_wp = wp.iter().find(|w| w.version_id == entry.version_id); 157 + match matching_wp { 158 + Some(wp_entry) => { 159 + verify_witness_proofs( 160 + &raw_entry, 161 + &wp_entry.proof, 162 + witness_config, 163 + entry_number, 164 + )?; 165 + } 166 + None => { 167 + return Err(WebVHDIDError::WitnessVerificationFailed { 168 + entry: entry_number, 169 + details: format!( 170 + "no witness proof found for version {}", 171 + entry.version_id 172 + ), 173 + }); 174 + } 175 + } 176 + } 177 + 178 + // Verify version time ordering 179 + if let Some(prev_time) = prev_version_time 180 + && entry.version_time.as_str() <= prev_time 181 + { 182 + return Err(WebVHDIDError::VersionTimeNotMonotonic { 183 + entry: entry_number, 184 + }); 185 + } 186 + 187 + Ok(()) 188 + })(); 189 + 190 + match entry_result { 191 + Ok(()) => { 192 + last_valid_index = Some(i); 193 + last_valid_params = Some(params.clone()); 194 + prev_version_time = Some(&entry.version_time); 195 + prev_next_key_hashes = params.next_key_hashes.clone(); 196 + } 197 + Err(e) => { 198 + if i == 0 { 199 + // Genesis entry MUST be valid — abort entirely 200 + return Err(e); 201 + } 202 + // For non-genesis entries, record the error and stop processing 203 + first_error = Some(e); 204 + break; 205 + } 206 + } 207 + } 208 + 209 + let last_valid_idx = last_valid_index.ok_or(WebVHDIDError::EmptyLog)?; 210 + let valid_params = last_valid_params.unwrap_or(params); 211 + 212 + // Verify version time bounds — last valid entry must not be in the future 213 + let last_valid_entry = &entries[last_valid_idx]; 214 + if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(&last_valid_entry.version_time) 215 + && last_time > chrono::Utc::now() 216 + { 217 + return Err(WebVHDIDError::VersionTimeInFuture { 218 + entry: last_valid_idx + 1, 219 + version_time: last_valid_entry.version_time.clone(), 220 + }); 221 + } 222 + 223 + // For portable DIDs, verify at least one entry had matching id 224 + if valid_params.portable && !did_id_matched { 225 + return Err(WebVHDIDError::DIDDocIdMismatch { 226 + entry: 0, 227 + expected: did.to_string(), 228 + found: "no matching DIDDoc id found in any entry".to_string(), 229 + }); 230 + } 231 + 232 + // Determine effective deactivation: explicit flag OR empty updateKeys 233 + let is_deactivated = valid_params.deactivated || valid_params.update_keys.is_empty(); 234 + 235 + // Build metadata from full chain 236 + let first_entry = &entries[0]; 237 + let created_time = first_entry.version_time.clone(); 238 + let updated_time = last_valid_entry.version_time.clone(); 239 + 240 + // Determine which entry to return based on query params 241 + // With partial validity, only entries up to last_valid_idx are selectable 242 + let valid_entries = &entries[..=last_valid_idx]; 243 + 244 + let (resolved_entry, resolved_number) = if let Some(qp) = query_params { 245 + select_entry_by_query(valid_entries, qp)? 246 + } else { 247 + // Default: return last valid entry, check deactivation 248 + if is_deactivated { 249 + return Err(WebVHDIDError::DeactivatedDID { 250 + did: did.to_string(), 251 + }); 252 + } 253 + // If there were invalid entries after the last valid one, return error 254 + if let Some(err) = first_error { 255 + return Err(err); 256 + } 257 + (last_valid_entry, (last_valid_idx + 1) as u64) 258 + }; 259 + 260 + let document: Document = serde_json::from_value(resolved_entry.state.clone()).map_err(|e| { 261 + WebVHDIDError::DocumentExtractionFailed { 262 + details: format!("failed to deserialize DID document: {}", e), 263 + } 264 + })?; 265 + 266 + let metadata = ResolutionMetadata { 267 + version_id: resolved_entry.version_id.clone(), 268 + version_time: resolved_entry.version_time.clone(), 269 + created: created_time, 270 + updated: updated_time, 271 + scid: valid_params.scid.clone(), 272 + portable: valid_params.portable, 273 + // Metadata MUST include deactivated: true when the DID is deactivated, 274 + // even when resolving a historical version via query params 275 + deactivated: is_deactivated, 276 + ttl: valid_params.ttl, 277 + witness: valid_params.witness.clone(), 278 + watchers: valid_params.watchers.clone(), 279 + extra: HashMap::new(), 280 + }; 281 + 282 + Ok(ResolvedLog { 283 + document, 284 + version_id: resolved_entry.version_id.clone(), 285 + version_number: resolved_number, 286 + version_time: resolved_entry.version_time.clone(), 287 + parameters: valid_params, 288 + entry_count, 289 + metadata, 290 + }) 291 + } 292 + 293 + /// Selects an entry from the log based on query parameters. 294 + /// 295 + /// Returns the matching entry and its version number (1-indexed). 296 + /// Only entries within the provided slice are selectable (supports partial validity). 297 + fn select_entry_by_query<'a>( 298 + entries: &'a [LogEntry], 299 + query_params: &QueryParams, 300 + ) -> Result<(&'a LogEntry, u64), WebVHDIDError> { 301 + if let Some(ref vid) = query_params.version_id { 302 + let pos = entries 303 + .iter() 304 + .position(|e| e.version_id == *vid) 305 + .ok_or_else(|| WebVHDIDError::VersionNotFound { 306 + details: format!("no entry with versionId '{}'", vid), 307 + })?; 308 + Ok((&entries[pos], pos as u64 + 1)) 309 + } else if let Some(ref vtime) = query_params.version_time { 310 + // Find latest entry with versionTime <= specified time 311 + let entry = entries 312 + .iter() 313 + .rev() 314 + .find(|e| e.version_time.as_str() <= vtime.as_str()) 315 + .ok_or_else(|| WebVHDIDError::VersionNotFound { 316 + details: format!("no entry active at time '{}'", vtime), 317 + })?; 318 + let number = entries 319 + .iter() 320 + .position(|e| e.version_id == entry.version_id) 321 + .unwrap() as u64 322 + + 1; 323 + Ok((entry, number)) 324 + } else if let Some(vnum) = query_params.version_number { 325 + if vnum == 0 || vnum as usize > entries.len() { 326 + return Err(WebVHDIDError::VersionNotFound { 327 + details: format!( 328 + "version number {} out of range (1..{})", 329 + vnum, 330 + entries.len() 331 + ), 332 + }); 333 + } 334 + let entry = &entries[vnum as usize - 1]; 335 + Ok((entry, vnum)) 336 + } else { 337 + // No query params — return last entry 338 + let last = entries.last().unwrap(); 339 + Ok((last, entries.len() as u64)) 340 + } 341 + } 342 + 343 + /// Normalizes JSON `null` parameter values to their spec defaults. 344 + /// 345 + /// The spec forbids `null` but says deprecated implementations SHOULD accept 346 + /// `null` and convert to the equivalent default. Returns `Some(normalized)` 347 + /// if any nulls were found and converted, or `None` if no changes needed. 348 + fn normalize_null_parameters(entry: &LogEntry) -> Option<LogEntry> { 349 + let params = entry.parameters.as_object()?; 350 + 351 + let has_nulls = params.values().any(|v| v.is_null()); 352 + if !has_nulls { 353 + return None; 354 + } 355 + 356 + let mut normalized = entry.clone(); 357 + let obj = normalized.parameters.as_object_mut().unwrap(); 358 + 359 + for (key, value) in obj.iter_mut() { 360 + if !value.is_null() { 361 + continue; 362 + } 363 + // Convert null to spec default for each known parameter 364 + *value = match key.as_str() { 365 + "portable" | "deactivated" => serde_json::Value::Bool(false), 366 + "ttl" => serde_json::json!(3600), 367 + "updateKeys" | "nextKeyHashes" | "watchers" => serde_json::json!([]), 368 + "witness" => serde_json::json!({}), 369 + // Unknown or string params: remove null by setting to empty object 370 + // (will be caught by unknown parameter validation if truly unknown) 371 + _ => serde_json::json!({}), 372 + }; 373 + } 374 + 375 + Some(normalized) 376 + } 377 + 378 + /// Validates that all parameter keys in an entry are recognized. 379 + fn validate_known_parameters( 380 + entry_params: &serde_json::Value, 381 + entry_number: usize, 382 + ) -> Result<(), WebVHDIDError> { 383 + if let Some(obj) = entry_params.as_object() { 384 + for key in obj.keys() { 385 + if !KNOWN_PARAMETERS.contains(&key.as_str()) { 386 + return Err(WebVHDIDError::UnknownParameter { 387 + entry: entry_number, 388 + parameter: key.clone(), 389 + }); 390 + } 391 + } 392 + } 393 + Ok(()) 394 + } 395 + 396 + /// Parses the JSONL body into a vector of LogEntry structs. 397 + fn parse_log_entries(body: &str) -> Result<Vec<LogEntry>, WebVHDIDError> { 398 + let mut entries = Vec::new(); 399 + for (i, line) in body.lines().enumerate() { 400 + let trimmed = line.trim(); 401 + if trimmed.is_empty() { 402 + continue; 403 + } 404 + let entry: LogEntry = 405 + serde_json::from_str(trimmed).map_err(|e| WebVHDIDError::LogEntryParseFailed { 406 + line: i + 1, 407 + details: e.to_string(), 408 + })?; 409 + entries.push(entry); 410 + } 411 + Ok(entries) 412 + } 413 + 414 + /// Parses a specific line from the JSONL body as a raw serde_json::Value. 415 + fn parse_raw_entry(body: &str, index: usize) -> Result<serde_json::Value, WebVHDIDError> { 416 + let line = body 417 + .lines() 418 + .filter(|l| !l.trim().is_empty()) 419 + .nth(index) 420 + .ok_or(WebVHDIDError::EmptyLog)?; 421 + 422 + serde_json::from_str(line.trim()).map_err(|e| WebVHDIDError::LogEntryParseFailed { 423 + line: index + 1, 424 + details: e.to_string(), 425 + }) 426 + } 427 + 428 + /// Processes and validates the genesis (first) log entry. 429 + fn process_genesis_entry( 430 + did: &str, 431 + scid: &str, 432 + entry: &LogEntry, 433 + raw_entry: &serde_json::Value, 434 + params: &mut MergedParameters, 435 + ) -> Result<(), WebVHDIDError> { 436 + let entry_params = &entry.parameters; 437 + 438 + // Validate required genesis parameters 439 + let method = entry_params.get("method").and_then(|v| v.as_str()).ok_or( 440 + WebVHDIDError::InvalidParameters { 441 + entry: 1, 442 + details: "missing required 'method' parameter in genesis entry".to_string(), 443 + }, 444 + )?; 445 + 446 + // Accept both 1.0 and 0.5 method versions 447 + if !method.starts_with("did:webvh:") { 448 + return Err(WebVHDIDError::InvalidParameters { 449 + entry: 1, 450 + details: format!("invalid method format: {}", method), 451 + }); 452 + } 453 + 454 + let entry_scid = entry_params.get("scid").and_then(|v| v.as_str()).ok_or( 455 + WebVHDIDError::InvalidParameters { 456 + entry: 1, 457 + details: "missing required 'scid' parameter in genesis entry".to_string(), 458 + }, 459 + )?; 460 + 461 + if entry_scid != scid { 462 + return Err(WebVHDIDError::InvalidParameters { 463 + entry: 1, 464 + details: format!( 465 + "SCID mismatch: DID contains '{}', entry has '{}'", 466 + scid, entry_scid 467 + ), 468 + }); 469 + } 470 + 471 + let update_keys = entry_params 472 + .get("updateKeys") 473 + .and_then(|v| v.as_array()) 474 + .ok_or(WebVHDIDError::InvalidParameters { 475 + entry: 1, 476 + details: "missing required 'updateKeys' parameter in genesis entry".to_string(), 477 + })?; 478 + 479 + if update_keys.is_empty() { 480 + return Err(WebVHDIDError::InvalidParameters { 481 + entry: 1, 482 + details: "updateKeys must not be empty in genesis entry".to_string(), 483 + }); 484 + } 485 + 486 + // Initialize merged parameters 487 + params.method = method.to_string(); 488 + params.scid = scid.to_string(); 489 + params.update_keys = update_keys 490 + .iter() 491 + .filter_map(|v| v.as_str().map(String::from)) 492 + .collect(); 493 + 494 + // Optional genesis parameters 495 + merge_optional_params(params, entry_params, true)?; 496 + 497 + // Validate SCID format (item 7) 498 + validate_scid_format(scid)?; 499 + 500 + // Verify SCID 501 + verify_scid(scid, raw_entry)?; 502 + 503 + // Validate hash algorithm matches method version (item 8) 504 + validate_hash_algorithm(scid, method)?; 505 + 506 + // Verify version ID (must be version 1) 507 + verify_version_id(raw_entry, 1, 1)?; 508 + 509 + // Verify proof 510 + verify_any_proof(raw_entry, &entry.proof, &params.update_keys, 1)?; 511 + 512 + // Verify state.id matches DID 513 + let state_id = entry.state.get("id").and_then(|v| v.as_str()); 514 + if state_id != Some(did) { 515 + return Err(WebVHDIDError::InvalidParameters { 516 + entry: 1, 517 + details: format!( 518 + "state.id '{}' does not match DID '{}'", 519 + state_id.unwrap_or("<missing>"), 520 + did 521 + ), 522 + }); 523 + } 524 + 525 + Ok(()) 526 + } 527 + 528 + /// Processes and validates a subsequent (non-genesis) log entry. 529 + fn process_subsequent_entry( 530 + _did: &str, 531 + entry: &LogEntry, 532 + raw_entry: &serde_json::Value, 533 + entry_number: usize, 534 + params: &mut MergedParameters, 535 + prev_next_key_hashes: &[String], 536 + ) -> Result<(), WebVHDIDError> { 537 + let entry_params = &entry.parameters; 538 + 539 + // SCID must NOT appear after genesis 540 + if entry_params.get("scid").is_some() { 541 + return Err(WebVHDIDError::InvalidParameters { 542 + entry: entry_number, 543 + details: "scid parameter must not appear after genesis entry".to_string(), 544 + }); 545 + } 546 + 547 + // Save previous update keys for proof verification 548 + let prev_update_keys = params.update_keys.clone(); 549 + 550 + // Merge parameters 551 + merge_entry_params(params, entry_params, entry_number)?; 552 + 553 + // Verify version ID 554 + verify_version_id(raw_entry, entry_number as u64, entry_number)?; 555 + 556 + // Verify pre-rotation if active 557 + if !prev_next_key_hashes.is_empty() { 558 + verify_prerotation(&params.update_keys, prev_next_key_hashes, entry_number)?; 559 + } 560 + 561 + // Verify proof 562 + // With pre-rotation: verify against current update keys (validated above) 563 + // Without pre-rotation: verify against previous update keys 564 + let verification_keys = if !prev_next_key_hashes.is_empty() { 565 + &params.update_keys 566 + } else { 567 + &prev_update_keys 568 + }; 569 + verify_any_proof(raw_entry, &entry.proof, verification_keys, entry_number)?; 570 + 571 + Ok(()) 572 + } 573 + 574 + /// Merges optional parameters from a genesis entry into the accumulated state. 575 + fn merge_optional_params( 576 + params: &mut MergedParameters, 577 + entry_params: &serde_json::Value, 578 + is_genesis: bool, 579 + ) -> Result<(), WebVHDIDError> { 580 + if let Some(next_key_hashes) = entry_params.get("nextKeyHashes").and_then(|v| v.as_array()) { 581 + params.next_key_hashes = next_key_hashes 582 + .iter() 583 + .filter_map(|v| v.as_str().map(String::from)) 584 + .collect(); 585 + } 586 + 587 + if let Some(portable) = entry_params.get("portable").and_then(|v| v.as_bool()) { 588 + if portable && !is_genesis { 589 + return Err(WebVHDIDError::InvalidParameters { 590 + entry: 1, 591 + details: "portable can only be set to true in genesis entry".to_string(), 592 + }); 593 + } 594 + params.portable = portable; 595 + } 596 + 597 + if let Some(deactivated) = entry_params.get("deactivated").and_then(|v| v.as_bool()) { 598 + params.deactivated = deactivated; 599 + } 600 + 601 + if let Some(ttl) = entry_params.get("ttl").and_then(|v| v.as_u64()) { 602 + params.ttl = ttl; 603 + } 604 + 605 + if let Some(witness) = entry_params.get("witness") { 606 + if witness.is_object() && !witness.as_object().unwrap().is_empty() { 607 + let config: WitnessConfig = serde_json::from_value(witness.clone()).map_err(|e| { 608 + WebVHDIDError::InvalidParameters { 609 + entry: 1, 610 + details: format!("invalid witness configuration: {}", e), 611 + } 612 + })?; 613 + params.witness = Some(config); 614 + } else { 615 + params.witness = None; 616 + } 617 + } 618 + 619 + if let Some(watchers) = entry_params.get("watchers").and_then(|v| v.as_array()) { 620 + params.watchers = watchers 621 + .iter() 622 + .filter_map(|v| v.as_str().map(String::from)) 623 + .collect(); 624 + } 625 + 626 + Ok(()) 627 + } 628 + 629 + /// Merges parameters from a subsequent entry into the accumulated state. 630 + fn merge_entry_params( 631 + params: &mut MergedParameters, 632 + entry_params: &serde_json::Value, 633 + entry_number: usize, 634 + ) -> Result<(), WebVHDIDError> { 635 + if let Some(method) = entry_params.get("method").and_then(|v| v.as_str()) { 636 + params.method = method.to_string(); 637 + } 638 + 639 + if let Some(update_keys) = entry_params.get("updateKeys").and_then(|v| v.as_array()) { 640 + params.update_keys = update_keys 641 + .iter() 642 + .filter_map(|v| v.as_str().map(String::from)) 643 + .collect(); 644 + } 645 + 646 + if let Some(next_key_hashes) = entry_params.get("nextKeyHashes").and_then(|v| v.as_array()) { 647 + params.next_key_hashes = next_key_hashes 648 + .iter() 649 + .filter_map(|v| v.as_str().map(String::from)) 650 + .collect(); 651 + } 652 + 653 + if let Some(portable) = entry_params.get("portable").and_then(|v| v.as_bool()) { 654 + if portable && !params.portable { 655 + return Err(WebVHDIDError::InvalidParameters { 656 + entry: entry_number, 657 + details: "portable cannot be changed from false to true after genesis".to_string(), 658 + }); 659 + } 660 + params.portable = portable; 661 + } 662 + 663 + if let Some(deactivated) = entry_params.get("deactivated").and_then(|v| v.as_bool()) { 664 + params.deactivated = deactivated; 665 + } 666 + 667 + if let Some(ttl) = entry_params.get("ttl").and_then(|v| v.as_u64()) { 668 + params.ttl = ttl; 669 + } 670 + 671 + if let Some(witness) = entry_params.get("witness") { 672 + if witness.is_object() && !witness.as_object().unwrap().is_empty() { 673 + let config: WitnessConfig = serde_json::from_value(witness.clone()).map_err(|e| { 674 + WebVHDIDError::InvalidParameters { 675 + entry: entry_number, 676 + details: format!("invalid witness configuration: {}", e), 677 + } 678 + })?; 679 + params.witness = Some(config); 680 + } else { 681 + params.witness = None; 682 + } 683 + } 684 + 685 + if let Some(watchers) = entry_params.get("watchers").and_then(|v| v.as_array()) { 686 + params.watchers = watchers 687 + .iter() 688 + .filter_map(|v| v.as_str().map(String::from)) 689 + .collect(); 690 + } 691 + 692 + Ok(()) 693 + } 694 + 695 + #[cfg(test)] 696 + mod tests { 697 + use super::super::jcs; 698 + use super::super::scid::compute_multihash_base58btc; 699 + use super::*; 700 + use crate::key::{KeyType, generate_key, to_public}; 701 + use serde_json::json; 702 + 703 + /// Helper: creates an Ed25519 keypair and returns (signing_key, multikey_string). 704 + fn make_ed25519_keypair() -> (ed25519_dalek::SigningKey, String) { 705 + let private_key = generate_key(KeyType::Ed25519Private).unwrap(); 706 + let public_key = to_public(&private_key).unwrap(); 707 + let did_key = format!("{}", &public_key); 708 + let multikey = did_key.strip_prefix("did:key:").unwrap().to_string(); 709 + let signing_key = 710 + ed25519_dalek::SigningKey::from_bytes(private_key.bytes().try_into().unwrap()); 711 + (signing_key, multikey) 712 + } 713 + 714 + /// Helper: signs a log entry and returns a DataIntegrityProof JSON value. 715 + fn sign_entry( 716 + signing_key: &ed25519_dalek::SigningKey, 717 + multikey: &str, 718 + entry: &serde_json::Value, 719 + ) -> serde_json::Value { 720 + let mut without_proof = entry.clone(); 721 + without_proof.as_object_mut().unwrap().remove("proof"); 722 + let canonical = jcs::canonicalize(&without_proof); 723 + let signature = ed25519_dalek::Signer::sign(signing_key, canonical.as_bytes()); 724 + let proof_value = multibase::encode(multibase::Base::Base58Btc, signature.to_bytes()); 725 + 726 + json!({ 727 + "type": "DataIntegrityProof", 728 + "cryptosuite": "eddsa-jcs-2022", 729 + "verificationMethod": format!("did:key:{}#{}", multikey, multikey), 730 + "created": "2025-04-29T17:15:59Z", 731 + "proofPurpose": "assertionMethod", 732 + "proofValue": proof_value 733 + }) 734 + } 735 + 736 + /// Helper: builds and signs a valid genesis entry. 737 + fn build_genesis_entry( 738 + signing_key: &ed25519_dalek::SigningKey, 739 + multikey: &str, 740 + ) -> (String, serde_json::Value) { 741 + // Step 1: create preliminary entry with {SCID} placeholder 742 + let preliminary = json!({ 743 + "versionId": "{SCID}", 744 + "versionTime": "2025-01-01T00:00:00Z", 745 + "parameters": { 746 + "method": "did:webvh:1.0", 747 + "scid": "{SCID}", 748 + "updateKeys": [multikey] 749 + }, 750 + "state": { 751 + "@context": ["https://www.w3.org/ns/did/v1"], 752 + "id": "did:webvh:{SCID}:example.com" 753 + } 754 + }); 755 + 756 + // Step 2: compute SCID 757 + let canonical = jcs::canonicalize(&preliminary); 758 + let scid = compute_multihash_base58btc(canonical.as_bytes()); 759 + 760 + // Step 3: replace {SCID} with actual value 761 + let json_str = serde_json::to_string(&preliminary).unwrap(); 762 + let replaced = json_str.replace("{SCID}", &scid); 763 + let mut entry: serde_json::Value = serde_json::from_str(&replaced).unwrap(); 764 + 765 + // Step 4: compute entry hash (versionId is excluded from hash) 766 + let entry_hash = super::super::scid::compute_entry_hash(&entry).unwrap(); 767 + entry["versionId"] = json!(format!("1-{}", entry_hash)); 768 + 769 + // Step 5: sign (proof is computed over entry without proof, without versionId) 770 + let proof = sign_entry(signing_key, multikey, &entry); 771 + entry["proof"] = json!([proof]); 772 + 773 + (scid, entry) 774 + } 775 + 776 + #[test] 777 + fn test_parse_log_entries_single() { 778 + let body = r#"{"versionId":"1-test","versionTime":"2025-01-01T00:00:00Z","parameters":{},"state":{},"proof":[]}"#; 779 + let entries = parse_log_entries(body).unwrap(); 780 + assert_eq!(entries.len(), 1); 781 + assert_eq!(entries[0].version_id, "1-test"); 782 + } 783 + 784 + #[test] 785 + fn test_parse_log_entries_multiple() { 786 + let body = concat!( 787 + r#"{"versionId":"1-a","versionTime":"2025-01-01T00:00:00Z","parameters":{},"state":{},"proof":[]}"#, 788 + "\n", 789 + r#"{"versionId":"2-b","versionTime":"2025-01-02T00:00:00Z","parameters":{},"state":{},"proof":[]}"#, 790 + ); 791 + let entries = parse_log_entries(body).unwrap(); 792 + assert_eq!(entries.len(), 2); 793 + } 794 + 795 + #[test] 796 + fn test_parse_log_entries_empty() { 797 + let entries = parse_log_entries("").unwrap(); 798 + assert!(entries.is_empty()); 799 + 800 + let entries = parse_log_entries("\n\n").unwrap(); 801 + assert!(entries.is_empty()); 802 + } 803 + 804 + #[test] 805 + fn test_parse_log_entries_invalid_json() { 806 + let body = "not json"; 807 + let result = parse_log_entries(body); 808 + assert!(matches!( 809 + result, 810 + Err(WebVHDIDError::LogEntryParseFailed { .. }) 811 + )); 812 + } 813 + 814 + #[test] 815 + fn test_process_log_empty() { 816 + let result = process_log("did:webvh:test:example.com", "test", ""); 817 + assert!(matches!(result, Err(WebVHDIDError::EmptyLog))); 818 + } 819 + 820 + #[test] 821 + fn test_process_log_valid_genesis() { 822 + let (signing_key, multikey) = make_ed25519_keypair(); 823 + let (scid, entry) = build_genesis_entry(&signing_key, &multikey); 824 + 825 + let did = format!("did:webvh:{}:example.com", scid); 826 + let body = serde_json::to_string(&entry).unwrap(); 827 + 828 + let result = process_log(&did, &scid, &body); 829 + assert!(result.is_ok(), "process_log failed: {:?}", result); 830 + 831 + let resolved = result.unwrap(); 832 + assert_eq!(resolved.version_number, 1); 833 + assert_eq!(resolved.entry_count, 1); 834 + assert_eq!(resolved.parameters.scid, scid); 835 + assert_eq!(resolved.parameters.method, "did:webvh:1.0"); 836 + } 837 + 838 + #[test] 839 + fn test_process_log_non_monotonic_time() { 840 + let (signing_key, multikey) = make_ed25519_keypair(); 841 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 842 + let did = format!("did:webvh:{}:example.com", scid); 843 + 844 + // Build second entry with earlier timestamp 845 + let mut entry2 = json!({ 846 + "versionTime": "2024-01-01T00:00:00Z", // Earlier than genesis 847 + "parameters": {}, 848 + "state": { 849 + "@context": ["https://www.w3.org/ns/did/v1"], 850 + "id": &did 851 + } 852 + }); 853 + 854 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 855 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 856 + 857 + let proof = sign_entry(&signing_key, &multikey, &entry2); 858 + entry2["proof"] = json!([proof]); 859 + 860 + let body = format!( 861 + "{}\n{}", 862 + serde_json::to_string(&genesis).unwrap(), 863 + serde_json::to_string(&entry2).unwrap() 864 + ); 865 + 866 + let result = process_log(&did, &scid, &body); 867 + assert!(matches!( 868 + result, 869 + Err(WebVHDIDError::VersionTimeNotMonotonic { .. }) 870 + )); 871 + } 872 + 873 + #[test] 874 + fn test_process_log_deactivated() { 875 + let (signing_key, multikey) = make_ed25519_keypair(); 876 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 877 + let did = format!("did:webvh:{}:example.com", scid); 878 + 879 + // Build second entry that deactivates 880 + let mut entry2 = json!({ 881 + "versionTime": "2025-06-01T00:00:00Z", 882 + "parameters": {"deactivated": true}, 883 + "state": { 884 + "@context": ["https://www.w3.org/ns/did/v1"], 885 + "id": &did 886 + } 887 + }); 888 + 889 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 890 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 891 + 892 + let proof = sign_entry(&signing_key, &multikey, &entry2); 893 + entry2["proof"] = json!([proof]); 894 + 895 + let body = format!( 896 + "{}\n{}", 897 + serde_json::to_string(&genesis).unwrap(), 898 + serde_json::to_string(&entry2).unwrap() 899 + ); 900 + 901 + let result = process_log(&did, &scid, &body); 902 + assert!(matches!(result, Err(WebVHDIDError::DeactivatedDID { .. }))); 903 + } 904 + 905 + #[test] 906 + fn test_process_log_missing_method() { 907 + let (_signing_key, multikey) = make_ed25519_keypair(); 908 + 909 + let entry = json!({ 910 + "versionId": "1-test", 911 + "versionTime": "2025-01-01T00:00:00Z", 912 + "parameters": { 913 + "scid": "test", 914 + "updateKeys": [&multikey] 915 + }, 916 + "state": { 917 + "@context": ["https://www.w3.org/ns/did/v1"], 918 + "id": "did:webvh:test:example.com" 919 + }, 920 + "proof": [] 921 + }); 922 + 923 + let body = serde_json::to_string(&entry).unwrap(); 924 + let result = process_log("did:webvh:test:example.com", "test", &body); 925 + assert!(matches!( 926 + result, 927 + Err(WebVHDIDError::InvalidParameters { .. }) 928 + )); 929 + } 930 + 931 + #[test] 932 + fn test_process_log_missing_update_keys() { 933 + let entry = json!({ 934 + "versionId": "1-test", 935 + "versionTime": "2025-01-01T00:00:00Z", 936 + "parameters": { 937 + "method": "did:webvh:1.0", 938 + "scid": "test" 939 + }, 940 + "state": { 941 + "@context": ["https://www.w3.org/ns/did/v1"], 942 + "id": "did:webvh:test:example.com" 943 + }, 944 + "proof": [] 945 + }); 946 + 947 + let body = serde_json::to_string(&entry).unwrap(); 948 + let result = process_log("did:webvh:test:example.com", "test", &body); 949 + assert!(matches!( 950 + result, 951 + Err(WebVHDIDError::InvalidParameters { .. }) 952 + )); 953 + } 954 + 955 + #[test] 956 + fn test_merge_entry_params_carry_forward() { 957 + let mut params = MergedParameters { 958 + method: "did:webvh:1.0".to_string(), 959 + scid: "test".to_string(), 960 + update_keys: vec!["key1".to_string()], 961 + ttl: 3600, 962 + ..Default::default() 963 + }; 964 + 965 + // Empty parameters should not change anything 966 + let empty = json!({}); 967 + merge_entry_params(&mut params, &empty, 2).unwrap(); 968 + assert_eq!(params.method, "did:webvh:1.0"); 969 + assert_eq!(params.update_keys, vec!["key1"]); 970 + assert_eq!(params.ttl, 3600); 971 + 972 + // Update only TTL 973 + let update = json!({"ttl": 7200}); 974 + merge_entry_params(&mut params, &update, 3).unwrap(); 975 + assert_eq!(params.ttl, 7200); 976 + assert_eq!(params.update_keys, vec!["key1"]); // unchanged 977 + } 978 + 979 + #[test] 980 + fn test_merge_entry_params_portable_constraint() { 981 + let mut params = MergedParameters::default(); 982 + params.portable = false; 983 + 984 + let update = json!({"portable": true}); 985 + let result = merge_entry_params(&mut params, &update, 2); 986 + assert!(matches!( 987 + result, 988 + Err(WebVHDIDError::InvalidParameters { .. }) 989 + )); 990 + } 991 + 992 + #[test] 993 + fn test_process_log_scid_in_subsequent_entry() { 994 + let (signing_key, multikey) = make_ed25519_keypair(); 995 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 996 + let did = format!("did:webvh:{}:example.com", scid); 997 + 998 + // Second entry with SCID (invalid) 999 + let mut entry2 = json!({ 1000 + "versionTime": "2025-06-01T00:00:00Z", 1001 + "parameters": {"scid": "should-not-be-here"}, 1002 + "state": { 1003 + "@context": ["https://www.w3.org/ns/did/v1"], 1004 + "id": &did 1005 + } 1006 + }); 1007 + 1008 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1009 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1010 + 1011 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1012 + entry2["proof"] = json!([proof]); 1013 + 1014 + let body = format!( 1015 + "{}\n{}", 1016 + serde_json::to_string(&genesis).unwrap(), 1017 + serde_json::to_string(&entry2).unwrap() 1018 + ); 1019 + 1020 + let result = process_log(&did, &scid, &body); 1021 + assert!(matches!( 1022 + result, 1023 + Err(WebVHDIDError::InvalidParameters { entry: 2, .. }) 1024 + )); 1025 + } 1026 + 1027 + /// Helper: builds and signs a genesis entry with witness configuration. 1028 + fn build_genesis_entry_with_witnesses( 1029 + signing_key: &ed25519_dalek::SigningKey, 1030 + multikey: &str, 1031 + witness_config: &serde_json::Value, 1032 + ) -> (String, serde_json::Value) { 1033 + let preliminary = json!({ 1034 + "versionId": "{SCID}", 1035 + "versionTime": "2025-01-01T00:00:00Z", 1036 + "parameters": { 1037 + "method": "did:webvh:1.0", 1038 + "scid": "{SCID}", 1039 + "updateKeys": [multikey], 1040 + "witness": witness_config 1041 + }, 1042 + "state": { 1043 + "@context": ["https://www.w3.org/ns/did/v1"], 1044 + "id": "did:webvh:{SCID}:example.com" 1045 + } 1046 + }); 1047 + 1048 + let canonical = jcs::canonicalize(&preliminary); 1049 + let scid = compute_multihash_base58btc(canonical.as_bytes()); 1050 + 1051 + let json_str = serde_json::to_string(&preliminary).unwrap(); 1052 + let replaced = json_str.replace("{SCID}", &scid); 1053 + let mut entry: serde_json::Value = serde_json::from_str(&replaced).unwrap(); 1054 + 1055 + let entry_hash = super::super::scid::compute_entry_hash(&entry).unwrap(); 1056 + entry["versionId"] = json!(format!("1-{}", entry_hash)); 1057 + 1058 + let proof = sign_entry(signing_key, multikey, &entry); 1059 + entry["proof"] = json!([proof]); 1060 + 1061 + (scid, entry) 1062 + } 1063 + 1064 + /// Helper: signs a witness proof for a log entry. 1065 + fn sign_witness_proof( 1066 + signing_key: &ed25519_dalek::SigningKey, 1067 + multikey: &str, 1068 + entry: &serde_json::Value, 1069 + ) -> serde_json::Value { 1070 + let mut without_proof = entry.clone(); 1071 + without_proof.as_object_mut().unwrap().remove("proof"); 1072 + let canonical = jcs::canonicalize(&without_proof); 1073 + let signature = ed25519_dalek::Signer::sign(signing_key, canonical.as_bytes()); 1074 + let proof_value = multibase::encode(multibase::Base::Base58Btc, signature.to_bytes()); 1075 + 1076 + json!({ 1077 + "type": "DataIntegrityProof", 1078 + "cryptosuite": "eddsa-jcs-2022", 1079 + "verificationMethod": format!("did:key:{}#{}", multikey, multikey), 1080 + "created": "2025-04-29T17:15:59Z", 1081 + "proofPurpose": "assertionMethod", 1082 + "proofValue": proof_value 1083 + }) 1084 + } 1085 + 1086 + #[test] 1087 + fn test_process_log_with_witnesses_valid() { 1088 + let (signing_key, multikey) = make_ed25519_keypair(); 1089 + let (witness_key, witness_multikey) = make_ed25519_keypair(); 1090 + 1091 + let witness_config = json!({ 1092 + "threshold": 1, 1093 + "witnesses": [{"id": format!("did:key:{}", witness_multikey), "weight": 1}] 1094 + }); 1095 + 1096 + let (scid, genesis) = 1097 + build_genesis_entry_with_witnesses(&signing_key, &multikey, &witness_config); 1098 + let did = format!("did:webvh:{}:example.com", scid); 1099 + 1100 + // Create witness proof for genesis 1101 + let witness_proof = sign_witness_proof(&witness_key, &witness_multikey, &genesis); 1102 + let version_id = genesis["versionId"].as_str().unwrap().to_string(); 1103 + 1104 + let witness_proofs = vec![super::super::model::WitnessProofEntry { 1105 + version_id, 1106 + proof: vec![serde_json::from_value(witness_proof).unwrap()], 1107 + }]; 1108 + 1109 + let body = serde_json::to_string(&genesis).unwrap(); 1110 + let result = process_log_with_witnesses(&did, &scid, &body, Some(&witness_proofs)); 1111 + assert!( 1112 + result.is_ok(), 1113 + "witness log processing failed: {:?}", 1114 + result 1115 + ); 1116 + } 1117 + 1118 + #[test] 1119 + fn test_process_log_with_witnesses_missing_proof() { 1120 + let (signing_key, multikey) = make_ed25519_keypair(); 1121 + let (_, witness_multikey) = make_ed25519_keypair(); 1122 + 1123 + let witness_config = json!({ 1124 + "threshold": 1, 1125 + "witnesses": [{"id": format!("did:key:{}", witness_multikey), "weight": 1}] 1126 + }); 1127 + 1128 + let (scid, genesis) = 1129 + build_genesis_entry_with_witnesses(&signing_key, &multikey, &witness_config); 1130 + let did = format!("did:webvh:{}:example.com", scid); 1131 + 1132 + // Provide empty witness proofs 1133 + let witness_proofs: Vec<super::super::model::WitnessProofEntry> = vec![]; 1134 + 1135 + let body = serde_json::to_string(&genesis).unwrap(); 1136 + let result = process_log_with_witnesses(&did, &scid, &body, Some(&witness_proofs)); 1137 + assert!( 1138 + matches!(result, Err(WebVHDIDError::WitnessVerificationFailed { .. })), 1139 + "expected WitnessVerificationFailed, got: {:?}", 1140 + result 1141 + ); 1142 + } 1143 + 1144 + #[test] 1145 + fn test_process_log_with_witnesses_threshold_not_met() { 1146 + let (signing_key, multikey) = make_ed25519_keypair(); 1147 + let (witness_key, witness_multikey) = make_ed25519_keypair(); 1148 + 1149 + let witness_config = json!({ 1150 + "threshold": 5, // Requires weight 5, but witness only has weight 1 1151 + "witnesses": [{"id": format!("did:key:{}", witness_multikey), "weight": 1}] 1152 + }); 1153 + 1154 + let (scid, genesis) = 1155 + build_genesis_entry_with_witnesses(&signing_key, &multikey, &witness_config); 1156 + let did = format!("did:webvh:{}:example.com", scid); 1157 + 1158 + let witness_proof = sign_witness_proof(&witness_key, &witness_multikey, &genesis); 1159 + let version_id = genesis["versionId"].as_str().unwrap().to_string(); 1160 + 1161 + let witness_proofs = vec![super::super::model::WitnessProofEntry { 1162 + version_id, 1163 + proof: vec![serde_json::from_value(witness_proof).unwrap()], 1164 + }]; 1165 + 1166 + let body = serde_json::to_string(&genesis).unwrap(); 1167 + let result = process_log_with_witnesses(&did, &scid, &body, Some(&witness_proofs)); 1168 + assert!( 1169 + matches!(result, Err(WebVHDIDError::WitnessVerificationFailed { .. })), 1170 + "expected WitnessVerificationFailed, got: {:?}", 1171 + result 1172 + ); 1173 + } 1174 + 1175 + #[test] 1176 + fn test_process_log_with_witnesses_no_witness_data_skips_verification() { 1177 + let (signing_key, multikey) = make_ed25519_keypair(); 1178 + let (_, witness_multikey) = make_ed25519_keypair(); 1179 + 1180 + let witness_config = json!({ 1181 + "threshold": 1, 1182 + "witnesses": [{"id": format!("did:key:{}", witness_multikey), "weight": 1}] 1183 + }); 1184 + 1185 + let (scid, genesis) = 1186 + build_genesis_entry_with_witnesses(&signing_key, &multikey, &witness_config); 1187 + let did = format!("did:webvh:{}:example.com", scid); 1188 + 1189 + // Pass None for witness_proofs — verification should be skipped 1190 + let body = serde_json::to_string(&genesis).unwrap(); 1191 + let result = process_log_with_witnesses(&did, &scid, &body, None); 1192 + assert!( 1193 + result.is_ok(), 1194 + "should skip witness verification when no data: {:?}", 1195 + result 1196 + ); 1197 + } 1198 + 1199 + #[test] 1200 + fn test_process_log_with_witnesses_multi_entry() { 1201 + let (signing_key, multikey) = make_ed25519_keypair(); 1202 + let (witness_key, witness_multikey) = make_ed25519_keypair(); 1203 + 1204 + let witness_config = json!({ 1205 + "threshold": 1, 1206 + "witnesses": [{"id": format!("did:key:{}", witness_multikey), "weight": 1}] 1207 + }); 1208 + 1209 + let (scid, genesis) = 1210 + build_genesis_entry_with_witnesses(&signing_key, &multikey, &witness_config); 1211 + let did = format!("did:webvh:{}:example.com", scid); 1212 + 1213 + // Build second entry 1214 + let mut entry2 = json!({ 1215 + "versionTime": "2025-06-01T00:00:00Z", 1216 + "parameters": {}, 1217 + "state": { 1218 + "@context": ["https://www.w3.org/ns/did/v1"], 1219 + "id": &did 1220 + } 1221 + }); 1222 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1223 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1224 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1225 + entry2["proof"] = json!([proof]); 1226 + 1227 + // Create witness proofs for both entries 1228 + let wp1 = sign_witness_proof(&witness_key, &witness_multikey, &genesis); 1229 + let wp2 = sign_witness_proof(&witness_key, &witness_multikey, &entry2); 1230 + 1231 + let witness_proofs = vec![ 1232 + super::super::model::WitnessProofEntry { 1233 + version_id: genesis["versionId"].as_str().unwrap().to_string(), 1234 + proof: vec![serde_json::from_value(wp1).unwrap()], 1235 + }, 1236 + super::super::model::WitnessProofEntry { 1237 + version_id: entry2["versionId"].as_str().unwrap().to_string(), 1238 + proof: vec![serde_json::from_value(wp2).unwrap()], 1239 + }, 1240 + ]; 1241 + 1242 + let body = format!( 1243 + "{}\n{}", 1244 + serde_json::to_string(&genesis).unwrap(), 1245 + serde_json::to_string(&entry2).unwrap() 1246 + ); 1247 + 1248 + let result = process_log_with_witnesses(&did, &scid, &body, Some(&witness_proofs)); 1249 + assert!( 1250 + result.is_ok(), 1251 + "multi-entry witness verification failed: {:?}", 1252 + result 1253 + ); 1254 + assert_eq!(result.unwrap().version_number, 2); 1255 + } 1256 + 1257 + #[test] 1258 + fn test_process_log_with_witnesses_second_entry_missing_proof() { 1259 + let (signing_key, multikey) = make_ed25519_keypair(); 1260 + let (witness_key, witness_multikey) = make_ed25519_keypair(); 1261 + 1262 + let witness_config = json!({ 1263 + "threshold": 1, 1264 + "witnesses": [{"id": format!("did:key:{}", witness_multikey), "weight": 1}] 1265 + }); 1266 + 1267 + let (scid, genesis) = 1268 + build_genesis_entry_with_witnesses(&signing_key, &multikey, &witness_config); 1269 + let did = format!("did:webvh:{}:example.com", scid); 1270 + 1271 + // Build second entry 1272 + let mut entry2 = json!({ 1273 + "versionTime": "2025-06-01T00:00:00Z", 1274 + "parameters": {}, 1275 + "state": { 1276 + "@context": ["https://www.w3.org/ns/did/v1"], 1277 + "id": &did 1278 + } 1279 + }); 1280 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1281 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1282 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1283 + entry2["proof"] = json!([proof]); 1284 + 1285 + // Only provide witness proof for genesis, not entry 2 1286 + let wp1 = sign_witness_proof(&witness_key, &witness_multikey, &genesis); 1287 + let witness_proofs = vec![super::super::model::WitnessProofEntry { 1288 + version_id: genesis["versionId"].as_str().unwrap().to_string(), 1289 + proof: vec![serde_json::from_value(wp1).unwrap()], 1290 + }]; 1291 + 1292 + let body = format!( 1293 + "{}\n{}", 1294 + serde_json::to_string(&genesis).unwrap(), 1295 + serde_json::to_string(&entry2).unwrap() 1296 + ); 1297 + 1298 + let result = process_log_with_witnesses(&did, &scid, &body, Some(&witness_proofs)); 1299 + assert!( 1300 + matches!( 1301 + result, 1302 + Err(WebVHDIDError::WitnessVerificationFailed { entry: 2, .. }) 1303 + ), 1304 + "expected WitnessVerificationFailed for entry 2, got: {:?}", 1305 + result 1306 + ); 1307 + } 1308 + 1309 + #[test] 1310 + fn test_process_log_two_entries() { 1311 + let (signing_key, multikey) = make_ed25519_keypair(); 1312 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 1313 + let did = format!("did:webvh:{}:example.com", scid); 1314 + 1315 + // Build valid second entry 1316 + let mut entry2 = json!({ 1317 + "versionTime": "2025-06-01T00:00:00Z", 1318 + "parameters": {}, 1319 + "state": { 1320 + "@context": ["https://www.w3.org/ns/did/v1"], 1321 + "id": &did, 1322 + "service": [{ 1323 + "id": format!("{}#domain", &did), 1324 + "type": "LinkedDomains", 1325 + "serviceEndpoint": "https://example.com" 1326 + }] 1327 + } 1328 + }); 1329 + 1330 + // Compute correct versionId 1331 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1332 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1333 + 1334 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1335 + entry2["proof"] = json!([proof]); 1336 + 1337 + let body = format!( 1338 + "{}\n{}", 1339 + serde_json::to_string(&genesis).unwrap(), 1340 + serde_json::to_string(&entry2).unwrap() 1341 + ); 1342 + 1343 + let result = process_log(&did, &scid, &body); 1344 + assert!(result.is_ok(), "process_log failed: {:?}", result); 1345 + 1346 + let resolved = result.unwrap(); 1347 + assert_eq!(resolved.version_number, 2); 1348 + assert_eq!(resolved.entry_count, 2); 1349 + assert!(!resolved.document.service.is_empty()); 1350 + } 1351 + 1352 + // ======================================================================== 1353 + // Gap 1: JSON null parameter handling 1354 + // ======================================================================== 1355 + 1356 + #[test] 1357 + fn test_null_parameter_normalization() { 1358 + let entry = LogEntry { 1359 + version_id: "1-test".to_string(), 1360 + version_time: "2025-01-01T00:00:00Z".to_string(), 1361 + parameters: json!({ 1362 + "method": "did:webvh:1.0", 1363 + "scid": "test", 1364 + "updateKeys": ["key1"], 1365 + "deactivated": null, 1366 + "ttl": null, 1367 + "portable": null 1368 + }), 1369 + state: json!({}), 1370 + proof: vec![], 1371 + }; 1372 + 1373 + let normalized = normalize_null_parameters(&entry).unwrap(); 1374 + let params = normalized.parameters.as_object().unwrap(); 1375 + 1376 + assert_eq!(params["deactivated"], json!(false)); 1377 + assert_eq!(params["ttl"], json!(3600)); 1378 + assert_eq!(params["portable"], json!(false)); 1379 + } 1380 + 1381 + #[test] 1382 + fn test_null_parameter_array_defaults() { 1383 + let entry = LogEntry { 1384 + version_id: "1-test".to_string(), 1385 + version_time: "2025-01-01T00:00:00Z".to_string(), 1386 + parameters: json!({ 1387 + "updateKeys": null, 1388 + "nextKeyHashes": null, 1389 + "watchers": null 1390 + }), 1391 + state: json!({}), 1392 + proof: vec![], 1393 + }; 1394 + 1395 + let normalized = normalize_null_parameters(&entry).unwrap(); 1396 + let params = normalized.parameters.as_object().unwrap(); 1397 + 1398 + assert_eq!(params["updateKeys"], json!([])); 1399 + assert_eq!(params["nextKeyHashes"], json!([])); 1400 + assert_eq!(params["watchers"], json!([])); 1401 + } 1402 + 1403 + #[test] 1404 + fn test_null_witness_defaults_to_empty_object() { 1405 + let entry = LogEntry { 1406 + version_id: "1-test".to_string(), 1407 + version_time: "2025-01-01T00:00:00Z".to_string(), 1408 + parameters: json!({ "witness": null }), 1409 + state: json!({}), 1410 + proof: vec![], 1411 + }; 1412 + 1413 + let normalized = normalize_null_parameters(&entry).unwrap(); 1414 + let params = normalized.parameters.as_object().unwrap(); 1415 + assert_eq!(params["witness"], json!({})); 1416 + } 1417 + 1418 + #[test] 1419 + fn test_no_normalization_when_no_nulls() { 1420 + let entry = LogEntry { 1421 + version_id: "1-test".to_string(), 1422 + version_time: "2025-01-01T00:00:00Z".to_string(), 1423 + parameters: json!({ "ttl": 7200 }), 1424 + state: json!({}), 1425 + proof: vec![], 1426 + }; 1427 + 1428 + assert!(normalize_null_parameters(&entry).is_none()); 1429 + } 1430 + 1431 + // ======================================================================== 1432 + // Gap 2: Partial log validity 1433 + // ======================================================================== 1434 + 1435 + #[test] 1436 + fn test_partial_validity_query_returns_valid_entry() { 1437 + let (signing_key, multikey) = make_ed25519_keypair(); 1438 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 1439 + let did = format!("did:webvh:{}:example.com", scid); 1440 + 1441 + // Build second entry with non-monotonic time (invalid) 1442 + let mut entry2 = json!({ 1443 + "versionTime": "2024-01-01T00:00:00Z", // Earlier than genesis = invalid 1444 + "parameters": {}, 1445 + "state": { 1446 + "@context": ["https://www.w3.org/ns/did/v1"], 1447 + "id": &did 1448 + } 1449 + }); 1450 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1451 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1452 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1453 + entry2["proof"] = json!([proof]); 1454 + 1455 + let body = format!( 1456 + "{}\n{}", 1457 + serde_json::to_string(&genesis).unwrap(), 1458 + serde_json::to_string(&entry2).unwrap() 1459 + ); 1460 + 1461 + // Without query params, should fail because the invalid entry is the last 1462 + let result = process_log(&did, &scid, &body); 1463 + assert!(result.is_err()); 1464 + 1465 + // With query params pointing to version 1, should succeed (partial validity) 1466 + let qp = QueryParams { 1467 + version_number: Some(1), 1468 + ..Default::default() 1469 + }; 1470 + let result = process_log_with_params(&did, &scid, &body, None, &qp); 1471 + assert!( 1472 + result.is_ok(), 1473 + "partial validity should return version 1: {:?}", 1474 + result 1475 + ); 1476 + assert_eq!(result.unwrap().version_number, 1); 1477 + } 1478 + 1479 + #[test] 1480 + fn test_partial_validity_genesis_failure_aborts() { 1481 + // If genesis entry is invalid, partial validity doesn't help 1482 + let entry = json!({ 1483 + "versionId": "1-test", 1484 + "versionTime": "2025-01-01T00:00:00Z", 1485 + "parameters": { 1486 + "scid": "test" 1487 + // missing method and updateKeys 1488 + }, 1489 + "state": { "id": "did:webvh:test:example.com" }, 1490 + "proof": [] 1491 + }); 1492 + 1493 + let body = serde_json::to_string(&entry).unwrap(); 1494 + let qp = QueryParams { 1495 + version_number: Some(1), 1496 + ..Default::default() 1497 + }; 1498 + let result = 1499 + process_log_with_params("did:webvh:test:example.com", "test", &body, None, &qp); 1500 + assert!(result.is_err(), "genesis failure should always abort"); 1501 + } 1502 + 1503 + // ======================================================================== 1504 + // Gap 3: Deactivated DID metadata on historical queries 1505 + // ======================================================================== 1506 + 1507 + #[test] 1508 + fn test_deactivated_metadata_on_historical_query() { 1509 + let (signing_key, multikey) = make_ed25519_keypair(); 1510 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 1511 + let did = format!("did:webvh:{}:example.com", scid); 1512 + 1513 + // Build second entry that deactivates 1514 + let mut entry2 = json!({ 1515 + "versionTime": "2025-06-01T00:00:00Z", 1516 + "parameters": {"deactivated": true}, 1517 + "state": { 1518 + "@context": ["https://www.w3.org/ns/did/v1"], 1519 + "id": &did 1520 + } 1521 + }); 1522 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1523 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1524 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1525 + entry2["proof"] = json!([proof]); 1526 + 1527 + let body = format!( 1528 + "{}\n{}", 1529 + serde_json::to_string(&genesis).unwrap(), 1530 + serde_json::to_string(&entry2).unwrap() 1531 + ); 1532 + 1533 + // Query for version 1 (before deactivation) — should succeed 1534 + // but metadata MUST include deactivated: true 1535 + let qp = QueryParams { 1536 + version_number: Some(1), 1537 + ..Default::default() 1538 + }; 1539 + let result = process_log_with_params(&did, &scid, &body, None, &qp); 1540 + assert!( 1541 + result.is_ok(), 1542 + "historical query should succeed: {:?}", 1543 + result 1544 + ); 1545 + 1546 + let resolved = result.unwrap(); 1547 + assert_eq!(resolved.version_number, 1); 1548 + assert!( 1549 + resolved.metadata.deactivated, 1550 + "metadata must include deactivated: true even for historical versions" 1551 + ); 1552 + } 1553 + 1554 + #[test] 1555 + fn test_deactivated_default_resolution_fails() { 1556 + let (signing_key, multikey) = make_ed25519_keypair(); 1557 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 1558 + let did = format!("did:webvh:{}:example.com", scid); 1559 + 1560 + // Build second entry that deactivates 1561 + let mut entry2 = json!({ 1562 + "versionTime": "2025-06-01T00:00:00Z", 1563 + "parameters": {"deactivated": true}, 1564 + "state": { 1565 + "@context": ["https://www.w3.org/ns/did/v1"], 1566 + "id": &did 1567 + } 1568 + }); 1569 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1570 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1571 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1572 + entry2["proof"] = json!([proof]); 1573 + 1574 + let body = format!( 1575 + "{}\n{}", 1576 + serde_json::to_string(&genesis).unwrap(), 1577 + serde_json::to_string(&entry2).unwrap() 1578 + ); 1579 + 1580 + // Default resolution (no query params) should fail for deactivated DID 1581 + let result = process_log(&did, &scid, &body); 1582 + assert!(matches!(result, Err(WebVHDIDError::DeactivatedDID { .. }))); 1583 + } 1584 + 1585 + // ======================================================================== 1586 + // Gap 4: Alternative deactivation via empty updateKeys 1587 + // ======================================================================== 1588 + 1589 + #[test] 1590 + fn test_empty_update_keys_deactivation() { 1591 + let (signing_key, multikey) = make_ed25519_keypair(); 1592 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 1593 + let did = format!("did:webvh:{}:example.com", scid); 1594 + 1595 + // Build second entry with empty updateKeys (alternative deactivation) 1596 + let mut entry2 = json!({ 1597 + "versionTime": "2025-06-01T00:00:00Z", 1598 + "parameters": {"updateKeys": []}, 1599 + "state": { 1600 + "@context": ["https://www.w3.org/ns/did/v1"], 1601 + "id": &did 1602 + } 1603 + }); 1604 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1605 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1606 + 1607 + // Sign with current keys (before they become empty) 1608 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1609 + entry2["proof"] = json!([proof]); 1610 + 1611 + let body = format!( 1612 + "{}\n{}", 1613 + serde_json::to_string(&genesis).unwrap(), 1614 + serde_json::to_string(&entry2).unwrap() 1615 + ); 1616 + 1617 + // Default resolution should fail — empty updateKeys is effectively deactivated 1618 + let result = process_log(&did, &scid, &body); 1619 + assert!( 1620 + matches!(result, Err(WebVHDIDError::DeactivatedDID { .. })), 1621 + "empty updateKeys should be treated as deactivation, got: {:?}", 1622 + result 1623 + ); 1624 + } 1625 + 1626 + #[test] 1627 + fn test_empty_update_keys_historical_query_shows_deactivated() { 1628 + let (signing_key, multikey) = make_ed25519_keypair(); 1629 + let (scid, genesis) = build_genesis_entry(&signing_key, &multikey); 1630 + let did = format!("did:webvh:{}:example.com", scid); 1631 + 1632 + // Build second entry with empty updateKeys 1633 + let mut entry2 = json!({ 1634 + "versionTime": "2025-06-01T00:00:00Z", 1635 + "parameters": {"updateKeys": []}, 1636 + "state": { 1637 + "@context": ["https://www.w3.org/ns/did/v1"], 1638 + "id": &did 1639 + } 1640 + }); 1641 + let entry_hash = super::super::scid::compute_entry_hash(&entry2).unwrap(); 1642 + entry2["versionId"] = json!(format!("2-{}", entry_hash)); 1643 + let proof = sign_entry(&signing_key, &multikey, &entry2); 1644 + entry2["proof"] = json!([proof]); 1645 + 1646 + let body = format!( 1647 + "{}\n{}", 1648 + serde_json::to_string(&genesis).unwrap(), 1649 + serde_json::to_string(&entry2).unwrap() 1650 + ); 1651 + 1652 + // Historical query for version 1 should succeed but show deactivated in metadata 1653 + let qp = QueryParams { 1654 + version_number: Some(1), 1655 + ..Default::default() 1656 + }; 1657 + let result = process_log_with_params(&did, &scid, &body, None, &qp); 1658 + assert!(result.is_ok(), "historical query should work: {:?}", result); 1659 + 1660 + let resolved = result.unwrap(); 1661 + assert!( 1662 + resolved.metadata.deactivated, 1663 + "metadata must show deactivated even for historical query when updateKeys is empty" 1664 + ); 1665 + } 1666 + }
+339
crates/atproto-identity/src/webvh/mod.rs
··· 1 + //! Web Verifiable History DID client for did:webvh resolution. 2 + //! 3 + //! Implements the [did:webvh v1.0](https://identity.foundation/didwebvh/v1.0/) 4 + //! DID method, which extends did:web with a verifiable, append-only log of DID 5 + //! document changes. Resolution fetches a `did.jsonl` (JSON Lines) file and 6 + //! sequentially validates every entry — verifying hashes, signatures, SCID 7 + //! integrity, pre-rotation keys, and timestamp ordering. 8 + //! 9 + //! ## URL Conversion 10 + //! 11 + //! Transforms DIDs like `did:webvh:{SCID}:example.com:path` into HTTPS URLs 12 + //! pointing to `did.jsonl` files at well-known locations. 13 + //! 14 + //! ## Resolution Flow 15 + //! 16 + //! 1. Parse DID, extract SCID and domain/path 17 + //! 2. Convert to HTTPS URL, fetch `did.jsonl` 18 + //! 3. Process each log entry sequentially with full verification 19 + //! 4. Return the latest DID document 20 + 21 + pub mod jcs; 22 + pub mod log; 23 + pub mod model; 24 + pub mod proof; 25 + pub mod scid; 26 + 27 + use tracing::instrument; 28 + 29 + use crate::errors::WebVHDIDError; 30 + use crate::model::Document; 31 + 32 + pub use model::{ 33 + DataIntegrityProof, LogEntry, MergedParameters, QueryParams, ResolutionMetadata, ResolvedLog, 34 + WitnessConfig, WitnessEntry, WitnessProofEntry, 35 + }; 36 + pub use scid::HashAlgorithm; 37 + 38 + pub use crate::errors::{ProblemDetails, ResolutionError, ResolutionErrorCode}; 39 + 40 + /// Converts a did:webvh DID to its corresponding HTTPS URL for the log file. 41 + /// 42 + /// Transforms the DID format to the expected `did.jsonl` location: 43 + /// - `did:webvh:{SCID}:example.com` → `https://example.com/.well-known/did.jsonl` 44 + /// - `did:webvh:{SCID}:example.com:path:sub` → `https://example.com/path/sub/did.jsonl` 45 + /// - `did:webvh:{SCID}:example.com%3A3000` → `https://example.com:3000/.well-known/did.jsonl` 46 + /// 47 + /// International domain names are converted to ASCII-compatible encoding 48 + /// (Punycode) per RFC 9233. 49 + pub fn did_webvh_to_url(did: &str) -> Result<String, WebVHDIDError> { 50 + let remainder = did 51 + .strip_prefix("did:webvh:") 52 + .ok_or(WebVHDIDError::InvalidDIDPrefix)?; 53 + 54 + let parts: Vec<&str> = remainder.split(':').collect(); 55 + if parts.len() < 2 { 56 + return Err(WebVHDIDError::MissingSCID); 57 + } 58 + 59 + // First part is SCID (skip for URL construction) 60 + let _scid = parts[0]; 61 + if _scid.is_empty() { 62 + return Err(WebVHDIDError::MissingSCID); 63 + } 64 + 65 + // Second part is hostname (may include percent-encoded port) 66 + let hostname_raw = parts[1]; 67 + if hostname_raw.is_empty() { 68 + return Err(WebVHDIDError::MissingHostname); 69 + } 70 + 71 + // Decode percent-encoded port (%3A → :) 72 + let decoded = hostname_raw.replace("%3A", ":").replace("%3a", ":"); 73 + 74 + // Apply IDNA normalization to hostname portion only (item 10) 75 + let hostname = normalize_hostname(&decoded)?; 76 + 77 + // Remaining parts are path segments 78 + let path_parts = &parts[2..]; 79 + 80 + let url = if path_parts.is_empty() { 81 + format!("https://{}/.well-known/did.jsonl", hostname) 82 + } else { 83 + format!("https://{}/{}/did.jsonl", hostname, path_parts.join("/")) 84 + }; 85 + 86 + Ok(url) 87 + } 88 + 89 + /// Normalizes a hostname using IDNA processing. 90 + /// 91 + /// Converts international domain names to ASCII-compatible encoding 92 + /// (Punycode) per RFC 9233. Preserves ports. Returns the hostname 93 + /// unchanged if it's already ASCII. 94 + fn normalize_hostname(hostname: &str) -> Result<String, WebVHDIDError> { 95 + // Split hostname from port 96 + let (host, port) = if let Some(colon_pos) = hostname.rfind(':') { 97 + // Verify the part after : looks like a port number 98 + let potential_port = &hostname[colon_pos + 1..]; 99 + if potential_port.chars().all(|c| c.is_ascii_digit()) { 100 + (&hostname[..colon_pos], Some(potential_port)) 101 + } else { 102 + (hostname, None) 103 + } 104 + } else { 105 + (hostname, None) 106 + }; 107 + 108 + // Apply IDNA processing to the host portion 109 + let ascii_host = 110 + idna::domain_to_ascii(host).map_err(|_| WebVHDIDError::HostnameNormalizationFailed { 111 + details: format!("IDNA processing failed for hostname: {}", host), 112 + })?; 113 + 114 + // Reassemble with port 115 + match port { 116 + Some(p) => Ok(format!("{}:{}", ascii_host, p)), 117 + None => Ok(ascii_host), 118 + } 119 + } 120 + 121 + /// Converts a did:webvh DID to its corresponding witness proof file URL. 122 + /// 123 + /// Same URL derivation as `did_webvh_to_url` but returns the path to 124 + /// `did-witnesses.json` instead of `did.jsonl`. 125 + pub fn did_webvh_to_witness_url(did: &str) -> Result<String, WebVHDIDError> { 126 + let log_url = did_webvh_to_url(did)?; 127 + Ok(log_url.replace("did.jsonl", "did-witnesses.json")) 128 + } 129 + 130 + /// Extracts the SCID from a did:webvh DID string. 131 + /// 132 + /// Returns the SCID component (the first segment after `did:webvh:`). 133 + pub fn extract_scid(did: &str) -> Result<String, WebVHDIDError> { 134 + let remainder = did 135 + .strip_prefix("did:webvh:") 136 + .ok_or(WebVHDIDError::InvalidDIDPrefix)?; 137 + 138 + let scid = remainder 139 + .split(':') 140 + .next() 141 + .filter(|s| !s.is_empty()) 142 + .ok_or(WebVHDIDError::MissingSCID)?; 143 + 144 + Ok(scid.to_string()) 145 + } 146 + 147 + /// Queries a did:webvh DID document from its hosting location. 148 + /// 149 + /// Fetches the `did.jsonl` log file, processes all entries sequentially 150 + /// with full verification, and returns the resolved DID document. 151 + /// 152 + /// If the log parameters include a witness configuration, the 153 + /// `did-witnesses.json` file is also fetched and witness proofs are 154 + /// verified against the configured threshold. 155 + #[instrument(skip(http_client), err)] 156 + pub async fn query(http_client: &reqwest::Client, did: &str) -> Result<Document, WebVHDIDError> { 157 + let scid = extract_scid(did)?; 158 + let url = did_webvh_to_url(did)?; 159 + 160 + let body = http_client 161 + .get(&url) 162 + .send() 163 + .await 164 + .map_err(|error| WebVHDIDError::HttpRequestFailed { 165 + url: url.clone(), 166 + error, 167 + })? 168 + .text() 169 + .await 170 + .map_err(|error| WebVHDIDError::HttpRequestFailed { url, error })?; 171 + 172 + // First pass: process log without witness verification to determine if witnesses are configured 173 + let preliminary = log::process_log(did, &scid, &body)?; 174 + 175 + if preliminary.parameters.witness.is_some() { 176 + // Witnesses are configured — fetch witness proofs and re-process with full verification 177 + let witness_url = did_webvh_to_witness_url(did)?; 178 + let witness_body = http_client 179 + .get(&witness_url) 180 + .send() 181 + .await 182 + .map_err(|error| WebVHDIDError::HttpRequestFailed { 183 + url: witness_url.clone(), 184 + error, 185 + })? 186 + .text() 187 + .await 188 + .map_err(|error| WebVHDIDError::HttpRequestFailed { 189 + url: witness_url.clone(), 190 + error, 191 + })?; 192 + 193 + let witness_proofs: Vec<model::WitnessProofEntry> = serde_json::from_str(&witness_body) 194 + .map_err(|e| WebVHDIDError::WitnessVerificationFailed { 195 + entry: 0, 196 + details: format!("failed to parse witness proofs: {}", e), 197 + })?; 198 + 199 + let resolved = log::process_log_with_witnesses(did, &scid, &body, Some(&witness_proofs))?; 200 + Ok(resolved.document) 201 + } else { 202 + Ok(preliminary.document) 203 + } 204 + } 205 + 206 + #[cfg(test)] 207 + mod tests { 208 + use super::*; 209 + 210 + #[test] 211 + fn test_did_webvh_to_url_simple_hostname() { 212 + let result = did_webvh_to_url("did:webvh:abc123:example.com"); 213 + assert_eq!(result.unwrap(), "https://example.com/.well-known/did.jsonl"); 214 + } 215 + 216 + #[test] 217 + fn test_did_webvh_to_url_with_path() { 218 + let result = did_webvh_to_url("did:webvh:abc123:example.com:path"); 219 + assert_eq!(result.unwrap(), "https://example.com/path/did.jsonl"); 220 + } 221 + 222 + #[test] 223 + fn test_did_webvh_to_url_with_nested_path() { 224 + let result = did_webvh_to_url("did:webvh:abc123:example.com:path:subpath"); 225 + assert_eq!( 226 + result.unwrap(), 227 + "https://example.com/path/subpath/did.jsonl" 228 + ); 229 + } 230 + 231 + #[test] 232 + fn test_did_webvh_to_url_with_port() { 233 + let result = did_webvh_to_url("did:webvh:abc123:example.com%3A3000"); 234 + assert_eq!( 235 + result.unwrap(), 236 + "https://example.com:3000/.well-known/did.jsonl" 237 + ); 238 + } 239 + 240 + #[test] 241 + fn test_did_webvh_to_url_with_port_and_path() { 242 + let result = did_webvh_to_url("did:webvh:abc123:example.com%3A3000:dids:issuer"); 243 + assert_eq!( 244 + result.unwrap(), 245 + "https://example.com:3000/dids/issuer/did.jsonl" 246 + ); 247 + } 248 + 249 + #[test] 250 + fn test_did_webvh_to_url_with_subdomain() { 251 + let result = did_webvh_to_url("did:webvh:abc123:issuer.example.com"); 252 + assert_eq!( 253 + result.unwrap(), 254 + "https://issuer.example.com/.well-known/did.jsonl" 255 + ); 256 + } 257 + 258 + #[test] 259 + fn test_did_webvh_to_url_invalid_prefix() { 260 + let result = did_webvh_to_url("did:web:abc123:example.com"); 261 + assert!(matches!(result, Err(WebVHDIDError::InvalidDIDPrefix))); 262 + } 263 + 264 + #[test] 265 + fn test_did_webvh_to_url_missing_scid() { 266 + let result = did_webvh_to_url("did:webvh:"); 267 + assert!(matches!(result, Err(WebVHDIDError::MissingSCID))); 268 + } 269 + 270 + #[test] 271 + fn test_did_webvh_to_url_missing_hostname() { 272 + let result = did_webvh_to_url("did:webvh:abc123:"); 273 + assert!(matches!(result, Err(WebVHDIDError::MissingHostname))); 274 + } 275 + 276 + #[test] 277 + fn test_did_webvh_to_url_only_scid() { 278 + // Only SCID, no hostname 279 + let result = did_webvh_to_url("did:webvh:abc123"); 280 + assert!(matches!(result, Err(WebVHDIDError::MissingSCID))); 281 + } 282 + 283 + #[test] 284 + fn test_did_webvh_to_url_no_prefix() { 285 + let result = did_webvh_to_url("example.com"); 286 + assert!(matches!(result, Err(WebVHDIDError::InvalidDIDPrefix))); 287 + } 288 + 289 + #[test] 290 + fn test_did_webvh_to_witness_url() { 291 + let result = did_webvh_to_witness_url("did:webvh:abc123:example.com"); 292 + assert_eq!( 293 + result.unwrap(), 294 + "https://example.com/.well-known/did-witnesses.json" 295 + ); 296 + } 297 + 298 + #[test] 299 + fn test_did_webvh_to_witness_url_with_path() { 300 + let result = did_webvh_to_witness_url("did:webvh:abc123:example.com:dids:issuer"); 301 + assert_eq!( 302 + result.unwrap(), 303 + "https://example.com/dids/issuer/did-witnesses.json" 304 + ); 305 + } 306 + 307 + #[test] 308 + fn test_extract_scid() { 309 + let scid = extract_scid("did:webvh:QmTest123:example.com").unwrap(); 310 + assert_eq!(scid, "QmTest123"); 311 + } 312 + 313 + #[test] 314 + fn test_extract_scid_with_path() { 315 + let scid = extract_scid("did:webvh:QmTest123:example.com:path:sub").unwrap(); 316 + assert_eq!(scid, "QmTest123"); 317 + } 318 + 319 + #[test] 320 + fn test_extract_scid_invalid_prefix() { 321 + let result = extract_scid("did:web:example.com"); 322 + assert!(matches!(result, Err(WebVHDIDError::InvalidDIDPrefix))); 323 + } 324 + 325 + #[test] 326 + fn test_extract_scid_empty() { 327 + let result = extract_scid("did:webvh:"); 328 + assert!(matches!(result, Err(WebVHDIDError::MissingSCID))); 329 + } 330 + 331 + #[test] 332 + fn test_did_webvh_to_url_lowercase_port_encoding() { 333 + let result = did_webvh_to_url("did:webvh:abc123:example.com%3a8080"); 334 + assert_eq!( 335 + result.unwrap(), 336 + "https://example.com:8080/.well-known/did.jsonl" 337 + ); 338 + } 339 + }
+260
crates/atproto-identity/src/webvh/model.rs
··· 1 + //! Data types for did:webvh log entries, parameters, and proofs. 2 + //! 3 + //! Defines the structures used to parse and process did:webvh DID log files 4 + //! (did.jsonl), including log entries, Data Integrity proofs, witness 5 + //! configuration, and the accumulated parameter state. 6 + 7 + use std::collections::HashMap; 8 + 9 + use serde::{Deserialize, Serialize}; 10 + 11 + use crate::model::Document; 12 + 13 + /// A single entry in a did:webvh log file (did.jsonl). 14 + /// 15 + /// Each line in the JSONL file represents one version of the DID document 16 + /// with its associated metadata, parameters, and cryptographic proofs. 17 + #[derive(Debug, Clone, Serialize, Deserialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct LogEntry { 20 + /// Version identifier in the format "{N}-{hash}". 21 + pub version_id: String, 22 + /// ISO 8601 UTC timestamp of this version. 23 + pub version_time: String, 24 + /// Parameters for this entry (only changed parameters are included). 25 + pub parameters: serde_json::Value, 26 + /// The DID Document state at this version. 27 + pub state: serde_json::Value, 28 + /// Array of Data Integrity proofs. 29 + #[serde(default)] 30 + pub proof: Vec<DataIntegrityProof>, 31 + } 32 + 33 + /// A W3C Data Integrity proof for a log entry. 34 + /// 35 + /// Uses the `eddsa-jcs-2022` cryptosuite for Ed25519 signatures 36 + /// over JCS-canonicalized content. 37 + #[derive(Debug, Clone, Serialize, Deserialize)] 38 + #[serde(rename_all = "camelCase")] 39 + pub struct DataIntegrityProof { 40 + /// Proof type, must be "DataIntegrityProof". 41 + pub r#type: String, 42 + /// Cryptographic suite, must be "eddsa-jcs-2022" for v1.0. 43 + pub cryptosuite: String, 44 + /// DID URL of the signing key (e.g., "did:key:z6Mk...#z6Mk..."). 45 + pub verification_method: String, 46 + /// ISO 8601 timestamp when the proof was created. 47 + pub created: String, 48 + /// Purpose of the proof, typically "assertionMethod". 49 + pub proof_purpose: String, 50 + /// Multibase-encoded (base58btc) Ed25519 signature. 51 + pub proof_value: String, 52 + } 53 + 54 + /// Accumulated parameter state across all processed log entries. 55 + /// 56 + /// Parameters carry forward from previous entries — only changed parameters 57 + /// appear in subsequent entries. This struct tracks the fully merged state. 58 + #[derive(Debug, Clone)] 59 + pub struct MergedParameters { 60 + /// Method version string (e.g., "did:webvh:1.0"). 61 + pub method: String, 62 + /// Self-Certifying Identifier from the genesis entry. 63 + pub scid: String, 64 + /// Public keys authorized to sign log entries. 65 + pub update_keys: Vec<String>, 66 + /// Hashes of pre-rotated future keys. 67 + pub next_key_hashes: Vec<String>, 68 + /// Whether the DID is portable (can be relocated). 69 + pub portable: bool, 70 + /// Whether the DID has been deactivated. 71 + pub deactivated: bool, 72 + /// Cache TTL in seconds (default 3600). 73 + pub ttl: u64, 74 + /// Witness configuration, if any. 75 + pub witness: Option<WitnessConfig>, 76 + /// Watcher notification URLs. 77 + pub watchers: Vec<String>, 78 + } 79 + 80 + impl Default for MergedParameters { 81 + fn default() -> Self { 82 + Self { 83 + method: String::new(), 84 + scid: String::new(), 85 + update_keys: Vec::new(), 86 + next_key_hashes: Vec::new(), 87 + portable: false, 88 + deactivated: false, 89 + ttl: 3600, 90 + witness: None, 91 + watchers: Vec::new(), 92 + } 93 + } 94 + } 95 + 96 + /// Witness configuration for multi-signature approval of DID updates. 97 + #[derive(Debug, Clone, Serialize, Deserialize)] 98 + pub struct WitnessConfig { 99 + /// Minimum number of weighted witness approvals required. 100 + pub threshold: usize, 101 + /// List of configured witnesses. 102 + pub witnesses: Vec<WitnessEntry>, 103 + } 104 + 105 + /// A single witness in the witness configuration. 106 + #[derive(Debug, Clone, Serialize, Deserialize)] 107 + pub struct WitnessEntry { 108 + /// DID key identifier of the witness (e.g., "did:key:z6Mk..."). 109 + pub id: String, 110 + /// Weight of this witness's approval toward the threshold. 111 + #[serde(default = "default_weight")] 112 + pub weight: usize, 113 + } 114 + 115 + fn default_weight() -> usize { 116 + 1 117 + } 118 + 119 + /// A witness proof entry from the did-witnesses.json file. 120 + #[derive(Debug, Clone, Serialize, Deserialize)] 121 + #[serde(rename_all = "camelCase")] 122 + pub struct WitnessProofEntry { 123 + /// Version ID this witness proof applies to. 124 + pub version_id: String, 125 + /// Array of witness Data Integrity proofs. 126 + pub proof: Vec<DataIntegrityProof>, 127 + } 128 + 129 + /// Query parameters for historical version resolution. 130 + /// 131 + /// When resolving a did:webvh DID, these parameters select which version 132 + /// of the DID document to return. Only one should be set at a time. 133 + #[derive(Debug, Clone, Default)] 134 + pub struct QueryParams { 135 + /// Resolve a specific version by its full versionId (e.g., "2-zHash"). 136 + pub version_id: Option<String>, 137 + /// Resolve the DIDDoc active at this ISO 8601 UTC timestamp. 138 + pub version_time: Option<String>, 139 + /// Resolve by integer version number (1-indexed). 140 + pub version_number: Option<u64>, 141 + } 142 + 143 + /// Result of processing a complete did:webvh log. 144 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 145 + #[derive(Clone)] 146 + pub struct ResolvedLog { 147 + /// The resolved DID Document from the final log entry. 148 + pub document: Document, 149 + /// Version ID of the final entry. 150 + pub version_id: String, 151 + /// Version number (1-indexed) of the final entry. 152 + pub version_number: u64, 153 + /// ISO 8601 timestamp of the final entry. 154 + pub version_time: String, 155 + /// Fully merged parameters across all entries. 156 + pub parameters: MergedParameters, 157 + /// Total number of entries in the log. 158 + pub entry_count: usize, 159 + /// Resolution metadata. 160 + pub metadata: ResolutionMetadata, 161 + } 162 + 163 + /// Resolution metadata returned alongside the DID document. 164 + #[derive(Debug, Clone, Serialize, Deserialize)] 165 + #[serde(rename_all = "camelCase")] 166 + pub struct ResolutionMetadata { 167 + /// Version ID of the resolved entry. 168 + pub version_id: String, 169 + /// Timestamp of the resolved entry. 170 + pub version_time: String, 171 + /// Timestamp of the first entry (creation time). 172 + pub created: String, 173 + /// Timestamp of the last entry (update time). 174 + pub updated: String, 175 + /// Self-Certifying Identifier. 176 + pub scid: String, 177 + /// Whether the DID is portable. 178 + pub portable: bool, 179 + /// Whether the DID is deactivated. 180 + pub deactivated: bool, 181 + /// Cache TTL in seconds. 182 + pub ttl: u64, 183 + /// Witness configuration. 184 + #[serde(skip_serializing_if = "Option::is_none")] 185 + pub witness: Option<WitnessConfig>, 186 + /// Watcher URLs. 187 + #[serde(default)] 188 + pub watchers: Vec<String>, 189 + /// Additional metadata fields. 190 + #[serde(flatten)] 191 + pub extra: HashMap<String, serde_json::Value>, 192 + } 193 + 194 + #[cfg(test)] 195 + mod tests { 196 + use super::*; 197 + 198 + #[test] 199 + fn test_log_entry_deserialize() { 200 + let json = r#"{"versionId":"1-QmTest","versionTime":"2025-04-29T17:15:59Z","parameters":{"method":"did:webvh:1.0","scid":"QmTest","updateKeys":["z6MkTest"]},"state":{"@context":["https://www.w3.org/ns/did/v1"],"id":"did:webvh:QmTest:example.com"},"proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","verificationMethod":"did:key:z6MkTest#z6MkTest","created":"2025-04-29T17:15:59Z","proofPurpose":"assertionMethod","proofValue":"zSigTest"}]}"#; 201 + let entry: LogEntry = serde_json::from_str(json).unwrap(); 202 + assert_eq!(entry.version_id, "1-QmTest"); 203 + assert_eq!(entry.version_time, "2025-04-29T17:15:59Z"); 204 + assert_eq!(entry.proof.len(), 1); 205 + assert_eq!(entry.proof[0].cryptosuite, "eddsa-jcs-2022"); 206 + } 207 + 208 + #[test] 209 + fn test_log_entry_roundtrip() { 210 + let json = r#"{"versionId":"1-QmTest","versionTime":"2025-04-29T17:15:59Z","parameters":{},"state":{"id":"did:webvh:QmTest:example.com"},"proof":[]}"#; 211 + let entry: LogEntry = serde_json::from_str(json).unwrap(); 212 + let serialized = serde_json::to_string(&entry).unwrap(); 213 + let reparsed: LogEntry = serde_json::from_str(&serialized).unwrap(); 214 + assert_eq!(entry.version_id, reparsed.version_id); 215 + } 216 + 217 + #[test] 218 + fn test_data_integrity_proof_deserialize() { 219 + let json = r#"{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","verificationMethod":"did:key:z6MkTest#z6MkTest","created":"2025-04-29T17:15:59Z","proofPurpose":"assertionMethod","proofValue":"zSigValue"}"#; 220 + let proof: DataIntegrityProof = serde_json::from_str(json).unwrap(); 221 + assert_eq!(proof.r#type, "DataIntegrityProof"); 222 + assert_eq!(proof.cryptosuite, "eddsa-jcs-2022"); 223 + assert_eq!(proof.proof_value, "zSigValue"); 224 + } 225 + 226 + #[test] 227 + fn test_merged_parameters_default() { 228 + let params = MergedParameters::default(); 229 + assert_eq!(params.ttl, 3600); 230 + assert!(!params.portable); 231 + assert!(!params.deactivated); 232 + assert!(params.update_keys.is_empty()); 233 + assert!(params.next_key_hashes.is_empty()); 234 + assert!(params.witness.is_none()); 235 + } 236 + 237 + #[test] 238 + fn test_witness_config_deserialize() { 239 + let json = r#"{"threshold":2,"witnesses":[{"id":"did:key:z6MkA","weight":1},{"id":"did:key:z6MkB","weight":2}]}"#; 240 + let config: WitnessConfig = serde_json::from_str(json).unwrap(); 241 + assert_eq!(config.threshold, 2); 242 + assert_eq!(config.witnesses.len(), 2); 243 + assert_eq!(config.witnesses[1].weight, 2); 244 + } 245 + 246 + #[test] 247 + fn test_witness_entry_default_weight() { 248 + let json = r#"{"id":"did:key:z6MkA"}"#; 249 + let entry: WitnessEntry = serde_json::from_str(json).unwrap(); 250 + assert_eq!(entry.weight, 1); 251 + } 252 + 253 + #[test] 254 + fn test_witness_proof_entry_deserialize() { 255 + let json = r#"{"versionId":"2-QmHash","proof":[{"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","verificationMethod":"did:key:z6MkW#z6MkW","created":"2025-04-29T17:15:59Z","proofPurpose":"assertionMethod","proofValue":"zWitnessSig"}]}"#; 256 + let entry: WitnessProofEntry = serde_json::from_str(json).unwrap(); 257 + assert_eq!(entry.version_id, "2-QmHash"); 258 + assert_eq!(entry.proof.len(), 1); 259 + } 260 + }
+795
crates/atproto-identity/src/webvh/proof.rs
··· 1 + //! Data Integrity proof verification for did:webvh log entries. 2 + //! 3 + //! Verifies `eddsa-jcs-2022` cryptosuite proofs using Ed25519 signatures 4 + //! over JCS-canonicalized log entry content. Supports both controller proofs 5 + //! and witness proofs. 6 + 7 + use crate::errors::WebVHDIDError; 8 + 9 + use super::jcs; 10 + use super::model::DataIntegrityProof; 11 + use super::scid::compute_multihash_base58btc; 12 + 13 + /// Ed25519 multicodec prefix bytes. 14 + const ED25519_PUB_MULTICODEC: [u8; 2] = [0xed, 0x01]; 15 + 16 + /// Verifies a Data Integrity proof against a log entry. 17 + /// 18 + /// Validates that: 19 + /// 1. The proof type is "DataIntegrityProof" 20 + /// 2. The cryptosuite is "eddsa-jcs-2022" 21 + /// 3. The signing key is in the authorized `update_keys` list 22 + /// 4. The Ed25519 signature over the JCS-canonicalized entry is valid 23 + pub fn verify_proof( 24 + entry: &serde_json::Value, 25 + proof: &DataIntegrityProof, 26 + update_keys: &[String], 27 + entry_index: usize, 28 + ) -> Result<(), WebVHDIDError> { 29 + // Validate proof type 30 + if proof.r#type != "DataIntegrityProof" { 31 + return Err(WebVHDIDError::ProofVerificationFailed { 32 + entry: entry_index, 33 + details: format!("unexpected proof type: {}", proof.r#type), 34 + }); 35 + } 36 + 37 + // Validate cryptosuite 38 + if proof.cryptosuite != "eddsa-jcs-2022" { 39 + return Err(WebVHDIDError::ProofVerificationFailed { 40 + entry: entry_index, 41 + details: format!("unsupported cryptosuite: {}", proof.cryptosuite), 42 + }); 43 + } 44 + 45 + // Extract multikey from verification method 46 + // Format: "did:key:{multikey}#{multikey}" or "did:key:{multikey}" 47 + let multikey = extract_multikey(&proof.verification_method).ok_or_else(|| { 48 + WebVHDIDError::ProofVerificationFailed { 49 + entry: entry_index, 50 + details: format!( 51 + "cannot extract multikey from verification method: {}", 52 + proof.verification_method 53 + ), 54 + } 55 + })?; 56 + 57 + // Verify the key is in the authorized update keys 58 + if !update_keys.iter().any(|k| k == &multikey) { 59 + return Err(WebVHDIDError::ProofVerificationFailed { 60 + entry: entry_index, 61 + details: format!("signing key {} not in update keys", multikey), 62 + }); 63 + } 64 + 65 + // Decode the multikey to get Ed25519 public key bytes 66 + let public_key_bytes = decode_ed25519_multikey(&multikey).map_err(|details| { 67 + WebVHDIDError::ProofVerificationFailed { 68 + entry: entry_index, 69 + details, 70 + } 71 + })?; 72 + 73 + // Remove proof from entry and canonicalize 74 + let mut entry_without_proof = entry.clone(); 75 + if let Some(obj) = entry_without_proof.as_object_mut() { 76 + obj.remove("proof"); 77 + } 78 + let canonical = jcs::canonicalize(&entry_without_proof); 79 + 80 + // Decode the proof value (multibase base58btc encoded signature) 81 + let signature_bytes = decode_proof_value(&proof.proof_value).map_err(|details| { 82 + WebVHDIDError::ProofVerificationFailed { 83 + entry: entry_index, 84 + details, 85 + } 86 + })?; 87 + 88 + // Verify the Ed25519 signature 89 + verify_ed25519_signature(&public_key_bytes, canonical.as_bytes(), &signature_bytes).map_err( 90 + |details| WebVHDIDError::ProofVerificationFailed { 91 + entry: entry_index, 92 + details, 93 + }, 94 + ) 95 + } 96 + 97 + /// Verifies that at least one proof in the entry is valid. 98 + /// 99 + /// Returns Ok if at least one proof verifies successfully against the update keys. 100 + pub fn verify_any_proof( 101 + entry: &serde_json::Value, 102 + proofs: &[DataIntegrityProof], 103 + update_keys: &[String], 104 + entry_index: usize, 105 + ) -> Result<(), WebVHDIDError> { 106 + if proofs.is_empty() { 107 + return Err(WebVHDIDError::ProofVerificationFailed { 108 + entry: entry_index, 109 + details: "no proofs present".to_string(), 110 + }); 111 + } 112 + 113 + let mut last_error = None; 114 + for proof in proofs { 115 + match verify_proof(entry, proof, update_keys, entry_index) { 116 + Ok(()) => return Ok(()), 117 + Err(e) => last_error = Some(e), 118 + } 119 + } 120 + 121 + Err( 122 + last_error.unwrap_or_else(|| WebVHDIDError::ProofVerificationFailed { 123 + entry: entry_index, 124 + details: "no valid proof found".to_string(), 125 + }), 126 + ) 127 + } 128 + 129 + /// Extracts the multikey string from a DID key verification method. 130 + /// 131 + /// Supports formats: 132 + /// - `did:key:{multikey}#{multikey}` → `{multikey}` 133 + /// - `did:key:{multikey}` → `{multikey}` 134 + /// - `{multikey}` (bare) → `{multikey}` 135 + fn extract_multikey(verification_method: &str) -> Option<String> { 136 + let key_part = if let Some(stripped) = verification_method.strip_prefix("did:key:") { 137 + // Take the part before the fragment (#) if present 138 + stripped.split('#').next()? 139 + } else { 140 + verification_method 141 + }; 142 + 143 + if key_part.is_empty() { 144 + return None; 145 + } 146 + 147 + Some(key_part.to_string()) 148 + } 149 + 150 + /// Decodes an Ed25519 multikey string to raw public key bytes. 151 + /// 152 + /// Multikey format: `z` + base58btc(multicodec_prefix + raw_key_bytes) 153 + /// Ed25519 multicodec prefix: `[0xed, 0x01]` 154 + fn decode_ed25519_multikey(multikey: &str) -> Result<[u8; 32], String> { 155 + let (_, decoded) = multibase::decode(multikey) 156 + .map_err(|e| format!("failed to decode multibase key: {}", e))?; 157 + 158 + if decoded.len() < 2 { 159 + return Err("multikey too short".to_string()); 160 + } 161 + 162 + if decoded[..2] != ED25519_PUB_MULTICODEC { 163 + return Err(format!( 164 + "unexpected multicodec prefix: [{:#04x}, {:#04x}], expected Ed25519 [{:#04x}, {:#04x}]", 165 + decoded[0], decoded[1], ED25519_PUB_MULTICODEC[0], ED25519_PUB_MULTICODEC[1] 166 + )); 167 + } 168 + 169 + let key_bytes = &decoded[2..]; 170 + if key_bytes.len() != 32 { 171 + return Err(format!( 172 + "invalid Ed25519 public key length: expected 32, got {}", 173 + key_bytes.len() 174 + )); 175 + } 176 + 177 + let mut result = [0u8; 32]; 178 + result.copy_from_slice(key_bytes); 179 + Ok(result) 180 + } 181 + 182 + /// Decodes a multibase-encoded proof value to raw signature bytes. 183 + /// 184 + /// Proof values use base58btc encoding with `z` prefix. 185 + fn decode_proof_value(proof_value: &str) -> Result<Vec<u8>, String> { 186 + let (_, decoded) = multibase::decode(proof_value) 187 + .map_err(|e| format!("failed to decode proof value: {}", e))?; 188 + Ok(decoded) 189 + } 190 + 191 + /// Verifies an Ed25519 signature. 192 + fn verify_ed25519_signature( 193 + public_key: &[u8; 32], 194 + message: &[u8], 195 + signature: &[u8], 196 + ) -> Result<(), String> { 197 + let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(public_key) 198 + .map_err(|e| format!("invalid Ed25519 public key: {}", e))?; 199 + 200 + let sig_bytes: &[u8; 64] = signature.try_into().map_err(|_| { 201 + format!( 202 + "invalid signature length: expected 64, got {}", 203 + signature.len() 204 + ) 205 + })?; 206 + 207 + let sig = ed25519_dalek::Signature::from_bytes(sig_bytes); 208 + 209 + ed25519_dalek::Verifier::verify(&verifying_key, message, &sig) 210 + .map_err(|e| format!("Ed25519 signature verification failed: {}", e)) 211 + } 212 + 213 + /// Verifies witness proofs for a log entry meet the configured threshold. 214 + /// 215 + /// For each witness proof: 216 + /// 1. Verifies the proof is a valid Data Integrity proof 217 + /// 2. Checks the signing key belongs to a configured witness 218 + /// 3. Accumulates the witness weight 219 + /// 4. Returns Ok once the threshold is met 220 + /// 221 + /// The witness proofs sign the same content as controller proofs — the 222 + /// JCS-canonicalized entry without the `proof` field. 223 + pub fn verify_witness_proofs( 224 + entry: &serde_json::Value, 225 + witness_proofs: &[DataIntegrityProof], 226 + witness_config: &super::model::WitnessConfig, 227 + entry_index: usize, 228 + ) -> Result<(), WebVHDIDError> { 229 + if witness_proofs.is_empty() { 230 + return Err(WebVHDIDError::WitnessVerificationFailed { 231 + entry: entry_index, 232 + details: "no witness proofs provided".to_string(), 233 + }); 234 + } 235 + 236 + let mut accumulated_weight: usize = 0; 237 + 238 + for proof in witness_proofs { 239 + // Validate proof type and cryptosuite 240 + if proof.r#type != "DataIntegrityProof" || proof.cryptosuite != "eddsa-jcs-2022" { 241 + continue; 242 + } 243 + 244 + // Extract multikey from verification method 245 + let multikey = match extract_multikey(&proof.verification_method) { 246 + Some(k) => k, 247 + None => continue, 248 + }; 249 + 250 + // Find matching witness and get its weight 251 + let did_key = format!("did:key:{}", multikey); 252 + let witness_weight = witness_config 253 + .witnesses 254 + .iter() 255 + .find(|w| w.id == did_key || w.id == multikey) 256 + .map(|w| w.weight); 257 + 258 + let weight = match witness_weight { 259 + Some(w) => w, 260 + None => continue, // Not a configured witness, skip 261 + }; 262 + 263 + // Decode the Ed25519 public key 264 + let public_key_bytes = match decode_ed25519_multikey(&multikey) { 265 + Ok(bytes) => bytes, 266 + Err(_) => continue, 267 + }; 268 + 269 + // Remove proof from entry and canonicalize 270 + let mut entry_without_proof = entry.clone(); 271 + if let Some(obj) = entry_without_proof.as_object_mut() { 272 + obj.remove("proof"); 273 + } 274 + let canonical = jcs::canonicalize(&entry_without_proof); 275 + 276 + // Decode and verify the signature 277 + let signature_bytes = match decode_proof_value(&proof.proof_value) { 278 + Ok(bytes) => bytes, 279 + Err(_) => continue, 280 + }; 281 + 282 + if verify_ed25519_signature(&public_key_bytes, canonical.as_bytes(), &signature_bytes) 283 + .is_ok() 284 + { 285 + accumulated_weight += weight; 286 + if accumulated_weight >= witness_config.threshold { 287 + return Ok(()); 288 + } 289 + } 290 + } 291 + 292 + Err(WebVHDIDError::WitnessVerificationFailed { 293 + entry: entry_index, 294 + details: format!( 295 + "witness threshold not met: accumulated weight {} < required threshold {}", 296 + accumulated_weight, witness_config.threshold 297 + ), 298 + }) 299 + } 300 + 301 + /// Verifies pre-rotation key hashes. 302 + /// 303 + /// When pre-rotation is active (previous entry's `nextKeyHashes` is non-empty), 304 + /// at least one key in the current `updateKeys` must have a hash matching 305 + /// one of the previous `nextKeyHashes`. 306 + pub fn verify_prerotation( 307 + current_update_keys: &[String], 308 + prev_next_key_hashes: &[String], 309 + entry_index: usize, 310 + ) -> Result<(), WebVHDIDError> { 311 + if prev_next_key_hashes.is_empty() { 312 + return Ok(()); 313 + } 314 + 315 + for key in current_update_keys { 316 + let key_hash = compute_multihash_base58btc(key.as_bytes()); 317 + if prev_next_key_hashes.contains(&key_hash) { 318 + return Ok(()); 319 + } 320 + } 321 + 322 + Err(WebVHDIDError::PreRotationKeyMismatch { entry: entry_index }) 323 + } 324 + 325 + #[cfg(test)] 326 + mod tests { 327 + use super::*; 328 + use crate::key::{KeyType, generate_key, sign as key_sign, to_public}; 329 + use serde_json::json; 330 + 331 + fn make_ed25519_keypair() -> (ed25519_dalek::SigningKey, String) { 332 + let private_key = generate_key(KeyType::Ed25519Private).unwrap(); 333 + let public_key = to_public(&private_key).unwrap(); 334 + // Display formats as did:key:z... 335 + let did_key = format!("{}", &public_key); 336 + let multikey = did_key.strip_prefix("did:key:").unwrap().to_string(); 337 + 338 + let signing_key = 339 + ed25519_dalek::SigningKey::from_bytes(private_key.bytes().try_into().unwrap()); 340 + 341 + (signing_key, multikey) 342 + } 343 + 344 + fn sign_entry(signing_key: &ed25519_dalek::SigningKey, entry: &serde_json::Value) -> String { 345 + let mut entry_without_proof = entry.clone(); 346 + if let Some(obj) = entry_without_proof.as_object_mut() { 347 + obj.remove("proof"); 348 + } 349 + let canonical = jcs::canonicalize(&entry_without_proof); 350 + let signature = ed25519_dalek::Signer::sign(signing_key, canonical.as_bytes()); 351 + multibase::encode(multibase::Base::Base58Btc, signature.to_bytes()) 352 + } 353 + 354 + #[test] 355 + fn test_extract_multikey_did_key_with_fragment() { 356 + let result = extract_multikey("did:key:z6MkTest#z6MkTest"); 357 + assert_eq!(result, Some("z6MkTest".to_string())); 358 + } 359 + 360 + #[test] 361 + fn test_extract_multikey_did_key_without_fragment() { 362 + let result = extract_multikey("did:key:z6MkTest"); 363 + assert_eq!(result, Some("z6MkTest".to_string())); 364 + } 365 + 366 + #[test] 367 + fn test_extract_multikey_bare() { 368 + let result = extract_multikey("z6MkTest"); 369 + assert_eq!(result, Some("z6MkTest".to_string())); 370 + } 371 + 372 + #[test] 373 + fn test_extract_multikey_empty() { 374 + assert_eq!(extract_multikey("did:key:"), None); 375 + } 376 + 377 + #[test] 378 + fn test_decode_ed25519_multikey_valid() { 379 + let (_, multikey) = make_ed25519_keypair(); 380 + let result = decode_ed25519_multikey(&multikey); 381 + assert!(result.is_ok()); 382 + assert_eq!(result.unwrap().len(), 32); 383 + } 384 + 385 + #[test] 386 + fn test_decode_ed25519_multikey_wrong_prefix() { 387 + // P-256 public key multicodec prefix 388 + let mut bytes = vec![0x80, 0x24]; 389 + bytes.extend_from_slice(&[0x00; 32]); 390 + let encoded = multibase::encode(multibase::Base::Base58Btc, &bytes); 391 + let result = decode_ed25519_multikey(&encoded); 392 + assert!(result.is_err()); 393 + assert!(result.unwrap_err().contains("unexpected multicodec prefix")); 394 + } 395 + 396 + #[test] 397 + fn test_verify_proof_valid() { 398 + let (signing_key, multikey) = make_ed25519_keypair(); 399 + 400 + let mut entry = json!({ 401 + "versionId": "1-test", 402 + "versionTime": "2025-04-29T17:15:59Z", 403 + "parameters": {"method": "did:webvh:1.0"}, 404 + "state": {"id": "did:webvh:test:example.com"} 405 + }); 406 + 407 + let proof_value = sign_entry(&signing_key, &entry); 408 + let verification_method = format!("did:key:{}#{}", multikey, multikey); 409 + 410 + let proof = DataIntegrityProof { 411 + r#type: "DataIntegrityProof".to_string(), 412 + cryptosuite: "eddsa-jcs-2022".to_string(), 413 + verification_method, 414 + created: "2025-04-29T17:15:59Z".to_string(), 415 + proof_purpose: "assertionMethod".to_string(), 416 + proof_value, 417 + }; 418 + 419 + entry["proof"] = serde_json::to_value(&[&proof]).unwrap(); 420 + 421 + let update_keys = vec![multikey]; 422 + let result = verify_proof(&entry, &proof, &update_keys, 1); 423 + assert!(result.is_ok(), "proof verification failed: {:?}", result); 424 + } 425 + 426 + #[test] 427 + fn test_verify_proof_tampered_entry() { 428 + let (signing_key, multikey) = make_ed25519_keypair(); 429 + 430 + let entry = json!({ 431 + "versionId": "1-test", 432 + "versionTime": "2025-04-29T17:15:59Z", 433 + "parameters": {"method": "did:webvh:1.0"}, 434 + "state": {"id": "did:webvh:test:example.com"} 435 + }); 436 + 437 + let proof_value = sign_entry(&signing_key, &entry); 438 + let verification_method = format!("did:key:{}#{}", multikey, multikey); 439 + 440 + let proof = DataIntegrityProof { 441 + r#type: "DataIntegrityProof".to_string(), 442 + cryptosuite: "eddsa-jcs-2022".to_string(), 443 + verification_method, 444 + created: "2025-04-29T17:15:59Z".to_string(), 445 + proof_purpose: "assertionMethod".to_string(), 446 + proof_value, 447 + }; 448 + 449 + // Tamper with the entry 450 + let mut tampered = entry; 451 + tampered["state"]["id"] = json!("did:webvh:test:evil.com"); 452 + tampered["proof"] = serde_json::to_value(&[&proof]).unwrap(); 453 + 454 + let update_keys = vec![multikey]; 455 + let result = verify_proof(&tampered, &proof, &update_keys, 1); 456 + assert!(result.is_err()); 457 + } 458 + 459 + #[test] 460 + fn test_verify_proof_wrong_key() { 461 + let (signing_key, multikey) = make_ed25519_keypair(); 462 + let (_, other_multikey) = make_ed25519_keypair(); 463 + 464 + let entry = json!({ 465 + "versionId": "1-test", 466 + "versionTime": "2025-04-29T17:15:59Z", 467 + "parameters": {}, 468 + "state": {} 469 + }); 470 + 471 + let proof_value = sign_entry(&signing_key, &entry); 472 + 473 + let proof = DataIntegrityProof { 474 + r#type: "DataIntegrityProof".to_string(), 475 + cryptosuite: "eddsa-jcs-2022".to_string(), 476 + verification_method: format!("did:key:{}#{}", multikey, multikey), 477 + created: "2025-04-29T17:15:59Z".to_string(), 478 + proof_purpose: "assertionMethod".to_string(), 479 + proof_value, 480 + }; 481 + 482 + // Update keys only contains the other key 483 + let update_keys = vec![other_multikey]; 484 + let result = verify_proof(&entry, &proof, &update_keys, 1); 485 + assert!(result.is_err()); 486 + if let Err(WebVHDIDError::ProofVerificationFailed { details, .. }) = result { 487 + assert!(details.contains("not in update keys")); 488 + } 489 + } 490 + 491 + #[test] 492 + fn test_verify_proof_wrong_cryptosuite() { 493 + let proof = DataIntegrityProof { 494 + r#type: "DataIntegrityProof".to_string(), 495 + cryptosuite: "wrong-suite".to_string(), 496 + verification_method: "did:key:z6MkTest#z6MkTest".to_string(), 497 + created: "2025-04-29T17:15:59Z".to_string(), 498 + proof_purpose: "assertionMethod".to_string(), 499 + proof_value: "zSig".to_string(), 500 + }; 501 + 502 + let entry = json!({"proof": []}); 503 + let result = verify_proof(&entry, &proof, &["z6MkTest".to_string()], 1); 504 + assert!(result.is_err()); 505 + } 506 + 507 + #[test] 508 + fn test_verify_any_proof_empty() { 509 + let entry = json!({"versionId": "1-test"}); 510 + let result = verify_any_proof(&entry, &[], &[], 1); 511 + assert!(matches!( 512 + result, 513 + Err(WebVHDIDError::ProofVerificationFailed { .. }) 514 + )); 515 + } 516 + 517 + #[test] 518 + fn test_verify_prerotation_no_hashes() { 519 + // Empty prev hashes means pre-rotation is not active 520 + let result = verify_prerotation(&["z6MkTest".to_string()], &[], 1); 521 + assert!(result.is_ok()); 522 + } 523 + 524 + #[test] 525 + fn test_verify_prerotation_matching() { 526 + let key = "z6MkTestKey"; 527 + let key_hash = compute_multihash_base58btc(key.as_bytes()); 528 + 529 + let result = verify_prerotation(&[key.to_string()], &[key_hash], 2); 530 + assert!(result.is_ok()); 531 + } 532 + 533 + #[test] 534 + fn test_verify_prerotation_no_match() { 535 + let result = 536 + verify_prerotation(&["z6MkNewKey".to_string()], &["zWrongHash".to_string()], 2); 537 + assert!(matches!( 538 + result, 539 + Err(WebVHDIDError::PreRotationKeyMismatch { entry: 2 }) 540 + )); 541 + } 542 + 543 + #[test] 544 + fn test_verify_witness_proofs_threshold_met() { 545 + let (signing_key, multikey) = make_ed25519_keypair(); 546 + 547 + let entry = json!({ 548 + "versionId": "1-test", 549 + "versionTime": "2025-04-29T17:15:59Z", 550 + "parameters": {"method": "did:webvh:1.0"}, 551 + "state": {"id": "did:webvh:test:example.com"} 552 + }); 553 + 554 + let proof_value = sign_entry(&signing_key, &entry); 555 + let verification_method = format!("did:key:{}#{}", multikey, multikey); 556 + 557 + let proof = DataIntegrityProof { 558 + r#type: "DataIntegrityProof".to_string(), 559 + cryptosuite: "eddsa-jcs-2022".to_string(), 560 + verification_method, 561 + created: "2025-04-29T17:15:59Z".to_string(), 562 + proof_purpose: "assertionMethod".to_string(), 563 + proof_value, 564 + }; 565 + 566 + let witness_config = super::super::model::WitnessConfig { 567 + threshold: 1, 568 + witnesses: vec![super::super::model::WitnessEntry { 569 + id: format!("did:key:{}", multikey), 570 + weight: 1, 571 + }], 572 + }; 573 + 574 + let result = verify_witness_proofs(&entry, &[proof], &witness_config, 1); 575 + assert!(result.is_ok(), "witness verification failed: {:?}", result); 576 + } 577 + 578 + #[test] 579 + fn test_verify_witness_proofs_threshold_not_met() { 580 + let (signing_key, multikey) = make_ed25519_keypair(); 581 + 582 + let entry = json!({ 583 + "versionId": "1-test", 584 + "versionTime": "2025-04-29T17:15:59Z", 585 + "parameters": {}, 586 + "state": {} 587 + }); 588 + 589 + let proof_value = sign_entry(&signing_key, &entry); 590 + let verification_method = format!("did:key:{}#{}", multikey, multikey); 591 + 592 + let proof = DataIntegrityProof { 593 + r#type: "DataIntegrityProof".to_string(), 594 + cryptosuite: "eddsa-jcs-2022".to_string(), 595 + verification_method, 596 + created: "2025-04-29T17:15:59Z".to_string(), 597 + proof_purpose: "assertionMethod".to_string(), 598 + proof_value, 599 + }; 600 + 601 + let witness_config = super::super::model::WitnessConfig { 602 + threshold: 3, // Requires weight of 3, but only 1 witness with weight 1 603 + witnesses: vec![super::super::model::WitnessEntry { 604 + id: format!("did:key:{}", multikey), 605 + weight: 1, 606 + }], 607 + }; 608 + 609 + let result = verify_witness_proofs(&entry, &[proof], &witness_config, 1); 610 + assert!(matches!( 611 + result, 612 + Err(WebVHDIDError::WitnessVerificationFailed { .. }) 613 + )); 614 + } 615 + 616 + #[test] 617 + fn test_verify_witness_proofs_multiple_witnesses_weighted() { 618 + let (signing_key1, multikey1) = make_ed25519_keypair(); 619 + let (signing_key2, multikey2) = make_ed25519_keypair(); 620 + 621 + let entry = json!({ 622 + "versionId": "1-test", 623 + "versionTime": "2025-04-29T17:15:59Z", 624 + "parameters": {}, 625 + "state": {} 626 + }); 627 + 628 + let proof1 = DataIntegrityProof { 629 + r#type: "DataIntegrityProof".to_string(), 630 + cryptosuite: "eddsa-jcs-2022".to_string(), 631 + verification_method: format!("did:key:{}#{}", multikey1, multikey1), 632 + created: "2025-04-29T17:15:59Z".to_string(), 633 + proof_purpose: "assertionMethod".to_string(), 634 + proof_value: sign_entry(&signing_key1, &entry), 635 + }; 636 + 637 + let proof2 = DataIntegrityProof { 638 + r#type: "DataIntegrityProof".to_string(), 639 + cryptosuite: "eddsa-jcs-2022".to_string(), 640 + verification_method: format!("did:key:{}#{}", multikey2, multikey2), 641 + created: "2025-04-29T17:15:59Z".to_string(), 642 + proof_purpose: "assertionMethod".to_string(), 643 + proof_value: sign_entry(&signing_key2, &entry), 644 + }; 645 + 646 + let witness_config = super::super::model::WitnessConfig { 647 + threshold: 3, 648 + witnesses: vec![ 649 + super::super::model::WitnessEntry { 650 + id: format!("did:key:{}", multikey1), 651 + weight: 1, 652 + }, 653 + super::super::model::WitnessEntry { 654 + id: format!("did:key:{}", multikey2), 655 + weight: 2, 656 + }, 657 + ], 658 + }; 659 + 660 + // Both witnesses together have weight 3, meeting threshold 661 + let result = verify_witness_proofs(&entry, &[proof1, proof2], &witness_config, 1); 662 + assert!( 663 + result.is_ok(), 664 + "weighted witness verification failed: {:?}", 665 + result 666 + ); 667 + } 668 + 669 + #[test] 670 + fn test_verify_witness_proofs_empty() { 671 + let witness_config = super::super::model::WitnessConfig { 672 + threshold: 1, 673 + witnesses: vec![], 674 + }; 675 + 676 + let entry = json!({"versionId": "1-test"}); 677 + let result = verify_witness_proofs(&entry, &[], &witness_config, 1); 678 + assert!(matches!( 679 + result, 680 + Err(WebVHDIDError::WitnessVerificationFailed { .. }) 681 + )); 682 + } 683 + 684 + #[test] 685 + fn test_verify_witness_proofs_unknown_witness_skipped() { 686 + let (signing_key, multikey) = make_ed25519_keypair(); 687 + let (_, unknown_multikey) = make_ed25519_keypair(); 688 + 689 + let entry = json!({ 690 + "versionId": "1-test", 691 + "versionTime": "2025-04-29T17:15:59Z", 692 + "parameters": {}, 693 + "state": {} 694 + }); 695 + 696 + let proof = DataIntegrityProof { 697 + r#type: "DataIntegrityProof".to_string(), 698 + cryptosuite: "eddsa-jcs-2022".to_string(), 699 + verification_method: format!("did:key:{}#{}", multikey, multikey), 700 + created: "2025-04-29T17:15:59Z".to_string(), 701 + proof_purpose: "assertionMethod".to_string(), 702 + proof_value: sign_entry(&signing_key, &entry), 703 + }; 704 + 705 + // Witness config only knows about a different key 706 + let witness_config = super::super::model::WitnessConfig { 707 + threshold: 1, 708 + witnesses: vec![super::super::model::WitnessEntry { 709 + id: format!("did:key:{}", unknown_multikey), 710 + weight: 1, 711 + }], 712 + }; 713 + 714 + let result = verify_witness_proofs(&entry, &[proof], &witness_config, 1); 715 + assert!(matches!( 716 + result, 717 + Err(WebVHDIDError::WitnessVerificationFailed { .. }) 718 + )); 719 + } 720 + 721 + #[test] 722 + fn test_verify_witness_proofs_invalid_signature_skipped() { 723 + let (_, multikey) = make_ed25519_keypair(); 724 + let (other_signing_key, _) = make_ed25519_keypair(); 725 + 726 + let entry = json!({ 727 + "versionId": "1-test", 728 + "versionTime": "2025-04-29T17:15:59Z", 729 + "parameters": {}, 730 + "state": {} 731 + }); 732 + 733 + // Sign with wrong key 734 + let proof = DataIntegrityProof { 735 + r#type: "DataIntegrityProof".to_string(), 736 + cryptosuite: "eddsa-jcs-2022".to_string(), 737 + verification_method: format!("did:key:{}#{}", multikey, multikey), 738 + created: "2025-04-29T17:15:59Z".to_string(), 739 + proof_purpose: "assertionMethod".to_string(), 740 + proof_value: sign_entry(&other_signing_key, &entry), 741 + }; 742 + 743 + let witness_config = super::super::model::WitnessConfig { 744 + threshold: 1, 745 + witnesses: vec![super::super::model::WitnessEntry { 746 + id: format!("did:key:{}", multikey), 747 + weight: 1, 748 + }], 749 + }; 750 + 751 + let result = verify_witness_proofs(&entry, &[proof], &witness_config, 1); 752 + assert!(matches!( 753 + result, 754 + Err(WebVHDIDError::WitnessVerificationFailed { .. }) 755 + )); 756 + } 757 + 758 + #[test] 759 + fn test_verify_proof_with_key_module() { 760 + // Test using the key module's Ed25519 support 761 + let private_key = generate_key(KeyType::Ed25519Private).unwrap(); 762 + let public_key = to_public(&private_key).unwrap(); 763 + let multikey = format!("{}", &public_key) 764 + .strip_prefix("did:key:") 765 + .unwrap() 766 + .to_string(); 767 + 768 + let entry = json!({ 769 + "versionId": "1-test", 770 + "versionTime": "2025-04-29T17:15:59Z", 771 + "parameters": {"method": "did:webvh:1.0"}, 772 + "state": {"id": "test"} 773 + }); 774 + 775 + // Sign using key module 776 + let mut entry_without_proof = entry.clone(); 777 + entry_without_proof.as_object_mut().unwrap().remove("proof"); 778 + let canonical = jcs::canonicalize(&entry_without_proof); 779 + let signature = key_sign(&private_key, canonical.as_bytes()).unwrap(); 780 + let proof_value = multibase::encode(multibase::Base::Base58Btc, &signature); 781 + 782 + let proof = DataIntegrityProof { 783 + r#type: "DataIntegrityProof".to_string(), 784 + cryptosuite: "eddsa-jcs-2022".to_string(), 785 + verification_method: format!("did:key:{}#{}", multikey, multikey), 786 + created: "2025-04-29T17:15:59Z".to_string(), 787 + proof_purpose: "assertionMethod".to_string(), 788 + proof_value, 789 + }; 790 + 791 + let update_keys = vec![multikey]; 792 + let result = verify_proof(&entry, &proof, &update_keys, 1); 793 + assert!(result.is_ok(), "proof verification failed: {:?}", result); 794 + } 795 + }
+477
crates/atproto-identity/src/webvh/scid.rs
··· 1 + //! SCID computation, verification, and entry hash operations for did:webvh. 2 + //! 3 + //! Implements the Self-Certifying Identifier (SCID) algorithm and entry hash 4 + //! computation used to create verifiable chains of DID document updates. 5 + //! All hashing uses SHA-256 with multihash encoding and base58btc multibase output. 6 + 7 + use sha2::{Digest, Sha256}; 8 + 9 + use crate::errors::WebVHDIDError; 10 + 11 + use super::jcs; 12 + 13 + /// SHA-256 multihash header: algorithm identifier (0x12) + digest length (0x20 = 32 bytes). 14 + const MULTIHASH_SHA256_HEADER: [u8; 2] = [0x12, 0x20]; 15 + 16 + /// Valid base58btc alphabet characters. 17 + const BASE58BTC_ALPHABET: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; 18 + 19 + /// Minimum expected SCID length in base58btc characters (includes 'z' prefix). 20 + const SCID_MIN_LENGTH: usize = 45; 21 + 22 + /// Maximum expected SCID length in base58btc characters (includes 'z' prefix). 23 + const SCID_MAX_LENGTH: usize = 48; 24 + 25 + /// Hash algorithms supported by did:webvh. 26 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 27 + pub enum HashAlgorithm { 28 + /// SHA-256 (multihash code 0x12, digest length 0x20). 29 + Sha256, 30 + } 31 + 32 + /// Detects the hash algorithm from a base58btc-encoded multihash SCID. 33 + /// 34 + /// Decodes the multibase string, reads the multihash header, and returns 35 + /// the identified algorithm. 36 + pub fn detect_hash_algorithm(scid: &str) -> Result<HashAlgorithm, WebVHDIDError> { 37 + let (_, decoded) = multibase::decode(scid).map_err(|e| WebVHDIDError::InvalidSCIDFormat { 38 + details: format!("failed to decode SCID multibase: {}", e), 39 + })?; 40 + 41 + if decoded.len() < 2 { 42 + return Err(WebVHDIDError::InvalidSCIDFormat { 43 + details: "SCID multihash too short".to_string(), 44 + }); 45 + } 46 + 47 + match (decoded[0], decoded[1]) { 48 + (0x12, 0x20) => Ok(HashAlgorithm::Sha256), 49 + (code, length) => Err(WebVHDIDError::UnsupportedHashAlgorithm { 50 + details: format!( 51 + "multihash code {:#04x} with length {:#04x} is not supported", 52 + code, length 53 + ), 54 + }), 55 + } 56 + } 57 + 58 + /// Validates the hash algorithm in a SCID matches the permitted algorithms 59 + /// for the given method version. 60 + /// 61 + /// For "did:webvh:1.0" and "did:webvh:0.5", only SHA-256 is permitted. 62 + pub fn validate_hash_algorithm( 63 + scid: &str, 64 + method_version: &str, 65 + ) -> Result<HashAlgorithm, WebVHDIDError> { 66 + let algorithm = detect_hash_algorithm(scid)?; 67 + 68 + match method_version { 69 + "did:webvh:1.0" | "did:webvh:0.5" => match algorithm { 70 + HashAlgorithm::Sha256 => Ok(algorithm), 71 + }, 72 + _ => Err(WebVHDIDError::UnsupportedHashAlgorithm { 73 + details: format!("unknown method version: {}", method_version), 74 + }), 75 + } 76 + } 77 + 78 + /// Validates the format of a SCID string. 79 + /// 80 + /// Checks that the SCID: 81 + /// - Is exactly 46 characters long 82 + /// - Contains only valid base58btc characters 83 + /// - Starts with 'z' (base58btc multibase prefix) 84 + pub fn validate_scid_format(scid: &str) -> Result<(), WebVHDIDError> { 85 + if !scid.starts_with('z') { 86 + return Err(WebVHDIDError::InvalidSCIDFormat { 87 + details: format!( 88 + "SCID must start with 'z' (base58btc prefix), got '{}'", 89 + scid.chars().next().unwrap_or(' ') 90 + ), 91 + }); 92 + } 93 + 94 + if scid.len() < SCID_MIN_LENGTH || scid.len() > SCID_MAX_LENGTH { 95 + return Err(WebVHDIDError::InvalidSCIDFormat { 96 + details: format!( 97 + "SCID must be {}-{} characters, got {}", 98 + SCID_MIN_LENGTH, 99 + SCID_MAX_LENGTH, 100 + scid.len() 101 + ), 102 + }); 103 + } 104 + 105 + // Check all characters after the 'z' prefix are valid base58btc 106 + for ch in scid[1..].chars() { 107 + if !BASE58BTC_ALPHABET.contains(ch) { 108 + return Err(WebVHDIDError::InvalidSCIDFormat { 109 + details: format!("invalid base58btc character in SCID: '{}'", ch), 110 + }); 111 + } 112 + } 113 + 114 + Ok(()) 115 + } 116 + 117 + /// Computes a SHA-256 multihash of the input data. 118 + /// 119 + /// Returns bytes in the format: `[0x12, 0x20, ...32 hash bytes...]` 120 + pub fn compute_multihash_sha256(data: &[u8]) -> Vec<u8> { 121 + let hash = Sha256::digest(data); 122 + let mut result = Vec::with_capacity(2 + hash.len()); 123 + result.extend_from_slice(&MULTIHASH_SHA256_HEADER); 124 + result.extend_from_slice(&hash); 125 + result 126 + } 127 + 128 + /// Computes the base58btc-encoded multihash of the input data. 129 + /// 130 + /// Returns the multibase-encoded string with the `z` prefix (base58btc). 131 + pub fn compute_multihash_base58btc(data: &[u8]) -> String { 132 + let multihash = compute_multihash_sha256(data); 133 + multibase::encode(multibase::Base::Base58Btc, &multihash) 134 + } 135 + 136 + /// Computes the entry hash for a log entry. 137 + /// 138 + /// Algorithm: 139 + /// 1. Remove the `proof` and `versionId` fields from the entry 140 + /// 2. JCS-canonicalize the result 141 + /// 3. SHA-256 multihash the canonical bytes 142 + /// 4. Base58btc multibase encode 143 + /// 144 + /// The `versionId` is excluded because it contains the hash itself 145 + /// (format: `{N}-{hash}`), making its inclusion circular. 146 + pub fn compute_entry_hash(entry: &serde_json::Value) -> Result<String, WebVHDIDError> { 147 + let mut hashable = entry.clone(); 148 + if let Some(obj) = hashable.as_object_mut() { 149 + obj.remove("proof"); 150 + obj.remove("versionId"); 151 + } else { 152 + return Err(WebVHDIDError::InvalidVersionId { 153 + entry: 0, 154 + details: "log entry is not a JSON object".to_string(), 155 + }); 156 + } 157 + 158 + let canonical = jcs::canonicalize(&hashable); 159 + Ok(compute_multihash_base58btc(canonical.as_bytes())) 160 + } 161 + 162 + /// Parses a version ID into its number and hash components. 163 + /// 164 + /// Version IDs have the format `{number}-{hash}`, e.g., `1-QmV8pidQB1...`. 165 + pub fn parse_version_id(version_id: &str) -> Result<(u64, &str), WebVHDIDError> { 166 + let dash_pos = version_id 167 + .find('-') 168 + .ok_or_else(|| WebVHDIDError::InvalidVersionId { 169 + entry: 0, 170 + details: format!("missing dash separator in version ID: {}", version_id), 171 + })?; 172 + 173 + let number_str = &version_id[..dash_pos]; 174 + let hash = &version_id[dash_pos + 1..]; 175 + 176 + let number: u64 = number_str 177 + .parse() 178 + .map_err(|_| WebVHDIDError::InvalidVersionId { 179 + entry: 0, 180 + details: format!("invalid version number: {}", number_str), 181 + })?; 182 + 183 + if hash.is_empty() { 184 + return Err(WebVHDIDError::InvalidVersionId { 185 + entry: 0, 186 + details: "empty hash in version ID".to_string(), 187 + }); 188 + } 189 + 190 + Ok((number, hash)) 191 + } 192 + 193 + /// Verifies the entry hash matches the hash component of the version ID. 194 + /// 195 + /// Returns the parsed version number on success. 196 + pub fn verify_version_id( 197 + entry: &serde_json::Value, 198 + expected_number: u64, 199 + entry_index: usize, 200 + ) -> Result<u64, WebVHDIDError> { 201 + let version_id = entry 202 + .get("versionId") 203 + .and_then(|v| v.as_str()) 204 + .ok_or_else(|| WebVHDIDError::InvalidVersionId { 205 + entry: entry_index, 206 + details: "missing or non-string versionId".to_string(), 207 + })?; 208 + 209 + let (number, expected_hash) = parse_version_id(version_id).map_err(|e| { 210 + if let WebVHDIDError::InvalidVersionId { details, .. } = e { 211 + WebVHDIDError::InvalidVersionId { 212 + entry: entry_index, 213 + details, 214 + } 215 + } else { 216 + e 217 + } 218 + })?; 219 + 220 + if number != expected_number { 221 + return Err(WebVHDIDError::InvalidVersionId { 222 + entry: entry_index, 223 + details: format!("expected version {}, got {}", expected_number, number), 224 + }); 225 + } 226 + 227 + let computed_hash = compute_entry_hash(entry)?; 228 + 229 + if computed_hash != expected_hash { 230 + return Err(WebVHDIDError::EntryHashVerificationFailed { entry: entry_index }); 231 + } 232 + 233 + Ok(number) 234 + } 235 + 236 + /// Verifies the SCID of a genesis log entry. 237 + /// 238 + /// Algorithm: 239 + /// 1. Clone the genesis entry and remove `proof` 240 + /// 2. Replace `versionId` with the literal string `"{SCID}"` 241 + /// 3. Text-replace all occurrences of the actual SCID with `{SCID}` 242 + /// 4. JCS-canonicalize and compute multihash 243 + /// 5. Compare with the provided SCID 244 + pub fn verify_scid(scid: &str, genesis_entry: &serde_json::Value) -> Result<(), WebVHDIDError> { 245 + let mut entry = genesis_entry.clone(); 246 + 247 + // Remove proof 248 + if let Some(obj) = entry.as_object_mut() { 249 + obj.remove("proof"); 250 + } 251 + 252 + // Set versionId to "{SCID}" 253 + if let Some(obj) = entry.as_object_mut() { 254 + obj.insert( 255 + "versionId".to_string(), 256 + serde_json::Value::String("{SCID}".to_string()), 257 + ); 258 + } 259 + 260 + // Serialize to JSON string 261 + let json_str = serde_json::to_string(&entry).map_err(|e| WebVHDIDError::InvalidSCIDFormat { 262 + details: format!("failed to serialize genesis entry: {}", e), 263 + })?; 264 + 265 + // Replace all SCID occurrences with placeholder 266 + let replaced = json_str.replace(scid, "{SCID}"); 267 + 268 + // Parse back to Value for JCS canonicalization 269 + let replaced_value: serde_json::Value = 270 + serde_json::from_str(&replaced).map_err(|e| WebVHDIDError::InvalidSCIDFormat { 271 + details: format!("failed to parse replaced entry: {}", e), 272 + })?; 273 + 274 + let canonical = jcs::canonicalize(&replaced_value); 275 + let computed_scid = compute_multihash_base58btc(canonical.as_bytes()); 276 + 277 + if computed_scid != scid { 278 + return Err(WebVHDIDError::SCIDVerificationFailed); 279 + } 280 + 281 + Ok(()) 282 + } 283 + 284 + #[cfg(test)] 285 + mod tests { 286 + use super::*; 287 + use serde_json::json; 288 + 289 + #[test] 290 + fn test_compute_multihash_sha256() { 291 + let data = b"hello"; 292 + let result = compute_multihash_sha256(data); 293 + // Should start with SHA-256 multihash header 294 + assert_eq!(result[0], 0x12); // SHA-256 identifier 295 + assert_eq!(result[1], 0x20); // 32 bytes length 296 + assert_eq!(result.len(), 34); // 2 header + 32 hash 297 + 298 + // Verify deterministic 299 + let result2 = compute_multihash_sha256(data); 300 + assert_eq!(result, result2); 301 + } 302 + 303 + #[test] 304 + fn test_compute_multihash_base58btc() { 305 + let data = b"hello"; 306 + let result = compute_multihash_base58btc(data); 307 + // Should start with 'z' (base58btc multibase prefix) 308 + assert!(result.starts_with('z')); 309 + 310 + // Verify deterministic 311 + let result2 = compute_multihash_base58btc(data); 312 + assert_eq!(result, result2); 313 + } 314 + 315 + #[test] 316 + fn test_compute_entry_hash() { 317 + let entry = json!({ 318 + "versionId": "1-test", 319 + "versionTime": "2025-04-29T17:15:59Z", 320 + "parameters": {"method": "did:webvh:1.0"}, 321 + "state": {"id": "did:webvh:test:example.com"}, 322 + "proof": [{"type": "DataIntegrityProof"}] 323 + }); 324 + 325 + let hash = compute_entry_hash(&entry).unwrap(); 326 + assert!(hash.starts_with('z')); 327 + 328 + // Proof should not affect hash 329 + let mut entry2 = entry.clone(); 330 + entry2["proof"] = json!([{"type": "DataIntegrityProof", "extra": "data"}]); 331 + let hash2 = compute_entry_hash(&entry2).unwrap(); 332 + assert_eq!(hash, hash2); 333 + } 334 + 335 + #[test] 336 + fn test_compute_entry_hash_not_object() { 337 + let entry = json!("not an object"); 338 + assert!(compute_entry_hash(&entry).is_err()); 339 + } 340 + 341 + #[test] 342 + fn test_parse_version_id() { 343 + let (num, hash) = parse_version_id("1-QmTest").unwrap(); 344 + assert_eq!(num, 1); 345 + assert_eq!(hash, "QmTest"); 346 + 347 + let (num, hash) = parse_version_id("42-zAbcdef").unwrap(); 348 + assert_eq!(num, 42); 349 + assert_eq!(hash, "zAbcdef"); 350 + } 351 + 352 + #[test] 353 + fn test_parse_version_id_invalid() { 354 + // No dash 355 + assert!(parse_version_id("1QmTest").is_err()); 356 + // Empty hash 357 + assert!(parse_version_id("1-").is_err()); 358 + // Non-numeric version 359 + assert!(parse_version_id("abc-QmTest").is_err()); 360 + } 361 + 362 + #[test] 363 + fn test_verify_version_id_valid() { 364 + // Create an entry, compute its hash, then set the versionId 365 + let mut entry = json!({ 366 + "versionTime": "2025-04-29T17:15:59Z", 367 + "parameters": {"method": "did:webvh:1.0"}, 368 + "state": {"id": "did:webvh:test:example.com"} 369 + }); 370 + 371 + // Compute the actual hash (versionId is excluded from hash) 372 + let hash = compute_entry_hash(&entry).unwrap(); 373 + entry["versionId"] = serde_json::Value::String(format!("1-{}", hash)); 374 + 375 + // Should verify successfully 376 + let result = verify_version_id(&entry, 1, 1); 377 + assert!(result.is_ok()); 378 + assert_eq!(result.unwrap(), 1); 379 + } 380 + 381 + #[test] 382 + fn test_verify_version_id_wrong_number() { 383 + let entry = json!({ 384 + "versionId": "2-zSomeHash", 385 + "parameters": {}, 386 + "state": {} 387 + }); 388 + 389 + let result = verify_version_id(&entry, 1, 1); 390 + assert!(matches!( 391 + result, 392 + Err(WebVHDIDError::InvalidVersionId { .. }) 393 + )); 394 + } 395 + 396 + #[test] 397 + fn test_verify_version_id_hash_mismatch() { 398 + let entry = json!({ 399 + "versionId": "1-zWrongHash", 400 + "versionTime": "2025-04-29T17:15:59Z", 401 + "parameters": {}, 402 + "state": {} 403 + }); 404 + 405 + let result = verify_version_id(&entry, 1, 1); 406 + assert!(matches!( 407 + result, 408 + Err(WebVHDIDError::EntryHashVerificationFailed { .. }) 409 + )); 410 + } 411 + 412 + #[test] 413 + fn test_verify_scid_roundtrip() { 414 + // Build a preliminary entry with {SCID} placeholders 415 + let preliminary = json!({ 416 + "versionId": "{SCID}", 417 + "versionTime": "2025-04-29T17:15:59Z", 418 + "parameters": { 419 + "method": "did:webvh:1.0", 420 + "scid": "{SCID}", 421 + "updateKeys": ["z6MkTestKey"] 422 + }, 423 + "state": { 424 + "@context": ["https://www.w3.org/ns/did/v1"], 425 + "id": "did:webvh:{SCID}:example.com" 426 + } 427 + }); 428 + 429 + // Compute SCID 430 + let canonical = jcs::canonicalize(&preliminary); 431 + let scid = compute_multihash_base58btc(canonical.as_bytes()); 432 + 433 + // Replace {SCID} with actual value 434 + let json_str = serde_json::to_string(&preliminary).unwrap(); 435 + let replaced = json_str.replace("{SCID}", &scid); 436 + let genesis: serde_json::Value = serde_json::from_str(&replaced).unwrap(); 437 + 438 + // Verification should succeed 439 + assert!(verify_scid(&scid, &genesis).is_ok()); 440 + } 441 + 442 + #[test] 443 + fn test_verify_scid_mismatch() { 444 + let entry = json!({ 445 + "versionId": "zWrongSCID", 446 + "versionTime": "2025-04-29T17:15:59Z", 447 + "parameters": { 448 + "method": "did:webvh:1.0", 449 + "scid": "zWrongSCID", 450 + "updateKeys": ["z6MkTestKey"] 451 + }, 452 + "state": { 453 + "@context": ["https://www.w3.org/ns/did/v1"], 454 + "id": "did:webvh:zWrongSCID:example.com" 455 + } 456 + }); 457 + 458 + assert!(matches!( 459 + verify_scid("zWrongSCID", &entry), 460 + Err(WebVHDIDError::SCIDVerificationFailed) 461 + )); 462 + } 463 + 464 + #[test] 465 + fn test_entry_hash_deterministic() { 466 + let entry = json!({ 467 + "versionId": "1-test", 468 + "versionTime": "2025-01-01T00:00:00Z", 469 + "parameters": {"method": "did:webvh:1.0"}, 470 + "state": {"id": "test"} 471 + }); 472 + 473 + let hash1 = compute_entry_hash(&entry).unwrap(); 474 + let hash2 = compute_entry_hash(&entry).unwrap(); 475 + assert_eq!(hash1, hash2); 476 + } 477 + }
+5 -5
crates/atproto-jetstream/src/consumer.rs
··· 56 56 const MAX_MESSAGE_SIZE: usize = 56000; 57 57 58 58 /// Configuration for the Jetstream consumer task 59 - #[cfg_attr(debug_assertions, derive(Debug))] 59 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 60 60 #[derive(Clone)] 61 61 pub struct ConsumerTaskConfig { 62 62 /// User-Agent header value for WebSocket connections ··· 80 80 } 81 81 82 82 /// Event data structure for Jetstream events 83 - #[cfg_attr(debug_assertions, derive(Debug))] 83 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 84 84 #[derive(Clone, Serialize, Deserialize)] 85 85 #[serde(untagged)] 86 86 pub enum JetstreamEvent { ··· 134 134 } 135 135 136 136 /// Repository commit operation details 137 - #[cfg_attr(debug_assertions, derive(Debug))] 137 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 138 138 #[derive(Clone, Serialize, Deserialize)] 139 139 pub struct JetstreamEventCommit { 140 140 /// Repository revision identifier ··· 152 152 } 153 153 154 154 /// Repository delete operation details 155 - #[cfg_attr(debug_assertions, derive(Debug))] 155 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 156 156 #[derive(Clone, Serialize, Deserialize)] 157 157 pub struct JetstreamEventDelete { 158 158 /// Repository revision identifier ··· 180 180 fn handler_id(&self) -> &str; 181 181 } 182 182 183 - #[cfg_attr(debug_assertions, derive(Debug))] 183 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 184 184 #[derive(Clone, Serialize, Deserialize)] 185 185 #[serde(tag = "type", content = "payload")] 186 186 pub(crate) enum SubscriberSourcedMessage {
+1 -1
crates/atproto-oauth/src/dpop.rs
··· 433 433 /// Configuration for DPoP JWT validation. 434 434 /// 435 435 /// This struct allows callers to specify what aspects of the DPoP JWT should be validated. 436 - #[cfg_attr(debug_assertions, derive(Debug))] 436 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 437 437 #[derive(Clone)] 438 438 pub struct DpopValidationConfig { 439 439 /// Expected HTTP method (e.g., "POST", "GET"). If None, method validation is skipped.
+2 -1
crates/atproto-oauth/src/jwk.rs
··· 18 18 19 19 /// A wrapped JSON Web Key with additional metadata. 20 20 #[derive(Serialize, Deserialize, Clone, PartialEq)] 21 - #[cfg_attr(debug_assertions, derive(Debug))] 21 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 22 22 #[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))] 23 23 pub struct WrappedJsonWebKey { 24 24 /// Key identifier (kid) for the JWK. ··· 57 57 KeyType::P384Private => Some("ES384".to_string()), 58 58 KeyType::K256Public => Some("ES256K".to_string()), 59 59 KeyType::K256Private => Some("ES256K".to_string()), 60 + KeyType::Ed25519Public | KeyType::Ed25519Private => Some("EdDSA".to_string()), 60 61 }; 61 62 let jwk = key_data.try_into()?; 62 63
+4 -3
crates/atproto-oauth/src/jwt.rs
··· 19 19 20 20 /// JWT header containing algorithm and key metadata. 21 21 #[derive(Clone, Default, PartialEq, Serialize, Deserialize)] 22 - #[cfg_attr(debug_assertions, derive(Debug))] 22 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 23 23 #[cfg_attr(feature = "zeroize", derive(Zeroize, ZeroizeOnDrop))] 24 24 pub struct Header { 25 25 /// Algorithm used for signing (e.g., "ES256", "ES384", "ES256K"). ··· 50 50 KeyType::P384Private => Some("ES384".to_string()), 51 51 KeyType::K256Public => Some("ES256K".to_string()), 52 52 KeyType::K256Private => Some("ES256K".to_string()), 53 + KeyType::Ed25519Public | KeyType::Ed25519Private => Some("EdDSA".to_string()), 53 54 }; 54 55 55 56 let public_key = to_public(&value)?; ··· 65 66 } 66 67 67 68 /// JWT claims combining standard JOSE claims with custom private claims. 68 - #[cfg_attr(debug_assertions, derive(Debug))] 69 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 69 70 #[derive(Clone, Default, PartialEq, Serialize, Deserialize)] 70 71 pub struct Claims { 71 72 /// Standard JOSE claims. ··· 90 91 pub type SecondsSinceEpoch = u64; 91 92 92 93 /// Standard JOSE claims for JWT tokens. 93 - #[cfg_attr(debug_assertions, derive(Debug))] 94 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 94 95 #[derive(Clone, Default, PartialEq, Serialize, Deserialize)] 95 96 pub struct JoseClaims { 96 97 /// Issuer of the token.
+1 -1
crates/atproto-oauth/src/resources.rs
··· 28 28 /// OAuth 2.0 authorization server metadata from RFC 8414 oauth-authorization-server endpoint. 29 29 /// 30 30 /// AT Protocol requires specific grant types, scopes, authentication methods, and security features. 31 - #[cfg_attr(debug_assertions, derive(Debug))] 31 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 32 32 #[derive(Clone, Deserialize, Default)] 33 33 pub struct AuthorizationServer { 34 34 /// URL of the authorization server's token introspection endpoint (optional).
+2 -2
crates/atproto-record/src/aturi.rs
··· 46 46 /// 47 47 /// This struct provides validated access to these components after successful parsing 48 48 /// and implements `Display` for reconstructing the original URI format. 49 - #[cfg_attr(debug_assertions, derive(Debug))] 49 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 50 50 #[derive(Clone)] 51 51 pub struct ATURI { 52 52 /// The authority component as a DID (e.g., "did:plc:abc123") ··· 89 89 90 90 let did = match parse_input(authority) { 91 91 Ok(InputType::Handle(_)) => return Err(AturiError::HandleNotSupported), 92 - Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => did, 92 + Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) | Ok(InputType::WebVH(did)) => did, 93 93 Err(error) => return Err(AturiError::AuthorityParsingFailed { error }), 94 94 }; 95 95
+2 -2
crates/atproto-record/src/lexicon/app_bsky_richtext_facet.rs
··· 142 142 /// }); 143 143 /// ``` 144 144 #[derive(Serialize, Deserialize, Clone, PartialEq)] 145 - #[cfg_attr(debug_assertions, derive(Debug))] 145 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 146 146 #[serde(tag = "$type")] 147 147 pub enum FacetFeature { 148 148 /// Account mention feature ··· 195 195 /// }; 196 196 /// ``` 197 197 #[derive(Serialize, Deserialize, Clone, PartialEq)] 198 - #[cfg_attr(debug_assertions, derive(Debug))] 198 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 199 199 pub struct Facet { 200 200 /// Byte range this facet applies to 201 201 pub index: ByteSlice,
+1 -1
crates/atproto-record/src/lexicon/com_atproto_repo.rs
··· 35 35 /// let typed_ref = TypedStrongRef::new(strong_ref); 36 36 /// ``` 37 37 #[derive(Serialize, Deserialize, PartialEq, Clone)] 38 - #[cfg_attr(debug_assertions, derive(Debug))] 38 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 39 39 pub struct StrongRef { 40 40 /// AT URI pointing to a specific record 41 41 /// Format: `at://[did]/[collection]/[rkey]`
+2 -2
crates/atproto-record/src/lexicon/community_lexicon_attestation.rs
··· 37 37 /// let reference = SignatureOrRef::Reference(typed_strong_ref); 38 38 /// ``` 39 39 #[derive(Deserialize, Serialize, Clone, PartialEq)] 40 - #[cfg_attr(debug_assertions, derive(Debug))] 40 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 41 41 #[serde(untagged)] 42 42 pub enum SignatureOrRef { 43 43 /// A reference to a signature stored elsewhere ··· 76 76 /// }; 77 77 /// ``` 78 78 #[derive(Deserialize, Serialize, Clone, PartialEq)] 79 - #[cfg_attr(debug_assertions, derive(Debug))] 79 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 80 80 pub struct Signature { 81 81 /// The cryptographic signature bytes 82 82 pub signature: Bytes,
+2 -2
crates/atproto-record/src/lexicon/community_lexicon_badge.rs
··· 40 40 /// let typed_badge = TypedDefinition::new(badge); 41 41 /// ``` 42 42 #[derive(Serialize, Deserialize, Clone, PartialEq)] 43 - #[cfg_attr(debug_assertions, derive(Debug))] 43 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 44 44 pub struct Definition { 45 45 /// Name of the badge 46 46 pub name: String, ··· 94 94 /// let typed_award = TypedAward::new(award); 95 95 /// ``` 96 96 #[derive(Serialize, Deserialize, Clone, PartialEq)] 97 - #[cfg_attr(debug_assertions, derive(Debug))] 97 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 98 98 pub struct Award { 99 99 /// Reference to the badge definition being awarded 100 100 pub badge: StrongRef,
+8 -8
crates/atproto-record/src/lexicon/community_lexicon_calendar_event.rs
··· 24 24 /// 25 25 /// Represents the current status of a calendar event. 26 26 #[derive(Serialize, Deserialize, PartialEq, Clone, Default)] 27 - #[cfg_attr(debug_assertions, derive(Debug))] 27 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 28 28 pub enum Status { 29 29 /// Event is scheduled and confirmed 30 30 #[default] ··· 52 52 /// 53 53 /// Represents how attendees can participate in the event. 54 54 #[derive(Serialize, Deserialize, PartialEq, Clone, Default)] 55 - #[cfg_attr(debug_assertions, derive(Debug))] 55 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 56 56 pub enum Mode { 57 57 /// In-person attendance only 58 58 #[default] ··· 78 78 /// Represents a URI with an optional human-readable name. 79 79 /// Used for linking to external resources related to an event. 80 80 #[derive(Serialize, Deserialize, PartialEq, Clone)] 81 - #[cfg_attr(debug_assertions, derive(Debug))] 81 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 82 82 pub struct NamedUri { 83 83 /// The URI/URL 84 84 pub uri: String, ··· 111 111 /// Similar to NamedUri but kept as a separate type for semantic clarity 112 112 /// and type safety when dealing with event-specific links. 113 113 #[derive(Serialize, Deserialize, PartialEq, Clone)] 114 - #[cfg_attr(debug_assertions, derive(Debug))] 114 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 115 115 pub struct EventLink { 116 116 /// The URI/URL for the event link 117 117 pub uri: String, ··· 143 143 /// 144 144 /// Represents the width-to-height ratio of visual media. 145 145 #[derive(Serialize, Deserialize, PartialEq, Clone)] 146 - #[cfg_attr(debug_assertions, derive(Debug))] 146 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 147 147 pub struct AspectRatio { 148 148 /// Width component of the ratio 149 149 pub width: u64, ··· 160 160 /// 161 161 /// Represents images, videos, or other media associated with an event. 162 162 #[derive(Serialize, Deserialize, PartialEq, Clone)] 163 - #[cfg_attr(debug_assertions, derive(Debug))] 163 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 164 164 pub struct Media { 165 165 /// The media content as a blob reference 166 166 pub content: TypedBlob, ··· 198 198 /// 199 199 /// Extends `LocationOrRef` with URI location support specific to calendar events. 200 200 #[derive(Deserialize, Serialize, Clone, PartialEq)] 201 - #[cfg_attr(debug_assertions, derive(Debug))] 201 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 202 202 #[serde(untagged)] 203 203 pub enum EventLocation { 204 204 /// An inline URI location ··· 241 241 /// let typed_event = TypedEvent::new(event); 242 242 /// ``` 243 243 #[derive(Serialize, Deserialize, PartialEq, Clone)] 244 - #[cfg_attr(debug_assertions, derive(Debug))] 244 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 245 245 pub struct Event { 246 246 /// Name/title of the event 247 247 pub name: String,
+2 -2
crates/atproto-record/src/lexicon/community_lexicon_calendar_rsvp.rs
··· 19 19 /// 20 20 /// Represents the response status for an event invitation. 21 21 #[derive(Serialize, Deserialize, PartialEq, Clone, Default)] 22 - #[cfg_attr(debug_assertions, derive(Debug))] 22 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 23 23 pub enum RsvpStatus { 24 24 /// Attendee is planning to attend 25 25 #[default] ··· 63 63 /// let typed_rsvp = TypedRsvp::new(rsvp); 64 64 /// ``` 65 65 #[derive(Serialize, Deserialize, PartialEq, Clone)] 66 - #[cfg_attr(debug_assertions, derive(Debug))] 66 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 67 67 pub struct Rsvp { 68 68 /// Reference to the event being responded to 69 69 pub subject: StrongRef,
+5 -5
crates/atproto-record/src/lexicon/community_lexicon_location.rs
··· 44 44 /// let location = LocationOrRef::InlineAddress(TypedAddress::new(address)); 45 45 /// ``` 46 46 #[derive(Deserialize, Serialize, Clone, PartialEq)] 47 - #[cfg_attr(debug_assertions, derive(Debug))] 47 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 48 48 #[serde(untagged)] 49 49 pub enum LocationOrRef { 50 50 /// A reference to a location stored elsewhere ··· 87 87 /// }; 88 88 /// ``` 89 89 #[derive(Serialize, Deserialize, PartialEq, Clone)] 90 - #[cfg_attr(debug_assertions, derive(Debug))] 90 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 91 91 pub struct Address { 92 92 /// Country name (required) 93 93 pub country: String, ··· 143 143 /// }; 144 144 /// ``` 145 145 #[derive(Serialize, Deserialize, PartialEq, Clone)] 146 - #[cfg_attr(debug_assertions, derive(Debug))] 146 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 147 147 pub struct Geo { 148 148 /// Latitude coordinate as a string 149 149 pub latitude: String, ··· 181 181 /// }; 182 182 /// ``` 183 183 #[derive(Serialize, Deserialize, PartialEq, Clone)] 184 - #[cfg_attr(debug_assertions, derive(Debug))] 184 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 185 185 pub struct Fsq { 186 186 /// Foursquare place identifier 187 187 pub fsq_place_id: String, ··· 216 216 /// }; 217 217 /// ``` 218 218 #[derive(Serialize, Deserialize, PartialEq, Clone)] 219 - #[cfg_attr(debug_assertions, derive(Debug))] 219 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 220 220 pub struct Hthree { 221 221 /// H3 hexagon identifier 222 222 pub value: String,
+3 -3
crates/atproto-record/src/lexicon/primatives.rs
··· 34 34 /// let typed_blob = TypedBlob::new(blob); 35 35 /// ``` 36 36 #[derive(Serialize, Deserialize, PartialEq, Clone)] 37 - #[cfg_attr(debug_assertions, derive(Debug))] 37 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 38 38 pub struct Blob { 39 39 /// Link to the blob content via CID 40 40 #[serde(rename = "ref")] ··· 63 63 /// Represents a content-addressed link using a CID (Content Identifier). 64 64 /// This is used to reference immutable content in the AT Protocol network. 65 65 #[derive(Serialize, Deserialize, PartialEq, Clone)] 66 - #[cfg_attr(debug_assertions, derive(Debug))] 66 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 67 67 pub struct Link { 68 68 /// The CID (Content Identifier) as a string 69 69 #[serde(rename = "$link")] ··· 85 85 /// // Serializes to: {"$bytes": "c2lnbmF0dXJlIGRhdGE="} 86 86 /// ``` 87 87 #[derive(Serialize, Deserialize, PartialEq, Clone)] 88 - #[cfg_attr(debug_assertions, derive(Debug))] 88 + #[cfg_attr(any(debug_assertions, test), derive(Debug))] 89 89 pub struct Bytes { 90 90 /// The raw bytes, serialized as base64 in JSON 91 91 #[serde(rename = "$bytes", with = "bytes_format")]