lys-core 0.3.2

Domain-agnostic cryptographic trust primitives — certificate authority, Merkle transparency log, signed attestations, sealed envelopes, and Ed25519 key management.
Documentation
#![cfg(test)]
//! Parse refusals: invalid pairs, the envelope malleability sweep, integer
//! heads, map order, the payload, the tag, the content type and the size
//! cap, all one error.

use super::*;

#[test]
fn a_hand_built_invalid_kind_role_pair_encodes_and_is_then_refused() {
    // `to_cose_bytes` is infallible by design, so a struct literal can pair a
    // domain with speaks-for and encode it. The decoder refuses it, which is what
    // makes the pair rule a property of the format rather than of the issuing
    // path — an artifact minted by another implementation is refused too.
    //
    // Note what does NOT save us here: the byte-compare cannot, because the
    // canonical re-encoding of an invalid pair is the invalid pair. If
    // `decode_payload`'s pair check were deleted, this test would fail and the
    // envelope sweep would not.
    let mut refused = 0;
    for (kind, role) in [
        (DelegationSubjectKind::Domain, DelegationRole::SpeaksFor),
        (DelegationSubjectKind::Seat, DelegationRole::Operational),
    ] {
        let mut delegation = sample();
        delegation.claim.subject_kind = kind;
        delegation.claim.role = role;
        let bytes = delegation.to_cose_bytes();
        assert!(
            ciborium::de::from_reader::<ciborium::value::Value, _>(bytes.as_slice()).is_ok(),
            "the mutant must be well-formed CBOR for this test to mean anything"
        );
        assert!(
            matches!(
                Delegation::from_cose_bytes(&bytes),
                Err(TrustError::DelegationVerification)
            ),
            "({kind:?}, {role:?}) parsed back"
        );
        refused += 1;
    }
    assert_eq!(refused, 2, "both invalid pairs must have been tried");

    // Positive controls: the two valid pairs parse back through the same route.
    Delegation::from_cose_bytes(&sample().to_cose_bytes()).unwrap();
    Delegation::from_cose_bytes(&seat_sample().to_cose_bytes()).unwrap();
}

// ---------------------------------------------------------------------------
// Envelope canonicality — the byte-compare's own territory.
// ---------------------------------------------------------------------------

#[test]
fn the_envelope_malleability_sweep_is_refused() {
    // Every one of these is well-formed CBOR carrying the identical statement,
    // and every one is a second spelling of an artifact that must have exactly
    // one. Each is asserted to parse as CBOR first, so a mutant that merely
    // corrupted the document would be caught here rather than passing as a
    // canonicality rejection.
    let good = sample().to_cose_bytes();
    let protected = encoding::protected_bytes(CONTENT_TYPE, &ROOT_KEY);
    let payload = encoding::payload_bytes(&sample().claim);

    // The head widths the canonical form uses, so the mutants below are known to
    // be *widenings* rather than corrections.
    assert_eq!(&good[..4], &[0xd2, 0x84, 0x58, 0x4f]);

    let mut mutants: Vec<(&str, Vec<u8>)> = Vec::new();

    // 1. Non-minimal tag head: `d8 12` instead of `d2`.
    let mut m = vec![0xd8, 0x12];
    m.extend_from_slice(&good[1..]);
    mutants.push(("non-minimal tag head", m));

    // 2. Non-minimal outer-array head: `98 04` instead of `84`.
    let mut m = vec![0xd2, 0x98, 0x04];
    m.extend_from_slice(&good[2..]);
    mutants.push(("non-minimal outer-array head", m));

    // 3. Indefinite-length outer array: `9f ... ff`. This is the case spec §6.2
    //    designates as the isolating injection for the canonicality rule.
    let mut m = vec![0xd2, 0x9f];
    m.extend_from_slice(&good[2..]);
    m.push(0xff);
    mutants.push(("indefinite outer array", m));

    // 4. Indefinite-length protected bstr: `5f 58 4f <79 bytes> ff`.
    let mut m = vec![0xd2, 0x84, 0x5f, 0x58, 0x4f];
    m.extend_from_slice(&protected);
    m.push(0xff);
    m.extend_from_slice(&good[PROTECTED_AT + PROTECTED_LEN..]);
    mutants.push(("indefinite protected bstr", m));

    // 5. Non-minimal protected bstr length head: `59 00 4f` instead of `58 4f`.
    let mut m = vec![0xd2, 0x84, 0x59, 0x00, 0x4f];
    m.extend_from_slice(&protected);
    m.extend_from_slice(&good[PROTECTED_AT + PROTECTED_LEN..]);
    mutants.push(("non-minimal protected bstr head", m));

    // 6. Indefinite-length unprotected map: `bf ff` instead of `a0`.
    let mut m = good[..UNPROTECTED_AT].to_vec();
    m.extend_from_slice(&[0xbf, 0xff]);
    m.extend_from_slice(&good[UNPROTECTED_AT + 1..]);
    mutants.push(("indefinite unprotected map", m));

    // 7. Indefinite-length payload bstr.
    let mut m = good[..=UNPROTECTED_AT].to_vec();
    m.push(0x5f);
    m.push(0x58);
    m.push(u8::try_from(payload.len()).unwrap());
    m.extend_from_slice(&payload);
    m.push(0xff);
    m.extend_from_slice(&[0x58, 0x40]);
    m.extend_from_slice(&SIGNATURE);
    mutants.push(("indefinite payload bstr", m));

    // 8. Non-minimal payload bstr length head: `59 00 xx`.
    let mut m = good[..=UNPROTECTED_AT].to_vec();
    m.extend_from_slice(&[0x59, 0x00, u8::try_from(payload.len()).unwrap()]);
    m.extend_from_slice(&payload);
    m.extend_from_slice(&[0x58, 0x40]);
    m.extend_from_slice(&SIGNATURE);
    mutants.push(("non-minimal payload bstr head", m));

    // 9. Non-minimal signature bstr length head: `59 00 40` instead of `58 40`.
    let mut m = good[..good.len() - 66].to_vec();
    m.extend_from_slice(&[0x59, 0x00, 0x40]);
    m.extend_from_slice(&SIGNATURE);
    mutants.push(("non-minimal signature bstr head", m));

    // 10. Indefinite-length signature bstr.
    let mut m = good[..good.len() - 66].to_vec();
    m.extend_from_slice(&[0x5f, 0x58, 0x40]);
    m.extend_from_slice(&SIGNATURE);
    m.push(0xff);
    mutants.push(("indefinite signature bstr", m));

    // 11. Trailing garbage. ciborium stops at the end of the first value, so
    //     nothing but the byte-compare sees this.
    let mut m = good.clone();
    m.push(0x00);
    mutants.push(("trailing garbage", m));

    // 12. Sixteen bytes of trailing garbage, so the case is not special to one.
    let mut m = good.clone();
    m.extend_from_slice(&[0xff; 16]);
    mutants.push(("trailing garbage, 16 bytes", m));

    let mut refused = 0;
    for (name, mutant) in &mutants {
        assert_ne!(mutant, &good, "{name} did not change the artifact");
        assert!(
            ciborium::de::from_reader::<ciborium::value::Value, _>(mutant.as_slice()).is_ok(),
            "{name} is not well-formed CBOR, so its rejection would prove nothing"
        );
        assert!(
            matches!(
                Delegation::from_cose_bytes(mutant),
                Err(TrustError::DelegationVerification)
            ),
            "{name} was accepted"
        );
        refused += 1;
    }
    assert_eq!(
        refused, 12,
        "every malleability mutant must have been tried"
    );

    // The positive control. Without it a parser that refused everything would
    // satisfy all twelve assertions above.
    Delegation::from_cose_bytes(&good).unwrap();
}

#[test]
fn an_oversized_integer_head_in_the_payload_is_refused() {
    // Encode the role `2` as `19 00 02` (two-byte head) rather than `02`. The
    // value is identical; the bytes are not shortest-form, so the same statement
    // would have two encodings.
    //
    // The role entry is located by its neighbours, not by a byte search: the same
    // two bytes can occur inside the delegated key or the subject value, and a
    // fixture where they do not is a fixture rather than a property.
    let payload = encoding::payload_bytes(&sample().claim);
    // `06 19 01 2c` (4) + `05 1b <8 bytes>` (10) + `04 <role>` (2) = 16.
    let role_at = payload.len() - 4 - 10 - 2;
    assert_eq!(
        payload[role_at], 0x04,
        "label 4 must be where the tail puts it"
    );
    assert_eq!(payload[role_at + 1], 0x02, "operational is wire value 2");
    assert_eq!(payload[role_at + 2], 0x05, "label 5 follows the role value");

    let mut fat = payload[..role_at].to_vec();
    fat.extend_from_slice(&[0x04, 0x19, 0x00, 0x02]);
    fat.extend_from_slice(&payload[role_at + 2..]);

    let mutant = artifact_with_payload(&fat);
    assert!(
        ciborium::de::from_reader::<ciborium::value::Value, _>(mutant.as_slice()).is_ok(),
        "the mutant must be well-formed CBOR for this test to mean anything"
    );
    assert!(Delegation::from_cose_bytes(&mutant).is_err());
}

#[test]
fn a_reordered_protected_map_is_refused() {
    // Swap labels 3 and 4 in the protected bucket. Every value is unchanged and
    // a permissive verifier reads the same headers; the bytes differ, so the
    // signature over them would differ too.
    //
    // Note what refuses this: the positional decode pins, NOT the byte-compare.
    // `encoding_tests::decode_refuses_a_permuted_payload_map_*` is the isolating
    // case for that rule.
    let good = sample().to_cose_bytes();
    let protected = &good[PROTECTED_AT..PROTECTED_AT + PROTECTED_LEN];

    let mut reordered = vec![0xa3, 0x01, 0x27];
    reordered.extend_from_slice(&[0x04, 0x58, 0x20]);
    reordered.extend_from_slice(&ROOT_KEY);
    reordered.extend_from_slice(&protected[3..3 + 3 + CONTENT_TYPE.len()]);
    assert_eq!(reordered.len(), PROTECTED_LEN);
    assert_ne!(reordered.as_slice(), protected);

    let mut mutant = good[..PROTECTED_AT].to_vec();
    mutant.extend_from_slice(&reordered);
    mutant.extend_from_slice(&good[PROTECTED_AT + PROTECTED_LEN..]);
    assert_eq!(mutant.len(), good.len());
    assert!(Delegation::from_cose_bytes(&mutant).is_err());
}

#[test]
fn a_detached_nil_payload_is_not_a_delegation() {
    // The shape a receipt uses. A delegation's assertion cannot be recomputed by
    // anyone, so an artifact that does not carry its payload asserts nothing.
    let protected = encoding::protected_bytes(CONTENT_TYPE, &ROOT_KEY);
    let mut mutant = vec![0xd2, 0x84, 0x58, 0x4f];
    mutant.extend_from_slice(&protected);
    mutant.push(0xa0);
    mutant.push(0xf6);
    mutant.extend_from_slice(&[0x58, 0x40]);
    mutant.extend_from_slice(&SIGNATURE);
    assert!(
        ciborium::de::from_reader::<ciborium::value::Value, _>(mutant.as_slice()).is_ok(),
        "the mutant must be well-formed CBOR for this test to mean anything"
    );
    assert!(Delegation::from_cose_bytes(&mutant).is_err());
}

#[test]
fn stripping_the_tag_is_refused() {
    let good = sample().to_cose_bytes();
    assert_eq!(good[0], 0xd2);
    assert!(Delegation::from_cose_bytes(&good[1..]).is_err());
}

#[test]
fn a_wrong_tag_number_is_refused() {
    // Tag 98 is `COSE_Sign` (multi-signer): the same outer array shape carrying
    // a different meaning.
    let mut mutant = sample().to_cose_bytes();
    mutant[0] = 0xd8;
    mutant.insert(1, 98);
    assert!(Delegation::from_cose_bytes(&mutant).is_err());
}

#[test]
fn a_receipt_content_type_in_the_protected_bucket_is_refused_by_the_parser() {
    // The parser pins its own constant, so an otherwise perfectly shaped
    // artifact bearing another lys artifact's discriminator never parses.
    // `sign_tests` proves the same thing for an artifact with a *valid*
    // signature, which is the case that matters.
    let confused = encoding::envelope_bytes(
        &encoding::protected_bytes("application/vnd.lys.receipt.v1+cbor", &ROOT_KEY),
        &encoding::payload_bytes(&sample().claim),
        &SIGNATURE,
    );
    assert!(Delegation::from_cose_bytes(&confused).is_err());
}

#[test]
fn a_ten_thousand_character_subject_parses_whole() {
    let subject = "o".repeat(10_000);
    let delegation = Delegation {
        root_public_key: ROOT_KEY,
        claim: claim_for(&subject, 0, 0),
        signature: SIGNATURE,
    };
    let bytes = delegation.to_cose_bytes();
    assert!(bytes.len() > 10_000);
    let parsed = Delegation::from_cose_bytes(&bytes).unwrap();
    assert_eq!(parsed.claim.subject_value, subject);
    assert_eq!(parsed, delegation);
}

#[test]
fn every_parse_failure_is_the_same_error() {
    // Non-oracle discipline, with a positive control: the unmodified artifact
    // must parse, or a parser that refused everything would satisfy every
    // assertion below without doing any work.
    let good = sample().to_cose_bytes();
    Delegation::from_cose_bytes(&good).unwrap();

    let mut trailing = good.clone();
    trailing.push(0x00);

    let mut truncated = good.clone();
    truncated.truncate(good.len() / 2);

    let mut untagged = good.clone();
    untagged.remove(0);

    // A well-formed one-entry unprotected bucket `{1: 1}`, spliced in place of
    // the empty map. The document stays valid CBOR; only the empty-bucket rule
    // rejects it.
    let mut non_empty_unprotected = good[..UNPROTECTED_AT].to_vec();
    non_empty_unprotected.extend_from_slice(&[0xa1, 0x01, 0x01]);
    non_empty_unprotected.extend_from_slice(&good[UNPROTECTED_AT + 1..]);
    assert!(
        ciborium::de::from_reader::<ciborium::value::Value, _>(non_empty_unprotected.as_slice())
            .is_ok(),
        "the non-empty-bucket mutant must be well-formed CBOR"
    );

    let cases = [
        ("trailing garbage", trailing),
        ("truncated", truncated),
        ("untagged", untagged),
        ("non-empty unprotected", non_empty_unprotected),
        ("empty input", vec![]),
        ("noise", vec![0xff; 10]),
    ];

    let mut refused = 0;
    for (name, mutant) in cases {
        let err = Delegation::from_cose_bytes(&mutant).unwrap_err();
        assert!(
            matches!(err, TrustError::DelegationVerification),
            "{name} produced a distinguishable error: {err:?}"
        );
        assert_eq!(
            format!("{err}"),
            format!("{}", TrustError::DelegationVerification),
            "{name} produced a distinguishable message"
        );
        refused += 1;
    }
    assert_eq!(refused, 6, "every mutant must have been rejected");
}