diff --git a/rustfs/src/connect/offline/enrollment.rs b/rustfs/src/connect/offline/enrollment.rs index 7975c3955..ba25a92b4 100644 --- a/rustfs/src/connect/offline/enrollment.rs +++ b/rustfs/src/connect/offline/enrollment.rs @@ -271,6 +271,21 @@ struct SignedDocument { signature: DocumentSignature, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnverifiedSignedDocument { + bytes: String, + signature: UnverifiedDocumentSignature, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnverifiedDocumentSignature { + algorithm: Option, + key_id: Option, + value: Option, +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct DocumentSignature { @@ -364,7 +379,8 @@ impl OfflineEnrollment { now_unix: i64, root: EnrollmentRoot, ) -> Result { - let envelope: SignedDocument = serde_json::from_slice(document).map_err(|_| EnrollmentError::MalformedDocument)?; + let envelope: UnverifiedSignedDocument = + serde_json::from_slice(document).map_err(|_| EnrollmentError::MalformedDocument)?; // Decode the exact transmitted octets before reading any routing field. // Standard padded base64 is canonical in this protocol: accepting an @@ -385,7 +401,17 @@ impl OfflineEnrollment { // parsing any trust-link envelope or routing fields. Otherwise a // malformed link could mask a malformed artifact signature with // DOCUMENT_MALFORMED. - let signature = decode_signature(&envelope.signature)?; + let signature_member = |value: Option| { + value + .and_then(|value| value.as_str().map(str::to_owned)) + .ok_or(EnrollmentError::SignatureMalformed) + }; + let signature_document = DocumentSignature { + algorithm: signature_member(envelope.signature.algorithm)?, + key_id: signature_member(envelope.signature.key_id)?, + value: signature_member(envelope.signature.value)?, + }; + let signature = decode_signature(&signature_document)?; let first = routing.trust_chain.first().ok_or(EnrollmentError::MalformedDocument)?; let first_bytes = first @@ -413,7 +439,7 @@ impl OfflineEnrollment { // Step 6. The detached signature must name the same chained key whose // public key verifies it. A different well-formed key id is a signature // failure, not an opportunity to ignore the binding. - if envelope.signature.key_id != routing.connect_key_id || !verifies(&connect_key, TAG_CHALLENGE, &bytes, &signature) { + if signature_document.key_id != routing.connect_key_id || !verifies(&connect_key, TAG_CHALLENGE, &bytes, &signature) { return Err(EnrollmentError::SignatureInvalid); } @@ -438,7 +464,7 @@ impl OfflineEnrollment { issued_at: challenge.issued_at, expires_at: challenge.expires_at, connect_key_id: challenge.connect_key_id, - challenge_proof: envelope.signature.value, + challenge_proof: signature_document.value, }) } diff --git a/rustfs/tests/connect_offline_enrollment.rs b/rustfs/tests/connect_offline_enrollment.rs index 70cd68a88..24e5dd891 100644 --- a/rustfs/tests/connect_offline_enrollment.rs +++ b/rustfs/tests/connect_offline_enrollment.rs @@ -357,6 +357,41 @@ fn every_challenge_accept_vector_verifies_and_exposes_the_signed_fields() { ); } +#[test] +fn malformed_top_level_signature_members_keep_the_frozen_reason() { + let vector = accept_vector_named("challenge signed by a chained signing key under the pinned root"); + let now = unix(field(&vector, "evaluationTime")); + + for (member, malformed) in [ + ("algorithm", serde_json::json!(1)), + ("keyId", serde_json::Value::Null), + ("value", serde_json::json!([])), + ] { + let mut document = vector["document"].clone(); + document["signature"][member] = malformed; + + let error = OfflineEnrollment::verify_challenge(&envelope(&document), now) + .expect_err(&format!("a malformed top-level signature {member} must be refused")); + assert_eq!( + error.reason(), + "SIGNATURE_MALFORMED", + "a malformed top-level signature {member} keeps the frozen classifier" + ); + } +} + +#[test] +fn duplicate_top_level_signature_members_are_refused() { + let vector = accept_vector_named("challenge signed by a chained signing key under the pinned root"); + let document = String::from_utf8(envelope(&vector["document"])).expect("envelope is UTF-8 JSON"); + let duplicate = document.replacen("\"algorithm\":\"ES256\"", "\"algorithm\":\"ES384\",\"algorithm\":\"ES256\"", 1); + assert_ne!(duplicate, document, "the accepted vector carries the expected algorithm"); + + let error = OfflineEnrollment::verify_challenge(duplicate.as_bytes(), unix(field(&vector, "evaluationTime"))) + .expect_err("a duplicate top-level signature member must be refused"); + assert_eq!(error.reason(), "DOCUMENT_MALFORMED"); +} + #[cfg(feature = "offline-enrollment-e2e-root")] #[test] fn e2e_root_is_fixed_and_disjoint_from_the_hosted_root() {