diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..59f586b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug Report +about: Report a bug in the CoRIM crate +title: "[Bug] " +labels: bug +assignees: '' + +--- + +## Description + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +1. ... +2. ... +3. ... + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. Include error messages, stack traces, or CBOR hex dumps if applicable. + +## Environment + +- **Rust version**: (`rustc --version`) +- **corim crate version**: +- **OS**: +- **CBOR backend feature**: (e.g., `cbor-ciborium`) + +## Additional Context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ade68f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,30 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: "[Feature] " +labels: enhancement +assignees: '' + +--- + +## Summary + +A clear and concise description of the feature you'd like. + +## Motivation + +Why is this feature needed? What problem does it solve? + +## Proposed Solution + +Describe the solution you'd like. Reference relevant CDDL productions or sections of +[draft-ietf-rats-corim-10](https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html) +if applicable. + +## Alternatives Considered + +Any alternative solutions or features you've considered. + +## Additional Context + +Add any other context, CDDL snippets, or CBOR examples here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d09ceb6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## Description + + + +## Related Issues + + + +## Checklist + +- [ ] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guidelines +- [ ] All new source files include the Microsoft copyright header +- [ ] New public APIs have doc comments +- [ ] Tests have been added or updated +- [ ] `cargo test --all` passes +- [ ] `cargo fmt --all -- --check` passes +- [ ] `cargo clippy --all -- -D warnings` passes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b1a61e6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,385 @@ +# CoRIM Crate — Copilot Instructions + +This document describes the code patterns and conventions used throughout the +`corim` Rust crate. Follow these rules when generating or modifying code to +ensure consistency across the codebase. + +## Project overview + +A Rust implementation of Concise Reference Integrity Manifest (CoRIM) per +[draft-ietf-rats-corim-10](https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html). +Three crates in a workspace: `corim` (library), `corim-macros` (proc-macro +derives), `corim-cli` (CLI tool). Zero external CBOR dependencies — uses an +in-house minimal encoder/decoder. + +## Specification references + +- **Primary spec**: draft-ietf-rats-corim-10 (CoRIM, CoMID, CoTL) +- **CBOR**: RFC 8949 (STD 94), deterministic encoding per §4.2.1 +- **COSE**: RFC 9052 (STD 96), specifically COSE_Sign1 (§4) +- **CoSWID**: RFC 9393 +- **CWT Claims**: RFC 8392 / RFC 9597 +- **COSE Hash Envelope**: draft-ietf-cose-hash-envelope + +Always cite the specific RFC/draft section in doc-comments and constant +definitions (e.g., `/// Per RFC 9052 §4.4`). + +## Named constants — no magic numbers + +Every numeric literal that corresponds to a wire-format key, tag number, or +protocol value MUST be defined as a named constant with a doc-comment citing +its source. + +### CBOR tag constants (`types/tags.rs`) + +All CBOR tag numbers from the CoRIM/CoMID/CoSWID registries live in +`types/tags.rs`. Use `pub const TAG_*: u64` naming. These are imported via +`use super::tags::*` throughout `types/`. + +```rust +/// `tagged-unsigned-corim-map` = `#6.501(unsigned-corim-map)`. +pub const TAG_CORIM: u64 = 501; +``` + +### CDDL map key constants (`types/tags.rs`) + +Integer keys for CBOR map fields (e.g., `&(id: 0)`) use module-level +constants in `tags.rs`, grouped by map type with comments matching the IANA +registry section. + +```rust +// CoRIM Map keys (§12.3) +pub const CORIM_KEY_ID: i64 = 0; +pub const CORIM_KEY_TAGS: i64 = 1; +``` + +### COSE / CWT constants (`types/signed.rs`) + +COSE header labels and CWT claim keys live at the top of `signed.rs`, before +any type definitions. COSE headers are `pub const COSE_HEADER_*: i64`. +CWT claim keys are `const CWT_CLAIM_*: i64` (crate-private, since they are +a different namespace from COSE headers despite overlapping numeric values). +String protocol constants use descriptive names: + +```rust +pub const COSE_HEADER_ALG: i64 = 1; // RFC 9052 +const CWT_CLAIM_ISS: i64 = 1; // RFC 8392 §4 +pub const CORIM_CONTENT_TYPE: &str = "application/rim+cbor"; +const SIG_STRUCTURE1_CONTEXT: &str = "Signature1"; // RFC 9052 §4.4 +``` + +### Where NOT to use constants + +Simple structural values like array lengths in match guards (e.g., +`a.len() == 4` for the 4-element COSE_Sign1 array) are acceptable as inline +literals when the error message immediately explains the expectation. + +## Integer safety — no `as` casts for narrowing + +**NEVER** use `as i64`, `as u64`, `as usize`, or `as i128` for narrowing +conversions. These silently truncate. + +### Required pattern + +```rust +// ✅ Correct: returns an error on overflow +let key = i64::try_from(*n).map_err(|_| serde::de::Error::custom("out of range"))?; + +// ❌ WRONG: silent truncation +let key = *n as i64; +``` + +### Widening casts + +Widening casts (`i64 as i128`, `u64 as i128`) are acceptable since they +cannot lose data. Comment them when not obvious. + +### Float-to-integer conversions + +Always validate range before casting: + +```rust +let n = *f; +if n.is_nan() || n.is_infinite() || n < (i64::MIN as f64) || n > (i64::MAX as f64) { + return Err("out of range".into()); +} +Ok(n as i64) +``` + +## Serde patterns for CBOR types + +### Derive macros for CBOR maps + +Structs that map to CDDL `{ ... }` maps use `CborSerialize`/`CborDeserialize` +derives with `#[cbor(key = N)]` attributes. Keys MUST be in ascending order. + +```rust +#[derive(CborSerialize, CborDeserialize)] +pub struct CorimMap { + #[cbor(key = 0)] + pub id: CorimId, + #[cbor(key = 1)] + pub tags: Vec, + #[cbor(key = 2, optional)] + pub dependent_rims: Option>, +} +``` + +### Hand-written serde for type-choice enums + +Type-choice enums (CDDL `int / text / ...`) need hand-written `Serialize` +and `Deserialize` impls that go through `Value` for tag dispatch: + +```rust +impl<'de> Deserialize<'de> for MyTypeChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Text(t) => Ok(Self::Text(t)), + Value::Tag(TAG_FOO, inner) => { /* ... */ } + _ => Err(serde::de::Error::custom("expected ...")), + } + } +} +``` + +Every `match` MUST have a catch-all `_ => Err(...)` arm. Never silently +accept unexpected input. + +### Hand-written serde for CBOR maps (signed.rs pattern) + +When a map has dynamic/extensible keys (e.g., COSE headers, CWT claims), +use hand-written serde with `Value::Map` iteration and named constant +matching: + +```rust +for (k, v) in map { + let key = match &k { + Value::Integer(n) => i64::try_from(*n) + .map_err(|_| serde::de::Error::custom("key out of range"))?, + _ => continue, // skip non-integer keys + }; + match key { + COSE_HEADER_ALG => { /* ... */ } + COSE_HEADER_CONTENT_TYPE => { /* ... */ } + _ => { extra.insert(key, v); } // forward-compat + } +} +``` + +Unknown keys MUST be skipped (or stored in an extras map) for forward +compatibility, never rejected. + +## Error handling + +### Error types + +Four error enums in `error.rs`, all `#[non_exhaustive]`: +- `EncodeError` — CBOR serialization failures +- `DecodeError` — CBOR deserialization / structural failures +- `BuilderError` — builder API misuse (with `#[from] EncodeError`) +- `ValidationError` — RFC constraint violations + +### When to use `String` vs typed enum variants + +Use a **typed enum variant** (with structured fields) when callers are +expected to match on the specific failure condition programmatically: + +```rust +// ✅ Caller can match: "was it expired or not-yet-valid?" +ValidationError::Expired +ValidationError::PayloadTooLarge { size, max } +DecodeError::UnexpectedTag { expected, found } +``` + +Use a **`String`-carrying variant** when the message is diagnostic-only +(not matched on), or when wrapping heterogeneous upstream error types: + +```rust +// ✅ Wraps serde/io errors — caller only needs "decode failed" +DecodeError::Deserialization(String) + +// ✅ Wraps 20+ Validate impls — caller only needs "validation failed" +ValidationError::Invalid(String) + +// ✅ Many distinct one-off structural violations in COSE decode +DecodeError::InvalidStructure(String) +``` + +Do NOT add a new typed variant for every possible error message. Only +promote a `String` to a typed variant when there is a concrete caller +that needs to match on it. This follows the same pattern as +`std::io::Error::new(ErrorKind::Other, msg)`. + +### `Validate` trait returns `String` + +`Validate::valid()` returns `Result<(), String>` deliberately. The trait +is implemented by 20+ types, each with different constraints. A single +shared error enum would be unmaintainable; per-type error enums would +break trait-object usage. The `String` return is bridged to +`ValidationError::Invalid(String)` at the public API boundary. + +### Rules + +- All public functions return `Result`. No `panic!`, `unwrap()`, or + `expect()` in non-test code unless provably safe (guarded by a prior + length check — document with a comment). +- Serde impls use `serde::de::Error::custom(...)` / `serde::ser::Error::custom(...)`. +- Never use `unreachable!()` unless the preceding match arm already + confirmed the variant (document with a comment). + +## Type design rules + +### `#[non_exhaustive]` on all public enums + +Every public enum MUST have `#[non_exhaustive]` for semver safety. + +### `#[must_use]` on all builder structs + +Every builder struct (`ComidBuilder`, `CotlBuilder`, `CorimBuilder`, +`SignedCorimBuilder`) MUST have `#[must_use]`. + +### Derive traits + +All public types derive `Clone, Debug, PartialEq` at minimum. Add `Eq` +when the type contains no floats. Add `Default` where semantically +meaningful. + +### Validate trait + +Types with RFC-defined constraints implement `Validate`: + +```rust +impl Validate for MyType { + fn valid(&self) -> Result<(), String> { + if self.required_field.is_none() { + return Err("required_field must be present".into()); + } + Ok(()) + } +} +``` + +## Builder pattern + +Builders use a fluent API with `mut self` → `Self` for infallible setters, +and `Result` for fallible ones. The terminal `build()` +method validates all constraints. `build_bytes()` combines `build()` + +CBOR encoding. + +For `SignedCorimBuilder`, the terminal methods are: +- `to_be_signed(&mut self, external_aad)` — emits the RFC 9052 TBS blob +- `build_with_signature(self, signature)` — attached payload mode +- `build_detached_with_signature(self, signature)` — detached (nil) payload + +The builder caches the protected header bytes after the first +`to_be_signed()` call. Any setter that modifies the protected header MUST +set `self.cached_protected_bytes = None`. + +## Signed CoRIM patterns (`types/signed.rs`) + +### Architecture: no crypto dependency + +The crate parses, validates, and constructs `#6.18(COSE_Sign1-corim)` +structures but does NOT perform cryptographic signature operations. The +caller: +1. Calls `to_be_signed()` / `to_be_signed_detached()` to get TBS bytes +2. Signs TBS externally with their crypto library +3. Calls `build_with_signature()` / `build_detached_with_signature()` + +### Protected header bytes preservation + +`CoseSign1Corim.protected_header_bytes` stores the exact `bstr` from the +COSE structure. This is the bytes that go into `Sig_structure1` for +verification. NEVER re-encode the protected header — always use the +original bytes. + +### Attached vs detached payload + +- `payload: Option>` — `Some` for attached, `None` for detached (nil) +- `to_be_signed()` — errors on detached envelopes (directs caller to use + `to_be_signed_detached()`) +- `to_be_signed_detached(payload, aad)` — works for both modes (payload + parameter takes precedence) +- `is_detached()` — convenience predicate + +### COSE bstr-wrapped fields + +`corim-meta` (key 8) is `bstr .cbor corim-meta-map` — it is CBOR-encoded +*inside* a CBOR byte string. On serialize, encode the inner map to bytes +first, then serialize as `Value::Bytes`. On deserialize, extract the byte +string, then decode the inner CBOR. + +`CWT-Claims` (key 15) is directly a CBOR map (NOT bstr-wrapped). Use +`cbor::value::from_value()` to deserialize from the `Value` tree. + +### Validation rules for protected header (§4.2.1) + +1. `alg` (key 1) MUST be present +2. At least one of `corim-meta` (key 8) or `CWT-Claims` (key 15) MUST be + present (the meta-group constraint) +3. Inline mode: `content-type` (key 3) MUST be present +4. Hash-envelope mode: `payload_preimage_content_type` (key 259) MUST be + present +5. When both `corim-meta` and `CWT-Claims` are present: + `signer-name` == `iss` (§4.2.1 consistency) + +## File organization + +``` +corim/src/ + lib.rs — crate root, Validate trait, doc examples + error.rs — 4 error enums (#[non_exhaustive]) + builder.rs — ComidBuilder, CotlBuilder, CorimBuilder + validate.rs — decode_and_validate, matching, appraisal + cbor/ — CBOR engine + serde bridge + types/ + mod.rs — module decls + selective re-exports + tags.rs — ALL RFC constants (CBOR tags, map keys, roles) + common.rs — type-choice enums, CborTime, EntityMap, etc. + corim.rs — CorimMap, ConciseTagChoice, CorimLocator, etc. + comid.rs — ComidTag (thin wrapper) + environment.rs — ClassMap, EnvironmentMap + measurement.rs — SvnChoice, FlagsMap, Digest, etc. + triples.rs — 9 triple types, TriplesMap + coswid.rs — ConciseSwidTag, SwidEntity, SwidLink + signed.rs — CoseSign1Corim, CwtClaims, SignedCorimBuilder + json/ — optional JSON conversion (feature-gated) +``` + +## Testing conventions + +- Tests live in `corim/tests/*.rs` (integration test files) +- Name test files by feature: `signed_corim_tests.rs`, `cddl_conformance_tests.rs` +- Every `Deserialize` error path needs a negative test +- Builder tests cover all constraint violations +- Round-trip tests: encode → decode → assert equality +- Use `cbor::encode` / `cbor::decode` directly, not through serde +- Test helpers (e.g., `build_sample_corim_bytes()`) are defined as + `fn` at the top of each test file, not in the library + +## Documentation style + +- Module-level `//!` doc with CDDL excerpt in ` ```text ``` ` block +- Every public type/function has `///` doc-comment +- Struct fields get `///` doc citing the CDDL key name and index +- Use `[`backtick-links`]` for cross-references within the crate +- RFC section references use `§N.N` format (e.g., `§4.2.1`) + +## Security audit checklist (for new code) + +When adding new types or serde impls, verify: + +1. ☐ Zero `as` narrowing casts — all use `try_from` +2. ☐ Zero `unwrap()` / `expect()` / `panic!()` in non-test code + (unless provably safe with comment) +3. ☐ Zero `unsafe` blocks +4. ☐ Every `Deserialize` match has `_ => Err(...)` catch-all +5. ☐ All integer map keys use named constants +6. ☐ All string protocol values use named constants +7. ☐ `#[non_exhaustive]` on every public enum +8. ☐ `#[must_use]` on every builder struct +9. ☐ `Validate` impl covers all MUST/SHOULD constraints +10. ☐ Payload size checked against `MAX_PAYLOAD_SIZE` before decode +11. ☐ Forward-compatible: unknown map keys skipped, not rejected +12. ☐ Float-to-int conversions validate range (NaN, infinity, overflow) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..18fb3fb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + branches: [main, staging] + pull_request: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + strategy: + matrix: + rust: [stable, nightly] + steps: + - uses: actions/checkout@v4 + + - name: Install Rust ${{ matrix.rust }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + run: cargo build --workspace --features json + + - name: Test + run: cargo test --workspace --features json + + fmt: + name: Formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + + - name: Clippy + run: cargo clippy --workspace --features json -- -D warnings + + docs: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Build docs + run: cargo doc --workspace --no-deps + env: + RUSTDOCFLAGS: "-D warnings" + + # -------------------------------------------------------------------------- + # Code coverage gate + # -------------------------------------------------------------------------- + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Run tests and check coverage threshold + run: | + cargo llvm-cov --workspace --features json \ + --ignore-filename-regex '(corim-cli/|corim-macros/|src/lib\.rs$)' \ + --fail-under-lines 70 diff --git a/.gitignore b/.gitignore index ad67955..ce8051f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,27 @@ -# Generated by Cargo -# will have compiled files and executables -debug -target +# Build artifacts +/target/ +**/target/ -# These are backup files generated by rustfmt -**/*.rs.bk +# Cargo lock is checked in for libraries per Rust convention discussion, +# but some prefer not to. Uncomment if desired: +# Cargo.lock + +# Editor/IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb +# Rust specific +**/*.rs.bk -# Generated by cargo mutants -# Contains mutation testing data -**/mutants.out*/ +# Test data / binary artifacts +*.cbor +*.cose +data/ -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Internal planning documents (tracked locally, not published) +PLAN.md +OPENSOURCE_CHECKLIST.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..686e5e7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f935d24 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to CoRIM + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [Contributor License Agreements](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## How to Contribute + +### Reporting Issues + +Please search the [existing issues](https://github.com/mingweishih/corim/issues) before filing new +issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. + +When filing a bug report, please include: + +- A clear description of the problem +- Steps to reproduce +- Expected vs. actual behavior +- Rust version (`rustc --version`) +- OS and architecture + +### Submitting Pull Requests + +1. Fork the repository and create a feature branch from `main`. +2. If you've added code, add tests that cover the new functionality. +3. Ensure the test suite passes: `cargo test --all` +4. Ensure your code is formatted: `cargo fmt --all -- --check` +5. Ensure clippy passes: `cargo clippy --all -- -D warnings` +6. Update documentation if you've changed APIs. +7. Submit your pull request. + +### Development Setup + +```bash +# Clone and build +git clone https://github.com/mingweishih/corim.git +cd corim +cargo build + +# Install pre-commit hook (runs fmt + clippy before each commit) +cp scripts/pre-commit.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit + +# Run tests +cargo test --all + +# Run lints (ALWAYS do this before commit/push — CI will reject failures) +cargo fmt --all -- --check +cargo clippy --workspace -- -D warnings +``` + +> **⚠️ Before every commit and push**, run: +> ```bash +> cargo fmt --all && cargo clippy --workspace -- -D warnings && cargo test --workspace +> ``` +> The CI pipeline rejects any formatting diffs or clippy warnings. +> The pre-commit hook automates the fmt + clippy checks. + +### Coding Guidelines + +- Follow standard Rust idioms and naming conventions. +- All public APIs must have doc comments. +- Every source file must include the Microsoft copyright header: + ```rust + // Copyright (c) Microsoft Corporation. + // Licensed under the MIT License. + ``` +- Keep CBOR backend abstraction intact — types must not import `ciborium` directly. +- New CDDL type additions should reference the relevant section of + [draft-ietf-rats-corim-10](https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6ef8cdb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "corim" +version = "0.1.0" +dependencies = [ + "corim-macros", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "corim-cli" +version = "0.1.0" +dependencies = [ + "clap", + "corim", + "hex", +] + +[[package]] +name = "corim-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..678dc84 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +members = [ + "corim", + "corim-macros", + "corim-cli", +] +resolver = "2" + +[workspace.dependencies] +corim-macros = { path = "corim-macros", version = "0.1.0" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f29f55d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e33b93 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# corim + +**Concise Reference Integrity Manifest (CoRIM)** — Rust implementation of +[draft-ietf-rats-corim-10](https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html). + +This crate provides CBOR-native Rust types for the CoRIM / CoMID CDDL schema, +a builder API, validation/appraisal logic, and signed CoRIM (COSE_Sign1) +support for Remote Attestation (RATS) Endorsements and Reference Values. + +## Features + +- **Full CDDL coverage** — types for `corim-map`, `concise-mid-tag` (CoMID), + `concise-tl-tag` (CoTL), all 9 triple types (reference, endorsed, identity, + attest-key, domain dependency/membership, CoSWID, conditional endorsement, + conditional endorsement series), `measurement-values-map` with all fields + (digests, SVN, flags, raw-value, MAC/IP addresses, integrity registers, + int-range, crypto keys, etc.). + +- **Signed CoRIM (`#6.18`)** — decode, validate, and construct COSE_Sign1-corim + structures per §4.2. Supports both attached and detached payload modes. + No cryptographic dependencies — the caller signs/verifies externally using + the emitted `Sig_structure1` TBS blob. Protected header extraction includes + `corim-meta`, `CWT-Claims`, and hash-envelope fields. + +- **Zero-dependency CBOR** — built-in CBOR encoder/decoder with deterministic + encoding per RFC 8949 §4.2.1. No external CBOR library required. The + `CborCodec` trait allows plugging in alternative backends in the future. + +- **Integer-keyed CBOR maps** — derive macros (`CborSerialize` / + `CborDeserialize`) emit deterministic CBOR with integer keys per RFC 8949 + §4.2.1. + +- **Builder API** — fluent `ComidBuilder`, `CotlBuilder`, `CorimBuilder`, and + `SignedCorimBuilder` for constructing tagged CoRIM payloads. + +- **Validation & Appraisal** — reference value matching (Phase 3) and + conditional endorsement series application (Phase 4) per §9 of the spec. + +- **CoSWID** — structured `ConciseSwidTag`, `SwidEntity`, `SwidLink` types + per RFC 9393 with co-constraint validation (patch/supplemental, tag-creator + role, patches link). + +- **Optional JSON** — `json` feature gate adds `Value ↔ serde_json::Value` + conversion with integer-to-string key remapping and type-choice JSON format. + +## Quick start + +```rust +use corim::builder::{ComidBuilder, CorimBuilder}; +use corim::types::common::{TagIdChoice, MeasuredElement}; +use corim::types::corim::CorimId; +use corim::types::environment::{ClassMap, EnvironmentMap}; +use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; +use corim::types::triples::ReferenceTriple; + +let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("ACME".into()), + model: Some("Widget".into()), + layer: None, + index: None, + }), + instance: None, + group: None, +}; + +let meas = MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, +}; + +// Build a CoMID with reference values +let comid = ComidBuilder::new(TagIdChoice::Text("my-comid-tag".into())) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .build() + .unwrap(); + +// Wrap in a CoRIM and encode to tag-501-wrapped CBOR +let bytes = CorimBuilder::new(CorimId::Text("my-corim".into())) + .add_comid_tag(comid).unwrap() + .build_bytes().unwrap(); + +// Decode and validate +let (_corim, _comids) = corim::validate::decode_and_validate(&bytes).unwrap(); +``` + +## Compliance notes + +This crate implements CoRIM per draft-ietf-rats-corim-10. + +| Feature | Status | +|---------|--------| +| **CoMID** (§5) — `#6.506` | ✅ Fully modeled — types, builder, validation, appraisal | +| **CoTL** (§6) — `#6.508` | ✅ Fully modeled — `ConciseTlTag`, `CotlBuilder`, validity checks | +| **CoSWID** (RFC 9393) — `#6.505` | ✅ Structured — `ConciseSwidTag`, `SwidEntity`, `SwidLink`; payload/evidence opaque | +| **Signed CoRIM** (§4.2) — `#6.18` | ✅ Decode, validate, construct (attached + detached); no crypto dependency | +| CDDL extension sockets | ❌ Not modeled; unknown keys silently skipped for forward compatibility | +| CoTS (concise-ta-stores) | ❌ Separate draft, not modeled | + +## Signed CoRIM + +The crate supports creating and parsing signed CoRIM documents (`#6.18` / +`COSE_Sign1-corim`) without any cryptographic dependencies. The caller +performs signature operations externally. + +```rust,no_run +use corim::types::signed::{SignedCorimBuilder, CwtClaims}; + +// 1. Build unsigned CoRIM payload bytes (tag-501-wrapped) +let corim_bytes: Vec = /* CorimBuilder::build_bytes() */ vec![]; + +// 2. Create a signed CoRIM builder +let mut builder = SignedCorimBuilder::new(-7, corim_bytes) // ES256 + .set_cwt_claims(CwtClaims::new("ACME Corp")); + +// 3. Get the Sig_structure1 TBS blob +let tbs = builder.to_be_signed(&[]).unwrap(); + +// 4. Sign with your crypto library (ring, openssl, etc.) +let signature = vec![0u8; 64]; // placeholder + +// 5. Produce the final signed CoRIM +let signed_bytes = builder.build_with_signature(signature).unwrap(); +``` + +For detached payloads, use `build_detached_with_signature()` and +`to_be_signed_detached()` on the decoded envelope. See the +[`types::signed`](corim/src/types/signed.rs) module documentation for +the full API. + +## Crate structure + +| Crate | Description | +|-------|-------------| +| `corim` | Main library — types, builder, validation, signed CoRIM, CBOR engine | +| `corim-macros` | Proc-macro derives for integer-keyed CBOR map serde | +| `corim-cli` | CLI tool for validating and inspecting CoRIM documents | + +## CBOR implementation + +This crate includes a built-in minimal CBOR encoder/decoder. No external CBOR +library is needed. + +**What's supported** — the CBOR subset used by CoRIM: +- All CBOR major types (unsigned/negative int, byte/text strings, arrays, maps, tags) +- Deterministic encoding per RFC 8949 §4.2.1 (canonical map key sorting) +- Semantic tags (essential for CoRIM type-choice dispatching) +- Half/single/double precision float decoding + +**Limitations** (none affect CoRIM functionality): +- No indefinite-length encoding (rejected on decode; CoRIM uses definite only) +- Float encoding always uses float64 (CoRIM rarely uses floats) +- No CBOR simple values beyond false/true/null (not used in CoRIM) +- Nesting depth limited by call stack (~100+ levels; CoRIM is typically 5–10) + +## CLI tool + +The `corim-cli` binary validates and inspects both unsigned (tag 501) and +signed (tag 18) CoRIM documents: + +```sh +# Validate an unsigned CoRIM +corim-cli --skip-expiry myfile.corim + +# Validate a signed CoRIM (auto-detected) +corim-cli --skip-expiry signed.corim + +# JSON output +corim-cli -f json myfile.corim +``` + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit [Contributor License Agreements](https://cla.opensource.microsoft.com). + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. + +## License + +[MIT](LICENSE) diff --git a/RFC_REFERENCES.md b/RFC_REFERENCES.md new file mode 100644 index 0000000..699974f --- /dev/null +++ b/RFC_REFERENCES.md @@ -0,0 +1,200 @@ +# RFC Reference Tracking + +This document tracks all RFCs and Internet-Drafts referenced by the `corim` crate implementation. It serves as a compliance checklist and must be updated when: + +- A referenced draft advances to a new revision or becomes an RFC +- The implementation adds support for a new specification +- An RFC errata affects our implementation + +**Last reviewed**: April 13, 2026 + +--- + +## Primary Specification + +### draft-ietf-rats-corim-10 — Concise Reference Integrity Manifests (CoRIM) + +| | | +|-|-| +| **Status** | Internet-Draft (not yet RFC) | +| **Version implemented** | **-10** (December 2024) | +| **URL** | https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html | +| **Datatracker** | https://datatracker.ietf.org/doc/draft-ietf-rats-corim/ | +| **CDDL source** | `cddl/corim.cddl` (local copy from -10) | + +#### Sections Implemented + +| Section | Topic | Status | Rust Module | +|---------|-------|--------|-------------| +| §4 | `corim-map` (unsigned CoRIM) | ✅ Full | `types/corim.rs` → `CorimMap` | +| §4.1.1 | `corim-id` | ✅ Full | `types/corim.rs` → `CorimId` | +| §4.1.3 | `corim-locator-map` | ✅ Full | `types/corim.rs` → `CorimLocator` | +| §4.1.4 | `profile` | ✅ Full | `types/corim.rs` → `ProfileChoice` | +| §4.1.5 | `entity-map` (CoRIM) | ✅ Full | `types/common.rs` → `EntityMap` | +| §4.2 | Signed CoRIM (`#6.18`) | ✅ Full (no crypto) | `types/signed.rs` → `CoseSign1Corim`, `SignedCorimBuilder` | +| §5 | `concise-mid-tag` (CoMID) | ✅ Full | `types/comid.rs` → `ComidTag` | +| §5.1.1 | `tag-identity-map` | ✅ Full | `types/common.rs` → `TagIdentity` | +| §5.1.2 | CoMID entities | ✅ Full | `types/common.rs` → `EntityMap` | +| §5.1.3 | `linked-tag-map` | ✅ Full | `types/common.rs` → `LinkedTagMap` | +| §5.1.4.1 | `environment-map` | ✅ Full | `types/environment.rs` → `EnvironmentMap` | +| §5.1.4.2 | `class-map` | ✅ Full | `types/environment.rs` → `ClassMap` | +| §5.1.4.5 | `measurement-map` | ✅ Full | `types/measurement.rs` → `MeasurementMap` | +| §5.1.4.5.3 | `version-map` | ✅ Full | `types/common.rs` → `VersionMap` | +| §5.1.5 | Reference triples | ✅ Full | `types/triples.rs` → `ReferenceTriple` | +| §5.1.6 | Endorsed triples | ✅ Full | `types/triples.rs` → `EndorsedTriple` | +| §5.1.7 | Conditional endorsement triples | ✅ Full | `types/triples.rs` → `ConditionalEndorsementTriple` | +| §5.1.8 | Conditional endorsement series | ✅ Full | `types/triples.rs` → `ConditionalEndorsementSeriesTriple` | +| §5.1.9 | Identity triples | ✅ Full | `types/triples.rs` → `IdentityTriple` | +| §5.1.10 | Attest-key triples | ✅ Full | `types/triples.rs` → `AttestKeyTriple` | +| §5.1.11.1 | Domain membership triples | ✅ Full | `types/triples.rs` → `DomainMembershipTriple` | +| §5.1.11.2 | Domain dependency triples | ✅ Full | `types/triples.rs` → `DomainDependencyTriple` | +| §5.1.12 | CoSWID triples | ✅ Full | `types/triples.rs` → `CoswidTriple` | +| §6 | `concise-tl-tag` (CoTL) | ✅ Full | `types/corim.rs` → `ConciseTlTag` | +| §6.1 | CoTL validity checks | ✅ Full | `validate.rs` → `validate_cotl` | +| §7 | Type-choice definitions | ✅ Full | `types/common.rs`, `types/measurement.rs` | +| §7.3 | `validity-map` | ✅ Full | `types/common.rs` → `ValidityMap` | +| §7.4 | UUID size constraints | ✅ Full | `types/tags.rs` → `UUID_SIZE` | +| §7.5 | UEID size constraints (7–33 bytes) | ✅ Full | `types/common.rs` → `InstanceIdChoice::Ueid` | +| §9 | Appraisal / Validation | ✅ Partial | `validate.rs` | +| §9.2 | Input validation | ✅ Full | `validate.rs` → `decode_and_validate` | +| §9.3.3 | Reference value matching | ✅ Full | `validate.rs` → `match_reference_values` | +| §9.3.4.3 | CES application | ✅ Full | `validate.rs` → `apply_endorsement_series` | +| §9.4.2 | Environment matching | ✅ Full | `validate.rs` → `environment_matches` | +| §9.4.6 | Measurement matching | ✅ Full | `validate.rs` → `measurement_matches` | +| §9.4.6.1.2 | SVN comparison | ✅ Full | `validate.rs` → `svn_matches` | +| §9.4.6.1.3 | Digest comparison | ✅ Full | `validate.rs` → `digests_match` | +| §12 | IANA registries / constants | ✅ Full | `types/tags.rs` (all constants) | + +#### Sections Not Implemented + +| Section | Topic | Reason | +|---------|-------|--------| +| CDDL `$$*-extension` sockets | Extension points | Deferred; unknown keys silently skipped for forward-compat | + +#### ⚠️ Draft Tracking Notes + +This is an **Internet-Draft**, not a finalized RFC. Changes to watch for: + +- **CDDL changes**: Any new keys, renamed fields, or restructured maps. Our `cddl/corim.cddl` is a snapshot from -10. Diff against new revisions. +- **IANA registry updates**: New tag numbers, role values, or version scheme values may be added. Check `types/tags.rs` constants. +- **Appraisal algorithm changes**: §9 may be refined. Our `validate.rs` implements the -10 semantics. +- **Signed CoRIM changes**: §4.2 COSE structure may evolve. Our `types/signed.rs` implements the -10 semantics. + +**How to check for updates**: Visit the [datatracker page](https://datatracker.ietf.org/doc/draft-ietf-rats-corim/) and compare the latest revision number against `-10`. + +--- + +## CBOR Encoding + +### RFC 8949 — Concise Binary Object Representation (CBOR) + +| | | +|-|-| +| **Status** | Standards Track (STD 94) — **Stable** | +| **URL** | https://www.rfc-editor.org/rfc/rfc8949.html | +| **Replaces** | RFC 7049 | + +#### Sections Implemented + +| Section | Topic | Status | Rust Module | +|---------|-------|--------|-------------| +| §3.1 | Major types 0–7 | ✅ Types 0–6 + simple values from type 7 | `cbor/minimal.rs` | +| §3.3 | Floating-point | ✅ Decode f16/f32/f64; encode always f64 | `cbor/minimal.rs` | +| §3.4.2 | Epoch-based date/time (`#6.1`) | ✅ Full | `types/common.rs` → `CborTime` | +| §4.2.1 | Core Deterministic Encoding | ✅ Full — shortest integer form + canonical map key ordering | `cbor/minimal.rs` → `encode_head`, `encode_value` | + +#### Documented Limitations + +| Feature | Status | Impact | +|---------|--------|--------| +| Indefinite-length encoding | ❌ Rejected on decode | CoRIM CDDL uses definite-length only | +| Float encode precision | Always f64 | CoRIM rarely uses floats (only CWT claims) | +| Simple values >23 (except false/true/null) | ❌ Rejected | Not used in CoRIM | +| CBOR sequences | ❌ Not supported | CoRIM always has single tagged wrapper | +| Maximum nesting depth | Stack-limited (~100) | CoRIM is typically 5–10 levels | + +--- + +## CoSWID + +### RFC 9393 — Concise Software Identification Tags + +| | | +|-|-| +| **Status** | Standards Track — **Stable** | +| **URL** | https://www.rfc-editor.org/rfc/rfc9393.html | + +#### Sections Implemented + +| Section | Topic | Status | Rust Module | +|---------|-------|--------|-------------| +| §2.3 | `concise-swid-tag` map | ✅ Core subset | `types/coswid.rs` → `ConciseSwidTag` | +| §2.4 | Co-constraints (patch+supplemental, tag-creator) | ✅ Full | `types/coswid.rs` → `Validate` impl | +| §2.6 | `entity-entry` | ✅ Full | `types/coswid.rs` → `SwidEntity` | +| §2.7 | `link-entry` | ✅ Full | `types/coswid.rs` → `SwidLink` | +| §2.8 | `software-meta-entry` | ☐ Not modeled | Rarely used in CoRIM context | +| §2.9 | Resource collection (payload/evidence) | ☐ Opaque `Value` | Full filesystem model deferred | +| §4.1 | Version scheme values | ✅ Constants | `types/tags.rs` | +| §4.2 | Entity role values | ✅ Constants | `types/tags.rs` | +| §4.4 | Link rel values | ✅ Constants | `types/tags.rs` | + +#### Not Implemented (out of scope for CoRIM use cases) + +- `software-meta-entry` fields (§2.8) — activation-status, channel-type, etc. +- `file-entry`, `directory-entry`, `process-entry`, `resource-entry` (§2.9.2) — filesystem inventory +- `payload-entry` / `evidence-entry` (§2.9.3–4) — stored as opaque `Value` +- XML serialization — out of scope +- CBOR tag `#6.1398229316` wrapping — CoSWID inside CoRIM uses tag 505 + +--- + +## Supporting RFCs + +### RFC 4648 — Base Encodings (Base64) + +| | | +|-|-| +| **Status** | Standards Track — **Stable** | +| **URL** | https://www.rfc-editor.org/rfc/rfc4648.html | +| **Used in** | `json/value_conv.rs` — base64 encode/decode for bytes↔JSON string | +| **Implementation** | In-house standard alphabet (no URL-safe variant) | + +### RFC 4122 — UUID Format + +| | | +|-|-| +| **Status** | Standards Track — **Stable** | +| **URL** | https://www.rfc-editor.org/rfc/rfc4122.html | +| **Used in** | `types/common.rs` — `TagIdChoice::Uuid`, `ClassIdChoice::Uuid`, etc. (CBOR tag 37, 16-byte binary) | +| **Note** | RFC 9562 updates UUID with v6/v7/v8 — our code accepts any 16-byte value under tag 37 | + +### RFC 9334 — RATS Architecture + +| | | +|-|-| +| **Status** | Informational — **Stable** | +| **URL** | https://www.rfc-editor.org/rfc/rfc9334.html | +| **Used in** | Conceptual reference — Endorser/Verifier/Attester roles. Our `validate.rs` implements the Verifier's appraisal logic. | + +### IANA Registries Referenced + +| Registry | URL | Used in | +|----------|-----|---------| +| CBOR Tags | https://www.iana.org/assignments/cbor-tags | `types/tags.rs` — tags 1, 18, 37, 111, 501, 505, 506, 508, 550–564 | +| Named Information Hash Algorithm | https://www.iana.org/assignments/named-information | `types/measurement.rs` — `Digest` algorithm IDs | +| CoSWID Items | https://www.iana.org/assignments/coswid | `types/tags.rs` — CoSWID key indices 0–57 | + +--- + +## How to Update This Document + +When a new revision of `draft-ietf-rats-corim` is published: + +1. **Check the datatracker**: https://datatracker.ietf.org/doc/draft-ietf-rats-corim/ +2. **Diff the CDDL**: Download the new CDDL and diff against `cddl/corim.cddl` +3. **Check for new keys**: Look for new map keys in `corim-map`, `concise-mid-tag`, `triples-map`, `measurement-values-map` +4. **Check IANA registries**: New tag numbers, role values, version schemes +5. **Update this file**: Change the version number, URL, and mark any new sections +6. **Update `types/tags.rs`**: Add any new constants +7. **Update `cddl/corim.cddl`**: Replace with new snapshot +8. **Run tests**: `cargo test --features json` — look for decode failures from changed wire format diff --git a/SECURITY.md b/SECURITY.md index e751608..656f791 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,4 +11,4 @@ For security reporting information, locations, contact information, and policies please review the latest guidance for Microsoft repositories at [https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md). - \ No newline at end of file + diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..da43fd5 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,14 @@ +# Support + +## How to file issues and get help + +This project uses GitHub Issues to track bugs and feature requests. Please search the +[existing issues](https://github.com/mingweishih/corim/issues) before filing new issues to +avoid duplicates. For new issues, file your bug or feature request as a new Issue. + +For help and questions about using this project, please file a +[GitHub Issue](https://github.com/mingweishih/corim/issues/new). + +## Microsoft Support Policy + +Support for this project is limited to the resources listed above. diff --git a/cddl/corim.cddl b/cddl/corim.cddl new file mode 100644 index 0000000..8e4b60b --- /dev/null +++ b/cddl/corim.cddl @@ -0,0 +1,585 @@ +corim = concise-rim-type-choice +concise-rim-type-choice /= tagged-unsigned-corim-map / signed-corim +concise-tl-tag = { + &(tag-identity: 0) => tag-identity-map, + &(tags-list: 1) => [+ tag-identity-map], + &(tl-validity: 2) => validity-map, +} +$concise-tag-type-choice /= tagged-concise-swid-tag / tagged-concise\ + -mid-tag / tagged-concise-tl-tag +corim-entity-map = entity-map<$corim-role-type-choice, $$corim-\ + entity-map-extension> +$corim-id-type-choice /= tstr / uuid-type +corim-locator-map = { + &(href: 0) => uri / [+ uri], + ? &(thumbprint: 1) => eatmc.digest / [eatmc.digest], +} +corim-map = { + &(id: 0) => $corim-id-type-choice, + &(tags: 1) => [+ $concise-tag-type-choice], + ? &(dependent-rims: 2) => [+ corim-locator-map], + ? &(profile: 3) => $profile-type-choice, + ? &(rim-validity: 4) => validity-map, + ? &(entities: 5) => [+ corim-entity-map], + * $$corim-map-extension, +} +unsigned-corim-map = corim-map +corim-meta-map = { + &(signer: 0) => corim-signer-map, + ? &(signature-validity: 1) => validity-map, +} +$corim-role-type-choice /= &(manifest-creator: 1) / &(manifest-\ + signer: 2) +corim-signer-map = { + &(signer-name: 0) => $entity-name-type-choice, + ? &(signer-uri: 1) => uri, + * $$corim-signer-map-extension, +} +COSE-Sign1-corim = [ + protected: bstr .cbor protected-corim-header-map, + unprotected: unprotected-corim-header-map, + payload: bstr .cbor tagged-unsigned-corim-map / hash-envelope-\ + digest / nil, + signature: bstr, +] +hash-envelope-digest = bstr +$profile-type-choice /= uri / tagged-oid-type +cwt-claims = { + &(iss: 1) => tstr, + ? &(sub: 2) => tstr, + ? &(exp: 4) => int / float, + ? &(nbf: 5) => int / float, + * int => any, +} +protected-corim-header-map = protected-corim-header-map-inline / \ + protected-corim-header-map-hash-envelope +protected-corim-header-map-inline = { + &(alg: 1) => int, + &(content-type: 3) => "application/rim+cbor", + meta-group, + * cose-label => cose-value, +} +protected-corim-header-map-hash-envelope = { + &(alg: 1) => int, + &(payload_hash_alg: 258) => int, + &(payload_preimage_content_type: 259) => "application/rim+cbor", + ? &(payload_location: 260) => tstr, + meta-group, + * cose-label => cose-value, +} +meta-group = (( + corim-meta-identity, + ? cwt-claims-identity, + ) // cwt-claims-identity) +corim-meta-identity = (&(corim-meta: 8) => bstr .cbor corim-meta-map) +cwt-claims-identity = (&(CWT-Claims: 15) => cwt-claims) +signed-corim = #6.18(COSE-Sign1-corim) +tagged-concise-swid-tag = #6.505(bytes .cbor coswid.concise-swid-tag) +tagged-concise-mid-tag = #6.506(bytes .cbor concise-mid-tag) +tagged-concise-tl-tag = #6.508(bytes .cbor concise-tl-tag) +tagged-unsigned-corim-map = #6.501(unsigned-corim-map) +unprotected-corim-header-map = {* cose-label => cose-value} +validity-map = { + ? &(not-before: 0) => time, + &(not-after: 1) => time, +} +concise-mid-tag = { + ? &(language: 0) => text, + &(tag-identity: 1) => tag-identity-map, + ? &(entities: 2) => [+ comid-entity-map], + ? &(linked-tags: 3) => [+ linked-tag-map], + &(triples: 4) => triples-map, + * $$concise-mid-tag-extension, +} +attest-key-triple-record = [ + environment: environment-map, + key-list: [+ $crypto-key-type-choice], + ? conditions: non-empty<{ + ? &(mkey: 0) => $measured-element-type-choice, + ? &(authorized-by: 1) => [+ $crypto-key-type-choice], +}>, +] +$class-id-type-choice /= tagged-oid-type / tagged-uuid-type / tagged\ + -bytes +class-map = non-empty<{ + ? &(class-id: 0) => $class-id-type-choice, + ? &(vendor: 1) => tstr, + ? &(model: 2) => tstr, + ? &(layer: 3) => uint, + ? &(index: 4) => uint, +}> +comid-entity-map = entity-map<$comid-role-type-choice, $$comid-\ + entity-map-extension> +$comid-role-type-choice /= &(tag-creator: 0) / &(creator: 1) / &(\ + maintainer: 2) +conditional-endorsement-series-triple-record = [ + condition: [ + environment: environment-map, + claims-list: [* measurement-map], + ? authorized-by: [+ $crypto-key-type-choice], +], + series: [+ conditional-series-record], +] +conditional-series-record = [ + selection: [+ measurement-map], + addition: [+ measurement-map], +] +COSE_Key = { + 1 => tstr / int, + ? 2 => bstr, + ? 3 => tstr / int, + ? 4 => [+ tstr / int], + ? 5 => bstr, + * cose-label => cose-value, +} +cose-label = int / tstr +cose-value = any +coswid-triple-record = [ + environment-map, + [+ coswid.tag-id], +] +$crypto-key-type-choice /= tagged-pkix-base64-key-type / tagged-pkix\ +-base64-cert-type / tagged-pkix-base64-cert-path-type / tagged-cose-\ +key-type / tagged-pkix-asn1der-cert-type / tagged-key-thumbprint-\ +type / tagged-cert-thumbprint-type / tagged-cert-path-thumbprint-\ + type / tagged-bytes +tagged-pkix-base64-key-type = #6.554(tstr) +tagged-pkix-base64-cert-type = #6.555(tstr) +tagged-pkix-base64-cert-path-type = #6.556(tstr) +tagged-key-thumbprint-type = #6.557(eatmc.digest) +tagged-cose-key-type = #6.558(COSE_Key) +tagged-cert-thumbprint-type = #6.559(eatmc.digest) +tagged-cert-path-thumbprint-type = #6.561(eatmc.digest) +tagged-pkix-asn1der-cert-type = #6.562(bstr) +domain-dependency-triple-record = [ + domain-id: domain-type, + trustees: [+ domain-type], +] +domain-membership-triple-record = [ + domain-id: domain-type, + members: [+ domain-type], +] +conditional-endorsement-triple-record = [ + conditions: [+ stateful-environment-record], + endorsements: [+ endorsed-triple-record], +] +domain-type = environment-map +endorsed-triple-record = [ + condition: environment-map, + endorsement: [+ measurement-map], +] +entity-map = { + &(entity-name: 0) => $entity-name-type-choice, + ? &(reg-id: 1) => uri, + &(role: 2) => [+ role-type-choice], + * extension-socket, +} +$entity-name-type-choice /= text +environment-map = non-empty<{ + ? &(class: 0) => class-map, + ? &(instance: 1) => $instance-id-type-choice, + ? &(group: 2) => $group-id-type-choice, +}> +flags-map = non-empty<{ + ? &(is-configured: 0) => bool, + ? &(is-secure: 1) => bool, + ? &(is-recovery: 2) => bool, + ? &(is-debug: 3) => bool, + ? &(is-replay-protected: 4) => bool, + ? &(is-integrity-protected: 5) => bool, + ? &(is-runtime-meas: 6) => bool, + ? &(is-immutable: 7) => bool, + ? &(is-tcb: 8) => bool, + ? &(is-confidentiality-protected: 9) => bool, + * $$flags-map-extension, +}> +$group-id-type-choice /= tagged-uuid-type / tagged-bytes +identity-triple-record = [ + environment: environment-map, + key-list: [+ $crypto-key-type-choice], + ? conditions: non-empty<{ + ? &(mkey: 0) => $measured-element-type-choice, + ? &(authorized-by: 1) => [+ $crypto-key-type-choice], +}>, +] +$instance-id-type-choice /= tagged-ueid-type / tagged-uuid-type / \ +tagged-bytes / tagged-pkix-base64-key-type / tagged-pkix-base64-cert\ +-type / tagged-cose-key-type / tagged-key-thumbprint-type / tagged-\ + cert-thumbprint-type / tagged-pkix-asn1der-cert-type +ip-addr-type-choice /= cbor-ip.ipv4-address / cbor-ip.ipv6-address +int-range-type-choice = int / tagged-int-range +int-range = [ + min: int / negative-inf, + max: int / positive-inf, +] +tagged-int-range = #6.564(int-range) +positive-inf = null +negative-inf = null +linked-tag-map = { + &(linked-tag-id: 0) => $tag-id-type-choice, + &(tag-rel: 1) => $tag-rel-type-choice, +} +mac-addr-type-choice = eui48-addr-type / eui64-addr-type +eui48-addr-type = bytes .size 6 +eui64-addr-type = bytes .size 8 +$measured-element-type-choice /= tagged-oid-type / tagged-uuid-type \ + / uint / tstr +measurement-map = { + ? &(mkey: 0) => $measured-element-type-choice, + &(mval: 1) => measurement-values-map, + ? &(authorized-by: 2) => [+ $crypto-key-type-choice], +} +measurement-values-map = non-empty<{ + ? &(version: 0) => version-map, + ? &(svn: 1) => svn-type-choice, + ? &(digests: 2) => digests-type, + ? &(flags: 3) => flags-map, + ? ( + &(raw-value: 4) => $raw-value-type-choice, + ? &(raw-value-mask-DEPRECATED: 5) => raw-value-\ + mask-type, + ), + ? &(mac-addr: 6) => mac-addr-type-choice, + ? &(ip-addr: 7) => ip-addr-type-choice, + ? &(serial-number: 8) => text, + ? &(ueid: 9) => ueid-type, + ? &(uuid: 10) => uuid-type, + ? &(name: 11) => text, + ? &(cryptokeys: 13) => [+ $crypto-key-type-choice], + ? &(integrity-registers: 14) => integrity-registers, + ? &(int-range: 15) => int-range-type-choice, + * $$measurement-values-map-extension, +}> +non-empty = M .and ({+ any => any}) +oid-type = bytes +tagged-oid-type = #6.111(oid-type) +$raw-value-type-choice /= tagged-bytes / tagged-masked-raw-value +raw-value-mask-type = bytes +tagged-masked-raw-value = #6.563([ + value: bytes, + mask: bytes, +]) +reference-triple-record = [ + ref-env: environment-map, + ref-claims: [+ measurement-map], +] +stateful-environment-record = [ + environment: environment-map, + claims-list: [+ measurement-map], +] +svn-type = uint +svn = svn-type +min-svn = svn-type +tagged-svn = #6.552(svn) +tagged-min-svn = #6.553(min-svn) +svn-type-choice = svn / tagged-svn / tagged-min-svn +$tag-id-type-choice /= tstr / uuid-type +tag-identity-map = { + &(tag-id: 0) => $tag-id-type-choice, + ? &(tag-version: 1) => tag-version-type, +} +$tag-rel-type-choice /= &(supplements: 0) / &(replaces: 1) +tag-version-type = uint .default 0 +tagged-bytes = #6.560(bytes) +triples-map = non-empty<{ + ? &(reference-triples: 0) => [+ reference-triple-record], + ? &(endorsed-triples: 1) => [+ endorsed-triple-record], + ? &(identity-triples: 2) => [+ identity-triple-record], + ? &(attest-key-triples: 3) => [+ attest-key-triple-record], + ? &(dependency-triples: 4) => [+ domain-dependency-triple-record\ + ], + ? &(membership-triples: 5) => [+ domain-membership-triple-record\ + ], + ? &(coswid-triples: 6) => [+ coswid-triple-record], + ? &(conditional-endorsement-series-triples: 8) => [+ conditional\ + -endorsement-series-triple-record], + ? &(conditional-endorsement-triples: 10) => [+ conditional-\ + endorsement-triple-record], + * $$triples-map-extension, +}> +ueid-type = bytes .size (7 .. 33) +tagged-ueid-type = #6.550(ueid-type) +uuid-type = bytes .size 16 +tagged-uuid-type = #6.37(uuid-type) +version-map = { + &(version: 0) => text, + ? &(version-scheme: 1) => coswid.$version-scheme, +} +digests-type = [+ eatmc.digest] +integrity-register-id-type-choice = uint / text +integrity-registers = {+ integrity-register-id-type-choice => \ + digests-type} +eatmc.measured-component = { + eatmc.component-id-label => eatmc.component-id, + eatmc.measurement, + ? eatmc.authorities-label => [+ eatmc.authority-id-type], + ? eatmc.flags-label => eatmc.flags-type, +} +eatmc.measurement //= (eatmc.digested-measurement-label => eatmc.\ + digest // eatmc.raw-measurement-label => bytes) +eatmc.authority-id-type = eatmc.eat.JC +eatmc.flags-type = eatmc.eat.JC +eatmc.component-id = [ + name: text, + ? version: eatmc.version, +] +eatmc.version = [ + val: text, + ? scheme: eatmc.coswid.$version-scheme, +] +eatmc.digest = [ + alg: int / text, + val: eatmc.digest-value-type, +] +eatmc.digest-value-type = eatmc.eat.JC +eatmc.bytes-b64u = text .b64u bytes +eatmc.bytes8 = bytes .size 8 +eatmc.bytes8-b64u = text .b64u eatmc.bytes8 +eatmc.component-id-label = eatmc.eat.JC<"id", 1> +eatmc.digested-measurement-label = eatmc.eat.JC<"digested-\ + measurement", 2> +eatmc.raw-measurement-label = eatmc.eat.JC<"raw-measurement", 5> +eatmc.authorities-label = eatmc.eat.JC<"authorities", 3> +eatmc.flags-label = eatmc.eat.JC<"flags", 4> +eatmc.mc-cbor = bytes .cbor eatmc.measured-component +eatmc.mc-json = text .json eatmc.measured-component +eatmc.$measurements-body-cbor /= eatmc.mc-cbor / eatmc.mc-json +eatmc.$measurements-body-json /= eatmc.mc-json / text .b64u eatmc.mc\ + -cbor +eatmc.eat.JSON-ONLY = J .feature "json" +eatmc.eat.CBOR-ONLY = C .feature "cbor" +eatmc.eat.JC = eatmc.eat.JSON-ONLY / eatmc.eat.CBOR-ONLY +eatmc.coswid.$version-scheme /= eatmc.coswid.multipartnumeric / \ +eatmc.coswid.multipartnumeric-suffix / eatmc.coswid.alphanumeric / \ + eatmc.coswid.decimal / eatmc.coswid.semver / int / text +eatmc.coswid.multipartnumeric = 1 +eatmc.coswid.multipartnumeric-suffix = 2 +eatmc.coswid.alphanumeric = 3 +eatmc.coswid.decimal = 4 +eatmc.coswid.semver = 16384 +coswid.concise-swid-tag = { + coswid.tag-id => text / bstr .size 16, + coswid.tag-version => integer, + ? coswid.corpus => bool, + ? coswid.patch => bool, + ? coswid.supplemental => bool, + coswid.software-name => text, + ? coswid.software-version => text, + ? coswid.version-scheme => coswid.$version-scheme, + ? coswid.media => text, + ? coswid.software-meta => coswid.one-or-more, + coswid.entity => coswid.one-or-more, + ? coswid.link => coswid.one-or-more, + ? coswid.payload-or-evidence, + * $$coswid-extension, + coswid.global-attributes, +} +coswid.tag-id = 0 +coswid.$version-scheme /= coswid.multipartnumeric / coswid.\ +multipartnumeric-suffix / coswid.alphanumeric / coswid.decimal / \ + coswid.semver / int / text +coswid.one-or-more = T / [2*T] +coswid.tag-version = 12 +coswid.corpus = 8 +coswid.patch = 9 +coswid.supplemental = 11 +coswid.software-name = 1 +coswid.software-version = 13 +coswid.version-scheme = 14 +coswid.media = 10 +coswid.software-meta = 5 +coswid.software-meta-entry = { + ? coswid.activation-status => text, + ? coswid.channel-type => text, + ? coswid.colloquial-version => text, + ? coswid.description => text, + ? coswid.edition => text, + ? coswid.entitlement-data-required => bool, + ? coswid.entitlement-key => text, + ? coswid.generator => text / bstr .size 16, + ? coswid.persistent-id => text, + ? coswid.product => text, + ? coswid.product-family => text, + ? coswid.revision => text, + ? coswid.summary => text, + ? coswid.unspsc-code => text, + ? coswid.unspsc-version => text, + * $$software-meta-extension, + coswid.global-attributes, +} +coswid.entity = 2 +coswid.entity-entry = { + coswid.entity-name => text, + ? coswid.reg-id => coswid.any-uri, + coswid.role => coswid.one-or-more, + ? coswid.thumbprint => coswid.hash-entry, + * $$entity-extension, + coswid.global-attributes, +} +coswid.link = 4 +coswid.link-entry = { + ? coswid.artifact => text, + coswid.href => coswid.any-uri, + ? coswid.media => text, + ? coswid.ownership => coswid.$ownership, + coswid.rel => coswid.$rel, + ? coswid.media-type => text, + ? coswid.use => coswid.$use, + * $$link-extension, + coswid.global-attributes, +} +coswid.payload-or-evidence //= (coswid.payload => coswid.payload-\ + entry // coswid.evidence => coswid.evidence-entry) +coswid.global-attributes = ( + ? coswid.lang => text, + * coswid.any-attribute, + ) +coswid.multipartnumeric = 1 +coswid.multipartnumeric-suffix = 2 +coswid.alphanumeric = 3 +coswid.decimal = 4 +coswid.semver = 16384 +coswid.activation-status = 43 +coswid.channel-type = 44 +coswid.colloquial-version = 45 +coswid.description = 46 +coswid.edition = 47 +coswid.entitlement-data-required = 48 +coswid.entitlement-key = 49 +coswid.generator = 50 +coswid.persistent-id = 51 +coswid.product = 52 +coswid.product-family = 53 +coswid.revision = 54 +coswid.summary = 55 +coswid.unspsc-code = 56 +coswid.unspsc-version = 57 +coswid.entity-name = 31 +coswid.reg-id = 32 +coswid.any-uri = uri +coswid.role = 33 +coswid.$role /= coswid.tag-creator / coswid.software-creator / \ +coswid.aggregator / coswid.distributor / coswid.licensor / coswid.\ + maintainer / int / text +coswid.thumbprint = 34 +coswid.hash-entry = [ + hash-alg-id: int, + hash-value: bytes, +] +coswid.artifact = 37 +coswid.href = 38 +coswid.ownership = 39 +coswid.$ownership /= coswid.shared / coswid.private / coswid.\ + abandon / int / text +coswid.rel = 40 +coswid.$rel /= coswid.ancestor / coswid.component / coswid.feature \ +/ coswid.installationmedia / coswid.packageinstaller / coswid.\ +parent / coswid.patches / coswid.requires / coswid.see-also / coswid\ + .supersedes / coswid.supplemental / -256 .. 65536 / text +coswid.media-type = 41 +coswid.use = 42 +coswid.$use /= coswid.optional / coswid.required / coswid.\ + recommended / int / text +coswid.payload = 6 +coswid.payload-entry = { + coswid.resource-collection, + * $$payload-extension, + coswid.global-attributes, +} +coswid.evidence = 3 +coswid.evidence-entry = { + coswid.resource-collection, + ? coswid.date => coswid.integer-time, + ? coswid.device-id => text, + ? coswid.location => text, + * $$evidence-extension, + coswid.global-attributes, +} +coswid.lang = 15 +coswid.any-attribute = (coswid.label => coswid.one-or-more / \ + coswid.one-or-more) +coswid.tag-creator = 1 +coswid.software-creator = 2 +coswid.aggregator = 3 +coswid.distributor = 4 +coswid.licensor = 5 +coswid.maintainer = 6 +coswid.shared = 3 +coswid.private = 2 +coswid.abandon = 1 +coswid.ancestor = 1 +coswid.component = 2 +coswid.feature = 3 +coswid.installationmedia = 4 +coswid.packageinstaller = 5 +coswid.parent = 6 +coswid.patches = 7 +coswid.requires = 8 +coswid.see-also = 9 +coswid.supersedes = 10 +coswid.optional = 1 +coswid.required = 2 +coswid.recommended = 3 +coswid.resource-collection = ( + coswid.path-elements-group, + ? coswid.process => coswid.one-or-more, + ? coswid.resource => coswid.one-or-more, + * $$resource-collection-extension, + ) +coswid.date = 35 +coswid.integer-time = #6.1(int) +coswid.device-id = 36 +coswid.location = 23 +coswid.label = text / int +coswid.path-elements-group = ( + ? coswid.directory => coswid.one-or-more, + ? coswid.file => coswid.one-or-more, + ) +coswid.process = 18 +coswid.process-entry = { + coswid.process-name => text, + ? coswid.pid => integer, + * $$process-extension, + coswid.global-attributes, +} +coswid.resource = 19 +coswid.resource-entry = { + coswid.type => text, + * $$resource-extension, + coswid.global-attributes, +} +coswid.directory = 16 +coswid.directory-entry = { + coswid.filesystem-item, + ? coswid.path-elements => {coswid.path-elements-group}, + * $$directory-extension, + coswid.global-attributes, +} +coswid.file = 17 +coswid.file-entry = { + coswid.filesystem-item, + ? coswid.size => uint, + ? coswid.file-version => text, + ? coswid.hash => coswid.hash-entry, + * $$file-extension, + coswid.global-attributes, +} +coswid.process-name = 27 +coswid.pid = 28 +coswid.type = 29 +coswid.filesystem-item = ( + ? coswid.key => bool, + ? coswid.location => text, + coswid.fs-name => text, + ? coswid.root => text, + ) +coswid.path-elements = 26 +coswid.size = 20 +coswid.file-version = 21 +coswid.hash = 7 +coswid.key = 22 +coswid.fs-name = 24 +coswid.root = 25 +cbor-ip.ipv4-address = bytes .size 4 +cbor-ip.ipv6-address = bytes .size 16 diff --git a/corim-cli/Cargo.toml b/corim-cli/Cargo.toml new file mode 100644 index 0000000..3b0c2e0 --- /dev/null +++ b/corim-cli/Cargo.toml @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "corim-cli" +version = "0.1.0" +edition = "2021" +description = "CLI tool for validating and inspecting CoRIM (Concise Reference Integrity Manifest) documents." +license = "MIT" +authors = ["Microsoft"] +repository = "https://github.com/mingweishih/corim" +keywords = ["corim", "comid", "cbor", "attestation", "cli"] +categories = ["command-line-utilities"] + +[[bin]] +name = "corim-cli" +path = "src/main.rs" + +[[bin]] +name = "corim-gen-sample" +path = "src/gen_sample.rs" + +[[bin]] +name = "corim-gen-signed-sample" +path = "src/gen_signed_sample.rs" + +[dependencies] +corim = { path = "../corim" } +clap = { version = "4", features = ["derive"] } +hex = "0.4" diff --git a/corim-cli/src/display.rs b/corim-cli/src/display.rs new file mode 100644 index 0000000..b91fff3 --- /dev/null +++ b/corim-cli/src/display.rs @@ -0,0 +1,635 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pretty-printing helpers for CoRIM/CoMID structures. + +use corim::types::comid::ComidTag; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::triples::*; + +// ── Top-level CoRIM ────────────────────────────────────────────────────── + +pub fn print_corim(corim: &CorimMap, show_raw: bool) { + println!(" id: {}", corim_id_str(&corim.id)); + + if let Some(ref profile) = corim.profile { + println!(" profile: {}", profile_str(profile)); + } + + if let Some(ref validity) = corim.rim_validity { + print_validity(validity, " "); + } + + if let Some(ref entities) = corim.entities { + println!(" entities:"); + for e in entities { + print_entity(e, " "); + } + } + + if let Some(ref deps) = corim.dependent_rims { + println!(" dependent-rims: ({} locators)", deps.len()); + for (i, loc) in deps.iter().enumerate() { + print!(" [{}] href: ", i); + match &loc.href { + CorimLocatorHref::Single(u) => println!("{}", u), + CorimLocatorHref::Multiple(us) => println!("{}", us.join(", ")), + _ => println!("(unknown href variant)"), + } + } + } + + if show_raw { + for (i, tag) in corim.tags.iter().enumerate() { + match tag { + ConciseTagChoice::Coswid(b) => { + println!( + " tags[{}] (CoSWID): {} bytes — {}", + i, + b.len(), + hex_short(b) + ); + } + ConciseTagChoice::Cotl(b) => { + println!(" tags[{}] (CoTL): {} bytes — {}", i, b.len(), hex_short(b)); + } + ConciseTagChoice::Unknown(t, b) => { + println!( + " tags[{}] (unknown tag {}): {} bytes — {}", + i, + t, + b.len(), + hex_short(b) + ); + } + _ => {} // CoMIDs are printed separately + } + } + } +} + +// ── CoMID ──────────────────────────────────────────────────────────────── + +pub fn print_comid(comid: &ComidTag, indent: &str, _show_raw: bool) { + println!( + "{}tag-id: {}", + indent, + tag_id_str(&comid.tag_identity.tag_id) + ); + if let Some(v) = comid.tag_identity.tag_version { + println!("{}tag-version: {}", indent, v); + } + if let Some(ref lang) = comid.language { + println!("{}language: {}", indent, lang); + } + if let Some(ref entities) = comid.entities { + println!("{}entities:", indent); + for e in entities { + print_entity(e, &format!("{} ", indent)); + } + } + if let Some(ref links) = comid.linked_tags { + println!("{}linked-tags:", indent); + for lt in links { + let rel = match lt.tag_rel { + 0 => "supplements", + 1 => "replaces", + n => &format!("{}", n), + }; + println!("{} {} → {}", indent, rel, tag_id_str(<.linked_tag_id)); + } + } + + print_triples(&comid.triples, indent); +} + +// ── Triples ────────────────────────────────────────────────────────────── + +fn print_triples(triples: &TriplesMap, indent: &str) { + println!("{}triples:", indent); + let ti = format!("{} ", indent); + + if let Some(ref v) = triples.reference_triples { + println!("{}reference-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}]", ti, i); + print_env(&t.0, &format!("{} ", ti)); + println!("{} measurements: ({} entries)", ti, t.1.len()); + for m in &t.1 { + print_measurement(m, &format!("{} ", ti)); + } + } + } + + if let Some(ref v) = triples.endorsed_triples { + println!("{}endorsed-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}]", ti, i); + print_env(&t.0, &format!("{} ", ti)); + println!("{} endorsements: ({} entries)", ti, t.1.len()); + for m in &t.1 { + print_measurement(m, &format!("{} ", ti)); + } + } + } + + if let Some(ref v) = triples.identity_triples { + println!("{}identity-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}]", ti, i); + print_env(&t.0, &format!("{} ", ti)); + println!("{} keys: ({} entries)", ti, t.1.len()); + for k in &t.1 { + println!("{} {}", ti, crypto_key_str(k)); + } + } + } + + if let Some(ref v) = triples.attest_key_triples { + println!("{}attest-key-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}]", ti, i); + print_env(&t.0, &format!("{} ", ti)); + println!("{} keys: ({} entries)", ti, t.1.len()); + for k in &t.1 { + println!("{} {}", ti, crypto_key_str(k)); + } + } + } + + if let Some(ref v) = triples.dependency_triples { + println!("{}dependency-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}] domain:", ti, i); + print_env(&t.0, &format!("{} ", ti)); + println!("{} trustees: ({} entries)", ti, t.1.len()); + } + } + + if let Some(ref v) = triples.membership_triples { + println!("{}membership-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}] domain:", ti, i); + print_env(&t.0, &format!("{} ", ti)); + println!("{} members: ({} entries)", ti, t.1.len()); + } + } + + if let Some(ref v) = triples.coswid_triples { + println!("{}coswid-triples: ({} entries)", ti, v.len()); + for (i, t) in v.iter().enumerate() { + println!("{} [{}]", ti, i); + print_env(&t.0, &format!("{} ", ti)); + let ids: Vec = t.1.iter().map(tag_id_str).collect(); + println!("{} tag-ids: [{}]", ti, ids.join(", ")); + } + } + + if let Some(ref v) = triples.conditional_endorsement_series { + println!( + "{}conditional-endorsement-series: ({} entries)", + ti, + v.len() + ); + for (i, t) in v.iter().enumerate() { + let cond = t.condition(); + println!("{} [{}] condition:", ti, i); + print_env(&cond.environment, &format!("{} ", ti)); + if !cond.claims_list.is_empty() { + println!( + "{} claims-list: ({} entries)", + ti, + cond.claims_list.len() + ); + } + println!("{} series: ({} entries)", ti, t.series().len()); + for (j, sr) in t.series().iter().enumerate() { + println!( + "{} [{}] selection: {} meas → addition: {} meas", + ti, + j, + sr.selection().len(), + sr.addition().len() + ); + } + } + } + + if let Some(ref v) = triples.conditional_endorsement { + println!("{}conditional-endorsement: ({} entries)", ti, v.len()); + } +} + +// ── Environment ────────────────────────────────────────────────────────── + +fn print_env(env: &EnvironmentMap, indent: &str) { + println!("{}environment:", indent); + let ei = format!("{} ", indent); + if let Some(ref class) = env.class { + print_class(class, &ei); + } + if let Some(ref inst) = env.instance { + println!("{}instance: {}", ei, instance_id_str(inst)); + } + if let Some(ref grp) = env.group { + println!("{}group: {}", ei, group_id_str(grp)); + } +} + +fn print_class(class: &ClassMap, indent: &str) { + if let Some(ref cid) = class.class_id { + println!("{}class-id: {}", indent, class_id_str(cid)); + } + if let Some(ref v) = class.vendor { + println!("{}vendor: {}", indent, v); + } + if let Some(ref m) = class.model { + println!("{}model: {}", indent, m); + } + if let Some(l) = class.layer { + println!("{}layer: {}", indent, l); + } + if let Some(idx) = class.index { + println!("{}index: {}", indent, idx); + } +} + +// ── Measurement ────────────────────────────────────────────────────────── + +fn print_measurement(m: &MeasurementMap, indent: &str) { + if let Some(ref mkey) = m.mkey { + println!("{}mkey: {}", indent, measured_element_str(mkey)); + } + println!("{}mval:", indent); + let mi = format!("{} ", indent); + let mv = &m.mval; + + if let Some(ref ver) = mv.version { + print!("{}version: \"{}\"", mi, ver.version); + if let Some(scheme) = ver.version_scheme { + print!(" (scheme: {})", version_scheme_str(scheme)); + } + println!(); + } + + if let Some(ref svn) = mv.svn { + match svn { + SvnChoice::ExactValue(v) => println!("{}svn: {} (exact)", mi, v), + SvnChoice::MinValue(v) => println!("{}svn: {} (min)", mi, v), + _ => println!("{}svn: (unknown variant)", mi), + } + } + + if let Some(ref digests) = mv.digests { + println!("{}digests:", mi); + for d in digests { + println!("{} [alg={}] {}", mi, d.alg(), hex_short(d.value())); + } + } + + if let Some(ref flags) = mv.flags { + print_flags(flags, &mi); + } + + if let Some(ref raw) = mv.raw_value { + match raw { + RawValueChoice::Bytes(b) => println!("{}raw-value: {}", mi, hex_short(b)), + RawValueChoice::Masked { value, mask } => { + println!("{}raw-value (masked):", mi); + println!("{} value: {}", mi, hex_short(value)); + println!("{} mask: {}", mi, hex_short(mask)); + } + _ => println!("{}raw-value: (unknown variant)", mi), + } + } + + if let Some(ref mac) = mv.mac_addr { + match mac { + MacAddr::Eui48(b) => println!("{}mac-addr: {} (EUI-48)", mi, hex::encode(b)), + MacAddr::Eui64(b) => println!("{}mac-addr: {} (EUI-64)", mi, hex::encode(b)), + _ => println!("{}mac-addr: (unknown variant)", mi), + } + } + + if let Some(ref ip) = mv.ip_addr { + match ip { + IpAddr::V4(b) => println!("{}ip-addr: {}.{}.{}.{}", mi, b[0], b[1], b[2], b[3]), + IpAddr::V6(b) => println!("{}ip-addr: {}", mi, hex::encode(b)), + _ => println!("{}ip-addr: (unknown variant)", mi), + } + } + + if let Some(ref sn) = mv.serial_number { + println!("{}serial-number: \"{}\"", mi, sn); + } + + if let Some(ref ueid) = mv.ueid { + println!("{}ueid: {}", mi, hex::encode(ueid)); + } + + if let Some(ref uuid) = mv.uuid { + println!("{}uuid: {}", mi, format_uuid(uuid)); + } + + if let Some(ref name) = mv.name { + println!("{}name: \"{}\"", mi, name); + } + + if let Some(ref keys) = mv.cryptokeys { + println!("{}cryptokeys: ({} entries)", mi, keys.len()); + for k in keys { + println!("{} {}", mi, crypto_key_str(k)); + } + } + + if let Some(ref regs) = mv.integrity_registers { + println!("{}integrity-registers: ({} entries)", mi, regs.0.len()); + for (id, digests) in ®s.0 { + let id_str = match id { + IntegrityRegisterId::Uint(n) => n.to_string(), + IntegrityRegisterId::Text(t) => format!("\"{}\"", t), + _ => "(unknown)".to_string(), + }; + print!("{} {}: ", mi, id_str); + let ds: Vec = digests + .iter() + .map(|d| format!("[alg={} {}]", d.alg(), hex_short(d.value()))) + .collect(); + println!("{}", ds.join(", ")); + } + } + + if let Some(ref range) = mv.int_range { + match range { + IntRangeChoice::Int(v) => println!("{}int-range: {}", mi, v), + IntRangeChoice::Range { min, max } => { + let min_s = match min { + Some(n) => n.to_string(), + None => "-∞".into(), + }; + let max_s = match max { + Some(n) => n.to_string(), + None => "+∞".into(), + }; + println!("{}int-range: [{}..{}]", mi, min_s, max_s); + } + _ => println!("{}int-range: (unknown variant)", mi), + } + } + + if let Some(ref auth) = m.authorized_by { + println!("{}authorized-by: ({} keys)", indent, auth.len()); + } +} + +fn print_flags(flags: &FlagsMap, indent: &str) { + let mut parts = Vec::new(); + if let Some(v) = flags.is_configured { + parts.push(format!("configured={}", v)); + } + if let Some(v) = flags.is_secure { + parts.push(format!("secure={}", v)); + } + if let Some(v) = flags.is_recovery { + parts.push(format!("recovery={}", v)); + } + if let Some(v) = flags.is_debug { + parts.push(format!("debug={}", v)); + } + if let Some(v) = flags.is_replay_protected { + parts.push(format!("replay-protected={}", v)); + } + if let Some(v) = flags.is_integrity_protected { + parts.push(format!("integrity-protected={}", v)); + } + if let Some(v) = flags.is_runtime_meas { + parts.push(format!("runtime-meas={}", v)); + } + if let Some(v) = flags.is_immutable { + parts.push(format!("immutable={}", v)); + } + if let Some(v) = flags.is_tcb { + parts.push(format!("tcb={}", v)); + } + if let Some(v) = flags.is_confidentiality_protected { + parts.push(format!("confidentiality-protected={}", v)); + } + if !parts.is_empty() { + println!("{}flags: {{{}}}", indent, parts.join(", ")); + } +} + +// ── Entity / Validity ──────────────────────────────────────────────────── + +fn print_entity(e: &EntityMap, indent: &str) { + let roles: Vec = e.role.iter().map(|r| role_str(*r)).collect(); + print!("{}{} [{}]", indent, e.entity_name, roles.join(", ")); + if let Some(ref uri) = e.reg_id { + print!(" <{}>", uri); + } + println!(); +} + +pub fn print_validity(v: &ValidityMap, indent: &str) { + if let Some(nb) = v.not_before { + println!("{}not-before: {} (epoch)", indent, nb.epoch_secs()); + } + println!("{}not-after: {} (epoch)", indent, v.not_after.epoch_secs()); +} + +// ── String formatters ──────────────────────────────────────────────────── + +pub fn corim_id_str(id: &CorimId) -> String { + match id { + CorimId::Text(t) => format!("\"{}\"", t), + CorimId::Uuid(u) => format_uuid(u), + _ => "(unknown)".to_string(), + } +} + +/// JSON-safe CoRIM ID: always properly quoted. +pub fn corim_id_json(id: &CorimId) -> String { + match id { + CorimId::Text(t) => format!("\"{}\"", t.replace('\\', "\\\\").replace('"', "\\\"")), + CorimId::Uuid(u) => format!("\"{}\"", format_uuid(u)), + _ => "\"unknown\"".to_string(), + } +} + +pub fn profile_str(p: &ProfileChoice) -> String { + match p { + ProfileChoice::Uri(u) => u.clone(), + ProfileChoice::Oid(b) => format!("OID({})", hex::encode(b)), + _ => "(unknown)".to_string(), + } +} + +pub fn tag_id_str(id: &TagIdChoice) -> String { + match id { + TagIdChoice::Text(t) => format!("\"{}\"", t), + TagIdChoice::Uuid(u) => format_uuid(u), + _ => "(unknown)".to_string(), + } +} + +/// JSON-safe tag ID: always properly quoted. +pub fn tag_id_json(id: &TagIdChoice) -> String { + match id { + TagIdChoice::Text(t) => format!("\"{}\"", t.replace('\\', "\\\\").replace('"', "\\\"")), + TagIdChoice::Uuid(u) => format!("\"{}\"", format_uuid(u)), + _ => "\"unknown\"".to_string(), + } +} + +pub fn triple_type_list(triples: &TriplesMap) -> String { + let mut types = Vec::new(); + if triples.reference_triples.is_some() { + types.push("\"reference\""); + } + if triples.endorsed_triples.is_some() { + types.push("\"endorsed\""); + } + if triples.identity_triples.is_some() { + types.push("\"identity\""); + } + if triples.attest_key_triples.is_some() { + types.push("\"attest-key\""); + } + if triples.dependency_triples.is_some() { + types.push("\"dependency\""); + } + if triples.membership_triples.is_some() { + types.push("\"membership\""); + } + if triples.coswid_triples.is_some() { + types.push("\"coswid\""); + } + if triples.conditional_endorsement_series.is_some() { + types.push("\"cond-endorsement-series\""); + } + if triples.conditional_endorsement.is_some() { + types.push("\"cond-endorsement\""); + } + types.join(", ") +} + +fn class_id_str(id: &ClassIdChoice) -> String { + match id { + ClassIdChoice::Oid(b) => format!("OID({})", hex::encode(b)), + ClassIdChoice::Uuid(u) => format_uuid(u), + ClassIdChoice::Bytes(b) => format!("bytes({})", hex_short(b)), + _ => "(unknown)".to_string(), + } +} + +fn instance_id_str(id: &InstanceIdChoice) -> String { + match id { + InstanceIdChoice::Ueid(b) => format!("UEID({})", hex::encode(b)), + InstanceIdChoice::Uuid(u) => format_uuid(u), + InstanceIdChoice::Bytes(b) => format!("bytes({})", hex_short(b)), + InstanceIdChoice::PkixBase64Key(s) => format!("pkix-key({}...)", &s[..s.len().min(32)]), + InstanceIdChoice::PkixBase64Cert(s) => format!("pkix-cert({}...)", &s[..s.len().min(32)]), + InstanceIdChoice::CoseKey(b) => format!("cose-key({} bytes)", b.len()), + InstanceIdChoice::KeyThumbprint(d) => { + format!("key-thumbprint(alg={}, {})", d.alg(), hex_short(d.value())) + } + InstanceIdChoice::CertThumbprint(d) => { + format!("cert-thumbprint(alg={}, {})", d.alg(), hex_short(d.value())) + } + InstanceIdChoice::PkixAsn1DerCert(b) => format!("asn1der-cert({} bytes)", b.len()), + _ => "(unknown)".to_string(), + } +} + +fn group_id_str(id: &GroupIdChoice) -> String { + match id { + GroupIdChoice::Uuid(u) => format_uuid(u), + GroupIdChoice::Bytes(b) => format!("bytes({})", hex_short(b)), + _ => "(unknown)".to_string(), + } +} + +fn measured_element_str(me: &MeasuredElement) -> String { + match me { + MeasuredElement::Oid(b) => format!("OID({})", hex::encode(b)), + MeasuredElement::Uuid(u) => format_uuid(u), + MeasuredElement::Uint(n) => n.to_string(), + MeasuredElement::Text(t) => format!("\"{}\"", t), + _ => "(unknown)".to_string(), + } +} + +fn crypto_key_str(k: &CryptoKey) -> String { + match k { + CryptoKey::PkixBase64Key(s) => format!("pkix-base64-key({}...)", &s[..s.len().min(40)]), + CryptoKey::PkixBase64Cert(s) => format!("pkix-base64-cert({}...)", &s[..s.len().min(40)]), + CryptoKey::PkixBase64CertPath(s) => { + format!("pkix-base64-cert-path({}...)", &s[..s.len().min(40)]) + } + CryptoKey::KeyThumbprint(d) => { + format!("key-thumbprint(alg={}, {})", d.alg(), hex_short(d.value())) + } + CryptoKey::CoseKey(b) => format!("cose-key({} bytes)", b.len()), + CryptoKey::CertThumbprint(d) => { + format!("cert-thumbprint(alg={}, {})", d.alg(), hex_short(d.value())) + } + CryptoKey::CertPathThumbprint(d) => { + format!( + "cert-path-thumbprint(alg={}, {})", + d.alg(), + hex_short(d.value()) + ) + } + CryptoKey::PkixAsn1DerCert(b) => format!("pkix-asn1der-cert({} bytes)", b.len()), + CryptoKey::Bytes(b) => format!("bytes({})", hex_short(b)), + _ => "(unknown)".to_string(), + } +} + +fn role_str(role: i64) -> String { + match role { + 0 => "tag-creator".into(), + 1 => "creator/manifest-creator".into(), + 2 => "maintainer/manifest-signer".into(), + _ => format!("role({})", role), + } +} + +fn version_scheme_str(scheme: i64) -> String { + match scheme { + 1 => "multipartnumeric".into(), + 2 => "multipartnumeric-suffix".into(), + 3 => "alphanumeric".into(), + 4 => "decimal".into(), + 16384 => "semver".into(), + _ => format!("{}", scheme), + } +} + +// ── Hex / UUID helpers ─────────────────────────────────────────────────── + +fn hex_short(bytes: &[u8]) -> String { + if bytes.len() <= 32 { + hex::encode(bytes) + } else { + format!("{}...({} bytes)", hex::encode(&bytes[..16]), bytes.len()) + } +} + +fn format_uuid(bytes: &[u8]) -> String { + if bytes.len() == 16 { + format!( + "{}-{}-{}-{}-{}", + hex::encode(&bytes[0..4]), + hex::encode(&bytes[4..6]), + hex::encode(&bytes[6..8]), + hex::encode(&bytes[8..10]), + hex::encode(&bytes[10..16]), + ) + } else { + hex::encode(bytes) + } +} diff --git a/corim-cli/src/gen_sample.rs b/corim-cli/src/gen_sample.rs new file mode 100644 index 0000000..67cf5a8 --- /dev/null +++ b/corim-cli/src/gen_sample.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Generate a sample CoRIM CBOR file for testing the CLI. + +fn main() { + use corim::builder::{ComidBuilder, CorimBuilder}; + use corim::types::common::{EntityMap, MeasuredElement, TagIdChoice}; + use corim::types::corim::CorimId; + use corim::types::environment::{ClassMap, EnvironmentMap}; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap, SvnChoice}; + use corim::types::triples::{ + CesCondition, ConditionalEndorsementSeriesTriple, ConditionalSeriesRecord, EndorsedTriple, + ReferenceTriple, + }; + + let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("ACME Corp".into()), + model: Some("Turbo Encabulator".into()), + layer: Some(0), + index: None, + }), + instance: None, + group: None, + }; + + let ref_meas = MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![ + Digest::new(7, vec![0xAA; 48]), // SHA-384 + Digest::new(2, vec![0xBB; 32]), // SHA-256 + ]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + let endorsed_meas = MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::MinValue(3)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + let ces_triple = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ + ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(5)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ), + ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xCC; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(4)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ), + ], + ); + + let comid = ComidBuilder::new(TagIdChoice::Text( + "example.com/acme/turbo-encabulator/1.0".into(), + )) + .set_tag_version(0) + .set_language("en") + .add_entity(EntityMap { + entity_name: "ACME Corp".into(), + reg_id: Some("https://acme.example.com".into()), + role: vec![0, 1], // tag-creator + creator + }) + .add_reference_triple(ReferenceTriple::new(env.clone(), vec![ref_meas])) + .add_endorsed_triple(EndorsedTriple::new(env.clone(), vec![endorsed_meas])) + .add_conditional_endorsement_series(ces_triple) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("example.com/acme/corim/1.0".into())) + .set_profile(corim::types::corim::ProfileChoice::Uri( + "https://example.com/acme-profile/v1".into(), + )) + .set_validity(Some(1700000000), 1900000000) + .unwrap() + .add_entity(EntityMap { + entity_name: "ACME Corp".into(), + reg_id: Some("https://acme.example.com".into()), + role: vec![1], // manifest-creator + }) + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + + use std::io::Write; + let args: Vec = std::env::args().collect(); + if args.len() > 1 { + std::fs::write(&args[1], &bytes).unwrap(); + eprintln!("Wrote {} bytes to {}", bytes.len(), args[1]); + } else { + std::io::stdout().write_all(&bytes).unwrap(); + } +} diff --git a/corim-cli/src/gen_signed_sample.rs b/corim-cli/src/gen_signed_sample.rs new file mode 100644 index 0000000..1443a34 --- /dev/null +++ b/corim-cli/src/gen_signed_sample.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Generates a sample signed CoRIM document for testing. + +use corim::builder::{ComidBuilder, CorimBuilder}; +use corim::types::common::{MeasuredElement, TagIdChoice}; +use corim::types::corim::{CorimId, CorimMetaMap, CorimSignerMap}; +use corim::types::environment::{ClassMap, EnvironmentMap}; +use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; +use corim::types::signed::{CwtClaims, SignedCorimBuilder}; +use corim::types::triples::ReferenceTriple; + +fn main() { + // Build the inner unsigned CoRIM + let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("ACME Corp".into()), + model: Some("Widget v2".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + }; + + let meas = MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + let comid = ComidBuilder::new(TagIdChoice::Text("sample-signed-comid".into())) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .build() + .unwrap(); + + let corim_bytes = CorimBuilder::new(CorimId::Text("sample-signed-corim".into())) + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + + // Build the signed CoRIM + let mut builder = SignedCorimBuilder::new(-7, corim_bytes) + .set_corim_meta(CorimMetaMap { + signer: CorimSignerMap { + signer_name: "ACME Corp".into(), + signer_uri: Some("https://acme.example.com".into()), + }, + signature_validity: None, + }) + .set_cwt_claims( + CwtClaims::new("ACME Corp") + .with_sub("sample-signed-corim") + .with_nbf(1700000000) + .with_exp(2000000000), + ); + + // Get TBS and "sign" with a fake signature + let _tbs = builder.to_be_signed(&[]).unwrap(); + let fake_signature = vec![0xAB; 64]; // 64-byte fake ES256 signature + + let signed_bytes = builder.build_with_signature(fake_signature).unwrap(); + + // Write to stdout + use std::io::Write; + std::io::stdout().write_all(&signed_bytes).unwrap(); +} diff --git a/corim-cli/src/main.rs b/corim-cli/src/main.rs new file mode 100644 index 0000000..3689798 --- /dev/null +++ b/corim-cli/src/main.rs @@ -0,0 +1,445 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CLI tool for validating and inspecting CoRIM documents. + +use std::fs; +use std::io::{self, Read}; +use std::process; + +use clap::Parser; + +mod display; + +/// Validate and inspect CoRIM (Concise Reference Integrity Manifest) documents. +/// +/// Reads a CBOR-encoded CoRIM file (tag-501-wrapped), validates its structure +/// against draft-ietf-rats-corim-10, and outputs the decoded structure. +#[derive(Parser)] +#[command(name = "corim-cli", version, about)] +struct Cli { + /// Path to the CoRIM CBOR file. Use "-" or omit for stdin. + #[arg(value_name = "FILE")] + file: Option, + + /// Output format. + #[arg(short, long, default_value = "text", value_parser = ["text", "json"])] + format: String, + + /// Skip validity-period expiration check. + #[arg(long)] + skip_expiry: bool, + + /// Show raw hex of tag payloads (CoMID/CoSWID/CoTL bytes). + #[arg(long)] + show_raw: bool, +} + +fn main() { + let cli = Cli::parse(); + + let bytes = match read_input(&cli.file) { + Ok(b) => b, + Err(e) => { + eprintln!("Error reading input: {}", e); + process::exit(1); + } + }; + + if bytes.is_empty() { + eprintln!("Error: input is empty"); + process::exit(1); + } + + // Step 1: Detect format — try signed CoRIM (tag 18) first, then unsigned (tag 501) + let (corim, signed_info) = match try_decode_signed(&bytes) { + Some(result) => match result { + Ok((corim_map, info)) => (corim_map, Some(info)), + Err(e) => { + eprintln!("FAIL: Detected signed CoRIM (tag 18) but decode failed"); + eprintln!(" Error: {}", e); + process::exit(2); + } + }, + None => { + // Try unsigned CoRIM (tag 501) + let tagged: corim::cbor::value::Tagged = + match corim::cbor::decode(&bytes) { + Ok(t) => t, + Err(e) => { + eprintln!("FAIL: Cannot decode as CoRIM"); + eprintln!(" Not a signed CoRIM (tag 18) or unsigned CoRIM (tag 501)"); + eprintln!(" CBOR decode error: {}", e); + process::exit(2); + } + }; + + if tagged.tag != corim::types::tags::TAG_CORIM { + eprintln!( + "FAIL: Expected CBOR tag {} (unsigned) or {} (signed), found tag {}", + corim::types::tags::TAG_CORIM, + corim::types::tags::TAG_SIGNED_CORIM, + tagged.tag + ); + process::exit(2); + } + (tagged.value, None) + } + }; + + // Step 2: Structural validation + let mut warnings: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + // Check rim-validity + if let Some(ref validity) = corim.rim_validity { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + if let Some(nb) = validity.not_before { + if now < nb.epoch_secs() && !cli.skip_expiry { + warnings.push(format!( + "rim-validity.not-before ({}) is in the future", + nb.epoch_secs() + )); + } + } + + if validity.not_after.epoch_secs() < now && !cli.skip_expiry { + errors.push(format!( + "rim-validity.not-after ({}) is in the past — CoRIM is expired", + validity.not_after.epoch_secs() + )); + } + } + + if corim.tags.is_empty() { + errors.push("tags array is empty — at least one tag is required".into()); + } + + // Step 3: Decode and validate each tag + let mut comid_tags: Vec = Vec::new(); + let mut coswid_count = 0u32; + let mut cotl_count = 0u32; + let mut unknown_count = 0u32; + + for (i, tag) in corim.tags.iter().enumerate() { + match tag { + corim::types::corim::ConciseTagChoice::Comid(comid_bytes) => { + match corim::cbor::decode::(comid_bytes) { + Ok(comid) => { + // Validate triples non-empty + let t = &comid.triples; + let has_triples = t.reference_triples.is_some() + || t.endorsed_triples.is_some() + || t.identity_triples.is_some() + || t.attest_key_triples.is_some() + || t.dependency_triples.is_some() + || t.membership_triples.is_some() + || t.coswid_triples.is_some() + || t.conditional_endorsement_series.is_some() + || t.conditional_endorsement.is_some(); + + if !has_triples { + errors.push(format!("tags[{}] (CoMID): triples-map is empty", i)); + } + comid_tags.push(comid); + } + Err(e) => { + errors.push(format!( + "tags[{}] (CoMID): failed to decode inner CBOR — {}", + i, e + )); + } + } + } + corim::types::corim::ConciseTagChoice::Coswid(_) => coswid_count += 1, + corim::types::corim::ConciseTagChoice::Cotl(_) => cotl_count += 1, + corim::types::corim::ConciseTagChoice::Unknown(tag_num, _) => { + warnings.push(format!("tags[{}]: unknown tag type {}", i, tag_num)); + unknown_count += 1; + } + _ => { + warnings.push(format!("tags[{}]: unrecognized tag variant", i)); + unknown_count += 1; + } + } + } + + // Step 4: Output results + match cli.format.as_str() { + "json" => { + print_json_output(&corim, &comid_tags, &errors, &warnings, cli.show_raw); + } + _ => { + print_text_output( + &corim, + &comid_tags, + &errors, + &warnings, + cli.show_raw, + coswid_count, + cotl_count, + unknown_count, + &signed_info, + ); + } + } + + if !errors.is_empty() { + process::exit(2); + } +} + +/// Information extracted from a signed CoRIM's COSE_Sign1 wrapper. +struct SignedInfo { + alg: i64, + signer_name: Option, + content_type: Option, + signature_len: usize, + has_cwt_claims: bool, + has_corim_meta: bool, +} + +/// Try to decode the bytes as a signed CoRIM (tag 18). +/// Returns `None` if the first byte doesn't indicate tag 18 (0xD2). +/// Returns `Some(Ok(...))` on success or `Some(Err(...))` on decode failure. +fn try_decode_signed( + bytes: &[u8], +) -> Option> { + // Quick check: tag 18 starts with 0xD2 + if bytes.first() != Some(&0xD2) { + return None; + } + + let signed = match corim::types::signed::decode_signed_corim(bytes) { + Ok(s) => s, + Err(e) => return Some(Err(format!("{}", e))), + }; + + let payload = match &signed.payload { + Some(p) => p, + None => return Some(Err("signed CoRIM has detached (nil) payload".into())), + }; + + // Decode the inner CoRIM from the payload + let tagged: corim::cbor::value::Tagged = + match corim::cbor::decode(payload) { + Ok(t) => t, + Err(e) => return Some(Err(format!("cannot decode inner CoRIM payload: {}", e))), + }; + + if tagged.tag != corim::types::tags::TAG_CORIM { + return Some(Err(format!( + "inner payload expected tag {}, found {}", + corim::types::tags::TAG_CORIM, + tagged.tag + ))); + } + + let signer_name = signed + .protected + .cwt_claims + .as_ref() + .map(|c| c.iss.clone()) + .or_else(|| { + signed + .protected + .corim_meta + .as_ref() + .map(|m| m.signer.signer_name.clone()) + }); + + let info = SignedInfo { + alg: signed.protected.alg, + signer_name, + content_type: signed.protected.content_type.clone(), + signature_len: signed.signature.len(), + has_cwt_claims: signed.protected.cwt_claims.is_some(), + has_corim_meta: signed.protected.corim_meta.is_some(), + }; + + Some(Ok((tagged.value, info))) +} + +fn read_input(path: &Option) -> io::Result> { + match path { + Some(p) if p != "-" => fs::read(p), + _ => { + let mut buf = Vec::new(); + io::stdin().read_to_end(&mut buf)?; + Ok(buf) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn print_text_output( + corim: &corim::types::corim::CorimMap, + comids: &[corim::types::comid::ComidTag], + errors: &[String], + warnings: &[String], + show_raw: bool, + coswid_count: u32, + cotl_count: u32, + unknown_count: u32, + signed_info: &Option, +) { + // Validation result + if errors.is_empty() { + println!("✓ CoRIM is valid\n"); + } else { + println!("✗ CoRIM validation failed\n"); + for e in errors { + println!(" ERROR: {}", e); + } + println!(); + } + + for w in warnings { + println!(" WARNING: {}", w); + } + if !warnings.is_empty() { + println!(); + } + + // CoRIM header + println!("═══ CoRIM Map ═══"); + + // Show signed CoRIM info if present + if let Some(ref info) = signed_info { + println!(" [SIGNED] COSE_Sign1 (tag 18)"); + println!(" Algorithm: {}", cose_alg_name(info.alg)); + if let Some(ref ct) = info.content_type { + println!(" Content-Type: {}", ct); + } + if let Some(ref name) = info.signer_name { + println!(" Signer: {}", name); + } + println!( + " Metadata: {}{}", + if info.has_cwt_claims { + "CWT-Claims" + } else { + "" + }, + if info.has_corim_meta { + if info.has_cwt_claims { + " + corim-meta" + } else { + "corim-meta" + } + } else { + "" + }, + ); + println!(" Signature: {} bytes", info.signature_len); + println!(); + } + + display::print_corim(corim, show_raw); + + // Tag summary + println!( + "\n Tags: {} total ({} CoMID, {} CoSWID, {} CoTL, {} unknown)", + corim.tags.len(), + comids.len(), + coswid_count, + cotl_count, + unknown_count, + ); + + // Each CoMID + for (i, comid) in comids.iter().enumerate() { + println!("\n ─── CoMID [{}] ───", i); + display::print_comid(comid, " ", show_raw); + } + + println!(); +} + +fn print_json_output( + corim: &corim::types::corim::CorimMap, + comids: &[corim::types::comid::ComidTag], + errors: &[String], + warnings: &[String], + _show_raw: bool, +) { + // Simple JSON output without pulling in serde_json + println!("{{"); + println!(" \"valid\": {},", errors.is_empty()); + + if !errors.is_empty() { + println!(" \"errors\": ["); + for (i, e) in errors.iter().enumerate() { + let comma = if i + 1 < errors.len() { "," } else { "" }; + println!(" \"{}\"{}", json_escape(e), comma); + } + println!(" ],"); + } + + if !warnings.is_empty() { + println!(" \"warnings\": ["); + for (i, w) in warnings.iter().enumerate() { + let comma = if i + 1 < warnings.len() { "," } else { "" }; + println!(" \"{}\"{}", json_escape(w), comma); + } + println!(" ],"); + } + + println!(" \"id\": {},", display::corim_id_json(&corim.id)); + println!(" \"tags_count\": {},", corim.tags.len()); + println!(" \"comid_count\": {},", comids.len()); + + if let Some(ref profile) = corim.profile { + println!( + " \"profile\": \"{}\",", + json_escape(&display::profile_str(profile)) + ); + } + + // CoMIDs summary + println!(" \"comids\": ["); + for (i, comid) in comids.iter().enumerate() { + let comma = if i + 1 < comids.len() { "," } else { "" }; + println!(" {{"); + println!( + " \"tag_id\": {},", + display::tag_id_json(&comid.tag_identity.tag_id) + ); + if let Some(v) = comid.tag_identity.tag_version { + println!(" \"tag_version\": {},", v); + } + let triple_types = display::triple_type_list(&comid.triples); + println!(" \"triple_types\": [{}]", triple_types); + println!(" }}{}", comma); + } + println!(" ]"); + + println!("}}"); +} + +fn json_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +/// Map a COSE algorithm identifier to a human-readable name. +fn cose_alg_name(alg: i64) -> String { + match alg { + -7 => "ES256 (-7)".into(), + -35 => "ES384 (-35)".into(), + -36 => "ES512 (-36)".into(), + -37 => "PS256 (-37)".into(), + -38 => "PS384 (-38)".into(), + -39 => "PS512 (-39)".into(), + -257 => "PS256 (-257)".into(), + -258 => "PS384 (-258)".into(), + -259 => "PS512 (-259)".into(), + -65535 => "HSS-LMS (-65535)".into(), + other => format!("{}", other), + } +} diff --git a/corim-macros/Cargo.toml b/corim-macros/Cargo.toml new file mode 100644 index 0000000..9fa833b --- /dev/null +++ b/corim-macros/Cargo.toml @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "corim-macros" +version = "0.1.0" +edition = "2021" +description = "Internal proc-macro derive crate for the corim crate. Provides CborSerialize/CborDeserialize derives for CBOR integer-keyed map encoding." +license = "MIT" +authors = ["Microsoft"] +repository = "https://github.com/mingweishih/corim" +keywords = ["corim", "cbor", "derive", "proc-macro"] +categories = ["encoding"] + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/corim-macros/src/attrs.rs b/corim-macros/src/attrs.rs new file mode 100644 index 0000000..37fdf05 --- /dev/null +++ b/corim-macros/src/attrs.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Attribute parsing for `#[cbor(...)]`. + +use syn::Attribute; + +/// Struct-level attributes. +#[derive(Debug, Default)] +pub struct StructAttrs { + /// If set, wrap the serialized form in this CBOR tag number. + pub tag: Option, + /// If true, at least one field must be present (CDDL `non-empty`). + pub non_empty: bool, +} + +/// Field-level attributes. +#[derive(Debug)] +pub struct FieldAttrs { + /// The CBOR integer key for this field. + pub key: i64, + /// Whether the field is optional (`Option`). + pub optional: bool, +} + +impl StructAttrs { + pub fn from_attrs(attrs: &[Attribute]) -> syn::Result { + let mut result = Self::default(); + + for attr in attrs { + if !attr.path().is_ident("cbor") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("tag") { + let _eq: syn::Token![=] = meta.input.parse()?; + let lit: syn::LitInt = meta.input.parse()?; + result.tag = Some(lit.base10_parse::()?); + Ok(()) + } else if meta.path.is_ident("non_empty") { + result.non_empty = true; + Ok(()) + } else { + Err(meta.error("unknown cbor struct attribute")) + } + })?; + } + + Ok(result) + } +} + +impl FieldAttrs { + pub fn from_attrs(attrs: &[Attribute]) -> syn::Result> { + let mut key: Option = None; + let mut optional = false; + + for attr in attrs { + if !attr.path().is_ident("cbor") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("key") { + let _eq: syn::Token![=] = meta.input.parse()?; + let lit: syn::LitInt = meta.input.parse()?; + key = Some(lit.base10_parse::()?); + Ok(()) + } else if meta.path.is_ident("optional") { + optional = true; + Ok(()) + } else { + Err(meta.error("unknown cbor field attribute")) + } + })?; + } + + match key { + Some(k) => Ok(Some(FieldAttrs { key: k, optional })), + None if optional => Err(syn::Error::new_spanned( + &attrs[0], + "#[cbor(optional)] requires #[cbor(key = ...)]", + )), + None => Ok(None), + } + } +} + +/// Parsed field info for code generation. +pub struct CborField { + pub ident: syn::Ident, + pub attrs: FieldAttrs, +} + +/// Extract all CBOR-annotated fields from a struct. +pub fn parse_fields(data: &syn::DataStruct) -> syn::Result> { + let mut fields = Vec::new(); + + for field in &data.fields { + let ident = field + .ident + .clone() + .ok_or_else(|| syn::Error::new_spanned(field, "tuple structs not supported"))?; + + if let Some(attrs) = FieldAttrs::from_attrs(&field.attrs)? { + fields.push(CborField { ident, attrs }); + } + } + + if fields.is_empty() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "at least one field must have #[cbor(key = ...)]", + )); + } + + Ok(fields) +} diff --git a/corim-macros/src/de.rs b/corim-macros/src/de.rs new file mode 100644 index 0000000..0d24a54 --- /dev/null +++ b/corim-macros/src/de.rs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `CborDeserialize` expansion — generates `serde::Deserialize` impls using +//! `MapAccess` visitor with integer keys. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Data, DeriveInput}; + +use crate::attrs::{parse_fields, StructAttrs}; + +pub fn expand_deserialize(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + let (_impl_generics, _ty_generics, _where_clause) = input.generics.split_for_impl(); + + let struct_attrs = StructAttrs::from_attrs(&input.attrs)?; + + let data = match &input.data { + Data::Struct(s) => s, + _ => { + return Err(syn::Error::new_spanned( + input, + "CborDeserialize only supports structs", + )) + } + }; + + let fields = parse_fields(data)?; + let vis_name = format!("{}Visitor", name); + let visitor_ident = format_ident!("__{}Visitor", name); + + // For each field, get its type from the original struct definition + let field_types: Vec<_> = data + .fields + .iter() + .filter_map(|f| { + let ident = f.ident.as_ref()?; + // Check if this field is in our parsed cbor fields + fields.iter().find(|cf| cf.ident == *ident).map(|_| &f.ty) + }) + .collect(); + + // Temporaries: one Option per field to accumulate during visitation + let temp_decls: Vec<_> = fields + .iter() + .zip(field_types.iter()) + .map(|(f, _ty)| { + let temp = format_ident!("__field_{}", f.ident); + quote! { let mut #temp: Option<#_ty> = None; } + }) + .collect(); + + // Match arms: map integer key -> set the right temporary + let match_arms: Vec<_> = fields + .iter() + .zip(field_types.iter()) + .map(|(f, _ty)| { + let key = f.attrs.key; + let temp = format_ident!("__field_{}", f.ident); + if f.attrs.optional { + // For optional fields the struct type is Option. + // We want to deserialize the inner type and wrap in Some. + // But the map value is the inner type, not Option. + // We can just deserialize as the full Option type or + // use the inner. Let's deserialize as the field type directly: + // since the value exists in the map, we set Some(value). + quote! { + #key => { + #temp = Some(map.next_value()?); + } + } + } else { + quote! { + #key => { + #temp = Some(map.next_value()?); + } + } + } + }) + .collect(); + + // Post-visit: build the struct from temporaries + let field_constructs: Vec<_> = fields + .iter() + .map(|f| { + let ident = &f.ident; + let temp = format_ident!("__field_{}", f.ident); + if f.attrs.optional { + // Optional fields: if the key wasn't in the map, it's None. + // If it was, temp is Some(Option) — but we deserialized as the field type. + // Actually we need to be careful. The field type IS Option. + // When the key is present, we did `temp = Some(map.next_value::()?)`. + // That means temp is Option>. We want to flatten. + // Better approach: deserialize as the inner type directly. + // Let's handle it differently. The temp holds Option. + // If FieldType is Option, then temp: Option>. + // We flatten with .unwrap_or(None) → Option. + // Actually .flatten() is cleaner. + quote! { #ident: #temp.flatten() } + } else { + let err_msg = format!("missing required field with key {}", f.attrs.key); + quote! { + #ident: #temp.ok_or_else(|| serde::de::Error::custom(#err_msg))? + } + } + }) + .collect(); + + // Non-empty check after constructing + let non_empty_check = if struct_attrs.non_empty { + let checks: Vec<_> = fields + .iter() + .filter(|f| f.attrs.optional) + .map(|f| { + let ident = &f.ident; + quote! { result.#ident.is_none() } + }) + .collect(); + + if checks.is_empty() { + quote! {} + } else { + quote! { + if #(#checks)&&* { + return Err(serde::de::Error::custom( + concat!("non-empty constraint violated: all optional fields are None in ", stringify!(#name)) + )); + } + } + } + } else { + quote! {} + }; + + let deserialize_body = quote! { + fn deserialize<__D>(deserializer: __D) -> Result + where + __D: serde::Deserializer<'de>, + { + struct #visitor_ident; + + impl<'de> serde::de::Visitor<'de> for #visitor_ident { + type Value = #name; + + fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(#vis_name) + } + + fn visit_map<__A>(self, mut map: __A) -> Result + where + __A: serde::de::MapAccess<'de>, + { + #(#temp_decls)* + + while let Some(key) = map.next_key::()? { + match key { + #(#match_arms)* + _ => { + // Skip unknown keys for forward compatibility + let _ = map.next_value::()?; + } + } + } + + let result = #name { + #(#field_constructs,)* + }; + + #non_empty_check + + Ok(result) + } + } + + deserializer.deserialize_map(#visitor_ident) + } + }; + + // If a tag is set, unwrap the tag first + let expanded = if let Some(tag) = struct_attrs.tag { + quote! { + impl<'de> serde::Deserialize<'de> for #name { + fn deserialize<__D>(deserializer: __D) -> Result + where + __D: serde::Deserializer<'de>, + { + let tagged: crate::cbor::value::Tagged<__CborDeInner> = + crate::cbor::value::Tagged::deserialize(deserializer)?; + if tagged.tag != #tag { + return Err(serde::de::Error::custom( + format!("expected CBOR tag {}, found {}", #tag, tagged.tag) + )); + } + Ok(tagged.value.0) + } + } + + // Helper for inner map deserialization. + // Uses a name unlikely to collide in user code. + struct __CborDeInner(#name); + + impl<'de> serde::Deserialize<'de> for __CborDeInner { + #deserialize_body + } + } + } else { + quote! { + impl<'de> serde::Deserialize<'de> for #name { + #deserialize_body + } + } + }; + + Ok(expanded) +} diff --git a/corim-macros/src/lib.rs b/corim-macros/src/lib.rs new file mode 100644 index 0000000..56eb811 --- /dev/null +++ b/corim-macros/src/lib.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Proc-macro derive crate for CBOR integer-keyed map serialization. +//! +//! Provides `CborSerialize` and `CborDeserialize` derives that generate +//! `serde::Serialize` and `serde::Deserialize` implementations using +//! `serialize_map` / `MapAccess` visitors with integer keys — required for +//! CoRIM CDDL types where every map uses integer keys, not string field names. +//! +//! # Supported attributes +//! +//! ## Struct-level +//! - `#[cbor(tag = )]` — wrap the serialized form in a CBOR semantic tag +//! - `#[cbor(non_empty)]` — enforce at least one field is present +//! +//! ## Field-level +//! - `#[cbor(key = )]` — CBOR integer key for this field (required) +//! - `#[cbor(optional)]` — field is `Option`; skip on `None`, tolerate absence + +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; + +mod attrs; +mod de; +mod ser; + +/// Derive `serde::Serialize` using integer-keyed CBOR map encoding. +/// +/// Fields must be annotated with `#[cbor(key = )]`. +/// Optional fields use `#[cbor(optional)]` and must be `Option`. +#[proc_macro_derive(CborSerialize, attributes(cbor))] +pub fn derive_cbor_serialize(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match ser::expand_serialize(&input) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} + +/// Derive `serde::Deserialize` using integer-keyed CBOR map decoding. +/// +/// Fields must be annotated with `#[cbor(key = )]`. +/// Optional fields use `#[cbor(optional)]` and must be `Option`. +#[proc_macro_derive(CborDeserialize, attributes(cbor))] +pub fn derive_cbor_deserialize(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + match de::expand_deserialize(&input) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} diff --git a/corim-macros/src/ser.rs b/corim-macros/src/ser.rs new file mode 100644 index 0000000..33f9a0f --- /dev/null +++ b/corim-macros/src/ser.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `CborSerialize` expansion — generates `serde::Serialize` impls using +//! `serialize_map` with integer keys in ascending order for deterministic +//! CBOR encoding. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput}; + +use crate::attrs::{parse_fields, StructAttrs}; + +pub fn expand_serialize(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let struct_attrs = StructAttrs::from_attrs(&input.attrs)?; + + let data = match &input.data { + Data::Struct(s) => s, + _ => { + return Err(syn::Error::new_spanned( + input, + "CborSerialize only supports structs", + )) + } + }; + + let fields = parse_fields(data)?; + + // Build non_empty check if needed + let non_empty_check = if struct_attrs.non_empty { + let checks: Vec<_> = fields + .iter() + .filter(|f| f.attrs.optional) + .map(|f| { + let ident = &f.ident; + quote! { self.#ident.is_none() } + }) + .collect(); + + if checks.is_empty() { + quote! {} + } else { + quote! { + if #(#checks)&&* { + return Err(serde::ser::Error::custom( + concat!("non-empty constraint violated: all optional fields are None in ", stringify!(#name)) + )); + } + } + } + } else { + quote! {} + }; + + // Count entries for the map. We compute the count at runtime since optional + // fields may be absent. + let count_exprs: Vec<_> = fields + .iter() + .map(|f| { + let ident = &f.ident; + if f.attrs.optional { + quote! { if self.#ident.is_some() { 1usize } else { 0usize } } + } else { + quote! { 1usize } + } + }) + .collect(); + + // Emit map entries in key-ascending order (caller must declare fields in order) + let entry_stmts: Vec<_> = fields + .iter() + .map(|f| { + let ident = &f.ident; + let key = f.attrs.key; + if f.attrs.optional { + quote! { + if let Some(ref val) = self.#ident { + map.serialize_entry(&#key, val)?; + } + } + } else { + quote! { + map.serialize_entry(&#key, &self.#ident)?; + } + } + }) + .collect(); + + let serialize_body = quote! { + fn serialize<__S>(&self, serializer: __S) -> Result<__S::Ok, __S::Error> + where + __S: serde::Serializer, + { + use serde::ser::SerializeMap as _; + + #non_empty_check + + let count: usize = #(#count_exprs)+*; + let mut map = serializer.serialize_map(Some(count))?; + #(#entry_stmts)* + serde::ser::SerializeMap::end(map) + } + }; + + // If a tag is set, wrap with crate::cbor::value::Tagged + let expanded = if let Some(tag) = struct_attrs.tag { + quote! { + impl #impl_generics serde::Serialize for #name #ty_generics #where_clause { + fn serialize<__S>(&self, serializer: __S) -> Result<__S::Ok, __S::Error> + where + __S: serde::Serializer, + { + use serde::ser::Error as _; + + let inner = __CborSerInner(self); + crate::cbor::value::Tagged::new(#tag, inner).serialize(serializer) + } + } + + // A helper newtype for the inner map serialization (without tag). + // Uses a name unlikely to collide in user code. + struct __CborSerInner<'a>(pub &'a #name); + + impl<'a> serde::Serialize for __CborSerInner<'a> { + #serialize_body + } + } + } else { + quote! { + impl #impl_generics serde::Serialize for #name #ty_generics #where_clause { + #serialize_body + } + } + }; + + Ok(expanded) +} diff --git a/corim/Cargo.toml b/corim/Cargo.toml new file mode 100644 index 0000000..14dc485 --- /dev/null +++ b/corim/Cargo.toml @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "corim" +version = "0.1.0" +edition = "2021" +description = "Concise Reference Integrity Manifest (CoRIM) — CBOR-based encoding of Endorsements and Reference Values for Remote Attestation (RATS)." +license = "MIT" +authors = ["Microsoft"] +repository = "https://github.com/mingweishih/corim" +keywords = ["corim", "comid", "cbor", "attestation", "rats"] +categories = ["encoding", "authentication", "cryptography"] +readme = "../README.md" +# Exclude development-only test files from the published crate. +# These are useful locally but not needed by downstream users. +exclude = [ + "tests/coverage_boost_tests.rs", + "tests/coverage_ceiling_tests.rs", + "tests/negative_error_path_tests.rs", +] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +default = [] +json = ["serde_json"] + +[dependencies] +corim-macros.workspace = true +serde.workspace = true +serde_json = { workspace = true, optional = true } +thiserror.workspace = true diff --git a/corim/examples/build_corim.rs b/corim/examples/build_corim.rs new file mode 100644 index 0000000..1862e01 --- /dev/null +++ b/corim/examples/build_corim.rs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Example: build a CoRIM with reference values and encode to CBOR. + +use corim::builder::{ComidBuilder, CorimBuilder}; +use corim::types::common::{EntityMap, MeasuredElement, TagIdChoice}; +use corim::types::corim::{CorimId, ProfileChoice}; +use corim::types::environment::{ClassMap, EnvironmentMap}; +use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; +use corim::types::tags::COMID_ROLE_TAG_CREATOR; +use corim::types::triples::ReferenceTriple; + +fn main() { + // Define the target environment + let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("ACME Corp".into()), + model: Some("Turbo Encabulator".into()), + layer: Some(0), + index: None, + }), + instance: None, + group: None, + }; + + // Create a measurement with a SHA-384 digest + let measurement = MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + // Build a CoMID + let comid = ComidBuilder::new(TagIdChoice::Text( + "example.com/acme/turbo-encabulator".into(), + )) + .set_tag_version(0) + .add_entity(EntityMap { + entity_name: "ACME Corp".into(), + reg_id: Some("https://acme.example.com".into()), + role: vec![COMID_ROLE_TAG_CREATOR], + }) + .add_reference_triple(ReferenceTriple::new(env, vec![measurement])) + .build() + .expect("failed to build CoMID"); + + // Wrap in a CoRIM and encode + let bytes = CorimBuilder::new(CorimId::Text("acme/corim/v1".into())) + .set_profile(ProfileChoice::Uri( + "https://example.com/acme-profile".into(), + )) + .set_validity(Some(1700000000), 1900000000) + .unwrap() + .add_comid_tag(comid) + .expect("failed to encode CoMID") + .build_bytes() + .expect("failed to build CoRIM"); + + println!("Encoded CoRIM: {} bytes", bytes.len()); + println!( + "Hex: {}", + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + ); +} diff --git a/corim/examples/validate_corim.rs b/corim/examples/validate_corim.rs new file mode 100644 index 0000000..14f3619 --- /dev/null +++ b/corim/examples/validate_corim.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Example: decode and validate a CoRIM from a CBOR file. + +use std::env; +use std::fs; + +fn main() { + let args: Vec = env::args().collect(); + let path = args.get(1).expect("Usage: validate_corim "); + + let bytes = fs::read(path).expect("failed to read file"); + println!("Read {} bytes from {}", bytes.len(), path); + + match corim::validate::decode_and_validate(&bytes) { + Ok((corim, comids)) => { + println!("✓ CoRIM is valid"); + println!(" id: {}", corim.id); + if let Some(ref p) = corim.profile { + println!(" profile: {}", p); + } + println!(" tags: {}", corim.tags.len()); + for (i, comid) in comids.iter().enumerate() { + println!(" CoMID[{}]: tag-id={}", i, comid.tag_identity.tag_id); + } + } + Err(e) => { + eprintln!("✗ Validation failed: {}", e); + std::process::exit(1); + } + } +} diff --git a/corim/src/builder.rs b/corim/src/builder.rs new file mode 100644 index 0000000..cd29d8c --- /dev/null +++ b/corim/src/builder.rs @@ -0,0 +1,542 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Builder API for CoRIM and CoMID generation. +//! +//! Provides a fluent interface for constructing CoRIM and CoMID structures +//! per draft-ietf-rats-corim-10. +//! +//! # Example +//! +//! ```rust +//! use corim::builder::{ComidBuilder, CorimBuilder}; +//! use corim::types::common::{TagIdChoice, MeasuredElement}; +//! use corim::types::corim::CorimId; +//! use corim::types::environment::{ClassMap, EnvironmentMap}; +//! use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; +//! use corim::types::triples::ReferenceTriple; +//! +//! let env = EnvironmentMap { +//! class: Some(ClassMap { +//! class_id: None, +//! vendor: Some("ACME".into()), +//! model: Some("Widget".into()), +//! layer: None, +//! index: None, +//! }), +//! instance: None, +//! group: None, +//! }; +//! +//! let meas = MeasurementMap { +//! mkey: Some(MeasuredElement::Text("firmware".into())), +//! mval: MeasurementValuesMap { +//! digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), +//! ..MeasurementValuesMap::default() +//! }, +//! authorized_by: None, +//! }; +//! +//! let comid = ComidBuilder::new(TagIdChoice::Text("my-comid-tag".into())) +//! .add_reference_triple(ReferenceTriple::new(env, vec![meas])) +//! .build() +//! .unwrap(); +//! +//! let bytes = CorimBuilder::new(CorimId::Text("my-corim".into())) +//! .add_comid_tag(comid).unwrap() +//! .build_bytes() +//! .unwrap(); +//! ``` + +use crate::cbor; +use crate::error::BuilderError; +use crate::types::comid::ComidTag; +use crate::types::common::{ + CborTime, EntityMap, LinkedTagMap, TagIdChoice, TagIdentity, ValidityMap, +}; +use crate::types::corim::{ + ConciseTagChoice, ConciseTlTag, CorimId, CorimLocator, CorimMap, ProfileChoice, +}; +use crate::types::coswid::ConciseSwidTag; +use crate::types::tags::TAG_CORIM; +use crate::types::triples::{ + AttestKeyTriple, ConditionalEndorsementSeriesTriple, ConditionalEndorsementTriple, + CoswidTriple, DomainDependencyTriple, DomainMembershipTriple, EndorsedTriple, IdentityTriple, + ReferenceTriple, TriplesMap, +}; +use crate::Validate; + +// --------------------------------------------------------------------------- +// ComidBuilder +// --------------------------------------------------------------------------- + +/// Builder for constructing a [`ComidTag`] (CoMID). +/// +/// Accepts a caller-provided `tag-id` and allows adding any combination +/// of the nine triple types defined by the RFC. At least one triple must +/// be present for [`build`](ComidBuilder::build) to succeed. +#[must_use] +pub struct ComidBuilder { + tag_id: TagIdChoice, + tag_version: Option, + language: Option, + entities: Option>, + linked_tags: Option>, + reference_triples: Option>, + endorsed_triples: Option>, + identity_triples: Option>, + attest_key_triples: Option>, + dependency_triples: Option>, + membership_triples: Option>, + coswid_triples: Option>, + conditional_endorsement_series: Option>, + conditional_endorsement: Option>, +} + +impl ComidBuilder { + /// Create a new builder with the given tag identifier. + /// + /// The tag identifier must be globally unique per §5.1.1.1. + pub fn new(tag_id: TagIdChoice) -> Self { + Self { + tag_id, + tag_version: None, + language: None, + entities: None, + linked_tags: None, + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + } + } + + /// Set the tag version (§5.1.1.2). Defaults to 0 if not set. + pub fn set_tag_version(mut self, version: u64) -> Self { + self.tag_version = Some(version); + self + } + + /// Set the optional language tag (BCP 47). + pub fn set_language(mut self, lang: impl Into) -> Self { + self.language = Some(lang.into()); + self + } + + /// Add an entity (§5.1.2). + pub fn add_entity(mut self, entity: EntityMap) -> Self { + self.entities.get_or_insert_with(Vec::new).push(entity); + self + } + + /// Add a linked tag (§5.1.3). + pub fn add_linked_tag(mut self, linked_tag: LinkedTagMap) -> Self { + self.linked_tags + .get_or_insert_with(Vec::new) + .push(linked_tag); + self + } + + /// Add a reference values triple (§5.1.5). + pub fn add_reference_triple(mut self, triple: ReferenceTriple) -> Self { + self.reference_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add an endorsed values triple (§5.1.6). + pub fn add_endorsed_triple(mut self, triple: EndorsedTriple) -> Self { + self.endorsed_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add a device identity triple (§5.1.9). + pub fn add_identity_triple(mut self, triple: IdentityTriple) -> Self { + self.identity_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add an attest key triple (§5.1.10). + pub fn add_attest_key_triple(mut self, triple: AttestKeyTriple) -> Self { + self.attest_key_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add a domain dependency triple (§5.1.11.2). + pub fn add_dependency_triple(mut self, triple: DomainDependencyTriple) -> Self { + self.dependency_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add a domain membership triple (§5.1.11.1). + pub fn add_membership_triple(mut self, triple: DomainMembershipTriple) -> Self { + self.membership_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add a CoMID-CoSWID linking triple (§5.1.12). + pub fn add_coswid_triple(mut self, triple: CoswidTriple) -> Self { + self.coswid_triples + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add a conditional endorsement series triple (§5.1.8). + pub fn add_conditional_endorsement_series( + mut self, + triple: ConditionalEndorsementSeriesTriple, + ) -> Self { + self.conditional_endorsement_series + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Add a conditional endorsement triple (§5.1.7). + pub fn add_conditional_endorsement(mut self, triple: ConditionalEndorsementTriple) -> Self { + self.conditional_endorsement + .get_or_insert_with(Vec::new) + .push(triple); + self + } + + /// Build the [`ComidTag`]. + /// + /// Returns an error if no triples have been added, or if any triple + /// contains an empty list where the CDDL requires `[+ T]`. + pub fn build(self) -> Result { + let has_triples = self.reference_triples.is_some() + || self.endorsed_triples.is_some() + || self.identity_triples.is_some() + || self.attest_key_triples.is_some() + || self.dependency_triples.is_some() + || self.membership_triples.is_some() + || self.coswid_triples.is_some() + || self.conditional_endorsement_series.is_some() + || self.conditional_endorsement.is_some(); + + if !has_triples { + return Err(BuilderError::EmptyTriples); + } + + // Validate [+ T] constraints inside triple records + if let Some(ref triples) = self.reference_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { + field: "ref-claims", + }); + } + } + } + if let Some(ref triples) = self.endorsed_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { + field: "endorsement", + }); + } + } + } + if let Some(ref triples) = self.identity_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { field: "key-list" }); + } + } + } + if let Some(ref triples) = self.attest_key_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { field: "key-list" }); + } + } + } + if let Some(ref triples) = self.dependency_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { field: "trustees" }); + } + } + } + if let Some(ref triples) = self.membership_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { field: "members" }); + } + } + } + if let Some(ref triples) = self.coswid_triples { + for t in triples { + if t.1.is_empty() { + return Err(BuilderError::EmptyList { field: "tag-ids" }); + } + } + } + + let triples = TriplesMap { + reference_triples: self.reference_triples, + endorsed_triples: self.endorsed_triples, + identity_triples: self.identity_triples, + attest_key_triples: self.attest_key_triples, + dependency_triples: self.dependency_triples, + membership_triples: self.membership_triples, + coswid_triples: self.coswid_triples, + conditional_endorsement_series: self.conditional_endorsement_series, + conditional_endorsement: self.conditional_endorsement, + }; + + Ok(ComidTag { + language: self.language, + tag_identity: TagIdentity { + tag_id: self.tag_id, + tag_version: self.tag_version, + }, + entities: self.entities, + linked_tags: self.linked_tags, + triples, + }) + } +} + +// --------------------------------------------------------------------------- +// CotlBuilder +// --------------------------------------------------------------------------- + +/// Builder for constructing a [`ConciseTlTag`] (CoTL) — §6.1. +/// +/// A CoTL signals which CoMID/CoSWID tags the Verifier should consider +/// "active" at a given point in time. +#[must_use] +pub struct CotlBuilder { + tag_id: TagIdChoice, + tag_version: Option, + tags_list: Vec, + not_before: Option, + not_after: i64, +} + +impl CotlBuilder { + /// Create a new CoTL builder with the given tag identifier and validity end. + pub fn new(tag_id: TagIdChoice, not_after: i64) -> Self { + Self { + tag_id, + tag_version: None, + tags_list: Vec::new(), + not_before: None, + not_after, + } + } + + /// Set the tag version. + pub fn set_tag_version(mut self, version: u64) -> Self { + self.tag_version = Some(version); + self + } + + /// Set the optional not-before timestamp. + pub fn set_not_before(mut self, not_before: i64) -> Self { + self.not_before = Some(not_before); + self + } + + /// Add a tag identity to the activation list. + pub fn add_tag(mut self, tag_identity: TagIdentity) -> Self { + self.tags_list.push(tag_identity); + self + } + + /// Add a tag by ID (convenience — version defaults to None). + pub fn add_tag_id(mut self, tag_id: TagIdChoice) -> Self { + self.tags_list.push(TagIdentity { + tag_id, + tag_version: None, + }); + self + } + + /// Build the [`ConciseTlTag`]. + /// + /// Returns an error if the tags list is empty (CDDL requires `[+ tag-identity-map]`). + pub fn build(self) -> Result { + if self.tags_list.is_empty() { + return Err(BuilderError::EmptyList { field: "tags-list" }); + } + if let Some(nb) = self.not_before { + if nb > self.not_after { + return Err(BuilderError::InvalidValidity); + } + } + Ok(ConciseTlTag { + tag_identity: TagIdentity { + tag_id: self.tag_id, + tag_version: self.tag_version, + }, + tags_list: self.tags_list, + tl_validity: ValidityMap { + not_before: self.not_before.map(CborTime::new), + not_after: CborTime::new(self.not_after), + }, + }) + } +} + +// --------------------------------------------------------------------------- +// CorimBuilder +// --------------------------------------------------------------------------- + +/// Builder for constructing a [`CorimMap`] (top-level CoRIM). +/// +/// At least one tag (CoMID, CoSWID, or CoTL) must be added for +/// [`build`](CorimBuilder::build) to succeed. +#[must_use] +pub struct CorimBuilder { + id: CorimId, + profile: Option, + rim_validity: Option, + entities: Option>, + dependent_rims: Option>, + tags: Vec, +} + +impl CorimBuilder { + /// Create a new CoRIM builder with the given identifier (§4.1.1). + pub fn new(id: CorimId) -> Self { + Self { + id, + profile: None, + rim_validity: None, + entities: None, + dependent_rims: None, + tags: Vec::new(), + } + } + + /// Set the optional profile (§4.1.4). + pub fn set_profile(mut self, profile: ProfileChoice) -> Self { + self.profile = Some(profile); + self + } + + /// Set the optional validity window (§7.3). + /// + /// Returns an error if `not_before` is present and greater than `not_after`. + pub fn set_validity( + mut self, + not_before: Option, + not_after: i64, + ) -> Result { + if let Some(nb) = not_before { + if nb > not_after { + return Err(BuilderError::InvalidValidity); + } + } + self.rim_validity = Some(ValidityMap { + not_before: not_before.map(CborTime::new), + not_after: CborTime::new(not_after), + }); + Ok(self) + } + + /// Add an entity (§4.1.5). + pub fn add_entity(mut self, entity: EntityMap) -> Self { + self.entities.get_or_insert_with(Vec::new).push(entity); + self + } + + /// Add a dependent RIM locator (§4.1.3). + pub fn add_dependent_rim(mut self, locator: CorimLocator) -> Self { + self.dependent_rims + .get_or_insert_with(Vec::new) + .push(locator); + self + } + + /// Add a pre-built [`ComidTag`], encoding it to CBOR and wrapping with tag 506. + pub fn add_comid_tag(mut self, comid: ComidTag) -> Result { + let comid_bytes = cbor::encode(&comid)?; + self.tags.push(ConciseTagChoice::Comid(comid_bytes)); + Ok(self) + } + + /// Add a CoSWID tag as opaque CBOR bytes (tag 505). + pub fn add_coswid_tag(mut self, coswid_bytes: Vec) -> Self { + self.tags.push(ConciseTagChoice::Coswid(coswid_bytes)); + self + } + + /// Add a pre-built [`ConciseSwidTag`], encoding it to CBOR and wrapping with tag 505. + pub fn add_coswid(mut self, coswid: ConciseSwidTag) -> Result { + coswid + .valid() + .map_err(|e| BuilderError::Validation(e.to_string()))?; + let coswid_bytes = cbor::encode(&coswid)?; + self.tags.push(ConciseTagChoice::Coswid(coswid_bytes)); + Ok(self) + } + + /// Add a CoTL tag as opaque CBOR bytes (tag 508). + pub fn add_cotl_tag(mut self, cotl_bytes: Vec) -> Self { + self.tags.push(ConciseTagChoice::Cotl(cotl_bytes)); + self + } + + /// Add a pre-built [`ConciseTlTag`], encoding it to CBOR and wrapping with tag 508. + pub fn add_cotl(mut self, cotl: ConciseTlTag) -> Result { + let cotl_bytes = cbor::encode(&cotl)?; + self.tags.push(ConciseTagChoice::Cotl(cotl_bytes)); + Ok(self) + } + + /// Add a raw [`ConciseTagChoice`] directly. + pub fn add_tag(mut self, tag: ConciseTagChoice) -> Self { + self.tags.push(tag); + self + } + + /// Build the [`CorimMap`]. + /// + /// Returns an error if no tags have been added. + pub fn build(self) -> Result { + if self.tags.is_empty() { + return Err(BuilderError::NoTags); + } + + Ok(CorimMap { + id: self.id, + tags: self.tags, + dependent_rims: self.dependent_rims, + profile: self.profile, + rim_validity: self.rim_validity, + entities: self.entities, + }) + } + + /// Build and encode as deterministic CBOR bytes with tag 501 wrapper. + /// + /// This is equivalent to calling [`build`](CorimBuilder::build) followed + /// by wrapping in `Tagged::new(501, corim)` and encoding. + pub fn build_bytes(self) -> Result, BuilderError> { + let corim = self.build()?; + let tagged = cbor::value::Tagged::new(TAG_CORIM, corim); + let bytes = cbor::encode(&tagged)?; + Ok(bytes) + } +} diff --git a/corim/src/cbor/constants.rs b/corim/src/cbor/constants.rs new file mode 100644 index 0000000..fbb7064 --- /dev/null +++ b/corim/src/cbor/constants.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CBOR wire-format constants from RFC 8949 (STD 94). +//! +//! All numeric values in this module come directly from +//! [RFC 8949](https://www.rfc-editor.org/rfc/rfc8949) — Concise Binary Object +//! Representation (CBOR). They describe the wire format structure: major types, +//! additional information encoding, and well-known simple values. + +// ═══════════════════════════════════════════════════════════════════════════ +// Major types (RFC 8949 §3.1, Table 1) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Major type 0: unsigned integer. +pub const MAJOR_UNSIGNED: u8 = 0; +/// Major type 1: negative integer (value = -1 - argument). +pub const MAJOR_NEGATIVE: u8 = 1; +/// Major type 2: byte string. +pub const MAJOR_BYTES: u8 = 2; +/// Major type 3: UTF-8 text string. +pub const MAJOR_TEXT: u8 = 3; +/// Major type 4: array of data items. +pub const MAJOR_ARRAY: u8 = 4; +/// Major type 5: map of pairs of data items. +pub const MAJOR_MAP: u8 = 5; +/// Major type 6: semantic tag. +pub const MAJOR_TAG: u8 = 6; +/// Major type 7: simple values, floats, and break. +pub const MAJOR_SIMPLE: u8 = 7; + +// ═══════════════════════════════════════════════════════════════════════════ +// Additional information values (RFC 8949 §3) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Maximum value encoded inline in the initial byte (0–23). +pub const AI_MAX_INLINE: u8 = 23; +/// Additional information: 1-byte argument follows. +pub const AI_ONE_BYTE: u8 = 24; +/// Additional information: 2-byte argument follows (network byte order). +pub const AI_TWO_BYTES: u8 = 25; +/// Additional information: 4-byte argument follows (network byte order). +pub const AI_FOUR_BYTES: u8 = 26; +/// Additional information: 8-byte argument follows (network byte order). +pub const AI_EIGHT_BYTES: u8 = 27; +/// Additional information: indefinite-length indicator (not supported by this crate). +pub const AI_INDEFINITE: u8 = 31; + +// ═══════════════════════════════════════════════════════════════════════════ +// Simple values — major type 7 (RFC 8949 §3.3, Table 4) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Simple value `false` (major 7, additional 20). +pub const SIMPLE_FALSE: u8 = 20; +/// Simple value `true` (major 7, additional 21). +pub const SIMPLE_TRUE: u8 = 21; +/// Simple value `null` (major 7, additional 22). +pub const SIMPLE_NULL: u8 = 22; +/// Simple value `undefined` (major 7, additional 23) — not used by CoRIM. +pub const SIMPLE_UNDEFINED: u8 = 23; + +// ═══════════════════════════════════════════════════════════════════════════ +// Float sub-types — major type 7 (RFC 8949 §3.3) +// ═══════════════════════════════════════════════════════════════════════════ + +/// IEEE 754 half-precision float (16-bit), major 7, additional 25. +pub const FLOAT_HALF: u8 = 25; +/// IEEE 754 single-precision float (32-bit), major 7, additional 26. +pub const FLOAT_SINGLE: u8 = 26; +/// IEEE 754 double-precision float (64-bit), major 7, additional 27. +pub const FLOAT_DOUBLE: u8 = 27; + +// ═══════════════════════════════════════════════════════════════════════════ +// Pre-computed initial bytes for simple values and floats +// ═══════════════════════════════════════════════════════════════════════════ + +/// Initial byte for `false`: `0xF4` (major 7 << 5 | 20). +pub const BYTE_FALSE: u8 = (MAJOR_SIMPLE << 5) | SIMPLE_FALSE; +/// Initial byte for `true`: `0xF5` (major 7 << 5 | 21). +pub const BYTE_TRUE: u8 = (MAJOR_SIMPLE << 5) | SIMPLE_TRUE; +/// Initial byte for `null`: `0xF6` (major 7 << 5 | 22). +pub const BYTE_NULL: u8 = (MAJOR_SIMPLE << 5) | SIMPLE_NULL; +/// Initial byte for float64: `0xFB` (major 7 << 5 | 27). +pub const BYTE_FLOAT64: u8 = (MAJOR_SIMPLE << 5) | FLOAT_DOUBLE; diff --git a/corim/src/cbor/minimal.rs b/corim/src/cbor/minimal.rs new file mode 100644 index 0000000..c13febb --- /dev/null +++ b/corim/src/cbor/minimal.rs @@ -0,0 +1,484 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Minimal in-house CBOR encoder/decoder. +//! +//! Implements the subset of CBOR needed by CoRIM: +//! - Major types 0–5 (unsigned int, negative int, byte string, text string, array, map) +//! - Major type 6 (semantic tags) +//! - Major type 7 (simple values: false, true, null, float64) +//! - Deterministic encoding per RFC 8949 §4.2.1 +//! +//! Does **not** support: +//! - Indefinite-length encoding +//! - Half/single precision floats +//! - Simple values other than false/true/null + +use std::io::{self, Read, Write}; + +/// Maximum number of items allowed in a single CBOR array or map. +/// +/// Prevents excessive memory allocation from maliciously crafted inputs +/// even within the 16 MiB payload limit. A single item is at minimum 1 byte, +/// so 2 million items × 1 byte = 2 MB — well within limits. +const MAX_COLLECTION_ITEMS: usize = 2_000_000; + +// Import CBOR wire-format constants. +// In match arms we use fully-qualified `c::` prefix to avoid Rust interpreting +// UPPER_CASE identifiers as variable bindings. +use super::constants as c; + +// ═══════════════════════════════════════════════════════════════════════════ +// Encoder +// ═══════════════════════════════════════════════════════════════════════════ + +/// Encode a CBOR major type + argument in deterministic (shortest) form. +/// +/// Per RFC 8949 §4.2.1, the argument is encoded in the shortest form that +/// can represent the value. +fn encode_head(w: &mut impl Write, major: u8, val: u64) -> io::Result<()> { + let mt = major << 5; + if val <= c::AI_MAX_INLINE as u64 { + w.write_all(&[mt | val as u8]) + } else if val <= u8::MAX as u64 { + w.write_all(&[mt | c::AI_ONE_BYTE, val as u8]) + } else if val <= u16::MAX as u64 { + let b = (val as u16).to_be_bytes(); + w.write_all(&[mt | c::AI_TWO_BYTES, b[0], b[1]]) + } else if val <= u32::MAX as u64 { + let b = (val as u32).to_be_bytes(); + w.write_all(&[mt | c::AI_FOUR_BYTES, b[0], b[1], b[2], b[3]]) + } else { + let b = val.to_be_bytes(); + let mut buf = [0u8; 9]; + buf[0] = mt | c::AI_EIGHT_BYTES; + buf[1..9].copy_from_slice(&b); + w.write_all(&buf) + } +} + +/// Encode a dynamic [`Value`](super::value::Value) to CBOR bytes. +pub fn encode_value(w: &mut impl Write, val: &super::value::Value) -> io::Result<()> { + use super::value::Value; + match val { + Value::Integer(n) => { + if *n >= 0 { + // CBOR can represent unsigned integers in [0, 2^64-1]. + let arg = u64::try_from(*n).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "positive integer above 2^64-1 cannot be encoded in CBOR", + ) + })?; + encode_head(w, c::MAJOR_UNSIGNED, arg) + } else { + // CBOR negative: encode as major type 1, argument = -1 - n + // CBOR can represent negative integers in [-1, -(2^64)]. + // Values below -(2^64) cannot be represented; return error. + let arg_i128 = -1_i128 - *n; + let arg = u64::try_from(arg_i128).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "negative integer below -(2^64) cannot be encoded in CBOR", + ) + })?; + encode_head(w, c::MAJOR_NEGATIVE, arg) + } + } + Value::Bytes(b) => { + encode_head(w, c::MAJOR_BYTES, b.len() as u64)?; + w.write_all(b) + } + Value::Text(t) => { + encode_head(w, c::MAJOR_TEXT, t.len() as u64)?; + w.write_all(t.as_bytes()) + } + Value::Array(arr) => { + encode_head(w, c::MAJOR_ARRAY, arr.len() as u64)?; + for item in arr { + encode_value(w, item)?; + } + Ok(()) + } + Value::Map(entries) => { + // RFC 8949 §4.2.1: map keys MUST be sorted in canonical order. + // Canonical order: sort by encoded key length first, then + // lexicographically by encoded bytes. + let mut items: Vec<(Vec, &super::value::Value, &super::value::Value)> = entries + .iter() + .map(|(k, v)| { + let mut kb = Vec::new(); + encode_value(&mut kb, k).expect("key encoding cannot fail for canonical sort"); + (kb, k, v) + }) + .collect(); + items.sort_by(|(a, _, _), (b, _, _)| a.len().cmp(&b.len()).then_with(|| a.cmp(b))); + + encode_head(w, c::MAJOR_MAP, items.len() as u64)?; + for (_, k, v) in &items { + encode_value(w, k)?; + encode_value(w, v)?; + } + Ok(()) + } + Value::Tag(tag, inner) => { + encode_head(w, c::MAJOR_TAG, *tag)?; + encode_value(w, inner) + } + Value::Bool(true) => w.write_all(&[c::BYTE_TRUE]), + Value::Bool(false) => w.write_all(&[c::BYTE_FALSE]), + Value::Null => w.write_all(&[c::BYTE_NULL]), + Value::Float(f) => { + let b = f.to_bits().to_be_bytes(); + let mut buf = [0u8; 9]; + buf[0] = c::BYTE_FLOAT64; + buf[1..9].copy_from_slice(&b); + w.write_all(&buf) + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Decoder +// ═══════════════════════════════════════════════════════════════════════════ + +/// CBOR decode error. +#[derive(Debug)] +pub enum CborError { + /// Unexpected end of input. + Eof, + /// Invalid CBOR encoding. + Invalid(String), + /// I/O error. + Io(io::Error), +} + +impl From for CborError { + fn from(e: io::Error) -> Self { + if e.kind() == io::ErrorKind::UnexpectedEof { + CborError::Eof + } else { + CborError::Io(e) + } + } +} + +impl std::fmt::Display for CborError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CborError::Eof => write!(f, "unexpected end of CBOR input"), + CborError::Invalid(s) => write!(f, "invalid CBOR: {}", s), + CborError::Io(e) => write!(f, "I/O error: {}", e), + } + } +} + +impl std::error::Error for CborError {} + +fn read_u8(r: &mut impl Read) -> Result { + let mut buf = [0u8; 1]; + r.read_exact(&mut buf)?; + Ok(buf[0]) +} + +fn read_bytes(r: &mut impl Read, len: usize) -> Result, CborError> { + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf)?; + Ok(buf) +} + +/// Decode the argument from a CBOR head byte. +fn decode_arg(r: &mut impl Read, additional: u8) -> Result { + match additional { + 0..=c::AI_MAX_INLINE => Ok(additional as u64), + c::AI_ONE_BYTE => Ok(read_u8(r)? as u64), + c::AI_TWO_BYTES => { + let b = read_bytes(r, 2)?; + Ok(u16::from_be_bytes([b[0], b[1]]) as u64) + } + c::AI_FOUR_BYTES => { + let b = read_bytes(r, 4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as u64) + } + c::AI_EIGHT_BYTES => { + let b = read_bytes(r, 8)?; + Ok(u64::from_be_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + _ => Err(CborError::Invalid(format!( + "invalid additional info {}", + additional + ))), + } +} + +/// Decode a single CBOR [`Value`](super::value::Value) from a reader. +pub fn decode_value(r: &mut impl Read) -> Result { + use super::value::Value; + + let head = read_u8(r)?; + let major = head >> 5; + let additional = head & 0x1F; + + match major { + c::MAJOR_UNSIGNED => { + let val = decode_arg(r, additional)?; + Ok(Value::Integer(val as i128)) + } + c::MAJOR_NEGATIVE => { + // Negative integer: value = -1 - arg + let val = decode_arg(r, additional)?; + // N.B. `as` binds tighter than `-`, so we must parenthesize: + // Without parens, `-1 - val as i128` == `-1 - (val as i128)` which + // wraps for val=u64::MAX. Correct: -1 - i128::from(val) + Ok(Value::Integer(-1 - i128::from(val))) + } + c::MAJOR_BYTES => { + if additional == c::AI_INDEFINITE { + return Err(CborError::Invalid( + "indefinite-length bytes not supported".into(), + )); + } + let len = usize::try_from(decode_arg(r, additional)?).map_err(|_| { + CborError::Invalid("byte string length exceeds platform capacity".into()) + })?; + let data = read_bytes(r, len)?; + Ok(Value::Bytes(data)) + } + c::MAJOR_TEXT => { + if additional == c::AI_INDEFINITE { + return Err(CborError::Invalid( + "indefinite-length text not supported".into(), + )); + } + let len = usize::try_from(decode_arg(r, additional)?).map_err(|_| { + CborError::Invalid("text string length exceeds platform capacity".into()) + })?; + let data = read_bytes(r, len)?; + let text = String::from_utf8(data) + .map_err(|e| CborError::Invalid(format!("invalid UTF-8: {}", e)))?; + Ok(Value::Text(text)) + } + c::MAJOR_ARRAY => { + if additional == c::AI_INDEFINITE { + return Err(CborError::Invalid( + "indefinite-length array not supported".into(), + )); + } + let count = usize::try_from(decode_arg(r, additional)?) + .map_err(|_| CborError::Invalid("array length exceeds platform capacity".into()))?; + if count > MAX_COLLECTION_ITEMS { + return Err(CborError::Invalid(format!( + "array length {} exceeds maximum {}", + count, MAX_COLLECTION_ITEMS + ))); + } + let mut arr = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + arr.push(decode_value(r)?); + } + Ok(Value::Array(arr)) + } + c::MAJOR_MAP => { + if additional == c::AI_INDEFINITE { + return Err(CborError::Invalid( + "indefinite-length map not supported".into(), + )); + } + let count = usize::try_from(decode_arg(r, additional)?) + .map_err(|_| CborError::Invalid("map length exceeds platform capacity".into()))?; + if count > MAX_COLLECTION_ITEMS { + return Err(CborError::Invalid(format!( + "map length {} exceeds maximum {}", + count, MAX_COLLECTION_ITEMS + ))); + } + let mut map = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + let k = decode_value(r)?; + let v = decode_value(r)?; + map.push((k, v)); + } + Ok(Value::Map(map)) + } + c::MAJOR_TAG => { + let tag = decode_arg(r, additional)?; + let inner = decode_value(r)?; + Ok(Value::Tag(tag, Box::new(inner))) + } + c::MAJOR_SIMPLE => match additional { + c::SIMPLE_FALSE => Ok(Value::Bool(false)), + c::SIMPLE_TRUE => Ok(Value::Bool(true)), + c::SIMPLE_NULL => Ok(Value::Null), + c::FLOAT_HALF => { + let b = read_bytes(r, 2)?; + let half = u16::from_be_bytes([b[0], b[1]]); + Ok(Value::Float(f16_to_f64(half))) + } + c::FLOAT_SINGLE => { + let b = read_bytes(r, 4)?; + let val = f32::from_be_bytes([b[0], b[1], b[2], b[3]]); + Ok(Value::Float(val as f64)) + } + c::FLOAT_DOUBLE => { + let b = read_bytes(r, 8)?; + let val = f64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]); + Ok(Value::Float(val)) + } + _ => Err(CborError::Invalid(format!( + "unsupported simple value {}", + additional + ))), + }, + _ => Err(CborError::Invalid(format!("unknown major type {}", major))), + } +} + +/// Convert IEEE 754 half-precision float to f64. +fn f16_to_f64(half: u16) -> f64 { + let sign = ((half >> 15) & 1) as u64; + let exp = ((half >> 10) & 0x1F) as i32; + let mant = (half & 0x3FF) as u64; + + if exp == 0 { + // Subnormal or zero + let val = (mant as f64) * 2.0f64.powi(-24); + if sign == 1 { + -val + } else { + val + } + } else if exp == 31 { + // Inf or NaN + if mant == 0 { + if sign == 1 { + f64::NEG_INFINITY + } else { + f64::INFINITY + } + } else { + f64::NAN + } + } else { + // Normal + let val = ((mant as f64) + 1024.0) * 2.0f64.powi(exp - 25); + if sign == 1 { + -val + } else { + val + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cbor::value::Value; + + fn round_trip(val: &Value) -> Value { + let mut buf = Vec::new(); + encode_value(&mut buf, val).unwrap(); + let mut reader = &buf[..]; + decode_value(&mut reader).unwrap() + } + + #[test] + fn integers() { + assert_eq!(round_trip(&Value::Integer(0)), Value::Integer(0)); + assert_eq!(round_trip(&Value::Integer(23)), Value::Integer(23)); + assert_eq!(round_trip(&Value::Integer(24)), Value::Integer(24)); + assert_eq!(round_trip(&Value::Integer(255)), Value::Integer(255)); + assert_eq!(round_trip(&Value::Integer(256)), Value::Integer(256)); + assert_eq!(round_trip(&Value::Integer(65535)), Value::Integer(65535)); + assert_eq!( + round_trip(&Value::Integer(1000000)), + Value::Integer(1000000) + ); + assert_eq!(round_trip(&Value::Integer(-1)), Value::Integer(-1)); + assert_eq!(round_trip(&Value::Integer(-100)), Value::Integer(-100)); + assert_eq!(round_trip(&Value::Integer(-1000)), Value::Integer(-1000)); + } + + #[test] + fn bytes_and_text() { + assert_eq!(round_trip(&Value::Bytes(vec![])), Value::Bytes(vec![])); + assert_eq!( + round_trip(&Value::Bytes(vec![1, 2, 3])), + Value::Bytes(vec![1, 2, 3]) + ); + assert_eq!(round_trip(&Value::Text("".into())), Value::Text("".into())); + assert_eq!( + round_trip(&Value::Text("hello".into())), + Value::Text("hello".into()) + ); + } + + #[test] + fn arrays_and_maps() { + let arr = Value::Array(vec![Value::Integer(1), Value::Text("two".into())]); + assert_eq!(round_trip(&arr), arr); + + let map = Value::Map(vec![ + (Value::Integer(0), Value::Text("zero".into())), + (Value::Integer(1), Value::Bool(true)), + ]); + assert_eq!(round_trip(&map), map); + } + + #[test] + fn tags() { + let tagged = Value::Tag( + 501, + Box::new(Value::Map(vec![( + Value::Integer(0), + Value::Text("id".into()), + )])), + ); + assert_eq!(round_trip(&tagged), tagged); + } + + #[test] + fn simple_values() { + assert_eq!(round_trip(&Value::Bool(true)), Value::Bool(true)); + assert_eq!(round_trip(&Value::Bool(false)), Value::Bool(false)); + assert_eq!(round_trip(&Value::Null), Value::Null); + } + + #[test] + fn deterministic_integer_encoding() { + // Verify shortest-form encoding per RFC 8949 §4.2.1 + let mut buf = Vec::new(); + encode_value(&mut buf, &Value::Integer(0)).unwrap(); + assert_eq!(buf, vec![0x00]); // single byte + + buf.clear(); + encode_value(&mut buf, &Value::Integer(23)).unwrap(); + assert_eq!(buf, vec![0x17]); // single byte + + buf.clear(); + encode_value(&mut buf, &Value::Integer(24)).unwrap(); + assert_eq!(buf, vec![0x18, 0x18]); // 2 bytes + + buf.clear(); + encode_value(&mut buf, &Value::Integer(255)).unwrap(); + assert_eq!(buf, vec![0x18, 0xFF]); // 2 bytes + + buf.clear(); + encode_value(&mut buf, &Value::Integer(256)).unwrap(); + assert_eq!(buf, vec![0x19, 0x01, 0x00]); // 3 bytes + } + + #[test] + fn eof_on_truncated() { + let result = decode_value(&mut &[0x58][..]); // byte string claiming 1-byte length but no data + assert!(result.is_err()); + } + + #[test] + fn rejects_indefinite_length() { + // 0x5F = indefinite-length byte string + let result = decode_value(&mut &[0x5F][..]); + assert!(result.is_err()); + } +} diff --git a/corim/src/cbor/minimal_backend/mod.rs b/corim/src/cbor/minimal_backend/mod.rs new file mode 100644 index 0000000..5234174 --- /dev/null +++ b/corim/src/cbor/minimal_backend/mod.rs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Minimal CBOR backend: [`MinimalCodec`] implementation using +//! the in-house encoder/decoder. +//! +//! This backend avoids the ciborium dependency entirely. It goes through +//! the backend-agnostic [`Value`](super::value::Value) as an intermediate +//! representation: +//! +//! ```text +//! Encode: T →(serde)→ Value →(minimal::encode_value)→ CBOR bytes +//! Decode: CBOR bytes →(minimal::decode_value)→ Value →(serde)→ T +//! ``` +//! +//! To enable: set `features = ["cbor-minimal"]` and disable `cbor-ciborium`. + +#![allow(dead_code)] // Only used when cbor-ciborium is disabled + +use crate::cbor::CborCodec; +use crate::error::{DecodeError, EncodeError}; +use serde::{de::DeserializeOwned, Serialize}; + +pub(crate) mod value_de; +pub(crate) mod value_ser; + +/// CBOR codec using the in-house minimal encoder/decoder. +pub struct MinimalCodec; + +impl CborCodec for MinimalCodec { + fn encode_deterministic(value: &T) -> Result, EncodeError> { + // Step 1: Serialize T into a Value tree via serde + let val = value_ser::to_value(value).map_err(EncodeError::Serialization)?; + // Step 2: Encode Value to deterministic CBOR bytes + let mut buf = Vec::new(); + super::minimal::encode_value(&mut buf, &val) + .map_err(|e| EncodeError::Serialization(e.to_string()))?; + Ok(buf) + } + + fn decode(bytes: &[u8]) -> Result { + // Step 1: Decode CBOR bytes into a Value tree (preserves tags) + let mut cursor = std::io::Cursor::new(bytes); + let val = super::minimal::decode_value(&mut cursor) + .map_err(|e| DecodeError::Deserialization(e.to_string()))?; + + // Step 2: Deserialize Value into T via serde. + // Tags are preserved in the Value tree. The value_de module + // presents Value::Tag as a 2-element seq to the serde visitor. + // Types that expect tags (Tagged, type-choice enums) handle + // this correctly because their Deserialize impls go through + // Value::deserialize which calls deserialize_any, and our + // ValueDeserializer presents the tag data appropriately. + let result = value_de::from_value(val).map_err(DecodeError::Deserialization)?; + Ok(result) + } +} diff --git a/corim/src/cbor/minimal_backend/value_de.rs b/corim/src/cbor/minimal_backend/value_de.rs new file mode 100644 index 0000000..787e5f3 --- /dev/null +++ b/corim/src/cbor/minimal_backend/value_de.rs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Serde `Deserializer` that replays a [`Value`] tree. + +use crate::cbor::value::Value; +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; + +/// Deserialize a `T` from a [`Value`]. +pub fn from_value Deserialize<'de>>(val: Value) -> Result { + T::deserialize(ValueDeserializer(val)).map_err(|e| e.0) +} + +#[derive(Debug)] +struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Error {} + +impl de::Error for Error { + fn custom(msg: T) -> Self { + Error(msg.to_string()) + } +} + +struct ValueDeserializer(Value); + +impl<'de> de::Deserializer<'de> for ValueDeserializer { + type Error = Error; + + fn deserialize_any>(self, visitor: V) -> Result { + match self.0 { + Value::Integer(n) => { + if n >= 0 && n <= u64::MAX as i128 { + visitor.visit_u64(n as u64) + } else if n >= i64::MIN as i128 { + visitor.visit_i64(n as i64) + } else { + visitor.visit_i128(n) + } + } + Value::Bytes(b) => visitor.visit_bytes(&b), + Value::Text(t) => visitor.visit_string(t), + Value::Array(arr) => visitor.visit_seq(SeqDeserializer { + iter: arr.into_iter(), + }), + Value::Map(entries) => visitor.visit_map(MapDeserializer { + iter: entries.into_iter(), + pending_value: None, + }), + Value::Tag(tag, inner) => { + // Present as a 2-element sequence [tag_number, inner_value]. + // The ValueVisitor in minimal_value_serde recognizes this as + // a special 2-element seq where the first item is a u64, + // producing Value::Tag. Other visitors (like Tagged) see + // a seq and handle it accordingly. + visitor.visit_seq(TagSeqDeserializer { + tag: Some(tag), + inner: Some(*inner), + }) + } + Value::Bool(b) => visitor.visit_bool(b), + Value::Null => visitor.visit_none(), + Value::Float(f) => visitor.visit_f64(f), + } + } + + fn deserialize_option>(self, visitor: V) -> Result { + match self.0 { + Value::Null => visitor.visit_none(), + other => visitor.visit_some(ValueDeserializer(other)), + } + } + + fn deserialize_newtype_struct>( + self, + _name: &'static str, + visitor: V, + ) -> Result { + visitor.visit_newtype_struct(self) + } + + fn deserialize_seq>(self, visitor: V) -> Result { + match self.0 { + Value::Array(arr) => visitor.visit_seq(SeqDeserializer { + iter: arr.into_iter(), + }), + other => Err(Error(format!( + "expected array, got {:?}", + std::mem::discriminant(&other) + ))), + } + } + + fn deserialize_tuple>( + self, + _len: usize, + visitor: V, + ) -> Result { + self.deserialize_seq(visitor) + } + + fn deserialize_tuple_struct>( + self, + _name: &'static str, + _len: usize, + visitor: V, + ) -> Result { + self.deserialize_seq(visitor) + } + + fn deserialize_map>(self, visitor: V) -> Result { + match self.0 { + Value::Map(entries) => visitor.visit_map(MapDeserializer { + iter: entries.into_iter(), + pending_value: None, + }), + other => Err(Error(format!( + "expected map, got {:?}", + std::mem::discriminant(&other) + ))), + } + } + + fn deserialize_struct>( + self, + _name: &'static str, + _fields: &'static [&'static str], + visitor: V, + ) -> Result { + self.deserialize_map(visitor) + } + + fn deserialize_bytes>(self, visitor: V) -> Result { + match self.0 { + Value::Bytes(b) => visitor.visit_bytes(&b), + _ => self.deserialize_any(visitor), + } + } + + fn deserialize_byte_buf>(self, visitor: V) -> Result { + match self.0 { + Value::Bytes(b) => visitor.visit_byte_buf(b), + _ => self.deserialize_any(visitor), + } + } + + fn deserialize_string>(self, visitor: V) -> Result { + self.deserialize_any(visitor) + } + + fn deserialize_str>(self, visitor: V) -> Result { + self.deserialize_any(visitor) + } + + fn deserialize_ignored_any>(self, visitor: V) -> Result { + visitor.visit_unit() + } + + // Forward all other deserialize_* methods to deserialize_any + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char + unit unit_struct enum identifier + } +} + +// --------------------------------------------------------------------------- +// Seq access +// --------------------------------------------------------------------------- + +struct SeqDeserializer { + iter: std::vec::IntoIter, +} + +impl<'de> SeqAccess<'de> for SeqDeserializer { + type Error = Error; + + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Error> { + match self.iter.next() { + Some(val) => seed.deserialize(ValueDeserializer(val)).map(Some), + None => Ok(None), + } + } + + fn size_hint(&self) -> Option { + let (lo, hi) = self.iter.size_hint(); + hi.or(Some(lo)) + } +} + +// --------------------------------------------------------------------------- +// Map access +// --------------------------------------------------------------------------- + +struct MapDeserializer { + iter: std::vec::IntoIter<(Value, Value)>, + pending_value: Option, +} + +impl<'de> MapAccess<'de> for MapDeserializer { + type Error = Error; + + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, Error> { + match self.iter.next() { + Some((k, v)) => { + self.pending_value = Some(v); + seed.deserialize(ValueDeserializer(k)).map(Some) + } + None => Ok(None), + } + } + + fn next_value_seed>(&mut self, seed: V) -> Result { + let val = self + .pending_value + .take() + .ok_or_else(|| Error("value without key".into()))?; + seed.deserialize(ValueDeserializer(val)) + } +} + +// --------------------------------------------------------------------------- +// Tag as seq [tag_number, inner] +// --------------------------------------------------------------------------- + +struct TagSeqDeserializer { + tag: Option, + inner: Option, +} + +impl<'de> SeqAccess<'de> for TagSeqDeserializer { + type Error = Error; + + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Error> { + if let Some(tag) = self.tag.take() { + return seed + .deserialize(ValueDeserializer(Value::Integer(tag as i128))) + .map(Some); + } + if let Some(inner) = self.inner.take() { + return seed.deserialize(ValueDeserializer(inner)).map(Some); + } + Ok(None) + } + + fn size_hint(&self) -> Option { + // Sentinel value to distinguish tags from regular arrays. + // The ValueVisitor in minimal_value_serde checks for this. + Some(usize::MAX) + } +} diff --git a/corim/src/cbor/minimal_backend/value_ser.rs b/corim/src/cbor/minimal_backend/value_ser.rs new file mode 100644 index 0000000..1de65e4 --- /dev/null +++ b/corim/src/cbor/minimal_backend/value_ser.rs @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Serde `Serializer` that captures output into a [`Value`] tree. + +use crate::cbor::value::Value; +use serde::ser::{self, Serialize}; + +/// Serialize any `T: Serialize` into a [`Value`]. +pub fn to_value(value: &T) -> Result { + value.serialize(ValueSerializer).map_err(|e| e.0) +} + +#[derive(Debug)] +struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Error {} + +impl ser::Error for Error { + fn custom(msg: T) -> Self { + Error(msg.to_string()) + } +} + +struct ValueSerializer; + +impl ser::Serializer for ValueSerializer { + type Ok = Value; + type Error = Error; + type SerializeSeq = SeqSerializer; + type SerializeTuple = SeqSerializer; + type SerializeTupleStruct = SeqSerializer; + type SerializeTupleVariant = SeqSerializer; + type SerializeMap = MapSerializer; + type SerializeStruct = MapSerializer; + type SerializeStructVariant = MapSerializer; + + fn serialize_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn serialize_i8(self, v: i8) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_i16(self, v: i16) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_i32(self, v: i32) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_i64(self, v: i64) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_i128(self, v: i128) -> Result { + Ok(Value::Integer(v)) + } + fn serialize_u8(self, v: u8) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_u16(self, v: u16) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_u32(self, v: u32) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_u64(self, v: u64) -> Result { + Ok(Value::Integer(v as i128)) + } + fn serialize_u128(self, v: u128) -> Result { + let n = i128::try_from(v).map_err(|_| Error("u128 value exceeds i128 range".into()))?; + Ok(Value::Integer(n)) + } + fn serialize_f32(self, v: f32) -> Result { + Ok(Value::Float(v as f64)) + } + fn serialize_f64(self, v: f64) -> Result { + Ok(Value::Float(v)) + } + fn serialize_char(self, v: char) -> Result { + Ok(Value::Text(v.to_string())) + } + fn serialize_str(self, v: &str) -> Result { + Ok(Value::Text(v.to_owned())) + } + fn serialize_bytes(self, v: &[u8]) -> Result { + Ok(Value::Bytes(v.to_vec())) + } + fn serialize_none(self) -> Result { + Ok(Value::Null) + } + fn serialize_some(self, value: &T) -> Result { + value.serialize(self) + } + fn serialize_unit(self) -> Result { + Ok(Value::Null) + } + fn serialize_unit_struct(self, _name: &'static str) -> Result { + Ok(Value::Null) + } + fn serialize_unit_variant( + self, + _name: &'static str, + idx: u32, + _variant: &'static str, + ) -> Result { + Ok(Value::Integer(idx as i128)) + } + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result { + value.serialize(self) + } + fn serialize_newtype_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + value: &T, + ) -> Result { + value.serialize(self) + } + + fn serialize_seq(self, len: Option) -> Result { + Ok(SeqSerializer { + items: Vec::with_capacity(len.unwrap_or(0)), + tag_mode: false, + }) + } + fn serialize_tuple(self, len: usize) -> Result { + Ok(SeqSerializer { + items: Vec::with_capacity(len), + tag_mode: false, + }) + } + fn serialize_tuple_struct( + self, + name: &'static str, + len: usize, + ) -> Result { + Ok(SeqSerializer { + items: Vec::with_capacity(len), + tag_mode: name == "__cbor_tag", + }) + } + fn serialize_tuple_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + len: usize, + ) -> Result { + Ok(SeqSerializer { + items: Vec::with_capacity(len), + tag_mode: false, + }) + } + fn serialize_map(self, len: Option) -> Result { + Ok(MapSerializer { + entries: Vec::with_capacity(len.unwrap_or(0)), + pending_key: None, + }) + } + fn serialize_struct(self, _name: &'static str, len: usize) -> Result { + Ok(MapSerializer { + entries: Vec::with_capacity(len), + pending_key: None, + }) + } + fn serialize_struct_variant( + self, + _name: &'static str, + _idx: u32, + _variant: &'static str, + len: usize, + ) -> Result { + Ok(MapSerializer { + entries: Vec::with_capacity(len), + pending_key: None, + }) + } +} + +struct SeqSerializer { + items: Vec, + tag_mode: bool, +} + +impl ser::SerializeSeq for SeqSerializer { + type Ok = Value; + type Error = Error; + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + self.items.push(value.serialize(ValueSerializer)?); + Ok(()) + } + fn end(self) -> Result { + Ok(Value::Array(self.items)) + } +} + +impl ser::SerializeTuple for SeqSerializer { + type Ok = Value; + type Error = Error; + fn serialize_element(&mut self, value: &T) -> Result<(), Error> { + ser::SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +impl ser::SerializeTupleStruct for SeqSerializer { + type Ok = Value; + type Error = Error; + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + ser::SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + if self.tag_mode && self.items.len() == 2 { + let mut items = self.items; + let inner = items.pop().unwrap(); + let tag_val = items.pop().unwrap(); + if let Value::Integer(n) = tag_val { + if let Ok(tag) = u64::try_from(n) { + return Ok(Value::Tag(tag, Box::new(inner))); + } + // Negative or oversized — not a valid tag, fall through to array + return Ok(Value::Array(vec![Value::Integer(n), inner])); + } + // Not a valid tag pattern — fall through to array + return Ok(Value::Array(vec![tag_val, inner])); + } + Ok(Value::Array(self.items)) + } +} + +impl ser::SerializeTupleVariant for SeqSerializer { + type Ok = Value; + type Error = Error; + fn serialize_field(&mut self, value: &T) -> Result<(), Error> { + ser::SerializeSeq::serialize_element(self, value) + } + fn end(self) -> Result { + ser::SerializeSeq::end(self) + } +} + +struct MapSerializer { + entries: Vec<(Value, Value)>, + pending_key: Option, +} + +impl ser::SerializeMap for MapSerializer { + type Ok = Value; + type Error = Error; + fn serialize_key(&mut self, key: &T) -> Result<(), Error> { + self.pending_key = Some(key.serialize(ValueSerializer)?); + Ok(()) + } + fn serialize_value(&mut self, value: &T) -> Result<(), Error> { + let k = self + .pending_key + .take() + .ok_or_else(|| Error("key missing".into()))?; + self.entries.push((k, value.serialize(ValueSerializer)?)); + Ok(()) + } + fn end(self) -> Result { + Ok(Value::Map(self.entries)) + } +} + +impl ser::SerializeStruct for MapSerializer { + type Ok = Value; + type Error = Error; + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Error> { + self.entries.push(( + Value::Text(key.to_owned()), + value.serialize(ValueSerializer)?, + )); + Ok(()) + } + fn end(self) -> Result { + Ok(Value::Map(self.entries)) + } +} + +impl ser::SerializeStructVariant for MapSerializer { + type Ok = Value; + type Error = Error; + fn serialize_field( + &mut self, + key: &'static str, + value: &T, + ) -> Result<(), Error> { + ser::SerializeStruct::serialize_field(self, key, value) + } + fn end(self) -> Result { + ser::SerializeStruct::end(self) + } +} diff --git a/corim/src/cbor/mod.rs b/corim/src/cbor/mod.rs new file mode 100644 index 0000000..ab6a84c --- /dev/null +++ b/corim/src/cbor/mod.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CBOR encoding/decoding abstraction layer. +//! +//! Provides a [`CborCodec`] trait for deterministic CBOR encoding/decoding, +//! plus a backend-agnostic [`value::Value`] enum and [`value::Tagged`] wrapper. +//! +//! The default (and currently only) backend is the in-house minimal CBOR +//! implementation in [`minimal`], which guarantees RFC 8949 §4.2.1 +//! deterministic encoding with zero external dependencies. +//! +//! The [`CborCodec`] trait is designed so that alternative backends (e.g., +//! ciborium) can be added behind feature gates in the future without +//! changing any type definitions or public APIs. + +pub mod constants; +pub mod minimal; +mod minimal_backend; + +pub mod value; + +use crate::error::{DecodeError, EncodeError}; +use serde::{de::DeserializeOwned, Serialize}; + +/// Trait abstracting CBOR encode/decode operations. +/// +/// Only deterministic encoding is provided. Map keys are emitted in ascending +/// integer order by the `CborSerialize` derive macro, satisfying RFC 8949 +/// §4.2.1 (CBOR Core Deterministic Encoding). +/// +/// This trait exists so that alternative CBOR backends can be plugged in +/// behind feature gates without changing the rest of the crate. +pub trait CborCodec { + /// Encode a value as deterministic CBOR bytes. + fn encode_deterministic(value: &T) -> Result, EncodeError>; + + /// Decode a value from CBOR bytes. + fn decode(bytes: &[u8]) -> Result; +} + +/// The active CBOR codec. +pub type DefaultCodec = minimal_backend::MinimalCodec; + +/// Convenience: encode using the default codec. +pub fn encode(value: &T) -> Result, EncodeError> { + DefaultCodec::encode_deterministic(value) +} + +/// Convenience: decode using the default codec. +pub fn decode(bytes: &[u8]) -> Result { + DefaultCodec::decode(bytes) +} diff --git a/corim/src/cbor/value/minimal_value_serde.rs b/corim/src/cbor/value/minimal_value_serde.rs new file mode 100644 index 0000000..b941982 --- /dev/null +++ b/corim/src/cbor/value/minimal_value_serde.rs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Minimal-backend serde impls for `Value` and `Tagged`, +//! plus `serialize_tagged` / `serialize_tagged_bytes` helpers. +//! +//! These mirror the ciborium_value_serde API exactly so the rest of the +//! crate works unchanged. + +use super::{Tagged, Value}; +use crate::cbor::minimal_backend::value_de; +use crate::cbor::minimal_backend::value_ser; +use serde::de::{self, Deserializer}; +use serde::ser::Serializer; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Value serde — go through minimal_backend's value_ser/value_de +// --------------------------------------------------------------------------- + +impl Serialize for Value { + fn serialize(&self, s: S) -> Result { + // Encode to CBOR bytes, then present as serde bytes + // Actually this is wrong — we need to map Value → serde data model + // directly, not to CBOR bytes. The serializer is the thing that + // converts serde data model → CBOR bytes. + // + // For the minimal backend, the codec goes: + // T.serialize(ValueSerializer) → Value → encode_value → bytes + // + // So when Value itself is T, we need Value.serialize(ValueSerializer) + // to produce... Value. That's circular. + // + // The solution: Value implements Serialize by mapping directly to + // serde primitives. The ValueSerializer captures those into a new Value. + // When the outer codec calls T.serialize(ValueSerializer), and T is + // Value, the ValueSerializer produces a clone of the Value. + match self { + Value::Integer(n) => { + if *n >= 0 { + if *n <= u64::MAX as i128 { + s.serialize_u64(*n as u64) + } else { + s.serialize_u128(*n as u128) + } + } else if *n >= i64::MIN as i128 { + s.serialize_i64(*n as i64) + } else { + s.serialize_i128(*n) + } + } + Value::Bytes(b) => s.serialize_bytes(b), + Value::Text(t) => s.serialize_str(t), + Value::Array(arr) => { + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(arr.len()))?; + for item in arr { + seq.serialize_element(item)?; + } + seq.end() + } + Value::Map(entries) => { + use serde::ser::SerializeMap; + let mut map = s.serialize_map(Some(entries.len()))?; + for (k, v) in entries { + map.serialize_entry(k, v)?; + } + map.end() + } + Value::Tag(tag, inner) => { + // Use a sentinel struct name so ValueSerializer can detect + // this and produce Value::Tag instead of a flat array. + use serde::ser::SerializeTupleStruct; + let mut ts = s.serialize_tuple_struct("__cbor_tag", 2)?; + ts.serialize_field(&(*tag as i128))?; + ts.serialize_field(inner.as_ref())?; + ts.end() + } + Value::Bool(b) => s.serialize_bool(*b), + Value::Null => s.serialize_none(), + Value::Float(f) => s.serialize_f64(*f), + } + } +} + +impl<'de> Deserialize<'de> for Value { + fn deserialize>(d: D) -> Result { + // For the minimal backend, Value goes through our ValueDeserializer + // which presents the raw Value tree. Tags are presented as seqs + // [tag_number, inner] by the ValueDeserializer. We detect this + // pattern in visit_seq by checking if the SeqAccess is our + // TagSeqDeserializer (via a size_hint marker). + d.deserialize_any(ValueVisitor) + } +} + +struct ValueVisitor; + +impl<'de> de::Visitor<'de> for ValueVisitor { + type Value = Value; + + fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("any CBOR value") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Integer(v as i128)) + } + fn visit_i128(self, v: i128) -> Result { + Ok(Value::Integer(v)) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Integer(v as i128)) + } + fn visit_u128(self, v: u128) -> Result { + let n = i128::try_from(v).map_err(|_| E::custom("u128 value exceeds i128 range"))?; + Ok(Value::Integer(n)) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Value::Float(v)) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::Text(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::Text(v)) + } + fn visit_bytes(self, v: &[u8]) -> Result { + Ok(Value::Bytes(v.to_vec())) + } + fn visit_byte_buf(self, v: Vec) -> Result { + Ok(Value::Bytes(v)) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_some>(self, d: D) -> Result { + Value::deserialize(d) + } + + fn visit_seq>(self, mut seq: A) -> Result { + // Use the size_hint to detect TagSeqDeserializer which returns + // Some(usize::MAX) as a sentinel value. + let is_tag = seq.size_hint() == Some(usize::MAX); + + let mut arr = Vec::new(); + while let Some(item) = seq.next_element::()? { + arr.push(item); + } + + if is_tag && arr.len() == 2 { + let inner = arr.pop().unwrap(); + let tag_val = arr.pop().unwrap(); + if let Value::Integer(t) = tag_val { + if let Ok(tag) = u64::try_from(t) { + return Ok(Value::Tag(tag, Box::new(inner))); + } + // Negative or oversized tag number — fall through to array + return Ok(Value::Array(vec![Value::Integer(t), inner])); + } + } + Ok(Value::Array(arr)) + } + + fn visit_map>(self, mut map: A) -> Result { + let mut entries = Vec::new(); + while let Some((k, v)) = map.next_entry::()? { + entries.push((k, v)); + } + Ok(Value::Map(entries)) + } +} + +// --------------------------------------------------------------------------- +// Tagged serde +// --------------------------------------------------------------------------- + +impl Serialize for Tagged { + fn serialize(&self, s: S) -> Result { + // Convert inner T to Value, wrap in Tag, then serialize. + // The Value::Tag variant's Serialize uses the __cbor_tag sentinel + // so that ValueSerializer preserves the tag structure. + let inner_value = value_ser::to_value(&self.value).map_err(serde::ser::Error::custom)?; + let tagged = Value::Tag(self.tag, Box::new(inner_value)); + tagged.serialize(s) + } +} + +impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Tagged { + fn deserialize>(d: D) -> Result { + // Deserialize as Value, extract tag, deserialize inner + let val = Value::deserialize(d)?; + match val { + Value::Tag(tag, inner) => { + let value: T = value_de::from_value(*inner) + .map_err(|e| de::Error::custom(format!("inner value: {}", e)))?; + Ok(Tagged { tag, value }) + } + _ => Err(de::Error::custom("expected CBOR tag")), + } + } +} + +// --------------------------------------------------------------------------- +// Helper functions (same API as ciborium_value_serde) +// --------------------------------------------------------------------------- + +/// Serialize a value wrapped in a specific CBOR tag number. +pub fn serialize_tagged( + tag: u64, + value: &T, + s: S, +) -> Result { + let inner_value = value_ser::to_value(value).map_err(serde::ser::Error::custom)?; + let tagged = Value::Tag(tag, Box::new(inner_value)); + tagged.serialize(s) +} + +/// Serialize bytes wrapped in a specific CBOR tag number. +pub fn serialize_tagged_bytes( + tag: u64, + bytes: &[u8], + s: S, +) -> Result { + let tagged = Value::Tag(tag, Box::new(Value::Bytes(bytes.to_vec()))); + tagged.serialize(s) +} diff --git a/corim/src/cbor/value/mod.rs b/corim/src/cbor/value/mod.rs new file mode 100644 index 0000000..a1fc9db --- /dev/null +++ b/corim/src/cbor/value/mod.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Backend-agnostic CBOR value types. +//! +//! This module provides a `Value` enum and `Tagged` wrapper that abstract +//! over the underlying CBOR library. All type-choice serde impls in `types/` +//! use these types instead of importing `ciborium` directly. +//! +//! When switching CBOR backends, only this module (and the backend impl in +//! `cbor/ciborium_backend.rs`) need to change — the rest of the crate is +//! unaffected. + +// --------------------------------------------------------------------------- +// Value — dynamic CBOR representation +// --------------------------------------------------------------------------- + +/// Backend-agnostic dynamic CBOR value. +/// +/// Mirrors the common CBOR data model. Used in custom `Serialize`/`Deserialize` +/// impls for type-choice enums that need to inspect CBOR tags at runtime. +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + /// Unsigned or negative integer. + Integer(i128), + /// Byte string. + Bytes(Vec), + /// UTF-8 text string. + Text(String), + /// Ordered array. + Array(Vec), + /// Ordered map (preserves insertion order). + Map(Vec<(Value, Value)>), + /// CBOR semantic tag wrapping an inner value. + Tag(u64, Box), + /// Boolean. + Bool(bool), + /// Null. + Null, + /// IEEE 754 float. + Float(f64), +} + +impl Value { + /// Try to extract as integer. + pub fn into_integer(self) -> Option { + match self { + Value::Integer(n) => Some(n), + _ => None, + } + } + + /// Try to extract as bytes. + pub fn into_bytes(self) -> Option> { + match self { + Value::Bytes(b) => Some(b), + _ => None, + } + } + + /// Try to extract as text. + pub fn into_text(self) -> Option { + match self { + Value::Text(t) => Some(t), + _ => None, + } + } + + /// Try to extract as array. + pub fn into_array(self) -> Option> { + match self { + Value::Array(a) => Some(a), + _ => None, + } + } + + /// Try to unwrap a tag, returning `(tag_number, inner_value)`. + pub fn into_tag(self) -> Option<(u64, Value)> { + match self { + Value::Tag(t, v) => Some((t, *v)), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// Serde for Value — delegates to the active backend +// --------------------------------------------------------------------------- + +// The Serialize/Deserialize impls for Value convert to/from the backend's +// native value type. These are implemented in the backend module. + +mod minimal_value_serde; +pub use minimal_value_serde::*; + +// --------------------------------------------------------------------------- +// Tagged — CBOR semantic tag wrapper +// --------------------------------------------------------------------------- + +/// A value wrapped in a CBOR semantic tag. +/// +/// This is the backend-agnostic equivalent of `ciborium::tag::Required`. +/// It requires a specific tag number on deserialization and always emits it +/// on serialization. +#[derive(Clone, Debug, PartialEq)] +pub struct Tagged { + pub tag: u64, + pub value: T, +} + +impl Tagged { + pub fn new(tag: u64, value: T) -> Self { + Self { tag, value } + } +} + +// Serialize/Deserialize for Tagged are in the backend-specific module. + +// --------------------------------------------------------------------------- +// Value conversion helpers (for JSON bridge) +// --------------------------------------------------------------------------- + +/// Serialize a Rust type into a `Value` using serde. +/// +/// This is the first step in JSON encoding: `T → Value → serde_json::Value`. +pub fn to_value(value: &T) -> Result { + // Use our minimal backend serializer to get a Value + let bytes = crate::cbor::encode(value).map_err(|e| e.to_string())?; + let val: Value = crate::cbor::decode(&bytes).map_err(|e| e.to_string())?; + Ok(val) +} + +/// Deserialize a `Value` back into a Rust type using serde. +/// +/// This is the last step in JSON decoding: `serde_json::Value → Value → T`. +pub fn from_value(value: &Value) -> Result { + // Encode the Value to CBOR bytes, then decode into T + let bytes = crate::cbor::encode(value).map_err(|e| e.to_string())?; + crate::cbor::decode(&bytes).map_err(|e| e.to_string()) +} diff --git a/corim/src/error.rs b/corim/src/error.rs new file mode 100644 index 0000000..8079344 --- /dev/null +++ b/corim/src/error.rs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Error types for the corim crate. + +use thiserror::Error; + +/// Errors from CBOR encoding. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum EncodeError { + /// CBOR serialization failed. + #[error("CBOR serialization failed: {0}")] + Serialization(String), +} + +/// Errors from CBOR decoding. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum DecodeError { + /// CBOR deserialization failed. + #[error("CBOR deserialization failed: {0}")] + Deserialization(String), + + /// Expected a specific CBOR tag but found a different one. + #[error("expected CBOR tag {expected}, found {found}")] + UnexpectedTag { + /// The tag number that was expected. + expected: u64, + /// The tag number that was found. + found: u64, + }, + + /// The decoded structure is invalid. + #[error("invalid structure: {0}")] + InvalidStructure(String), +} + +/// Errors from the builder API. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum BuilderError { + /// A required field was not set. + #[error("missing required field: {0}")] + MissingField(&'static str), + + /// The triples map is empty. + #[error("triples map is empty — at least one triple type must be populated")] + EmptyTriples, + + /// No CoMID tags were added to the CoRIM. + #[error("at least one CoMID tag is required")] + NoTags, + + /// A list that must be non-empty (CDDL `[+ T]`) was empty. + #[error("CDDL requires [+ {field}] but the list is empty")] + EmptyList { + /// Name of the field. + field: &'static str, + }, + + /// Validity constraint violated (not_before > not_after). + #[error("invalid validity: not_before must be <= not_after")] + InvalidValidity, + + /// A validation error from a type's `Valid()` check. + #[error("validation error: {0}")] + Validation(String), + + /// An encoding error occurred during building. + #[error("encoding error: {0}")] + Encode(#[from] EncodeError), +} + +/// Errors from validation / appraisal. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ValidationError { + /// A decode error occurred during validation. + #[error("decode error: {0}")] + Decode(#[from] DecodeError), + + /// The CoRIM has expired. + #[error("CoRIM has expired (not-after is in the past)")] + Expired, + + /// The CoRIM is not yet valid (not-before is in the future). + #[error("CoRIM is not yet valid (not-before is in the future)")] + NotYetValid, + + /// No CoMID tags were found in the CoRIM. + #[error("no CoMID tags found in the CoRIM")] + NoComidTags, + + /// The CoMID tag-identity is missing tag-id. + #[error("CoMID tag-identity is missing tag-id")] + MissingTagId, + + /// The CoMID triples map is empty. + #[error("CoMID triples map is empty")] + EmptyTriples, + + /// The CoTL tags-list is empty. + #[error("CoTL tags-list is empty")] + EmptyTagsList, + + /// A type-level validation failed. + #[error("{0}")] + Invalid(String), + + /// A non-empty constraint was violated. + #[error("non-empty constraint violated: {0}")] + NonEmpty(String), + + /// No common digest algorithms between reference and evidence. + #[error("no common digest algorithms between reference and evidence")] + NoCommonAlgorithms, + + /// Digest values do not match for a given algorithm. + #[error("digest mismatch for algorithm {alg}")] + DigestMismatch { + /// The algorithm identifier where the mismatch occurred. + alg: i64, + }, + + /// SVN values do not match. + #[error("SVN mismatch: expected {expected}, got {actual}")] + SvnMismatch { + /// The expected SVN value. + expected: u64, + /// The actual SVN value. + actual: u64, + }, + + /// Conditional endorsement series entries use inconsistent mkeys. + #[error("conditional-endorsement-series entries use inconsistent mkeys")] + InconsistentMkeys, + + /// System clock error. + #[error("system clock error: {0}")] + Clock(String), + + /// Input payload exceeds maximum allowed size. + #[error("input payload too large: {size} bytes (max {max})")] + PayloadTooLarge { + /// Actual size in bytes. + size: usize, + /// Maximum allowed size in bytes. + max: usize, + }, +} diff --git a/corim/src/json/key_maps.rs b/corim/src/json/key_maps.rs new file mode 100644 index 0000000..7eee033 --- /dev/null +++ b/corim/src/json/key_maps.rs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integer → string key mapping tables for JSON serialization. +//! +//! Each table maps CBOR integer keys to JSON string keys matching +//! the CoRIM/CoMID/CoSWID JSON format and the CDDL key names. + +#![allow(dead_code)] + +/// Lookup a JSON string key for a given CBOR integer key. +/// +/// Returns the string key if found in any registered map, or `None`. +pub(crate) fn int_to_string_key(key: i64) -> Option<&'static str> { + // Search all tables; keys are globally unique across CoRIM/CoMID/CoSWID + ALL_KEYS.iter().find(|(k, _)| *k == key).map(|(_, v)| *v) +} + +/// Lookup a CBOR integer key for a given JSON string key. +pub(crate) fn string_to_int_key(key: &str) -> Option { + ALL_KEYS.iter().find(|(_, v)| *v == key).map(|(k, _)| *k) +} + +/// Combined key table — globally unique integer↔string mappings only. +/// +/// Keys 0–30 are **not** included because they overlap across different +/// map types (corim-map, comid-tag, class-map, etc.). For those keys, +/// the value_conv module falls back to integer-as-string representation. +/// Keys 31+ are globally unique across CoRIM, CoMID, and CoSWID. +static ALL_KEYS: &[(i64, &str)] = &[ + // --- CoSWID entity-entry (RFC 9393 §2.6) — keys 31-34 --- + (31, "entity-name"), + (32, "reg-id"), + (33, "role"), + (34, "thumbprint"), + // --- CoSWID link-entry (RFC 9393 §2.7) — keys 37-42 --- + (37, "artifact"), + (38, "href"), + (39, "ownership"), + (40, "rel"), + (41, "media-type"), + (42, "use"), + // --- CoSWID software-meta-entry (RFC 9393 §2.8) — keys 43-57 --- + (43, "activation-status"), + (44, "channel-type"), + (45, "colloquial-version"), + (46, "description"), + (47, "edition"), + (48, "entitlement-data-required"), + (49, "entitlement-key"), + (50, "generator"), + (51, "persistent-id"), + (52, "product"), + (53, "product-family"), + (54, "revision"), + (55, "summary"), + (56, "unspsc-code"), + (57, "unspsc-version"), +]; + +// ---- Context-aware key tables for overlapping key ranges ---- + +/// Key names for `corim-map`. +pub(crate) static CORIM_MAP_KEYS: &[(i64, &str)] = &[ + (0, "corim-id"), + (1, "tags"), + (2, "dependent-rims"), + (3, "profile"), + (4, "rim-validity"), + (5, "entities"), +]; + +/// Key names for `concise-mid-tag` (CoMID). +pub(crate) static COMID_MAP_KEYS: &[(i64, &str)] = &[ + (0, "lang"), + (1, "tag-identity"), + (2, "entities"), + (3, "linked-tags"), + (4, "triples"), +]; + +/// Key names for `tag-identity-map`. +pub(crate) static TAG_IDENTITY_KEYS: &[(i64, &str)] = &[(0, "id"), (1, "version")]; + +/// Key names for `validity-map`. +pub(crate) static VALIDITY_MAP_KEYS: &[(i64, &str)] = &[(0, "not-before"), (1, "not-after")]; + +/// Key names for `entity-map` (CoRIM entity). +pub(crate) static ENTITY_MAP_KEYS: &[(i64, &str)] = + &[(0, "entity-name"), (1, "reg-id"), (2, "role")]; + +/// Key names for `class-map`. +pub(crate) static CLASS_MAP_KEYS: &[(i64, &str)] = &[ + (0, "id"), + (1, "vendor"), + (2, "model"), + (3, "layer"), + (4, "index"), +]; + +/// Key names for `environment-map`. +pub(crate) static ENVIRONMENT_MAP_KEYS: &[(i64, &str)] = + &[(0, "class"), (1, "instance"), (2, "group")]; + +/// Key names for `measurement-map`. +pub(crate) static MEASUREMENT_MAP_KEYS: &[(i64, &str)] = + &[(0, "key"), (1, "value"), (2, "authorized-by")]; + +/// Key names for `measurement-values-map`. +pub(crate) static MVAL_MAP_KEYS: &[(i64, &str)] = &[ + (0, "version"), + (1, "svn"), + (2, "digests"), + (3, "flags"), + (4, "raw-value"), + (6, "mac-addr"), + (7, "ip-addr"), + (8, "serial-number"), + (9, "ueid"), + (10, "uuid"), + (11, "name"), + (13, "cryptokeys"), + (14, "integrity-registers"), + (15, "int-range"), +]; + +/// Key names for `version-map`. +pub(crate) static VERSION_MAP_KEYS: &[(i64, &str)] = &[(0, "version"), (1, "version-scheme")]; + +/// Key names for `flags-map`. +pub(crate) static FLAGS_MAP_KEYS: &[(i64, &str)] = &[ + (0, "is-configured"), + (1, "is-secure"), + (2, "is-recovery"), + (3, "is-debug"), + (4, "is-replay-protected"), + (5, "is-integrity-protected"), + (6, "is-runtime-meas"), + (7, "is-immutable"), + (8, "is-tcb"), + (9, "is-confidentiality-protected"), +]; + +/// Key names for `triples-map`. +pub(crate) static TRIPLES_MAP_KEYS: &[(i64, &str)] = &[ + (0, "reference-triples"), + (1, "endorsed-triples"), + (2, "identity-triples"), + (3, "attest-key-triples"), + (4, "dependency-triples"), + (5, "membership-triples"), + (6, "coswid-triples"), + (8, "conditional-endorsement-series-triples"), + (10, "conditional-endorsement-triples"), +]; + +/// Key names for `linked-tag-map`. +pub(crate) static LINKED_TAG_MAP_KEYS: &[(i64, &str)] = &[(0, "target"), (1, "rel")]; + +/// Key names for `corim-locator-map`. +pub(crate) static LOCATOR_MAP_KEYS: &[(i64, &str)] = &[(0, "href"), (1, "thumbprint")]; + +/// Key names for `corim-signer-map`. +pub(crate) static SIGNER_MAP_KEYS: &[(i64, &str)] = &[(0, "signer-name"), (1, "signer-uri")]; + +/// Key names for `corim-meta-map`. +pub(crate) static META_MAP_KEYS: &[(i64, &str)] = &[(0, "signer"), (1, "signature-validity")]; + +/// Key names for `concise-tl-tag` (CoTL). +pub(crate) static COTL_MAP_KEYS: &[(i64, &str)] = + &[(0, "tag-identity"), (1, "tags-list"), (2, "tl-validity")]; + +/// Key names for `concise-swid-tag` (CoSWID). +pub(crate) static COSWID_MAP_KEYS: &[(i64, &str)] = &[ + (0, "tag-id"), + (1, "software-name"), + (2, "entity"), + (4, "link"), + (8, "corpus"), + (9, "patch"), + (11, "supplemental"), + (12, "tag-version"), + (13, "software-version"), + (14, "version-scheme"), + (15, "lang"), +]; + +/// Key names for CoSWID `entity-entry`. +pub(crate) static SWID_ENTITY_KEYS: &[(i64, &str)] = &[ + (31, "entity-name"), + (32, "reg-id"), + (33, "role"), + (34, "thumbprint"), +]; + +/// Key names for CoSWID `link-entry`. +pub(crate) static SWID_LINK_KEYS: &[(i64, &str)] = &[ + (10, "media"), + (37, "artifact"), + (38, "href"), + (39, "ownership"), + (40, "rel"), + (41, "media-type"), + (42, "use"), +]; + +/// Key names for `conditions` map (identity/attest-key triple). +pub(crate) static KEY_TRIPLE_COND_KEYS: &[(i64, &str)] = &[(0, "mkey"), (1, "authorized-by")]; diff --git a/corim/src/json/mod.rs b/corim/src/json/mod.rs new file mode 100644 index 0000000..9b63452 --- /dev/null +++ b/corim/src/json/mod.rs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! JSON serialization for CoRIM/CoMID types. +//! +//! This module provides `to_json` / `from_json` functions that convert between +//! Rust types and JSON using string-key format compatible with the CoRIM +//! JSON representation. +//! +//! # Architecture +//! +//! Our types implement `serde::Serialize`/`Deserialize` with integer keys (for +//! CBOR). For JSON, we: +//! 1. Serialize to `cbor::value::Value` (integer-keyed maps) +//! 2. Convert `Value` → `serde_json::Value` using per-type key mapping tables +//! 3. Reverse for deserialization +//! +//! This avoids duplicating `Serialize`/`Deserialize` impls. +//! +//! # Feature gate +//! +//! This module is only available with the `json` feature enabled. +//! +//! ```toml +//! [dependencies] +//! corim = { version = "0.1", features = ["json"] } +//! ``` + +mod key_maps; +mod value_conv; + +use crate::cbor; +use crate::error::{DecodeError, EncodeError}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json; + +pub use value_conv::{json_to_value, value_to_json}; + +/// Encode a CoRIM/CoMID type to a JSON string. +/// +/// Serializes to `cbor::value::Value` first, then converts to JSON using +/// the RFC-specified string key names. +/// +/// # Example +/// +/// ```rust +/// use corim::types::environment::ClassMap; +/// let class = ClassMap::new("ACME", "Widget"); +/// let json = corim::json::to_json(&class).unwrap(); +/// // Keys 0-30 use integer-as-string (context-dependent); keys 31+ use names +/// assert!(json.contains("ACME")); +/// assert!(json.contains("Widget")); +/// ``` +pub fn to_json(value: &T) -> Result { + // Serialize to our Value intermediate + let cbor_value = cbor::value::to_value(value) + .map_err(|e| EncodeError::Serialization(format!("to_value: {e}")))?; + // Convert to JSON value with string keys + let json_value = value_to_json(&cbor_value); + // Serialize to string + serde_json::to_string(&json_value).map_err(|e| EncodeError::Serialization(format!("json: {e}"))) +} + +/// Encode a CoRIM/CoMID type to a pretty-printed JSON string. +pub fn to_json_pretty(value: &T) -> Result { + let cbor_value = cbor::value::to_value(value) + .map_err(|e| EncodeError::Serialization(format!("to_value: {e}")))?; + let json_value = value_to_json(&cbor_value); + serde_json::to_string_pretty(&json_value) + .map_err(|e| EncodeError::Serialization(format!("json: {e}"))) +} + +/// Decode a CoRIM/CoMID type from a JSON string. +/// +/// Parses the JSON, converts string keys back to integer keys, then +/// deserializes into the target type. +pub fn from_json(json_str: &str) -> Result { + let json_value: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| DecodeError::Deserialization(format!("json parse: {e}")))?; + let cbor_value = json_to_value(&json_value); + cbor::value::from_value(&cbor_value) + .map_err(|e| DecodeError::Deserialization(format!("from_value: {e}"))) +} diff --git a/corim/src/json/value_conv.rs b/corim/src/json/value_conv.rs new file mode 100644 index 0000000..6d99223 --- /dev/null +++ b/corim/src/json/value_conv.rs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Conversion between `cbor::value::Value` (integer keys) and +//! `serde_json::Value` (string keys). + +use crate::cbor::value::Value; + +use super::key_maps as keys; + +/// Convert a `Value` to a `serde_json::Value`, remapping integer map keys +/// to JSON string names. +/// +/// CBOR tags are represented in JSON as `{"__cbor_tag": , "__cbor_value": }`. +/// Type-choice tagged values (UUID, OID, etc.) are converted to the +/// `{"type": "...", "value": ...}` format. +pub fn value_to_json(v: &Value) -> serde_json::Value { + match v { + Value::Null => serde_json::Value::Null, + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Integer(n) => { + // JSON numbers: try i64 first, fall back to string for very large values + if let Ok(i) = i64::try_from(*n) { + serde_json::Value::Number(serde_json::Number::from(i)) + } else { + serde_json::Value::String(n.to_string()) + } + } + Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::Text(t) => serde_json::Value::String(t.clone()), + Value::Bytes(b) => { + // Bytes → base64 string (standard convention for JSON) + serde_json::Value::String(base64_encode(b)) + } + Value::Array(arr) => serde_json::Value::Array(arr.iter().map(value_to_json).collect()), + Value::Map(entries) => { + let mut obj = serde_json::Map::new(); + for (k, v) in entries { + let key_str = match k { + Value::Integer(n) => { + // Safe: key mapping only covers small i64-range keys. + // Fall back to the full i128 string for out-of-range keys. + match i64::try_from(*n) { + Ok(n_i64) => keys::int_to_string_key(n_i64) + .map(|s| s.to_string()) + .unwrap_or_else(|| n_i64.to_string()), + Err(_) => n.to_string(), + } + } + Value::Text(t) => t.clone(), + _ => format!("{:?}", k), + }; + obj.insert(key_str, value_to_json(v)); + } + serde_json::Value::Object(obj) + } + Value::Tag(tag, inner) => tag_to_json(*tag, inner), + } +} + +/// Convert a `serde_json::Value` to a `Value`, remapping string map keys +/// back to integer keys. +pub fn json_to_value(j: &serde_json::Value) -> Value { + match j { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(*b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Integer(i as i128) + } else if let Some(u) = n.as_u64() { + Value::Integer(u as i128) + } else if let Some(f) = n.as_f64() { + Value::Float(f) + } else { + Value::Integer(0) + } + } + serde_json::Value::String(s) => { + // Check if this looks like a base64-encoded bytes value + // (we can't distinguish text from bytes in JSON without context) + Value::Text(s.clone()) + } + serde_json::Value::Array(arr) => Value::Array(arr.iter().map(json_to_value).collect()), + serde_json::Value::Object(obj) => { + // Check for CBOR tag representation + if let Some(tag_num) = obj.get("__cbor_tag") { + if let Some(tag_val) = obj.get("__cbor_value") { + if let Some(n) = tag_num.as_u64() { + return Value::Tag(n, Box::new(json_to_value(tag_val))); + } + } + } + + // Check for type-choice format: {"type": "...", "value": ...} + if let (Some(serde_json::Value::String(type_name)), Some(value)) = + (obj.get("type"), obj.get("value")) + { + if obj.len() == 2 { + return type_choice_to_value(type_name, value); + } + } + + // Regular object → map with integer keys where possible + let entries: Vec<(Value, Value)> = obj + .iter() + .map(|(k, v)| { + let key = keys::string_to_int_key(k) + .map(|i| Value::Integer(i as i128)) + .unwrap_or_else(|| { + // Try parsing as integer + k.parse::() + .map(|i| Value::Integer(i as i128)) + .unwrap_or_else(|_| Value::Text(k.clone())) + }); + (key, json_to_value(v)) + }) + .collect(); + Value::Map(entries) + } + } +} + +// --------------------------------------------------------------------------- +// CBOR tag → JSON conversion (type-choice enums) +// --------------------------------------------------------------------------- + +/// Known CBOR tags for type-choice → JSON format. +fn tag_to_json(tag: u64, inner: &Value) -> serde_json::Value { + match tag { + // Tag 1: epoch time → just the integer + 1 => value_to_json(inner), + // Tag 37: UUID → {"type": "uuid", "value": "xxxxxxxx-xxxx-..."} + 37 => match inner { + Value::Bytes(b) if b.len() == 16 => { + type_choice_json("uuid", serde_json::Value::String(format_uuid(b))) + } + _ => type_choice_json("uuid", value_to_json(inner)), + }, + // Tag 111: OID → {"type": "oid", "value": ""} + 111 => type_choice_json("oid", value_to_json(inner)), + // Tag 550: UEID → {"type": "ueid", "value": ""} + 550 => match inner { + Value::Bytes(b) => { + type_choice_json("ueid", serde_json::Value::String(base64_encode(b))) + } + _ => type_choice_json("ueid", value_to_json(inner)), + }, + // Tag 552: SVN → {"type": "svn", "value": } + 552 => type_choice_json("svn", value_to_json(inner)), + // Tag 553: min-SVN → {"type": "min-svn", "value": } + 553 => type_choice_json("min-svn", value_to_json(inner)), + // Tag 554-562: crypto key types + 554 => type_choice_json("pkix-base64-key", value_to_json(inner)), + 555 => type_choice_json("pkix-base64-cert", value_to_json(inner)), + 556 => type_choice_json("pkix-base64-cert-path", value_to_json(inner)), + 557 => type_choice_json("key-thumbprint", value_to_json(inner)), + 558 => type_choice_json("cose-key", value_to_json(inner)), + 559 => type_choice_json("cert-thumbprint", value_to_json(inner)), + 560 => type_choice_json("bytes", value_to_json(inner)), + 561 => type_choice_json("cert-path-thumbprint", value_to_json(inner)), + 562 => type_choice_json("pkix-asn1der-cert", value_to_json(inner)), + 563 => type_choice_json("masked-raw-value", value_to_json(inner)), + 564 => type_choice_json("int-range", value_to_json(inner)), + // Tag 505, 506, 508: CoSWID/CoMID/CoTL inside tags array → base64 bytes + 505 | 506 | 508 => { + let mut obj = serde_json::Map::new(); + obj.insert("__cbor_tag".into(), serde_json::Value::Number(tag.into())); + obj.insert("__cbor_value".into(), value_to_json(inner)); + serde_json::Value::Object(obj) + } + // Default: preserve as explicit tag object + _ => { + let mut obj = serde_json::Map::new(); + obj.insert("__cbor_tag".into(), serde_json::Value::Number(tag.into())); + obj.insert("__cbor_value".into(), value_to_json(inner)); + serde_json::Value::Object(obj) + } + } +} + +/// Convert `{"type": ..., "value": ...}` JSON back to CBOR tagged value. +fn type_choice_to_value(type_name: &str, value: &serde_json::Value) -> Value { + match type_name { + "uuid" => { + if let Some(s) = value.as_str() { + if let Some(bytes) = parse_uuid(s) { + return Value::Tag(37, Box::new(Value::Bytes(bytes))); + } + } + Value::Tag(37, Box::new(json_to_value(value))) + } + "oid" => Value::Tag(111, Box::new(json_to_value(value))), + "ueid" => { + if let Some(s) = value.as_str() { + if let Ok(bytes) = base64_decode(s) { + return Value::Tag(550, Box::new(Value::Bytes(bytes))); + } + } + Value::Tag(550, Box::new(json_to_value(value))) + } + "bytes" => { + if let Some(s) = value.as_str() { + if let Ok(bytes) = base64_decode(s) { + return Value::Tag(560, Box::new(Value::Bytes(bytes))); + } + } + Value::Tag(560, Box::new(json_to_value(value))) + } + "svn" => Value::Tag(552, Box::new(json_to_value(value))), + "min-svn" => Value::Tag(553, Box::new(json_to_value(value))), + "pkix-base64-key" => Value::Tag(554, Box::new(json_to_value(value))), + "pkix-base64-cert" => Value::Tag(555, Box::new(json_to_value(value))), + "pkix-base64-cert-path" => Value::Tag(556, Box::new(json_to_value(value))), + "key-thumbprint" => Value::Tag(557, Box::new(json_to_value(value))), + "cose-key" => Value::Tag(558, Box::new(json_to_value(value))), + "cert-thumbprint" => Value::Tag(559, Box::new(json_to_value(value))), + "cert-path-thumbprint" => Value::Tag(561, Box::new(json_to_value(value))), + "pkix-asn1der-cert" => Value::Tag(562, Box::new(json_to_value(value))), + "masked-raw-value" => Value::Tag(563, Box::new(json_to_value(value))), + "int-range" => Value::Tag(564, Box::new(json_to_value(value))), + _ => { + // Unknown type-choice → preserve as-is in a map + let entries = vec![ + (Value::Text("type".into()), Value::Text(type_name.into())), + (Value::Text("value".into()), json_to_value(value)), + ]; + Value::Map(entries) + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn type_choice_json(type_name: &str, value: serde_json::Value) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert("type".into(), serde_json::Value::String(type_name.into())); + obj.insert("value".into(), value); + serde_json::Value::Object(obj) +} + +fn format_uuid(bytes: &[u8]) -> String { + let hex = |s: &[u8]| -> String { s.iter().map(|b| format!("{:02x}", b)).collect() }; + format!( + "{}-{}-{}-{}-{}", + hex(&bytes[0..4]), + hex(&bytes[4..6]), + hex(&bytes[6..8]), + hex(&bytes[8..10]), + hex(&bytes[10..16]) + ) +} + +fn parse_uuid(s: &str) -> Option> { + let hex: String = s.chars().filter(|c| *c != '-').collect(); + if hex.len() != 32 { + return None; + } + let mut bytes = Vec::with_capacity(16); + for i in (0..32).step_by(2) { + bytes.push(u8::from_str_radix(&hex[i..i + 2], 16).ok()?); + } + Some(bytes) +} + +fn base64_encode(bytes: &[u8]) -> String { + // Simple base64 encoding (RFC 4648 standard alphabet) + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + for chunk in bytes.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let n = (b0 << 16) | (b1 << 8) | b2; + result.push(ALPHABET[((n >> 18) & 63) as usize] as char); + result.push(ALPHABET[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + result.push(ALPHABET[((n >> 6) & 63) as usize] as char); + } else { + result.push('='); + } + if chunk.len() > 2 { + result.push(ALPHABET[(n & 63) as usize] as char); + } else { + result.push('='); + } + } + result +} + +fn base64_decode(s: &str) -> Result, ()> { + fn val(c: u8) -> Result { + match c { + b'A'..=b'Z' => Ok((c - b'A') as u32), + b'a'..=b'z' => Ok((c - b'a' + 26) as u32), + b'0'..=b'9' => Ok((c - b'0' + 52) as u32), + b'+' => Ok(62), + b'/' => Ok(63), + b'=' => Ok(0), + _ => Err(()), + } + } + let bytes: Vec = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if !bytes.len().is_multiple_of(4) { + return Err(()); + } + let mut result = Vec::new(); + for chunk in bytes.chunks(4) { + let a = val(chunk[0])?; + let b = val(chunk[1])?; + let c = val(chunk[2])?; + let d = val(chunk[3])?; + let n = (a << 18) | (b << 12) | (c << 6) | d; + result.push((n >> 16) as u8); + if chunk[2] != b'=' { + result.push(((n >> 8) & 0xFF) as u8); + } + if chunk[3] != b'=' { + result.push((n & 0xFF) as u8); + } + } + Ok(result) +} diff --git a/corim/src/lib.rs b/corim/src/lib.rs new file mode 100644 index 0000000..e4e9b82 --- /dev/null +++ b/corim/src/lib.rs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CoRIM (Concise Reference Integrity Manifest) — Rust library. +//! +//! This crate provides Rust types for the CoRIM/CoMID CDDL schema +//! ([draft-ietf-rats-corim-10](https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html)), +//! CBOR encoding/decoding (via a swappable backend), a builder API for +//! constructing CoRIM and CoMID structures, and validation per the spec. +//! +//! # Quick example +//! +//! ```rust +//! use corim::builder::{ComidBuilder, CorimBuilder}; +//! use corim::types::common::{TagIdChoice, MeasuredElement}; +//! use corim::types::corim::CorimId; +//! use corim::types::environment::{ClassMap, EnvironmentMap}; +//! use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; +//! use corim::types::triples::ReferenceTriple; +//! +//! let env = EnvironmentMap { +//! class: Some(ClassMap { +//! class_id: None, vendor: Some("ACME".into()), +//! model: Some("Widget".into()), layer: None, index: None, +//! }), +//! instance: None, group: None, +//! }; +//! +//! let meas = MeasurementMap { +//! mkey: Some(MeasuredElement::Text("firmware".into())), +//! mval: MeasurementValuesMap { +//! digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), +//! ..MeasurementValuesMap::default() +//! }, +//! authorized_by: None, +//! }; +//! +//! let comid = ComidBuilder::new(TagIdChoice::Text("my-tag".into())) +//! .add_reference_triple(ReferenceTriple::new(env, vec![meas])) +//! .build().unwrap(); +//! +//! let bytes = CorimBuilder::new(CorimId::Text("my-corim".into())) +//! .add_comid_tag(comid).unwrap() +//! .build_bytes().unwrap(); +//! +//! let (_corim, _comids) = corim::validate::decode_and_validate(&bytes).unwrap(); +//! ``` +//! +//! # CBOR backend +//! +//! This crate includes an in-house minimal CBOR encoder/decoder that +//! guarantees RFC 8949 §4.2.1 deterministic encoding with zero external +//! CBOR dependencies. The [`cbor::CborCodec`] trait is designed so that +//! alternative backends (e.g., ciborium) can be added behind feature gates +//! in the future without changing any public APIs. +//! +//! ## CBOR implementation limitations +//! +//! The built-in CBOR codec covers the subset needed by CoRIM. Known +//! limitations (none of which affect CoRIM functionality): +//! +//! - **No indefinite-length encoding** — rejected on decode. CoRIM/CoMID +//! CDDL uses definite-length only. +//! - **Float encoding is always float64** — half and single precision floats +//! are decoded correctly, but encoding always uses 8-byte float64. CoRIM +//! data rarely uses floats (only `cwt-claims` exp/nbf, which are `int`). +//! - **No CBOR simple values** beyond `false`, `true`, `null` — other simple +//! values (0–19, 32–255) are rejected. Not used in CoRIM. +//! - **No CBOR sequences** — only single top-level items. CoRIM always has +//! a single tagged wrapper. +//! - **Maximum nesting depth** is limited by the call stack (~100+ levels). +//! CoRIM documents are typically 5–10 levels deep. +//! +//! # Compliance notes +//! +//! This crate implements CoRIM per draft-ietf-rats-corim-10. +//! +//! ## Tag coverage +//! +//! The RFC defines three tag types inside a CoRIM `tags` array: +//! +//! | Tag | CBOR | Status | +//! |-----|------|--------| +//! | **CoMID** (§5) | `#6.506` | ✅ Fully modeled — types, builder, validation, appraisal | +//! | **CoTL** (§6) | `#6.508` | ✅ Fully modeled — `ConciseTlTag`, `CotlBuilder`, validity checks | +//! | **CoSWID** (RFC 9393) | `#6.505` | ✅ Structured — `ConciseSwidTag`, `SwidEntity`, `SwidLink`; payload/evidence opaque | +//! +//! ## Signed CoRIM (`#6.18`) +//! +//! The crate supports **decoding, structural validation, and construction** +//! of signed CoRIM documents (`COSE_Sign1-corim`) per §4.2. Cryptographic +//! signature verification is intentionally **not** performed — the caller +//! is responsible for verifying signatures using their preferred crypto +//! library. The crate provides: +//! +//! - [`types::signed::decode_signed_corim`]: Parse `#6.18` COSE_Sign1 structures +//! - [`types::signed::validate_signed_corim_payload`]: Validate attached payloads +//! - [`types::signed::validate_signed_corim_payload_detached`]: Validate detached payloads +//! - [`types::signed::SignedCorimBuilder`]: Construct signed CoRIM with external signing +//! (attached via `build_with_signature` or detached via `build_detached_with_signature`) +//! - [`types::signed::CoseSign1Corim::to_be_signed`]: Emit TBS for attached payloads +//! - [`types::signed::CoseSign1Corim::to_be_signed_detached`]: Emit TBS for detached payloads +//! +//! Additionally: +//! - **CoTS** (`draft-ietf-rats-concise-ta-stores`) is a separate draft, not modeled. +//! - **CDDL extension sockets** (`$$corim-map-extension`, etc.) are not +//! modeled; unknown CBOR map keys are silently skipped for forward +//! compatibility. +//! - **`raw-value-mask-DEPRECATED`** (key 5) is accepted on decode but not +//! exposed as a struct field. + +#![cfg_attr(docsrs, feature(doc_cfg))] + +pub mod cbor; +pub mod error; +pub mod types; + +pub mod builder; +pub mod validate; + +#[cfg(feature = "json")] +#[cfg_attr(docsrs, doc(cfg(feature = "json")))] +pub mod json; + +pub use error::{BuilderError, DecodeError, EncodeError, ValidationError}; + +/// Trait for types that can self-validate per the CoRIM specification. +/// +/// Each type validates its own constraints (non-empty maps, +/// size limits, structural invariants) without requiring an external validator. +/// +/// # Example +/// +/// ```rust +/// use corim::Validate; +/// use corim::types::environment::{ClassMap, EnvironmentMap}; +/// +/// let env = EnvironmentMap::for_class("ACME", "Widget"); +/// assert!(env.valid().is_ok()); +/// +/// let empty_env = EnvironmentMap { class: None, instance: None, group: None }; +/// assert!(empty_env.valid().is_err()); +/// ``` +pub trait Validate { + /// Validate that this value satisfies its specification constraints. + /// + /// Returns `Ok(())` if valid, or a human-readable error message. + fn valid(&self) -> Result<(), String>; +} diff --git a/corim/src/types/comid.rs b/corim/src/types/comid.rs new file mode 100644 index 0000000..b84dd02 --- /dev/null +++ b/corim/src/types/comid.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `concise-mid-tag` (CoMID) type. + +use corim_macros::{CborDeserialize, CborSerialize}; + +use super::common::{EntityMap, LinkedTagMap, TagIdentity}; +use super::triples::TriplesMap; +use crate::Validate; + +// --------------------------------------------------------------------------- +// concise-mid-tag { language: 0, tag-identity: 1, entities: 2, +// linked-tags: 3, triples: 4 } +// --------------------------------------------------------------------------- + +/// `concise-mid-tag` — a CoMID tag containing triples. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct ComidTag { + /// `language` (key 0): optional BCP 47 language tag. + #[cbor(key = 0, optional)] + pub language: Option, + + /// `tag-identity` (key 1): identifies this CoMID. + #[cbor(key = 1)] + pub tag_identity: TagIdentity, + + /// `entities` (key 2): optional list of entities. + #[cbor(key = 2, optional)] + pub entities: Option>, + + /// `linked-tags` (key 3): optional references to other tags. + #[cbor(key = 3, optional)] + pub linked_tags: Option>, + + /// `triples` (key 4): the measurement triples. + #[cbor(key = 4)] + pub triples: TriplesMap, +} + +impl Validate for ComidTag { + fn valid(&self) -> Result<(), String> { + // Validate triples + self.triples + .valid() + .map_err(|e| format!("triples validation failed: {e}"))?; + + // Validate entities if present + if let Some(ref entities) = self.entities { + if entities.is_empty() { + return Err("entities list must not be empty".into()); + } + } + + // Validate linked-tags if present + if let Some(ref linked) = self.linked_tags { + if linked.is_empty() { + return Err("linked-tags list must not be empty".into()); + } + } + + Ok(()) + } +} diff --git a/corim/src/types/common.rs b/corim/src/types/common.rs new file mode 100644 index 0000000..cca38ce --- /dev/null +++ b/corim/src/types/common.rs @@ -0,0 +1,751 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Common types shared across CoRIM and CoMID structures. +//! +//! All custom serde impls use [`crate::cbor::value::Value`] for tag dispatch +//! rather than importing any specific CBOR backend directly. + +use corim_macros::{CborDeserialize, CborSerialize}; +use serde::{Deserialize, Serialize}; + +use super::tags::*; +use crate::cbor::value::{self, Value}; +use crate::types::measurement::Digest; + +// --------------------------------------------------------------------------- +// CborTime — CBOR epoch-based date/time (#6.1) +// --------------------------------------------------------------------------- + +/// CBOR epoch-based date/time per RFC 8949 §3.4.2. +/// +/// Serializes as `#6.1(int)`. Deserializes accepting both tagged (`#6.1(int)`) +/// and untagged `int` for interoperability. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CborTime(pub i64); + +impl CborTime { + /// Create a new epoch time value. + pub fn new(epoch_secs: i64) -> Self { + Self(epoch_secs) + } + + /// Get the epoch seconds value. + pub fn epoch_secs(self) -> i64 { + self.0 + } +} + +impl From for CborTime { + fn from(v: i64) -> Self { + Self(v) + } +} + +impl From for i64 { + fn from(t: CborTime) -> Self { + t.0 + } +} + +impl core::fmt::Display for CborTime { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for CborTime { + fn serialize(&self, s: S) -> Result { + // Serialize as #6.1(int) per RFC 8949 §3.4.2 + value::serialize_tagged(TAG_EPOCH_TIME, &self.0, s) + } +} + +impl<'de> Deserialize<'de> for CborTime { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + // Accept tagged #6.1(int) + Value::Tag(TAG_EPOCH_TIME, inner) => { + let n = inner + .into_integer() + .ok_or_else(|| serde::de::Error::custom("tag 1 must wrap integer"))?; + let n: i64 = n + .try_into() + .map_err(|_| serde::de::Error::custom("epoch time out of i64 range"))?; + Ok(CborTime(n)) + } + // Also accept untagged int for interop with non-conformant producers + Value::Integer(n) => { + let n: i64 = n + .try_into() + .map_err(|_| serde::de::Error::custom("epoch time out of i64 range"))?; + Ok(CborTime(n)) + } + _ => Err(serde::de::Error::custom("expected time (tag 1 or integer)")), + } + } +} + +// --------------------------------------------------------------------------- +// tag-identity-map { tag-id: 0, tag-version: 1 } +// --------------------------------------------------------------------------- + +/// `tag-identity-map` — identifies a CoMID tag. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct TagIdentity { + /// `tag-id` (key 0): globally unique tag identifier. + #[cbor(key = 0)] + pub tag_id: TagIdChoice, + /// `tag-version` (key 1): optional revision number. + /// + /// Per CDDL `uint .default 0`, absent means version 0. + /// Use [`tag_version_or_default`](TagIdentity::tag_version_or_default) + /// to get the effective value. + #[cbor(key = 1, optional)] + pub tag_version: Option, +} + +impl TagIdentity { + /// Returns the tag version, treating `None` as the CDDL default of 0. + pub fn tag_version_or_default(&self) -> u64 { + self.tag_version.unwrap_or(0) + } +} + +// --------------------------------------------------------------------------- +// validity-map { not-before: 0, not-after: 1 } +// --------------------------------------------------------------------------- + +/// `validity-map` — time window. +/// +/// Time values are CBOR epoch-based date/time (`#6.1(int)`) per RFC 8949 §3.4.2. +/// [`CborTime`] handles both tagged and untagged integers on decode. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct ValidityMap { + /// `not-before` (key 0): optional start of validity. + #[cbor(key = 0, optional)] + pub not_before: Option, + /// `not-after` (key 1): end of validity. + #[cbor(key = 1)] + pub not_after: CborTime, +} + +// --------------------------------------------------------------------------- +// entity-map { entity-name: 0, reg-id: 1, role: 2 } +// --------------------------------------------------------------------------- + +/// `entity-map` — describes an entity (creator, signer, etc.). +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct EntityMap { + /// `entity-name` (key 0): name of the entity. + #[cbor(key = 0)] + pub entity_name: String, + /// `reg-id` (key 1): optional URI for the organization. + #[cbor(key = 1, optional)] + pub reg_id: Option, + /// `role` (key 2): list of roles. + #[cbor(key = 2)] + pub role: Vec, +} + +// --------------------------------------------------------------------------- +// version-map { version: 0, version-scheme: 1 } +// --------------------------------------------------------------------------- + +/// `version-map` — software version info. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct VersionMap { + /// `version` (key 0): the version string. + #[cbor(key = 0)] + pub version: String, + /// `version-scheme` (key 1): optional versioning convention. + #[cbor(key = 1, optional)] + pub version_scheme: Option, +} + +// --------------------------------------------------------------------------- +// Type-choice enums +// --------------------------------------------------------------------------- + +/// `$tag-id-type-choice` — text string or UUID. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum TagIdChoice { + /// A textual tag identifier. + Text(String), + /// A 16-byte UUID (CBOR tag 37). + Uuid([u8; 16]), +} + +impl Serialize for TagIdChoice { + fn serialize(&self, s: S) -> Result { + match self { + TagIdChoice::Text(t) => s.serialize_str(t), + TagIdChoice::Uuid(u) => value::serialize_tagged_bytes(TAG_UUID, u.as_slice(), s), + } + } +} + +impl<'de> Deserialize<'de> for TagIdChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Text(t) => Ok(TagIdChoice::Text(t)), + Value::Tag(TAG_UUID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 37 must wrap bytes"))?; + let arr: [u8; 16] = b + .try_into() + .map_err(|_| serde::de::Error::custom("UUID must be 16 bytes"))?; + Ok(TagIdChoice::Uuid(arr)) + } + _ => Err(serde::de::Error::custom("expected text or tagged UUID")), + } + } +} + +/// `$class-id-type-choice` — OID, UUID, or generic bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ClassIdChoice { + /// OID (CBOR tag 111). + Oid(Vec), + /// UUID (CBOR tag 37). + Uuid([u8; 16]), + /// Generic tagged bytes (CBOR tag 560). + Bytes(Vec), +} + +impl Serialize for ClassIdChoice { + fn serialize(&self, s: S) -> Result { + match self { + ClassIdChoice::Oid(b) => value::serialize_tagged_bytes(TAG_OID, b, s), + ClassIdChoice::Uuid(u) => value::serialize_tagged_bytes(TAG_UUID, u, s), + ClassIdChoice::Bytes(b) => value::serialize_tagged_bytes(TAG_BYTES, b, s), + } + } +} + +impl<'de> Deserialize<'de> for ClassIdChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_OID, inner) => { + Ok(ClassIdChoice::Oid(inner.into_bytes().ok_or_else(|| { + serde::de::Error::custom("tag 111 must wrap bytes") + })?)) + } + Value::Tag(TAG_UUID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 37 must wrap bytes"))?; + Ok(ClassIdChoice::Uuid(b.try_into().map_err(|_| { + serde::de::Error::custom("UUID must be 16 bytes") + })?)) + } + Value::Tag(TAG_BYTES, inner) => { + Ok(ClassIdChoice::Bytes(inner.into_bytes().ok_or_else( + || serde::de::Error::custom("tag 560 must wrap bytes"), + )?)) + } + _ => Err(serde::de::Error::custom( + "expected tagged OID, UUID, or bytes", + )), + } + } +} + +/// `$instance-id-type-choice` — UEID, UUID, bytes, or crypto key types. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum InstanceIdChoice { + /// UEID (CBOR tag 550). + Ueid(Vec), + /// UUID (CBOR tag 37). + Uuid([u8; 16]), + /// Generic tagged bytes (CBOR tag 560). + Bytes(Vec), + /// PEM SubjectPublicKeyInfo (CBOR tag 554). + PkixBase64Key(String), + /// PEM X.509 certificate (CBOR tag 555). + PkixBase64Cert(String), + /// CBOR-encoded COSE_Key (CBOR tag 558). + CoseKey(Vec), + /// Key thumbprint digest (CBOR tag 557). + KeyThumbprint(Digest), + /// Cert thumbprint digest (CBOR tag 559). + CertThumbprint(Digest), + /// ASN.1 DER X.509 certificate (CBOR tag 562). + PkixAsn1DerCert(Vec), +} + +impl Serialize for InstanceIdChoice { + fn serialize(&self, s: S) -> Result { + match self { + InstanceIdChoice::Ueid(b) => value::serialize_tagged_bytes(TAG_UEID, b, s), + InstanceIdChoice::Uuid(u) => value::serialize_tagged_bytes(TAG_UUID, u, s), + InstanceIdChoice::Bytes(b) => value::serialize_tagged_bytes(TAG_BYTES, b, s), + InstanceIdChoice::PkixBase64Key(t) => { + value::serialize_tagged(TAG_PKIX_BASE64_KEY, t, s) + } + InstanceIdChoice::PkixBase64Cert(t) => { + value::serialize_tagged(TAG_PKIX_BASE64_CERT, t, s) + } + InstanceIdChoice::CoseKey(b) => value::serialize_tagged_bytes(TAG_COSE_KEY, b, s), + InstanceIdChoice::KeyThumbprint(d) => value::serialize_tagged(TAG_KEY_THUMBPRINT, d, s), + InstanceIdChoice::CertThumbprint(d) => { + value::serialize_tagged(TAG_CERT_THUMBPRINT, d, s) + } + InstanceIdChoice::PkixAsn1DerCert(b) => { + value::serialize_tagged_bytes(TAG_PKIX_ASN1DER_CERT, b, s) + } + } + } +} + +impl<'de> Deserialize<'de> for InstanceIdChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_UEID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 550 must wrap bytes"))?; + if b.len() < 7 || b.len() > 33 { + return Err(serde::de::Error::custom(format!( + "UEID must be 7-33 bytes, got {}", + b.len() + ))); + } + Ok(InstanceIdChoice::Ueid(b)) + } + Value::Tag(TAG_UUID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 37 must wrap bytes"))?; + Ok(InstanceIdChoice::Uuid(b.try_into().map_err(|_| { + serde::de::Error::custom("UUID must be 16 bytes") + })?)) + } + Value::Tag(TAG_PKIX_BASE64_KEY, inner) => match *inner { + Value::Text(t) => Ok(InstanceIdChoice::PkixBase64Key(t)), + _ => Err(serde::de::Error::custom("tag 554 must wrap text")), + }, + Value::Tag(TAG_PKIX_BASE64_CERT, inner) => match *inner { + Value::Text(t) => Ok(InstanceIdChoice::PkixBase64Cert(t)), + _ => Err(serde::de::Error::custom("tag 555 must wrap text")), + }, + Value::Tag(TAG_COSE_KEY, inner) => { + Ok(InstanceIdChoice::CoseKey(inner.into_bytes().ok_or_else( + || serde::de::Error::custom("tag 558 must wrap bytes"), + )?)) + } + Value::Tag(TAG_KEY_THUMBPRINT, inner) => { + let arr = inner + .into_array() + .ok_or_else(|| serde::de::Error::custom("tag 557 must wrap array"))?; + Ok(InstanceIdChoice::KeyThumbprint(digest_from_value_array( + arr, + )?)) + } + Value::Tag(TAG_CERT_THUMBPRINT, inner) => { + let arr = inner + .into_array() + .ok_or_else(|| serde::de::Error::custom("tag 559 must wrap array"))?; + Ok(InstanceIdChoice::CertThumbprint(digest_from_value_array( + arr, + )?)) + } + Value::Tag(TAG_PKIX_ASN1DER_CERT, inner) => Ok(InstanceIdChoice::PkixAsn1DerCert( + inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 562 must wrap bytes"))?, + )), + Value::Tag(TAG_BYTES, inner) => { + Ok(InstanceIdChoice::Bytes(inner.into_bytes().ok_or_else( + || serde::de::Error::custom("tag 560 must wrap bytes"), + )?)) + } + _ => Err(serde::de::Error::custom( + "expected tagged UEID, UUID, bytes, or crypto key", + )), + } + } +} + +/// `$group-id-type-choice` — UUID or bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum GroupIdChoice { + /// UUID (CBOR tag 37). + Uuid([u8; 16]), + /// Generic tagged bytes (CBOR tag 560). + Bytes(Vec), +} + +impl Serialize for GroupIdChoice { + fn serialize(&self, s: S) -> Result { + match self { + GroupIdChoice::Uuid(u) => value::serialize_tagged_bytes(TAG_UUID, u, s), + GroupIdChoice::Bytes(b) => value::serialize_tagged_bytes(TAG_BYTES, b, s), + } + } +} + +impl<'de> Deserialize<'de> for GroupIdChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_UUID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 37 must wrap bytes"))?; + Ok(GroupIdChoice::Uuid(b.try_into().map_err(|_| { + serde::de::Error::custom("UUID must be 16 bytes") + })?)) + } + Value::Tag(TAG_BYTES, inner) => { + Ok(GroupIdChoice::Bytes(inner.into_bytes().ok_or_else( + || serde::de::Error::custom("tag 560 must wrap bytes"), + )?)) + } + _ => Err(serde::de::Error::custom("expected tagged UUID or bytes")), + } + } +} + +/// `$measured-element-type-choice` — OID, UUID, uint, or text. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum MeasuredElement { + /// OID (CBOR tag 111). + Oid(Vec), + /// UUID (CBOR tag 37). + Uuid([u8; 16]), + /// Unsigned integer. + Uint(u64), + /// Text string. + Text(String), +} + +impl Serialize for MeasuredElement { + fn serialize(&self, s: S) -> Result { + match self { + MeasuredElement::Oid(b) => value::serialize_tagged_bytes(TAG_OID, b, s), + MeasuredElement::Uuid(u) => value::serialize_tagged_bytes(TAG_UUID, u, s), + MeasuredElement::Uint(n) => s.serialize_u64(*n), + MeasuredElement::Text(t) => s.serialize_str(t), + } + } +} + +impl<'de> Deserialize<'de> for MeasuredElement { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_OID, inner) => { + Ok(MeasuredElement::Oid(inner.into_bytes().ok_or_else( + || serde::de::Error::custom("tag 111 must wrap bytes"), + )?)) + } + Value::Tag(TAG_UUID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 37 must wrap bytes"))?; + Ok(MeasuredElement::Uuid(b.try_into().map_err(|_| { + serde::de::Error::custom("UUID must be 16 bytes") + })?)) + } + Value::Integer(n) => { + Ok(MeasuredElement::Uint(n.try_into().map_err(|_| { + serde::de::Error::custom("expected unsigned integer") + })?)) + } + Value::Text(t) => Ok(MeasuredElement::Text(t)), + _ => Err(serde::de::Error::custom( + "expected OID, UUID, uint, or text", + )), + } + } +} + +/// `$crypto-key-type-choice` — covers CBOR tags 554–562. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CryptoKey { + /// PEM SubjectPublicKeyInfo (CBOR tag 554). + PkixBase64Key(String), + /// PEM X.509 certificate (CBOR tag 555). + PkixBase64Cert(String), + /// PEM X.509 certificate chain (CBOR tag 556). + PkixBase64CertPath(String), + /// Key thumbprint `[alg, val]` (CBOR tag 557). + KeyThumbprint(Digest), + /// CBOR-encoded COSE_Key (CBOR tag 558). + CoseKey(Vec), + /// Certificate thumbprint (CBOR tag 559). + CertThumbprint(Digest), + /// Certification path thumbprint (CBOR tag 561). + CertPathThumbprint(Digest), + /// ASN.1 DER X.509 certificate (CBOR tag 562). + PkixAsn1DerCert(Vec), + /// Opaque key identifier (CBOR tag 560). + Bytes(Vec), +} + +impl Serialize for CryptoKey { + fn serialize(&self, s: S) -> Result { + match self { + CryptoKey::PkixBase64Key(v) => value::serialize_tagged(TAG_PKIX_BASE64_KEY, v, s), + CryptoKey::PkixBase64Cert(v) => value::serialize_tagged(TAG_PKIX_BASE64_CERT, v, s), + CryptoKey::PkixBase64CertPath(v) => { + value::serialize_tagged(TAG_PKIX_BASE64_CERT_PATH, v, s) + } + CryptoKey::KeyThumbprint(v) => value::serialize_tagged(TAG_KEY_THUMBPRINT, v, s), + CryptoKey::CoseKey(v) => value::serialize_tagged_bytes(TAG_COSE_KEY, v, s), + CryptoKey::CertThumbprint(v) => value::serialize_tagged(TAG_CERT_THUMBPRINT, v, s), + CryptoKey::CertPathThumbprint(v) => { + value::serialize_tagged(TAG_CERT_PATH_THUMBPRINT, v, s) + } + CryptoKey::PkixAsn1DerCert(v) => { + value::serialize_tagged_bytes(TAG_PKIX_ASN1DER_CERT, v, s) + } + CryptoKey::Bytes(v) => value::serialize_tagged_bytes(TAG_BYTES, v, s), + } + } +} + +impl<'de> Deserialize<'de> for CryptoKey { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_PKIX_BASE64_KEY, inner) => match *inner { + Value::Text(t) => Ok(CryptoKey::PkixBase64Key(t)), + _ => Err(serde::de::Error::custom("tag 554 must wrap text")), + }, + Value::Tag(TAG_PKIX_BASE64_CERT, inner) => match *inner { + Value::Text(t) => Ok(CryptoKey::PkixBase64Cert(t)), + _ => Err(serde::de::Error::custom("tag 555 must wrap text")), + }, + Value::Tag(TAG_PKIX_BASE64_CERT_PATH, inner) => match *inner { + Value::Text(t) => Ok(CryptoKey::PkixBase64CertPath(t)), + _ => Err(serde::de::Error::custom("tag 556 must wrap text")), + }, + Value::Tag(TAG_KEY_THUMBPRINT, inner) => { + let arr = inner + .into_array() + .ok_or_else(|| serde::de::Error::custom("tag 557 must wrap array"))?; + Ok(CryptoKey::KeyThumbprint(digest_from_value_array(arr)?)) + } + Value::Tag(TAG_COSE_KEY, inner) => match *inner { + Value::Bytes(b) => Ok(CryptoKey::CoseKey(b)), + _ => Err(serde::de::Error::custom("tag 558 must wrap bytes")), + }, + Value::Tag(TAG_CERT_THUMBPRINT, inner) => { + let arr = inner + .into_array() + .ok_or_else(|| serde::de::Error::custom("tag 559 must wrap array"))?; + Ok(CryptoKey::CertThumbprint(digest_from_value_array(arr)?)) + } + Value::Tag(TAG_CERT_PATH_THUMBPRINT, inner) => { + let arr = inner + .into_array() + .ok_or_else(|| serde::de::Error::custom("tag 561 must wrap array"))?; + Ok(CryptoKey::CertPathThumbprint(digest_from_value_array(arr)?)) + } + Value::Tag(TAG_PKIX_ASN1DER_CERT, inner) => match *inner { + Value::Bytes(b) => Ok(CryptoKey::PkixAsn1DerCert(b)), + _ => Err(serde::de::Error::custom("tag 562 must wrap bytes")), + }, + Value::Tag(TAG_BYTES, inner) => match *inner { + Value::Bytes(b) => Ok(CryptoKey::Bytes(b)), + _ => Err(serde::de::Error::custom("tag 560 must wrap bytes")), + }, + _ => Err(serde::de::Error::custom("expected a tagged crypto key")), + } + } +} + +/// `linked-tag-map` — references another tag. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct LinkedTagMap { + /// `linked-tag-id` (key 0). + #[cbor(key = 0)] + pub linked_tag_id: TagIdChoice, + /// `tag-rel` (key 1): supplements(0) or replaces(1). + #[cbor(key = 1)] + pub tag_rel: i64, +} + +// --------------------------------------------------------------------------- +// Digest helper +// --------------------------------------------------------------------------- + +/// Deserialize a `[alg, val]` array of [`Value`]s into a [`Digest`]. +fn digest_from_value_array(arr: Vec) -> Result { + if arr.len() != 2 { + return Err(E::custom("digest must be [alg, val]")); + } + let mut it = arr.into_iter(); + let alg = match it.next().unwrap() { + Value::Integer(n) => { + i64::try_from(n).map_err(|_| E::custom("digest alg out of i64 range"))? + } + _ => return Err(E::custom("digest alg must be int")), + }; + let val = match it.next().unwrap() { + Value::Bytes(b) => b, + _ => return Err(E::custom("digest val must be bytes")), + }; + Ok(Digest::new(alg, val)) +} + +// --------------------------------------------------------------------------- +// From conversions for type-choice enums +// --------------------------------------------------------------------------- + +impl From for TagIdChoice { + fn from(s: String) -> Self { + Self::Text(s) + } +} + +impl From<&str> for TagIdChoice { + fn from(s: &str) -> Self { + Self::Text(s.to_owned()) + } +} + +impl From<[u8; 16]> for TagIdChoice { + fn from(u: [u8; 16]) -> Self { + Self::Uuid(u) + } +} + +impl From for MeasuredElement { + fn from(s: String) -> Self { + Self::Text(s) + } +} + +impl From<&str> for MeasuredElement { + fn from(s: &str) -> Self { + Self::Text(s.to_owned()) + } +} + +impl From for MeasuredElement { + fn from(n: u64) -> Self { + Self::Uint(n) + } +} + +// --------------------------------------------------------------------------- +// Display impls +// --------------------------------------------------------------------------- + +fn hex_short(bytes: &[u8]) -> String { + if bytes.len() <= 16 { + hex_encode(bytes) + } else { + format!("{}..({} bytes)", hex_encode(&bytes[..8]), bytes.len()) + } +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn format_uuid(bytes: &[u8; 16]) -> String { + format!( + "{}-{}-{}-{}-{}", + hex_encode(&bytes[0..4]), + hex_encode(&bytes[4..6]), + hex_encode(&bytes[6..8]), + hex_encode(&bytes[8..10]), + hex_encode(&bytes[10..16]), + ) +} + +impl core::fmt::Display for TagIdChoice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TagIdChoice::Text(t) => write!(f, "{}", t), + TagIdChoice::Uuid(u) => write!(f, "{}", format_uuid(u)), + } + } +} + +impl core::fmt::Display for ClassIdChoice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ClassIdChoice::Oid(b) => write!(f, "oid:{}", hex_encode(b)), + ClassIdChoice::Uuid(u) => write!(f, "{}", format_uuid(u)), + ClassIdChoice::Bytes(b) => write!(f, "bytes:{}", hex_short(b)), + } + } +} + +impl core::fmt::Display for InstanceIdChoice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + InstanceIdChoice::Ueid(b) => write!(f, "ueid:{}", hex_short(b)), + InstanceIdChoice::Uuid(u) => write!(f, "{}", format_uuid(u)), + InstanceIdChoice::Bytes(b) => write!(f, "bytes:{}", hex_short(b)), + InstanceIdChoice::PkixBase64Key(s) => write!(f, "pkix-key:{:.32}...", s), + InstanceIdChoice::PkixBase64Cert(s) => write!(f, "pkix-cert:{:.32}...", s), + InstanceIdChoice::CoseKey(b) => write!(f, "cose-key:({} bytes)", b.len()), + InstanceIdChoice::KeyThumbprint(d) => { + write!(f, "key-tp:alg={}:{}", d.alg(), hex_short(d.value())) + } + InstanceIdChoice::CertThumbprint(d) => { + write!(f, "cert-tp:alg={}:{}", d.alg(), hex_short(d.value())) + } + InstanceIdChoice::PkixAsn1DerCert(b) => write!(f, "asn1-cert:({} bytes)", b.len()), + } + } +} + +impl core::fmt::Display for GroupIdChoice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + GroupIdChoice::Uuid(u) => write!(f, "{}", format_uuid(u)), + GroupIdChoice::Bytes(b) => write!(f, "bytes:{}", hex_short(b)), + } + } +} + +impl core::fmt::Display for MeasuredElement { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + MeasuredElement::Oid(b) => write!(f, "oid:{}", hex_encode(b)), + MeasuredElement::Uuid(u) => write!(f, "{}", format_uuid(u)), + MeasuredElement::Uint(n) => write!(f, "{}", n), + MeasuredElement::Text(t) => write!(f, "{}", t), + } + } +} + +impl core::fmt::Display for CryptoKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CryptoKey::PkixBase64Key(s) => write!(f, "pkix-key:{:.40}...", s), + CryptoKey::PkixBase64Cert(s) => write!(f, "pkix-cert:{:.40}...", s), + CryptoKey::PkixBase64CertPath(s) => write!(f, "pkix-cert-path:{:.40}...", s), + CryptoKey::KeyThumbprint(d) => { + write!(f, "key-tp:alg={}:{}", d.alg(), hex_short(d.value())) + } + CryptoKey::CoseKey(b) => write!(f, "cose-key:({} bytes)", b.len()), + CryptoKey::CertThumbprint(d) => { + write!(f, "cert-tp:alg={}:{}", d.alg(), hex_short(d.value())) + } + CryptoKey::CertPathThumbprint(d) => { + write!(f, "cert-path-tp:alg={}:{}", d.alg(), hex_short(d.value())) + } + CryptoKey::PkixAsn1DerCert(b) => write!(f, "asn1-cert:({} bytes)", b.len()), + CryptoKey::Bytes(b) => write!(f, "bytes:{}", hex_short(b)), + } + } +} diff --git a/corim/src/types/corim.rs b/corim/src/types/corim.rs new file mode 100644 index 0000000..342fe5b --- /dev/null +++ b/corim/src/types/corim.rs @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Top-level `corim-map` and related types. + +use corim_macros::{CborDeserialize, CborSerialize}; +use serde::{Deserialize, Serialize}; + +use super::common::{EntityMap, TagIdentity, ValidityMap}; +use super::measurement::Digest; +use super::tags::*; +use crate::cbor; +use crate::cbor::value::{self, Value}; +use crate::Validate; + +// --------------------------------------------------------------------------- +// corim-id +// --------------------------------------------------------------------------- + +/// `$corim-id-type-choice` — text or UUID. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CorimId { + /// A text CoRIM identifier. + Text(String), + /// A UUID CoRIM identifier (CBOR tag 37). + Uuid([u8; 16]), +} + +impl Serialize for CorimId { + fn serialize(&self, s: S) -> Result { + match self { + CorimId::Text(t) => s.serialize_str(t), + CorimId::Uuid(u) => value::serialize_tagged_bytes(TAG_UUID, u, s), + } + } +} + +impl<'de> Deserialize<'de> for CorimId { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Text(t) => Ok(CorimId::Text(t)), + Value::Tag(TAG_UUID, inner) => { + let b = inner + .into_bytes() + .ok_or_else(|| serde::de::Error::custom("tag 37 must wrap bytes"))?; + Ok(CorimId::Uuid(b.try_into().map_err(|_| { + serde::de::Error::custom("UUID must be 16 bytes") + })?)) + } + _ => Err(serde::de::Error::custom("expected text or tagged UUID")), + } + } +} + +impl core::fmt::Display for CorimId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CorimId::Text(t) => write!(f, "{}", t), + CorimId::Uuid(u) => { + let hex = |s: &[u8]| -> String { s.iter().map(|b| format!("{:02x}", b)).collect() }; + write!( + f, + "{}-{}-{}-{}-{}", + hex(&u[0..4]), + hex(&u[4..6]), + hex(&u[6..8]), + hex(&u[8..10]), + hex(&u[10..16]) + ) + } + } + } +} + +impl From for CorimId { + fn from(s: String) -> Self { + Self::Text(s) + } +} + +impl From<&str> for CorimId { + fn from(s: &str) -> Self { + Self::Text(s.to_owned()) + } +} + +impl From<[u8; 16]> for CorimId { + fn from(u: [u8; 16]) -> Self { + Self::Uuid(u) + } +} + +// --------------------------------------------------------------------------- +// profile +// --------------------------------------------------------------------------- + +/// `$profile-type-choice` — URI or OID. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProfileChoice { + /// A URI profile identifier. + Uri(String), + /// An OID profile identifier (CBOR tag 111). + Oid(Vec), +} + +impl Serialize for ProfileChoice { + fn serialize(&self, s: S) -> Result { + match self { + ProfileChoice::Uri(u) => s.serialize_str(u), + ProfileChoice::Oid(b) => value::serialize_tagged_bytes(TAG_OID, b, s), + } + } +} + +impl<'de> Deserialize<'de> for ProfileChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Text(t) => Ok(ProfileChoice::Uri(t)), + Value::Tag(TAG_OID, inner) => { + Ok(ProfileChoice::Oid(inner.into_bytes().ok_or_else(|| { + serde::de::Error::custom("tag 111 must wrap bytes") + })?)) + } + _ => Err(serde::de::Error::custom("expected text URI or tagged OID")), + } + } +} + +impl core::fmt::Display for ProfileChoice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ProfileChoice::Uri(u) => write!(f, "{}", u), + ProfileChoice::Oid(b) => { + write!(f, "oid:")?; + for byte in b { + write!(f, "{:02x}", byte)?; + } + Ok(()) + } + } + } +} + +// --------------------------------------------------------------------------- +// ConciseTagChoice +// --------------------------------------------------------------------------- + +/// A tag entry in the CoRIM `tags` array. +/// +/// Only CoMID (tag 506) is fully modeled. CoSWID (505) and CoTL (508) +/// are stored as opaque bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ConciseTagChoice { + /// CoMID tag (CBOR tag 506 wrapping bytes). + Comid(Vec), + /// CoSWID tag (CBOR tag 505 wrapping bytes). + Coswid(Vec), + /// CoTL tag (CBOR tag 508 wrapping bytes). + Cotl(Vec), + /// Unknown tag type. + Unknown(u64, Vec), +} + +impl Serialize for ConciseTagChoice { + fn serialize(&self, s: S) -> Result { + match self { + ConciseTagChoice::Comid(bytes) => value::serialize_tagged_bytes(TAG_COMID, bytes, s), + ConciseTagChoice::Coswid(bytes) => value::serialize_tagged_bytes(TAG_COSWID, bytes, s), + ConciseTagChoice::Cotl(bytes) => value::serialize_tagged_bytes(TAG_COTL, bytes, s), + ConciseTagChoice::Unknown(tag, bytes) => { + let val = Value::Tag(*tag, Box::new(Value::Bytes(bytes.clone()))); + val.serialize(s) + } + } + } +} + +impl<'de> Deserialize<'de> for ConciseTagChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_COMID, inner) => match *inner { + Value::Bytes(b) => Ok(ConciseTagChoice::Comid(b)), + _ => Err(serde::de::Error::custom("tag 506 must wrap bytes")), + }, + Value::Tag(TAG_COSWID, inner) => match *inner { + Value::Bytes(b) => Ok(ConciseTagChoice::Coswid(b)), + _ => Err(serde::de::Error::custom("tag 505 must wrap bytes")), + }, + Value::Tag(TAG_COTL, inner) => match *inner { + Value::Bytes(b) => Ok(ConciseTagChoice::Cotl(b)), + _ => Err(serde::de::Error::custom("tag 508 must wrap bytes")), + }, + Value::Tag(tag, inner) => { + let raw_bytes = cbor::encode(&*inner).map_err(serde::de::Error::custom)?; + Ok(ConciseTagChoice::Unknown(tag, raw_bytes)) + } + _ => Err(serde::de::Error::custom("expected a tagged concise tag")), + } + } +} + +// --------------------------------------------------------------------------- +// corim-locator-map +// --------------------------------------------------------------------------- + +/// `corim-locator-map` — locator for dependent manifests. +/// +/// CDDL: +/// ```text +/// corim-locator-map = { +/// &(href: 0) => uri / [+ uri], +/// ? &(thumbprint: 1) => eatmc.digest / [eatmc.digest], +/// } +/// ``` +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct CorimLocator { + /// `href` (key 0): URI or array of URIs. + #[cbor(key = 0)] + pub href: CorimLocatorHref, + /// `thumbprint` (key 1): optional digest(s). + #[cbor(key = 1, optional)] + pub thumbprint: Option, +} + +/// Href can be a single URI string or an array of URI strings. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CorimLocatorHref { + /// Single URI. + Single(String), + /// Multiple URIs. + Multiple(Vec), +} + +impl Serialize for CorimLocatorHref { + fn serialize(&self, s: S) -> Result { + match self { + CorimLocatorHref::Single(uri) => s.serialize_str(uri), + CorimLocatorHref::Multiple(uris) => uris.serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for CorimLocatorHref { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Text(t) => Ok(CorimLocatorHref::Single(t)), + Value::Array(arr) => { + let mut uris = Vec::new(); + for v in arr { + match v { + Value::Text(t) => uris.push(t), + _ => { + return Err(serde::de::Error::custom("href array must contain strings")) + } + } + } + Ok(CorimLocatorHref::Multiple(uris)) + } + _ => Err(serde::de::Error::custom("expected text or array for href")), + } + } +} + +/// Thumbprint can be a single digest or array of digests. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CorimLocatorThumbprint { + /// Single digest. + Single(Digest), + /// Multiple digests. + Multiple(Vec), +} + +impl Serialize for CorimLocatorThumbprint { + fn serialize(&self, s: S) -> Result { + match self { + CorimLocatorThumbprint::Single(d) => d.serialize(s), + CorimLocatorThumbprint::Multiple(ds) => ds.serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for CorimLocatorThumbprint { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Array(ref arr) if !arr.is_empty() => { + // Check if it's a single digest [alg, val] or array of digests [[alg,val],...] + match &arr[0] { + Value::Array(_) => { + // Array of digests: [[alg, val], ...] + let Value::Array(outer) = val else { + unreachable!() + }; + let mut ds = Vec::new(); + for item in outer { + match item { + Value::Array(pair) if pair.len() == 2 => { + let mut it = pair.into_iter(); + let alg = match it.next().unwrap() { + Value::Integer(n) => i64::try_from(n).map_err(|_| { + serde::de::Error::custom("digest alg out of i64 range") + })?, + _ => { + return Err(serde::de::Error::custom( + "digest alg must be int", + )) + } + }; + let v = match it.next().unwrap() { + Value::Bytes(b) => b, + _ => { + return Err(serde::de::Error::custom( + "digest val must be bytes", + )) + } + }; + ds.push(Digest::new(alg, v)); + } + _ => { + return Err(serde::de::Error::custom( + "digest must be [alg, val]", + )) + } + } + } + Ok(CorimLocatorThumbprint::Multiple(ds)) + } + _ => { + // Single digest [alg, val] + let Value::Array(pair) = val else { + unreachable!() + }; + if pair.len() != 2 { + return Err(serde::de::Error::custom("digest must be [alg, val]")); + } + let mut it = pair.into_iter(); + let alg = match it.next().unwrap() { + Value::Integer(n) => i64::try_from(n).map_err(|_| { + serde::de::Error::custom("digest alg out of i64 range") + })?, + _ => return Err(serde::de::Error::custom("digest alg must be int")), + }; + let v = match it.next().unwrap() { + Value::Bytes(b) => b, + _ => return Err(serde::de::Error::custom("digest val must be bytes")), + }; + Ok(CorimLocatorThumbprint::Single(Digest::new(alg, v))) + } + } + } + _ => Err(serde::de::Error::custom("expected array for thumbprint")), + } + } +} + +// --------------------------------------------------------------------------- +// corim-signer-map +// --------------------------------------------------------------------------- + +/// `corim-signer-map` — identifies the signer of a CoRIM. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct CorimSignerMap { + /// `signer-name` (key 0). + #[cbor(key = 0)] + pub signer_name: String, + /// `signer-uri` (key 1). + #[cbor(key = 1, optional)] + pub signer_uri: Option, +} + +// --------------------------------------------------------------------------- +// corim-meta-map +// --------------------------------------------------------------------------- + +/// `corim-meta-map` — metadata about a signed CoRIM. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct CorimMetaMap { + /// `signer` (key 0). + #[cbor(key = 0)] + pub signer: CorimSignerMap, + /// `signature-validity` (key 1). + #[cbor(key = 1, optional)] + pub signature_validity: Option, +} + +// --------------------------------------------------------------------------- +// concise-tl-tag (CoTL) +// --------------------------------------------------------------------------- + +/// `concise-tl-tag` — a tag list. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct ConciseTlTag { + /// `tag-identity` (key 0). + #[cbor(key = 0)] + pub tag_identity: TagIdentity, + /// `tags-list` (key 1): list of tag identities. + #[cbor(key = 1)] + pub tags_list: Vec, + /// `tl-validity` (key 2): validity period. + #[cbor(key = 2)] + pub tl_validity: ValidityMap, +} + +// --------------------------------------------------------------------------- +// corim-map +// --------------------------------------------------------------------------- + +/// `corim-map` — top-level CoRIM structure. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct CorimMap { + /// `id` (key 0): CoRIM identifier. + #[cbor(key = 0)] + pub id: CorimId, + /// `tags` (key 1): array of concise tags. + #[cbor(key = 1)] + pub tags: Vec, + /// `dependent-rims` (key 2): optional locators. + #[cbor(key = 2, optional)] + pub dependent_rims: Option>, + /// `profile` (key 3): optional profile identifier. + #[cbor(key = 3, optional)] + pub profile: Option, + /// `rim-validity` (key 4): optional validity period. + #[cbor(key = 4, optional)] + pub rim_validity: Option, + /// `entities` (key 5): optional entity list. + #[cbor(key = 5, optional)] + pub entities: Option>, +} + +impl Validate for ConciseTlTag { + fn valid(&self) -> Result<(), String> { + // tags-list must be non-empty (CDDL: [+ tag-identity-map]) + if self.tags_list.is_empty() { + return Err("tags-list must not be empty".into()); + } + // Validate validity window consistency + if let Some(nb) = self.tl_validity.not_before { + if nb.epoch_secs() > self.tl_validity.not_after.epoch_secs() { + return Err("not-before must be <= not-after".into()); + } + } + Ok(()) + } +} + +impl Validate for CorimMap { + fn valid(&self) -> Result<(), String> { + // tags must be non-empty + if self.tags.is_empty() { + return Err("tags list must not be empty".into()); + } + // Validate validity window consistency + if let Some(ref validity) = self.rim_validity { + if let Some(nb) = validity.not_before { + if nb.epoch_secs() > validity.not_after.epoch_secs() { + return Err("rim-validity: not-before must be <= not-after".into()); + } + } + } + // Validate entities if present + if let Some(ref entities) = self.entities { + if entities.is_empty() { + return Err("entities list must not be empty".into()); + } + } + Ok(()) + } +} diff --git a/corim/src/types/coswid.rs b/corim/src/types/coswid.rs new file mode 100644 index 0000000..f2a578c --- /dev/null +++ b/corim/src/types/coswid.rs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CoSWID (Concise Software Identification) types per RFC 9393. +//! +//! This module models the core subset of `concise-swid-tag` needed for CoRIM +//! integration. The full RFC 9393 resource-collection model (file-entry, +//! directory-entry, process-entry) is deferred — payload and evidence are +//! stored as opaque [`crate::cbor::value::Value`] when present. +//! +//! # Modeled types +//! +//! | CDDL | Rust type | +//! |------|-----------| +//! | `concise-swid-tag` | [`ConciseSwidTag`] | +//! | `entity-entry` | [`SwidEntity`] | +//! | `link-entry` | [`SwidLink`] | +//! | `hash-entry` | reuses [`crate::types::measurement::Digest`] | + +use corim_macros::{CborDeserialize, CborSerialize}; + +use super::common::TagIdChoice; +use super::measurement::Digest; +use crate::Validate; + +// --------------------------------------------------------------------------- +// concise-swid-tag (RFC 9393 §2.3) +// --------------------------------------------------------------------------- + +/// `concise-swid-tag` — top-level CoSWID tag. +/// +/// Models the core fields of the `concise-swid-tag` map per RFC 9393 §2.3. +/// Payload and evidence are not modeled (opaque in CoRIM context). +/// +/// CDDL (subset): +/// ```text +/// concise-swid-tag = { +/// tag-id: 0 => text / bstr .size 16, +/// software-name: 1 => text, +/// entity: 2 => entity-entry / [2* entity-entry], +/// ? link: 4 => link-entry / [2* link-entry], +/// ? corpus: 8 => bool, +/// ? patch: 9 => bool, +/// ? supplemental: 11 => bool, +/// tag-version: 12 => integer, +/// ? software-version: 13 => text, +/// ? version-scheme: 14 => int / text, +/// ? lang: 15 => text, +/// } +/// ``` +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct ConciseSwidTag { + /// `tag-id` (key 0): globally unique tag identifier (text or UUID). + #[cbor(key = 0)] + pub tag_id: TagIdChoice, + + /// `software-name` (key 1): human-readable software name. + #[cbor(key = 1)] + pub software_name: String, + + /// `entity` (key 2): one or more entities (tag creator, etc.). + #[cbor(key = 2)] + pub entities: Vec, + + /// `link` (key 4): optional relationship links. + #[cbor(key = 4, optional)] + pub links: Option>, + + /// `corpus` (key 8): true if this is a corpus (pre-installation) tag. + #[cbor(key = 8, optional)] + pub corpus: Option, + + /// `patch` (key 9): true if this is a patch tag. + #[cbor(key = 9, optional)] + pub patch: Option, + + /// `supplemental` (key 11): true if this is a supplemental tag. + #[cbor(key = 11, optional)] + pub supplemental: Option, + + /// `tag-version` (key 12): revision number of the tag itself. + #[cbor(key = 12)] + pub tag_version: i64, + + /// `software-version` (key 13): version of the software component. + #[cbor(key = 13, optional)] + pub software_version: Option, + + /// `version-scheme` (key 14): versioning scheme (e.g., semver=16384). + #[cbor(key = 14, optional)] + pub version_scheme: Option, + + /// `lang` (key 15): BCP 47 language tag. + #[cbor(key = 15, optional)] + pub lang: Option, +} + +impl Validate for ConciseSwidTag { + fn valid(&self) -> Result<(), String> { + // entities must be non-empty (CDDL: one-or-more) + if self.entities.is_empty() { + return Err("at least one entity is required".into()); + } + + // At least one entity must have the tag-creator role + let has_tag_creator = self + .entities + .iter() + .any(|e| e.roles.contains(&super::tags::SWID_ROLE_TAG_CREATOR)); + if !has_tag_creator { + return Err("at least one entity must have the tag-creator role".into()); + } + + // Validate entities + for (i, e) in self.entities.iter().enumerate() { + e.valid() + .map_err(|err| format!("entity at index {i}: {err}"))?; + } + + // Validate links if present + if let Some(ref links) = self.links { + for (i, l) in links.iter().enumerate() { + l.valid() + .map_err(|err| format!("link at index {i}: {err}"))?; + } + } + + // Co-constraints (RFC 9393 §2.4): + // patch and supplemental must not both be true + if self.patch == Some(true) && self.supplemental == Some(true) { + return Err("patch and supplemental must not both be true".into()); + } + + // If patch is true, must have at least one link with rel="patches" + if self.patch == Some(true) { + let has_patches_link = self + .links + .as_ref() + .is_some_and(|links| links.iter().any(|l| l.rel == super::tags::SWID_REL_PATCHES)); + if !has_patches_link { + return Err("patch tag must have at least one link with rel=\"patches\"".into()); + } + } + + Ok(()) + } +} + +impl ConciseSwidTag { + /// Create a new CoSWID tag with the minimum required fields. + pub fn new( + tag_id: TagIdChoice, + software_name: impl Into, + tag_version: i64, + entities: Vec, + ) -> Self { + Self { + tag_id, + software_name: software_name.into(), + entities, + links: None, + corpus: None, + patch: None, + supplemental: None, + tag_version, + software_version: None, + version_scheme: None, + lang: None, + } + } +} + +// --------------------------------------------------------------------------- +// entity-entry (RFC 9393 §2.6) +// --------------------------------------------------------------------------- + +/// `entity-entry` — an entity involved in a CoSWID tag. +/// +/// CDDL: +/// ```text +/// entity-entry = { +/// entity-name: 31 => text, +/// ? reg-id: 32 => any-uri, +/// role: 33 => $role / [2* $role], +/// ? thumbprint: 34 => hash-entry, +/// } +/// ``` +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct SwidEntity { + /// `entity-name` (key 31): name of the entity. + #[cbor(key = 31)] + pub entity_name: String, + + /// `reg-id` (key 32): optional registration URI. + #[cbor(key = 32, optional)] + pub reg_id: Option, + + /// `role` (key 33): one or more role values. + #[cbor(key = 33)] + pub roles: Vec, + + /// `thumbprint` (key 34): optional signing entity thumbprint. + #[cbor(key = 34, optional)] + pub thumbprint: Option, +} + +impl Validate for SwidEntity { + fn valid(&self) -> Result<(), String> { + if self.entity_name.is_empty() { + return Err("entity-name must not be empty".into()); + } + if self.roles.is_empty() { + return Err("at least one role is required".into()); + } + Ok(()) + } +} + +impl SwidEntity { + /// Create a new entity with the given name and roles. + pub fn new(name: impl Into, roles: Vec) -> Self { + Self { + entity_name: name.into(), + reg_id: None, + roles, + thumbprint: None, + } + } + + /// Set the registration URI. + pub fn with_reg_id(mut self, reg_id: impl Into) -> Self { + self.reg_id = Some(reg_id.into()); + self + } +} + +// --------------------------------------------------------------------------- +// link-entry (RFC 9393 §2.7) +// --------------------------------------------------------------------------- + +/// `link-entry` — a relationship link in a CoSWID tag. +/// +/// CDDL: +/// ```text +/// link-entry = { +/// ? artifact: 37 => text, +/// href: 38 => any-uri, +/// ? media: 10 => text, +/// ? ownership: 39 => $ownership, +/// rel: 40 => $rel, +/// ? media-type: 41 => text, +/// ? use: 42 => $use, +/// } +/// ``` +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct SwidLink { + /// `media` (key 10): optional media query hint. + #[cbor(key = 10, optional)] + pub media: Option, + + /// `artifact` (key 37): optional artifact path. + #[cbor(key = 37, optional)] + pub artifact: Option, + + /// `href` (key 38): URI reference. + #[cbor(key = 38)] + pub href: String, + + /// `ownership` (key 39): optional ownership type. + #[cbor(key = 39, optional)] + pub ownership: Option, + + /// `rel` (key 40): relationship type. + #[cbor(key = 40)] + pub rel: i64, + + /// `media-type` (key 41): optional media type hint. + #[cbor(key = 41, optional)] + pub media_type: Option, + + /// `use` (key 42): optional use type. + #[cbor(key = 42, optional)] + pub use_: Option, +} + +impl Validate for SwidLink { + fn valid(&self) -> Result<(), String> { + if self.href.is_empty() { + return Err("href must not be empty".into()); + } + Ok(()) + } +} + +impl SwidLink { + /// Create a new link with the given href and rel. + pub fn new(href: impl Into, rel: i64) -> Self { + Self { + media: None, + artifact: None, + href: href.into(), + ownership: None, + rel, + media_type: None, + use_: None, + } + } +} diff --git a/corim/src/types/environment.rs b/corim/src/types/environment.rs new file mode 100644 index 0000000..fa37d7e --- /dev/null +++ b/corim/src/types/environment.rs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `environment-map` and `class-map` types. + +use corim_macros::{CborDeserialize, CborSerialize}; + +use super::common::{ClassIdChoice, GroupIdChoice, InstanceIdChoice}; +use crate::Validate; + +// --------------------------------------------------------------------------- +// class-map { class-id: 0, vendor: 1, model: 2, layer: 3, index: 4 } +// --------------------------------------------------------------------------- + +/// `class-map` — identifies a component class. +/// +/// CDDL: `non-empty<{ ?0 => class-id, ?1 => vendor, ?2 => model, ?3 => layer, ?4 => index }>` +#[derive(Clone, Debug, Default, PartialEq, CborSerialize, CborDeserialize)] +#[cbor(non_empty)] +pub struct ClassMap { + /// `class-id` (key 0): optional platform-specific identifier. + #[cbor(key = 0, optional)] + pub class_id: Option, + + /// `vendor` (key 1): e.g. "Intel", "AMD", "Microsoft". + #[cbor(key = 1, optional)] + pub vendor: Option, + + /// `model` (key 2): e.g. "TDX", "SEV-SNP", "VBS-CVM". + #[cbor(key = 2, optional)] + pub model: Option, + + /// `layer` (key 3): optional layer number. + #[cbor(key = 3, optional)] + pub layer: Option, + + /// `index` (key 4): optional index number. + #[cbor(key = 4, optional)] + pub index: Option, +} + +impl ClassMap { + /// Create a class-map with vendor and model (the most common case). + /// + /// Other fields default to `None`. + pub fn new(vendor: impl Into, model: impl Into) -> Self { + Self { + class_id: None, + vendor: Some(vendor.into()), + model: Some(model.into()), + layer: None, + index: None, + } + } +} + +impl Validate for ClassMap { + fn valid(&self) -> Result<(), String> { + // CDDL: non-empty<{ ?class-id, ?vendor, ?model, ?layer, ?index }> + if self.class_id.is_none() + && self.vendor.is_none() + && self.model.is_none() + && self.layer.is_none() + && self.index.is_none() + { + return Err("class must not be empty".into()); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// environment-map { class: 0, instance: 1, group: 2 } +// --------------------------------------------------------------------------- + +/// `environment-map` — identifies a Target Environment. +/// +/// CDDL: `non-empty<{ ?0 => class-map, ?1 => instance-id, ?2 => group-id }>` +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +#[cbor(non_empty)] +pub struct EnvironmentMap { + /// `class` (key 0): component class. + #[cbor(key = 0, optional)] + pub class: Option, + + /// `instance` (key 1): specific instance identifier. + #[cbor(key = 1, optional)] + pub instance: Option, + + /// `group` (key 2): group identifier. + #[cbor(key = 2, optional)] + pub group: Option, +} + +impl EnvironmentMap { + /// Create an environment-map with a class (vendor + model). + /// + /// This is the most common pattern for identifying a Target Environment. + pub fn for_class(vendor: impl Into, model: impl Into) -> Self { + Self { + class: Some(ClassMap::new(vendor, model)), + instance: None, + group: None, + } + } +} + +impl Validate for EnvironmentMap { + fn valid(&self) -> Result<(), String> { + // CDDL: non-empty<{ ?class, ?instance, ?group }> + if self.class.is_none() && self.instance.is_none() && self.group.is_none() { + return Err("environment must not be empty".into()); + } + if let Some(ref class) = self.class { + class + .valid() + .map_err(|e| format!("class validation failed: {e}"))?; + } + Ok(()) + } +} diff --git a/corim/src/types/measurement.rs b/corim/src/types/measurement.rs new file mode 100644 index 0000000..3a37667 --- /dev/null +++ b/corim/src/types/measurement.rs @@ -0,0 +1,671 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Measurement types: `measurement-map`, `measurement-values-map`, +//! `digest`, `svn-type-choice`, `flags-map`, `integrity-registers`, +//! `int-range`, and address types. + +use std::collections::BTreeMap; + +use corim_macros::{CborDeserialize, CborSerialize}; +use serde::{Deserialize, Serialize}; + +use super::common::{CryptoKey, MeasuredElement, VersionMap}; +use super::tags::*; +use crate::cbor::value::{self, Value}; +use crate::Validate; + +// --------------------------------------------------------------------------- +// digest = [alg: int, val: bytes] +// --------------------------------------------------------------------------- + +/// `eatmc.digest` — a `[algorithm-id, digest-value]` pair. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Digest(pub i64, #[serde(with = "serde_bytes")] pub Vec); + +impl Digest { + /// Create a new digest. + pub fn new(alg: i64, value: Vec) -> Self { + Self(alg, value) + } + /// Get the algorithm identifier. + pub fn alg(&self) -> i64 { + self.0 + } + /// Get the digest value bytes. + pub fn value(&self) -> &[u8] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// svn-type-choice = svn / tagged-svn / tagged-min-svn +// --------------------------------------------------------------------------- + +/// `svn-type-choice` — SVN with exact-value or minimum-value semantics. +/// +/// - Untagged `uint` or `#6.552(uint)`: exact SVN. +/// - `#6.553(uint)`: minimum SVN. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SvnChoice { + /// Exact SVN value (untagged or tag 552). + ExactValue(u64), + /// Minimum acceptable SVN (tag 553). + MinValue(u64), +} + +impl Serialize for SvnChoice { + fn serialize(&self, s: S) -> Result { + match self { + SvnChoice::ExactValue(v) => value::serialize_tagged(TAG_SVN, v, s), + SvnChoice::MinValue(v) => value::serialize_tagged(TAG_MIN_SVN, v, s), + } + } +} + +impl<'de> Deserialize<'de> for SvnChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_SVN, inner) => { + let n = inner + .into_integer() + .ok_or_else(|| serde::de::Error::custom("tag 552 must wrap uint"))?; + Ok(SvnChoice::ExactValue(n.try_into().map_err(|_| { + serde::de::Error::custom("SVN must be unsigned") + })?)) + } + Value::Tag(TAG_MIN_SVN, inner) => { + let n = inner + .into_integer() + .ok_or_else(|| serde::de::Error::custom("tag 553 must wrap uint"))?; + Ok(SvnChoice::MinValue(n.try_into().map_err(|_| { + serde::de::Error::custom("min-SVN must be unsigned") + })?)) + } + Value::Integer(i) => { + Ok(SvnChoice::ExactValue(i.try_into().map_err(|_| { + serde::de::Error::custom("SVN must be unsigned") + })?)) + } + _ => Err(serde::de::Error::custom( + "expected uint, tag 552, or tag 553", + )), + } + } +} + +// --------------------------------------------------------------------------- +// flags-map +// --------------------------------------------------------------------------- + +/// `flags-map` — boolean operational mode flags. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +#[cbor(non_empty)] +pub struct FlagsMap { + /// `is-configured` (key 0). + #[cbor(key = 0, optional)] + pub is_configured: Option, + /// `is-secure` (key 1). + #[cbor(key = 1, optional)] + pub is_secure: Option, + /// `is-recovery` (key 2). + #[cbor(key = 2, optional)] + pub is_recovery: Option, + /// `is-debug` (key 3). + #[cbor(key = 3, optional)] + pub is_debug: Option, + /// `is-replay-protected` (key 4). + #[cbor(key = 4, optional)] + pub is_replay_protected: Option, + /// `is-integrity-protected` (key 5). + #[cbor(key = 5, optional)] + pub is_integrity_protected: Option, + /// `is-runtime-meas` (key 6). + #[cbor(key = 6, optional)] + pub is_runtime_meas: Option, + /// `is-immutable` (key 7). + #[cbor(key = 7, optional)] + pub is_immutable: Option, + /// `is-tcb` (key 8). + #[cbor(key = 8, optional)] + pub is_tcb: Option, + /// `is-confidentiality-protected` (key 9). + #[cbor(key = 9, optional)] + pub is_confidentiality_protected: Option, +} + +// --------------------------------------------------------------------------- +// raw-value-type-choice +// --------------------------------------------------------------------------- + +/// `$raw-value-type-choice` — tagged bytes or masked raw value. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum RawValueChoice { + /// Plain bytes (CBOR tag 560). + Bytes(Vec), + /// Masked value `[value, mask]` (CBOR tag 563). + Masked { + /// The raw value bytes. + value: Vec, + /// The comparison mask bytes. + mask: Vec, + }, +} + +impl Serialize for RawValueChoice { + fn serialize(&self, s: S) -> Result { + match self { + RawValueChoice::Bytes(b) => value::serialize_tagged_bytes(TAG_BYTES, b, s), + RawValueChoice::Masked { value, mask } => { + let arr = Value::Array(vec![ + Value::Bytes(value.clone()), + Value::Bytes(mask.clone()), + ]); + value::serialize_tagged(TAG_MASKED_RAW_VALUE, &arr, s) + } + } + } +} + +impl<'de> Deserialize<'de> for RawValueChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Tag(TAG_BYTES, inner) => match *inner { + Value::Bytes(b) => Ok(RawValueChoice::Bytes(b)), + _ => Err(serde::de::Error::custom("tag 560 must wrap bytes")), + }, + Value::Tag(TAG_MASKED_RAW_VALUE, inner) => match *inner { + Value::Array(mut a) if a.len() == 2 => { + let mask = match a.pop().unwrap() { + Value::Bytes(b) => b, + _ => return Err(serde::de::Error::custom("mask must be bytes")), + }; + let value = match a.pop().unwrap() { + Value::Bytes(b) => b, + _ => return Err(serde::de::Error::custom("value must be bytes")), + }; + Ok(RawValueChoice::Masked { value, mask }) + } + _ => Err(serde::de::Error::custom("tag 563 must wrap [value, mask]")), + }, + _ => Err(serde::de::Error::custom("expected tag 560 or 563")), + } + } +} + +// --------------------------------------------------------------------------- +// mac-addr-type-choice +// --------------------------------------------------------------------------- + +/// `mac-addr-type-choice` — EUI-48 or EUI-64 MAC address. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum MacAddr { + /// EUI-48 (6 bytes). + Eui48([u8; 6]), + /// EUI-64 (8 bytes). + Eui64([u8; 8]), +} + +impl Serialize for MacAddr { + fn serialize(&self, s: S) -> Result { + match self { + MacAddr::Eui48(b) => s.serialize_bytes(b), + MacAddr::Eui64(b) => s.serialize_bytes(b), + } + } +} + +impl<'de> Deserialize<'de> for MacAddr { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Bytes(b) if b.len() == 6 => { + let arr: [u8; 6] = b.try_into().unwrap(); + Ok(MacAddr::Eui48(arr)) + } + Value::Bytes(b) if b.len() == 8 => { + let arr: [u8; 8] = b.try_into().unwrap(); + Ok(MacAddr::Eui64(arr)) + } + Value::Bytes(_) => Err(serde::de::Error::custom("MAC address must be 6 or 8 bytes")), + _ => Err(serde::de::Error::custom("expected bytes for MAC address")), + } + } +} + +// --------------------------------------------------------------------------- +// ip-addr-type-choice +// --------------------------------------------------------------------------- + +/// `ip-addr-type-choice` — IPv4 or IPv6 address. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum IpAddr { + /// IPv4 address (4 bytes). + V4([u8; 4]), + /// IPv6 address (16 bytes). + V6([u8; 16]), +} + +impl Serialize for IpAddr { + fn serialize(&self, s: S) -> Result { + match self { + IpAddr::V4(b) => s.serialize_bytes(b), + IpAddr::V6(b) => s.serialize_bytes(b), + } + } +} + +impl<'de> Deserialize<'de> for IpAddr { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Bytes(b) if b.len() == 4 => { + let arr: [u8; 4] = b.try_into().unwrap(); + Ok(IpAddr::V4(arr)) + } + Value::Bytes(b) if b.len() == 16 => { + let arr: [u8; 16] = b.try_into().unwrap(); + Ok(IpAddr::V6(arr)) + } + Value::Bytes(_) => Err(serde::de::Error::custom("IP address must be 4 or 16 bytes")), + _ => Err(serde::de::Error::custom("expected bytes for IP address")), + } + } +} + +// --------------------------------------------------------------------------- +// int-range-type-choice +// --------------------------------------------------------------------------- + +/// `int-range-type-choice` — integer or tagged int range. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum IntRangeChoice { + /// A single integer value. + Int(i64), + /// A range `[min, max]` (CBOR tag 564). `None` represents infinity. + Range { + /// Minimum (inclusive), or `None` for negative infinity. + min: Option, + /// Maximum (inclusive), or `None` for positive infinity. + max: Option, + }, +} + +impl Serialize for IntRangeChoice { + fn serialize(&self, s: S) -> Result { + match self { + IntRangeChoice::Int(v) => s.serialize_i64(*v), + IntRangeChoice::Range { min, max } => { + let min_val = match min { + Some(n) => Value::Integer(*n as i128), + None => Value::Null, + }; + let max_val = match max { + Some(n) => Value::Integer(*n as i128), + None => Value::Null, + }; + let arr = Value::Array(vec![min_val, max_val]); + value::serialize_tagged(TAG_INT_RANGE, &arr, s) + } + } + } +} + +impl<'de> Deserialize<'de> for IntRangeChoice { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Integer(n) => { + Ok(IntRangeChoice::Int(i64::try_from(n).map_err(|_| { + serde::de::Error::custom("int-range value out of i64 range") + })?)) + } + Value::Tag(TAG_INT_RANGE, inner) => match *inner { + Value::Array(a) if a.len() == 2 => { + let min = match &a[0] { + Value::Null => None, + Value::Integer(n) => Some(i64::try_from(*n).map_err(|_| { + serde::de::Error::custom("int-range min out of i64 range") + })?), + _ => { + return Err(serde::de::Error::custom( + "int-range min must be int or null", + )) + } + }; + let max = match &a[1] { + Value::Null => None, + Value::Integer(n) => Some(i64::try_from(*n).map_err(|_| { + serde::de::Error::custom("int-range max out of i64 range") + })?), + _ => { + return Err(serde::de::Error::custom( + "int-range max must be int or null", + )) + } + }; + Ok(IntRangeChoice::Range { min, max }) + } + _ => Err(serde::de::Error::custom("tag 564 must wrap [min, max]")), + }, + _ => Err(serde::de::Error::custom("expected int or tag 564")), + } + } +} + +// --------------------------------------------------------------------------- +// integrity-registers +// --------------------------------------------------------------------------- + +/// `integrity-register-id-type-choice` — uint or text key. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum IntegrityRegisterId { + /// Unsigned integer register ID. + Uint(u64), + /// Text register ID. + Text(String), +} + +impl Serialize for IntegrityRegisterId { + fn serialize(&self, s: S) -> Result { + match self { + IntegrityRegisterId::Uint(n) => s.serialize_u64(*n), + IntegrityRegisterId::Text(t) => s.serialize_str(t), + } + } +} + +impl<'de> Deserialize<'de> for IntegrityRegisterId { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Integer(n) => { + Ok(IntegrityRegisterId::Uint(n.try_into().map_err(|_| { + serde::de::Error::custom("register id must be unsigned") + })?)) + } + Value::Text(t) => Ok(IntegrityRegisterId::Text(t)), + _ => Err(serde::de::Error::custom( + "expected uint or text for register id", + )), + } + } +} + +/// `integrity-registers` — map of register IDs to digest lists. +/// +/// CDDL: `{+ integrity-register-id-type-choice => digests-type}` +#[derive(Clone, Debug, PartialEq)] +pub struct IntegrityRegisters(pub BTreeMap>); + +impl Serialize for IntegrityRegisters { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeMap; + let mut map = s.serialize_map(Some(self.0.len()))?; + for (k, v) in &self.0 { + map.serialize_entry(k, v)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for IntegrityRegisters { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + match val { + Value::Map(entries) => { + let mut map = BTreeMap::new(); + for (k, v) in entries { + let key = match k { + Value::Integer(n) => { + IntegrityRegisterId::Uint(n.try_into().map_err(|_| { + serde::de::Error::custom("register id must be unsigned") + })?) + } + Value::Text(t) => IntegrityRegisterId::Text(t), + _ => { + return Err(serde::de::Error::custom( + "register id must be uint or text", + )) + } + }; + let digests: Vec = match v { + Value::Array(arr) => { + let mut ds = Vec::new(); + for item in arr { + match item { + Value::Array(pair) if pair.len() == 2 => { + let mut it = pair.into_iter(); + let alg = match it.next().unwrap() { + Value::Integer(n) => { + i64::try_from(n).map_err(|_| { + serde::de::Error::custom( + "digest alg out of i64 range", + ) + })? + } + _ => { + return Err(serde::de::Error::custom( + "digest alg must be int", + )) + } + }; + let val = match it.next().unwrap() { + Value::Bytes(b) => b, + _ => { + return Err(serde::de::Error::custom( + "digest val must be bytes", + )) + } + }; + ds.push(Digest::new(alg, val)); + } + _ => { + return Err(serde::de::Error::custom( + "digest must be [alg, val]", + )) + } + } + } + ds + } + _ => return Err(serde::de::Error::custom("digests must be an array")), + }; + map.insert(key, digests); + } + Ok(IntegrityRegisters(map)) + } + _ => Err(serde::de::Error::custom( + "expected map for integrity-registers", + )), + } + } +} + +// --------------------------------------------------------------------------- +// measurement-values-map +// --------------------------------------------------------------------------- + +/// `measurement-values-map` — all possible measurement value fields. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +#[cbor(non_empty)] +pub struct MeasurementValuesMap { + /// `version` (key 0). + #[cbor(key = 0, optional)] + pub version: Option, + /// `svn` (key 1). + #[cbor(key = 1, optional)] + pub svn: Option, + /// `digests` (key 2). + #[cbor(key = 2, optional)] + pub digests: Option>, + /// `flags` (key 3). + #[cbor(key = 3, optional)] + pub flags: Option, + /// `raw-value` (key 4). + #[cbor(key = 4, optional)] + pub raw_value: Option, + /// `mac-addr` (key 6). + #[cbor(key = 6, optional)] + pub mac_addr: Option, + /// `ip-addr` (key 7). + #[cbor(key = 7, optional)] + pub ip_addr: Option, + /// `serial-number` (key 8). + #[cbor(key = 8, optional)] + pub serial_number: Option, + /// `ueid` (key 9). + #[cbor(key = 9, optional)] + pub ueid: Option>, + /// `uuid` (key 10). + #[cbor(key = 10, optional)] + pub uuid: Option>, + /// `name` (key 11). + #[cbor(key = 11, optional)] + pub name: Option, + /// `cryptokeys` (key 13). + #[cbor(key = 13, optional)] + pub cryptokeys: Option>, + /// `integrity-registers` (key 14). + #[cbor(key = 14, optional)] + pub integrity_registers: Option, + /// `int-range` (key 15). + #[cbor(key = 15, optional)] + pub int_range: Option, +} + +impl MeasurementValuesMap { + /// Create a new empty `MeasurementValuesMap`. + /// + /// Note: encoding will fail due to `non_empty` unless at least one field is set. + pub fn new() -> Self { + Self { + version: None, + svn: None, + digests: None, + flags: None, + raw_value: None, + mac_addr: None, + ip_addr: None, + serial_number: None, + ueid: None, + uuid: None, + name: None, + cryptokeys: None, + integrity_registers: None, + int_range: None, + } + } +} + +impl Default for MeasurementValuesMap { + fn default() -> Self { + Self::new() + } +} + +impl Validate for MeasurementValuesMap { + fn valid(&self) -> Result<(), String> { + // CDDL: non-empty<{ ... }> + if self.version.is_none() + && self.svn.is_none() + && self.digests.is_none() + && self.flags.is_none() + && self.raw_value.is_none() + && self.mac_addr.is_none() + && self.ip_addr.is_none() + && self.serial_number.is_none() + && self.ueid.is_none() + && self.uuid.is_none() + && self.name.is_none() + && self.cryptokeys.is_none() + && self.integrity_registers.is_none() + && self.int_range.is_none() + { + return Err("no measurement value set".into()); + } + // Validate digests if present: at least one digest required + if let Some(ref digests) = self.digests { + if digests.is_empty() { + return Err("digests list must not be empty".into()); + } + } + Ok(()) + } +} + +impl Validate for MeasurementMap { + fn valid(&self) -> Result<(), String> { + self.mval + .valid() + .map_err(|e| format!("measurement values: {e}"))?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// measurement-map +// --------------------------------------------------------------------------- + +/// `measurement-map` — a single measurement within a triple. +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +pub struct MeasurementMap { + /// `mkey` (key 0): optional measurement key. + #[cbor(key = 0, optional)] + pub mkey: Option, + /// `mval` (key 1): measurement values. + #[cbor(key = 1)] + pub mval: MeasurementValuesMap, + /// `authorized-by` (key 2): optional authority keys. + #[cbor(key = 2, optional)] + pub authorized_by: Option>, +} + +/// Serde helper for bytes fields in Digest. +mod serde_bytes { + use crate::cbor::value::Value; + use serde::{self, Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bytes(bytes) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let val = Value::deserialize(deserializer)?; + match val { + Value::Bytes(b) => Ok(b), + Value::Array(arr) => { + let mut bytes = Vec::with_capacity(arr.len()); + for v in arr { + match v { + Value::Integer(i) => { + let b: u8 = i + .try_into() + .map_err(|_| serde::de::Error::custom("byte value out of range"))?; + bytes.push(b); + } + _ => { + return Err(serde::de::Error::custom("expected integer in byte array")) + } + } + } + Ok(bytes) + } + _ => Err(serde::de::Error::custom("expected bytes")), + } + } +} diff --git a/corim/src/types/mod.rs b/corim/src/types/mod.rs new file mode 100644 index 0000000..6cf4fdd --- /dev/null +++ b/corim/src/types/mod.rs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CDDL-derived Rust types for CoRIM / CoMID. +//! +//! The type hierarchy mirrors the CDDL productions in `corim.cddl`. +//! Map types use `CborSerialize`/`CborDeserialize` derives for integer-keyed +//! CBOR encoding. Array types (triple records) use standard serde derives. + +pub mod comid; +pub mod common; +pub mod corim; +pub mod coswid; +pub mod environment; +pub mod measurement; +pub mod signed; +pub mod tags; +pub mod triples; + +// Selective re-exports of the most commonly used types. +// Users can always access the full set via the submodules (e.g., `types::common::*`). +pub use self::comid::ComidTag; +pub use self::common::{ + CborTime, ClassIdChoice, CryptoKey, EntityMap, GroupIdChoice, InstanceIdChoice, LinkedTagMap, + MeasuredElement, TagIdChoice, TagIdentity, ValidityMap, VersionMap, +}; +pub use self::corim::{ + ConciseTagChoice, ConciseTlTag, CorimId, CorimLocator, CorimMap, CorimMetaMap, CorimSignerMap, + ProfileChoice, +}; +pub use self::coswid::{ConciseSwidTag, SwidEntity, SwidLink}; +pub use self::environment::{ClassMap, EnvironmentMap}; +pub use self::measurement::{ + Digest, FlagsMap, IntRangeChoice, IntegrityRegisters, IpAddr, MacAddr, MeasurementMap, + MeasurementValuesMap, RawValueChoice, SvnChoice, +}; +pub use self::signed::{CoseSign1Corim, CwtClaims, ProtectedCorimHeaderMap, SignedCorimBuilder}; +pub use self::triples::{ + AttestKeyTriple, CesCondition, ConditionalEndorsementSeriesTriple, + ConditionalEndorsementTriple, ConditionalSeriesRecord, CoswidTriple, DomainDependencyTriple, + DomainMembershipTriple, EndorsedTriple, IdentityTriple, KeyTripleConditions, ReferenceTriple, + StatefulEnvironmentRecord, TriplesMap, +}; diff --git a/corim/src/types/signed.rs b/corim/src/types/signed.rs new file mode 100644 index 0000000..8f05d44 --- /dev/null +++ b/corim/src/types/signed.rs @@ -0,0 +1,1024 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Signed CoRIM (`#6.18(COSE-Sign1-corim)`) types per §4.2. +//! +//! Provides types for parsing and constructing signed CoRIM documents +//! without requiring any cryptographic dependencies. The caller performs +//! the actual signature creation/verification externally. +//! +//! # Wire format +//! +//! ```text +//! signed-corim = #6.18(COSE-Sign1-corim) +//! +//! COSE-Sign1-corim = [ +//! protected: bstr .cbor protected-corim-header-map, +//! unprotected: unprotected-corim-header-map, +//! payload: bstr .cbor tagged-unsigned-corim-map / nil, +//! signature: bstr, +//! ] +//! ``` + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::corim::CorimMetaMap; +use super::tags::*; +use crate::cbor; +use crate::cbor::value::Value; +use crate::Validate; + +// =================================================================== +// COSE Header Label Constants (RFC 9052 / draft-ietf-rats-corim-10 §4.2) +// =================================================================== + +/// COSE header: `alg` (key 1) — Algorithm identifier. +pub const COSE_HEADER_ALG: i64 = 1; +/// COSE header: `content-type` (key 3). +pub const COSE_HEADER_CONTENT_TYPE: i64 = 3; +/// CoRIM protected header: `corim-meta` (key 8). +pub const COSE_HEADER_CORIM_META: i64 = 8; +/// CoRIM protected header: `CWT-Claims` (key 15) per RFC 9597. +pub const COSE_HEADER_CWT_CLAIMS: i64 = 15; +/// COSE Hash Envelope: `payload_hash_alg` (key 258). +pub const COSE_HEADER_PAYLOAD_HASH_ALG: i64 = 258; +/// COSE Hash Envelope: `payload_preimage_content_type` (key 259). +pub const COSE_HEADER_PAYLOAD_PREIMAGE_CT: i64 = 259; +/// COSE Hash Envelope: `payload_location` (key 260). +pub const COSE_HEADER_PAYLOAD_LOCATION: i64 = 260; + +// =================================================================== +// CWT Claim Keys (RFC 8392 §4) +// =================================================================== + +/// CWT claim: `iss` (key 1) — Issuer. +const CWT_CLAIM_ISS: i64 = 1; +/// CWT claim: `sub` (key 2) — Subject. +const CWT_CLAIM_SUB: i64 = 2; +/// CWT claim: `exp` (key 4) — Expiration Time. +const CWT_CLAIM_EXP: i64 = 4; +/// CWT claim: `nbf` (key 5) — Not Before. +const CWT_CLAIM_NBF: i64 = 5; + +/// Expected `content-type` value for inline CoRIM signing. +pub const CORIM_CONTENT_TYPE: &str = "application/rim+cbor"; + +/// COSE `Sig_structure1` context string (RFC 9052 §4.4). +const SIG_STRUCTURE1_CONTEXT: &str = "Signature1"; + +// =================================================================== +// CWT Claims (RFC 8392 / RFC 9597) +// =================================================================== + +/// CWT Claims map, used in the protected header of a signed CoRIM (§4.2.2). +/// +/// ```text +/// cwt-claims = { +/// &(iss: 1) => tstr, +/// ? &(sub: 2) => tstr, +/// ? &(exp: 4) => int / float, +/// ? &(nbf: 5) => int / float, +/// * int => any, +/// } +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct CwtClaims { + /// `iss` (key 1): Issuer — identifies the CoRIM signer. + pub iss: String, + /// `sub` (key 2): Subject — optional, identifies the CoRIM document. + pub sub: Option, + /// `exp` (key 4): Expiration time as epoch seconds. + pub exp: Option, + /// `nbf` (key 5): Not-before time as epoch seconds. + pub nbf: Option, + /// Additional CWT claims beyond the standard ones. + pub extra: BTreeMap, +} + +impl CwtClaims { + /// Create a new `CwtClaims` with just the required issuer. + pub fn new(iss: impl Into) -> Self { + Self { + iss: iss.into(), + sub: None, + exp: None, + nbf: None, + extra: BTreeMap::new(), + } + } + + /// Set the subject. + pub fn with_sub(mut self, sub: impl Into) -> Self { + self.sub = Some(sub.into()); + self + } + + /// Set the expiration time. + pub fn with_exp(mut self, exp: i64) -> Self { + self.exp = Some(exp); + self + } + + /// Set the not-before time. + pub fn with_nbf(mut self, nbf: i64) -> Self { + self.nbf = Some(nbf); + self + } +} + +impl Serialize for CwtClaims { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeMap; + // Count entries + let mut count = 1; // iss is required + if self.sub.is_some() { + count += 1; + } + if self.exp.is_some() { + count += 1; + } + if self.nbf.is_some() { + count += 1; + } + count += self.extra.len(); + + let mut map = s.serialize_map(Some(count))?; + map.serialize_entry(&CWT_CLAIM_ISS, &self.iss)?; + if let Some(ref sub) = self.sub { + map.serialize_entry(&CWT_CLAIM_SUB, sub)?; + } + if let Some(exp) = self.exp { + map.serialize_entry(&CWT_CLAIM_EXP, &exp)?; + } + if let Some(nbf) = self.nbf { + map.serialize_entry(&CWT_CLAIM_NBF, &nbf)?; + } + for (k, v) in &self.extra { + map.serialize_entry(k, v)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for CwtClaims { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + let map = match val { + Value::Map(m) => m, + _ => return Err(serde::de::Error::custom("cwt-claims must be a map")), + }; + + let mut iss: Option = None; + let mut sub: Option = None; + let mut exp: Option = None; + let mut nbf: Option = None; + let mut extra = BTreeMap::new(); + + for (k, v) in map { + let key = match &k { + Value::Integer(n) => i64::try_from(*n) + .map_err(|_| serde::de::Error::custom("cwt key out of range"))?, + _ => { + // Non-integer keys: skip + continue; + } + }; + match key { + CWT_CLAIM_ISS => { + iss = Some(match v { + Value::Text(t) => t, + _ => return Err(serde::de::Error::custom("iss must be tstr")), + }); + } + CWT_CLAIM_SUB => { + sub = Some(match v { + Value::Text(t) => t, + _ => return Err(serde::de::Error::custom("sub must be tstr")), + }); + } + CWT_CLAIM_EXP => { + exp = Some(value_to_epoch(&v).map_err(serde::de::Error::custom)?); + } + CWT_CLAIM_NBF => { + nbf = Some(value_to_epoch(&v).map_err(serde::de::Error::custom)?); + } + _ => { + extra.insert(key, v); + } + } + } + + let iss = iss.ok_or_else(|| serde::de::Error::custom("cwt-claims: missing iss (key 1)"))?; + Ok(CwtClaims { + iss, + sub, + exp, + nbf, + extra, + }) + } +} + +/// Convert a Value (integer or float) to epoch seconds. +fn value_to_epoch(v: &Value) -> Result { + match v { + Value::Integer(n) => i64::try_from(*n).map_err(|_| "epoch time out of i64 range".into()), + Value::Float(f) => { + let n = *f; + // Reject NaN, infinity, and values outside i64 range before cast. + if n.is_nan() || n.is_infinite() || n < (i64::MIN as f64) || n > (i64::MAX as f64) { + return Err("epoch float out of i64 range".into()); + } + Ok(n as i64) + } + _ => Err("epoch time must be int or float".into()), + } +} + +// =================================================================== +// Protected CoRIM Header Map (§4.2.1) +// =================================================================== + +/// Protected CoRIM header map (§4.2.1). +/// +/// Contains the algorithm identifier, content type, and signer metadata. +/// Supports both inline signing and hash-envelope modes. +/// +/// ```text +/// protected-corim-header-map-inline = { +/// &(alg: 1) => int, +/// &(content-type: 3) => "application/rim+cbor", +/// meta-group, +/// * cose-label => cose-value, +/// } +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct ProtectedCorimHeaderMap { + /// COSE algorithm identifier (key 1). + pub alg: i64, + /// Content type (key 3) — present for inline signing. + /// Should be "application/rim+cbor" per the spec. + pub content_type: Option, + + // Hash-envelope fields (draft-ietf-cose-hash-envelope) + /// `payload_hash_alg` (key 258) — hash algorithm for hash-envelope mode. + pub payload_hash_alg: Option, + /// `payload_preimage_content_type` (key 259) — content type for hash-envelope mode. + pub payload_preimage_content_type: Option, + /// `payload_location` (key 260) — resource locator for hash-envelope mode. + pub payload_location: Option, + + /// `corim-meta` (key 8): Metadata about the CoRIM signer (legacy). + /// Stored as the decoded `CorimMetaMap`. + pub corim_meta: Option, + /// `CWT-Claims` (key 15): CWT claims identifying the signer. + pub cwt_claims: Option, + + /// Any additional COSE header labels not explicitly modeled above. + pub extra: BTreeMap, +} + +impl ProtectedCorimHeaderMap { + /// Check whether this header uses hash-envelope mode. + pub fn is_hash_envelope(&self) -> bool { + self.payload_hash_alg.is_some() + } +} + +impl Serialize for ProtectedCorimHeaderMap { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeMap; + + let mut count = 1; // alg is required + if self.content_type.is_some() { + count += 1; + } + if self.payload_hash_alg.is_some() { + count += 1; + } + if self.payload_preimage_content_type.is_some() { + count += 1; + } + if self.payload_location.is_some() { + count += 1; + } + if self.corim_meta.is_some() { + count += 1; + } + if self.cwt_claims.is_some() { + count += 1; + } + count += self.extra.len(); + + let mut map = s.serialize_map(Some(count))?; + map.serialize_entry(&COSE_HEADER_ALG, &self.alg)?; + if let Some(ref ct) = self.content_type { + map.serialize_entry(&COSE_HEADER_CONTENT_TYPE, ct)?; + } + if let Some(ref meta) = self.corim_meta { + // corim-meta is CBOR-encoded as bstr .cbor corim-meta-map + let meta_bytes = + cbor::encode(meta).map_err(|e| serde::ser::Error::custom(e.to_string()))?; + // Wrap in a Value::Bytes for proper CBOR bstr encoding + let meta_val = Value::Bytes(meta_bytes); + map.serialize_entry(&COSE_HEADER_CORIM_META, &meta_val)?; + } + if let Some(ref claims) = self.cwt_claims { + map.serialize_entry(&COSE_HEADER_CWT_CLAIMS, claims)?; + } + if let Some(alg) = self.payload_hash_alg { + map.serialize_entry(&COSE_HEADER_PAYLOAD_HASH_ALG, &alg)?; + } + if let Some(ref ct) = self.payload_preimage_content_type { + map.serialize_entry(&COSE_HEADER_PAYLOAD_PREIMAGE_CT, ct)?; + } + if let Some(ref loc) = self.payload_location { + map.serialize_entry(&COSE_HEADER_PAYLOAD_LOCATION, &loc)?; + } + for (k, v) in &self.extra { + map.serialize_entry(k, v)?; + } + map.end() + } +} + +impl<'de> Deserialize<'de> for ProtectedCorimHeaderMap { + fn deserialize>(d: D) -> Result { + let val = Value::deserialize(d)?; + let map = match val { + Value::Map(m) => m, + _ => { + return Err(serde::de::Error::custom( + "protected header must be a CBOR map", + )) + } + }; + + let mut alg: Option = None; + let mut content_type: Option = None; + let mut payload_hash_alg: Option = None; + let mut payload_preimage_content_type: Option = None; + let mut payload_location: Option = None; + let mut corim_meta: Option = None; + let mut cwt_claims: Option = None; + let mut extra = BTreeMap::new(); + + for (k, v) in map { + let key = match &k { + Value::Integer(n) => i64::try_from(*n) + .map_err(|_| serde::de::Error::custom("header key out of range"))?, + Value::Text(_) => { + // Text COSE labels are valid per RFC 9052 but not modeled; + // skip since our extra map uses i64 keys only. + continue; + } + _ => continue, + }; + match key { + COSE_HEADER_ALG => { + alg = Some(match &v { + Value::Integer(n) => i64::try_from(*n) + .map_err(|_| serde::de::Error::custom("alg out of range"))?, + _ => return Err(serde::de::Error::custom("alg must be int")), + }); + } + COSE_HEADER_CONTENT_TYPE => { + content_type = Some(match v { + Value::Text(t) => t, + _ => return Err(serde::de::Error::custom("content-type must be tstr")), + }); + } + COSE_HEADER_CORIM_META => { + // bstr .cbor corim-meta-map + let meta_bytes = match v { + Value::Bytes(b) => b, + _ => return Err(serde::de::Error::custom("corim-meta must be bstr")), + }; + let meta: CorimMetaMap = cbor::decode(&meta_bytes).map_err(|e| { + serde::de::Error::custom(format!("corim-meta decode: {}", e)) + })?; + corim_meta = Some(meta); + } + COSE_HEADER_CWT_CLAIMS => { + // CWT-Claims is directly a map (not bstr-wrapped) + let claims: CwtClaims = cbor::value::from_value(&v).map_err(|e| { + serde::de::Error::custom(format!("cwt-claims decode: {}", e)) + })?; + cwt_claims = Some(claims); + } + COSE_HEADER_PAYLOAD_HASH_ALG => { + payload_hash_alg = Some(match &v { + Value::Integer(n) => i64::try_from(*n).map_err(|_| { + serde::de::Error::custom("payload_hash_alg out of range") + })?, + _ => return Err(serde::de::Error::custom("payload_hash_alg must be int")), + }); + } + COSE_HEADER_PAYLOAD_PREIMAGE_CT => { + payload_preimage_content_type = Some(match v { + Value::Text(t) => t, + _ => { + return Err(serde::de::Error::custom( + "payload_preimage_content_type must be tstr", + )) + } + }); + } + COSE_HEADER_PAYLOAD_LOCATION => { + payload_location = Some(match v { + Value::Text(t) => t, + _ => return Err(serde::de::Error::custom("payload_location must be tstr")), + }); + } + _ => { + extra.insert(key, v); + } + } + } + + let alg = + alg.ok_or_else(|| serde::de::Error::custom("protected header: missing alg (key 1)"))?; + + // meta-group validation: at least one of corim-meta or cwt-claims must be present + if corim_meta.is_none() && cwt_claims.is_none() { + return Err(serde::de::Error::custom( + "protected header: at least one of corim-meta (8) or CWT-Claims (15) must be present", + )); + } + + Ok(ProtectedCorimHeaderMap { + alg, + content_type, + payload_hash_alg, + payload_preimage_content_type, + payload_location, + corim_meta, + cwt_claims, + extra, + }) + } +} + +impl Validate for ProtectedCorimHeaderMap { + fn valid(&self) -> Result<(), String> { + // Must have at least one of corim-meta or cwt-claims + if self.corim_meta.is_none() && self.cwt_claims.is_none() { + return Err( + "protected header: at least one of corim-meta or CWT-Claims required".into(), + ); + } + + // For inline mode, content-type should be present + if !self.is_hash_envelope() && self.content_type.is_none() { + return Err("inline mode: content-type (key 3) is required".into()); + } + + // For hash-envelope mode, payload_preimage_content_type should be present + if self.is_hash_envelope() && self.payload_preimage_content_type.is_none() { + return Err( + "hash-envelope mode: payload_preimage_content_type (key 259) is required".into(), + ); + } + + // If both corim-meta and cwt-claims are present, validate consistency + // (§4.2.1: iss must match signer-name, nbf/exp must match signature-validity) + if let (Some(meta), Some(cwt)) = (&self.corim_meta, &self.cwt_claims) { + if meta.signer.signer_name != cwt.iss { + return Err(format!( + "corim-meta signer-name '{}' != cwt-claims iss '{}'", + meta.signer.signer_name, cwt.iss + )); + } + } + + Ok(()) + } +} + +// =================================================================== +// CoseSign1Corim — the decoded COSE_Sign1-corim structure +// =================================================================== + +/// A decoded `COSE_Sign1-corim` structure (§4.2). +/// +/// This is the parsed form of `#6.18([protected, unprotected, payload, signature])`. +/// The crypto verification is NOT performed by this crate — the caller must +/// verify the signature externally using the TBS and algorithm from the +/// protected header. +/// +/// # Decode flow +/// +/// ```text +/// bytes → CBOR tag 18 → 4-element array +/// [0] protected: bstr → decode as ProtectedCorimHeaderMap +/// [1] unprotected: map +/// [2] payload: bstr | nil +/// [3] signature: bstr +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct CoseSign1Corim { + /// The raw CBOR-encoded protected header bytes. + /// This is the exact `bstr` from the COSE structure, needed for + /// signature verification (it is signed as-is). + pub protected_header_bytes: Vec, + + /// The decoded protected header. + pub protected: ProtectedCorimHeaderMap, + + /// The unprotected header map. + /// Stored as CBOR map entries since it contains arbitrary COSE labels. + pub unprotected: Vec<(Value, Value)>, + + /// The payload bytes, or `None` if the payload is detached (nil). + /// When present, this is `bstr .cbor tagged-unsigned-corim-map`. + pub payload: Option>, + + /// The COSE signature bytes. + pub signature: Vec, +} + +impl CoseSign1Corim { + /// Construct the COSE `Sig_structure1` to-be-signed bytes per RFC 9052 §4.4. + /// + /// ```text + /// Sig_structure1 = [ + /// context : "Signature1", + /// body_protected : bstr, + /// external_aad : bstr, + /// payload : bstr, + /// ] + /// ``` + /// + /// For attached payloads, this uses the embedded payload. For detached + /// payloads (where `self.payload` is `None`), this returns an error — + /// use [`to_be_signed_detached`](Self::to_be_signed_detached) instead + /// to supply the payload externally. + /// + /// The `external_aad` is application-supplied additional authenticated data. + /// Pass `&[]` if not used. + /// + /// Returns the CBOR-encoded `Sig_structure1` bytes. + pub fn to_be_signed(&self, external_aad: &[u8]) -> Result, crate::EncodeError> { + let payload = self.payload.as_deref().ok_or_else(|| { + crate::EncodeError::Serialization( + "payload is detached (nil); use to_be_signed_detached() with the payload".into(), + ) + })?; + build_sig_structure1(&self.protected_header_bytes, external_aad, payload) + } + + /// Construct the COSE `Sig_structure1` TBS bytes for a **detached** payload. + /// + /// Per RFC 9052 §4.4, the `Sig_structure1` always contains the actual + /// payload bytes, even when the COSE_Sign1 envelope carries `nil`. + /// This method allows the caller to supply the detached payload for + /// TBS construction. + /// + /// Also works for attached payloads — the `detached_payload` parameter + /// takes precedence over any embedded payload. + pub fn to_be_signed_detached( + &self, + detached_payload: &[u8], + external_aad: &[u8], + ) -> Result, crate::EncodeError> { + build_sig_structure1(&self.protected_header_bytes, external_aad, detached_payload) + } + + /// Returns `true` if this envelope has a detached (nil) payload. + pub fn is_detached(&self) -> bool { + self.payload.is_none() + } +} + +/// Build a COSE `Sig_structure1` for signing (RFC 9052 §4.4). +/// +/// ```text +/// Sig_structure1 = [ +/// context : "Signature1", +/// body_protected : bstr, +/// external_aad : bstr, +/// payload : bstr, +/// ] +/// ``` +pub fn build_sig_structure1( + protected_header_bytes: &[u8], + external_aad: &[u8], + payload: &[u8], +) -> Result, crate::EncodeError> { + let sig_structure = Value::Array(vec![ + Value::Text(SIG_STRUCTURE1_CONTEXT.into()), + Value::Bytes(protected_header_bytes.to_vec()), + Value::Bytes(external_aad.to_vec()), + Value::Bytes(payload.to_vec()), + ]); + cbor::encode(&sig_structure) +} + +/// Encode a `CoseSign1Corim` into CBOR bytes with tag 18 wrapper. +/// +/// Produces `#6.18([protected, unprotected, payload, signature])`. +pub fn encode_signed_corim(signed: &CoseSign1Corim) -> Result, crate::EncodeError> { + let payload_val = match &signed.payload { + Some(p) => Value::Bytes(p.clone()), + None => Value::Null, + }; + + let arr = Value::Array(vec![ + Value::Bytes(signed.protected_header_bytes.clone()), + Value::Map(signed.unprotected.clone()), + payload_val, + Value::Bytes(signed.signature.clone()), + ]); + + let tagged = Value::Tag(TAG_SIGNED_CORIM, Box::new(arr)); + cbor::encode(&tagged) +} + +/// Decode CBOR bytes as a signed CoRIM (`#6.18(COSE_Sign1-corim)`). +/// +/// This does NOT verify the cryptographic signature. It only parses the +/// COSE_Sign1 structure and decodes the protected header. +/// +/// The caller should: +/// 1. Use [`CoseSign1Corim::to_be_signed`] to get the TBS bytes. +/// 2. Verify the signature using the algorithm from `protected.alg`. +/// 3. Use [`validate_signed_corim_payload`] to validate the payload. +pub fn decode_signed_corim(bytes: &[u8]) -> Result { + use crate::error::DecodeError; + + if bytes.len() > crate::validate::MAX_PAYLOAD_SIZE { + return Err(DecodeError::InvalidStructure(format!( + "payload too large: {} bytes (max {})", + bytes.len(), + crate::validate::MAX_PAYLOAD_SIZE, + ))); + } + + // Decode the top-level tagged value + let val: Value = cbor::decode(bytes) + .map_err(|e| DecodeError::Deserialization(format!("cannot decode CBOR: {}", e)))?; + + // Must be tag 18 + let (tag, inner) = match val { + Value::Tag(t, inner) => (t, *inner), + _ => { + return Err(DecodeError::InvalidStructure( + "expected CBOR tag 18 for signed-corim".into(), + )); + } + }; + if tag != TAG_SIGNED_CORIM { + return Err(DecodeError::UnexpectedTag { + expected: TAG_SIGNED_CORIM, + found: tag, + }); + } + + // Must be a 4-element array + let arr = match inner { + Value::Array(a) if a.len() == 4 => a, + Value::Array(a) => { + return Err(DecodeError::InvalidStructure(format!( + "COSE_Sign1 must be a 4-element array, got {}", + a.len() + ))); + } + _ => { + return Err(DecodeError::InvalidStructure( + "COSE_Sign1 must be an array".into(), + )); + } + }; + + let mut it = arr.into_iter(); + let protected_val = it.next().unwrap(); + let unprotected_val = it.next().unwrap(); + let payload_val = it.next().unwrap(); + let signature_val = it.next().unwrap(); + + // [0] protected: bstr + let protected_header_bytes = match protected_val { + Value::Bytes(b) => b, + _ => { + return Err(DecodeError::InvalidStructure( + "COSE_Sign1 protected must be bstr".into(), + )); + } + }; + + // Decode the protected header from the bstr + let protected: ProtectedCorimHeaderMap = cbor::decode(&protected_header_bytes) + .map_err(|e| DecodeError::InvalidStructure(format!("protected header decode: {}", e)))?; + + // [1] unprotected: map + let unprotected = match unprotected_val { + Value::Map(m) => m, + _ => { + return Err(DecodeError::InvalidStructure( + "COSE_Sign1 unprotected must be a map".into(), + )); + } + }; + + // [2] payload: bstr / nil + let payload = match payload_val { + Value::Bytes(b) => Some(b), + Value::Null => None, + _ => { + return Err(DecodeError::InvalidStructure( + "COSE_Sign1 payload must be bstr or nil".into(), + )); + } + }; + + // [3] signature: bstr + let signature = match signature_val { + Value::Bytes(b) => b, + _ => { + return Err(DecodeError::InvalidStructure( + "COSE_Sign1 signature must be bstr".into(), + )); + } + }; + + Ok(CoseSign1Corim { + protected_header_bytes, + protected, + unprotected, + payload, + signature, + }) +} + +/// Validate the payload of a signed CoRIM without verifying the signature. +/// +/// For **attached** payloads, extracts the `tagged-unsigned-corim-map` from +/// the embedded payload bytes and runs structural validation. +/// +/// For **detached** payloads, returns an error — use +/// [`validate_signed_corim_payload_detached`] instead to supply the payload. +/// +/// This is useful when the caller has already verified the signature externally +/// and wants to inspect/validate the inner CoRIM. +pub fn validate_signed_corim_payload( + signed: &CoseSign1Corim, + now_epoch_secs: i64, +) -> Result { + let payload = signed.payload.as_ref().ok_or_else(|| { + crate::ValidationError::Invalid( + "signed CoRIM has detached (nil) payload; use validate_signed_corim_payload_detached()" + .into(), + ) + })?; + + // Validate the protected header structure + signed + .protected + .valid() + .map_err(crate::ValidationError::Invalid)?; + + // Delegate to the existing validation implementation + crate::validate::decode_and_validate_full_at(payload, now_epoch_secs) +} + +/// Validate a **detached** signed CoRIM payload without verifying the signature. +/// +/// The `detached_payload` parameter supplies the CoRIM payload that was +/// transported separately from the COSE_Sign1 envelope. +/// +/// The caller should verify the signature *before* calling this function: +/// 1. Reconstruct the TBS via +/// [`CoseSign1Corim::to_be_signed_detached(detached_payload, &external_aad)`]. +/// 2. Verify the signature using the algorithm from `protected.alg`. +/// 3. Call this function with the same `detached_payload` to validate the +/// inner CoRIM structure. +pub fn validate_signed_corim_payload_detached( + signed: &CoseSign1Corim, + detached_payload: &[u8], + now_epoch_secs: i64, +) -> Result { + // Validate the protected header structure + signed + .protected + .valid() + .map_err(crate::ValidationError::Invalid)?; + + // Delegate to the existing validation implementation + crate::validate::decode_and_validate_full_at(detached_payload, now_epoch_secs) +} + +// =================================================================== +// SignedCorimBuilder +// =================================================================== + +/// Builder for constructing signed CoRIM documents. +/// +/// This builder creates the COSE_Sign1 structure without a cryptographic +/// signature. The caller uses [`to_be_signed`](SignedCorimBuilder::to_be_signed) +/// to obtain the data that must be signed externally, then calls +/// [`build_with_signature`](SignedCorimBuilder::build_with_signature) to produce +/// the final signed CoRIM bytes. +/// +/// # Example +/// +/// ```rust,no_run +/// use corim::types::signed::SignedCorimBuilder; +/// use corim::types::signed::CwtClaims; +/// use corim::builder::CorimBuilder; +/// use corim::types::corim::CorimId; +/// +/// // 1. Build the unsigned CoRIM payload +/// let corim_bytes = CorimBuilder::new(CorimId::Text("test".into())) +/// // ... add tags ... +/// # ; +/// +/// // 2. Create the signed CoRIM builder +/// # let corim_bytes = vec![]; +/// let mut builder = SignedCorimBuilder::new(-7, corim_bytes) // ES256 = -7 +/// .set_cwt_claims(CwtClaims::new("ACME Corp")); +/// +/// // 3. Get the TBS blob +/// let tbs = builder.to_be_signed(&[]).unwrap(); +/// +/// // 4. Sign externally (e.g., with ring, openssl, etc.) +/// let signature = vec![0u8; 64]; // placeholder +/// +/// // 5. Produce the final signed CoRIM +/// let signed_bytes = builder.build_with_signature(signature).unwrap(); +/// ``` +#[must_use] +pub struct SignedCorimBuilder { + alg: i64, + content_type: String, + corim_meta: Option, + cwt_claims: Option, + payload: Vec, + unprotected: Vec<(Value, Value)>, + extra_protected: BTreeMap, + // Cached protected header bytes (computed lazily) + cached_protected_bytes: Option>, +} + +impl SignedCorimBuilder { + /// Create a new builder with the specified COSE algorithm and CoRIM payload bytes. + /// + /// The `alg` parameter is the COSE algorithm identifier (e.g., -7 for ES256, + /// -35 for ES384, -36 for ES512, -257 for PS256). + /// + /// The `corim_payload` must be the CBOR-encoded `tagged-unsigned-corim-map` + /// (i.e., tag-501-wrapped bytes as produced by [`crate::builder::CorimBuilder::build_bytes`]). + pub fn new(alg: i64, corim_payload: Vec) -> Self { + Self { + alg, + content_type: CORIM_CONTENT_TYPE.into(), + corim_meta: None, + cwt_claims: None, + payload: corim_payload, + unprotected: Vec::new(), + extra_protected: BTreeMap::new(), + cached_protected_bytes: None, + } + } + + /// Set the `corim-meta` (key 8) in the protected header. + pub fn set_corim_meta(mut self, meta: CorimMetaMap) -> Self { + self.corim_meta = Some(meta); + self.cached_protected_bytes = None; + self + } + + /// Set the `CWT-Claims` (key 15) in the protected header. + pub fn set_cwt_claims(mut self, claims: CwtClaims) -> Self { + self.cwt_claims = Some(claims); + self.cached_protected_bytes = None; + self + } + + /// Override the content-type header value (default: "application/rim+cbor"). + pub fn set_content_type(mut self, ct: impl Into) -> Self { + self.content_type = ct.into(); + self.cached_protected_bytes = None; + self + } + + /// Add an entry to the unprotected header map. + pub fn add_unprotected(mut self, key: Value, value: Value) -> Self { + self.unprotected.push((key, value)); + self + } + + /// Add an extra entry to the protected header map. + pub fn add_protected(mut self, key: i64, value: Value) -> Self { + self.extra_protected.insert(key, value); + self.cached_protected_bytes = None; + self + } + + /// Build the protected header and return its CBOR-encoded bytes. + fn build_protected_bytes(&mut self) -> Result, crate::EncodeError> { + if let Some(ref cached) = self.cached_protected_bytes { + return Ok(cached.clone()); + } + + let header = self.build_protected_header()?; + let bytes = cbor::encode(&header)?; + self.cached_protected_bytes = Some(bytes.clone()); + Ok(bytes) + } + + /// Construct the `ProtectedCorimHeaderMap` from builder state. + fn build_protected_header(&self) -> Result { + if self.corim_meta.is_none() && self.cwt_claims.is_none() { + return Err(crate::EncodeError::Serialization( + "at least one of corim-meta or cwt-claims must be set".into(), + )); + } + + Ok(ProtectedCorimHeaderMap { + alg: self.alg, + content_type: Some(self.content_type.clone()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: self.corim_meta.clone(), + cwt_claims: self.cwt_claims.clone(), + extra: self.extra_protected.clone(), + }) + } + + /// Compute the COSE `Sig_structure1` to-be-signed (TBS) bytes. + /// + /// This is the data that must be signed by the external crypto operation. + /// The `external_aad` is application-supplied additional authenticated data; + /// pass `&[]` if not used. + /// + /// ```text + /// Sig_structure1 = [ + /// "Signature1", + /// body_protected, // CBOR-encoded protected header + /// external_aad, + /// payload, // the CoRIM payload bytes + /// ] + /// ``` + pub fn to_be_signed(&mut self, external_aad: &[u8]) -> Result, crate::EncodeError> { + let protected_bytes = self.build_protected_bytes()?; + build_sig_structure1(&protected_bytes, external_aad, &self.payload) + } + + /// Produce the final signed CoRIM CBOR bytes with the given signature. + /// + /// The `signature` must be the cryptographic signature over the TBS bytes + /// returned by [`to_be_signed`](SignedCorimBuilder::to_be_signed). + /// + /// The payload is **attached** (embedded in the COSE_Sign1 envelope). + /// + /// Returns `#6.18([protected, unprotected, payload, signature])` as CBOR bytes. + pub fn build_with_signature( + mut self, + signature: Vec, + ) -> Result, crate::EncodeError> { + let protected_bytes = self.build_protected_bytes()?; + let protected = self.build_protected_header()?; + + let signed = CoseSign1Corim { + protected_header_bytes: protected_bytes, + protected, + unprotected: self.unprotected, + payload: Some(self.payload), + signature, + }; + + encode_signed_corim(&signed) + } + + /// Produce the final signed CoRIM CBOR bytes in **detached payload** mode. + /// + /// The payload is NOT embedded in the COSE_Sign1 envelope (the payload + /// field is set to `nil`). The payload must be transported separately. + /// + /// The `signature` must be the cryptographic signature over the TBS bytes + /// returned by [`to_be_signed`](SignedCorimBuilder::to_be_signed). + /// Note: the TBS is computed over the *actual* payload even though the + /// envelope will carry `nil`. + /// + /// Returns `#6.18([protected, unprotected, nil, signature])` as CBOR bytes. + pub fn build_detached_with_signature( + mut self, + signature: Vec, + ) -> Result, crate::EncodeError> { + let protected_bytes = self.build_protected_bytes()?; + let protected = self.build_protected_header()?; + + let signed = CoseSign1Corim { + protected_header_bytes: protected_bytes, + protected, + unprotected: self.unprotected, + payload: None, + signature, + }; + + encode_signed_corim(&signed) + } +} diff --git a/corim/src/types/tags.rs b/corim/src/types/tags.rs new file mode 100644 index 0000000..d846ded --- /dev/null +++ b/corim/src/types/tags.rs @@ -0,0 +1,522 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! RFC-defined constants for CoRIM / CoMID / CoTL. +//! +//! All numeric values in this module come directly from +//! [draft-ietf-rats-corim-10](https://www.ietf.org/archive/id/draft-ietf-rats-corim-10.html) +//! and its referenced specifications. They are organized by category to match +//! the IANA registries defined in §12 of the draft. +//! +//! Using named constants instead of inline literals makes the code easier to +//! audit against the specification and reduces the risk of transcription errors. + +// =========================================================================== +// CBOR tags (§12.2 — "CBOR Tags" registry) +// =========================================================================== + +/// CBOR epoch-based date/time tag (RFC 8949 §3.4.2). +/// +/// Used by `validity-map` fields (`not-before`, `not-after`). +pub const TAG_EPOCH_TIME: u64 = 1; + +/// `signed-corim` = `#6.18(COSE-Sign1-corim)`. +pub const TAG_SIGNED_CORIM: u64 = 18; + +/// `tagged-uuid-type` = `#6.37(bytes .size 16)`. +pub const TAG_UUID: u64 = 37; + +/// `tagged-oid-type` = `#6.111(bytes)`. +pub const TAG_OID: u64 = 111; + +/// `tagged-unsigned-corim-map` = `#6.501(unsigned-corim-map)`. +pub const TAG_CORIM: u64 = 501; + +/// `tagged-concise-swid-tag` = `#6.505(bytes .cbor concise-swid-tag)`. +pub const TAG_COSWID: u64 = 505; + +/// `tagged-concise-mid-tag` = `#6.506(bytes .cbor concise-mid-tag)`. +pub const TAG_COMID: u64 = 506; + +/// `tagged-concise-tl-tag` = `#6.508(bytes .cbor concise-tl-tag)`. +pub const TAG_COTL: u64 = 508; + +/// `tagged-ueid-type` = `#6.550(bytes .size (7..33))`. +pub const TAG_UEID: u64 = 550; + +/// `tagged-svn` = `#6.552(uint)` — exact SVN. +pub const TAG_SVN: u64 = 552; + +/// `tagged-min-svn` = `#6.553(uint)` — minimum SVN. +pub const TAG_MIN_SVN: u64 = 553; + +/// `tagged-pkix-base64-key-type` = `#6.554(tstr)`. +pub const TAG_PKIX_BASE64_KEY: u64 = 554; + +/// `tagged-pkix-base64-cert-type` = `#6.555(tstr)`. +pub const TAG_PKIX_BASE64_CERT: u64 = 555; + +/// `tagged-pkix-base64-cert-path-type` = `#6.556(tstr)`. +pub const TAG_PKIX_BASE64_CERT_PATH: u64 = 556; + +/// `tagged-key-thumbprint-type` = `#6.557(eatmc.digest)`. +pub const TAG_KEY_THUMBPRINT: u64 = 557; + +/// `tagged-cose-key-type` = `#6.558(COSE_Key)`. +pub const TAG_COSE_KEY: u64 = 558; + +/// `tagged-cert-thumbprint-type` = `#6.559(eatmc.digest)`. +pub const TAG_CERT_THUMBPRINT: u64 = 559; + +/// `tagged-bytes` = `#6.560(bytes)`. +pub const TAG_BYTES: u64 = 560; + +/// `tagged-cert-path-thumbprint-type` = `#6.561(eatmc.digest)`. +pub const TAG_CERT_PATH_THUMBPRINT: u64 = 561; + +/// `tagged-pkix-asn1der-cert-type` = `#6.562(bstr)`. +pub const TAG_PKIX_ASN1DER_CERT: u64 = 562; + +/// `tagged-masked-raw-value` = `#6.563([value, mask])`. +pub const TAG_MASKED_RAW_VALUE: u64 = 563; + +/// `tagged-int-range` = `#6.564(int-range)`. +pub const TAG_INT_RANGE: u64 = 564; + +// =========================================================================== +// CoRIM Map keys (§12.3 — "CoRIM Map" registry) +// =========================================================================== + +/// `corim-map` key: `id` (index 0). +pub const CORIM_KEY_ID: i64 = 0; +/// `corim-map` key: `tags` (index 1). +pub const CORIM_KEY_TAGS: i64 = 1; +/// `corim-map` key: `dependent-rims` (index 2). +pub const CORIM_KEY_DEPENDENT_RIMS: i64 = 2; +/// `corim-map` key: `profile` (index 3). +pub const CORIM_KEY_PROFILE: i64 = 3; +/// `corim-map` key: `rim-validity` (index 4). +pub const CORIM_KEY_RIM_VALIDITY: i64 = 4; +/// `corim-map` key: `entities` (index 5). +pub const CORIM_KEY_ENTITIES: i64 = 5; + +// =========================================================================== +// CoRIM Entity / Signer Map keys (§12.4, §12.5) +// =========================================================================== + +/// `entity-map` key: `entity-name` (index 0). +pub const ENTITY_KEY_NAME: i64 = 0; +/// `entity-map` key: `reg-id` (index 1). +pub const ENTITY_KEY_REG_ID: i64 = 1; +/// `entity-map` key: `role` (index 2). +pub const ENTITY_KEY_ROLE: i64 = 2; + +/// `corim-signer-map` key: `signer-name` (index 0). +pub const SIGNER_KEY_NAME: i64 = 0; +/// `corim-signer-map` key: `signer-uri` (index 1). +pub const SIGNER_KEY_URI: i64 = 1; + +/// `corim-meta-map` key: `signer` (index 0). +pub const META_KEY_SIGNER: i64 = 0; +/// `corim-meta-map` key: `signature-validity` (index 1). +pub const META_KEY_SIGNATURE_VALIDITY: i64 = 1; + +// =========================================================================== +// CoRIM role values (§12.4) +// =========================================================================== + +/// `$corim-role-type-choice`: `manifest-creator` (1). +pub const CORIM_ROLE_MANIFEST_CREATOR: i64 = 1; +/// `$corim-role-type-choice`: `manifest-signer` (2). +pub const CORIM_ROLE_MANIFEST_SIGNER: i64 = 2; + +// =========================================================================== +// CoMID Map keys (§12.6 — "CoMID Map" registry) +// =========================================================================== + +/// `concise-mid-tag` key: `language` (index 0). +pub const COMID_KEY_LANGUAGE: i64 = 0; +/// `concise-mid-tag` key: `tag-identity` (index 1). +pub const COMID_KEY_TAG_IDENTITY: i64 = 1; +/// `concise-mid-tag` key: `entities` (index 2). +pub const COMID_KEY_ENTITIES: i64 = 2; +/// `concise-mid-tag` key: `linked-tags` (index 3). +pub const COMID_KEY_LINKED_TAGS: i64 = 3; +/// `concise-mid-tag` key: `triples` (index 4). +pub const COMID_KEY_TRIPLES: i64 = 4; + +// =========================================================================== +// CoMID role values (§12.7) +// =========================================================================== + +/// `$comid-role-type-choice`: `tag-creator` (0). +pub const COMID_ROLE_TAG_CREATOR: i64 = 0; +/// `$comid-role-type-choice`: `creator` (1). +pub const COMID_ROLE_CREATOR: i64 = 1; +/// `$comid-role-type-choice`: `maintainer` (2). +pub const COMID_ROLE_MAINTAINER: i64 = 2; + +// =========================================================================== +// Tag Identity Map keys (§5.1.1) +// =========================================================================== + +/// `tag-identity-map` key: `tag-id` (index 0). +pub const TAG_IDENTITY_KEY_TAG_ID: i64 = 0; +/// `tag-identity-map` key: `tag-version` (index 1). +pub const TAG_IDENTITY_KEY_TAG_VERSION: i64 = 1; + +// =========================================================================== +// Tag relation values (§5.1.3) +// =========================================================================== + +/// `$tag-rel-type-choice`: `supplements` (0). +pub const TAG_REL_SUPPLEMENTS: i64 = 0; +/// `$tag-rel-type-choice`: `replaces` (1). +pub const TAG_REL_REPLACES: i64 = 1; + +// =========================================================================== +// Linked Tag Map keys (§5.1.3) +// =========================================================================== + +/// `linked-tag-map` key: `linked-tag-id` (index 0). +pub const LINKED_TAG_KEY_ID: i64 = 0; +/// `linked-tag-map` key: `tag-rel` (index 1). +pub const LINKED_TAG_KEY_REL: i64 = 1; + +// =========================================================================== +// Validity Map keys (§7.3) +// =========================================================================== + +/// `validity-map` key: `not-before` (index 0). +pub const VALIDITY_KEY_NOT_BEFORE: i64 = 0; +/// `validity-map` key: `not-after` (index 1). +pub const VALIDITY_KEY_NOT_AFTER: i64 = 1; + +// =========================================================================== +// Class Map keys (§5.1.4.2) +// =========================================================================== + +/// `class-map` key: `class-id` (index 0). +pub const CLASS_KEY_CLASS_ID: i64 = 0; +/// `class-map` key: `vendor` (index 1). +pub const CLASS_KEY_VENDOR: i64 = 1; +/// `class-map` key: `model` (index 2). +pub const CLASS_KEY_MODEL: i64 = 2; +/// `class-map` key: `layer` (index 3). +pub const CLASS_KEY_LAYER: i64 = 3; +/// `class-map` key: `index` (index 4). +pub const CLASS_KEY_INDEX: i64 = 4; + +// =========================================================================== +// Environment Map keys (§5.1.4.1) +// =========================================================================== + +/// `environment-map` key: `class` (index 0). +pub const ENV_KEY_CLASS: i64 = 0; +/// `environment-map` key: `instance` (index 1). +pub const ENV_KEY_INSTANCE: i64 = 1; +/// `environment-map` key: `group` (index 2). +pub const ENV_KEY_GROUP: i64 = 2; + +// =========================================================================== +// Measurement Map keys (§5.1.4.5) +// =========================================================================== + +/// `measurement-map` key: `mkey` (index 0). +pub const MEAS_KEY_MKEY: i64 = 0; +/// `measurement-map` key: `mval` (index 1). +pub const MEAS_KEY_MVAL: i64 = 1; +/// `measurement-map` key: `authorized-by` (index 2). +pub const MEAS_KEY_AUTHORIZED_BY: i64 = 2; + +// =========================================================================== +// Measurement Values Map keys (§12.9 — "CoMID Measurement Values Map") +// =========================================================================== + +/// `measurement-values-map` key: `version` (index 0). +pub const MVAL_KEY_VERSION: i64 = 0; +/// `measurement-values-map` key: `svn` (index 1). +pub const MVAL_KEY_SVN: i64 = 1; +/// `measurement-values-map` key: `digests` (index 2). +pub const MVAL_KEY_DIGESTS: i64 = 2; +/// `measurement-values-map` key: `flags` (index 3). +pub const MVAL_KEY_FLAGS: i64 = 3; +/// `measurement-values-map` key: `raw-value` (index 4). +pub const MVAL_KEY_RAW_VALUE: i64 = 4; +/// `measurement-values-map` key: `raw-value-mask-DEPRECATED` (index 5). +pub const MVAL_KEY_RAW_VALUE_MASK_DEPRECATED: i64 = 5; +/// `measurement-values-map` key: `mac-addr` (index 6). +pub const MVAL_KEY_MAC_ADDR: i64 = 6; +/// `measurement-values-map` key: `ip-addr` (index 7). +pub const MVAL_KEY_IP_ADDR: i64 = 7; +/// `measurement-values-map` key: `serial-number` (index 8). +pub const MVAL_KEY_SERIAL_NUMBER: i64 = 8; +/// `measurement-values-map` key: `ueid` (index 9). +pub const MVAL_KEY_UEID: i64 = 9; +/// `measurement-values-map` key: `uuid` (index 10). +pub const MVAL_KEY_UUID: i64 = 10; +/// `measurement-values-map` key: `name` (index 11). +pub const MVAL_KEY_NAME: i64 = 11; +/// `measurement-values-map` key: `cryptokeys` (index 13). +pub const MVAL_KEY_CRYPTOKEYS: i64 = 13; +/// `measurement-values-map` key: `integrity-registers` (index 14). +pub const MVAL_KEY_INTEGRITY_REGISTERS: i64 = 14; +/// `measurement-values-map` key: `int-range` (index 15). +pub const MVAL_KEY_INT_RANGE: i64 = 15; + +// =========================================================================== +// Flags Map keys (§12.10 — "CoMID Flags Map" registry) +// =========================================================================== + +/// `flags-map` key: `is-configured` (index 0). +pub const FLAG_KEY_IS_CONFIGURED: i64 = 0; +/// `flags-map` key: `is-secure` (index 1). +pub const FLAG_KEY_IS_SECURE: i64 = 1; +/// `flags-map` key: `is-recovery` (index 2). +pub const FLAG_KEY_IS_RECOVERY: i64 = 2; +/// `flags-map` key: `is-debug` (index 3). +pub const FLAG_KEY_IS_DEBUG: i64 = 3; +/// `flags-map` key: `is-replay-protected` (index 4). +pub const FLAG_KEY_IS_REPLAY_PROTECTED: i64 = 4; +/// `flags-map` key: `is-integrity-protected` (index 5). +pub const FLAG_KEY_IS_INTEGRITY_PROTECTED: i64 = 5; +/// `flags-map` key: `is-runtime-meas` (index 6). +pub const FLAG_KEY_IS_RUNTIME_MEAS: i64 = 6; +/// `flags-map` key: `is-immutable` (index 7). +pub const FLAG_KEY_IS_IMMUTABLE: i64 = 7; +/// `flags-map` key: `is-tcb` (index 8). +pub const FLAG_KEY_IS_TCB: i64 = 8; +/// `flags-map` key: `is-confidentiality-protected` (index 9). +pub const FLAG_KEY_IS_CONFIDENTIALITY_PROTECTED: i64 = 9; + +// =========================================================================== +// Triples Map keys (§12.8 — "CoMID Triples Map" registry) +// =========================================================================== + +/// `triples-map` key: `reference-triples` (index 0). +pub const TRIPLES_KEY_REFERENCE: i64 = 0; +/// `triples-map` key: `endorsed-triples` (index 1). +pub const TRIPLES_KEY_ENDORSED: i64 = 1; +/// `triples-map` key: `identity-triples` (index 2). +pub const TRIPLES_KEY_IDENTITY: i64 = 2; +/// `triples-map` key: `attest-key-triples` (index 3). +pub const TRIPLES_KEY_ATTEST_KEY: i64 = 3; +/// `triples-map` key: `dependency-triples` (index 4). +pub const TRIPLES_KEY_DEPENDENCY: i64 = 4; +/// `triples-map` key: `membership-triples` (index 5). +pub const TRIPLES_KEY_MEMBERSHIP: i64 = 5; +/// `triples-map` key: `coswid-triples` (index 6). +pub const TRIPLES_KEY_COSWID: i64 = 6; +/// `triples-map` key: `conditional-endorsement-series-triples` (index 8). +pub const TRIPLES_KEY_COND_ENDORSEMENT_SERIES: i64 = 8; +/// `triples-map` key: `conditional-endorsement-triples` (index 10). +pub const TRIPLES_KEY_COND_ENDORSEMENT: i64 = 10; + +// =========================================================================== +// Version Map keys (§5.1.4.5.3) +// =========================================================================== + +/// `version-map` key: `version` (index 0). +pub const VERSION_KEY_VERSION: i64 = 0; +/// `version-map` key: `version-scheme` (index 1). +pub const VERSION_KEY_SCHEME: i64 = 1; + +// =========================================================================== +// Version Scheme values (CoSWID §4.1, imported by CoRIM) +// =========================================================================== + +/// `$version-scheme`: `multipartnumeric` (1). +pub const VERSION_SCHEME_MULTIPARTNUMERIC: i64 = 1; +/// `$version-scheme`: `multipartnumeric-suffix` (2). +pub const VERSION_SCHEME_MULTIPARTNUMERIC_SUFFIX: i64 = 2; +/// `$version-scheme`: `alphanumeric` (3). +pub const VERSION_SCHEME_ALPHANUMERIC: i64 = 3; +/// `$version-scheme`: `decimal` (4). +pub const VERSION_SCHEME_DECIMAL: i64 = 4; +/// `$version-scheme`: `semver` (16384). +pub const VERSION_SCHEME_SEMVER: i64 = 16384; + +// =========================================================================== +// CoTL Map keys (§12.11) +// =========================================================================== + +/// `concise-tl-tag` key: `tag-identity` (index 0). +pub const COTL_KEY_TAG_IDENTITY: i64 = 0; +/// `concise-tl-tag` key: `tags-list` (index 1). +pub const COTL_KEY_TAGS_LIST: i64 = 1; +/// `concise-tl-tag` key: `tl-validity` (index 2). +pub const COTL_KEY_VALIDITY: i64 = 2; + +// =========================================================================== +// CoRIM Locator Map keys (§4.1.3) +// =========================================================================== + +/// `corim-locator-map` key: `href` (index 0). +pub const LOCATOR_KEY_HREF: i64 = 0; +/// `corim-locator-map` key: `thumbprint` (index 1). +pub const LOCATOR_KEY_THUMBPRINT: i64 = 1; + +// =========================================================================== +// Protected CoRIM Header keys (§4.2.1) +// =========================================================================== + +/// COSE header: `alg` (index 1). +pub const COSE_HDR_ALG: i64 = 1; +/// COSE header: `content-type` (index 3). +pub const COSE_HDR_CONTENT_TYPE: i64 = 3; +/// CoRIM protected header: `corim-meta` (index 8). +pub const CORIM_HDR_META: i64 = 8; +/// CoRIM protected header: `CWT-Claims` (index 15). +pub const CORIM_HDR_CWT_CLAIMS: i64 = 15; +/// Hash envelope: `payload_hash_alg` (index 258). +pub const CORIM_HDR_PAYLOAD_HASH_ALG: i64 = 258; +/// Hash envelope: `payload_preimage_content_type` (index 259). +pub const CORIM_HDR_PAYLOAD_PREIMAGE_CONTENT_TYPE: i64 = 259; +/// Hash envelope: `payload_location` (index 260). +pub const CORIM_HDR_PAYLOAD_LOCATION: i64 = 260; + +// =========================================================================== +// Byte size constraints (§7) +// =========================================================================== + +/// UUID byte length: 16 bytes (§7.4). +pub const UUID_SIZE: usize = 16; +/// UEID minimum byte length: 7 bytes (§7.5). +pub const UEID_MIN_SIZE: usize = 7; +/// UEID maximum byte length: 33 bytes (§7.5). +pub const UEID_MAX_SIZE: usize = 33; +/// EUI-48 MAC address byte length: 6 bytes. +pub const EUI48_SIZE: usize = 6; +/// EUI-64 MAC address byte length: 8 bytes. +pub const EUI64_SIZE: usize = 8; +/// IPv4 address byte length: 4 bytes. +pub const IPV4_SIZE: usize = 4; +/// IPv6 address byte length: 16 bytes. +pub const IPV6_SIZE: usize = 16; + +// =========================================================================== +// Identity / Attest-Key triple condition keys (§5.1.9, §5.1.10) +// =========================================================================== + +/// `conditions` map key: `mkey` (index 0). +pub const KEY_TRIPLE_COND_MKEY: i64 = 0; +/// `conditions` map key: `authorized-by` (index 1). +pub const KEY_TRIPLE_COND_AUTHORIZED_BY: i64 = 1; + +// =========================================================================== +// Media type string (§12.12) +// =========================================================================== + +/// CoRIM CBOR media type: `application/rim+cbor`. +pub const MEDIA_TYPE_RIM_CBOR: &str = "application/rim+cbor"; +/// CoRIM COSE media type: `application/rim+cose`. +pub const MEDIA_TYPE_RIM_COSE: &str = "application/rim+cose"; + +// =========================================================================== +// CoSWID constants (RFC 9393) +// =========================================================================== + +/// CoSWID CBOR tag: `#6.1398229316` (0x53574944 = "SWID"). +pub const TAG_COSWID_CBOR: u64 = 1398229316; + +// --- concise-swid-tag map keys (RFC 9393 §2.3) --- + +/// `tag-id` (index 0). +pub const SWID_KEY_TAG_ID: i64 = 0; +/// `software-name` (index 1). +pub const SWID_KEY_SOFTWARE_NAME: i64 = 1; +/// `entity` (index 2). +pub const SWID_KEY_ENTITY: i64 = 2; +/// `evidence` (index 3). +pub const SWID_KEY_EVIDENCE: i64 = 3; +/// `link` (index 4). +pub const SWID_KEY_LINK: i64 = 4; +/// `software-meta` (index 5). +pub const SWID_KEY_SOFTWARE_META: i64 = 5; +/// `payload` (index 6). +pub const SWID_KEY_PAYLOAD: i64 = 6; +/// `corpus` (index 8). +pub const SWID_KEY_CORPUS: i64 = 8; +/// `patch` (index 9). +pub const SWID_KEY_PATCH: i64 = 9; +/// `media` (index 10). +pub const SWID_KEY_MEDIA: i64 = 10; +/// `supplemental` (index 11). +pub const SWID_KEY_SUPPLEMENTAL: i64 = 11; +/// `tag-version` (index 12). +pub const SWID_KEY_TAG_VERSION: i64 = 12; +/// `software-version` (index 13). +pub const SWID_KEY_SOFTWARE_VERSION: i64 = 13; +/// `version-scheme` (index 14). +pub const SWID_KEY_VERSION_SCHEME: i64 = 14; +/// `lang` (index 15). +pub const SWID_KEY_LANG: i64 = 15; + +// --- entity-entry map keys (RFC 9393 §2.6) --- + +/// `entity-name` (index 31). +pub const SWID_KEY_ENTITY_NAME: i64 = 31; +/// `reg-id` (index 32). +pub const SWID_KEY_REG_ID: i64 = 32; +/// `role` (index 33). +pub const SWID_KEY_ROLE: i64 = 33; +/// `thumbprint` (index 34). +pub const SWID_KEY_THUMBPRINT: i64 = 34; + +// --- link-entry map keys (RFC 9393 §2.7) --- + +/// `artifact` (index 37). +pub const SWID_KEY_ARTIFACT: i64 = 37; +/// `href` (index 38). +pub const SWID_KEY_HREF: i64 = 38; +/// `ownership` (index 39). +pub const SWID_KEY_OWNERSHIP: i64 = 39; +/// `rel` (index 40). +pub const SWID_KEY_REL: i64 = 40; +/// `media-type` (index 41). +pub const SWID_KEY_MEDIA_TYPE: i64 = 41; +/// `use` (index 42). +pub const SWID_KEY_USE: i64 = 42; + +// --- CoSWID entity role values (RFC 9393 §4.2) --- + +/// `tagCreator` (1). +pub const SWID_ROLE_TAG_CREATOR: i64 = 1; +/// `softwareCreator` (2). +pub const SWID_ROLE_SOFTWARE_CREATOR: i64 = 2; +/// `aggregator` (3). +pub const SWID_ROLE_AGGREGATOR: i64 = 3; +/// `distributor` (4). +pub const SWID_ROLE_DISTRIBUTOR: i64 = 4; +/// `licensor` (5). +pub const SWID_ROLE_LICENSOR: i64 = 5; +/// `maintainer` (6). +pub const SWID_ROLE_MAINTAINER: i64 = 6; + +// --- CoSWID link rel values (RFC 9393 §4.4) --- + +/// `ancestor` (1). +pub const SWID_REL_ANCESTOR: i64 = 1; +/// `component` (2). +pub const SWID_REL_COMPONENT: i64 = 2; +/// `feature` (3). +pub const SWID_REL_FEATURE: i64 = 3; +/// `installationmedia` (4). +pub const SWID_REL_INSTALLATIONMEDIA: i64 = 4; +/// `packageinstaller` (5). +pub const SWID_REL_PACKAGEINSTALLER: i64 = 5; +/// `parent` (6). +pub const SWID_REL_PARENT: i64 = 6; +/// `patches` (7). +pub const SWID_REL_PATCHES: i64 = 7; +/// `requires` (8). +pub const SWID_REL_REQUIRES: i64 = 8; +/// `see-also` (9). +pub const SWID_REL_SEE_ALSO: i64 = 9; +/// `supersedes` (10). +pub const SWID_REL_SUPERSEDES: i64 = 10; +/// `supplemental` (11). +pub const SWID_REL_SUPPLEMENTAL: i64 = 11; + +/// CoSWID media type: `application/swid+cbor`. +pub const MEDIA_TYPE_SWID_CBOR: &str = "application/swid+cbor"; diff --git a/corim/src/types/triples.rs b/corim/src/types/triples.rs new file mode 100644 index 0000000..64d5be7 --- /dev/null +++ b/corim/src/types/triples.rs @@ -0,0 +1,614 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Triple types from `triples-map`. +//! +//! All triple record types are CBOR arrays (not maps), so they use standard +//! serde tuple serialization. + +use corim_macros::{CborDeserialize, CborSerialize}; +use serde::{Deserialize, Serialize}; + +use super::common::{CryptoKey, MeasuredElement, TagIdChoice}; +use super::environment::EnvironmentMap; +use super::measurement::MeasurementMap; +use crate::Validate; + +// --------------------------------------------------------------------------- +// triples-map +// --------------------------------------------------------------------------- + +/// `triples-map` — the core payload of a CoMID. +/// +/// At least one triple type must be present (`non-empty`). +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +#[cbor(non_empty)] +pub struct TriplesMap { + /// `reference-triples` (key 0). + #[cbor(key = 0, optional)] + pub reference_triples: Option>, + /// `endorsed-triples` (key 1). + #[cbor(key = 1, optional)] + pub endorsed_triples: Option>, + /// `identity-triples` (key 2). + #[cbor(key = 2, optional)] + pub identity_triples: Option>, + /// `attest-key-triples` (key 3). + #[cbor(key = 3, optional)] + pub attest_key_triples: Option>, + /// `dependency-triples` (key 4). + #[cbor(key = 4, optional)] + pub dependency_triples: Option>, + /// `membership-triples` (key 5). + #[cbor(key = 5, optional)] + pub membership_triples: Option>, + /// `coswid-triples` (key 6). + #[cbor(key = 6, optional)] + pub coswid_triples: Option>, + /// `conditional-endorsement-series-triples` (key 8). + #[cbor(key = 8, optional)] + pub conditional_endorsement_series: Option>, + /// `conditional-endorsement-triples` (key 10). + #[cbor(key = 10, optional)] + pub conditional_endorsement: Option>, +} + +// --------------------------------------------------------------------------- +// reference-triple-record = [ environment-map, [+ measurement-map] ] +// --------------------------------------------------------------------------- + +/// `reference-triple-record` — reference values for a target environment. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ReferenceTriple(pub EnvironmentMap, pub Vec); + +impl ReferenceTriple { + /// Create a new reference triple. + pub fn new(environment: EnvironmentMap, measurements: Vec) -> Self { + Self(environment, measurements) + } + /// Get the target environment. + pub fn environment(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the reference measurements. + pub fn measurements(&self) -> &[MeasurementMap] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// endorsed-triple-record = [ environment-map, [+ measurement-map] ] +// --------------------------------------------------------------------------- + +/// `endorsed-triple-record` — endorsed values for a target environment. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EndorsedTriple(pub EnvironmentMap, pub Vec); + +impl EndorsedTriple { + /// Create a new endorsed triple. + pub fn new(condition: EnvironmentMap, endorsement: Vec) -> Self { + Self(condition, endorsement) + } + /// Get the condition environment. + pub fn condition(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the endorsement measurements. + pub fn endorsement(&self) -> &[MeasurementMap] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// Key triple conditions (shared by identity and attest-key triples) +// --------------------------------------------------------------------------- + +/// Conditions map for identity/attest-key triples. +/// +/// CDDL: `non-empty<{ ?mkey: 0, ?authorized-by: 1 }>` +#[derive(Clone, Debug, PartialEq, CborSerialize, CborDeserialize)] +#[cbor(non_empty)] +pub struct KeyTripleConditions { + /// `mkey` (key 0): optional measured element key. + #[cbor(key = 0, optional)] + pub mkey: Option, + /// `authorized-by` (key 1): optional authority keys. + #[cbor(key = 1, optional)] + pub authorized_by: Option>, +} + +// --------------------------------------------------------------------------- +// identity-triple-record +// --------------------------------------------------------------------------- + +/// `identity-triple-record` — device identity keys. +/// +/// CDDL: +/// ```text +/// identity-triple-record = [ +/// environment: environment-map, +/// key-list: [+ $crypto-key-type-choice], +/// ? conditions: non-empty<{ ?mkey, ?authorized-by }>, +/// ] +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct IdentityTriple( + pub EnvironmentMap, + pub Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] pub Option, +); + +impl IdentityTriple { + /// Create a new identity triple. + pub fn new( + environment: EnvironmentMap, + keys: Vec, + conditions: Option, + ) -> Self { + Self(environment, keys, conditions) + } + /// Get the environment. + pub fn environment(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the key list. + pub fn keys(&self) -> &[CryptoKey] { + &self.1 + } + /// Get optional conditions. + pub fn conditions(&self) -> Option<&KeyTripleConditions> { + self.2.as_ref() + } +} + +// --------------------------------------------------------------------------- +// attest-key-triple-record +// --------------------------------------------------------------------------- + +/// `attest-key-triple-record` — attestation key endorsement. +/// +/// Same structure as identity-triple-record. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AttestKeyTriple( + pub EnvironmentMap, + pub Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] pub Option, +); + +impl AttestKeyTriple { + /// Create a new attest-key triple. + pub fn new( + environment: EnvironmentMap, + keys: Vec, + conditions: Option, + ) -> Self { + Self(environment, keys, conditions) + } + /// Get the environment. + pub fn environment(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the key list. + pub fn keys(&self) -> &[CryptoKey] { + &self.1 + } + /// Get optional conditions. + pub fn conditions(&self) -> Option<&KeyTripleConditions> { + self.2.as_ref() + } +} + +// --------------------------------------------------------------------------- +// domain-dependency-triple-record +// --------------------------------------------------------------------------- + +/// `domain-dependency-triple-record` — trust dependencies between domains. +/// +/// CDDL: `[domain-id: domain-type, trustees: [+ domain-type]]` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DomainDependencyTriple(pub EnvironmentMap, pub Vec); + +impl DomainDependencyTriple { + /// Create a new domain dependency triple. + pub fn new(domain_id: EnvironmentMap, trustees: Vec) -> Self { + Self(domain_id, trustees) + } + /// Get the domain identifier. + pub fn domain_id(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the trustee domains. + pub fn trustees(&self) -> &[EnvironmentMap] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// domain-membership-triple-record +// --------------------------------------------------------------------------- + +/// `domain-membership-triple-record` — domain composition. +/// +/// CDDL: `[domain-id: domain-type, members: [+ domain-type]]` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DomainMembershipTriple(pub EnvironmentMap, pub Vec); + +impl DomainMembershipTriple { + /// Create a new domain membership triple. + pub fn new(domain_id: EnvironmentMap, members: Vec) -> Self { + Self(domain_id, members) + } + /// Get the domain identifier. + pub fn domain_id(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the member environments. + pub fn members(&self) -> &[EnvironmentMap] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// coswid-triple-record = [ environment-map, [+ coswid.tag-id] ] +// --------------------------------------------------------------------------- + +/// `coswid-triple-record` — links an environment to CoSWID tags. +/// +/// The tag-ids are `text / bstr .size 16` (string or UUID). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CoswidTriple(pub EnvironmentMap, pub Vec); + +impl CoswidTriple { + /// Create a new CoSWID triple. + pub fn new(environment: EnvironmentMap, tag_ids: Vec) -> Self { + Self(environment, tag_ids) + } + /// Get the environment. + pub fn environment(&self) -> &EnvironmentMap { + &self.0 + } + /// Get the CoSWID tag identifiers. + pub fn tag_ids(&self) -> &[TagIdChoice] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// conditional-endorsement-series +// --------------------------------------------------------------------------- + +/// Condition block for conditional-endorsement-series triples. +/// +/// CDDL (this is a CBOR **array**, not a map): +/// ```text +/// condition: [ +/// environment: environment-map, +/// claims-list: [* measurement-map], +/// ? authorized-by: [+ $crypto-key-type-choice], +/// ] +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct CesCondition { + /// The target environment. + pub environment: EnvironmentMap, + /// Measurement conditions (may be empty). + pub claims_list: Vec, + /// Optional authority condition. + pub authorized_by: Option>, +} + +// Custom Serialize/Deserialize for CesCondition — it is a CBOR array [env, claims, ?auth] +impl Serialize for CesCondition { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq; + let len = if self.authorized_by.is_some() { 3 } else { 2 }; + let mut seq = serializer.serialize_seq(Some(len))?; + seq.serialize_element(&self.environment)?; + seq.serialize_element(&self.claims_list)?; + if let Some(ref auth) = self.authorized_by { + seq.serialize_element(auth)?; + } + seq.end() + } +} + +impl<'de> Deserialize<'de> for CesCondition { + fn deserialize>(deserializer: D) -> Result { + struct CesCondVisitor; + impl<'de> serde::de::Visitor<'de> for CesCondVisitor { + type Value = CesCondition; + fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("a CBOR array [environment, claims-list, ?authorized-by]") + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let environment: EnvironmentMap = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; + let claims_list: Vec = seq + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?; + let authorized_by: Option> = seq.next_element()?; + Ok(CesCondition { + environment, + claims_list, + authorized_by, + }) + } + } + deserializer.deserialize_seq(CesCondVisitor) + } +} + +/// `conditional-endorsement-series-triple-record`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConditionalEndorsementSeriesTriple(pub CesCondition, pub Vec); + +impl ConditionalEndorsementSeriesTriple { + /// Create a new CES triple. + pub fn new(condition: CesCondition, series: Vec) -> Self { + Self(condition, series) + } + /// Get the condition. + pub fn condition(&self) -> &CesCondition { + &self.0 + } + /// Get the series records. + pub fn series(&self) -> &[ConditionalSeriesRecord] { + &self.1 + } +} + +/// `conditional-series-record = [selection: [+ measurement-map], addition: [+ measurement-map]]`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConditionalSeriesRecord(pub Vec, pub Vec); + +impl ConditionalSeriesRecord { + /// Create a new conditional series record. + pub fn new(selection: Vec, addition: Vec) -> Self { + Self(selection, addition) + } + /// Get the selection criteria. + pub fn selection(&self) -> &[MeasurementMap] { + &self.0 + } + /// Get the addition values. + pub fn addition(&self) -> &[MeasurementMap] { + &self.1 + } +} + +// --------------------------------------------------------------------------- +// conditional-endorsement-triple-record +// --------------------------------------------------------------------------- + +/// `stateful-environment-record = [environment-map, [+ measurement-map]]`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StatefulEnvironmentRecord(pub EnvironmentMap, pub Vec); + +/// `conditional-endorsement-triple-record`. +/// +/// CDDL: +/// ```text +/// conditional-endorsement-triple-record = [ +/// conditions: [+ stateful-environment-record], +/// endorsements: [+ endorsed-triple-record], +/// ] +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConditionalEndorsementTriple( + pub Vec, + pub Vec, +); + +// --------------------------------------------------------------------------- +// Validate implementations +// --------------------------------------------------------------------------- + +impl Validate for TriplesMap { + fn valid(&self) -> Result<(), String> { + fn non_empty(v: &Option>) -> bool { + v.as_ref().is_some_and(|v| !v.is_empty()) + } + + let has_triples = non_empty(&self.reference_triples) + || non_empty(&self.endorsed_triples) + || non_empty(&self.identity_triples) + || non_empty(&self.attest_key_triples) + || non_empty(&self.dependency_triples) + || non_empty(&self.membership_triples) + || non_empty(&self.coswid_triples) + || non_empty(&self.conditional_endorsement_series) + || non_empty(&self.conditional_endorsement); + + if !has_triples { + return Err("triples struct must not be empty".into()); + } + + if let Some(ref triples) = self.reference_triples { + for (i, t) in triples.iter().enumerate() { + t.valid() + .map_err(|e| format!("reference value at index {i}: {e}"))?; + } + } + if let Some(ref triples) = self.endorsed_triples { + for (i, t) in triples.iter().enumerate() { + t.valid() + .map_err(|e| format!("endorsed value at index {i}: {e}"))?; + } + } + if let Some(ref triples) = self.identity_triples { + for (i, t) in triples.iter().enumerate() { + t.valid() + .map_err(|e| format!("identity triple at index {i}: {e}"))?; + } + } + if let Some(ref triples) = self.attest_key_triples { + for (i, t) in triples.iter().enumerate() { + t.valid() + .map_err(|e| format!("attest-key triple at index {i}: {e}"))?; + } + } + if let Some(ref triples) = self.dependency_triples { + for (i, t) in triples.iter().enumerate() { + t.valid() + .map_err(|e| format!("dependency triple at index {i}: {e}"))?; + } + } + if let Some(ref triples) = self.membership_triples { + for (i, t) in triples.iter().enumerate() { + t.valid() + .map_err(|e| format!("membership triple at index {i}: {e}"))?; + } + } + Ok(()) + } +} + +impl Validate for ReferenceTriple { + fn valid(&self) -> Result<(), String> { + self.0 + .valid() + .map_err(|e| format!("environment validation failed: {e}"))?; + if self.1.is_empty() { + return Err("measurements validation failed: no measurement entries".into()); + } + for (i, m) in self.1.iter().enumerate() { + m.valid() + .map_err(|e| format!("measurement at index {i}: {e}"))?; + } + Ok(()) + } +} + +impl Validate for EndorsedTriple { + fn valid(&self) -> Result<(), String> { + self.0 + .valid() + .map_err(|e| format!("environment validation failed: {e}"))?; + if self.1.is_empty() { + return Err("measurements validation failed: no measurement entries".into()); + } + for (i, m) in self.1.iter().enumerate() { + m.valid() + .map_err(|e| format!("measurement at index {i}: {e}"))?; + } + Ok(()) + } +} + +impl Validate for IdentityTriple { + fn valid(&self) -> Result<(), String> { + self.0 + .valid() + .map_err(|e| format!("environment validation failed: {e}"))?; + if self.1.is_empty() { + return Err("verification keys validation failed: no keys".into()); + } + Ok(()) + } +} + +impl Validate for AttestKeyTriple { + fn valid(&self) -> Result<(), String> { + self.0 + .valid() + .map_err(|e| format!("environment validation failed: {e}"))?; + if self.1.is_empty() { + return Err("verification keys validation failed: no keys".into()); + } + Ok(()) + } +} + +impl Validate for DomainDependencyTriple { + fn valid(&self) -> Result<(), String> { + self.0.valid().map_err(|e| format!("domain-id: {e}"))?; + if self.1.is_empty() { + return Err("at least one trustee required".into()); + } + for (i, t) in self.1.iter().enumerate() { + t.valid() + .map_err(|e| format!("trustee at index {i}: {e}"))?; + } + // Check domain-id does not appear in trustees (§5.1.11.2 constraint) + for trustee in &self.1 { + if self.0 == *trustee { + return Err("domain-id must not appear in trustees".into()); + } + } + Ok(()) + } +} + +impl Validate for DomainMembershipTriple { + fn valid(&self) -> Result<(), String> { + self.0.valid().map_err(|e| format!("domain-id: {e}"))?; + if self.1.is_empty() { + return Err("at least one member required".into()); + } + for (i, m) in self.1.iter().enumerate() { + m.valid().map_err(|e| format!("member at index {i}: {e}"))?; + } + Ok(()) + } +} + +impl Validate for CoswidTriple { + fn valid(&self) -> Result<(), String> { + self.0 + .valid() + .map_err(|e| format!("environment validation failed: {e}"))?; + if self.1.is_empty() { + return Err("at least one CoSWID tag-id required".into()); + } + Ok(()) + } +} + +impl Validate for ConditionalEndorsementSeriesTriple { + fn valid(&self) -> Result<(), String> { + self.0 + .environment + .valid() + .map_err(|e| format!("condition environment: {e}"))?; + if self.1.is_empty() { + return Err("no measurement entries in series".into()); + } + Ok(()) + } +} + +impl Validate for StatefulEnvironmentRecord { + fn valid(&self) -> Result<(), String> { + self.0 + .valid() + .map_err(|e| format!("environment validation failed: {e}"))?; + if self.1.is_empty() { + return Err("measurements must not be empty".into()); + } + Ok(()) + } +} + +impl Validate for ConditionalEndorsementTriple { + fn valid(&self) -> Result<(), String> { + if self.0.is_empty() { + return Err("conditions must not be empty".into()); + } + for (i, c) in self.0.iter().enumerate() { + c.valid() + .map_err(|e| format!("condition at index {i}: {e}"))?; + } + if self.1.is_empty() { + return Err("endorsements must not be empty".into()); + } + for (i, e) in self.1.iter().enumerate() { + e.valid() + .map_err(|e| format!("endorsement at index {i}: {e}"))?; + } + Ok(()) + } +} diff --git a/corim/src/validate.rs b/corim/src/validate.rs new file mode 100644 index 0000000..aacdd42 --- /dev/null +++ b/corim/src/validate.rs @@ -0,0 +1,608 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Validation and appraisal logic per draft-ietf-rats-corim-10 §9. +//! +//! Covers reference value matching (Phase 3) and conditional-endorsement-series +//! application (Phase 4). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::cbor; +use crate::error::ValidationError; +use crate::types::comid::ComidTag; +use crate::types::corim::{ConciseTagChoice, ConciseTlTag, CorimMap}; +use crate::types::coswid::ConciseSwidTag; +use crate::types::environment::EnvironmentMap; +use crate::types::measurement::{Digest, MeasurementMap, SvnChoice}; +use crate::types::tags::TAG_CORIM; +use crate::types::triples::{ + ConditionalEndorsementSeriesTriple, ConditionalSeriesRecord, ReferenceTriple, +}; +use crate::Validate; + +// --------------------------------------------------------------------------- +// Phase 1: Input validation (§9.2) +// --------------------------------------------------------------------------- + +/// Maximum allowed CoRIM payload size (16 MiB). +/// +/// Prevents denial-of-service from unbounded memory allocation when decoding +/// untrusted input. +pub const MAX_PAYLOAD_SIZE: usize = 16 * 1024 * 1024; + +/// Result of decoding and validating a CoRIM document. +/// +/// Contains the decoded `CorimMap` and all extracted/validated tags. +#[derive(Clone, Debug)] +pub struct ValidatedCorim { + /// The decoded top-level CoRIM map. + pub corim: CorimMap, + /// Decoded CoMID tags (tag 506). + pub comids: Vec, + /// Decoded CoTL tags (tag 508). + pub cotls: Vec, + /// Decoded CoSWID tags (tag 505). + pub coswids: Vec, + /// Number of CoSWID tags that failed structured decoding (opaque). + pub coswid_opaque_count: usize, +} + +/// Decode CBOR bytes as a CoRIM and validate structural requirements. +/// +/// Expects the bytes to be a CBOR tag-501-wrapped `corim-map`. Validates: +/// - Payload does not exceed [`MAX_PAYLOAD_SIZE`] +/// - `rim-validity` is not expired (if present) +/// - Each CoMID tag decodes correctly and has non-empty triples +/// +/// Uses the system clock for validity checks. For deterministic testing, +/// use [`decode_and_validate_at`] with an explicit timestamp. +pub fn decode_and_validate(bytes: &[u8]) -> Result<(CorimMap, Vec), ValidationError> { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| ValidationError::Clock(e.to_string()))? + .as_secs(); + let now = i64::try_from(secs) + .map_err(|_| ValidationError::Clock("system clock beyond i64 range".into()))?; + decode_and_validate_at(bytes, now) +} + +/// Decode and validate a CoRIM with an explicit "now" timestamp. +/// +/// Same as [`decode_and_validate`] but uses the provided `now_epoch_secs` +/// instead of the system clock. This is useful for deterministic testing. +pub fn decode_and_validate_at( + bytes: &[u8], + now_epoch_secs: i64, +) -> Result<(CorimMap, Vec), ValidationError> { + let validated = decode_and_validate_full_impl(bytes, now_epoch_secs)?; + Ok((validated.corim, validated.comids)) +} + +/// Validate a single CoMID tag. +fn validate_comid(comid: &ComidTag) -> Result<(), ValidationError> { + comid.valid().map_err(ValidationError::Invalid) +} + +/// Validate a single CoTL tag (§6.1). +/// +/// Checks: +/// - `tags-list` is non-empty +/// - `tl-validity` is within the current time window +fn validate_cotl(cotl: &ConciseTlTag, now_epoch_secs: i64) -> Result<(), ValidationError> { + if cotl.tags_list.is_empty() { + return Err(ValidationError::EmptyTagsList); + } + + // Check CoTL validity window + if let Some(nb) = cotl.tl_validity.not_before { + if now_epoch_secs < nb.epoch_secs() { + return Err(ValidationError::NotYetValid); + } + } + if cotl.tl_validity.not_after.epoch_secs() < now_epoch_secs { + return Err(ValidationError::Expired); + } + + Ok(()) +} + +/// Decode and validate a CoRIM, returning all extracted tag types. +/// +/// Like [`decode_and_validate`] but returns a [`ValidatedCorim`] with +/// CoMID, CoTL, and CoSWID counts. +pub fn decode_and_validate_full(bytes: &[u8]) -> Result { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| ValidationError::Clock(e.to_string()))? + .as_secs(); + let now = i64::try_from(secs) + .map_err(|_| ValidationError::Clock("system clock beyond i64 range".into()))?; + decode_and_validate_full_at(bytes, now) +} + +/// Decode and validate a CoRIM with an explicit timestamp, returning all tag types. +pub fn decode_and_validate_full_at( + bytes: &[u8], + now_epoch_secs: i64, +) -> Result { + decode_and_validate_full_impl(bytes, now_epoch_secs) +} + +/// Internal unified implementation — decodes all tags in a single pass. +fn decode_and_validate_full_impl( + bytes: &[u8], + now_epoch_secs: i64, +) -> Result { + if bytes.len() > MAX_PAYLOAD_SIZE { + return Err(ValidationError::PayloadTooLarge { + size: bytes.len(), + max: MAX_PAYLOAD_SIZE, + }); + } + // Decode the tag-501 wrapped CoRIM + let tagged: cbor::value::Tagged = + cbor::decode(bytes).map_err(ValidationError::Decode)?; + if tagged.tag != TAG_CORIM { + return Err(ValidationError::Decode( + crate::error::DecodeError::UnexpectedTag { + expected: TAG_CORIM, + found: tagged.tag, + }, + )); + } + let corim = tagged.value; + + // Check rim-validity + if let Some(ref validity) = corim.rim_validity { + if let Some(nb) = validity.not_before { + if now_epoch_secs < nb.epoch_secs() { + return Err(ValidationError::NotYetValid); + } + } + if validity.not_after.epoch_secs() < now_epoch_secs { + return Err(ValidationError::Expired); + } + } + + // Extract and validate each tag — single pass + let mut comids = Vec::new(); + let mut cotls = Vec::new(); + let mut coswids = Vec::new(); + let mut coswid_opaque_count = 0usize; + for tag in &corim.tags { + match tag { + ConciseTagChoice::Comid(comid_bytes) => { + let comid: ComidTag = cbor::decode(comid_bytes).map_err(ValidationError::Decode)?; + validate_comid(&comid)?; + comids.push(comid); + } + ConciseTagChoice::Cotl(cotl_bytes) => { + let cotl: ConciseTlTag = + cbor::decode(cotl_bytes).map_err(ValidationError::Decode)?; + validate_cotl(&cotl, now_epoch_secs)?; + cotls.push(cotl); + } + ConciseTagChoice::Coswid(coswid_bytes) => { + // Try structured decode; fall back to opaque count + match cbor::decode::(coswid_bytes) { + Ok(coswid) => { + coswid.valid().map_err(ValidationError::Invalid)?; + coswids.push(coswid); + } + Err(_) => coswid_opaque_count += 1, + } + } + _ => { + // Unknown tag types: forward-compat, skip silently + } + } + } + + if comids.is_empty() { + return Err(ValidationError::NoComidTags); + } + + Ok(ValidatedCorim { + corim, + comids, + cotls, + coswids, + coswid_opaque_count, + }) +} + +// --------------------------------------------------------------------------- +// Phase 3: Reference value matching (§9.3.3) +// --------------------------------------------------------------------------- + +/// A claim that has been corroborated by reference value matching. +#[derive(Clone, Debug)] +pub struct CorroboratedClaim { + /// The environment that was matched. + pub environment: EnvironmentMap, + /// The measurement(s) that matched. + pub measurements: Vec, +} + +/// Match reference values against evidence digests. +/// +/// For each `ReferenceTriple`, compares the environment and digests per +/// §9.4.2 (environment) and §9.4.6.1.3 (digests): +/// - Absent condition field = wildcard +/// - All common algorithms must agree +pub fn match_reference_values( + ref_triples: &[ReferenceTriple], + evidence: &[EvidenceClaim], +) -> Vec { + let mut corroborated = Vec::new(); + + for triple in ref_triples { + for ev in evidence { + if !environment_matches(triple.environment(), &ev.environment) { + continue; + } + + let mut matched_measurements = Vec::new(); + for ref_meas in triple.measurements() { + if measurement_matches(ref_meas, &ev.measurements) { + matched_measurements.push(ref_meas.clone()); + } + } + + if !matched_measurements.is_empty() { + corroborated.push(CorroboratedClaim { + environment: triple.environment().clone(), + measurements: matched_measurements, + }); + } + } + } + + corroborated +} + +/// An evidence claim (from the attestation report). +#[derive(Clone, Debug)] +pub struct EvidenceClaim { + /// The environment this evidence belongs to. + pub environment: EnvironmentMap, + /// The measurements reported in evidence. + pub measurements: Vec, +} + +// --------------------------------------------------------------------------- +// Phase 4: Conditional endorsement series (§9.3.4.3) +// --------------------------------------------------------------------------- + +/// An endorsed claim produced by conditional endorsement series matching. +#[derive(Clone, Debug)] +pub struct EndorsedClaim { + /// The environment that was matched. + pub environment: EnvironmentMap, + /// The endorsement values that were applied. + pub endorsements: Vec, +} + +/// Apply conditional-endorsement-series triples to produce endorsed claims. +/// +/// Per §9.3.4.3: +/// 1. Match condition environment against provided evidence +/// 2. Iterate series in order — first `selection` match wins +/// 3. Apply the corresponding `addition` as endorsed values +pub fn apply_endorsement_series( + ces_triples: &[ConditionalEndorsementSeriesTriple], + evidence: &[EvidenceClaim], +) -> Result, ValidationError> { + let mut endorsed = Vec::new(); + + for triple in ces_triples { + let condition = triple.condition(); + + let matching_evidence: Vec<_> = evidence + .iter() + .filter(|ev| environment_matches(&condition.environment, &ev.environment)) + .collect(); + + if matching_evidence.is_empty() { + continue; + } + + validate_series_mkeys(triple.series())?; + + for ev in &matching_evidence { + if let Some(addition) = find_matching_series(triple.series(), &ev.measurements) { + endorsed.push(EndorsedClaim { + environment: condition.environment.clone(), + endorsements: addition, + }); + } + } + } + + Ok(endorsed) +} + +/// Validate that all series entries use the same `mkey`s (§5.1.8.1.1). +/// +/// Comparison is set-based (order-independent) to handle producers that +/// may reorder measurement-map entries within a series record. +fn validate_series_mkeys(series: &[ConditionalSeriesRecord]) -> Result<(), ValidationError> { + if series.len() <= 1 { + return Ok(()); + } + + let collect_mkeys = |record: &ConditionalSeriesRecord| -> Vec { + let mut keys: Vec = record + .selection() + .iter() + .map(|m| format!("{:?}", m.mkey)) + .collect(); + keys.sort(); + keys + }; + + let first_mkeys = collect_mkeys(&series[0]); + + for record in &series[1..] { + if collect_mkeys(record) != first_mkeys { + return Err(ValidationError::InconsistentMkeys); + } + } + + Ok(()) +} + +/// Find the first matching series entry and return its addition. +fn find_matching_series( + series: &[ConditionalSeriesRecord], + evidence_measurements: &[MeasurementMap], +) -> Option> { + for record in series { + let all_match = record + .selection() + .iter() + .all(|sel| measurement_matches(sel, evidence_measurements)); + + if all_match { + return Some(record.addition().to_vec()); + } + } + None +} + +// --------------------------------------------------------------------------- +// SVN comparison (§9.4.6.1.2) +// --------------------------------------------------------------------------- + +/// Compare an SVN value against evidence. +/// +/// - `ExactValue(n)`: evidence SVN must equal `n` +/// - `MinValue(n)`: evidence SVN must be `>= n` +pub fn svn_matches(reference: &SvnChoice, evidence_svn: u64) -> bool { + match reference { + SvnChoice::ExactValue(n) => evidence_svn == *n, + SvnChoice::MinValue(n) => evidence_svn >= *n, + } +} + +// --------------------------------------------------------------------------- +// Comparison helpers (§9.4) +// --------------------------------------------------------------------------- + +/// Compare two environments per §9.4.2. +/// +/// Absent fields in the condition are wildcards. +fn environment_matches(condition: &EnvironmentMap, target: &EnvironmentMap) -> bool { + if let Some(ref cond_class) = condition.class { + match &target.class { + None => return false, + Some(tgt_class) => { + if !class_matches(cond_class, tgt_class) { + return false; + } + } + } + } + + if condition.instance.is_some() && condition.instance != target.instance { + return false; + } + + if condition.group.is_some() && condition.group != target.group { + return false; + } + + true +} + +/// Compare two class-maps. Absent condition fields are wildcards. +fn class_matches( + condition: &crate::types::environment::ClassMap, + target: &crate::types::environment::ClassMap, +) -> bool { + if condition.class_id.is_some() && condition.class_id != target.class_id { + return false; + } + if condition.vendor.is_some() && condition.vendor != target.vendor { + return false; + } + if condition.model.is_some() && condition.model != target.model { + return false; + } + if condition.layer.is_some() && condition.layer != target.layer { + return false; + } + if condition.index.is_some() && condition.index != target.index { + return false; + } + true +} + +/// Check if a reference measurement matches any evidence measurement. +/// +/// Matching is per §9.4.6: compares `mkey`, `digests` (§9.4.6.1.3), +/// and `svn` (§9.4.6.1.2) when present in the reference. +fn measurement_matches(reference: &MeasurementMap, evidence: &[MeasurementMap]) -> bool { + for ev_meas in evidence { + // Match mkey if specified in reference + if let Some(ref ref_mkey) = reference.mkey { + match &ev_meas.mkey { + Some(ev_mkey) if ev_mkey == ref_mkey => {} + _ => continue, + } + } + + // Match digests if present in reference (§9.4.6.1.3) + if let Some(ref ref_digests) = reference.mval.digests { + if let Some(ref ev_digests) = ev_meas.mval.digests { + if !digests_match(ref_digests, ev_digests) { + continue; + } + } else { + continue; // reference requires digests but evidence lacks them + } + } + + // Match SVN if present in reference (§9.4.6.1.2) + if let Some(ref ref_svn) = reference.mval.svn { + if let Some(ref ev_svn) = ev_meas.mval.svn { + let ev_val = match ev_svn { + SvnChoice::ExactValue(n) | SvnChoice::MinValue(n) => *n, + }; + if !svn_matches(ref_svn, ev_val) { + continue; + } + } else { + continue; // reference requires svn but evidence lacks it + } + } + + return true; + } + false +} + +/// Compare digest lists per §9.4.6.1.3. +fn digests_match(reference: &[Digest], evidence: &[Digest]) -> bool { + let mut has_common = false; + + for ref_d in reference { + for ev_d in evidence { + if ref_d.alg() == ev_d.alg() { + has_common = true; + if ref_d.value() != ev_d.value() { + return false; + } + } + } + } + + has_common +} + +// --------------------------------------------------------------------------- +// Appraisal context +// --------------------------------------------------------------------------- + +/// The type of a claim in the Appraisal Context Set. +#[derive(Clone, Debug, PartialEq)] +pub enum ClaimType { + /// Claim from attestation evidence. + Evidence, + /// Claim corroborated by reference values (Phase 3). + ReferenceValues, + /// Claim endorsed by endorsement triples (Phase 4). + Endorsement, +} + +/// An entry in the Appraisal Context Set (ACS). +#[derive(Clone, Debug)] +pub struct EnvironmentClaimTuple { + /// The environment this claim is about. + pub environment: EnvironmentMap, + /// The measurements/endorsements. + pub measurements: Vec, + /// The claim type. + pub claim_type: ClaimType, +} + +/// The Appraisal Context Set — accumulates claims across appraisal phases. +#[derive(Clone, Debug, Default)] +pub struct AppraisalContext { + /// All claim entries. + pub entries: Vec, +} + +impl AppraisalContext { + /// Create a new empty appraisal context. + pub fn new() -> Self { + Self::default() + } + + /// Initialize with evidence claims (Phase 2). + pub fn add_evidence(&mut self, claims: Vec) { + for claim in claims { + self.entries.push(EnvironmentClaimTuple { + environment: claim.environment, + measurements: claim.measurements, + claim_type: ClaimType::Evidence, + }); + } + } + + /// Apply reference value matching (Phase 3). + pub fn apply_reference_values( + &mut self, + ref_triples: &[ReferenceTriple], + ) -> Vec { + let evidence: Vec = self + .entries + .iter() + .filter(|e| e.claim_type == ClaimType::Evidence) + .map(|e| EvidenceClaim { + environment: e.environment.clone(), + measurements: e.measurements.clone(), + }) + .collect(); + + let corroborated = match_reference_values(ref_triples, &evidence); + + for claim in &corroborated { + self.entries.push(EnvironmentClaimTuple { + environment: claim.environment.clone(), + measurements: claim.measurements.clone(), + claim_type: ClaimType::ReferenceValues, + }); + } + + corroborated + } + + /// Apply conditional endorsement series (Phase 4). + pub fn apply_conditional_endorsements( + &mut self, + ces_triples: &[ConditionalEndorsementSeriesTriple], + ) -> Result, ValidationError> { + let evidence: Vec = self + .entries + .iter() + .map(|e| EvidenceClaim { + environment: e.environment.clone(), + measurements: e.measurements.clone(), + }) + .collect(); + + let endorsed = apply_endorsement_series(ces_triples, &evidence)?; + + for claim in &endorsed { + self.entries.push(EnvironmentClaimTuple { + environment: claim.environment.clone(), + measurements: claim.endorsements.clone(), + claim_type: ClaimType::Endorsement, + }); + } + + Ok(endorsed) + } +} diff --git a/corim/tests/cbor_rfc_conformance_tests.rs b/corim/tests/cbor_rfc_conformance_tests.rs new file mode 100644 index 0000000..5478380 --- /dev/null +++ b/corim/tests/cbor_rfc_conformance_tests.rs @@ -0,0 +1,698 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CBOR RFC 8949 conformance tests for the in-house minimal encoder/decoder. +//! +//! Tests are organized by RFC section. Test vectors come from RFC 8949 +//! Appendix A and the CBOR diagnostic notation examples in the spec. + +use corim::cbor::minimal::{decode_value, encode_value}; +use corim::cbor::value::Value; + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn enc(val: &Value) -> Vec { + let mut buf = Vec::new(); + encode_value(&mut buf, val).unwrap(); + buf +} + +fn dec(bytes: &[u8]) -> Value { + decode_value(&mut &bytes[..]).unwrap() +} + +fn rt(val: &Value) -> Value { + dec(&enc(val)) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RFC 8949 Appendix A — Diagnostic notation examples +// ═══════════════════════════════════════════════════════════════════════════ + +// §A: unsigned integers +#[test] +fn rfc_a_0() { + assert_eq!(enc(&Value::Integer(0)), vec![0x00]); +} +#[test] +fn rfc_a_1() { + assert_eq!(enc(&Value::Integer(1)), vec![0x01]); +} +#[test] +fn rfc_a_10() { + assert_eq!(enc(&Value::Integer(10)), vec![0x0a]); +} +#[test] +fn rfc_a_23() { + assert_eq!(enc(&Value::Integer(23)), vec![0x17]); +} +#[test] +fn rfc_a_24() { + assert_eq!(enc(&Value::Integer(24)), vec![0x18, 0x18]); +} +#[test] +fn rfc_a_25() { + assert_eq!(enc(&Value::Integer(25)), vec![0x18, 0x19]); +} +#[test] +fn rfc_a_100() { + assert_eq!(enc(&Value::Integer(100)), vec![0x18, 0x64]); +} +#[test] +fn rfc_a_1000() { + assert_eq!(enc(&Value::Integer(1000)), vec![0x19, 0x03, 0xe8]); +} +#[test] +fn rfc_a_1000000() { + assert_eq!( + enc(&Value::Integer(1000000)), + vec![0x1a, 0x00, 0x0f, 0x42, 0x40] + ); +} +#[test] +fn rfc_a_1000000000000() { + assert_eq!( + enc(&Value::Integer(1000000000000)), + vec![0x1b, 0x00, 0x00, 0x00, 0xe8, 0xd4, 0xa5, 0x10, 0x00] + ); +} + +// §A: negative integers +#[test] +fn rfc_a_neg1() { + assert_eq!(enc(&Value::Integer(-1)), vec![0x20]); +} +#[test] +fn rfc_a_neg10() { + assert_eq!(enc(&Value::Integer(-10)), vec![0x29]); +} +#[test] +fn rfc_a_neg100() { + assert_eq!(enc(&Value::Integer(-100)), vec![0x38, 0x63]); +} +#[test] +fn rfc_a_neg1000() { + assert_eq!(enc(&Value::Integer(-1000)), vec![0x39, 0x03, 0xe7]); +} + +// §A: byte strings +#[test] +fn rfc_a_empty_bytes() { + assert_eq!(enc(&Value::Bytes(vec![])), vec![0x40]); +} +#[test] +fn rfc_a_bytes_01020304() { + assert_eq!( + enc(&Value::Bytes(vec![0x01, 0x02, 0x03, 0x04])), + vec![0x44, 0x01, 0x02, 0x03, 0x04] + ); +} + +// §A: text strings +#[test] +fn rfc_a_empty_text() { + assert_eq!(enc(&Value::Text("".into())), vec![0x60]); +} +#[test] +fn rfc_a_text_a() { + assert_eq!(enc(&Value::Text("a".into())), vec![0x61, 0x61]); +} +#[test] +fn rfc_a_text_ietf() { + assert_eq!( + enc(&Value::Text("IETF".into())), + vec![0x64, 0x49, 0x45, 0x54, 0x46] + ); +} +#[test] +fn rfc_a_text_quote_backslash() { + assert_eq!(enc(&Value::Text("\"\\".into())), vec![0x62, 0x22, 0x5c]); +} +#[test] +fn rfc_a_text_unicode_u00fc() { + assert_eq!(enc(&Value::Text("\u{00fc}".into())), vec![0x62, 0xc3, 0xbc]); +} +#[test] +fn rfc_a_text_unicode_u6c34() { + assert_eq!( + enc(&Value::Text("\u{6c34}".into())), + vec![0x63, 0xe6, 0xb0, 0xb4] + ); +} + +// §A: arrays +#[test] +fn rfc_a_empty_array() { + assert_eq!(enc(&Value::Array(vec![])), vec![0x80]); +} +#[test] +fn rfc_a_array_123() { + let v = Value::Array(vec![ + Value::Integer(1), + Value::Integer(2), + Value::Integer(3), + ]); + assert_eq!(enc(&v), vec![0x83, 0x01, 0x02, 0x03]); +} +#[test] +fn rfc_a_nested_array() { + // [1, [2, 3], [4, 5]] + let v = Value::Array(vec![ + Value::Integer(1), + Value::Array(vec![Value::Integer(2), Value::Integer(3)]), + Value::Array(vec![Value::Integer(4), Value::Integer(5)]), + ]); + assert_eq!( + enc(&v), + vec![0x83, 0x01, 0x82, 0x02, 0x03, 0x82, 0x04, 0x05] + ); +} +#[test] +fn rfc_a_25_element_array() { + // [1, 2, ... 25] + let v = Value::Array((1..=25).map(Value::Integer).collect()); + let bytes = enc(&v); + assert_eq!(bytes[0], 0x98); // array with 1-byte length + assert_eq!(bytes[1], 25); + // 23 elements fit in 1 byte each (1..=23), 2 elements need 2 bytes (24,25) + assert_eq!(bytes.len(), 2 + 23 + 2 * 2); +} + +// §A: maps +#[test] +fn rfc_a_empty_map() { + assert_eq!(enc(&Value::Map(vec![])), vec![0xa0]); +} +#[test] +fn rfc_a_map_12_34() { + // {1: 2, 3: 4} + let v = Value::Map(vec![ + (Value::Integer(1), Value::Integer(2)), + (Value::Integer(3), Value::Integer(4)), + ]); + assert_eq!(enc(&v), vec![0xa2, 0x01, 0x02, 0x03, 0x04]); +} +#[test] +fn rfc_a_map_text_keys() { + // {"a": 1, "b": [2, 3]} + let v = Value::Map(vec![ + (Value::Text("a".into()), Value::Integer(1)), + ( + Value::Text("b".into()), + Value::Array(vec![Value::Integer(2), Value::Integer(3)]), + ), + ]); + assert_eq!( + enc(&v), + vec![0xa2, 0x61, 0x61, 0x01, 0x61, 0x62, 0x82, 0x02, 0x03] + ); +} + +// §A: simple values +#[test] +fn rfc_a_false() { + assert_eq!(enc(&Value::Bool(false)), vec![0xf4]); +} +#[test] +fn rfc_a_true() { + assert_eq!(enc(&Value::Bool(true)), vec![0xf5]); +} +#[test] +fn rfc_a_null() { + assert_eq!(enc(&Value::Null), vec![0xf6]); +} + +// §A: tags +#[test] +fn rfc_a_tag_0_text() { + // 0("2013-03-21T20:04:00Z") — tag 0 wrapping text + let v = Value::Tag(0, Box::new(Value::Text("2013-03-21T20:04:00Z".into()))); + let bytes = enc(&v); + assert_eq!(bytes[0], 0xc0); // tag 0 + assert_eq!(bytes[1], 0x74); // text(20) +} +#[test] +fn rfc_a_tag_1_int() { + // 1(1363896240) — epoch time + let v = Value::Tag(1, Box::new(Value::Integer(1363896240))); + let bytes = enc(&v); + assert_eq!(bytes[0], 0xc1); // tag 1 + assert_eq!(bytes[1], 0x1a); // uint32 +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §4.2.1 — Deterministic encoding (Core Deterministic Encoding Requirements) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn deterministic_preferred_integer_encoding() { + // "Integers MUST be encoded as per Section 3.4.5.2." + // → preferred serialization = shortest form + assert_eq!(enc(&Value::Integer(0)).len(), 1); + assert_eq!(enc(&Value::Integer(23)).len(), 1); + assert_eq!(enc(&Value::Integer(24)).len(), 2); + assert_eq!(enc(&Value::Integer(255)).len(), 2); + assert_eq!(enc(&Value::Integer(256)).len(), 3); + assert_eq!(enc(&Value::Integer(65535)).len(), 3); + assert_eq!(enc(&Value::Integer(65536)).len(), 5); + assert_eq!(enc(&Value::Integer(4294967295)).len(), 5); + assert_eq!(enc(&Value::Integer(4294967296)).len(), 9); +} + +#[test] +fn deterministic_negative_shortest() { + assert_eq!(enc(&Value::Integer(-1)).len(), 1); + assert_eq!(enc(&Value::Integer(-24)).len(), 1); + assert_eq!(enc(&Value::Integer(-25)).len(), 2); + assert_eq!(enc(&Value::Integer(-256)).len(), 2); + assert_eq!(enc(&Value::Integer(-257)).len(), 3); +} + +#[test] +fn deterministic_length_encoding() { + // String/bytes/array/map length MUST use shortest form + let bytes_23 = Value::Bytes(vec![0u8; 23]); + assert_eq!(enc(&bytes_23)[0], 0x57); // major 2, inline 23 + + let bytes_24 = Value::Bytes(vec![0u8; 24]); + assert_eq!(enc(&bytes_24)[0], 0x58); // major 2, 1-byte length + assert_eq!(enc(&bytes_24)[1], 24); + + let bytes_255 = Value::Bytes(vec![0u8; 255]); + assert_eq!(enc(&bytes_255)[0], 0x58); + assert_eq!(enc(&bytes_255)[1], 255); + + let bytes_256 = Value::Bytes(vec![0u8; 256]); + assert_eq!(enc(&bytes_256)[0], 0x59); // major 2, 2-byte length +} + +#[test] +fn deterministic_map_key_order_preserved() { + // Our encoder preserves insertion order. The derive macro emits keys + // in ascending order. Verify the bytes match the insertion order. + let map = Value::Map(vec![ + (Value::Integer(0), Value::Text("a".into())), + (Value::Integer(1), Value::Text("b".into())), + (Value::Integer(2), Value::Text("c".into())), + ]); + let bytes = enc(&map); + // Keys should appear as 0, 1, 2 + assert_eq!( + bytes, + vec![ + 0xa3, // map(3) + 0x00, 0x61, 0x61, // 0 => "a" + 0x01, 0x61, 0x62, // 1 => "b" + 0x02, 0x61, 0x63, // 2 => "c" + ] + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §3.1 — Major type decoding +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn decode_unsigned_inline() { + assert_eq!(dec(&[0x00]), Value::Integer(0)); +} +#[test] +fn decode_unsigned_1byte() { + assert_eq!(dec(&[0x18, 0x64]), Value::Integer(100)); +} +#[test] +fn decode_unsigned_2byte() { + assert_eq!(dec(&[0x19, 0x03, 0xe8]), Value::Integer(1000)); +} +#[test] +fn decode_unsigned_4byte() { + assert_eq!( + dec(&[0x1a, 0x00, 0x0f, 0x42, 0x40]), + Value::Integer(1000000) + ); +} +#[test] +fn decode_unsigned_8byte() { + assert_eq!( + dec(&[0x1b, 0x00, 0x00, 0x00, 0xe8, 0xd4, 0xa5, 0x10, 0x00]), + Value::Integer(1000000000000) + ); +} +#[test] +fn decode_negative_inline() { + assert_eq!(dec(&[0x20]), Value::Integer(-1)); +} +#[test] +fn decode_negative_1byte() { + assert_eq!(dec(&[0x38, 0x63]), Value::Integer(-100)); +} +#[test] +fn decode_negative_2byte() { + assert_eq!(dec(&[0x39, 0x03, 0xe7]), Value::Integer(-1000)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §3.3 — Simple values and floats +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn decode_false() { + assert_eq!(dec(&[0xf4]), Value::Bool(false)); +} +#[test] +fn decode_true() { + assert_eq!(dec(&[0xf5]), Value::Bool(true)); +} +#[test] +fn decode_null() { + assert_eq!(dec(&[0xf6]), Value::Null); +} + +#[test] +fn decode_float16_zero() { + assert_eq!(dec(&[0xf9, 0x00, 0x00]), Value::Float(0.0)); +} +#[test] +fn decode_float16_one() { + let v = dec(&[0xf9, 0x3c, 0x00]); + if let Value::Float(f) = v { + assert!((f - 1.0).abs() < 1e-10); + } else { + panic!(); + } +} +#[test] +fn decode_float16_inf() { + assert_eq!(dec(&[0xf9, 0x7c, 0x00]), Value::Float(f64::INFINITY)); +} +#[test] +fn decode_float16_neg_inf() { + assert_eq!(dec(&[0xf9, 0xfc, 0x00]), Value::Float(f64::NEG_INFINITY)); +} +#[test] +fn decode_float16_nan() { + if let Value::Float(f) = dec(&[0xf9, 0x7e, 0x00]) { + assert!(f.is_nan()); + } else { + panic!(); + } +} + +#[test] +fn decode_float32() { + let v = dec(&[0xfa, 0x47, 0xc3, 0x50, 0x00]); // 100000.0f + if let Value::Float(f) = v { + assert!((f - 100000.0).abs() < 0.1); + } else { + panic!(); + } +} + +#[test] +fn decode_float64() { + // 1.1 as float64: 0xFB 3F F1 99 99 99 99 99 9A + let v = dec(&[0xfb, 0x3f, 0xf1, 0x99, 0x99, 0x99, 0x99, 0x99, 0x9a]); + if let Value::Float(f) = v { + assert!((f - 1.1).abs() < 1e-15); + } else { + panic!(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §3.4.3 — Semantic tags +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn tag_round_trip_small() { + let v = Value::Tag(1, Box::new(Value::Integer(1363896240))); + assert_eq!(rt(&v), v); +} +#[test] +fn tag_round_trip_large_tag_number() { + let v = Value::Tag(55799, Box::new(Value::Null)); // self-described CBOR + assert_eq!(rt(&v), v); +} +#[test] +fn tag_nested() { + let v = Value::Tag( + 1, + Box::new(Value::Tag(37, Box::new(Value::Bytes(vec![0xAA; 16])))), + ); + assert_eq!(rt(&v), v); +} +#[test] +fn tag_501_corim() { + // Tag 501 wrapping a map — the CoRIM pattern + let v = Value::Tag( + 501, + Box::new(Value::Map(vec![ + (Value::Integer(0), Value::Text("id".into())), + (Value::Integer(1), Value::Array(vec![])), + ])), + ); + assert_eq!(rt(&v), v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §3.2.2 — Byte and text strings +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn bytes_empty_round_trip() { + assert_eq!(rt(&Value::Bytes(vec![])), Value::Bytes(vec![])); +} +#[test] +fn bytes_large_round_trip() { + let big = vec![0xAB; 1000]; + assert_eq!(rt(&Value::Bytes(big.clone())), Value::Bytes(big)); +} +#[test] +fn text_empty_round_trip() { + assert_eq!(rt(&Value::Text("".into())), Value::Text("".into())); +} +#[test] +fn text_utf8_round_trip() { + let s = "こんにちは世界 🌍"; + assert_eq!(rt(&Value::Text(s.into())), Value::Text(s.into())); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Error paths — invalid CBOR +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn error_empty_input() { + assert!(decode_value(&mut &[][..]).is_err()); +} +#[test] +fn error_truncated_uint() { + assert!(decode_value(&mut &[0x18][..]).is_err()); +} // expects 1 byte arg +#[test] +fn error_truncated_bytes() { + assert!(decode_value(&mut &[0x43, 0x01][..]).is_err()); +} // claims 3 bytes, only 1 +#[test] +fn error_truncated_text() { + assert!(decode_value(&mut &[0x64, 0x41][..]).is_err()); +} // claims 4 bytes, only 1 +#[test] +fn error_truncated_float64() { + assert!(decode_value(&mut &[0xfb, 0x00, 0x00][..]).is_err()); +} +#[test] +fn error_truncated_tag() { + assert!(decode_value(&mut &[0xd9, 0x01][..]).is_err()); +} // tag with 2-byte arg, only 1 + +// §3.2.3 — indefinite-length rejection +#[test] +fn error_indefinite_bytes() { + assert!(decode_value(&mut &[0x5f][..]).is_err()); +} +#[test] +fn error_indefinite_text() { + assert!(decode_value(&mut &[0x7f][..]).is_err()); +} +#[test] +fn error_indefinite_array() { + assert!(decode_value(&mut &[0x9f][..]).is_err()); +} +#[test] +fn error_indefinite_map() { + assert!(decode_value(&mut &[0xbf][..]).is_err()); +} + +// §3.3 — unsupported simple values +#[test] +fn error_simple_undefined() { + assert!(decode_value(&mut &[0xf7][..]).is_err()); +} // undefined +#[test] +fn error_simple_reserved() { + assert!(decode_value(&mut &[0xf8, 0x20][..]).is_err()); +} // simple(32) + +// Invalid additional info (28-30 are reserved) +#[test] +fn error_reserved_ai_28() { + assert!(decode_value(&mut &[0x1c][..]).is_err()); +} +#[test] +fn error_reserved_ai_29() { + assert!(decode_value(&mut &[0x1d][..]).is_err()); +} +#[test] +fn error_reserved_ai_30() { + assert!(decode_value(&mut &[0x1e][..]).is_err()); +} + +// Invalid UTF-8 in text string +#[test] +fn error_invalid_utf8() { + // text(2) followed by invalid UTF-8 sequence + assert!(decode_value(&mut &[0x62, 0xff, 0xfe][..]).is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Round-trip fidelity — encode then decode preserves value +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn rt_u64_max() { + assert_eq!( + rt(&Value::Integer(u64::MAX as i128)), + Value::Integer(u64::MAX as i128) + ); +} +#[test] +fn rt_i64_min() { + assert_eq!( + rt(&Value::Integer(i64::MIN as i128)), + Value::Integer(i64::MIN as i128) + ); +} +#[test] +fn rt_float_pi() { + assert_eq!( + rt(&Value::Float(std::f64::consts::PI)), + Value::Float(std::f64::consts::PI) + ); +} +#[test] +fn rt_float_neg_zero() { + assert_eq!(enc(&Value::Float(-0.0))[1], 0x80); +} // sign bit set + +#[test] +fn rt_deeply_nested() { + // 10 levels of nesting + let mut val = Value::Integer(42); + for _ in 0..10 { + val = Value::Array(vec![val]); + } + assert_eq!(rt(&val), val); +} + +#[test] +fn rt_map_with_various_key_types() { + // Input order: int, text, bytes + let map = Value::Map(vec![ + (Value::Integer(0), Value::Text("int-key".into())), + (Value::Text("key".into()), Value::Integer(1)), + (Value::Bytes(vec![0xFF]), Value::Bool(true)), + ]); + // After canonical sort: int (1 byte) < bytes (2 bytes) < text (4 bytes) + let expected = Value::Map(vec![ + (Value::Integer(0), Value::Text("int-key".into())), + (Value::Bytes(vec![0xFF]), Value::Bool(true)), + (Value::Text("key".into()), Value::Integer(1)), + ]); + assert_eq!(rt(&map), expected); +} + +#[test] +fn canonical_map_different_insertion_order_same_bytes() { + // Two maps with same logical entries in different insertion order + // must produce identical CBOR bytes (canonical ordering). + let map_a = Value::Map(vec![ + (Value::Integer(2), Value::Text("c".into())), + (Value::Integer(0), Value::Text("a".into())), + (Value::Integer(1), Value::Text("b".into())), + ]); + let map_b = Value::Map(vec![ + (Value::Integer(1), Value::Text("b".into())), + (Value::Integer(0), Value::Text("a".into())), + (Value::Integer(2), Value::Text("c".into())), + ]); + assert_eq!( + enc(&map_a), + enc(&map_b), + "same map in different order must encode identically" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Full-stack round-trip through MinimalCodec (encode + decode = identity) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn codec_round_trip_integer() { + let v = 42i64; + let bytes = corim::cbor::encode(&v).unwrap(); + let decoded: i64 = corim::cbor::decode(&bytes).unwrap(); + assert_eq!(v, decoded); +} + +#[test] +fn codec_round_trip_string() { + let v = "hello CBOR"; + let bytes = corim::cbor::encode(&v).unwrap(); + let decoded: String = corim::cbor::decode(&bytes).unwrap(); + assert_eq!(v, decoded); +} + +#[test] +fn codec_round_trip_bytes() { + // Bytes go through Value directly + let v = Value::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]); + let bytes = corim::cbor::encode(&v).unwrap(); + let decoded: Value = corim::cbor::decode(&bytes).unwrap(); + assert_eq!(v, decoded); +} + +#[test] +fn codec_round_trip_bool() { + let bytes_t = corim::cbor::encode(&true).unwrap(); + let bytes_f = corim::cbor::encode(&false).unwrap(); + assert_eq!(bytes_t, vec![0xf5]); + assert_eq!(bytes_f, vec![0xf4]); + assert!(corim::cbor::decode::(&bytes_t).unwrap()); + assert!(!corim::cbor::decode::(&bytes_f).unwrap()); +} + +#[test] +fn codec_round_trip_option_some() { + let v: Option = Some(42); + let bytes = corim::cbor::encode(&v).unwrap(); + let decoded: Option = corim::cbor::decode(&bytes).unwrap(); + assert_eq!(v, decoded); +} + +#[test] +fn codec_round_trip_option_none() { + let v: Option = None; + let bytes = corim::cbor::encode(&v).unwrap(); + assert_eq!(bytes, vec![0xf6]); // null +} + +#[test] +fn codec_round_trip_vec() { + let v = vec![1u32, 2, 3, 4, 5]; + let bytes = corim::cbor::encode(&v).unwrap(); + let decoded: Vec = corim::cbor::decode(&bytes).unwrap(); + assert_eq!(v, decoded); +} diff --git a/corim/tests/cddl_conformance_tests.rs b/corim/tests/cddl_conformance_tests.rs new file mode 100644 index 0000000..dc20a37 --- /dev/null +++ b/corim/tests/cddl_conformance_tests.rs @@ -0,0 +1,1331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Comprehensive CDDL conformance tests for draft-ietf-rats-corim-10. +//! +//! Systematically verifies every CDDL production: round-trip encoding, +//! correct CBOR tags, non-empty constraints, all type-choice variants, +//! optional-field handling, and real-world golden-file decoding. + +use corim::cbor; +use corim::types::comid::*; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::tags::*; +use corim::types::triples::*; +use std::collections::BTreeMap; + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +fn round_trip(val: &T) -> T +where + T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug + PartialEq, +{ + let bytes = cbor::encode(val).expect("encode failed"); + let decoded: T = cbor::decode(&bytes).expect("decode failed"); + assert_eq!(val, &decoded, "round-trip mismatch"); + decoded +} + +fn assert_cbor_tag(val: &T, expected_tag: u64) { + let bytes = cbor::encode(val).unwrap(); + let raw: corim::cbor::value::Value = cbor::decode(&bytes).unwrap(); + match raw { + corim::cbor::value::Value::Tag(t, _) => { + assert_eq!( + t, expected_tag, + "expected CBOR tag {}, got {}", + expected_tag, t + ); + } + _ => panic!("expected tagged value, got {:?}", raw), + } +} + +fn make_env() -> EnvironmentMap { + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("V".into()), + model: Some("M".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + } +} + +fn make_meas(mkey: &str) -> MeasurementMap { + MeasurementMap { + mkey: Some(MeasuredElement::Text(mkey.into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §7.4 — $tag-id-type-choice / $corim-id-type-choice (tags: text / #6.37) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn tag_id_text_round_trip() { + round_trip(&TagIdChoice::Text("my-tag-id".into())); +} + +#[test] +fn tag_id_uuid_round_trip_and_tag() { + let v = TagIdChoice::Uuid([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]); + assert_cbor_tag(&v, TAG_UUID); + round_trip(&v); +} + +#[test] +fn corim_id_text_round_trip() { + round_trip(&CorimId::Text("corim-001".into())); +} + +#[test] +fn corim_id_uuid_round_trip_and_tag() { + let v = CorimId::Uuid([0xAB; 16]); + assert_cbor_tag(&v, TAG_UUID); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.2 — $class-id-type-choice (#6.111 / #6.37 / #6.560) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn class_id_oid() { + let v = ClassIdChoice::Oid(vec![0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37]); + assert_cbor_tag(&v, TAG_OID); + round_trip(&v); +} + +#[test] +fn class_id_uuid() { + let v = ClassIdChoice::Uuid([0xCD; 16]); + assert_cbor_tag(&v, TAG_UUID); + round_trip(&v); +} + +#[test] +fn class_id_bytes() { + let v = ClassIdChoice::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]); + assert_cbor_tag(&v, TAG_BYTES); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.3 — $instance-id-type-choice (9 variants) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn instance_id_ueid() { + let v = InstanceIdChoice::Ueid(vec![0x01; 17]); + assert_cbor_tag(&v, TAG_UEID); + round_trip(&v); +} + +#[test] +fn instance_id_uuid() { + let v = InstanceIdChoice::Uuid([0x42; 16]); + assert_cbor_tag(&v, TAG_UUID); + round_trip(&v); +} + +#[test] +fn instance_id_bytes() { + let v = InstanceIdChoice::Bytes(vec![0xFF; 8]); + assert_cbor_tag(&v, TAG_BYTES); + round_trip(&v); +} + +#[test] +fn instance_id_pkix_base64_key() { + let v = InstanceIdChoice::PkixBase64Key("MIIBIjANBgkqhki...".into()); + assert_cbor_tag(&v, TAG_PKIX_BASE64_KEY); + round_trip(&v); +} + +#[test] +fn instance_id_pkix_base64_cert() { + let v = InstanceIdChoice::PkixBase64Cert("MIICpDCCAYwCAgP...".into()); + assert_cbor_tag(&v, TAG_PKIX_BASE64_CERT); + round_trip(&v); +} + +#[test] +fn instance_id_cose_key() { + let v = InstanceIdChoice::CoseKey(vec![0xA1, 0x01, 0x02]); + assert_cbor_tag(&v, TAG_COSE_KEY); + round_trip(&v); +} + +#[test] +fn instance_id_key_thumbprint() { + let v = InstanceIdChoice::KeyThumbprint(Digest::new(7, vec![0xAA; 48])); + assert_cbor_tag(&v, TAG_KEY_THUMBPRINT); + round_trip(&v); +} + +#[test] +fn instance_id_cert_thumbprint() { + let v = InstanceIdChoice::CertThumbprint(Digest::new(2, vec![0xBB; 32])); + assert_cbor_tag(&v, TAG_CERT_THUMBPRINT); + round_trip(&v); +} + +#[test] +fn instance_id_pkix_asn1der_cert() { + let v = InstanceIdChoice::PkixAsn1DerCert(vec![0x30, 0x82, 0x01, 0x22]); + assert_cbor_tag(&v, TAG_PKIX_ASN1DER_CERT); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.4 — $group-id-type-choice (#6.37 / #6.560) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn group_id_uuid() { + let v = GroupIdChoice::Uuid([0x99; 16]); + assert_cbor_tag(&v, TAG_UUID); + round_trip(&v); +} + +#[test] +fn group_id_bytes() { + let v = GroupIdChoice::Bytes(vec![0x11, 0x22, 0x33]); + assert_cbor_tag(&v, TAG_BYTES); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.1 — $measured-element-type-choice (OID / UUID / uint / text) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn measured_element_oid() { + let v = MeasuredElement::Oid(vec![0x2B, 0x06]); + assert_cbor_tag(&v, TAG_OID); + round_trip(&v); +} + +#[test] +fn measured_element_uuid() { + let v = MeasuredElement::Uuid([0x77; 16]); + assert_cbor_tag(&v, TAG_UUID); + round_trip(&v); +} + +#[test] +fn measured_element_uint() { + round_trip(&MeasuredElement::Uint(42)); +} + +#[test] +fn measured_element_text() { + round_trip(&MeasuredElement::Text("firmware-digest".into())); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.6 — $crypto-key-type-choice (9 variants, tags 554–562) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn crypto_key_pkix_base64_key() { + let v = CryptoKey::PkixBase64Key("MIIBIjAN...".into()); + assert_cbor_tag(&v, TAG_PKIX_BASE64_KEY); + round_trip(&v); +} + +#[test] +fn crypto_key_pkix_base64_cert() { + let v = CryptoKey::PkixBase64Cert("MIICpDCC...".into()); + assert_cbor_tag(&v, TAG_PKIX_BASE64_CERT); + round_trip(&v); +} + +#[test] +fn crypto_key_pkix_base64_cert_path() { + let v = CryptoKey::PkixBase64CertPath("MIICpDCC...chain...".into()); + assert_cbor_tag(&v, TAG_PKIX_BASE64_CERT_PATH); + round_trip(&v); +} + +#[test] +fn crypto_key_key_thumbprint() { + let v = CryptoKey::KeyThumbprint(Digest::new(7, vec![0xCC; 48])); + assert_cbor_tag(&v, TAG_KEY_THUMBPRINT); + round_trip(&v); +} + +#[test] +fn crypto_key_cose_key() { + let v = CryptoKey::CoseKey(vec![0xA1, 0x01, 0x01]); + assert_cbor_tag(&v, TAG_COSE_KEY); + round_trip(&v); +} + +#[test] +fn crypto_key_cert_thumbprint() { + let v = CryptoKey::CertThumbprint(Digest::new(2, vec![0xDD; 32])); + assert_cbor_tag(&v, TAG_CERT_THUMBPRINT); + round_trip(&v); +} + +#[test] +fn crypto_key_cert_path_thumbprint() { + let v = CryptoKey::CertPathThumbprint(Digest::new(2, vec![0xEE; 32])); + assert_cbor_tag(&v, TAG_CERT_PATH_THUMBPRINT); + round_trip(&v); +} + +#[test] +fn crypto_key_pkix_asn1der_cert() { + let v = CryptoKey::PkixAsn1DerCert(vec![0x30, 0x82, 0x03]); + assert_cbor_tag(&v, TAG_PKIX_ASN1DER_CERT); + round_trip(&v); +} + +#[test] +fn crypto_key_bytes() { + let v = CryptoKey::Bytes(vec![0x01, 0x02, 0x03, 0x04]); + assert_cbor_tag(&v, TAG_BYTES); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §4.1.4 — $profile-type-choice (URI / #6.111) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn profile_uri() { + round_trip(&ProfileChoice::Uri("https://example.com/profile/v1".into())); +} + +#[test] +fn profile_oid() { + let v = ProfileChoice::Oid(vec![0x2B, 0x06, 0x01, 0x04]); + assert_cbor_tag(&v, TAG_OID); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.6 — $raw-value-type-choice (#6.560 / #6.563) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn raw_value_bytes() { + let v = RawValueChoice::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]); + assert_cbor_tag(&v, TAG_BYTES); + round_trip(&v); +} + +#[test] +fn raw_value_masked() { + let v = RawValueChoice::Masked { + value: vec![0xFF; 16], + mask: vec![0x0F; 16], + }; + assert_cbor_tag(&v, TAG_MASKED_RAW_VALUE); + round_trip(&v); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.4 — svn-type-choice (#6.552 / #6.553 / uint) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn svn_exact_tagged() { + let v = SvnChoice::ExactValue(42); + assert_cbor_tag(&v, TAG_SVN); + round_trip(&v); +} + +#[test] +fn svn_min_tagged() { + let v = SvnChoice::MinValue(10); + assert_cbor_tag(&v, TAG_MIN_SVN); + round_trip(&v); +} + +#[test] +fn svn_untagged_uint_decodes_as_exact() { + // Encode a raw unsigned int — must decode as ExactValue + let bytes = cbor::encode(&42u64).unwrap(); + let decoded: SvnChoice = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, SvnChoice::ExactValue(42)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.7 — mac-addr-type-choice / ip-addr-type-choice +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn mac_addr_eui48() { + round_trip(&MacAddr::Eui48([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])); +} + +#[test] +fn mac_addr_eui64() { + round_trip(&MacAddr::Eui64([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + ])); +} + +#[test] +fn ip_addr_v4() { + round_trip(&IpAddr::V4([10, 0, 0, 1])); +} + +#[test] +fn ip_addr_v6() { + round_trip(&IpAddr::V6([ + 0xFE, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ])); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.8 — int-range-type-choice (int / #6.564) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn int_range_plain_int() { + round_trip(&IntRangeChoice::Int(42)); +} + +#[test] +fn int_range_bounded() { + let v = IntRangeChoice::Range { + min: Some(-10), + max: Some(100), + }; + assert_cbor_tag(&v, TAG_INT_RANGE); + round_trip(&v); +} + +#[test] +fn int_range_negative_inf() { + round_trip(&IntRangeChoice::Range { + min: None, + max: Some(50), + }); +} + +#[test] +fn int_range_positive_inf() { + round_trip(&IntRangeChoice::Range { + min: Some(0), + max: None, + }); +} + +#[test] +fn int_range_both_inf() { + round_trip(&IntRangeChoice::Range { + min: None, + max: None, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.7 — integrity-registers +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn integrity_registers_mixed_keys() { + let mut m = BTreeMap::new(); + m.insert( + IntegrityRegisterId::Uint(0), + vec![Digest::new(7, vec![0xAA; 48])], + ); + m.insert( + IntegrityRegisterId::Text("PCR1".into()), + vec![ + Digest::new(7, vec![0xBB; 48]), + Digest::new(2, vec![0xCC; 32]), + ], + ); + let v = IntegrityRegisters(m); + let bytes = cbor::encode(&v).unwrap(); + let decoded: IntegrityRegisters = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.0.len(), 2); + assert_eq!( + decoded.0[&IntegrityRegisterId::Text("PCR1".into())].len(), + 2 + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.5 — flags-map (round-trip + non-empty) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn flags_map_all_fields() { + round_trip(&FlagsMap { + is_configured: Some(true), + is_secure: Some(true), + is_recovery: Some(false), + is_debug: Some(false), + is_replay_protected: Some(true), + is_integrity_protected: Some(true), + is_runtime_meas: Some(false), + is_immutable: Some(true), + is_tcb: Some(true), + is_confidentiality_protected: Some(false), + }); +} + +#[test] +fn flags_map_single_field() { + round_trip(&FlagsMap { + is_configured: None, + is_secure: None, + is_recovery: None, + is_debug: Some(true), + is_replay_protected: None, + is_integrity_protected: None, + is_runtime_meas: None, + is_immutable: None, + is_tcb: None, + is_confidentiality_protected: None, + }); +} + +#[test] +fn flags_map_non_empty_enforced() { + let f = FlagsMap { + is_configured: None, + is_secure: None, + is_recovery: None, + is_debug: None, + is_replay_protected: None, + is_integrity_protected: None, + is_runtime_meas: None, + is_immutable: None, + is_tcb: None, + is_confidentiality_protected: None, + }; + assert!( + cbor::encode(&f).is_err(), + "all-None FlagsMap must fail non-empty" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §7.1 — non-empty constraint on all 6 non-empty types +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn non_empty_measurement_values_map() { + assert!(cbor::encode(&MeasurementValuesMap::default()).is_err()); +} + +#[test] +fn non_empty_triples_map() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + assert!(cbor::encode(&t).is_err()); +} + +#[test] +fn non_empty_key_triple_conditions() { + assert!(cbor::encode(&KeyTripleConditions { + mkey: None, + authorized_by: None + }) + .is_err()); +} + +#[test] +fn non_empty_environment_map() { + assert!(cbor::encode(&EnvironmentMap { + class: None, + instance: None, + group: None + }) + .is_err()); +} + +#[test] +fn non_empty_class_map() { + assert!(cbor::encode(&ClassMap { + class_id: None, + vendor: None, + model: None, + layer: None, + index: None + }) + .is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.2 — class-map with all fields populated +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn class_map_all_fields() { + round_trip(&ClassMap { + class_id: Some(ClassIdChoice::Uuid([0xAB; 16])), + vendor: Some("ACME".into()), + model: Some("Widget".into()), + layer: Some(2), + index: Some(3), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.1 — environment-map with each optional field +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn environment_with_instance() { + round_trip(&EnvironmentMap { + class: None, + instance: Some(InstanceIdChoice::Ueid(vec![0x01; 17])), + group: None, + }); +} + +#[test] +fn environment_with_group() { + round_trip(&EnvironmentMap { + class: None, + instance: None, + group: Some(GroupIdChoice::Uuid([0x88; 16])), + }); +} + +#[test] +fn environment_all_fields() { + round_trip(&EnvironmentMap { + class: Some(ClassMap { + class_id: Some(ClassIdChoice::Oid(vec![0x2B, 0x06])), + vendor: Some("V".into()), + model: Some("M".into()), + layer: Some(0), + index: Some(1), + }), + instance: Some(InstanceIdChoice::Uuid([0x42; 16])), + group: Some(GroupIdChoice::Bytes(vec![0xFF; 4])), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.1 — tag-identity-map (with/without version) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn tag_identity_with_version() { + round_trip(&TagIdentity { + tag_id: TagIdChoice::Uuid([0xAA; 16]), + tag_version: Some(5), + }); +} + +#[test] +fn tag_identity_without_version() { + round_trip(&TagIdentity { + tag_id: TagIdChoice::Text("my-tag".into()), + tag_version: None, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.3 — linked-tag-map (supplements / replaces) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn linked_tag_supplements() { + round_trip(&LinkedTagMap { + linked_tag_id: TagIdChoice::Text("other-tag".into()), + tag_rel: TAG_REL_SUPPLEMENTS, + }); +} + +#[test] +fn linked_tag_replaces() { + round_trip(&LinkedTagMap { + linked_tag_id: TagIdChoice::Uuid([0xBB; 16]), + tag_rel: TAG_REL_REPLACES, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §7.3 — validity-map (with/without not-before) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn validity_full() { + round_trip(&ValidityMap { + not_before: Some(CborTime(1700000000)), + not_after: CborTime(1800000000), + }); +} + +#[test] +fn validity_no_not_before() { + round_trip(&ValidityMap { + not_before: None, + not_after: CborTime(1800000000), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §7.2 — entity-map (with/without reg-id) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn entity_with_reg_id() { + round_trip(&EntityMap { + entity_name: "ACME Corp".into(), + reg_id: Some("https://acme.example.com".into()), + role: vec![COMID_ROLE_TAG_CREATOR, COMID_ROLE_CREATOR], + }); +} + +#[test] +fn entity_without_reg_id() { + round_trip(&EntityMap { + entity_name: "Anonymous".into(), + reg_id: None, + role: vec![COMID_ROLE_MAINTAINER], + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.3 — version-map (with/without scheme) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn version_with_scheme() { + round_trip(&VersionMap { + version: "1.2.3".into(), + version_scheme: Some(VERSION_SCHEME_SEMVER), + }); +} + +#[test] +fn version_without_scheme() { + round_trip(&VersionMap { + version: "rev42".into(), + version_scheme: None, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5.2 — measurement-values-map with ALL 14 fields populated +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn measurement_values_map_all_14_fields() { + let mut regs = BTreeMap::new(); + regs.insert( + IntegrityRegisterId::Uint(0), + vec![Digest::new(7, vec![0x11; 48])], + ); + + round_trip(&MeasurementValuesMap { + version: Some(VersionMap { + version: "2.0".into(), + version_scheme: Some(VERSION_SCHEME_SEMVER), + }), + svn: Some(SvnChoice::MinValue(3)), + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + flags: Some(FlagsMap { + is_configured: Some(true), + is_secure: Some(true), + is_recovery: None, + is_debug: Some(false), + is_replay_protected: None, + is_integrity_protected: None, + is_runtime_meas: None, + is_immutable: None, + is_tcb: Some(true), + is_confidentiality_protected: None, + }), + raw_value: Some(RawValueChoice::Bytes(vec![0xDE, 0xAD])), + mac_addr: Some(MacAddr::Eui48([0x00, 0x11, 0x22, 0x33, 0x44, 0x55])), + ip_addr: Some(IpAddr::V4([192, 168, 1, 1])), + serial_number: Some("SN-12345".into()), + ueid: Some(vec![0x01; 17]), + uuid: Some(vec![0x02; 16]), + name: Some("firmware-component".into()), + cryptokeys: Some(vec![CryptoKey::PkixBase64Key("MIIBIj...".into())]), + integrity_registers: Some(IntegrityRegisters(regs)), + int_range: Some(IntRangeChoice::Range { + min: Some(0), + max: Some(100), + }), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4.5 — measurement-map with/without authorized-by / anonymous +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn measurement_map_with_authorized_by() { + round_trip(&MeasurementMap { + mkey: Some(MeasuredElement::Uint(7)), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xEE; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: Some(vec![CryptoKey::PkixBase64Key("key-data".into())]), + }); +} + +#[test] +fn measurement_map_anonymous() { + // No mkey = "anonymous" measurement per §5.1.4.5.1 + round_trip(&MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.5 — reference-triple-record +// §5.1.6 — endorsed-triple-record +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn reference_triple() { + round_trip(&ReferenceTriple::new( + make_env(), + vec![make_meas("fw"), make_meas("config")], + )); +} + +#[test] +fn endorsed_triple() { + round_trip(&EndorsedTriple::new( + make_env(), + vec![make_meas("certified")], + )); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.9 / §5.1.10 — identity / attest-key triple (with and without conditions) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn identity_triple_with_conditions() { + round_trip(&IdentityTriple::new( + make_env(), + vec![CryptoKey::PkixBase64Cert("cert-data".into())], + Some(KeyTripleConditions { + mkey: Some(MeasuredElement::Text("idevid".into())), + authorized_by: Some(vec![CryptoKey::Bytes(vec![0x01])]), + }), + )); +} + +#[test] +fn identity_triple_without_conditions() { + round_trip(&IdentityTriple::new( + make_env(), + vec![CryptoKey::Bytes(vec![0x01, 0x02])], + None, + )); +} + +#[test] +fn attest_key_triple_with_conditions() { + round_trip(&AttestKeyTriple::new( + make_env(), + vec![CryptoKey::CoseKey(vec![0xA1, 0x01, 0x02])], + Some(KeyTripleConditions { + mkey: Some(MeasuredElement::Uint(0)), + authorized_by: None, + }), + )); +} + +#[test] +fn attest_key_triple_without_conditions() { + round_trip(&AttestKeyTriple::new( + make_env(), + vec![CryptoKey::Bytes(vec![0x03, 0x04])], + None, + )); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.11.1 — domain-membership-triple-record +// §5.1.11.2 — domain-dependency-triple-record +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn domain_dependency() { + round_trip(&DomainDependencyTriple::new(make_env(), vec![make_env()])); +} + +#[test] +fn domain_membership() { + round_trip(&DomainMembershipTriple::new(make_env(), vec![make_env()])); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.12 — coswid-triple-record (text + UUID tag ids) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn coswid_triple_text_tag_id() { + round_trip(&CoswidTriple::new( + make_env(), + vec![TagIdChoice::Text("test-tag".into())], + )); +} + +#[test] +fn coswid_triple_uuid_tag_id() { + round_trip(&CoswidTriple::new( + make_env(), + vec![TagIdChoice::Uuid([0x77; 16])], + )); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.7 — conditional-endorsement-triple-record + stateful-environment-record +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn stateful_environment_record() { + round_trip(&StatefulEnvironmentRecord( + make_env(), + vec![make_meas("state")], + )); +} + +#[test] +fn conditional_endorsement_triple() { + round_trip(&ConditionalEndorsementTriple( + vec![StatefulEnvironmentRecord( + make_env(), + vec![make_meas("state")], + )], + vec![EndorsedTriple::new(make_env(), vec![make_meas("endorsed")])], + )); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.8 — conditional-endorsement-series (CES condition, series record, triple) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn ces_condition_without_authorized_by() { + round_trip(&CesCondition { + environment: make_env(), + claims_list: vec![make_meas("fw")], + authorized_by: None, + }); +} + +#[test] +fn ces_condition_with_authorized_by() { + round_trip(&CesCondition { + environment: make_env(), + claims_list: Vec::new(), + authorized_by: Some(vec![CryptoKey::Bytes(vec![0xAA, 0xBB])]), + }); +} + +#[test] +fn ces_condition_empty_claims() { + round_trip(&CesCondition { + environment: make_env(), + claims_list: Vec::new(), + authorized_by: None, + }); +} + +#[test] +fn conditional_series_record() { + round_trip(&ConditionalSeriesRecord::new( + vec![make_meas("selection")], + vec![make_meas("addition")], + )); +} + +#[test] +fn conditional_endorsement_series_triple() { + round_trip(&ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: make_env(), + claims_list: vec![make_meas("fw")], + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![make_meas("fw")], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(5)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )], + )); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1.4 — triples-map with ALL 9 triple types populated simultaneously +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn triples_map_all_nine_types() { + let env = make_env(); + let m = make_meas("m"); + + round_trip(&TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new(env.clone(), vec![m.clone()])]), + endorsed_triples: Some(vec![EndorsedTriple::new(env.clone(), vec![m.clone()])]), + identity_triples: Some(vec![IdentityTriple::new( + env.clone(), + vec![CryptoKey::Bytes(vec![1])], + None, + )]), + attest_key_triples: Some(vec![AttestKeyTriple::new( + env.clone(), + vec![CryptoKey::Bytes(vec![2])], + None, + )]), + dependency_triples: Some(vec![DomainDependencyTriple::new( + env.clone(), + vec![env.clone()], + )]), + membership_triples: Some(vec![DomainMembershipTriple::new( + env.clone(), + vec![env.clone()], + )]), + coswid_triples: Some(vec![CoswidTriple::new( + env.clone(), + vec![TagIdChoice::Text("t".into())], + )]), + conditional_endorsement_series: Some(vec![ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![m.clone()], + vec![m.clone()], + )], + )]), + conditional_endorsement: Some(vec![ConditionalEndorsementTriple( + vec![StatefulEnvironmentRecord(env.clone(), vec![m.clone()])], + vec![EndorsedTriple::new(env.clone(), vec![m.clone()])], + )]), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §5.1 — concise-mid-tag (ComidTag) with ALL optional fields +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn comid_tag_all_optional_fields() { + round_trip(&ComidTag { + language: Some("en-US".into()), + tag_identity: TagIdentity { + tag_id: TagIdChoice::Uuid([0x42; 16]), + tag_version: Some(3), + }, + entities: Some(vec![EntityMap { + entity_name: "ACME".into(), + reg_id: Some("https://acme.example.com".into()), + role: vec![COMID_ROLE_TAG_CREATOR], + }]), + linked_tags: Some(vec![LinkedTagMap { + linked_tag_id: TagIdChoice::Text("other".into()), + tag_rel: TAG_REL_SUPPLEMENTS, + }]), + triples: TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new( + make_env(), + vec![make_meas("fw")], + )]), + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §4.1 — corim-map with ALL optional fields +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn corim_map_all_optional_fields() { + let comid = ComidTag { + language: None, + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("t1".into()), + tag_version: None, + }, + entities: None, + linked_tags: None, + triples: TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new(make_env(), vec![make_meas("f")])]), + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }, + }; + let comid_bytes = cbor::encode(&comid).unwrap(); + + round_trip(&CorimMap { + id: CorimId::Uuid([0x42; 16]), + tags: vec![ConciseTagChoice::Comid(comid_bytes)], + dependent_rims: Some(vec![CorimLocator { + href: CorimLocatorHref::Multiple(vec![ + "https://example.com/a.corim".into(), + "https://example.com/b.corim".into(), + ]), + thumbprint: Some(CorimLocatorThumbprint::Single(Digest::new( + 7, + vec![0xAA; 48], + ))), + }]), + profile: Some(ProfileChoice::Uri("https://example.com/profile".into())), + rim_validity: Some(ValidityMap { + not_before: Some(CborTime(1700000000)), + not_after: CborTime(1900000000), + }), + entities: Some(vec![EntityMap { + entity_name: "Creator".into(), + reg_id: None, + role: vec![CORIM_ROLE_MANIFEST_CREATOR], + }]), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §4.1.3 — corim-locator-map (all variants) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn corim_locator_single_href() { + round_trip(&CorimLocator { + href: CorimLocatorHref::Single("https://example.com/rim.corim".into()), + thumbprint: None, + }); +} + +#[test] +fn corim_locator_multiple_hrefs() { + round_trip(&CorimLocator { + href: CorimLocatorHref::Multiple(vec!["https://a.com".into(), "https://b.com".into()]), + thumbprint: None, + }); +} + +#[test] +fn corim_locator_with_single_thumbprint() { + round_trip(&CorimLocator { + href: CorimLocatorHref::Single("https://example.com".into()), + thumbprint: Some(CorimLocatorThumbprint::Single(Digest::new( + 2, + vec![0xBB; 32], + ))), + }); +} + +#[test] +fn corim_locator_with_multiple_thumbprints() { + round_trip(&CorimLocator { + href: CorimLocatorHref::Single("https://example.com".into()), + thumbprint: Some(CorimLocatorThumbprint::Multiple(vec![ + Digest::new(7, vec![0xAA; 48]), + Digest::new(2, vec![0xBB; 32]), + ])), + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §6.1 — concise-tl-tag +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn concise_tl_tag_with_uuid() { + round_trip(&ConciseTlTag { + tag_identity: TagIdentity { + tag_id: TagIdChoice::Uuid([0xCC; 16]), + tag_version: Some(1), + }, + tags_list: vec![ + TagIdentity { + tag_id: TagIdChoice::Text("a".into()), + tag_version: None, + }, + TagIdentity { + tag_id: TagIdChoice::Uuid([0xDD; 16]), + tag_version: Some(2), + }, + ], + tl_validity: ValidityMap { + not_before: Some(CborTime(1000)), + not_after: CborTime(2000), + }, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// §4.2.3 — corim-signer-map / corim-meta-map (with/without optional fields) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn corim_signer_with_uri() { + round_trip(&CorimSignerMap { + signer_name: "S".into(), + signer_uri: Some("https://s.com".into()), + }); +} + +#[test] +fn corim_signer_without_uri() { + round_trip(&CorimSignerMap { + signer_name: "S".into(), + signer_uri: None, + }); +} + +#[test] +fn corim_meta_with_validity() { + round_trip(&CorimMetaMap { + signer: CorimSignerMap { + signer_name: "S".into(), + signer_uri: None, + }, + signature_validity: Some(ValidityMap { + not_before: Some(CborTime(1000)), + not_after: CborTime(2000), + }), + }); +} + +#[test] +fn corim_meta_without_validity() { + round_trip(&CorimMetaMap { + signer: CorimSignerMap { + signer_name: "S".into(), + signer_uri: None, + }, + signature_validity: None, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// End-to-end: build → encode → decode → validate (4 triple types) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn end_to_end_build_encode_decode_validate() { + use corim::builder::{ComidBuilder, CorimBuilder}; + + let env = make_env(); + + let comid = ComidBuilder::new(TagIdChoice::Text("e2e-test".into())) + .set_tag_version(1) + .set_language("en") + .add_entity(EntityMap { + entity_name: "Tester".into(), + reg_id: None, + role: vec![COMID_ROLE_TAG_CREATOR], + }) + .add_reference_triple(ReferenceTriple::new(env.clone(), vec![make_meas("fw")])) + .add_endorsed_triple(EndorsedTriple::new( + env.clone(), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::MinValue(3)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )) + .add_identity_triple(IdentityTriple::new( + env.clone(), + vec![CryptoKey::PkixBase64Key("key-data".into())], + None, + )) + .add_conditional_endorsement_series(ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![make_meas("fw")], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(5)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )], + )) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("e2e-corim".into())) + .set_profile(ProfileChoice::Uri("https://example.com/profile".into())) + .set_validity(Some(0), i64::MAX) + .unwrap() + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + + // Validate + let (corim, comids) = corim::validate::decode_and_validate(&bytes).unwrap(); + assert_eq!(corim.id, CorimId::Text("e2e-corim".into())); + assert_eq!(comids.len(), 1); + assert!(comids[0].triples.reference_triples.is_some()); + assert!(comids[0].triples.endorsed_triples.is_some()); + assert!(comids[0].triples.identity_triples.is_some()); + assert!(comids[0].triples.conditional_endorsement_series.is_some()); + assert_eq!(comids[0].language.as_deref(), Some("en")); + assert_eq!(comids[0].tag_identity.tag_version, Some(1)); +} diff --git a/corim/tests/coswid_json_tests.rs b/corim/tests/coswid_json_tests.rs new file mode 100644 index 0000000..4315510 --- /dev/null +++ b/corim/tests/coswid_json_tests.rs @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for CoSWID types and JSON serialization. + +use corim::cbor; +use corim::types::common::TagIdChoice; +use corim::types::coswid::*; +use corim::types::tags::*; +use corim::Validate; + +// =========================================================================== +// CoSWID CBOR round-trip +// =========================================================================== + +fn make_swid_entity() -> SwidEntity { + SwidEntity::new( + "ACME Ltd", + vec![SWID_ROLE_TAG_CREATOR, SWID_ROLE_SOFTWARE_CREATOR], + ) + .with_reg_id("https://acme.example") +} + +fn make_coswid() -> ConciseSwidTag { + ConciseSwidTag::new( + TagIdChoice::Text("example.acme.roadrunner-sw-v1".into()), + "Roadrunner software bundle", + 0, + vec![make_swid_entity()], + ) +} + +#[test] +fn coswid_cbor_round_trip() { + let tag = make_coswid(); + let bytes = cbor::encode(&tag).unwrap(); + let decoded: ConciseSwidTag = cbor::decode(&bytes).unwrap(); + assert_eq!(tag.tag_id, decoded.tag_id); + assert_eq!(tag.software_name, decoded.software_name); + assert_eq!(tag.tag_version, decoded.tag_version); + assert_eq!(tag.entities.len(), decoded.entities.len()); + assert_eq!(tag.entities[0].entity_name, decoded.entities[0].entity_name); + assert_eq!(tag.entities[0].roles, decoded.entities[0].roles); +} + +#[test] +fn coswid_with_all_fields() { + let mut tag = make_coswid(); + tag.software_version = Some("1.0.0".into()); + tag.version_scheme = Some(VERSION_SCHEME_SEMVER); + tag.lang = Some("en-US".into()); + tag.corpus = Some(false); + tag.patch = Some(false); + tag.supplemental = Some(false); + tag.links = Some(vec![ + SwidLink::new("example.acme.roadrunner-hw-v1", SWID_REL_PARENT), + SwidLink::new("example.acme.roadrunner-sw-bl-v1", SWID_REL_COMPONENT), + ]); + + let bytes = cbor::encode(&tag).unwrap(); + let decoded: ConciseSwidTag = cbor::decode(&bytes).unwrap(); + assert_eq!(tag.software_version, decoded.software_version); + assert_eq!(tag.version_scheme, decoded.version_scheme); + assert_eq!(tag.lang, decoded.lang); + assert_eq!(tag.links.as_ref().unwrap().len(), 2); +} + +#[test] +fn coswid_entity_round_trip() { + let entity = make_swid_entity(); + let bytes = cbor::encode(&entity).unwrap(); + let decoded: SwidEntity = cbor::decode(&bytes).unwrap(); + assert_eq!(entity, decoded); +} + +#[test] +fn coswid_link_round_trip() { + let link = SwidLink::new( + "swid:2df9de35-0aff-4a86-ace6-f7dddd1ade4c", + SWID_REL_PATCHES, + ); + let bytes = cbor::encode(&link).unwrap(); + let decoded: SwidLink = cbor::decode(&bytes).unwrap(); + assert_eq!(link, decoded); +} + +#[test] +fn coswid_uuid_tag_id() { + let uuid = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, + ]; + let tag = ConciseSwidTag::new( + TagIdChoice::Uuid(uuid), + "Test", + 0, + vec![SwidEntity::new("Test Corp", vec![SWID_ROLE_TAG_CREATOR])], + ); + let bytes = cbor::encode(&tag).unwrap(); + let decoded: ConciseSwidTag = cbor::decode(&bytes).unwrap(); + assert_eq!(TagIdChoice::Uuid(uuid), decoded.tag_id); +} + +// =========================================================================== +// CoSWID Validation +// =========================================================================== + +#[test] +fn coswid_valid() { + let tag = make_coswid(); + assert!(tag.valid().is_ok()); +} + +#[test] +fn coswid_no_entity_is_invalid() { + let tag = ConciseSwidTag::new(TagIdChoice::Text("test".into()), "Test", 0, vec![]); + let err = tag.valid().unwrap_err(); + assert!(err.contains("at least one entity"), "got: {err}"); +} + +#[test] +fn coswid_no_tag_creator_is_invalid() { + let tag = ConciseSwidTag::new( + TagIdChoice::Text("test".into()), + "Test", + 0, + vec![SwidEntity::new( + "Test Corp", + vec![SWID_ROLE_SOFTWARE_CREATOR], + )], + ); + let err = tag.valid().unwrap_err(); + assert!(err.contains("tag-creator"), "got: {err}"); +} + +#[test] +fn coswid_patch_and_supplemental_both_true_is_invalid() { + let mut tag = make_coswid(); + tag.patch = Some(true); + tag.supplemental = Some(true); + tag.links = Some(vec![SwidLink::new("x", SWID_REL_PATCHES)]); + let err = tag.valid().unwrap_err(); + assert!(err.contains("patch and supplemental"), "got: {err}"); +} + +#[test] +fn coswid_patch_without_patches_link_is_invalid() { + let mut tag = make_coswid(); + tag.patch = Some(true); + // No links at all + let err = tag.valid().unwrap_err(); + assert!(err.contains("patches"), "got: {err}"); +} + +#[test] +fn coswid_patch_with_patches_link_is_valid() { + let mut tag = make_coswid(); + tag.patch = Some(true); + tag.links = Some(vec![SwidLink::new("example.base-sw", SWID_REL_PATCHES)]); + assert!(tag.valid().is_ok()); +} + +#[test] +fn swid_entity_empty_name_is_invalid() { + let entity = SwidEntity::new("", vec![SWID_ROLE_TAG_CREATOR]); + let err = entity.valid().unwrap_err(); + assert!(err.contains("entity-name"), "got: {err}"); +} + +#[test] +fn swid_entity_no_roles_is_invalid() { + let entity = SwidEntity::new("Test Corp", vec![]); + let err = entity.valid().unwrap_err(); + assert!(err.contains("role"), "got: {err}"); +} + +#[test] +fn swid_link_empty_href_is_invalid() { + let link = SwidLink::new("", SWID_REL_COMPONENT); + let err = link.valid().unwrap_err(); + assert!(err.contains("href"), "got: {err}"); +} + +// =========================================================================== +// CoSWID in CoRIM (builder + validate) +// =========================================================================== + +#[test] +fn corim_builder_add_coswid() { + use corim::builder::{ComidBuilder, CorimBuilder}; + use corim::types::corim::CorimId; + use corim::types::environment::EnvironmentMap; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; + use corim::types::triples::ReferenceTriple; + + let comid = ComidBuilder::new(TagIdChoice::Text("comid-1".into())) + .add_reference_triple(ReferenceTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 32])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + + let coswid = make_coswid(); + + let bytes = CorimBuilder::new(CorimId::Text("with-coswid".into())) + .add_comid_tag(comid) + .unwrap() + .add_coswid(coswid) + .unwrap() + .build_bytes() + .unwrap(); + + let full = corim::validate::decode_and_validate_full(&bytes).unwrap(); + assert_eq!(full.comids.len(), 1); + assert_eq!(full.coswids.len(), 1); + assert_eq!(full.coswids[0].software_name, "Roadrunner software bundle"); + assert_eq!(full.coswid_opaque_count, 0); +} + +// =========================================================================== +// JSON serialization (requires `json` feature) +// =========================================================================== + +#[cfg(feature = "json")] +mod json_tests { + use super::*; + use corim::json; + use corim::types::common::{CborTime, EntityMap, TagIdentity, ValidityMap}; + use corim::types::environment::{ClassMap, EnvironmentMap}; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap, SvnChoice}; + + #[test] + fn json_class_map_round_trip() { + let class = ClassMap::new("ACME", "Widget"); + let json_str = json::to_json(&class).unwrap(); + + // Should contain the values + assert!(json_str.contains("ACME"), "json: {json_str}"); + assert!(json_str.contains("Widget"), "json: {json_str}"); + + // Round-trip + let decoded: ClassMap = json::from_json(&json_str).unwrap(); + assert_eq!(class, decoded); + } + + #[test] + fn json_environment_map_round_trip() { + let env = EnvironmentMap::for_class("Intel", "TDX"); + let json_str = json::to_json(&env).unwrap(); + let decoded: EnvironmentMap = json::from_json(&json_str).unwrap(); + assert_eq!(env, decoded); + } + + #[test] + fn json_measurement_map_with_svn() { + // Round-trip for measurement values that don't contain raw bytes + let meas = MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(42)), + name: Some("test-component".into()), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + let json_str = json::to_json(&meas).unwrap(); + let decoded: MeasurementMap = json::from_json(&json_str).unwrap(); + assert_eq!(meas.mval.svn, decoded.mval.svn); + assert_eq!(meas.mval.name, decoded.mval.name); + } + + #[test] + fn json_measurement_with_digests_encodes() { + // Digests encode to JSON (bytes → base64), but round-trip requires + // context-aware bytes detection (deferred: tracked in Phase 7B.4) + let meas = MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xBB; 32])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + let json_str = json::to_json(&meas).unwrap(); + // Should contain the base64-encoded digest value + assert!(json_str.len() > 10, "json: {json_str}"); + } + + #[test] + fn json_tag_identity_round_trip() { + let tid = TagIdentity { + tag_id: TagIdChoice::Text("my-tag".into()), + tag_version: Some(1), + }; + let json_str = json::to_json(&tid).unwrap(); + assert!(json_str.contains("my-tag"), "json: {json_str}"); + let decoded: TagIdentity = json::from_json(&json_str).unwrap(); + assert_eq!(tid, decoded); + } + + #[test] + fn json_coswid_round_trip() { + let tag = make_coswid(); + let json_str = json::to_json(&tag).unwrap(); + assert!(json_str.contains("Roadrunner"), "json: {json_str}"); + assert!(json_str.contains("ACME"), "json: {json_str}"); + + let decoded: ConciseSwidTag = json::from_json(&json_str).unwrap(); + assert_eq!(tag.software_name, decoded.software_name); + assert_eq!(tag.tag_version, decoded.tag_version); + } + + #[test] + fn json_coswid_entity_uses_string_keys() { + let entity = make_swid_entity(); + let json_str = json::to_json(&entity).unwrap(); + // Keys 31+ should use string names from the global table + assert!(json_str.contains("entity-name"), "json: {json_str}"); + assert!(json_str.contains("reg-id"), "json: {json_str}"); + assert!(json_str.contains("role"), "json: {json_str}"); + } + + #[test] + fn json_swid_link_uses_string_keys() { + let link = SwidLink::new("https://example.com", SWID_REL_COMPONENT); + let json_str = json::to_json(&link).unwrap(); + assert!(json_str.contains("href"), "json: {json_str}"); + assert!(json_str.contains("rel"), "json: {json_str}"); + } + + #[test] + fn json_pretty_print() { + let class = ClassMap::new("ACME", "Widget"); + let json_str = json::to_json_pretty(&class).unwrap(); + assert!(json_str.contains('\n'), "should be multiline"); + assert!(json_str.contains("ACME")); + } + + #[test] + fn json_uuid_type_choice() { + let tag_id = TagIdChoice::Uuid([0x01; 16]); + let json_str = json::to_json(&tag_id).unwrap(); + // Should be {"type":"uuid","value":"01010101-0101-0101-0101-010101010101"} + assert!(json_str.contains("uuid"), "json: {json_str}"); + assert!(json_str.contains("01010101"), "json: {json_str}"); + } + + #[test] + fn json_validity_map_round_trip() { + let validity = ValidityMap { + not_before: Some(CborTime::new(1000)), + not_after: CborTime::new(2000), + }; + let json_str = json::to_json(&validity).unwrap(); + let decoded: ValidityMap = json::from_json(&json_str).unwrap(); + assert_eq!(validity, decoded); + } + + #[test] + fn json_entity_map_round_trip() { + let entity = EntityMap { + entity_name: "ACME Ltd.".into(), + reg_id: Some("https://acme.example".into()), + role: vec![1, 2], + }; + let json_str = json::to_json(&entity).unwrap(); + let decoded: EntityMap = json::from_json(&json_str).unwrap(); + assert_eq!(entity, decoded); + } +} diff --git a/corim/tests/cotl_tests.rs b/corim/tests/cotl_tests.rs new file mode 100644 index 0000000..d2de1a7 --- /dev/null +++ b/corim/tests/cotl_tests.rs @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for CoTL (Concise Tag List) — §6.1 of draft-ietf-rats-corim-10. + +use corim::builder::{ComidBuilder, CorimBuilder, CotlBuilder}; +use corim::cbor; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::EnvironmentMap; +use corim::types::measurement::*; +use corim::types::triples::ReferenceTriple; + +fn make_comid() -> corim::types::comid::ComidTag { + ComidBuilder::new(TagIdChoice::Text("test-comid".into())) + .add_reference_triple(ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap() +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CotlBuilder +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn cotl_builder_basic() { + let cotl = CotlBuilder::new(TagIdChoice::Text("cotl-1".into()), 2000000000) + .set_tag_version(0) + .add_tag_id(TagIdChoice::Text("comid-1".into())) + .add_tag_id(TagIdChoice::Text("comid-2".into())) + .build() + .unwrap(); + + assert_eq!(cotl.tag_identity.tag_id, TagIdChoice::Text("cotl-1".into())); + assert_eq!(cotl.tags_list.len(), 2); + assert_eq!(cotl.tl_validity.not_after.epoch_secs(), 2000000000); +} + +#[test] +fn cotl_builder_with_uuid_tags() { + let cotl = CotlBuilder::new(TagIdChoice::Uuid([0xAA; 16]), 2000000000) + .add_tag(TagIdentity { + tag_id: TagIdChoice::Uuid([0xBB; 16]), + tag_version: Some(3), + }) + .build() + .unwrap(); + + assert_eq!(cotl.tags_list.len(), 1); + assert_eq!(cotl.tags_list[0].tag_version, Some(3)); +} + +#[test] +fn cotl_builder_with_validity_window() { + let cotl = CotlBuilder::new(TagIdChoice::Text("cotl".into()), 2000000000) + .set_not_before(1000000000) + .add_tag_id(TagIdChoice::Text("t".into())) + .build() + .unwrap(); + + assert_eq!( + cotl.tl_validity.not_before.unwrap().epoch_secs(), + 1000000000 + ); +} + +#[test] +fn cotl_builder_empty_tags_fails() { + let result = CotlBuilder::new(TagIdChoice::Text("cotl".into()), 2000000000).build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("tags-list")); +} + +#[test] +fn cotl_builder_invalid_validity_fails() { + let result = CotlBuilder::new(TagIdChoice::Text("cotl".into()), 1000) + .set_not_before(2000) // not_before > not_after + .add_tag_id(TagIdChoice::Text("t".into())) + .build(); + assert!(result.is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ConciseTlTag round-trip +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn cotl_round_trip() { + let cotl = CotlBuilder::new(TagIdChoice::Text("cotl-rt".into()), i64::MAX) + .set_tag_version(1) + .set_not_before(1000000000) + .add_tag_id(TagIdChoice::Text("comid-a".into())) + .add_tag_id(TagIdChoice::Uuid([0xCC; 16])) + .build() + .unwrap(); + + let bytes = cbor::encode(&cotl).unwrap(); + let decoded: ConciseTlTag = cbor::decode(&bytes).unwrap(); + assert_eq!(cotl, decoded); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CoRIM with CoTL +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn corim_with_cotl_and_comid() { + let comid = make_comid(); + let cotl = CotlBuilder::new(TagIdChoice::Text("cotl-1".into()), i64::MAX) + .add_tag_id(TagIdChoice::Text("test-comid".into())) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("mixed-tags".into())) + .add_comid_tag(comid) + .unwrap() + .add_cotl(cotl) + .unwrap() + .build_bytes() + .unwrap(); + + // Validate — should decode both CoMID and CoTL + let (corim, comids) = corim::validate::decode_and_validate(&bytes).unwrap(); + assert_eq!(corim.tags.len(), 2); + assert_eq!(comids.len(), 1); + + // Full validation should also return CoTL + let full = corim::validate::decode_and_validate_full(&bytes).unwrap(); + assert_eq!(full.comids.len(), 1); + assert_eq!(full.cotls.len(), 1); + assert_eq!( + full.cotls[0].tag_identity.tag_id, + TagIdChoice::Text("cotl-1".into()) + ); + assert_eq!(full.coswids.len(), 0); + assert_eq!(full.coswid_opaque_count, 0); +} + +#[test] +fn corim_with_only_cotl_fails_validation() { + // CoRIM with only CoTL and no CoMID should fail (no CoMID tags) + let cotl = CotlBuilder::new(TagIdChoice::Text("cotl-only".into()), i64::MAX) + .add_tag_id(TagIdChoice::Text("nonexistent".into())) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("cotl-only".into())) + .add_cotl(cotl) + .unwrap() + .build_bytes() + .unwrap(); + + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("CoMID")); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CoTL validation +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn cotl_expired_in_corim() { + let comid = make_comid(); + let cotl = CotlBuilder::new(TagIdChoice::Text("expired-cotl".into()), 0) // epoch 0 = expired + .add_tag_id(TagIdChoice::Text("test-comid".into())) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("with-expired-cotl".into())) + .add_comid_tag(comid) + .unwrap() + .add_cotl(cotl) + .unwrap() + .build_bytes() + .unwrap(); + + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("expired")); +} + +#[test] +fn cotl_not_yet_valid_in_corim() { + let comid = make_comid(); + let cotl = CotlBuilder::new(TagIdChoice::Text("future-cotl".into()), i64::MAX) + .set_not_before(i64::MAX - 1) // far future + .add_tag_id(TagIdChoice::Text("test-comid".into())) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("with-future-cotl".into())) + .add_comid_tag(comid) + .unwrap() + .add_cotl(cotl) + .unwrap() + .build_bytes() + .unwrap(); + + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("not yet valid")); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CoSWID as opaque bytes +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn corim_with_coswid_opaque() { + let comid = make_comid(); + // CoSWID is opaque bytes — just pass any CBOR map + let coswid_bytes = cbor::encode(&corim::cbor::value::Value::Map(vec![( + corim::cbor::value::Value::Integer(0), + corim::cbor::value::Value::Text("fake-coswid".into()), + )])) + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("with-coswid".into())) + .add_comid_tag(comid) + .unwrap() + .add_coswid_tag(coswid_bytes) + .build_bytes() + .unwrap(); + + let full = corim::validate::decode_and_validate_full(&bytes).unwrap(); + assert_eq!(full.comids.len(), 1); + assert_eq!(full.coswid_opaque_count, 1); + assert_eq!(full.coswids.len(), 0); + assert_eq!(full.cotls.len(), 0); +} diff --git a/corim/tests/coverage_boost_tests.rs b/corim/tests/coverage_boost_tests.rs new file mode 100644 index 0000000..8ae7979 --- /dev/null +++ b/corim/tests/coverage_boost_tests.rs @@ -0,0 +1,1208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests to boost code coverage toward the 80% target. +//! +//! This file targets the lowest-coverage modules identified by cargo-llvm-cov: +//! - cbor/minimal_backend/value_ser.rs (41%) +//! - json/value_conv.rs (47%) +//! - types/common.rs (69%) +//! - types/triples.rs (69%) +//! - types/measurement.rs (72%) +//! - types/corim.rs (72%) +//! - cbor/value/mod.rs (59%) +//! - cbor/minimal_backend/value_de.rs (68%) + +use corim::cbor; +use corim::cbor::value::Value; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::triples::*; +use corim::Validate; + +// =========================================================================== +// cbor::value::Value — into_* failure paths + to_value/from_value +// =========================================================================== + +#[test] +fn value_into_integer_failure() { + assert!(Value::Text("hello".into()).into_integer().is_none()); + assert!(Value::Bool(true).into_integer().is_none()); + assert!(Value::Null.into_integer().is_none()); +} + +#[test] +fn value_into_bytes_failure() { + assert!(Value::Integer(42).into_bytes().is_none()); + assert!(Value::Text("hello".into()).into_bytes().is_none()); +} + +#[test] +fn value_into_text_failure() { + assert!(Value::Integer(42).into_text().is_none()); + assert!(Value::Bytes(vec![1, 2]).into_text().is_none()); +} + +#[test] +fn value_into_array_failure() { + assert!(Value::Integer(42).into_array().is_none()); + assert!(Value::Text("x".into()).into_array().is_none()); +} + +#[test] +fn value_into_tag_failure() { + assert!(Value::Integer(42).into_tag().is_none()); + assert!(Value::Array(vec![]).into_tag().is_none()); +} + +#[test] +fn value_into_tag_success() { + let v = Value::Tag(37, Box::new(Value::Bytes(vec![0; 16]))); + let (tag, inner) = v.into_tag().unwrap(); + assert_eq!(tag, 37); + assert!(matches!(inner, Value::Bytes(_))); +} + +#[test] +fn value_to_from_value_round_trip() { + use corim::cbor::value::{from_value, to_value}; + let class = ClassMap::new("ACME", "Widget"); + let v = to_value(&class).unwrap(); + assert!(matches!(v, Value::Map(_))); + let decoded: ClassMap = from_value(&v).unwrap(); + assert_eq!(class, decoded); +} + +// =========================================================================== +// value_ser.rs — exercise serde Serializer methods +// =========================================================================== + +#[test] +fn value_ser_bool_round_trip() { + let bytes = cbor::encode(&true).unwrap(); + let v: bool = cbor::decode(&bytes).unwrap(); + assert!(v); +} + +#[test] +fn value_ser_floats() { + let bytes = cbor::encode(&3.14f64).unwrap(); + let v: f64 = cbor::decode(&bytes).unwrap(); + assert!((v - 3.14).abs() < 0.001); + + let bytes32 = cbor::encode(&2.5f32).unwrap(); + let v32: f64 = cbor::decode(&bytes32).unwrap(); + assert!((v32 - 2.5).abs() < 0.001); +} + +#[test] +fn value_ser_various_integers() { + // i8, i16, i32 + let b = cbor::encode(&(-1i8)).unwrap(); + assert_eq!(cbor::decode::(&b).unwrap(), -1); + + let b = cbor::encode(&(300i16)).unwrap(); + assert_eq!(cbor::decode::(&b).unwrap(), 300); + + let b = cbor::encode(&(100000i32)).unwrap(); + assert_eq!(cbor::decode::(&b).unwrap(), 100000); + + // u8, u16, u32 + let b = cbor::encode(&(255u8)).unwrap(); + assert_eq!(cbor::decode::(&b).unwrap(), 255); + + let b = cbor::encode(&(65535u16)).unwrap(); + assert_eq!(cbor::decode::(&b).unwrap(), 65535); + + let b = cbor::encode(&(4000000000u32)).unwrap(); + assert_eq!(cbor::decode::(&b).unwrap(), 4000000000); +} + +#[test] +fn value_ser_option_some_none() { + let some_val: Option = Some(42); + let none_val: Option = None; + + let bytes_some = cbor::encode(&some_val).unwrap(); + let bytes_none = cbor::encode(&none_val).unwrap(); + + let decoded_some: Option = cbor::decode(&bytes_some).unwrap(); + let decoded_none: Option = cbor::decode(&bytes_none).unwrap(); + + assert_eq!(decoded_some, Some(42)); + assert_eq!(decoded_none, None); +} + +#[test] +fn value_ser_string_and_bytes() { + let s = "hello world"; + let bytes = cbor::encode(&s).unwrap(); + let decoded: String = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, s); +} + +#[test] +fn value_ser_nested_structures() { + // Vec> — exercises serialize_seq nesting + let nested: Vec> = vec![vec![1, 2], vec![3, 4, 5]]; + let bytes = cbor::encode(&nested).unwrap(); + let decoded: Vec> = cbor::decode(&bytes).unwrap(); + assert_eq!(nested, decoded); +} + +// =========================================================================== +// value_de.rs — exercise deserializer paths +// =========================================================================== + +#[test] +fn value_de_negative_i64() { + // Negative number that requires i64 + let v = Value::Integer(i64::MIN as i128); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, Value::Integer(i64::MIN as i128)); +} + +#[test] +fn value_de_float_round_trip_via_value() { + let v = Value::Float(2.718); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + if let Value::Float(f) = decoded { + assert!((f - 2.718).abs() < 0.001); + } else { + panic!("expected float"); + } +} + +#[test] +fn value_de_null_round_trip() { + let v = Value::Null; + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, Value::Null); +} + +#[test] +fn value_de_bool_round_trip() { + for b in [true, false] { + let v = Value::Bool(b); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, Value::Bool(b)); + } +} + +#[test] +fn value_de_bytes_via_deserialize_bytes() { + // Bytes round-trip through Value + let v = Value::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, Value::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF])); +} + +#[test] +fn value_de_tag_round_trip_via_value() { + let v = Value::Tag(42, Box::new(Value::Text("hello".into()))); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!( + decoded, + Value::Tag(42, Box::new(Value::Text("hello".into()))) + ); +} + +#[test] +fn value_de_map_with_text_keys() { + let entries = vec![ + (Value::Text("a".into()), Value::Integer(1)), + (Value::Text("b".into()), Value::Integer(2)), + ]; + let v = Value::Map(entries.clone()); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, Value::Map(entries)); +} + +// =========================================================================== +// Display impls — types/common.rs +// =========================================================================== + +#[test] +fn display_tag_id_choice() { + let text = TagIdChoice::Text("my-tag".into()); + assert_eq!(format!("{}", text), "my-tag"); + + let uuid = TagIdChoice::Uuid([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, + ]); + let s = format!("{}", uuid); + assert!(s.contains("01020304")); + assert!(s.contains("-")); +} + +#[test] +fn display_class_id_choice() { + let oid = ClassIdChoice::Oid(vec![0x06, 0x03, 0x55, 0x04, 0x03]); + assert!(format!("{}", oid).starts_with("oid:")); + + let uuid = ClassIdChoice::Uuid([0xAA; 16]); + assert!(format!("{}", uuid).contains("aaaaaaaa")); + + let bytes = ClassIdChoice::Bytes(vec![1, 2, 3]); + assert!(format!("{}", bytes).starts_with("bytes:")); +} + +#[test] +fn display_instance_id_choice() { + let ueid = InstanceIdChoice::Ueid(vec![0x02; 10]); + assert!(format!("{}", ueid).starts_with("ueid:")); + + let uuid = InstanceIdChoice::Uuid([0xBB; 16]); + assert!(format!("{}", uuid).contains("bbbbbbbb")); + + let bytes_id = InstanceIdChoice::Bytes(vec![0xFF; 4]); + assert!(format!("{}", bytes_id).starts_with("bytes:")); + + let pkix_key = InstanceIdChoice::PkixBase64Key("MIIBIjANBg...".into()); + assert!(format!("{}", pkix_key).starts_with("pkix-key:")); + + let pkix_cert = InstanceIdChoice::PkixBase64Cert("MIIC...".into()); + assert!(format!("{}", pkix_cert).starts_with("pkix-cert:")); + + let cose_key = InstanceIdChoice::CoseKey(vec![0xA0, 0x01]); + assert!(format!("{}", cose_key).contains("cose-key:")); + + let kt = InstanceIdChoice::KeyThumbprint(Digest::new(7, vec![0xCC; 32])); + assert!(format!("{}", kt).starts_with("key-tp:")); + + let ct = InstanceIdChoice::CertThumbprint(Digest::new(7, vec![0xDD; 32])); + assert!(format!("{}", ct).starts_with("cert-tp:")); + + let asn1 = InstanceIdChoice::PkixAsn1DerCert(vec![0x30; 100]); + assert!(format!("{}", asn1).starts_with("asn1-cert:")); +} + +#[test] +fn display_group_id_choice() { + let uuid = GroupIdChoice::Uuid([0xCC; 16]); + assert!(format!("{}", uuid).contains("cccccccc")); + + let bytes = GroupIdChoice::Bytes(vec![1, 2, 3, 4]); + assert!(format!("{}", bytes).starts_with("bytes:")); +} + +#[test] +fn display_measured_element() { + let oid = MeasuredElement::Oid(vec![0x06, 0x01]); + assert!(format!("{}", oid).starts_with("oid:")); + + let uuid = MeasuredElement::Uuid([0xDD; 16]); + assert!(format!("{}", uuid).contains("dddddddd")); + + let uint = MeasuredElement::Uint(42); + assert_eq!(format!("{}", uint), "42"); + + let text = MeasuredElement::Text("firmware".into()); + assert_eq!(format!("{}", text), "firmware"); +} + +#[test] +fn display_crypto_key_all_variants() { + assert!(format!("{}", CryptoKey::PkixBase64Key("key...".into())).starts_with("pkix-key:")); + assert!(format!("{}", CryptoKey::PkixBase64Cert("cert...".into())).starts_with("pkix-cert:")); + assert!( + format!("{}", CryptoKey::PkixBase64CertPath("path...".into())) + .starts_with("pkix-cert-path:") + ); + assert!( + format!("{}", CryptoKey::KeyThumbprint(Digest::new(1, vec![0; 32]))).starts_with("key-tp:") + ); + assert!(format!("{}", CryptoKey::CoseKey(vec![0xA1])).starts_with("cose-key:")); + assert!( + format!("{}", CryptoKey::CertThumbprint(Digest::new(1, vec![0; 32]))) + .starts_with("cert-tp:") + ); + assert!(format!( + "{}", + CryptoKey::CertPathThumbprint(Digest::new(1, vec![0; 32])) + ) + .starts_with("cert-path-tp:")); + assert!(format!("{}", CryptoKey::PkixAsn1DerCert(vec![0x30; 50])).starts_with("asn1-cert:")); + assert!(format!("{}", CryptoKey::Bytes(vec![1, 2, 3])).starts_with("bytes:")); +} + +#[test] +fn display_corim_id() { + let text = CorimId::Text("my-corim".into()); + assert_eq!(format!("{}", text), "my-corim"); + + let uuid = CorimId::Uuid([0xAA; 16]); + assert!(format!("{}", uuid).contains("aaaaaaaa")); +} + +#[test] +fn display_profile_choice() { + let uri = ProfileChoice::Uri("https://example.com".into()); + assert_eq!(format!("{}", uri), "https://example.com"); + + let oid = ProfileChoice::Oid(vec![0x06, 0x03]); + assert!(format!("{}", oid).starts_with("oid:")); +} + +#[test] +fn display_cbor_time() { + let t = CborTime::new(1234567890); + assert_eq!(format!("{}", t), "1234567890"); +} + +// =========================================================================== +// From conversions — types/common.rs +// =========================================================================== + +#[test] +fn from_conversions() { + let _: TagIdChoice = "hello".into(); + let _: TagIdChoice = String::from("hello").into(); + let _: TagIdChoice = [0u8; 16].into(); + + let _: CorimId = "test".into(); + let _: CorimId = String::from("test").into(); + let _: CorimId = [0u8; 16].into(); + + let _: MeasuredElement = "firmware".into(); + let _: MeasuredElement = String::from("firmware").into(); + let _: MeasuredElement = 42u64.into(); +} + +// =========================================================================== +// types/triples.rs — more Validate coverage +// =========================================================================== + +#[test] +fn endorsed_triple_empty_env_invalid() { + let t = EndorsedTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("environment"), "got: {err}"); +} + +#[test] +fn endorsed_triple_empty_measurements_invalid() { + let t = EndorsedTriple::new(EnvironmentMap::for_class("A", "B"), vec![]); + let err = t.valid().unwrap_err(); + assert!(err.contains("no measurement entries"), "got: {err}"); +} + +#[test] +fn endorsed_triple_invalid_measurement_invalid() { + let t = EndorsedTriple::new( + EnvironmentMap::for_class("A", "B"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap::default(), + authorized_by: None, + }], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("measurement at index 0"), "got: {err}"); +} + +#[test] +fn attest_key_triple_valid() { + let t = AttestKeyTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![CryptoKey::PkixBase64Key("key".into())], + None, + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn domain_membership_invalid_member_env() { + let t = DomainMembershipTriple::new( + EnvironmentMap::for_class("X", "Y"), + vec![EnvironmentMap { + class: None, + instance: None, + group: None, + }], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("member at index 0"), "got: {err}"); +} + +#[test] +fn domain_dependency_invalid_trustee_env() { + let t = DomainDependencyTriple::new( + EnvironmentMap::for_class("X", "Y"), + vec![EnvironmentMap { + class: None, + instance: None, + group: None, + }], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("trustee at index 0"), "got: {err}"); +} + +#[test] +fn conditional_endorsement_triple_invalid_condition() { + let env = EnvironmentMap::for_class("A", "B"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }]; + // Bad condition: empty environment + let t = ConditionalEndorsementTriple( + vec![StatefulEnvironmentRecord( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + meas.clone(), + )], + vec![EndorsedTriple::new(env, meas)], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("condition at index 0"), "got: {err}"); +} + +#[test] +fn conditional_endorsement_triple_empty_endorsements() { + let env = EnvironmentMap::for_class("A", "B"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }]; + let t = ConditionalEndorsementTriple(vec![StatefulEnvironmentRecord(env, meas)], vec![]); + let err = t.valid().unwrap_err(); + assert!(err.contains("endorsements must not be empty"), "got: {err}"); +} + +#[test] +fn conditional_endorsement_triple_invalid_endorsement() { + let env = EnvironmentMap::for_class("A", "B"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }]; + let bad_endorsed = EndorsedTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + meas.clone(), + ); + let t = ConditionalEndorsementTriple( + vec![StatefulEnvironmentRecord(env, meas)], + vec![bad_endorsed], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("endorsement at index 0"), "got: {err}"); +} + +#[test] +fn triples_map_validates_endorsed_triples() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: Some(vec![EndorsedTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![], + )]), + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!(err.contains("endorsed value at index 0"), "got: {err}"); +} + +#[test] +fn triples_map_validates_identity_triples() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: Some(vec![IdentityTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![CryptoKey::PkixBase64Key("k".into())], + None, + )]), + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!(err.contains("identity triple at index 0"), "got: {err}"); +} + +#[test] +fn triples_map_validates_attest_key_triples() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: Some(vec![AttestKeyTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![CryptoKey::PkixBase64Key("k".into())], + None, + )]), + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!(err.contains("attest-key triple at index 0"), "got: {err}"); +} + +#[test] +fn triples_map_validates_dependency_triples() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: Some(vec![DomainDependencyTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![EnvironmentMap::for_class("X", "Y")], + )]), + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!(err.contains("dependency triple at index 0"), "got: {err}"); +} + +#[test] +fn triples_map_validates_membership_triples() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: Some(vec![DomainMembershipTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![EnvironmentMap::for_class("X", "Y")], + )]), + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!(err.contains("membership triple at index 0"), "got: {err}"); +} + +// =========================================================================== +// types/corim.rs — Display, CorimLocator thumbprint, ConciseTagChoice +// =========================================================================== + +#[test] +fn corim_locator_single_thumbprint_round_trip() { + let loc = CorimLocator { + href: CorimLocatorHref::Single("https://example.com".into()), + thumbprint: Some(CorimLocatorThumbprint::Single(Digest::new( + 7, + vec![0xAA; 32], + ))), + }; + let bytes = cbor::encode(&loc).unwrap(); + let decoded: CorimLocator = cbor::decode(&bytes).unwrap(); + assert_eq!(loc, decoded); +} + +#[test] +fn corim_locator_multiple_thumbprints_round_trip() { + let loc = CorimLocator { + href: CorimLocatorHref::Multiple(vec!["https://a.com".into(), "https://b.com".into()]), + thumbprint: Some(CorimLocatorThumbprint::Multiple(vec![ + Digest::new(7, vec![0xAA; 32]), + Digest::new(1, vec![0xBB; 20]), + ])), + }; + let bytes = cbor::encode(&loc).unwrap(); + let decoded: CorimLocator = cbor::decode(&bytes).unwrap(); + assert_eq!(loc, decoded); +} + +#[test] +fn concise_tag_choice_unknown_tag() { + // Create a tagged value with an unknown tag number + let tagged = Value::Tag(999, Box::new(Value::Bytes(vec![1, 2, 3]))); + let bytes = cbor::encode(&tagged).unwrap(); + let decoded: ConciseTagChoice = cbor::decode(&bytes).unwrap(); + assert!(matches!(decoded, ConciseTagChoice::Unknown(999, _))); +} + +// =========================================================================== +// types/measurement.rs — IntegrityRegisters, IpAddr, more +// =========================================================================== + +#[test] +fn integrity_registers_round_trip() { + use std::collections::BTreeMap; + let mut map = BTreeMap::new(); + map.insert( + IntegrityRegisterId::Uint(0), + vec![Digest::new(7, vec![0xAA; 32])], + ); + map.insert( + IntegrityRegisterId::Text("pcr-1".into()), + vec![Digest::new(1, vec![0xBB; 20])], + ); + let regs = IntegrityRegisters(map); + let mval = MeasurementValuesMap { + integrity_registers: Some(regs), + ..MeasurementValuesMap::default() + }; + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert!(decoded.integrity_registers.is_some()); + let regs = decoded.integrity_registers.unwrap(); + assert_eq!(regs.0.len(), 2); +} + +#[test] +fn ip_addr_v6_round_trip() { + let mval = MeasurementValuesMap { + ip_addr: Some(IpAddr::V6([ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ])), + ..MeasurementValuesMap::default() + }; + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert!(matches!(decoded.ip_addr, Some(IpAddr::V6(_)))); +} + +#[test] +fn mac_addr_eui64_round_trip() { + let mval = MeasurementValuesMap { + mac_addr: Some(MacAddr::Eui64([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + ])), + ..MeasurementValuesMap::default() + }; + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert!(matches!(decoded.mac_addr, Some(MacAddr::Eui64(_)))); +} + +#[test] +fn raw_value_masked_round_trip() { + let mval = MeasurementValuesMap { + raw_value: Some(RawValueChoice::Masked { + value: vec![0x01, 0x02, 0x03, 0x04], + mask: vec![0xFF, 0xFF, 0xFF, 0xFF], + }), + ..MeasurementValuesMap::default() + }; + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert!(matches!( + decoded.raw_value, + Some(RawValueChoice::Masked { .. }) + )); +} + +#[test] +fn measurement_values_map_many_fields() { + let mval = MeasurementValuesMap { + version: Some(VersionMap { + version: "1.0".into(), + version_scheme: Some(16384), + }), + serial_number: Some("SN12345".into()), + ueid: Some(vec![0x02; 10]), + uuid: Some(vec![0xAA; 16]), + name: Some("test-comp".into()), + cryptokeys: Some(vec![CryptoKey::PkixBase64Key("key...".into())]), + ..MeasurementValuesMap::default() + }; + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.serial_number, Some("SN12345".into())); + assert_eq!(decoded.name, Some("test-comp".into())); + assert!(decoded.cryptokeys.is_some()); + assert!(decoded.ueid.is_some()); + assert!(decoded.uuid.is_some()); +} + +// =========================================================================== +// types/common.rs — CryptoKey CBOR round-trips (cover more serde paths) +// =========================================================================== + +#[test] +fn crypto_key_all_variants_round_trip() { + let keys: Vec = vec![ + CryptoKey::PkixBase64Key("MIIBIjANBg...".into()), + CryptoKey::PkixBase64Cert("MIIC...".into()), + CryptoKey::PkixBase64CertPath("MIIE...".into()), + CryptoKey::KeyThumbprint(Digest::new(7, vec![0xCC; 32])), + CryptoKey::CoseKey(vec![0xA1, 0x01]), + CryptoKey::CertThumbprint(Digest::new(7, vec![0xDD; 32])), + CryptoKey::CertPathThumbprint(Digest::new(7, vec![0xEE; 32])), + CryptoKey::PkixAsn1DerCert(vec![0x30, 0x82]), + CryptoKey::Bytes(vec![0x01, 0x02, 0x03]), + ]; + for key in &keys { + let bytes = cbor::encode(key).unwrap(); + let decoded: CryptoKey = cbor::decode(&bytes).unwrap(); + assert_eq!(key, &decoded, "failed for {:?}", key); + } +} + +#[test] +fn instance_id_all_variants_round_trip() { + let ids: Vec = vec![ + InstanceIdChoice::Uuid([0xBB; 16]), + InstanceIdChoice::Bytes(vec![0x01, 0x02]), + InstanceIdChoice::PkixBase64Key("key-str".into()), + InstanceIdChoice::PkixBase64Cert("cert-str".into()), + InstanceIdChoice::CoseKey(vec![0xA1, 0x01]), + InstanceIdChoice::KeyThumbprint(Digest::new(7, vec![0xCC; 32])), + InstanceIdChoice::CertThumbprint(Digest::new(7, vec![0xDD; 32])), + InstanceIdChoice::PkixAsn1DerCert(vec![0x30, 0x82]), + ]; + for id in &ids { + let bytes = cbor::encode(id).unwrap(); + let decoded: InstanceIdChoice = cbor::decode(&bytes).unwrap(); + assert_eq!(id, &decoded, "failed for {:?}", id); + } +} + +// =========================================================================== +// ComidTag validation — more paths +// =========================================================================== + +#[test] +fn comid_tag_empty_entities_invalid() { + let comid = corim::types::comid::ComidTag { + language: None, + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("t".into()), + tag_version: None, + }, + entities: Some(vec![]), // empty entities + linked_tags: None, + triples: TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )]), + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }, + }; + let err = comid.valid().unwrap_err(); + assert!(err.contains("entities"), "got: {err}"); +} + +#[test] +fn comid_tag_empty_linked_tags_invalid() { + let comid = corim::types::comid::ComidTag { + language: None, + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("t".into()), + tag_version: None, + }, + entities: None, + linked_tags: Some(vec![]), // empty linked_tags + triples: TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )]), + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }, + }; + let err = comid.valid().unwrap_err(); + assert!(err.contains("linked-tags"), "got: {err}"); +} + +// =========================================================================== +// TagIdentity helper +// =========================================================================== + +#[test] +fn tag_identity_version_default() { + let tid = TagIdentity { + tag_id: TagIdChoice::Text("x".into()), + tag_version: None, + }; + assert_eq!(tid.tag_version_or_default(), 0); + + let tid2 = TagIdentity { + tag_id: TagIdChoice::Text("x".into()), + tag_version: Some(5), + }; + assert_eq!(tid2.tag_version_or_default(), 5); +} + +// =========================================================================== +// JSON value_conv.rs — exercise more tag→JSON + type_choice→value paths +// =========================================================================== + +#[cfg(feature = "json")] +mod json_coverage_tests { + use corim::cbor::value::Value; + use corim::json::json_to_value; + use corim::json::value_to_json; + + #[test] + fn json_float_nan_becomes_null() { + let v = Value::Float(f64::NAN); + let j = value_to_json(&v); + assert!(j.is_null()); + } + + #[test] + fn json_large_integer_becomes_string() { + let v = Value::Integer(i128::MAX); + let j = value_to_json(&v); + assert!(j.is_string()); + } + + #[test] + fn json_map_with_text_key() { + let v = Value::Map(vec![( + Value::Text("name".into()), + Value::Text("test".into()), + )]); + let j = value_to_json(&v); + assert_eq!(j["name"], "test"); + } + + #[test] + fn json_map_with_non_standard_key() { + let v = Value::Map(vec![(Value::Bool(true), Value::Integer(1))]); + let j = value_to_json(&v); + // Bool key gets Debug-formatted + assert!(j.is_object()); + } + + #[test] + fn json_tag_oid() { + let v = Value::Tag(111, Box::new(Value::Bytes(vec![0x06, 0x03, 0x55, 0x04]))); + let j = value_to_json(&v); + assert_eq!(j["type"], "oid"); + } + + #[test] + fn json_tag_ueid_non_bytes() { + // UEID tag wrapping non-bytes + let v = Value::Tag(550, Box::new(Value::Text("not-bytes".into()))); + let j = value_to_json(&v); + assert_eq!(j["type"], "ueid"); + } + + #[test] + fn json_tag_uuid_non_bytes() { + // UUID tag wrapping non-bytes + let v = Value::Tag(37, Box::new(Value::Text("not-bytes".into()))); + let j = value_to_json(&v); + assert_eq!(j["type"], "uuid"); + } + + #[test] + fn json_tag_svn() { + let v = Value::Tag(552, Box::new(Value::Integer(42))); + let j = value_to_json(&v); + assert_eq!(j["type"], "svn"); + assert_eq!(j["value"], 42); + } + + #[test] + fn json_tag_min_svn() { + let v = Value::Tag(553, Box::new(Value::Integer(10))); + let j = value_to_json(&v); + assert_eq!(j["type"], "min-svn"); + } + + #[test] + fn json_tag_crypto_keys() { + for (tag, expected_type) in [ + (554, "pkix-base64-key"), + (555, "pkix-base64-cert"), + (556, "pkix-base64-cert-path"), + (557, "key-thumbprint"), + (558, "cose-key"), + (559, "cert-thumbprint"), + (560, "bytes"), + (561, "cert-path-thumbprint"), + (562, "pkix-asn1der-cert"), + (563, "masked-raw-value"), + (564, "int-range"), + ] { + let v = Value::Tag(tag, Box::new(Value::Integer(0))); + let j = value_to_json(&v); + assert_eq!(j["type"], expected_type, "tag {tag}"); + } + } + + #[test] + fn json_tag_coswid_comid_cotl() { + for tag in [505, 506, 508] { + let v = Value::Tag(tag, Box::new(Value::Bytes(vec![0xA0]))); + let j = value_to_json(&v); + assert_eq!(j["__cbor_tag"], tag); + } + } + + #[test] + fn json_tag_unknown() { + let v = Value::Tag(99999, Box::new(Value::Text("hello".into()))); + let j = value_to_json(&v); + assert_eq!(j["__cbor_tag"], 99999); + assert_eq!(j["__cbor_value"], "hello"); + } + + #[test] + fn json_epoch_time_tag() { + let v = Value::Tag(1, Box::new(Value::Integer(1234567890))); + let j = value_to_json(&v); + assert_eq!(j, 1234567890); + } + + // --- json_to_value type-choice paths --- + + #[test] + fn json_type_choice_svn_to_value() { + let j = serde_json::json!({"type": "svn", "value": 42}); + let v = json_to_value(&j); + assert!(matches!(v, Value::Tag(552, _))); + } + + #[test] + fn json_type_choice_min_svn_to_value() { + let j = serde_json::json!({"type": "min-svn", "value": 10}); + let v = json_to_value(&j); + assert!(matches!(v, Value::Tag(553, _))); + } + + #[test] + fn json_type_choice_all_crypto_to_value() { + let cases = vec![ + ("pkix-base64-key", 554), + ("pkix-base64-cert", 555), + ("pkix-base64-cert-path", 556), + ("key-thumbprint", 557), + ("cose-key", 558), + ("cert-thumbprint", 559), + ("cert-path-thumbprint", 561), + ("pkix-asn1der-cert", 562), + ("masked-raw-value", 563), + ("int-range", 564), + ]; + for (type_name, expected_tag) in cases { + let j = serde_json::json!({"type": type_name, "value": 0}); + let v = json_to_value(&j); + match v { + Value::Tag(t, _) => assert_eq!(t, expected_tag, "type: {type_name}"), + _ => panic!("expected tag for {type_name}, got {v:?}"), + } + } + } + + #[test] + fn json_type_choice_ueid_base64_to_value() { + let j = serde_json::json!({"type": "ueid", "value": "AQID"}); + let v = json_to_value(&j); + match v { + Value::Tag(550, inner) => assert!(matches!(*inner, Value::Bytes(_))), + _ => panic!("expected tag 550"), + } + } + + #[test] + fn json_type_choice_bytes_base64_to_value() { + let j = serde_json::json!({"type": "bytes", "value": "AQID"}); + let v = json_to_value(&j); + match v { + Value::Tag(560, inner) => assert!(matches!(*inner, Value::Bytes(_))), + _ => panic!("expected tag 560"), + } + } + + #[test] + fn json_type_choice_uuid_invalid_format() { + // UUID with non-hex string + let j = serde_json::json!({"type": "uuid", "value": "not-a-uuid"}); + let v = json_to_value(&j); + // Falls back to Tag(37, json_to_value) + assert!(matches!(v, Value::Tag(37, _))); + } + + #[test] + fn json_type_choice_unknown() { + let j = serde_json::json!({"type": "custom-type", "value": "data"}); + let v = json_to_value(&j); + // Should be a map with "type" and "value" text keys + assert!(matches!(v, Value::Map(_))); + } + + #[test] + fn json_cbor_tag_object_to_value() { + let j = serde_json::json!({"__cbor_tag": 501, "__cbor_value": {"0": "test"}}); + let v = json_to_value(&j); + assert!(matches!(v, Value::Tag(501, _))); + } + + #[test] + fn json_number_u64_to_value() { + let j = serde_json::json!(u64::MAX); + let v = json_to_value(&j); + assert!(matches!(v, Value::Integer(_))); + } + + #[test] + fn json_number_float_to_value() { + let j = serde_json::json!(3.14); + let v = json_to_value(&j); + assert!(matches!(v, Value::Float(_))); + } + + #[test] + fn json_string_key_to_int_key() { + // "entity-name" is in the global key table at index 31 + let j = serde_json::json!({"entity-name": "ACME"}); + let v = json_to_value(&j); + if let Value::Map(entries) = v { + assert_eq!(entries[0].0, Value::Integer(31)); + } else { + panic!("expected map"); + } + } + + #[test] + fn json_string_key_numeric() { + // A numeric string key that's not in the table + let j = serde_json::json!({"99": "value"}); + let v = json_to_value(&j); + if let Value::Map(entries) = v { + assert_eq!(entries[0].0, Value::Integer(99)); + } else { + panic!("expected map"); + } + } + + #[test] + fn json_string_key_non_numeric() { + // A non-numeric, non-registered string key + let j = serde_json::json!({"custom-key": "value"}); + let v = json_to_value(&j); + if let Value::Map(entries) = v { + assert_eq!(entries[0].0, Value::Text("custom-key".into())); + } else { + panic!("expected map"); + } + } + + #[test] + fn json_to_json_pretty_round_trip() { + use corim::json; + use corim::types::environment::ClassMap; + let class = ClassMap::new("Test", "Widget"); + let pretty = json::to_json_pretty(&class).unwrap(); + assert!(pretty.contains('\n')); + let decoded: ClassMap = json::from_json(&pretty).unwrap(); + assert_eq!(class, decoded); + } + + #[test] + fn json_from_json_parse_error() { + use corim::json; + use corim::types::environment::ClassMap; + let result = json::from_json::("not valid json{{{"); + assert!(result.is_err()); + } +} diff --git a/corim/tests/coverage_ceiling_tests.rs b/corim/tests/coverage_ceiling_tests.rs new file mode 100644 index 0000000..0ddfb69 --- /dev/null +++ b/corim/tests/coverage_ceiling_tests.rs @@ -0,0 +1,1237 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Coverage ceiling tests — targeting ~88% practical limit. +//! +//! Covers: +//! 🟢 Low-hanging fruit: builder remaining methods, validate no-match paths, json edges +//! 🟡 Negative decode: malformed CBOR for type-choice enums (common, measurement, corim, triples) +//! 🟠 Serde infra: value_de error paths, Tagged decode errors, i128 boundaries + +use corim::cbor; +use corim::cbor::value::Value; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::coswid::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::tags::*; +use corim::types::triples::*; + +// =================================================================== +// 🟢 builder.rs — remaining uncovered methods +// =================================================================== + +#[test] +fn builder_cotl_set_tag_version() { + let cotl = corim::builder::CotlBuilder::new(TagIdChoice::Text("v".into()), i64::MAX) + .set_tag_version(3) + .add_tag_id(TagIdChoice::Text("x".into())) + .build() + .unwrap(); + assert_eq!(cotl.tag_identity.tag_version, Some(3)); +} + +#[test] +fn builder_corim_add_entity() { + let entity = EntityMap { + entity_name: "ACME".into(), + reg_id: Some("https://acme.example".into()), + role: vec![1], + }; + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + let corim = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .add_entity(entity) + .add_comid_tag(comid) + .unwrap() + .build() + .unwrap(); + assert!(corim.entities.is_some()); + assert_eq!(corim.entities.unwrap().len(), 1); +} + +#[test] +fn builder_corim_add_dependent_rim() { + let locator = CorimLocator { + href: CorimLocatorHref::Single("https://example.com/dep.corim".into()), + thumbprint: None, + }; + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + let corim = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .add_dependent_rim(locator) + .add_comid_tag(comid) + .unwrap() + .build() + .unwrap(); + assert!(corim.dependent_rims.is_some()); +} + +#[test] +fn builder_corim_add_tag_directly() { + let corim = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .add_tag(ConciseTagChoice::Comid(vec![0xA0])) + .build() + .unwrap(); + assert_eq!(corim.tags.len(), 1); +} + +#[test] +fn builder_corim_set_profile() { + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + let corim = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .set_profile(ProfileChoice::Uri("https://example.com/profile".into())) + .add_comid_tag(comid) + .unwrap() + .build() + .unwrap(); + assert!(corim.profile.is_some()); +} + +#[test] +fn builder_comid_conditional_endorsement_empty_fails() { + let result = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_conditional_endorsement(ConditionalEndorsementTriple( + vec![StatefulEnvironmentRecord( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )], + vec![], // empty endorsements — but builder doesn't check inner validity, just [+T] + )) + .build(); + // The builder should succeed (it doesn't validate inner structure, just that triple types exist) + assert!(result.is_ok()); +} + +#[test] +fn builder_comid_conditional_endorsement_series() { + let env = EnvironmentMap::for_class("V", "M"); + let meas = vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }]; + let ces = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: vec![], + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new(meas.clone(), meas)], + ); + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_conditional_endorsement_series(ces) + .build() + .unwrap(); + assert!(comid.triples.conditional_endorsement_series.is_some()); +} + +#[test] +fn builder_comid_set_language_and_entities() { + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .set_language("en-US") + .set_tag_version(2) + .add_entity(EntityMap { + entity_name: "Test".into(), + reg_id: None, + role: vec![0], + }) + .add_linked_tag(LinkedTagMap { + linked_tag_id: TagIdChoice::Text("other".into()), + tag_rel: 0, + }) + .add_reference_triple(ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + assert_eq!(comid.language.as_deref(), Some("en-US")); + assert_eq!(comid.tag_identity.tag_version, Some(2)); + assert!(comid.entities.is_some()); + assert!(comid.linked_tags.is_some()); +} + +#[test] +fn builder_corim_add_coswid_invalid_fails() { + let bad_coswid = ConciseSwidTag::new( + TagIdChoice::Text("x".into()), + "Test", + 0, + vec![], // no entities + ); + let result = + corim::builder::CorimBuilder::new(CorimId::Text("c".into())).add_coswid(bad_coswid); + assert!(result.is_err()); +} + +// =================================================================== +// 🟢 validate.rs — no-match paths, endorsement, digests_match +// =================================================================== + +#[test] +fn match_reference_values_no_env_match() { + let ref_triple = ReferenceTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap::for_class("OTHER", "Thing"), + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn match_reference_values_no_measurement_match() { + let env = EnvironmentMap::for_class("ACME", "Widget"); + let ref_triple = ReferenceTriple::new( + env.clone(), + vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(99)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: env, + measurements: vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), // different mkey + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn match_reference_values_digest_mismatch_same_alg() { + let env = EnvironmentMap::for_class("V", "M"); + let ref_triple = ReferenceTriple::new( + env.clone(), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 32])]), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: env, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xBB; 32])]), // different value + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn match_reference_evidence_lacks_digests() { + let env = EnvironmentMap::for_class("V", "M"); + let ref_triple = ReferenceTriple::new( + env.clone(), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 32])]), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: env, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn match_reference_evidence_lacks_svn() { + let env = EnvironmentMap::for_class("V", "M"); + let ref_triple = ReferenceTriple::new( + env.clone(), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(5)), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: env, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 32])]), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn apply_endorsement_series_no_env_match() { + let ces = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: EnvironmentMap::for_class("ACME", "Widget"), + claims_list: vec![], + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("endorsed".into()), + ..Default::default() + }, + authorized_by: None, + }], + )], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap::for_class("OTHER", "Thing"), + measurements: vec![], + }]; + let result = corim::validate::apply_endorsement_series(&[ces], &evidence).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn apply_endorsement_series_no_selection_match() { + let env = EnvironmentMap::for_class("V", "M"); + let ces = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: vec![], + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(99)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(100)), + ..Default::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("e".into()), + ..Default::default() + }, + authorized_by: None, + }], + )], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: env, + measurements: vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::apply_endorsement_series(&[ces], &evidence).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn appraisal_context_endorsement_phase() { + let env = EnvironmentMap::for_class("V", "M"); + let meas_sel = vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }]; + let meas_add = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("endorsed-value".into()), + ..Default::default() + }, + authorized_by: None, + }]; + let ces = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: vec![], + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new(meas_sel.clone(), meas_add)], + ); + let mut ctx = corim::validate::AppraisalContext::new(); + ctx.add_evidence(vec![corim::validate::EvidenceClaim { + environment: env, + measurements: meas_sel, + }]); + let endorsed = ctx.apply_conditional_endorsements(&[ces]).unwrap(); + assert_eq!(endorsed.len(), 1); + assert!(ctx + .entries + .iter() + .any(|e| e.claim_type == corim::validate::ClaimType::Endorsement)); +} + +// =================================================================== +// 🟡 Negative decode: types/common.rs — wrong tag inner types +// =================================================================== + +/// Helper: encode a Value to CBOR, then try to decode as T. Returns the error string. +fn decode_err(val: &Value) -> String { + let bytes = cbor::encode(val).unwrap(); + cbor::decode::(&bytes).unwrap_err().to_string() +} + +#[test] +fn tag_id_bad_uuid_inner() { + // Tag 37 wrapping text instead of bytes + let v = Value::Tag(TAG_UUID, Box::new(Value::Text("not-bytes".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn tag_id_unexpected_type() { + let v = Value::Integer(42); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn class_id_oid_non_bytes() { + let v = Value::Tag(TAG_OID, Box::new(Value::Text("not-bytes".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn class_id_uuid_wrong_size() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Bytes(vec![0; 8]))); + let err = decode_err::(&v); + assert!(err.contains("16 bytes"), "got: {err}"); +} + +#[test] +fn class_id_uuid_non_bytes() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn class_id_bytes_non_bytes() { + let v = Value::Tag(TAG_BYTES, Box::new(Value::Integer(1))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn class_id_unknown_tag() { + let v = Value::Tag(999, Box::new(Value::Bytes(vec![1]))); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn instance_id_ueid_non_bytes() { + let v = Value::Tag(TAG_UEID, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn instance_id_ueid_wrong_size() { + let v = Value::Tag(TAG_UEID, Box::new(Value::Bytes(vec![0; 3]))); + let err = decode_err::(&v); + assert!(err.contains("7-33"), "got: {err}"); +} + +#[test] +fn instance_id_pkix_key_non_text() { + let v = Value::Tag(TAG_PKIX_BASE64_KEY, Box::new(Value::Bytes(vec![1]))); + let err = decode_err::(&v); + assert!(err.contains("text"), "got: {err}"); +} + +#[test] +fn instance_id_pkix_cert_non_text() { + let v = Value::Tag(TAG_PKIX_BASE64_CERT, Box::new(Value::Bytes(vec![1]))); + let err = decode_err::(&v); + assert!(err.contains("text"), "got: {err}"); +} + +#[test] +fn instance_id_cose_key_non_bytes() { + let v = Value::Tag(TAG_COSE_KEY, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn instance_id_key_thumbprint_non_array() { + let v = Value::Tag(TAG_KEY_THUMBPRINT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("array"), "got: {err}"); +} + +#[test] +fn instance_id_cert_thumbprint_non_array() { + let v = Value::Tag(TAG_CERT_THUMBPRINT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("array"), "got: {err}"); +} + +#[test] +fn instance_id_asn1_cert_non_bytes() { + let v = Value::Tag(TAG_PKIX_ASN1DER_CERT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn instance_id_bytes_non_bytes() { + let v = Value::Tag(TAG_BYTES, Box::new(Value::Integer(1))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn instance_id_unknown_tag() { + let v = Value::Integer(42); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn group_id_uuid_non_bytes() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn group_id_uuid_wrong_size() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Bytes(vec![0; 8]))); + let err = decode_err::(&v); + assert!(err.contains("16 bytes"), "got: {err}"); +} + +#[test] +fn group_id_bytes_non_bytes() { + let v = Value::Tag(TAG_BYTES, Box::new(Value::Integer(1))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn group_id_unknown() { + let v = Value::Text("nope".into()); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn measured_element_negative_int() { + let v = Value::Integer(-1); + let err = decode_err::(&v); + assert!(err.contains("unsigned"), "got: {err}"); +} + +#[test] +fn measured_element_unknown() { + let v = Value::Bool(true); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn crypto_key_pkix_key_non_text() { + let v = Value::Tag(TAG_PKIX_BASE64_KEY, Box::new(Value::Bytes(vec![1]))); + let err = decode_err::(&v); + assert!(err.contains("text"), "got: {err}"); +} + +#[test] +fn crypto_key_pkix_cert_non_text() { + let v = Value::Tag(TAG_PKIX_BASE64_CERT, Box::new(Value::Bytes(vec![1]))); + let err = decode_err::(&v); + assert!(err.contains("text"), "got: {err}"); +} + +#[test] +fn crypto_key_pkix_cert_path_non_text() { + let v = Value::Tag(TAG_PKIX_BASE64_CERT_PATH, Box::new(Value::Bytes(vec![1]))); + let err = decode_err::(&v); + assert!(err.contains("text"), "got: {err}"); +} + +#[test] +fn crypto_key_cose_key_non_bytes() { + let v = Value::Tag(TAG_COSE_KEY, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn crypto_key_asn1_cert_non_bytes() { + let v = Value::Tag(TAG_PKIX_ASN1DER_CERT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn crypto_key_bytes_non_bytes() { + let v = Value::Tag(TAG_BYTES, Box::new(Value::Integer(1))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn crypto_key_unknown_tag() { + let v = Value::Integer(42); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn crypto_key_key_thumbprint_non_array() { + let v = Value::Tag(TAG_KEY_THUMBPRINT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("array"), "got: {err}"); +} + +#[test] +fn crypto_key_cert_thumbprint_non_array() { + let v = Value::Tag(TAG_CERT_THUMBPRINT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("array"), "got: {err}"); +} + +#[test] +fn crypto_key_cert_path_thumbprint_non_array() { + let v = Value::Tag(TAG_CERT_PATH_THUMBPRINT, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("array"), "got: {err}"); +} + +// =================================================================== +// 🟡 Negative decode: types/measurement.rs +// =================================================================== + +#[test] +fn svn_unknown_type() { + let v = Value::Text("not-a-svn".into()); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn mac_addr_wrong_length() { + let v = Value::Bytes(vec![0; 4]); // not 6 or 8 + let err = decode_err::(&v); + assert!(err.contains("6 or 8"), "got: {err}"); +} + +#[test] +fn mac_addr_non_bytes() { + let v = Value::Text("not-bytes".into()); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn ip_addr_wrong_length() { + let v = Value::Bytes(vec![0; 8]); // not 4 or 16 + let err = decode_err::(&v); + assert!(err.contains("4 or 16"), "got: {err}"); +} + +#[test] +fn ip_addr_non_bytes() { + let v = Value::Text("not-bytes".into()); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn int_range_bad_tag_inner() { + let v = Value::Tag(TAG_INT_RANGE, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("[min, max]"), "got: {err}"); +} + +#[test] +fn int_range_unknown_type() { + let v = Value::Text("nope".into()); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn raw_value_bad_tag_560_inner() { + let v = Value::Tag(TAG_BYTES, Box::new(Value::Integer(1))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn raw_value_bad_tag_563_inner() { + let v = Value::Tag(TAG_MASKED_RAW_VALUE, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("[value, mask]"), "got: {err}"); +} + +#[test] +fn raw_value_unknown_tag() { + let v = Value::Integer(42); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn integrity_register_id_unknown_type() { + // Map with a bool key (invalid for register id) + let v = Value::Map(vec![( + Value::Bool(true), + Value::Array(vec![Value::Array(vec![ + Value::Integer(7), + Value::Bytes(vec![0xAA; 32]), + ])]), + )]); + let err = decode_err::(&v); + assert!(err.contains("uint or text"), "got: {err}"); +} + +#[test] +fn integrity_registers_non_map() { + let v = Value::Array(vec![]); + let err = decode_err::(&v); + assert!(err.contains("map"), "got: {err}"); +} + +#[test] +fn integrity_registers_non_array_digests() { + let v = Value::Map(vec![(Value::Integer(0), Value::Text("not-array".into()))]); + let err = decode_err::(&v); + assert!(err.contains("array"), "got: {err}"); +} + +#[test] +fn integrity_registers_bad_digest_format() { + let v = Value::Map(vec![( + Value::Integer(0), + Value::Array(vec![Value::Text("not-a-pair".into())]), + )]); + let err = decode_err::(&v); + assert!(err.contains("digest"), "got: {err}"); +} + +#[test] +fn integrity_registers_bad_digest_alg() { + let v = Value::Map(vec![( + Value::Integer(0), + Value::Array(vec![Value::Array(vec![ + Value::Text("not-int".into()), + Value::Bytes(vec![0]), + ])]), + )]); + let err = decode_err::(&v); + assert!(err.contains("alg"), "got: {err}"); +} + +#[test] +fn integrity_registers_bad_digest_val() { + let v = Value::Map(vec![( + Value::Integer(0), + Value::Array(vec![Value::Array(vec![ + Value::Integer(7), + Value::Text("not-bytes".into()), + ])]), + )]); + let err = decode_err::(&v); + assert!(err.contains("val"), "got: {err}"); +} + +// =================================================================== +// 🟡 Negative decode: types/corim.rs +// =================================================================== + +#[test] +fn corim_locator_href_non_text_array_items() { + let v = Value::Map(vec![( + Value::Integer(0), + Value::Array(vec![Value::Integer(42)]), + )]); + let err = decode_err::(&v); + assert!(err.contains("string"), "got: {err}"); +} + +#[test] +fn corim_locator_href_wrong_type() { + let v = Value::Map(vec![(Value::Integer(0), Value::Integer(42))]); + let err = decode_err::(&v); + assert!( + err.contains("expected") || err.contains("href"), + "got: {err}" + ); +} + +#[test] +fn concise_tag_choice_non_tagged() { + let v = Value::Text("not-tagged".into()); + let err = decode_err::(&v); + assert!( + err.contains("tagged") || err.contains("expected"), + "got: {err}" + ); +} + +#[test] +fn concise_tag_choice_comid_non_bytes() { + let v = Value::Tag(TAG_COMID, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn concise_tag_choice_coswid_non_bytes() { + let v = Value::Tag(TAG_COSWID, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn concise_tag_choice_cotl_non_bytes() { + let v = Value::Tag(TAG_COTL, Box::new(Value::Text("x".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn profile_choice_unknown() { + let v = Value::Integer(42); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +#[test] +fn corim_id_unknown() { + let v = Value::Bool(true); + let err = decode_err::(&v); + assert!(err.contains("expected"), "got: {err}"); +} + +// =================================================================== +// 🟡 Negative decode: types/triples.rs — CesCondition +// =================================================================== + +#[test] +fn ces_condition_round_trip_with_auth() { + let cond = CesCondition { + environment: EnvironmentMap::for_class("V", "M"), + claims_list: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + authorized_by: Some(vec![CryptoKey::PkixBase64Key("key".into())]), + }; + let bytes = cbor::encode(&cond).unwrap(); + let decoded: CesCondition = cbor::decode(&bytes).unwrap(); + assert!(decoded.authorized_by.is_some()); + assert_eq!(decoded.claims_list.len(), 1); +} + +#[test] +fn ces_condition_round_trip_without_auth() { + let cond = CesCondition { + environment: EnvironmentMap::for_class("V", "M"), + claims_list: vec![], + authorized_by: None, + }; + let bytes = cbor::encode(&cond).unwrap(); + let decoded: CesCondition = cbor::decode(&bytes).unwrap(); + assert!(decoded.authorized_by.is_none()); +} + +#[test] +fn ces_triple_accessor_methods() { + let env = EnvironmentMap::for_class("V", "M"); + let meas = vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }]; + let record = ConditionalSeriesRecord::new(meas.clone(), meas.clone()); + assert_eq!(record.selection().len(), 1); + assert_eq!(record.addition().len(), 1); + + let cond = CesCondition { + environment: env.clone(), + claims_list: vec![], + authorized_by: None, + }; + let ces = ConditionalEndorsementSeriesTriple::new(cond, vec![record]); + assert_eq!(ces.condition().environment, env); + assert_eq!(ces.series().len(), 1); +} + +#[test] +fn endorsed_triple_accessors() { + let env = EnvironmentMap::for_class("V", "M"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }]; + let t = EndorsedTriple::new(env.clone(), meas); + assert_eq!(*t.condition(), env); + assert_eq!(t.endorsement().len(), 1); +} + +#[test] +fn reference_triple_accessors() { + let env = EnvironmentMap::for_class("V", "M"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }]; + let t = ReferenceTriple::new(env.clone(), meas); + assert_eq!(*t.environment(), env); + assert_eq!(t.measurements().len(), 1); +} + +#[test] +fn coswid_triple_accessors() { + let t = CoswidTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![TagIdChoice::Text("t".into())], + ); + assert_eq!( + t.environment().class.as_ref().unwrap().vendor.as_deref(), + Some("V") + ); + assert_eq!(t.tag_ids().len(), 1); +} + +#[test] +fn identity_triple_accessors() { + let t = IdentityTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![CryptoKey::PkixBase64Key("k".into())], + Some(KeyTripleConditions { + mkey: Some(MeasuredElement::Uint(1)), + authorized_by: None, + }), + ); + assert!(t.conditions().is_some()); + assert_eq!(t.keys().len(), 1); + assert_eq!(*t.environment(), EnvironmentMap::for_class("V", "M")); +} + +#[test] +fn attest_key_triple_accessors() { + let t = AttestKeyTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![CryptoKey::PkixBase64Key("k".into())], + None, + ); + assert!(t.conditions().is_none()); + assert_eq!(t.keys().len(), 1); +} + +#[test] +fn domain_dependency_accessors() { + let t = DomainDependencyTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![EnvironmentMap::for_class("A", "B")], + ); + assert_eq!(*t.domain_id(), EnvironmentMap::for_class("V", "M")); + assert_eq!(t.trustees().len(), 1); +} + +#[test] +fn domain_membership_accessors() { + let t = DomainMembershipTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![EnvironmentMap::for_class("A", "B")], + ); + assert_eq!(*t.domain_id(), EnvironmentMap::for_class("V", "M")); + assert_eq!(t.members().len(), 1); +} + +// =================================================================== +// 🟠 Serde infra: value_de.rs error paths +// =================================================================== + +#[test] +fn value_de_deserialize_seq_non_array() { + // Try to decode an integer as a Vec + let v = Value::Integer(42); + let bytes = cbor::encode(&v).unwrap(); + let result = cbor::decode::>(&bytes); + assert!(result.is_err()); +} + +#[test] +fn value_de_deserialize_map_non_map() { + // Try to decode an integer as a map (via a struct) + let v = Value::Integer(42); + let bytes = cbor::encode(&v).unwrap(); + let result = cbor::decode::>(&bytes); + assert!(result.is_err()); +} + +// =================================================================== +// 🟠 Serde infra: Tagged decode errors +// =================================================================== + +#[test] +fn tagged_wrong_tag_number() { + // Encode with tag 999, try to decode as Tagged with expected tag + let v = Value::Tag(999, Box::new(Value::Text("hello".into()))); + let bytes = cbor::encode(&v).unwrap(); + let decoded: corim::cbor::value::Tagged = cbor::decode(&bytes).unwrap(); + // Tagged doesn't enforce the tag number itself — it just captures it + assert_eq!(decoded.tag, 999); +} + +#[test] +fn tagged_non_tag_value() { + // Try to decode a bare integer as Tagged + let v = Value::Integer(42); + let bytes = cbor::encode(&v).unwrap(); + let result = cbor::decode::>(&bytes); + assert!(result.is_err()); +} + +// =================================================================== +// 🟠 i128 boundary values through Value +// =================================================================== + +#[test] +fn value_i128_large_positive() { + // Values above u64::MAX cannot be represented in CBOR — should error, not panic + let v = Value::Integer((u64::MAX as i128) + 1); + let result = cbor::encode(&v); + assert!(result.is_err(), "encoding i128 > u64::MAX should fail"); +} + +#[test] +fn value_i128_large_negative() { + // Values below -(2^64) cannot be represented in CBOR — should error, not panic + let v = Value::Integer(-(u64::MAX as i128) - 2); + let result = cbor::encode(&v); + assert!(result.is_err(), "encoding i128 < -(2^64) should fail"); +} + +#[test] +fn value_i128_min() { + let v = Value::Integer(i64::MIN as i128); + let bytes = cbor::encode(&v).unwrap(); + let decoded: Value = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, Value::Integer(i64::MIN as i128)); +} + +// =================================================================== +// 🟡 digest_from_value_array error paths (common.rs helper) +// =================================================================== + +#[test] +fn digest_wrong_array_length() { + // Encode a CryptoKey::KeyThumbprint with a 3-element array inside tag 557 + let v = Value::Tag( + TAG_KEY_THUMBPRINT, + Box::new(Value::Array(vec![ + Value::Integer(7), + Value::Bytes(vec![0xAA; 32]), + Value::Integer(0), // extra element + ])), + ); + let err = decode_err::(&v); + assert!( + err.contains("digest") || err.contains("[alg, val]"), + "got: {err}" + ); +} + +#[test] +fn digest_non_int_alg() { + let v = Value::Tag( + TAG_KEY_THUMBPRINT, + Box::new(Value::Array(vec![ + Value::Text("not-int".into()), + Value::Bytes(vec![0]), + ])), + ); + let err = decode_err::(&v); + assert!(err.contains("alg"), "got: {err}"); +} + +#[test] +fn digest_non_bytes_val() { + let v = Value::Tag( + TAG_KEY_THUMBPRINT, + Box::new(Value::Array(vec![ + Value::Integer(7), + Value::Text("not-bytes".into()), + ])), + ); + let err = decode_err::(&v); + assert!(err.contains("val"), "got: {err}"); +} + +// =================================================================== +// CorimLocatorThumbprint edge cases +// =================================================================== + +#[test] +fn locator_thumbprint_empty_array() { + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://x.com".into())), + (Value::Integer(1), Value::Array(vec![])), + ]); + let err = decode_err::(&v); + assert!( + err.contains("array") || err.contains("thumbprint"), + "got: {err}" + ); +} + +// =================================================================== +// CborTime from/into i64 +// =================================================================== + +#[test] +fn cbor_time_conversions() { + let t: CborTime = 12345i64.into(); + let val: i64 = t.into(); + assert_eq!(val, 12345); +} diff --git a/corim/tests/error_path_tests.rs b/corim/tests/error_path_tests.rs new file mode 100644 index 0000000..2856cbc --- /dev/null +++ b/corim/tests/error_path_tests.rs @@ -0,0 +1,372 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Negative / error-path tests: malformed CBOR, wrong tags, size limits, +//! empty-list validation, and other failure modes. + +use corim::cbor; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::triples::*; + +fn make_env() -> EnvironmentMap { + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("V".into()), + model: Some("M".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + } +} + +fn make_meas() -> MeasurementMap { + MeasurementMap { + mkey: Some(MeasuredElement::Text("fw".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + } +} + +fn make_valid_corim_bytes() -> Vec { + use corim::builder::{ComidBuilder, CorimBuilder}; + let comid = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new(make_env(), vec![make_meas()])) + .build() + .unwrap(); + CorimBuilder::new(CorimId::Text("c".into())) + .set_validity(None, i64::MAX) + .unwrap() + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap() +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Malformed CBOR input +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn decode_empty_bytes() { + let result = corim::validate::decode_and_validate(&[]); + assert!(result.is_err()); +} + +#[test] +fn decode_truncated_cbor() { + let valid = make_valid_corim_bytes(); + // Truncate to half + let truncated = &valid[..valid.len() / 2]; + let result = corim::validate::decode_and_validate(truncated); + assert!(result.is_err()); +} + +#[test] +fn decode_random_garbage() { + let garbage = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA]; + let result = corim::validate::decode_and_validate(&garbage); + assert!(result.is_err()); +} + +#[test] +fn decode_valid_cbor_but_not_tagged() { + // Encode a plain map (not tag-501 wrapped) + let map = corim::cbor::value::Value::Map(vec![( + corim::cbor::value::Value::Integer(0), + corim::cbor::value::Value::Text("id".into()), + )]); + let bytes = cbor::encode(&map).unwrap(); + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); +} + +#[test] +fn decode_wrong_outer_tag() { + // Wrap in tag 500 instead of 501 + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new(make_env(), vec![make_meas()])) + .build() + .unwrap(); + let corim_map = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .add_comid_tag(comid) + .unwrap() + .build() + .unwrap(); + let tagged = corim::cbor::value::Tagged::new(500, corim_map); // wrong tag! + let bytes = cbor::encode(&tagged).unwrap(); + + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!( + err.contains("501") || err.contains("500"), + "error should mention tags: {}", + err + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Payload size limit +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn decode_payload_too_large() { + // Create a buffer slightly over MAX_PAYLOAD_SIZE + let huge = vec![0u8; corim::validate::MAX_PAYLOAD_SIZE + 1]; + let result = corim::validate::decode_and_validate(&huge); + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!(err.contains("too large"), "error: {}", err); +} + +#[test] +fn decode_payload_at_limit_is_accepted() { + // Payload exactly at the limit should not be rejected for size + // (it may fail for other reasons like invalid CBOR, but not size) + let bytes = vec![0u8; corim::validate::MAX_PAYLOAD_SIZE]; + let result = corim::validate::decode_and_validate(&bytes); + // Should fail with a CBOR decode error, NOT PayloadTooLarge + assert!(result.is_err()); + let err = format!("{}", result.unwrap_err()); + assert!( + !err.contains("too large"), + "should not be a size error: {}", + err + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Expiration / validity +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn decode_expired_corim() { + use corim::builder::{ComidBuilder, CorimBuilder}; + let comid = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new(make_env(), vec![make_meas()])) + .build() + .unwrap(); + let bytes = CorimBuilder::new(CorimId::Text("expired".into())) + .set_validity(None, 0) + .unwrap() // epoch 0 = always expired + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("expired")); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Builder: empty list validation ([+ T]) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn builder_reference_triple_empty_measurements() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(ReferenceTriple::new(make_env(), vec![])) // empty! + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("ref-claims")); +} + +#[test] +fn builder_endorsed_triple_empty_endorsements() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_endorsed_triple(EndorsedTriple::new(make_env(), vec![])) // empty! + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("endorsement")); +} + +#[test] +fn builder_identity_triple_empty_keys() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_identity_triple(IdentityTriple::new(make_env(), vec![], None)) // empty! + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("key-list")); +} + +#[test] +fn builder_attest_key_triple_empty_keys() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_attest_key_triple(AttestKeyTriple::new(make_env(), vec![], None)) + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("key-list")); +} + +#[test] +fn builder_dependency_triple_empty_trustees() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_dependency_triple(DomainDependencyTriple::new(make_env(), vec![])) + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("trustees")); +} + +#[test] +fn builder_membership_triple_empty_members() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_membership_triple(DomainMembershipTriple::new(make_env(), vec![])) + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("members")); +} + +#[test] +fn builder_coswid_triple_empty_tag_ids() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_coswid_triple(CoswidTriple::new(make_env(), vec![])) + .build(); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("tag-ids")); +} + +#[test] +fn builder_corim_no_tags() { + use corim::builder::CorimBuilder; + let result = CorimBuilder::new(CorimId::Text("empty".into())).build(); + assert!(result.is_err()); +} + +#[test] +fn builder_comid_no_triples() { + use corim::builder::ComidBuilder; + let result = ComidBuilder::new(TagIdChoice::Text("empty".into())).build(); + assert!(result.is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Type-choice deserialization with wrong CBOR types +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn class_id_wrong_tag_number() { + // Encode tag 999 wrapping bytes — should fail to decode as ClassIdChoice + let val = corim::cbor::value::Value::Tag( + 999, + Box::new(corim::cbor::value::Value::Bytes(vec![1, 2, 3])), + ); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn instance_id_wrong_tag_number() { + let val = corim::cbor::value::Value::Tag( + 999, + Box::new(corim::cbor::value::Value::Bytes(vec![1, 2, 3])), + ); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn tag_id_not_text_or_tagged() { + // Encode an integer — should fail to decode as TagIdChoice + let bytes = cbor::encode(&42i64).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn crypto_key_wrong_tag_number() { + let val = corim::cbor::value::Value::Tag( + 123, + Box::new(corim::cbor::value::Value::Text("data".into())), + ); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn uuid_wrong_size_in_tag_id() { + // Tag 37 wrapping 15 bytes (should be 16) + let val = corim::cbor::value::Value::Tag( + 37, + Box::new(corim::cbor::value::Value::Bytes(vec![0u8; 15])), + ); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn uuid_wrong_size_in_class_id() { + // Tag 37 wrapping 17 bytes + let val = corim::cbor::value::Value::Tag( + 37, + Box::new(corim::cbor::value::Value::Bytes(vec![0u8; 17])), + ); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Unknown map keys are skipped (forward-compat) +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn unknown_map_keys_skipped() { + // Encode a ClassMap with an extra key 99 + let val = corim::cbor::value::Value::Map(vec![ + ( + corim::cbor::value::Value::Integer(1), + corim::cbor::value::Value::Text("Vendor".into()), + ), + ( + corim::cbor::value::Value::Integer(99), + corim::cbor::value::Value::Text("unknown-extension".into()), + ), + ]); + let bytes = cbor::encode(&val).unwrap(); + let decoded: ClassMap = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.vendor.as_deref(), Some("Vendor")); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Display impls smoke test +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +fn display_impls_dont_panic() { + assert!(!format!("{}", TagIdChoice::Text("t".into())).is_empty()); + assert!(!format!("{}", TagIdChoice::Uuid([0xAA; 16])).is_empty()); + assert!(!format!("{}", CorimId::Text("c".into())).is_empty()); + assert!(!format!("{}", CorimId::Uuid([0xBB; 16])).is_empty()); + assert!(!format!("{}", ClassIdChoice::Oid(vec![0x2B])).is_empty()); + assert!(!format!("{}", ClassIdChoice::Uuid([0xCC; 16])).is_empty()); + assert!(!format!("{}", ClassIdChoice::Bytes(vec![1, 2, 3])).is_empty()); + assert!(!format!("{}", InstanceIdChoice::Ueid(vec![1; 17])).is_empty()); + assert!(!format!("{}", GroupIdChoice::Uuid([0xDD; 16])).is_empty()); + assert!(!format!("{}", MeasuredElement::Text("m".into())).is_empty()); + assert!(!format!("{}", MeasuredElement::Uint(42)).is_empty()); + assert!(!format!("{}", CryptoKey::PkixBase64Key("k".into())).is_empty()); + assert!(!format!("{}", CryptoKey::Bytes(vec![1])).is_empty()); + assert!(!format!("{}", ProfileChoice::Uri("u".into())).is_empty()); + assert!(!format!("{}", ProfileChoice::Oid(vec![0x2B])).is_empty()); +} diff --git a/corim/tests/integration_tests.rs b/corim/tests/integration_tests.rs new file mode 100644 index 0000000..a0b2725 --- /dev/null +++ b/corim/tests/integration_tests.rs @@ -0,0 +1,856 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the corim crate. +//! +//! Covers derive macro round-trips, builder API, CBOR encoding, +//! and validation. + +#[cfg(test)] +mod derive_tests { + use corim::cbor; + use corim::types::environment::{ClassMap, EnvironmentMap}; + + #[test] + fn class_map_round_trip() { + let class = ClassMap { + class_id: None, + vendor: Some("AMD".into()), + model: Some("SEV-SNP".into()), + layer: None, + index: None, + }; + + let bytes = cbor::encode(&class).unwrap(); + let decoded: ClassMap = cbor::decode(&bytes).unwrap(); + assert_eq!(class, decoded); + } + + #[test] + fn class_map_integer_keys() { + let class = ClassMap { + class_id: None, + vendor: Some("AMD".into()), + model: Some("SEV-SNP".into()), + layer: None, + index: None, + }; + + let bytes = cbor::encode(&class).unwrap(); + let val: corim::cbor::value::Value = corim::cbor::decode(&bytes).unwrap(); + if let corim::cbor::value::Value::Map(entries) = val { + for (key, _) in &entries { + match key { + corim::cbor::value::Value::Integer(_) => {} + other => panic!("Expected integer key, got {:?}", other), + } + } + assert_eq!(entries.len(), 2); + } else { + panic!("Expected CBOR map, got {:?}", val); + } + } + + #[test] + fn environment_map_non_empty_enforced() { + let env = EnvironmentMap { + class: None, + instance: None, + group: None, + }; + + let result = cbor::encode(&env); + assert!(result.is_err(), "should fail non-empty check"); + } + + #[test] + fn class_map_non_empty_enforced() { + let class = ClassMap { + class_id: None, + vendor: None, + model: None, + layer: None, + index: None, + }; + + let result = cbor::encode(&class); + assert!(result.is_err(), "should fail non-empty check"); + } + + #[test] + fn environment_map_round_trip() { + let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("Intel".into()), + model: Some("TDX".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + }; + + let bytes = cbor::encode(&env).unwrap(); + let decoded: EnvironmentMap = cbor::decode(&bytes).unwrap(); + assert_eq!(env, decoded); + } +} + +#[cfg(test)] +mod measurement_tests { + use corim::cbor; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap, SvnChoice}; + + #[test] + fn digest_round_trip() { + let digest = Digest::new(7, vec![0xAA; 48]); + + let bytes = cbor::encode(&digest).unwrap(); + let decoded: Digest = cbor::decode(&bytes).unwrap(); + assert_eq!(digest, decoded); + } + + #[test] + fn svn_exact_round_trip() { + let svn = SvnChoice::ExactValue(42); + + let bytes = cbor::encode(&svn).unwrap(); + let decoded: SvnChoice = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, SvnChoice::ExactValue(42)); + } + + #[test] + fn svn_min_round_trip() { + let svn = SvnChoice::MinValue(10); + + let bytes = cbor::encode(&svn).unwrap(); + let decoded: SvnChoice = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded, SvnChoice::MinValue(10)); + } + + #[test] + fn measurement_values_map_with_digests() { + let mval = MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + digests: Some(vec![Digest::new(7, vec![0xBB; 48])]), + ..MeasurementValuesMap::default() + }; + + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert_eq!(mval, decoded); + } + + #[test] + fn measurement_map_round_trip() { + let meas = MeasurementMap { + mkey: Some(corim::types::common::MeasuredElement::Text( + "MEASUREMENT".into(), + )), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xCC; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + let bytes = cbor::encode(&meas).unwrap(); + let decoded: MeasurementMap = cbor::decode(&bytes).unwrap(); + assert_eq!(meas, decoded); + } +} + +#[cfg(test)] +mod builder_tests { + use corim::builder::{ComidBuilder, CorimBuilder}; + use corim::cbor; + use corim::types::common::{MeasuredElement, TagIdChoice}; + use corim::types::corim::CorimId; + use corim::types::environment::{ClassMap, EnvironmentMap}; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap, SvnChoice}; + use corim::types::triples::{ + CesCondition, ConditionalEndorsementSeriesTriple, ConditionalSeriesRecord, ReferenceTriple, + }; + + fn make_env() -> EnvironmentMap { + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("ACME".into()), + model: Some("Widget".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + } + } + + fn make_ref_measurement(mkey: &str, digest: Vec) -> MeasurementMap { + MeasurementMap { + mkey: Some(MeasuredElement::Text(mkey.into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, digest)]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + } + } + + #[test] + fn comid_builder_with_reference_triple() { + let env = make_env(); + let meas = make_ref_measurement("firmware", vec![0xAA; 48]); + + let comid = ComidBuilder::new(TagIdChoice::Text("test-tag-001".into())) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .build() + .unwrap(); + + assert_eq!( + comid.tag_identity.tag_id, + TagIdChoice::Text("test-tag-001".into()) + ); + assert!(comid.triples.reference_triples.is_some()); + } + + #[test] + fn comid_builder_with_uuid_tag_id() { + let env = make_env(); + let meas = make_ref_measurement("firmware", vec![0xAA; 48]); + let uuid_bytes = [0x01u8; 16]; + + let comid = ComidBuilder::new(TagIdChoice::Uuid(uuid_bytes)) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .build() + .unwrap(); + + assert_eq!(comid.tag_identity.tag_id, TagIdChoice::Uuid(uuid_bytes)); + } + + #[test] + fn comid_builder_with_ces() { + let env = make_env(); + let meas = make_ref_measurement("firmware", vec![0xAA; 48]); + + let ces_triple = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![make_ref_measurement("firmware", vec![0xAA; 48])], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )], + ); + + let comid = ComidBuilder::new(TagIdChoice::Text("test-tag-002".into())) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .add_conditional_endorsement_series(ces_triple) + .build() + .unwrap(); + + assert!(comid.triples.reference_triples.is_some()); + assert!(comid.triples.conditional_endorsement_series.is_some()); + } + + #[test] + fn comid_builder_empty_triples_fails() { + let result = ComidBuilder::new(TagIdChoice::Text("empty".into())).build(); + assert!(result.is_err()); + } + + #[test] + fn corim_builder_round_trip() { + let env = make_env(); + let meas = make_ref_measurement("firmware", vec![0xAA; 48]); + + let comid = ComidBuilder::new(TagIdChoice::Text("test-tag-003".into())) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .build() + .unwrap(); + + let bytes = CorimBuilder::new(CorimId::Text("test-corim-001".into())) + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + + let tagged: corim::cbor::value::Tagged = + cbor::decode(&bytes).unwrap(); + let corim = tagged.value; + + assert_eq!(corim.id, CorimId::Text("test-corim-001".into())); + assert_eq!(corim.tags.len(), 1); + } + + #[test] + fn corim_builder_no_tags_fails() { + let result = CorimBuilder::new(CorimId::Text("empty".into())).build(); + assert!(result.is_err()); + } + + #[test] + fn corim_builder_with_coswid_tag() { + let coswid_bytes = vec![0xA0]; // minimal empty CBOR map for testing + let corim = CorimBuilder::new(CorimId::Text("coswid-test".into())) + .add_coswid_tag(coswid_bytes) + .build() + .unwrap(); + + assert_eq!(corim.tags.len(), 1); + match &corim.tags[0] { + corim::types::corim::ConciseTagChoice::Coswid(_) => {} + other => panic!("Expected Coswid tag, got {:?}", other), + } + } +} + +#[cfg(test)] +mod validation_tests { + use corim::builder::{ComidBuilder, CorimBuilder}; + use corim::types::common::{MeasuredElement, TagIdChoice}; + use corim::types::corim::CorimId; + use corim::types::environment::{ClassMap, EnvironmentMap}; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap, SvnChoice}; + use corim::types::triples::{ + CesCondition, ConditionalEndorsementSeriesTriple, ConditionalSeriesRecord, ReferenceTriple, + }; + use corim::validate::{ + apply_endorsement_series, match_reference_values, svn_matches, AppraisalContext, + EvidenceClaim, + }; + + fn make_env() -> EnvironmentMap { + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("ACME".into()), + model: Some("Widget".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + } + } + + fn make_measurement(mkey: &str, digest: Vec) -> MeasurementMap { + MeasurementMap { + mkey: Some(MeasuredElement::Text(mkey.into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, digest)]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + } + } + + fn make_test_comid() -> corim::types::comid::ComidTag { + let env = make_env(); + let ces_triple = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![make_measurement("firmware", vec![0xAA; 48])], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )], + ); + + ComidBuilder::new(TagIdChoice::Text("validation-comid".into())) + .add_reference_triple(ReferenceTriple::new( + env, + vec![make_measurement("firmware", vec![0xAA; 48])], + )) + .add_conditional_endorsement_series(ces_triple) + .build() + .unwrap() + } + + fn make_test_corim_bytes() -> Vec { + CorimBuilder::new(CorimId::Text("test-validation".into())) + .set_validity(None, i64::MAX) + .unwrap() + .add_comid_tag(make_test_comid()) + .unwrap() + .build_bytes() + .unwrap() + } + + #[test] + fn decode_and_validate_happy_path() { + let bytes = make_test_corim_bytes(); + let (corim, comids) = corim::validate::decode_and_validate(&bytes).unwrap(); + assert_eq!(corim.tags.len(), 1); + assert_eq!(comids.len(), 1); + } + + #[test] + fn decode_and_validate_expired() { + let comid = make_test_comid(); + + let bytes = CorimBuilder::new(CorimId::Text("expired".into())) + .set_validity(None, 0) + .unwrap() // epoch = expired + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("expired"), "error: {}", err_msg); + } + + #[test] + fn reference_value_matching() { + let env = make_env(); + + let ref_triples = vec![ReferenceTriple::new( + env.clone(), + vec![make_measurement("firmware", vec![0xAA; 48])], + )]; + + let evidence = vec![EvidenceClaim { + environment: env.clone(), + measurements: vec![make_measurement("firmware", vec![0xAA; 48])], + }]; + + let result = match_reference_values(&ref_triples, &evidence); + assert_eq!(result.len(), 1); + } + + #[test] + fn reference_value_digest_mismatch() { + let env = make_env(); + + let ref_triples = vec![ReferenceTriple::new( + env.clone(), + vec![make_measurement("firmware", vec![0xAA; 48])], + )]; + + let evidence = vec![EvidenceClaim { + environment: env.clone(), + measurements: vec![make_measurement("firmware", vec![0xBB; 48])], + }]; + + let result = match_reference_values(&ref_triples, &evidence); + assert!(result.is_empty()); + } + + #[test] + fn reference_value_no_common_algorithms() { + let env = make_env(); + + let ref_triples = vec![ReferenceTriple::new( + env.clone(), + vec![make_measurement("firmware", vec![0xAA; 48])], + )]; + + let evidence = vec![EvidenceClaim { + environment: env.clone(), + measurements: vec![MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(1, vec![0xAA; 32])]), // SHA-256 (different alg) + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + }]; + + let result = match_reference_values(&ref_triples, &evidence); + assert!(result.is_empty(), "no common alg should mean no match"); + } + + #[test] + fn svn_exact_match() { + assert!(svn_matches(&SvnChoice::ExactValue(5), 5)); + assert!(!svn_matches(&SvnChoice::ExactValue(5), 6)); + assert!(!svn_matches(&SvnChoice::ExactValue(5), 4)); + } + + #[test] + fn svn_min_match() { + assert!(svn_matches(&SvnChoice::MinValue(5), 5)); + assert!(svn_matches(&SvnChoice::MinValue(5), 10)); + assert!(!svn_matches(&SvnChoice::MinValue(5), 4)); + } + + #[test] + fn conditional_endorsement_series_application() { + let env = make_env(); + + let ces = vec![ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ + ConditionalSeriesRecord::new( + vec![make_measurement("firmware", vec![0xAA; 48])], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ), + ConditionalSeriesRecord::new( + vec![make_measurement("firmware", vec![0xBB; 48])], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(2)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ), + ], + )]; + + let evidence = vec![EvidenceClaim { + environment: env.clone(), + measurements: vec![make_measurement("firmware", vec![0xAA; 48])], + }]; + + let endorsed = apply_endorsement_series(&ces, &evidence).unwrap(); + assert_eq!(endorsed.len(), 1); + let svn = &endorsed[0].endorsements[0].mval.svn; + assert_eq!(*svn, Some(SvnChoice::ExactValue(1))); + } + + #[test] + fn appraisal_context_full_flow() { + let env = make_env(); + + let ref_triples = vec![ReferenceTriple::new( + env.clone(), + vec![make_measurement("firmware", vec![0xAA; 48])], + )]; + + let ces = vec![ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: Vec::new(), + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![make_measurement("firmware", vec![0xAA; 48])], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )], + )]; + + let mut acs = AppraisalContext::new(); + + // Phase 2: Add evidence + acs.add_evidence(vec![EvidenceClaim { + environment: env.clone(), + measurements: vec![make_measurement("firmware", vec![0xAA; 48])], + }]); + + // Phase 3: Reference values + let corroborated = acs.apply_reference_values(&ref_triples); + assert_eq!(corroborated.len(), 1); + + // Phase 4: Conditional endorsements + let endorsed = acs.apply_conditional_endorsements(&ces).unwrap(); + assert_eq!(endorsed.len(), 2); + + // ACS should have entries from all phases + assert!(acs.entries.len() >= 4); + } +} + +#[cfg(test)] +mod typed_triple_tests { + use corim::cbor; + use corim::types::common::{CryptoKey, TagIdChoice}; + use corim::types::environment::{ClassMap, EnvironmentMap}; + use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; + use corim::types::triples::*; + + fn make_env() -> EnvironmentMap { + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("TestVendor".into()), + model: Some("TestModel".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + } + } + + #[test] + fn identity_triple_round_trip() { + let triple = IdentityTriple::new( + make_env(), + vec![CryptoKey::PkixBase64Key("test-key".into())], + None, + ); + + let bytes = cbor::encode(&triple).unwrap(); + let decoded: IdentityTriple = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.0, triple.0); + assert_eq!(decoded.1.len(), 1); + } + + #[test] + fn attest_key_triple_round_trip() { + let triple = AttestKeyTriple::new( + make_env(), + vec![CryptoKey::Bytes(vec![0x01, 0x02, 0x03])], + None, + ); + + let bytes = cbor::encode(&triple).unwrap(); + let decoded: AttestKeyTriple = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.0, triple.0); + } + + #[test] + fn domain_dependency_triple_round_trip() { + let triple = DomainDependencyTriple::new(make_env(), vec![make_env()]); + + let bytes = cbor::encode(&triple).unwrap(); + let decoded: DomainDependencyTriple = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.0, triple.0); + assert_eq!(decoded.1.len(), 1); + } + + #[test] + fn domain_membership_triple_round_trip() { + let triple = DomainMembershipTriple::new(make_env(), vec![make_env()]); + + let bytes = cbor::encode(&triple).unwrap(); + let decoded: DomainMembershipTriple = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.0, triple.0); + } + + #[test] + fn coswid_triple_round_trip() { + let triple = CoswidTriple::new(make_env(), vec![TagIdChoice::Text("test-tag-id".into())]); + + let bytes = cbor::encode(&triple).unwrap(); + let decoded: CoswidTriple = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.0, triple.0); + assert_eq!(decoded.1.len(), 1); + } + + #[test] + fn triples_map_with_all_triple_types() { + let env = make_env(); + let meas = MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + + let triples = TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new(env.clone(), vec![meas.clone()])]), + endorsed_triples: Some(vec![EndorsedTriple::new(env.clone(), vec![meas.clone()])]), + identity_triples: Some(vec![IdentityTriple::new( + env.clone(), + vec![CryptoKey::PkixBase64Key("key".into())], + None, + )]), + attest_key_triples: Some(vec![AttestKeyTriple::new( + env.clone(), + vec![CryptoKey::Bytes(vec![1, 2, 3])], + None, + )]), + dependency_triples: Some(vec![DomainDependencyTriple::new( + env.clone(), + vec![env.clone()], + )]), + membership_triples: Some(vec![DomainMembershipTriple::new( + env.clone(), + vec![env.clone()], + )]), + coswid_triples: Some(vec![CoswidTriple::new( + env.clone(), + vec![TagIdChoice::Text("tag-1".into())], + )]), + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + + let bytes = cbor::encode(&triples).unwrap(); + let decoded: TriplesMap = cbor::decode(&bytes).unwrap(); + assert!(decoded.reference_triples.is_some()); + assert!(decoded.endorsed_triples.is_some()); + assert!(decoded.identity_triples.is_some()); + assert!(decoded.attest_key_triples.is_some()); + assert!(decoded.dependency_triples.is_some()); + assert!(decoded.membership_triples.is_some()); + assert!(decoded.coswid_triples.is_some()); + } +} + +#[cfg(test)] +mod corim_type_tests { + use corim::cbor; + use corim::types::common::{CborTime, TagIdChoice, TagIdentity, ValidityMap}; + use corim::types::corim::*; + + #[test] + fn corim_signer_map_round_trip() { + let signer = CorimSignerMap { + signer_name: "ACME Corp".into(), + signer_uri: Some("https://acme.example.com".into()), + }; + + let bytes = cbor::encode(&signer).unwrap(); + let decoded: CorimSignerMap = cbor::decode(&bytes).unwrap(); + assert_eq!(signer, decoded); + } + + #[test] + fn corim_meta_map_round_trip() { + let meta = CorimMetaMap { + signer: CorimSignerMap { + signer_name: "ACME Corp".into(), + signer_uri: None, + }, + signature_validity: Some(ValidityMap { + not_before: Some(CborTime(1000)), + not_after: CborTime(2000), + }), + }; + + let bytes = cbor::encode(&meta).unwrap(); + let decoded: CorimMetaMap = cbor::decode(&bytes).unwrap(); + assert_eq!(meta, decoded); + } + + #[test] + fn concise_tl_tag_round_trip() { + let cotl = ConciseTlTag { + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("cotl-1".into()), + tag_version: Some(0), + }, + tags_list: vec![TagIdentity { + tag_id: TagIdChoice::Text("comid-1".into()), + tag_version: None, + }], + tl_validity: ValidityMap { + not_before: None, + not_after: CborTime(9999999999), + }, + }; + + let bytes = cbor::encode(&cotl).unwrap(); + let decoded: ConciseTlTag = cbor::decode(&bytes).unwrap(); + assert_eq!(cotl, decoded); + } + + #[test] + fn corim_locator_round_trip() { + let locator = CorimLocator { + href: CorimLocatorHref::Single("https://example.com/rim.corim".into()), + thumbprint: None, + }; + + let bytes = cbor::encode(&locator).unwrap(); + let decoded: CorimLocator = cbor::decode(&bytes).unwrap(); + assert_eq!(locator, decoded); + } +} + +#[cfg(test)] +mod measurement_extension_tests { + use corim::cbor; + use corim::types::measurement::*; + + #[test] + fn mac_addr_eui48_round_trip() { + let mval = MeasurementValuesMap { + mac_addr: Some(MacAddr::Eui48([0x00, 0x11, 0x22, 0x33, 0x44, 0x55])), + ..MeasurementValuesMap::default() + }; + + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert_eq!(mval.mac_addr, decoded.mac_addr); + } + + #[test] + fn ip_addr_v4_round_trip() { + let mval = MeasurementValuesMap { + ip_addr: Some(IpAddr::V4([192, 168, 1, 1])), + ..MeasurementValuesMap::default() + }; + + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert_eq!(mval.ip_addr, decoded.ip_addr); + } + + #[test] + fn int_range_round_trip() { + let mval = MeasurementValuesMap { + int_range: Some(IntRangeChoice::Range { + min: Some(0), + max: Some(100), + }), + ..MeasurementValuesMap::default() + }; + + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert_eq!(mval.int_range, decoded.int_range); + } + + #[test] + fn int_range_unbounded_round_trip() { + let mval = MeasurementValuesMap { + int_range: Some(IntRangeChoice::Range { + min: None, + max: Some(50), + }), + ..MeasurementValuesMap::default() + }; + + let bytes = cbor::encode(&mval).unwrap(); + let decoded: MeasurementValuesMap = cbor::decode(&bytes).unwrap(); + assert_eq!(mval.int_range, decoded.int_range); + } +} diff --git a/corim/tests/negative_error_path_tests.rs b/corim/tests/negative_error_path_tests.rs new file mode 100644 index 0000000..da585bf --- /dev/null +++ b/corim/tests/negative_error_path_tests.rs @@ -0,0 +1,857 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Additional negative tests targeting the remaining ~13% reachable missed error paths. + +use corim::cbor; +use corim::cbor::value::Value; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::tags::*; +use corim::types::triples::*; +use corim::Validate; + +/// Encode a Value to CBOR, then try to decode as T. Returns the error string. +fn decode_err(val: &Value) -> String { + let bytes = cbor::encode(val).unwrap(); + cbor::decode::(&bytes).unwrap_err().to_string() +} + +// =================================================================== +// measurement.rs — SVN inner-type errors +// =================================================================== + +#[test] +fn svn_tag552_wrapping_non_int() { + let v = Value::Tag(TAG_SVN, Box::new(Value::Text("not-int".into()))); + let err = decode_err::(&v); + assert!(err.contains("uint") || err.contains("wrap"), "got: {err}"); +} + +#[test] +fn svn_tag553_wrapping_non_int() { + let v = Value::Tag(TAG_MIN_SVN, Box::new(Value::Text("not-int".into()))); + let err = decode_err::(&v); + assert!(err.contains("uint") || err.contains("wrap"), "got: {err}"); +} + +#[test] +fn svn_tag552_negative_value() { + let v = Value::Tag(TAG_SVN, Box::new(Value::Integer(-5))); + let err = decode_err::(&v); + assert!(err.contains("unsigned"), "got: {err}"); +} + +#[test] +fn svn_tag553_negative_value() { + let v = Value::Tag(TAG_MIN_SVN, Box::new(Value::Integer(-5))); + let err = decode_err::(&v); + assert!(err.contains("unsigned"), "got: {err}"); +} + +#[test] +fn svn_untagged_negative() { + let v = Value::Integer(-10); + let err = decode_err::(&v); + assert!(err.contains("unsigned"), "got: {err}"); +} + +// =================================================================== +// measurement.rs — IntRange inner-type errors +// =================================================================== + +#[test] +fn int_range_min_non_int_non_null() { + let v = Value::Tag( + TAG_INT_RANGE, + Box::new(Value::Array(vec![ + Value::Text("bad".into()), + Value::Integer(100), + ])), + ); + let err = decode_err::(&v); + assert!(err.contains("int or null"), "got: {err}"); +} + +#[test] +fn int_range_max_non_int_non_null() { + let v = Value::Tag( + TAG_INT_RANGE, + Box::new(Value::Array(vec![ + Value::Integer(0), + Value::Text("bad".into()), + ])), + ); + let err = decode_err::(&v); + assert!(err.contains("int or null"), "got: {err}"); +} + +// =================================================================== +// measurement.rs — masked raw-value inner errors +// =================================================================== + +#[test] +fn masked_raw_value_mask_non_bytes() { + let v = Value::Tag( + TAG_MASKED_RAW_VALUE, + Box::new(Value::Array(vec![ + Value::Bytes(vec![1, 2, 3]), + Value::Text("not-bytes".into()), // mask must be bytes + ])), + ); + let err = decode_err::(&v); + assert!(err.contains("mask") || err.contains("bytes"), "got: {err}"); +} + +#[test] +fn masked_raw_value_value_non_bytes() { + let v = Value::Tag( + TAG_MASKED_RAW_VALUE, + Box::new(Value::Array(vec![ + Value::Text("not-bytes".into()), // value must be bytes + Value::Bytes(vec![0xFF; 4]), + ])), + ); + let err = decode_err::(&v); + assert!(err.contains("value") || err.contains("bytes"), "got: {err}"); +} + +// =================================================================== +// measurement.rs — IntegrityRegisterId negative int +// =================================================================== + +#[test] +fn integrity_register_id_negative() { + let v = Value::Map(vec![( + Value::Integer(-1), + Value::Array(vec![Value::Array(vec![ + Value::Integer(7), + Value::Bytes(vec![0; 32]), + ])]), + )]); + let err = decode_err::(&v); + assert!(err.contains("unsigned"), "got: {err}"); +} + +// =================================================================== +// corim.rs — CorimLocatorThumbprint multi-digest error paths +// =================================================================== + +#[test] +fn locator_thumbprint_multi_digest_bad_item() { + // Array of digests where one item is not a pair + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + ( + Value::Integer(1), + Value::Array(vec![ + Value::Array(vec![Value::Integer(7), Value::Bytes(vec![0; 32])]), + Value::Text("not-a-pair".into()), // bad digest item + ]), + ), + ]); + let err = decode_err::(&v); + assert!( + err.contains("digest") || err.contains("[alg, val]"), + "got: {err}" + ); +} + +#[test] +fn locator_thumbprint_multi_digest_alg_non_int() { + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + ( + Value::Integer(1), + Value::Array(vec![Value::Array(vec![ + Value::Text("not-int".into()), + Value::Bytes(vec![0; 32]), + ])]), + ), + ]); + let err = decode_err::(&v); + assert!(err.contains("alg") || err.contains("int"), "got: {err}"); +} + +#[test] +fn locator_thumbprint_multi_digest_val_non_bytes() { + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + ( + Value::Integer(1), + Value::Array(vec![Value::Array(vec![ + Value::Integer(7), + Value::Text("not-bytes".into()), + ])]), + ), + ]); + let err = decode_err::(&v); + assert!(err.contains("val") || err.contains("bytes"), "got: {err}"); +} + +#[test] +fn locator_thumbprint_single_digest_wrong_length() { + // Single digest [alg, val, extra] — 3 elements + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + ( + Value::Integer(1), + Value::Array(vec![ + Value::Integer(7), + Value::Bytes(vec![0; 32]), + Value::Integer(0), // extra element + ]), + ), + ]); + let err = decode_err::(&v); + assert!( + err.contains("[alg, val]") || err.contains("digest"), + "got: {err}" + ); +} + +#[test] +fn locator_thumbprint_single_digest_alg_non_int() { + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + ( + Value::Integer(1), + Value::Array(vec![ + Value::Text("not-int".into()), + Value::Bytes(vec![0; 32]), + ]), + ), + ]); + let err = decode_err::(&v); + assert!(err.contains("alg") || err.contains("int"), "got: {err}"); +} + +#[test] +fn locator_thumbprint_single_digest_val_non_bytes() { + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + ( + Value::Integer(1), + Value::Array(vec![Value::Integer(7), Value::Text("not-bytes".into())]), + ), + ]); + let err = decode_err::(&v); + assert!(err.contains("val") || err.contains("bytes"), "got: {err}"); +} + +#[test] +fn locator_thumbprint_non_array() { + let v = Value::Map(vec![ + (Value::Integer(0), Value::Text("https://example.com".into())), + (Value::Integer(1), Value::Text("not-array".into())), + ]); + let err = decode_err::(&v); + assert!( + err.contains("array") || err.contains("thumbprint"), + "got: {err}" + ); +} + +// =================================================================== +// validate.rs — class_matches individual field mismatches +// =================================================================== + +#[test] +fn env_match_instance_mismatch() { + let ref_triple = corim::types::triples::ReferenceTriple::new( + EnvironmentMap { + class: None, + instance: Some(InstanceIdChoice::Uuid([0xAA; 16])), + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap { + class: None, + instance: Some(InstanceIdChoice::Uuid([0xBB; 16])), // different + group: None, + }, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn env_match_group_mismatch() { + let ref_triple = corim::types::triples::ReferenceTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: Some(GroupIdChoice::Uuid([0xAA; 16])), + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap { + class: None, + instance: None, + group: Some(GroupIdChoice::Uuid([0xBB; 16])), // different + }, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn class_match_class_id_mismatch() { + let ref_triple = corim::types::triples::ReferenceTriple::new( + EnvironmentMap { + class: Some(ClassMap { + class_id: Some(ClassIdChoice::Uuid([0xAA; 16])), + vendor: None, + model: None, + layer: None, + index: None, + }), + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap { + class: Some(ClassMap { + class_id: Some(ClassIdChoice::Uuid([0xBB; 16])), // different + vendor: None, + model: None, + layer: None, + index: None, + }), + instance: None, + group: None, + }, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn class_match_layer_mismatch() { + let ref_triple = corim::types::triples::ReferenceTriple::new( + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("V".into()), + model: None, + layer: Some(1), + index: None, + }), + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("V".into()), + model: None, + layer: Some(2), // different + index: None, + }), + instance: None, + group: None, + }, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn class_match_index_mismatch() { + let ref_triple = corim::types::triples::ReferenceTriple::new( + EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("V".into()), + model: None, + layer: None, + index: Some(0), + }), + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("V".into()), + model: None, + layer: None, + index: Some(1), // different + }), + instance: None, + group: None, + }, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +#[test] +fn class_match_target_missing_class() { + let ref_triple = corim::types::triples::ReferenceTriple::new( + EnvironmentMap { + class: Some(ClassMap::new("V", "M")), + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: EnvironmentMap { + class: None, // target has no class + instance: None, + group: None, + }, + measurements: vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::match_reference_values(&[ref_triple], &evidence); + assert!(result.is_empty()); +} + +// =================================================================== +// validate.rs — not-yet-valid CoRIM +// =================================================================== + +#[test] +fn validate_corim_not_yet_valid() { + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(corim::types::triples::ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + let bytes = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .set_validity(Some(i64::MAX - 1), i64::MAX) + .unwrap() + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap(); + let result = corim::validate::decode_and_validate(&bytes); + assert!(result.is_err()); + let err_str = format!("{}", result.unwrap_err()); + assert!(err_str.contains("not yet valid"), "got: {err_str}"); +} + +// =================================================================== +// common.rs — CborTime with out-of-range value +// =================================================================== + +#[test] +fn cbor_time_non_time_value() { + let v = Value::Text("not-a-time".into()); + let err = decode_err::(&v); + assert!( + err.contains("time") || err.contains("integer"), + "got: {err}" + ); +} + +// =================================================================== +// common.rs — MeasuredElement tag inner type errors +// =================================================================== + +#[test] +fn measured_element_oid_non_bytes() { + let v = Value::Tag(TAG_OID, Box::new(Value::Text("not-bytes".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn measured_element_uuid_non_bytes() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Text("not-bytes".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} + +#[test] +fn measured_element_uuid_wrong_size() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Bytes(vec![0; 8]))); + let err = decode_err::(&v); + assert!( + err.contains("16 bytes") || err.contains("UUID"), + "got: {err}" + ); +} + +// =================================================================== +// common.rs — InstanceIdChoice UUID wrong size +// =================================================================== + +#[test] +fn instance_id_uuid_wrong_size() { + let v = Value::Tag(TAG_UUID, Box::new(Value::Bytes(vec![0; 8]))); + let err = decode_err::(&v); + assert!(err.contains("16 bytes"), "got: {err}"); +} + +// =================================================================== +// triples.rs — CoswidTriple with invalid env +// =================================================================== + +#[test] +fn coswid_triple_invalid_env() { + let t = CoswidTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![TagIdChoice::Text("t".into())], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("environment"), "got: {err}"); +} + +// =================================================================== +// triples.rs — ConditionalEndorsementSeriesTriple with invalid env +// =================================================================== + +#[test] +fn ces_triple_invalid_condition_env() { + let ces = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: EnvironmentMap { + class: None, + instance: None, + group: None, + }, + claims_list: vec![], + authorized_by: None, + }, + vec![ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("x".into()), + ..Default::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("y".into()), + ..Default::default() + }, + authorized_by: None, + }], + )], + ); + let err = ces.valid().unwrap_err(); + assert!(err.contains("environment"), "got: {err}"); +} + +// =================================================================== +// minimal_value_serde.rs — Value::Integer u128 boundary +// =================================================================== + +#[test] +fn value_integer_large_positive_u128() { + // Value > u64::MAX — cannot be encoded in CBOR, should return error + let v = Value::Integer((u64::MAX as i128) + 100); + assert!(cbor::encode(&v).is_err()); +} + +#[test] +fn value_integer_large_negative_i128() { + // Value < -(2^64) — cannot be encoded in CBOR, should return error + let v = Value::Integer(-(u64::MAX as i128) - 2); + assert!(cbor::encode(&v).is_err()); +} + +#[test] +fn value_integer_negative_i64_min_encodes() { + // i64::MIN is within CBOR range — should succeed + let v = Value::Integer(i64::MIN as i128); + let bytes = cbor::encode(&v).unwrap(); + assert!(!bytes.is_empty()); +} + +// =================================================================== +// validate.rs — CoSWID decode failure falls back to opaque +// =================================================================== + +#[test] +fn validate_coswid_decode_failure_opaque() { + let comid = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_reference_triple(corim::types::triples::ReferenceTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + )) + .build() + .unwrap(); + // Add garbage bytes as a CoSWID — should fall back to opaque count + let bytes = corim::builder::CorimBuilder::new(CorimId::Text("c".into())) + .add_comid_tag(comid) + .unwrap() + .add_coswid_tag(vec![0xA0]) // minimal CBOR map but not valid ConciseSwidTag + .build_bytes() + .unwrap(); + let full = corim::validate::decode_and_validate_full(&bytes).unwrap(); + assert_eq!(full.comids.len(), 1); + assert_eq!(full.coswid_opaque_count, 1); + assert_eq!(full.coswids.len(), 0); +} + +// =================================================================== +// validate.rs — inconsistent mkeys in CES +// =================================================================== + +#[test] +fn validate_series_inconsistent_mkeys() { + let env = EnvironmentMap::for_class("V", "M"); + let ces = ConditionalEndorsementSeriesTriple::new( + CesCondition { + environment: env.clone(), + claims_list: vec![], + authorized_by: None, + }, + vec![ + ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("a".into()), + ..Default::default() + }, + authorized_by: None, + }], + ), + ConditionalSeriesRecord::new( + vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(99)), // different mkey + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(2)), + ..Default::default() + }, + authorized_by: None, + }], + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + name: Some("b".into()), + ..Default::default() + }, + authorized_by: None, + }], + ), + ], + ); + let evidence = vec![corim::validate::EvidenceClaim { + environment: env, + measurements: vec![MeasurementMap { + mkey: Some(MeasuredElement::Uint(1)), + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..Default::default() + }, + authorized_by: None, + }], + }]; + let result = corim::validate::apply_endorsement_series(&[ces], &evidence); + assert!(result.is_err()); + let err_str = format!("{}", result.unwrap_err()); + assert!( + err_str.contains("inconsistent") || err_str.contains("mkey"), + "got: {err_str}" + ); +} + +// =================================================================== +// builder.rs — remaining empty-list builder errors +// =================================================================== + +#[test] +fn builder_comid_identity_triple_empty_keys_via_builder() { + let result = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_identity_triple(IdentityTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![], // empty keys + None, + )) + .build(); + assert!(result.is_err()); +} + +#[test] +fn builder_comid_attest_key_triple_empty_keys_via_builder() { + let result = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_attest_key_triple(AttestKeyTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![], // empty keys + None, + )) + .build(); + assert!(result.is_err()); +} + +#[test] +fn builder_comid_dependency_triple_empty_trustees_via_builder() { + let result = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_dependency_triple(DomainDependencyTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![], // empty trustees + )) + .build(); + assert!(result.is_err()); +} + +#[test] +fn builder_comid_membership_triple_empty_members_via_builder() { + let result = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_membership_triple(DomainMembershipTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![], // empty members + )) + .build(); + assert!(result.is_err()); +} + +#[test] +fn builder_comid_coswid_triple_empty_tags_via_builder() { + let result = corim::builder::ComidBuilder::new(TagIdChoice::Text("t".into())) + .add_coswid_triple(CoswidTriple::new( + EnvironmentMap::for_class("V", "M"), + vec![], // empty tag IDs + )) + .build(); + assert!(result.is_err()); +} + +// =================================================================== +// corim.rs — CorimId from Profile OID tag inner +// =================================================================== + +#[test] +fn profile_oid_non_bytes() { + let v = Value::Tag(TAG_OID, Box::new(Value::Text("not-bytes".into()))); + let err = decode_err::(&v); + assert!(err.contains("bytes"), "got: {err}"); +} diff --git a/corim/tests/signed_corim_tests.rs b/corim/tests/signed_corim_tests.rs new file mode 100644 index 0000000..2adf0eb --- /dev/null +++ b/corim/tests/signed_corim_tests.rs @@ -0,0 +1,1018 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for signed CoRIM (COSE_Sign1-corim) support per §4.2. + +use corim::builder::{ComidBuilder, CorimBuilder}; +use corim::cbor; +use corim::cbor::value::Value; +use corim::types::common::{MeasuredElement, TagIdChoice}; +use corim::types::corim::{CorimId, CorimMetaMap, CorimSignerMap}; +use corim::types::environment::{ClassMap, EnvironmentMap}; +use corim::types::measurement::{Digest, MeasurementMap, MeasurementValuesMap}; +use corim::types::signed::*; +use corim::types::tags::TAG_SIGNED_CORIM; +use corim::types::triples::ReferenceTriple; +use corim::Validate; + +/// Build a sample unsigned CoRIM payload (tag-501 wrapped) for testing. +fn build_sample_corim_bytes() -> Vec { + let env = EnvironmentMap { + class: Some(ClassMap { + class_id: None, + vendor: Some("TestVendor".into()), + model: Some("TestModel".into()), + layer: None, + index: None, + }), + instance: None, + group: None, + }; + let meas = MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xBB; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + let comid = ComidBuilder::new(TagIdChoice::Text("signed-test-comid".into())) + .add_reference_triple(ReferenceTriple::new(env, vec![meas])) + .build() + .unwrap(); + + CorimBuilder::new(CorimId::Text("signed-test-corim".into())) + .add_comid_tag(comid) + .unwrap() + .build_bytes() + .unwrap() +} + +fn make_corim_meta() -> CorimMetaMap { + CorimMetaMap { + signer: CorimSignerMap { + signer_name: "ACME Corp".into(), + signer_uri: Some("https://acme.example.com".into()), + }, + signature_validity: None, + } +} + +fn make_cwt_claims() -> CwtClaims { + CwtClaims::new("ACME Corp") + .with_nbf(1700000000) + .with_exp(1800000000) +} + +// =================================================================== +// CwtClaims tests +// =================================================================== + +#[test] +fn cwt_claims_round_trip() { + let claims = CwtClaims::new("Test Issuer") + .with_sub("test-doc") + .with_nbf(1700000000) + .with_exp(1800000000); + + let bytes = cbor::encode(&claims).unwrap(); + let decoded: CwtClaims = cbor::decode(&bytes).unwrap(); + + assert_eq!(decoded.iss, "Test Issuer"); + assert_eq!(decoded.sub.as_deref(), Some("test-doc")); + assert_eq!(decoded.nbf, Some(1700000000)); + assert_eq!(decoded.exp, Some(1800000000)); +} + +#[test] +fn cwt_claims_minimal() { + let claims = CwtClaims::new("Minimal"); + let bytes = cbor::encode(&claims).unwrap(); + let decoded: CwtClaims = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.iss, "Minimal"); + assert_eq!(decoded.sub, None); + assert_eq!(decoded.exp, None); + assert_eq!(decoded.nbf, None); + assert!(decoded.extra.is_empty()); +} + +#[test] +fn cwt_claims_with_extra_fields() { + let mut claims = CwtClaims::new("Test"); + claims.extra.insert(100, Value::Text("custom".into())); + + let bytes = cbor::encode(&claims).unwrap(); + let decoded: CwtClaims = cbor::decode(&bytes).unwrap(); + assert_eq!(decoded.extra.get(&100), Some(&Value::Text("custom".into()))); +} + +#[test] +fn cwt_claims_missing_iss_fails() { + // Encode a map without key 1 (iss) + let val = Value::Map(vec![(Value::Integer(2), Value::Text("sub".into()))]); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +// =================================================================== +// ProtectedCorimHeaderMap tests +// =================================================================== + +#[test] +fn protected_header_with_corim_meta_round_trip() { + let meta = make_corim_meta(); + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(meta.clone()), + cwt_claims: None, + extra: std::collections::BTreeMap::new(), + }; + + let bytes = cbor::encode(&header).unwrap(); + let decoded: ProtectedCorimHeaderMap = cbor::decode(&bytes).unwrap(); + + assert_eq!(decoded.alg, -7); + assert_eq!( + decoded.content_type.as_deref(), + Some("application/rim+cbor") + ); + assert!(decoded.corim_meta.is_some()); + let dm = decoded.corim_meta.unwrap(); + assert_eq!(dm.signer.signer_name, "ACME Corp"); + assert_eq!( + dm.signer.signer_uri.as_deref(), + Some("https://acme.example.com") + ); +} + +#[test] +fn protected_header_with_cwt_claims_round_trip() { + let claims = make_cwt_claims(); + let header = ProtectedCorimHeaderMap { + alg: -35, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: None, + cwt_claims: Some(claims), + extra: std::collections::BTreeMap::new(), + }; + + let bytes = cbor::encode(&header).unwrap(); + let decoded: ProtectedCorimHeaderMap = cbor::decode(&bytes).unwrap(); + + assert_eq!(decoded.alg, -35); + assert!(decoded.cwt_claims.is_some()); + let dc = decoded.cwt_claims.unwrap(); + assert_eq!(dc.iss, "ACME Corp"); + assert_eq!(dc.nbf, Some(1700000000)); + assert_eq!(dc.exp, Some(1800000000)); +} + +#[test] +fn protected_header_with_both_meta_and_cwt() { + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(make_corim_meta()), + cwt_claims: Some(make_cwt_claims()), + extra: std::collections::BTreeMap::new(), + }; + + assert!(header.valid().is_ok()); + let bytes = cbor::encode(&header).unwrap(); + let decoded: ProtectedCorimHeaderMap = cbor::decode(&bytes).unwrap(); + assert!(decoded.corim_meta.is_some()); + assert!(decoded.cwt_claims.is_some()); +} + +#[test] +fn protected_header_missing_both_meta_and_cwt_fails_decode() { + // Build a map with just alg and content-type but no meta-group + let val = Value::Map(vec![ + (Value::Integer(1), Value::Integer(-7)), + ( + Value::Integer(3), + Value::Text("application/rim+cbor".into()), + ), + ]); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn protected_header_missing_alg_fails_decode() { + // Build a map with meta but no alg + let meta_bytes = cbor::encode(&make_corim_meta()).unwrap(); + let val = Value::Map(vec![ + ( + Value::Integer(3), + Value::Text("application/rim+cbor".into()), + ), + (Value::Integer(8), Value::Bytes(meta_bytes)), + ]); + let bytes = cbor::encode(&val).unwrap(); + let result = cbor::decode::(&bytes); + assert!(result.is_err()); +} + +#[test] +fn protected_header_validate_inline_mode() { + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(make_corim_meta()), + cwt_claims: None, + extra: std::collections::BTreeMap::new(), + }; + assert!(header.valid().is_ok()); +} + +#[test] +fn protected_header_validate_inline_missing_content_type() { + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: None, + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(make_corim_meta()), + cwt_claims: None, + extra: std::collections::BTreeMap::new(), + }; + assert!(header.valid().is_err()); +} + +#[test] +fn protected_header_validate_hash_envelope_mode() { + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: None, + payload_hash_alg: Some(1), // SHA-256 + payload_preimage_content_type: Some("application/rim+cbor".into()), + payload_location: Some("https://example.com/corim.cbor".into()), + corim_meta: Some(make_corim_meta()), + cwt_claims: None, + extra: std::collections::BTreeMap::new(), + }; + assert!(header.valid().is_ok()); + assert!(header.is_hash_envelope()); +} + +#[test] +fn protected_header_validate_meta_cwt_mismatch() { + let mut meta = make_corim_meta(); + meta.signer.signer_name = "Different Corp".into(); + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(meta), + cwt_claims: Some(make_cwt_claims()), + extra: std::collections::BTreeMap::new(), + }; + let result = header.valid(); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("signer-name")); +} + +// =================================================================== +// SignedCorimBuilder + full round-trip tests +// =================================================================== + +#[test] +fn signed_corim_builder_with_cwt_claims() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Builder Test")); + + let tbs = builder.to_be_signed(&[]).unwrap(); + assert!(!tbs.is_empty()); + + // Verify TBS is valid CBOR (array of 4 items) + let tbs_val: Value = cbor::decode(&tbs).unwrap(); + if let Value::Array(arr) = tbs_val { + assert_eq!(arr.len(), 4); + assert_eq!(arr[0], Value::Text("Signature1".into())); + // protected header bytes + assert!(matches!(arr[1], Value::Bytes(_))); + // external_aad (empty) + assert_eq!(arr[2], Value::Bytes(vec![])); + // payload + assert!(matches!(arr[3], Value::Bytes(_))); + } else { + panic!("TBS must be a CBOR array"); + } +} + +#[test] +fn signed_corim_builder_with_corim_meta() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-35, corim_bytes).set_corim_meta(make_corim_meta()); + + let tbs = builder.to_be_signed(&[]).unwrap(); + assert!(!tbs.is_empty()); + + let fake_signature = vec![0xAA; 64]; + let signed_bytes = builder + .build_with_signature(fake_signature.clone()) + .unwrap(); + + // Decode it back + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert_eq!(signed.protected.alg, -35); + assert!(signed.protected.corim_meta.is_some()); + assert_eq!( + signed + .protected + .corim_meta + .as_ref() + .unwrap() + .signer + .signer_name, + "ACME Corp" + ); + assert_eq!(signed.signature, fake_signature); + assert!(signed.payload.is_some()); +} + +#[test] +fn signed_corim_builder_with_both_meta_and_cwt() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-7, corim_bytes) + .set_corim_meta(make_corim_meta()) + .set_cwt_claims(make_cwt_claims()); + + let tbs = builder.to_be_signed(&[]).unwrap(); + assert!(!tbs.is_empty()); + + let fake_sig = vec![0xCC; 64]; + let signed_bytes = builder.build_with_signature(fake_sig).unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert!(signed.protected.corim_meta.is_some()); + assert!(signed.protected.cwt_claims.is_some()); +} + +#[test] +fn signed_corim_builder_missing_meta_and_cwt_fails() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-7, corim_bytes); + let result = builder.to_be_signed(&[]); + assert!(result.is_err()); +} + +#[test] +fn signed_corim_builder_with_external_aad() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("AAD Test")); + + let aad = b"some-external-aad"; + let tbs_with_aad = builder.to_be_signed(aad).unwrap(); + + // Verify the AAD is embedded in TBS + let tbs_val: Value = cbor::decode(&tbs_with_aad).unwrap(); + if let Value::Array(arr) = tbs_val { + assert_eq!(arr[2], Value::Bytes(aad.to_vec())); + } else { + panic!("TBS must be a CBOR array"); + } +} + +#[test] +fn signed_corim_builder_with_unprotected_header() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-7, corim_bytes) + .set_cwt_claims(CwtClaims::new("Unprotected Test")) + .add_unprotected(Value::Integer(33), Value::Text("x5chain".into())); + + let tbs = builder.to_be_signed(&[]).unwrap(); + assert!(!tbs.is_empty()); + + let signed_bytes = builder.build_with_signature(vec![0xDD; 64]).unwrap(); + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert!(!signed.unprotected.is_empty()); +} + +// =================================================================== +// decode_signed_corim tests +// =================================================================== + +#[test] +fn decode_signed_corim_valid() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Decode Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let signed_bytes = builder.build_with_signature(vec![0x42; 64]).unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert_eq!(signed.protected.alg, -7); + assert!(signed.payload.is_some()); + assert_eq!(signed.signature, vec![0x42; 64]); +} + +#[test] +fn decode_signed_corim_not_a_tag() { + let val = Value::Array(vec![]); + let bytes = cbor::encode(&val).unwrap(); + let result = decode_signed_corim(&bytes); + assert!(result.is_err()); +} + +#[test] +fn decode_signed_corim_wrong_tag() { + let val = Value::Tag(99, Box::new(Value::Array(vec![]))); + let bytes = cbor::encode(&val).unwrap(); + let result = decode_signed_corim(&bytes); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(format!("{}", err).contains("expected CBOR tag 18")); +} + +#[test] +fn decode_signed_corim_wrong_array_length() { + let val = Value::Tag( + TAG_SIGNED_CORIM, + Box::new(Value::Array(vec![Value::Bytes(vec![]), Value::Map(vec![])])), + ); + let bytes = cbor::encode(&val).unwrap(); + let result = decode_signed_corim(&bytes); + assert!(result.is_err()); +} + +#[test] +fn decode_signed_corim_not_an_array() { + let val = Value::Tag(TAG_SIGNED_CORIM, Box::new(Value::Text("bad".into()))); + let bytes = cbor::encode(&val).unwrap(); + let result = decode_signed_corim(&bytes); + assert!(result.is_err()); +} + +#[test] +fn decode_signed_corim_nil_payload() { + // Build a signed CoRIM with nil payload (detached) + let meta_bytes = cbor::encode(&make_corim_meta()).unwrap(); + let protected = Value::Map(vec![ + (Value::Integer(1), Value::Integer(-7)), + ( + Value::Integer(3), + Value::Text("application/rim+cbor".into()), + ), + (Value::Integer(8), Value::Bytes(meta_bytes)), + ]); + let protected_bytes = cbor::encode(&protected).unwrap(); + + let cose_arr = Value::Array(vec![ + Value::Bytes(protected_bytes), + Value::Map(vec![]), + Value::Null, + Value::Bytes(vec![0xFF; 32]), + ]); + let tagged = Value::Tag(TAG_SIGNED_CORIM, Box::new(cose_arr)); + let bytes = cbor::encode(&tagged).unwrap(); + + let signed = decode_signed_corim(&bytes).unwrap(); + assert!(signed.payload.is_none()); + assert_eq!(signed.signature, vec![0xFF; 32]); +} + +// =================================================================== +// TBS (to-be-signed) construction tests +// =================================================================== + +#[test] +fn tbs_sig_structure1_format() { + let protected_bytes = vec![0xA1, 0x01, 0x26]; // {1: -7} in CBOR + let payload = vec![0xDE, 0xAD]; + let external_aad = vec![]; + + let tbs = build_sig_structure1(&protected_bytes, &external_aad, &payload).unwrap(); + + // Decode and verify + let val: Value = cbor::decode(&tbs).unwrap(); + match val { + Value::Array(arr) => { + assert_eq!(arr.len(), 4); + assert_eq!(arr[0], Value::Text("Signature1".into())); + assert_eq!(arr[1], Value::Bytes(protected_bytes)); + assert_eq!(arr[2], Value::Bytes(vec![])); + assert_eq!(arr[3], Value::Bytes(vec![0xDE, 0xAD])); + } + _ => panic!("expected array"), + } +} + +#[test] +fn tbs_with_external_aad() { + let protected_bytes = vec![0xA0]; // empty map + let payload = vec![0x01]; + let external_aad = b"extra-context".to_vec(); + + let tbs = build_sig_structure1(&protected_bytes, &external_aad, &payload).unwrap(); + let val: Value = cbor::decode(&tbs).unwrap(); + match val { + Value::Array(arr) => { + assert_eq!(arr[2], Value::Bytes(external_aad)); + } + _ => panic!("expected array"), + } +} + +#[test] +fn cose_sign1_corim_tbs_method() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes.clone()).set_cwt_claims(CwtClaims::new("TBS Test")); + + let tbs_from_builder = builder.to_be_signed(&[]).unwrap(); + + let signed_bytes = builder.build_with_signature(vec![0x11; 64]).unwrap(); + let signed = decode_signed_corim(&signed_bytes).unwrap(); + + let tbs_from_struct = signed.to_be_signed(&[]).unwrap(); + + // Both TBS should be identical + assert_eq!(tbs_from_builder, tbs_from_struct); +} + +// =================================================================== +// encode_signed_corim / decode_signed_corim round-trip +// =================================================================== + +#[test] +fn encode_decode_round_trip() { + let corim_bytes = build_sample_corim_bytes(); + + let protected = ProtectedCorimHeaderMap { + alg: -7, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(make_corim_meta()), + cwt_claims: None, + extra: std::collections::BTreeMap::new(), + }; + let protected_header_bytes = cbor::encode(&protected).unwrap(); + + let signed = CoseSign1Corim { + protected_header_bytes: protected_header_bytes.clone(), + protected, + unprotected: vec![], + payload: Some(corim_bytes), + signature: vec![0xEE; 64], + }; + + let encoded = encode_signed_corim(&signed).unwrap(); + let decoded = decode_signed_corim(&encoded).unwrap(); + + assert_eq!(decoded.protected.alg, -7); + assert_eq!(decoded.protected_header_bytes, protected_header_bytes); + assert_eq!(decoded.signature, vec![0xEE; 64]); + assert!(decoded.payload.is_some()); +} + +// =================================================================== +// validate_signed_corim_payload tests +// =================================================================== + +#[test] +fn validate_signed_corim_payload_valid() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Validate Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let signed_bytes = builder.build_with_signature(vec![0x55; 64]).unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + + // Use a timestamp that skips expiry (far future) + let result = validate_signed_corim_payload(&signed, 0); + assert!(result.is_ok()); + + let validated = result.unwrap(); + assert_eq!(validated.comids.len(), 1); +} + +#[test] +fn validate_signed_corim_payload_detached_fails() { + let meta_bytes = cbor::encode(&make_corim_meta()).unwrap(); + let protected = Value::Map(vec![ + (Value::Integer(1), Value::Integer(-7)), + ( + Value::Integer(3), + Value::Text("application/rim+cbor".into()), + ), + (Value::Integer(8), Value::Bytes(meta_bytes)), + ]); + let protected_bytes = cbor::encode(&protected).unwrap(); + + let cose_arr = Value::Array(vec![ + Value::Bytes(protected_bytes), + Value::Map(vec![]), + Value::Null, + Value::Bytes(vec![0x00; 32]), + ]); + let tagged = Value::Tag(TAG_SIGNED_CORIM, Box::new(cose_arr)); + let bytes = cbor::encode(&tagged).unwrap(); + + let signed = decode_signed_corim(&bytes).unwrap(); + let result = validate_signed_corim_payload(&signed, 0); + assert!(result.is_err()); + assert!(format!("{}", result.unwrap_err()).contains("detached")); +} + +// =================================================================== +// COSE header constant tests +// =================================================================== + +#[test] +fn cose_header_constants() { + assert_eq!(COSE_HEADER_ALG, 1); + assert_eq!(COSE_HEADER_CONTENT_TYPE, 3); + assert_eq!(COSE_HEADER_CORIM_META, 8); + assert_eq!(COSE_HEADER_CWT_CLAIMS, 15); + assert_eq!(COSE_HEADER_PAYLOAD_HASH_ALG, 258); + assert_eq!(COSE_HEADER_PAYLOAD_PREIMAGE_CT, 259); + assert_eq!(COSE_HEADER_PAYLOAD_LOCATION, 260); +} + +// =================================================================== +// Full workflow: build → sign → decode → validate +// =================================================================== + +#[test] +fn full_signed_corim_workflow() { + // Step 1: Build an unsigned CoRIM + let corim_bytes = build_sample_corim_bytes(); + + // Step 2: Create a signed CoRIM builder + let mut builder = SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims( + CwtClaims::new("Workflow Test") + .with_nbf(0) + .with_exp(2000000000), + ); + + // Step 3: Get TBS blob for external signing + let tbs = builder.to_be_signed(&[]).unwrap(); + assert!(!tbs.is_empty()); + + // Step 4: "Sign" externally (fake signature for testing) + let fake_signature = vec![0xAB; 64]; + + // Step 5: Build the final signed CoRIM + let signed_bytes = builder + .build_with_signature(fake_signature.clone()) + .unwrap(); + + // Step 6: Decode the signed CoRIM + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert_eq!(signed.protected.alg, -7); + assert!(signed.protected.cwt_claims.is_some()); + assert_eq!(signed.signature, fake_signature); + + // Step 7: Verify TBS matches (caller would verify signature here) + let tbs2 = signed.to_be_signed(&[]).unwrap(); + assert_eq!(tbs, tbs2); + + // Step 8: Validate the inner payload + let validated = validate_signed_corim_payload(&signed, 1000000000).unwrap(); + assert_eq!(validated.comids.len(), 1); + assert_eq!( + validated.corim.id, + CorimId::Text("signed-test-corim".into()) + ); +} + +#[test] +fn full_workflow_with_corim_meta() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-35, corim_bytes).set_corim_meta(CorimMetaMap { + signer: CorimSignerMap { + signer_name: "Meta Signer".into(), + signer_uri: None, + }, + signature_validity: None, + }); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let signed_bytes = builder.build_with_signature(vec![0x99; 48]).unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert_eq!(signed.protected.alg, -35); + let meta = signed.protected.corim_meta.as_ref().unwrap(); + assert_eq!(meta.signer.signer_name, "Meta Signer"); + + let validated = validate_signed_corim_payload(&signed, 0).unwrap(); + assert_eq!(validated.comids.len(), 1); +} + +// =================================================================== +// Edge cases +// =================================================================== + +#[test] +fn protected_header_extra_fields_preserved() { + let header = ProtectedCorimHeaderMap { + alg: -7, + content_type: Some("application/rim+cbor".into()), + payload_hash_alg: None, + payload_preimage_content_type: None, + payload_location: None, + corim_meta: Some(make_corim_meta()), + cwt_claims: None, + extra: { + let mut m = std::collections::BTreeMap::new(); + m.insert(99, Value::Text("custom-value".into())); + m + }, + }; + + let bytes = cbor::encode(&header).unwrap(); + let decoded: ProtectedCorimHeaderMap = cbor::decode(&bytes).unwrap(); + assert_eq!( + decoded.extra.get(&99), + Some(&Value::Text("custom-value".into())) + ); +} + +#[test] +fn signed_corim_large_signature() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-36, corim_bytes).set_cwt_claims(CwtClaims::new("Large Sig Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let large_sig = vec![0xFE; 132]; // ES512 signatures are ~132 bytes + let signed_bytes = builder.build_with_signature(large_sig.clone()).unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert_eq!(signed.signature, large_sig); +} + +#[test] +fn signed_corim_empty_signature() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Empty Sig Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + // Build with empty signature (placeholder — TBS was computed) + let signed_bytes = builder.build_with_signature(vec![]).unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert!(signed.signature.is_empty()); +} + +#[test] +fn builder_caches_protected_bytes() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Cache Test")); + + let tbs1 = builder.to_be_signed(&[]).unwrap(); + let tbs2 = builder.to_be_signed(&[]).unwrap(); + // Same TBS both times (cached) + assert_eq!(tbs1, tbs2); +} + +#[test] +fn tag_18_magic_number() { + // The first bytes of a signed CoRIM should be d2 84 (tag 18 + array(4)) + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Magic Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let signed_bytes = builder.build_with_signature(vec![0x11; 64]).unwrap(); + + // Tag 18 = 0xD2, followed by 0x84 for 4-element array + assert_eq!(signed_bytes[0], 0xD2); + assert_eq!(signed_bytes[1], 0x84); +} + +// =================================================================== +// Detached payload signing tests +// =================================================================== + +#[test] +fn detached_builder_produces_nil_payload() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Detached Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let signed_bytes = builder + .build_detached_with_signature(vec![0xDD; 64]) + .unwrap(); + + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert!(signed.is_detached()); + assert!(signed.payload.is_none()); + assert_eq!(signed.signature, vec![0xDD; 64]); +} + +#[test] +fn detached_tbs_uses_actual_payload() { + let corim_bytes = build_sample_corim_bytes(); + + // Builder computes TBS over the real payload even in detached mode + let mut builder_a = + SignedCorimBuilder::new(-7, corim_bytes.clone()).set_cwt_claims(CwtClaims::new("TBS Test")); + let tbs_from_builder = builder_a.to_be_signed(&[]).unwrap(); + + // Build attached, decode, then compute TBS from the struct + let signed_bytes = builder_a.build_with_signature(vec![0x00; 64]).unwrap(); + let attached = decode_signed_corim(&signed_bytes).unwrap(); + let tbs_from_attached = attached.to_be_signed(&[]).unwrap(); + assert_eq!(tbs_from_builder, tbs_from_attached); + + // Now build a detached envelope from the same protected header + let mut builder_b = + SignedCorimBuilder::new(-7, corim_bytes.clone()).set_cwt_claims(CwtClaims::new("TBS Test")); + let tbs_before_detach = builder_b.to_be_signed(&[]).unwrap(); + let detached_bytes = builder_b + .build_detached_with_signature(vec![0x00; 64]) + .unwrap(); + let detached = decode_signed_corim(&detached_bytes).unwrap(); + + // to_be_signed on a detached envelope should fail + assert!(detached.to_be_signed(&[]).is_err()); + + // to_be_signed_detached with the original payload should match + let tbs_from_detached = detached.to_be_signed_detached(&corim_bytes, &[]).unwrap(); + assert_eq!(tbs_before_detach, tbs_from_detached); +} + +#[test] +fn detached_to_be_signed_errors_without_payload() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Error Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let detached_bytes = builder + .build_detached_with_signature(vec![0xAA; 64]) + .unwrap(); + + let signed = decode_signed_corim(&detached_bytes).unwrap(); + let err = signed.to_be_signed(&[]).unwrap_err(); + assert!( + err.to_string().contains("detached"), + "error should mention detached: {}", + err + ); +} + +#[test] +fn detached_to_be_signed_detached_with_external_aad() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-7, corim_bytes.clone()) + .set_cwt_claims(CwtClaims::new("AAD Detach Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let detached_bytes = builder + .build_detached_with_signature(vec![0xBB; 64]) + .unwrap(); + + let signed = decode_signed_corim(&detached_bytes).unwrap(); + let aad = b"my-external-aad"; + let tbs = signed.to_be_signed_detached(&corim_bytes, aad).unwrap(); + + // Verify the AAD is in the TBS + let tbs_val: Value = cbor::decode(&tbs).unwrap(); + if let Value::Array(arr) = tbs_val { + assert_eq!(arr[2], Value::Bytes(aad.to_vec())); + // payload should be the corim_bytes, not empty + assert_eq!(arr[3], Value::Bytes(corim_bytes.clone())); + } else { + panic!("TBS must be a CBOR array"); + } +} + +#[test] +fn detached_validate_fails_for_attached_api() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Validate Detach")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let detached_bytes = builder + .build_detached_with_signature(vec![0xCC; 64]) + .unwrap(); + + let signed = decode_signed_corim(&detached_bytes).unwrap(); + let result = validate_signed_corim_payload(&signed, 0); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("detached")); +} + +#[test] +fn detached_validate_with_supplied_payload() { + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-7, corim_bytes.clone()) + .set_cwt_claims(CwtClaims::new("Validate Detach OK")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let detached_bytes = builder + .build_detached_with_signature(vec![0xEE; 64]) + .unwrap(); + + let signed = decode_signed_corim(&detached_bytes).unwrap(); + let validated = validate_signed_corim_payload_detached(&signed, &corim_bytes, 0).unwrap(); + assert_eq!(validated.comids.len(), 1); +} + +#[test] +fn is_detached_flag() { + let corim_bytes = build_sample_corim_bytes(); + + // Attached + let mut builder_a = SignedCorimBuilder::new(-7, corim_bytes.clone()) + .set_cwt_claims(CwtClaims::new("Flag Test")); + let _tbs = builder_a.to_be_signed(&[]).unwrap(); + let attached_bytes = builder_a.build_with_signature(vec![0x11; 64]).unwrap(); + let attached = decode_signed_corim(&attached_bytes).unwrap(); + assert!(!attached.is_detached()); + + // Detached + let mut builder_b = + SignedCorimBuilder::new(-7, corim_bytes).set_cwt_claims(CwtClaims::new("Flag Test")); + let _tbs = builder_b.to_be_signed(&[]).unwrap(); + let detached_bytes = builder_b + .build_detached_with_signature(vec![0x22; 64]) + .unwrap(); + let detached = decode_signed_corim(&detached_bytes).unwrap(); + assert!(detached.is_detached()); +} + +#[test] +fn full_detached_workflow() { + // Step 1: Build an unsigned CoRIM payload + let corim_bytes = build_sample_corim_bytes(); + + // Step 2: Create builder, compute TBS, "sign" + let mut builder = SignedCorimBuilder::new(-35, corim_bytes.clone()) + .set_cwt_claims(CwtClaims::new("Detached Workflow")); + + let tbs = builder.to_be_signed(&[]).unwrap(); + assert!(!tbs.is_empty()); + + let fake_signature = vec![0xFE; 48]; + + // Step 3: Build DETACHED envelope + let envelope_bytes = builder + .build_detached_with_signature(fake_signature.clone()) + .unwrap(); + + // Step 4: Decode the envelope + let envelope = decode_signed_corim(&envelope_bytes).unwrap(); + assert!(envelope.is_detached()); + assert_eq!(envelope.protected.alg, -35); + + // Step 5: Reconstruct TBS from envelope + detached payload + let tbs2 = envelope.to_be_signed_detached(&corim_bytes, &[]).unwrap(); + assert_eq!(tbs, tbs2); + + // Step 6: Validate the detached payload + let validated = validate_signed_corim_payload_detached(&envelope, &corim_bytes, 0).unwrap(); + assert_eq!(validated.comids.len(), 1); +} + +#[test] +fn to_be_signed_detached_works_on_attached_too() { + // to_be_signed_detached should work even on an attached envelope, + // using the supplied payload (overriding the embedded one) + let corim_bytes = build_sample_corim_bytes(); + let mut builder = SignedCorimBuilder::new(-7, corim_bytes.clone()) + .set_cwt_claims(CwtClaims::new("Override Test")); + + let _tbs = builder.to_be_signed(&[]).unwrap(); + let signed_bytes = builder.build_with_signature(vec![0x11; 64]).unwrap(); + let signed = decode_signed_corim(&signed_bytes).unwrap(); + assert!(!signed.is_detached()); + + // Both methods should produce the same TBS + let tbs_attached = signed.to_be_signed(&[]).unwrap(); + let tbs_detached = signed.to_be_signed_detached(&corim_bytes, &[]).unwrap(); + assert_eq!(tbs_attached, tbs_detached); +} diff --git a/corim/tests/validate_tests.rs b/corim/tests/validate_tests.rs new file mode 100644 index 0000000..81145aa --- /dev/null +++ b/corim/tests/validate_tests.rs @@ -0,0 +1,707 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the `Validate` trait implementations. +//! +//! These tests mirror the validation patterns from the CoRIM specification +//! (e.g., `TestEnvironment_Valid_empty`, +//! `TestTriples_Valid`, etc.). + +use corim::types::comid::ComidTag; +use corim::types::common::*; +use corim::types::corim::*; +use corim::types::environment::*; +use corim::types::measurement::*; +use corim::types::triples::*; +use corim::Validate; + +// =========================================================================== +// ClassMap validation +// =========================================================================== + +#[test] +fn class_map_valid_vendor_model() { + let c = ClassMap::new("ACME", "Widget"); + assert!(c.valid().is_ok()); +} + +#[test] +fn class_map_valid_class_id_only() { + let c = ClassMap { + class_id: Some(ClassIdChoice::Uuid([0xAA; 16])), + ..ClassMap::default() + }; + assert!(c.valid().is_ok()); +} + +#[test] +fn class_map_empty_is_invalid() { + let c = ClassMap::default(); + let err = c.valid().unwrap_err(); + assert!(err.contains("class must not be empty"), "got: {err}"); +} + +// =========================================================================== +// EnvironmentMap validation +// =========================================================================== + +#[test] +fn env_valid_with_class() { + let env = EnvironmentMap::for_class("ACME", "Widget"); + assert!(env.valid().is_ok()); +} + +#[test] +fn env_valid_with_instance() { + let env = EnvironmentMap { + class: None, + instance: Some(InstanceIdChoice::Uuid([0xBB; 16])), + group: None, + }; + assert!(env.valid().is_ok()); +} + +#[test] +fn env_valid_with_group() { + let env = EnvironmentMap { + class: None, + instance: None, + group: Some(GroupIdChoice::Uuid([0xCC; 16])), + }; + assert!(env.valid().is_ok()); +} + +#[test] +fn env_empty_is_invalid() { + let env = EnvironmentMap { + class: None, + instance: None, + group: None, + }; + let err = env.valid().unwrap_err(); + assert!(err.contains("environment must not be empty"), "got: {err}"); +} + +#[test] +fn env_with_empty_class_is_invalid() { + let env = EnvironmentMap { + class: Some(ClassMap::default()), + instance: None, + group: None, + }; + let err = env.valid().unwrap_err(); + assert!(err.contains("class validation failed"), "got: {err}"); +} + +// =========================================================================== +// MeasurementValuesMap validation +// =========================================================================== + +#[test] +fn mval_empty_is_invalid() { + let mval = MeasurementValuesMap::default(); + let err = mval.valid().unwrap_err(); + assert!(err.contains("no measurement value set"), "got: {err}"); +} + +#[test] +fn mval_with_digests_is_valid() { + let mval = MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 32])]), + ..MeasurementValuesMap::default() + }; + assert!(mval.valid().is_ok()); +} + +#[test] +fn mval_with_empty_digests_is_invalid() { + let mval = MeasurementValuesMap { + digests: Some(vec![]), + ..MeasurementValuesMap::default() + }; + let err = mval.valid().unwrap_err(); + assert!(err.contains("digests list must not be empty"), "got: {err}"); +} + +#[test] +fn mval_with_svn_is_valid() { + let mval = MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(42)), + ..MeasurementValuesMap::default() + }; + assert!(mval.valid().is_ok()); +} + +#[test] +fn mval_with_version_is_valid() { + let mval = MeasurementValuesMap { + version: Some(VersionMap { + version: "1.0".into(), + version_scheme: None, + }), + ..MeasurementValuesMap::default() + }; + assert!(mval.valid().is_ok()); +} + +#[test] +fn mval_with_name_is_valid() { + let mval = MeasurementValuesMap { + name: Some("test-component".into()), + ..MeasurementValuesMap::default() + }; + assert!(mval.valid().is_ok()); +} + +// =========================================================================== +// MeasurementMap validation +// =========================================================================== + +#[test] +fn measurement_map_valid() { + let m = MeasurementMap { + mkey: Some(MeasuredElement::Text("firmware".into())), + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 48])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }; + assert!(m.valid().is_ok()); +} + +#[test] +fn measurement_map_empty_mval_is_invalid() { + let m = MeasurementMap { + mkey: None, + mval: MeasurementValuesMap::default(), + authorized_by: None, + }; + let err = m.valid().unwrap_err(); + assert!(err.contains("measurement values"), "got: {err}"); +} + +// =========================================================================== +// ReferenceTriple validation +// =========================================================================== + +#[test] +fn reference_triple_valid() { + let t = ReferenceTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + digests: Some(vec![Digest::new(7, vec![0xAA; 32])]), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn reference_triple_empty_env_is_invalid() { + let t = ReferenceTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("environment validation failed"), "got: {err}"); +} + +#[test] +fn reference_triple_empty_measurements_is_invalid() { + let t = ReferenceTriple::new(EnvironmentMap::for_class("ACME", "Widget"), vec![]); + let err = t.valid().unwrap_err(); + assert!(err.contains("no measurement entries"), "got: {err}"); +} + +#[test] +fn reference_triple_invalid_measurement_value() { + let t = ReferenceTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap::default(), // empty + authorized_by: None, + }], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("measurement at index 0"), "got: {err}"); +} + +// =========================================================================== +// IdentityTriple validation +// =========================================================================== + +#[test] +fn identity_triple_valid() { + let t = IdentityTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![CryptoKey::PkixBase64Key("MIIBIjANBg...".into())], + None, + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn identity_triple_empty_env_is_invalid() { + let t = IdentityTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![CryptoKey::PkixBase64Key("key".into())], + None, + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("environment"), "got: {err}"); +} + +#[test] +fn identity_triple_no_keys_is_invalid() { + let t = IdentityTriple::new(EnvironmentMap::for_class("X", "Y"), vec![], None); + let err = t.valid().unwrap_err(); + assert!(err.contains("no keys"), "got: {err}"); +} + +// =========================================================================== +// DomainDependencyTriple validation +// =========================================================================== + +#[test] +fn domain_dependency_valid() { + let t = DomainDependencyTriple::new( + EnvironmentMap { + class: None, + instance: Some(InstanceIdChoice::Uuid([0xAA; 16])), + group: None, + }, + vec![EnvironmentMap { + class: None, + instance: Some(InstanceIdChoice::Uuid([0xBB; 16])), + group: None, + }], + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn domain_dependency_empty_domain_id_is_invalid() { + let t = DomainDependencyTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![EnvironmentMap::for_class("X", "Y")], + ); + let err = t.valid().unwrap_err(); + assert!(err.contains("domain-id"), "got: {err}"); +} + +#[test] +fn domain_dependency_no_trustees_is_invalid() { + let t = DomainDependencyTriple::new(EnvironmentMap::for_class("X", "Y"), vec![]); + let err = t.valid().unwrap_err(); + assert!(err.contains("at least one trustee"), "got: {err}"); +} + +#[test] +fn domain_dependency_self_reference_is_invalid() { + let env = EnvironmentMap::for_class("ACME", "Widget"); + let t = DomainDependencyTriple::new(env.clone(), vec![env]); + let err = t.valid().unwrap_err(); + assert!( + err.contains("domain-id must not appear in trustees"), + "got: {err}" + ); +} + +// =========================================================================== +// DomainMembershipTriple validation +// =========================================================================== + +#[test] +fn domain_membership_valid() { + let t = DomainMembershipTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![EnvironmentMap::for_class("ACME", "SubWidget")], + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn domain_membership_no_members_is_invalid() { + let t = DomainMembershipTriple::new(EnvironmentMap::for_class("X", "Y"), vec![]); + let err = t.valid().unwrap_err(); + assert!(err.contains("at least one member"), "got: {err}"); +} + +// =========================================================================== +// CoswidTriple validation +// =========================================================================== + +#[test] +fn coswid_triple_valid() { + let t = CoswidTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![TagIdChoice::Text("tag1".into())], + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn coswid_triple_empty_tag_ids_is_invalid() { + let t = CoswidTriple::new(EnvironmentMap::for_class("ACME", "Widget"), vec![]); + let err = t.valid().unwrap_err(); + assert!(err.contains("at least one CoSWID tag-id"), "got: {err}"); +} + +// =========================================================================== +// TriplesMap validation +// =========================================================================== + +#[test] +fn triples_map_empty_is_invalid() { + let t = TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!( + err.contains("triples struct must not be empty"), + "got: {err}" + ); +} + +#[test] +fn triples_map_with_empty_vecs_is_invalid() { + let t = TriplesMap { + reference_triples: Some(vec![]), + endorsed_triples: Some(vec![]), + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!( + err.contains("triples struct must not be empty"), + "got: {err}" + ); +} + +#[test] +fn triples_map_validates_inner_triples() { + let bad_ref = ReferenceTriple::new( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![], + ); + let t = TriplesMap { + reference_triples: Some(vec![bad_ref]), + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }; + let err = t.valid().unwrap_err(); + assert!(err.contains("reference value at index 0"), "got: {err}"); +} + +// =========================================================================== +// ComidTag validation +// =========================================================================== + +#[test] +fn comid_tag_valid() { + let comid = ComidTag { + language: None, + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("test-tag".into()), + tag_version: None, + }, + entities: None, + linked_tags: None, + triples: TriplesMap { + reference_triples: Some(vec![ReferenceTriple::new( + EnvironmentMap::for_class("ACME", "Widget"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + )]), + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }, + }; + assert!(comid.valid().is_ok()); +} + +#[test] +fn comid_tag_empty_triples_is_invalid() { + let comid = ComidTag { + language: None, + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("test-tag".into()), + tag_version: None, + }, + entities: None, + linked_tags: None, + triples: TriplesMap { + reference_triples: None, + endorsed_triples: None, + identity_triples: None, + attest_key_triples: None, + dependency_triples: None, + membership_triples: None, + coswid_triples: None, + conditional_endorsement_series: None, + conditional_endorsement: None, + }, + }; + let err = comid.valid().unwrap_err(); + assert!(err.contains("triples validation failed"), "got: {err}"); +} + +// =========================================================================== +// ConciseTlTag validation +// =========================================================================== + +#[test] +fn cotl_valid() { + let cotl = ConciseTlTag { + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("tl-1".into()), + tag_version: None, + }, + tags_list: vec![TagIdentity { + tag_id: TagIdChoice::Text("comid-1".into()), + tag_version: None, + }], + tl_validity: ValidityMap { + not_before: Some(CborTime::new(1000)), + not_after: CborTime::new(2000), + }, + }; + assert!(cotl.valid().is_ok()); +} + +#[test] +fn cotl_empty_tags_list_is_invalid() { + let cotl = ConciseTlTag { + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("tl-1".into()), + tag_version: None, + }, + tags_list: vec![], + tl_validity: ValidityMap { + not_before: None, + not_after: CborTime::new(2000), + }, + }; + let err = cotl.valid().unwrap_err(); + assert!(err.contains("tags-list must not be empty"), "got: {err}"); +} + +#[test] +fn cotl_not_before_after_not_after_is_invalid() { + let cotl = ConciseTlTag { + tag_identity: TagIdentity { + tag_id: TagIdChoice::Text("tl-1".into()), + tag_version: None, + }, + tags_list: vec![TagIdentity { + tag_id: TagIdChoice::Text("comid-1".into()), + tag_version: None, + }], + tl_validity: ValidityMap { + not_before: Some(CborTime::new(3000)), + not_after: CborTime::new(2000), + }, + }; + let err = cotl.valid().unwrap_err(); + assert!( + err.contains("not-before must be <= not-after"), + "got: {err}" + ); +} + +// =========================================================================== +// CorimMap validation +// =========================================================================== + +#[test] +fn corim_map_valid() { + let corim = CorimMap { + id: CorimId::Text("test-corim".into()), + tags: vec![ConciseTagChoice::Comid(vec![0xA0])], + dependent_rims: None, + profile: None, + rim_validity: None, + entities: None, + }; + assert!(corim.valid().is_ok()); +} + +#[test] +fn corim_map_empty_tags_is_invalid() { + let corim = CorimMap { + id: CorimId::Text("test-corim".into()), + tags: vec![], + dependent_rims: None, + profile: None, + rim_validity: None, + entities: None, + }; + let err = corim.valid().unwrap_err(); + assert!(err.contains("tags list must not be empty"), "got: {err}"); +} + +#[test] +fn corim_map_invalid_validity() { + let corim = CorimMap { + id: CorimId::Text("test-corim".into()), + tags: vec![ConciseTagChoice::Comid(vec![0xA0])], + dependent_rims: None, + profile: None, + rim_validity: Some(ValidityMap { + not_before: Some(CborTime::new(3000)), + not_after: CborTime::new(2000), + }), + entities: None, + }; + let err = corim.valid().unwrap_err(); + assert!( + err.contains("not-before must be <= not-after"), + "got: {err}" + ); +} + +// =========================================================================== +// ConditionalEndorsementTriple validation +// =========================================================================== + +#[test] +fn conditional_endorsement_triple_valid() { + let env = EnvironmentMap::for_class("ACME", "Widget"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }]; + let t = ConditionalEndorsementTriple( + vec![StatefulEnvironmentRecord(env.clone(), meas.clone())], + vec![EndorsedTriple::new(env, meas)], + ); + assert!(t.valid().is_ok()); +} + +#[test] +fn conditional_endorsement_triple_empty_conditions_is_invalid() { + let env = EnvironmentMap::for_class("ACME", "Widget"); + let meas = vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }]; + let t = ConditionalEndorsementTriple(vec![], vec![EndorsedTriple::new(env, meas)]); + let err = t.valid().unwrap_err(); + assert!(err.contains("conditions must not be empty"), "got: {err}"); +} + +// =========================================================================== +// StatefulEnvironmentRecord validation +// =========================================================================== + +#[test] +fn stateful_env_record_valid() { + let r = StatefulEnvironmentRecord( + EnvironmentMap::for_class("ACME", "Widget"), + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ); + assert!(r.valid().is_ok()); +} + +#[test] +fn stateful_env_record_empty_env_is_invalid() { + let r = StatefulEnvironmentRecord( + EnvironmentMap { + class: None, + instance: None, + group: None, + }, + vec![MeasurementMap { + mkey: None, + mval: MeasurementValuesMap { + svn: Some(SvnChoice::ExactValue(1)), + ..MeasurementValuesMap::default() + }, + authorized_by: None, + }], + ); + let err = r.valid().unwrap_err(); + assert!(err.contains("environment"), "got: {err}"); +} + +#[test] +fn stateful_env_record_empty_measurements_is_invalid() { + let r = StatefulEnvironmentRecord(EnvironmentMap::for_class("ACME", "Widget"), vec![]); + let err = r.valid().unwrap_err(); + assert!(err.contains("measurements must not be empty"), "got: {err}"); +} diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000..faf36a3 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Code coverage helper script for the corim workspace. +# +# Usage: +# ./scripts/coverage.sh # Text summary (default) +# ./scripts/coverage.sh html # HTML report → target/llvm-cov/html/ +# ./scripts/coverage.sh lcov # LCOV file → lcov.info +# ./scripts/coverage.sh check # CI-mode: fail if < 70% line coverage +# +# Requires: cargo-llvm-cov + llvm-tools-preview +# rustup component add llvm-tools-preview +# cargo install cargo-llvm-cov + +set -e + +THRESHOLD=70 +EXCLUDE='(corim-cli/|corim-macros/|src/lib\.rs$)' +FEATURES="json" + +case "${1:-text}" in + text) + echo "=== Code Coverage (text summary) ===" + cargo llvm-cov --workspace --features "$FEATURES" \ + --ignore-filename-regex "$EXCLUDE" + ;; + html) + echo "=== Code Coverage (HTML report) ===" + cargo llvm-cov --workspace --features "$FEATURES" \ + --ignore-filename-regex "$EXCLUDE" \ + --html + echo "Report: target/llvm-cov/html/index.html" + ;; + lcov) + echo "=== Code Coverage (LCOV export) ===" + cargo llvm-cov --workspace --features "$FEATURES" \ + --ignore-filename-regex "$EXCLUDE" \ + --lcov --output-path lcov.info + echo "LCOV: lcov.info" + ;; + check) + echo "=== Code Coverage Gate (threshold: ${THRESHOLD}%) ===" + cargo llvm-cov --workspace --features "$FEATURES" \ + --ignore-filename-regex "$EXCLUDE" \ + --fail-under-lines "$THRESHOLD" + echo "✅ Coverage gate passed (≥${THRESHOLD}%)" + ;; + *) + echo "Usage: $0 [text|html|lcov|check]" + exit 1 + ;; +esac diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100755 index 0000000..a0923e1 --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Pre-commit hook: run formatting and clippy checks before allowing commit. +# Install: cp scripts/pre-commit.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit + +set -e + +echo "=== Pre-commit: checking formatting ===" +cargo fmt --all -- --check +if [ $? -ne 0 ]; then + echo "ERROR: cargo fmt check failed. Run 'cargo fmt --all' to fix." + exit 1 +fi + +echo "=== Pre-commit: checking clippy ===" +cargo clippy --workspace -- -D warnings +if [ $? -ne 0 ]; then + echo "ERROR: clippy check failed. Fix warnings before committing." + exit 1 +fi + +echo "=== Pre-commit: all checks passed ==="