From b4b2149a0717c9b5d5ccf278462367804afde6ed Mon Sep 17 00:00:00 2001 From: Martyn Date: Wed, 7 Oct 2026 15:31:03 +0100 Subject: [PATCH] Overhaul implementation Add tests, tightens key permissions, removes unwraps Switch to custom error types, includes breaking API changes Add evidence led assurance, apply review suggestions, switch to balcksmith.sh CI runners Harden key loading and persistence, add Tang-compatible rotated keys Upgrade crypto stack, replace JWK conversions, and raise MSRV to 1.88 Review of in-line docs, clean up readme, add fuzz tests [AI-Assisted] Codex GPT6.1-Sol --- .config/nextest.toml | 6 + .github/dependabot.yml | 14 + .github/workflows/actions.yml | 136 +++- .github/workflows/advisories.yml | 27 + .github/workflows/fuzz.yml | 37 + .gitignore | 5 + CHANGELOG.md | 29 + Cargo.lock | 431 ++++++---- Cargo.toml | 28 +- KEY_STORAGE.md | 103 +++ MIGRATING.md | 110 +++ README.md | 241 ++++++ TESTING.md | 124 +++ assurance/README.md | 97 +++ assurance/assertions.toml | 126 +++ fuzz/Cargo.lock | 633 +++++++++++++++ fuzz/Cargo.toml | 32 + fuzz/README.md | 51 ++ fuzz/fuzz_targets/jwk_conversion.rs | 41 + fuzz/fuzz_targets/recovery_request.rs | 36 + fuzz/seeds/jwk_conversion/ecmr.json | 11 + fuzz/seeds/jwk_conversion/empty-object | 1 + fuzz/seeds/jwk_conversion/es512.json | 12 + fuzz/seeds/jwk_conversion/invalid-d-3.json | 1 + fuzz/seeds/jwk_conversion/invalid-d-88.json | 1 + fuzz/seeds/jwk_conversion/invalid-x-2.json | 1 + fuzz/seeds/jwk_conversion/invalid-y-88.json | 1 + fuzz/seeds/jwk_conversion/malformed | 1 + fuzz/seeds/jwk_conversion/wrong-types | 1 + fuzz/seeds/recovery_request/empty-object | 1 + fuzz/seeds/recovery_request/invalid-alg.json | 1 + fuzz/seeds/recovery_request/invalid-crv.json | 1 + .../recovery_request/invalid-key_ops.json | 1 + fuzz/seeds/recovery_request/invalid-x.json | 1 + fuzz/seeds/recovery_request/invalid-y.json | 1 + fuzz/seeds/recovery_request/malformed | 1 + fuzz/seeds/recovery_request/valid.json | 1 + fuzz/seeds/recovery_request/vector-0.json | 1 + fuzz/seeds/recovery_request/vector-1.json | 1 + fuzz/seeds/recovery_request/wrong-types | 1 + readme.md | 80 -- scripts/check-assurance-local.sh | 39 + scripts/check-assurance.py | 247 ++++++ scripts/check-fixtures.py | 27 + scripts/clevis-rotation.py | 72 ++ scripts/clevis-round-trip.py | 28 + scripts/fuzz-smoke.sh | 23 + scripts/install-interop-tools.sh | 75 ++ scripts/prepare-interop-prefix.py | 87 ++ scripts/run-python-tests.py | 79 ++ scripts/test-check-assurance.py | 191 +++++ scripts/test-prepare-interop-prefix.py | 226 ++++++ src/error.rs | 312 +++++++ src/jwk.rs | 376 +++++++++ src/key_store.rs | 638 +++++++++++++++ src/lib.rs | 759 +----------------- src/protocol.rs | 253 ++++++ src/support.rs | 46 ++ src/tests.rs | 83 ++ tests/clevis.rs | 233 ++++++ tests/common/mod.rs | 48 ++ tests/errors.rs | 382 +++++++++ tests/fixtures/README.md | 32 + tests/fixtures/ecmr.json | 11 + tests/fixtures/es512-second.json | 12 + tests/fixtures/es512.json | 12 + tests/fixtures/recovery.json | 46 ++ tests/fixtures/thumbprints.json | 5 + tests/keys.rs | 280 +++++++ tests/persistence.rs | 369 +++++++++ tests/protocol.rs | 267 ++++++ tests/rotation.rs | 317 ++++++++ 72 files changed, 7005 insertions(+), 998 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/advisories.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 CHANGELOG.md create mode 100644 KEY_STORAGE.md create mode 100644 MIGRATING.md create mode 100644 README.md create mode 100644 TESTING.md create mode 100644 assurance/README.md create mode 100644 assurance/assertions.toml create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz_targets/jwk_conversion.rs create mode 100644 fuzz/fuzz_targets/recovery_request.rs create mode 100644 fuzz/seeds/jwk_conversion/ecmr.json create mode 100644 fuzz/seeds/jwk_conversion/empty-object create mode 100644 fuzz/seeds/jwk_conversion/es512.json create mode 100644 fuzz/seeds/jwk_conversion/invalid-d-3.json create mode 100644 fuzz/seeds/jwk_conversion/invalid-d-88.json create mode 100644 fuzz/seeds/jwk_conversion/invalid-x-2.json create mode 100644 fuzz/seeds/jwk_conversion/invalid-y-88.json create mode 100644 fuzz/seeds/jwk_conversion/malformed create mode 100644 fuzz/seeds/jwk_conversion/wrong-types create mode 100644 fuzz/seeds/recovery_request/empty-object create mode 100644 fuzz/seeds/recovery_request/invalid-alg.json create mode 100644 fuzz/seeds/recovery_request/invalid-crv.json create mode 100644 fuzz/seeds/recovery_request/invalid-key_ops.json create mode 100644 fuzz/seeds/recovery_request/invalid-x.json create mode 100644 fuzz/seeds/recovery_request/invalid-y.json create mode 100644 fuzz/seeds/recovery_request/malformed create mode 100644 fuzz/seeds/recovery_request/valid.json create mode 100644 fuzz/seeds/recovery_request/vector-0.json create mode 100644 fuzz/seeds/recovery_request/vector-1.json create mode 100644 fuzz/seeds/recovery_request/wrong-types delete mode 100644 readme.md create mode 100644 scripts/check-assurance-local.sh create mode 100644 scripts/check-assurance.py create mode 100644 scripts/check-fixtures.py create mode 100644 scripts/clevis-rotation.py create mode 100644 scripts/clevis-round-trip.py create mode 100644 scripts/fuzz-smoke.sh create mode 100755 scripts/install-interop-tools.sh create mode 100644 scripts/prepare-interop-prefix.py create mode 100644 scripts/run-python-tests.py create mode 100644 scripts/test-check-assurance.py create mode 100644 scripts/test-prepare-interop-prefix.py create mode 100644 src/error.rs create mode 100644 src/jwk.rs create mode 100644 src/key_store.rs create mode 100644 src/protocol.rs create mode 100644 src/support.rs create mode 100644 src/tests.rs create mode 100644 tests/clevis.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/errors.rs create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/ecmr.json create mode 100644 tests/fixtures/es512-second.json create mode 100644 tests/fixtures/es512.json create mode 100644 tests/fixtures/recovery.json create mode 100644 tests/fixtures/thumbprints.json create mode 100644 tests/keys.rs create mode 100644 tests/persistence.rs create mode 100644 tests/protocol.rs create mode 100644 tests/rotation.rs diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..05fcf3a --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,6 @@ +# JUnit reports from this profile are the evidence for assurance/assertions.toml. +[profile.ci] +fail-fast = false + +[profile.ci.junit] +path = "junit.xml" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7f56320 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: cargo + directories: + - / + - /fuzz + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 16dc033..3ac50af 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -1,35 +1,76 @@ name: Build and Run -on: - push +on: [push, pull_request] + +permissions: + contents: read jobs: rust-stable: - runs-on: ubuntu-latest + runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: taiki-e/install-action@nextest - name: Build & Test run: | - cargo build --release - cargo test + cargo fmt --check + python3 scripts/test-check-assurance.py + python3 scripts/run-python-tests.py scripts/test-prepare-interop-prefix.py --junit target/installer-junit/junit.xml + cargo check --locked --all-targets --all-features + cargo clippy --locked --all-targets --all-features -- -D warnings + cargo build --locked --release + cargo test --locked --doc + cargo nextest run --locked --profile ci + + - name: Check API documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --locked --no-deps + + - name: Upload test evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: junit-linux + path: target/nextest/ci/junit.xml + if-no-files-found: error + + - name: Upload installer test evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: junit-installer + path: target/installer-junit/junit.xml + if-no-files-found: error rust-stable-windows: - runs-on: windows-latest - + runs-on: blacksmith-2vcpu-windows-2025 steps: - - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - - name: Build & Test - run: | - cargo build --release - cargo test - + - uses: taiki-e/install-action@nextest + # Separate native-command steps propagate failures under PowerShell too. + - name: Check all targets + run: cargo check --locked --all-targets --all-features + - name: Build release + run: cargo build --locked --release + - name: Test documentation + run: cargo test --locked --doc + - name: Test Windows behavior + run: cargo nextest run --locked --profile ci + - name: Upload test evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: junit-windows + path: target/nextest/ci/junit.xml + if-no-files-found: error rust-msrv: runs-on: ubuntu-latest @@ -37,9 +78,70 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.1 + - uses: dtolnay/rust-toolchain@1.88.0 - name: Build & Test run: | - cargo build --release - cargo test + cargo check --locked --all-targets --all-features + cargo build --locked --release + cargo test --locked + + clevis-interop: + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@nextest + - name: Install interoperability tools + run: | + sudo apt-get update + sudo apt-get install -y clevis jose python3 curl + - name: Check independent fixtures and Clevis recovery + run: | + python3 scripts/check-fixtures.py + cargo nextest run --locked --profile ci --features interop --test clevis + - name: Upload test evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: junit-interop + path: target/nextest/ci/junit.xml + if-no-files-found: error + + # Every assertion in assurance/assertions.toml must be backed by passing tests. + assurance: + needs: [rust-stable, rust-stable-windows, clevis-interop] + if: ${{ !cancelled() }} + runs-on: blacksmith-2vcpu-ubuntu-2404 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: junit-* + path: ${{ runner.temp }}/junit + - name: Check assertions against test results + run: | + python3 scripts/check-assurance.py \ + --junit linux=${{ runner.temp }}/junit/junit-linux/junit.xml \ + --junit interop=${{ runner.temp }}/junit/junit-interop/junit.xml \ + --junit installer=${{ runner.temp }}/junit/junit-installer/junit.xml \ + --junit windows=${{ runner.temp }}/junit/junit-windows/junit.xml \ + --out ${{ runner.temp }}/evidence.json + - name: Upload evidence record + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: assurance-evidence + path: | + ${{ runner.temp }}/evidence.json + ${{ runner.temp }}/junit + - name: Attest evidence record + if: github.event_name == 'push' + uses: actions/attest-build-provenance@v2 + with: + subject-path: ${{ runner.temp }}/evidence.json diff --git a/.github/workflows/advisories.yml b/.github/workflows/advisories.yml new file mode 100644 index 0000000..e3d5bbb --- /dev/null +++ b/.github/workflows/advisories.yml @@ -0,0 +1,27 @@ +name: Dependency advisories + +on: + push: + pull_request: + schedule: + - cron: '23 6 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + # Each call fetches RustSec's current database; fail on warnings as well. + - name: Audit library dependencies + run: cargo audit --deny warnings + - name: Audit fuzz dependencies + run: cargo audit --file fuzz/Cargo.lock --deny warnings diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..ee0fda2 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,37 @@ +name: Bounded fuzzing + +on: + push: + pull_request: + schedule: + - cron: '41 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + fuzz: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + with: + components: rustfmt + - uses: taiki-e/install-action@v2 + with: + tool: cargo-fuzz + - name: Check fuzz formatting + run: cargo +nightly fmt --manifest-path fuzz/Cargo.toml --check + - name: Run both seeded targets within resource bounds + run: bash scripts/fuzz-smoke.sh + - name: Upload crash inputs and corpora + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-failures + path: | + fuzz/artifacts + fuzz/corpus + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 97d5c42..7e042f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ /target +/fuzz/target +/fuzz/corpus +/fuzz/artifacts +/fuzz/coverage *.jwk +/.roadmap.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ffae44 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## [1.0.0] + +### Added + +- Support for active and rotated Tang keys through `KeySource::KeySets`. +- Migration, key storage and test guides. +- Linux, Windows and Clevis tests, fuzz tests and automated assurance checks. + +### Changed + +- **Breaking:** Use typed errors and fallible key generation and conversion. + See [MIGRATING.md](MIGRATING.md) for API changes. +- Require Rust 1.88 and update cryptographic dependencies. +- Validate key material, key roles and permitted operations at initialization. +- Write complete key files before publication. Restrict Unix key permissions + and inherit Windows directory permissions. +- Hide private key values in debug output and error messages. +- Use Blacksmith CI runners and add dependency security checks. + +### Fixed + +- Return errors for invalid input and key storage failures instead of panicking + or ignoring failures. +- Prevent automatic key generation for invalid or incomplete key stores. +- Use unpadded Base64URL in advertisements and retain all active key signatures + when a signing key is selected. +- Advertise key operations and recovery key metadata compatible with Clevis. diff --git a/Cargo.lock b/Cargo.lock index da27089..9a4f31f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,61 +2,83 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "const-oid" -version = "0.9.6" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" dependencies = [ - "generic-array", + "cpubits", + "ctutils", + "getrandom", + "hybrid-array", + "num-traits", "rand_core", "subtle", "zeroize", @@ -64,42 +86,52 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "getrandom", + "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctutils" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +dependencies = [ + "cmov", + "subtle", ] [[package]] name = "der" -version = "0.7.10" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ "const-oid", - "pem-rfc7468", "zeroize", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", "const-oid", "crypto-common", - "subtle", + "ctutils", ] [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der", "digest", @@ -107,28 +139,25 @@ dependencies = [ "rfc6979", "signature", "spki", + "zeroize", ] [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" dependencies = [ "base16ct", - "base64ct", "crypto-bigint", + "crypto-common", "digest", "ff", - "generic-array", "group", - "hkdf", - "pem-rfc7468", + "hybrid-array", "pkcs8", "rand_core", "sec1", - "serde_json", - "serdect", "subtle", "zeroize", ] @@ -145,59 +174,37 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ "rand_core", "subtle", ] -[[package]] -name = "generic-array" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", + "rand_core", ] [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", "rand_core", @@ -205,143 +212,179 @@ dependencies = [ ] [[package]] -name = "hkdf" -version = "0.12.4" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "hmac", + "digest", ] [[package]] -name = "hmac" -version = "0.12.1" +name = "hybrid-array" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "digest", + "subtle", + "typenum", + "zeroize", ] [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "p521" -version = "0.13.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" dependencies = [ "base16ct", "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "rand_core", "sha2", ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "pkcs8" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "base64ct", + "der", + "spki", ] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "der", - "spki", + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", ] [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand_core" -version = "0.6.4" +name = "rand_chacha" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" dependencies = [ - "getrandom 0.2.16", + "ppv-lite86", + "rand_core", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ + "crypto-bigint", "hmac", - "subtle", ] [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", @@ -352,24 +395,23 @@ dependencies = [ [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", + "ctutils", "der", - "generic-array", - "pkcs8", - "serdect", + "hybrid-array", "subtle", "zeroize", ] [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -377,29 +419,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -410,9 +452,9 @@ dependencies = [ [[package]] name = "serdect" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct", "serde", @@ -420,9 +462,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", @@ -431,9 +473,9 @@ dependencies = [ [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ "digest", "rand_core", @@ -441,9 +483,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der", @@ -457,9 +499,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.112" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -468,62 +521,65 @@ dependencies = [ [[package]] name = "tangy-lib" -version = "0.1.11" +version = "1.0.0" dependencies = [ "base64ct", - "elliptic-curve", + "getrandom", + "libc", "p521", + "rand_chacha", "serde", "serde_json", "sha2", "tempfile", + "thiserror", + "zeroize", ] [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom", "once_cell", "rustix", "windows-sys", ] [[package]] -name = "typenum" -version = "1.19.0" +name = "thiserror" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] [[package]] -name = "version_check" -version = "0.9.5" +name = "thiserror-impl" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" +name = "unicode-ident" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "windows-link" @@ -541,19 +597,48 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.46.0" +name = "wnaf" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "serde", +] [[package]] name = "zmij" -version = "1.0.7" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de9211a9f64b825911bdf0240f58b7a8dac217fe260fc61f080a07f61372fbd5" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 402be2b..ed4e284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,37 @@ [package] name = "tangy-lib" description = "Tang protocol library implementation" -version = "0.1.11" +version = "1.0.0" edition = "2021" +rust-version = "1.88" license = "MIT OR Apache-2.0" authors = ["Martyn P "] repository = "https://github.com/martynp/tangy-lib" -readme = "readme.md" +readme = "README.md" + +[features] +# Opt-in tests requiring Clevis, JOSE, Python 3, and loopback networking. +interop = [] + +[[test]] +name = "clevis" +required-features = ["interop"] [dependencies] -p521 = { version = ">=0.13.3", features = ["ecdh", "ecdsa", "jwk"] } -elliptic-curve = { version = "0.13.8", features = ["jwk"] } +p521 = { version = "0.14", default-features = false, features = ["ecdsa", "std", "precomputed-tables"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -base64ct = "1.8" -sha2 = "0.10" +base64ct = { version = "1.8", features = ["alloc"] } +# Hashing uses fixed-size output; allocation and algorithm identifiers are unused. +sha2 = { version = "0.11", default-features = false } +thiserror = "2" +rand_chacha = "0.10" +getrandom = { version = "0.4", features = ["std"] } +zeroize = { version = "1.8", features = ["serde"] } [dev-dependencies] tempfile = "3" + +[target.'cfg(unix)'.dev-dependencies] +libc = "0.2" diff --git a/KEY_STORAGE.md b/KEY_STORAGE.md new file mode 100644 index 0000000..5b70112 --- /dev/null +++ b/KEY_STORAGE.md @@ -0,0 +1,103 @@ +# Key storage and rotation + +The directory key source reads private JSON Web Key (JWK) files from an +existing directory. See [loading keys](README.md#loading-keys) for API calls. + +## Initialize a store + +A store with no `.jwk` entries receives a new key set. +Start only one process on an empty store. If multiple processes start at the +same time, each can generate a different key set. + +Before it writes files, the library generates and validates both keys. +It also builds the default advertisement. For each key, the library: + +1. Writes a `.tangy-.tmp` file. +2. Flushes the file contents. +3. Renames the file to `.jwk`. +4. Synchronizes the directory on Unix. + +Initialization succeeds only after both keys are saved. +If a step fails, the library tries to remove files created by that call. +It returns the original error. File removal can also fail. + +After a crash, each key file is complete or absent. +The library ignores remaining `.tmp` files. + +- A crash before the first rename leaves no key files. The next start generates + a new key set. +- A crash between renames leaves one key file. Initialization returns + `MissingKeys` until you remove that file or restore a complete pair. + +The library reads complete stores without writing to them. +These stores do not require directory write access. + +## Set file permissions + +On Unix, the library creates files with mode `0440`. +It also sets mode `0440` through the open file handle before writing. +The final mode is independent of the process umask. +Owner and group read access is intentional. The library does not change +the process umask. + +On Windows, generated files inherit directory access control lists (ACLs). +Set the directory permissions before initialization. +Windows flushes file contents. It gives less protection against lost file names +after a crash than Unix directory synchronization. + +## Use symbolic links + +The library follows symbolic links for the store directory and key files. +This permits Kubernetes Secret volumes and other directories that use links. +Each key entry must refer to a regular file. +A broken link returns `KeyRead`. A link to a directory or named pipe returns +`NotAFile`. + +Control the link targets and coordinate manual file changes with the service. + +## Rotate keys manually + +The library has no public rotation helper. +Use this procedure for Tang-compatible rotation: + +1. Stop the service and all processes that can initialize the store. +2. Make a secure backup of the store. +3. Generate and install a new active signing and recovery key pair. + Use new `.jwk` file names. Keep the required Unix permissions or Windows ACLs. +4. Add `.` to the start of each previous active file name. + This marks those keys as rotated. Keep them while clients need them. + Do not keep copies in both sets. Duplicate keys cause initialization to fail. +5. Validate the directory with `TangyLib::init(KeySource::LocalDir(...))`. +6. Restart the service after validation succeeds. + +A store with only rotated keys returns an error. +Old recovery keys remain available for existing bindings. +The `/adv/` response contains the active key set and an +additional signature from the old key. Clients with the old public signing key +can verify this signature. + +For new enrollment, stock Clevis requires `thp` to match a signing key in the +advertisement payload. A rotated key thumbprint fails this check, even if its +signature is valid. After rotation, set the enrollment configuration to the +current active signing key thumbprint. + +This behavior follows Tang's active key advertisements and the Clevis trust +check. See [Tang key handling](https://github.com/latchset/tang/blob/master/src/keys.c) +and the [Clevis trust check](https://github.com/latchset/clevis/blob/master/src/pins/tang/clevis-encrypt-tang). + +## Protect private values + +JWK `Debug` output hides `d`. Library debug output also hides typed private keys. +Public key conversion copies only public fields. + +The library uses zeroizing wrappers to clear these temporary secret values: + +- Private JSON and parsed scalar strings. +- Decoded scalar buffers and scalar encoding buffers. +- Random number seeds. + +Public field types and the `Vec` key generation result remain unchanged. +You must manage strings and copies that your code owns. +The library does not guarantee removal of secret values from cryptographic +internals, allocator memory or serialization temporaries. +It does not guarantee removal of all secret values from memory. diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..6042d2d --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,110 @@ +# Migrate from 0.1 to 1.0.0 + +Version 1.0.0 changes the API, key validation and key storage. +Install Rust **1.88.0** or later before you update the library. + +## Update API calls + +| API in 0.1 | Required change in 1.0.0 | +| --- | --- | +| `TangyLib::init`, `adv`, `rec` | Handle `tangy_lib::Result`. Use `?` or match `Error`. | +| `create_new_key_set`, `MyJwkEcKey::thumbprint` | Handle the returned `Result`. | +| `MyJwkEcKey::to_jwk_ec_key(bool)` | Use `to_p521_public_key()` or `to_p521_secret_key()`. | +| `TangyLib::adv_internal` | Use `adv(None)` or `adv(Some(kid))`. | +| `KeySource::Vector(&Vec<&str>)` | Supply a slice (`&[&str]`). Rust also accepts a reference to an existing vector. | +| Active and rotated input keys | Use `KeySource::KeySets { active, rotated }`. | + +The conversion methods return P-521 0.14 key types. Use the same dependency +version series if your code uses these types directly. + +Public key conversion ignores `d`. Private key conversion requires a valid +private scalar that matches the public point. `to_public_key()` still returns +public JWK fields without validation or changes to metadata. +The `MyJwkEcKey` name and its public fields remain available. + +See the [README examples](README.md#usage) for key loading and protocol calls. + +## Handle errors + +Include a fallback arm when you match the non-exhaustive `Error` enum. +Match error variants and these enums: `Algorithm`, `KeyOperation`, +`RandomPurpose` and `WriteStep`. Do not match error message text. + +- `InvalidRequest` identifies invalid client input. +- `InvalidStoredKey` identifies an invalid server key. Match its `Box` + source to find the cause. Its location gives a path or an input index. + Input indices start at zero and identify the active or rotated set. +- I/O errors retain the path and cause. +- Signing and random number errors contain boxed source errors. + +Use `source()` to read the error chain. Each message gives its own context +without repeating the cause. `JsonError` gives the category, line and column. +It excludes raw Serde messages that can contain private input. +Validation reasons and serialization contexts are diagnostic text and can change. + +## Check keys and requests + +Stored keys must have: + +- `kty: "EC"` and `crv: "P-521"`. +- Valid public coordinates and a matching private scalar. +- Unpadded Base64URL fields that each decode to exactly 66 bytes. +- `alg: "ES512"` with `sign` and `verify` for signing keys. +- `alg: "ECMR"` with `deriveKey` for recovery keys. + +Initialization rejects missing or unsupported key roles and duplicate keys. +This includes identical keys in the active and rotated sets. + +Stored keys can have extra operations. Advertisements include only `verify` +for signing keys and `deriveKey` for recovery keys. Advertised recovery keys +omit `use` to prevent incorrect signature requirements in JOSE and Clevis. + +Recovery requests must contain a valid P-521 public point. +Requests can omit `alg` and `key_ops`. If supplied, these fields must permit +ECMR and `deriveKey`. Invalid JSON, points or operations return errors. +Responses contain only public key material. JWS fields use unpadded Base64URL. + +## Prepare key storage + +Supply an existing directory to `LocalDir`. The library reads entries with +names that end in `.jwk`. A name that starts with `.` identifies a rotated key. +The library ignores other files. Directory, read and JSON errors return errors +to the caller. + +The library generates keys only if the directory has no active or rotated key +entries. Invalid, incomplete or rotated-only stores return errors without +replacing keys. Slice inputs do not generate or save keys. + +Initialization must save both generated keys before it can succeed. +The library writes each key to a temporary file, then renames that file. +The two renames are separate operations. A crash between them leaves an +incomplete store that you must repair. Do not start multiple instances at +the same time on an empty store. + +Directory and key paths can use symbolic links. Each key entry must refer to +a regular file. Read the [storage guarantees](KEY_STORAGE.md) before deployment. + +Unix key files have mode `0440`, which permits owner and group read access. +On Windows, key files inherit directory access control lists (ACLs). +Set the directory permissions before initialization. +Windows gives less protection against lost file names after a crash than +Unix directory synchronization. CI tests cover Linux and Windows. +Other Unix platforms are not tested. + +## Rotate keys + +The library advertises only active keys. By default, only active keys sign +advertisements. Use `adv(Some(old_signer_id))` to add a rotated key signature +to the active signatures. Recovery accepts active and rotated ECMR keys. + +For new enrollment, stock Clevis requires a signing key thumbprint from the +current advertisement. A rotated key thumbprint fails this check, even if its +signature is valid. Follow the manual rotation procedure in the README. +Keep old recovery keys while clients still need them. + +## Protect private values + +Debug output and error messages hide private key values. +The library clears its temporary secret values with zeroizing wrappers. +You must manage strings and copies that your code owns. +The API does not guarantee removal of all secret values from memory. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bee802e --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +# Tangy Lib + +## Description + +Tangy-lib implements the Tang server protocol in Rust. +Your application supplies the HTTP server. + +Clients use Tang to recover encrypted secrets when the server is available. +For example, Clevis can unlock a LUKS device when it can reach the Tang server. + +See the [Tang project](https://github.com/latchset/tang) for more information. +Fraser Tweedale explains Clevis and Tang in this 2020 Linux Conference Australia talk: + +[![Clevis and Tang: securing your secrets at rest](https://img.youtube.com/vi/Dk6ZuydQt9I/0.jpg)](https://www.youtube.com/watch?v=Dk6ZuydQt9I) + +## How the Tang protocol works + +Tang has two stages: bind a secret to a server, then recover the secret. +The client encrypts and decrypts the secret locally. +Tang publishes public keys and calculates a response from a blinded public point. +Key messages use JSON Web Keys (JWKs), a JSON format for keys. + +### Bind a secret + +The client verifies the advertisement signatures and establishes trust in a +signing key. It uses a configured thumbprint or an explicit decision to trust +the key on first use. The client selects a recovery key and encrypts the secret. +See [Clevis enrollment](https://github.com/latchset/clevis/blob/master/src/pins/tang/clevis-encrypt-tang). + +The diagrams use these symbols: + +| Symbol | Meaning | +| --- | --- | +| `G` | Curve base point. | +| `s` | Server private recovery scalar. | +| `S = s*G` | Server public recovery point. | +| `c` | Client temporary private scalar. | +| `C = c*G` | Client public point. | +| `Z = c*S` | Shared point used to derive the encryption key. | + +```mermaid +sequenceDiagram + autonumber + participant Client as Client (for example, Clevis) + participant Tang as Tang server + participant Storage as Client storage + + Client->>Tang: GET /adv or /adv/{signing-kid} + Tang-->>Client: Signed advertisement of active public keys + Client->>Client: Verify signatures and establish signing-key trust + Client->>Client: Select recovery public key S and its thumbprint + Client->>Client: Generate temporary c and public point C = c*G + Client->>Client: Derive encryption key from Z = c*S + Client->>Client: Encrypt secret locally + Client->>Storage: Save ciphertext and public binding metadata + Client->>Client: Discard c, Z and the derived encryption key + Note over Client,Tang: The secret and encryption key stay on the client +``` + +The client saves `C`, the server public recovery key `S`, the recovery key +thumbprint and the server URL. Tang keeps its own keys. +It does not need a separate record for each client. +The client discards `c` and the derived key. Subsequent recovery requires +access to Tang. + +### Recover a secret + +For each recovery, the client generates a new random scalar `b` and point +`B = b*G`. It adds `B` to the saved point `C` before it contacts Tang. +See [Clevis recovery](https://github.com/latchset/clevis/blob/master/src/pins/tang/clevis-decrypt-tang). + +```mermaid +sequenceDiagram + autonumber + participant Client as Client (for example, Clevis) + participant Tang as Tang server + participant Storage as Client storage + + Client->>Storage: Read ciphertext and public binding metadata + Storage-->>Client: Ciphertext, C, S, recovery-key thumbprint and URL + Client->>Client: Generate fresh blinder b and point B = b*G + Client->>Client: Blind the saved point as Q = C + B + Client->>Tang: POST /rec/{recovery-kid} with public JWK Q + Tang->>Tang: Validate Q and select private recovery scalar s + Tang->>Tang: Compute response point R = s*Q + Tang-->>Client: Public JWK containing the full point R + Client->>Client: Remove blinding as Z = R - b*S + Client->>Client: Derive the same encryption key from Z + Client->>Client: Decrypt secret locally + Client->>Client: Discard b, Z and the derived encryption key +``` + +The calculation removes the blinding: +`s*(C + b*G) - b*S = s*C = c*S`. +This gives the original shared point. +Tang receives `Q` and returns `R`. The client uses its private scalar `b` +to recover `Z`. The server keeps no state for the request. +See the [Tang protocol description](https://github.com/latchset/tang#binding). + +Keep rotated recovery keys available while clients need them. +Existing bindings use their saved recovery key thumbprints. + +### Use the library for HTTP requests + +Your application supplies the HTTP endpoints. +Send the JSON strings returned by these methods: + +| HTTP request | Library call | +| --- | --- | +| `GET /adv` | `tangy.adv(None)` | +| `GET /adv/{signing-kid}` | `tangy.adv(Some(signing_kid))` | +| `POST /rec/{recovery-kid}` with a public JWK body | `tangy.rec(recovery_kid, request_body)` | + +## Installation + +Add the library with Cargo: + +``` bash +cargo add tangy-lib@1.0.0 +``` + +Or add it to the dependencies in `Cargo.toml`: + +``` toml +[dependencies] +tangy-lib = "1.0.0" +``` + +Use Rust **1.88.0** or later. CI tests cover Linux and Windows. + +## Usage + +Load keys once. Use the returned `TangyLib` for subsequent protocol requests. +Every active key set needs a private signing key and a private recovery key. + +### Loading keys + +**From a directory:** supply an existing directory with `.jwk` files. +The library generates and saves a new pair if the store has no key entries. +File names that start with `.` identify rotated keys. + +```rust,no_run +use std::path::Path; +use tangy_lib::{KeySource, TangyLib}; + +let tangy = TangyLib::init(KeySource::LocalDir(Path::new("/var/lib/tang")))?; +# Ok::<(), tangy_lib::Error>(()) +``` + +**From JWK strings:** supply a slice of private key strings. +Use existing keys or generate a pair as shown below. +This source does not write files. + +```rust +use tangy_lib::{create_new_key_set, KeySource, TangyLib}; + +let keys = create_new_key_set()?; +let inputs: Vec<&str> = keys.iter().map(String::as_str).collect(); +let tangy = TangyLib::init(KeySource::Vector(&inputs))?; +# Ok::<(), tangy_lib::Error>(()) +``` + +**With rotated keys:** supply separate active and rotated key slices. +Rotated keys remain available for recovery. Advertisements omit these keys. + +```rust +use tangy_lib::{KeySource, Result, TangyLib}; + +fn load_keys(active: &[&str], rotated: &[&str]) -> Result { + TangyLib::init(KeySource::KeySets { active, rotated }) +} +``` + +### Using the loaded library + +Get an advertisement or answer a recovery request: + +```rust +# fn responses(tangy: &tangy_lib::TangyLib, recovery_kid: &str, request_body: &str) -> tangy_lib::Result<()> { +let advertisement = tangy.adv(None)?; +let recovery = tangy.rec(recovery_kid, request_body)?; +# Ok(()) +# } +``` + +Both calls return JSON strings. Send them through your HTTP handler. +`recovery_kid` is the server recovery key thumbprint. +`request_body` is the client public JWK in JSON format. +Use `adv(Some(signing_kid))` to select a signing key. +The advertisement retains all active key signatures. +Errors return as `tangy_lib::Error`. The examples use `?` to return them to the caller. + +See [key storage and rotation](#key-storage-and-rotation) for storage requirements. +See [MIGRATING.md](MIGRATING.md) for validation rules and API changes in 1.0.0. + +## Key storage and rotation + +Use an existing directory. Keep a backup of its keys. +The library generates and saves a pair only if the store has no key entries. +Invalid or incomplete stores return errors. +Start only one process on an empty store. + +Unix files use mode `0440`, which permits owner and group read access. +Windows files inherit directory access control lists (ACLs). +Set the directory permissions before initialization. + +To rotate keys: + +1. Stop the service and all processes that can initialize the store. +2. Make a secure backup of the key directory. +3. Install a new signing and recovery key pair with the required permissions. +4. Add `.` to the start of the old `.jwk` file names to mark them as rotated. +5. Validate the store with `TangyLib::init`. +6. Restart the service after validation succeeds. + +Keep rotated recovery keys while clients need them. +Advertisements omit rotated keys. A selected rotated signing key can add a +signature to the advertisement. For new Clevis bindings, use the current +active signing key thumbprint. + +See the [key storage guide][key-storage-guide] for the full procedure, +file permissions and limits on recovery after a crash. + +## Testing + +See [TESTING.md](TESTING.md) for Rust checks, Clevis interoperability, platform +coverage, dependency audits and fuzzing. + +## Credits + +The original authors of [Tang](https://github.com/latchset/tang) are [Latchset](https://github.com/latchset/). Tang is based on the [protocol described by Nathaniel McCallum and Robert Relyea](https://marc.info/?m=144173814525805). + +## License + +Choose the Apache-2.0 or MIT license. + +## How to Contribute + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed (Apache-2.0 and MIT), without any additional terms or conditions. + +[key-storage-guide]: KEY_STORAGE.md diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..ea9aadf --- /dev/null +++ b/TESTING.md @@ -0,0 +1,124 @@ +# Testing and release checks + +## Rust checks + +Run these commands from the repository root to execute the Rust tests, check all build targets, and check formatting: + +```sh +cargo test --locked +cargo check --locked --all-targets --all-features +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo fmt --check +RUSTDOCFLAGS="-D warnings" cargo doc --locked --no-deps +python3 scripts/test-check-assurance.py +python3 scripts/test-prepare-interop-prefix.py +``` + +To include installer safety results in assurance evidence, run +`python3 scripts/run-python-tests.py scripts/test-prepare-interop-prefix.py --junit target/installer-junit/junit.xml`. +CI and the local assurance script collect this report automatically. + +The README is included in the crate documentation, so its Rust examples run as +doctests with `cargo test --locked --doc`. + +The Rust tests check advertisement signatures, secret recovery, input validation, +and key storage, including errors while reading or writing files and reuse of +saved keys after a restart. They use committed test keys and expected responses, +so no external services or command-line cryptography tools are required. +See [tests/fixtures/README.md](tests/fixtures/README.md) for how those test values +were produced and how to reproduce them. + +## Clevis interoperability + +To check compatibility with a real Tang client on Linux, install the `clevis`, +`jose`, `python3`, and `curl` command-line tools (available as packages with those +names on Debian and Ubuntu). Clevis is the client that encrypts and recovers a +secret; JOSE provides an independent implementation of the key operations. +Then run: + +```sh +python3 scripts/check-fixtures.py +cargo test --locked --features interop --test clevis +``` + +The Python script compares the committed test values with results from JOSE. +The Cargo command enables the optional `interop` test, which starts a temporary +HTTP server on the loopback interface using this library and asks Clevis to +encrypt and decrypt a secret through it. It exercises advertisements both with +and without a requested signing-key thumbprint. No separately running Tang server +is needed. The test fails if a required tool is missing or the client fails. + +### Fedora without system installation + +On Fedora, you can use [scripts/install-interop-tools.sh](scripts/install-interop-tools.sh) +to download and extract JOSE and Clevis under `/tmp` without installing system +packages or using `sudo`. It requires DNF with its `download` command, RPM, +`rpm2cpio`, `cpio`, Bash, coreutils (including `timeout`), curl, jq, and Python 3. +The first run needs network access. Run from the repository root: + +```sh +bash scripts/install-interop-tools.sh +source "/tmp/tangy-lib-interop-$UID/env.sh" +python3 scripts/check-fixtures.py +cargo test --locked --features interop --test clevis +``` + +The default directory is `/tmp/tangy-lib-interop-$UID`; it holds package downloads, +repository caches, extracted tools, and `env.sh`. Sourcing `env.sh` puts the local +commands and shared libraries on `PATH` and `LD_LIBRARY_PATH` for the current +shell. Source it again in each new shell before running the tests. If `/tmp` has +been cleared, rerun the installer. You can choose another writable directory: + +```sh +bash scripts/install-interop-tools.sh /tmp/tangy-interop +source /tmp/tangy-interop/env.sh +``` + +The installer creates a private prefix and checks existing ownership and write +permissions before using it. Prefix and parent-directory symlinks, unsafe shared +parents, writable contents and package links escaping the prefix are rejected. +Sticky shared parents such as `/tmp` are supported. An unsafe existing prefix +must be replaced with a new, trusted directory; the installer does not repair it. +Each download attempt uses a fresh staging directory, removed on exit, so cached +RPMs and partial downloads from a failed attempt cannot overwrite current tools. + +The extracted tools support the Tang tests; this setup is not a system Clevis +installation for configuring encrypted disks. Other Linux distributions can use +their normal package manager as described above. + +## Platform coverage + +The minimum supported Rust version is **1.88.0**, declared as `rust-version = "1.88"` +in `Cargo.toml`. CI runs the Rust build and tests on Linux +and Windows, checks Rust 1.88.0 on Linux, and runs the Clevis compatibility tests +in a separate Linux job. + +Other Unix platforms have no CI coverage. See [key storage and rotation](KEY_STORAGE.md) +for platform permission and crash-durability differences. + +## Release checks and fuzzing + +CI requires formatting, strict Clippy, compilation, release builds, tests and +Rust 1.88.0 checks, retaining Linux, Windows and Clevis jobs. The assurance job +checks the combined platform reports; a local Linux run alone cannot establish +Windows behavior. Repository maintainers must select these jobs as required +status checks in GitHub branch protection to enforce them before merging. + +Dependency advisories are checked on pushes, pull requests and a daily schedule +with a freshly fetched RustSec database. Both the library and fuzz lockfiles are +scanned, and warnings fail the check. Dependabot monitors Cargo dependencies +(including fuzz tooling) and GitHub Actions weekly. To run the advisory checks: + +```sh +cargo install cargo-audit --locked +cargo audit --deny warnings +cargo audit --file fuzz/Cargo.lock --deny warnings +``` + +Two bounded, seeded fuzz targets exercise recovery request parsing and JWK +conversion on pull requests, pushes and weekly runs. They run on nightly Rust +with AddressSanitizer, separately from the library's stable/MSRV builds. See +[fuzz/README.md](fuzz/README.md) for commands, resource bounds, seed provenance +and crash reproduction. A bounded run is a regression check, not exhaustive +proof of input safety. + diff --git a/assurance/README.md b/assurance/README.md new file mode 100644 index 0000000..071fa95 --- /dev/null +++ b/assurance/README.md @@ -0,0 +1,97 @@ +# Assurance + +This directory records the assertions tangy-lib makes about itself and how CI +proves them, following the evidence-led model of signals, evidence, assertions +and decisions. + +- `assertions.toml` lists each assertion with an ID, a statement, and the + minimum number of passing tests (`min_evidence`) needed to support it. + Optional `required_reports = ["linux", "windows"]` requires that minimum + independently within each named report; evidence cannot be pooled to meet + a platform requirement. +- Tests claim the assertions they support with a doc comment directly above + `#[test]`: + + ```rust + /// Assures: KEY-LOAD-001, KEY-STORE-001 + #[test] + fn invalid_existing_stores_fail_without_changing_files() { ... } + ``` + +- Python unittest methods in `scripts/test-*.py` use `# Assures: ID` directly + above the method or its decorators. The installer tests produce JUnit with + `scripts/run-python-tests.py`. +- CI collects Rust nextest reports and the Python installer report. + `scripts/check-assurance.py` joins the tags to those reports. + +## What the check enforces + +The `assurance` CI job fails when: + +- an assertion has fewer passing tagged tests than `min_evidence`; +- a required report is missing or does not independently meet the minimum; +- a tagged test failed, was skipped, or is missing from every report; +- a tag names an assertion that is not in the registry; or +- a tag is not attached to a Rust `#[test]` function or Python unittest method. + +Only results from the reports count, so a tag on a test that did not run is +not evidence. The job prints tests that support no assertion, without failing. + +## Evidence record + +The job writes `evidence.json` with the commit, the `Cargo.lock` hash, each +assertion's supporting tests and their per-report outcomes. It uploads the +record together with the JUnit reports as the `assurance-evidence` artifact. +On pushes it also creates a signed GitHub artifact attestation, which binds +the record to the commit and workflow that produced it. Verify a downloaded +record with: + +```sh +gh attestation verify evidence.json --repo rustfoo/tangy-lib +``` + +## Running locally + +```sh +bash scripts/check-assurance-local.sh /path/to/windows-junit.xml +``` + +This runs Linux tests, installer safety tests, fixture verification and both real Clevis tests, then +checks them alongside an authentic Windows nextest JUnit report from the same +revision. Download `junit-windows` from CI or produce it on Windows with +`cargo nextest run --locked --profile ci`. It writes `target/evidence.json` and +needs `cargo-nextest`, `clevis`, `jose` and Python 3.11 or later. Run as a normal +user: the installer tests check Unix ownership and unreadable directories. +Linux evidence is mandatory for `KEY-PERM-001` and `KEY-PERM-ROLLBACK-001`; +Windows evidence is mandatory for `KEY-PERM-WINDOWS-001` and +both platforms are required for `KEY-PATH-001`. Windows evidence cannot be +substituted by a renamed Linux report. Full assurance cannot be claimed from +a Linux-only run. On Fedora, `scripts/install-interop-tools.sh` extracts +Clevis and JOSE into a local prefix without installing system packages. + +`ADV-OPS-001` requires both the Linux protocol regression and a stock Clevis +run with extra stored key operations and recovery `use: "sig"` metadata. +`INTEROP-PREFIX-001` requires all twelve prefix safety tests and +`INTEROP-PACKAGES-001` requires the download isolation regression to pass in the +separate `installer` report. Produce that report with: + +```sh +python3 scripts/run-python-tests.py scripts/test-prepare-interop-prefix.py --junit target/installer-junit/junit.xml +``` + +## Adding an assertion + +Add it to `assertions.toml`, tag the tests that support it, and set +`min_evidence` to the number of tests that must pass. Choose tests that would +fail if the assertion stopped holding; a test that merely runs the code is not +evidence. + +To inspect available evidence without a Windows report, run the checker directly +with Linux, interop and installer reports; it intentionally exits unsuccessfully for the +missing Windows requirements and records those gaps. The evidence record includes +`passing_by_report` for assertions with platform requirements. The checker counts +reported tests, not their target platform metadata, so report labels must accurately +identify the original CI job. + +Run `python3 scripts/test-check-assurance.py` to verify per-report minimums, +missing reports, Python JUnit generation, and failure/skip handling without external tooling. diff --git a/assurance/assertions.toml b/assurance/assertions.toml new file mode 100644 index 0000000..5156eb1 --- /dev/null +++ b/assurance/assertions.toml @@ -0,0 +1,126 @@ +# Assertions this library makes about itself. +# +# Each assertion is supported by tests tagged in code with a doc comment +# directly above the test, e.g. `/// Assures: KEY-PERM-001` in Rust or +# `# Assures: INTEROP-PREFIX-001` in Python. CI runs +# `scripts/check-assurance.py`, which fails unless every assertion has at least +# `min_evidence` tagged tests that passed. Skipped or missing tests do not count. +# See assurance/README.md. + +[[assertion]] +id = "ADV-SIG-001" +statement = "Advertisements are JWS-signed by every advertised ES512 key, signatures verify over the exact wire bytes, and tampering is detected." +min_evidence = 3 + +[[assertion]] +id = "KEY-PRIV-001" +statement = "Advertisements, recovery responses, error messages and direct JWK/library debug output never expose private key scalars." +min_evidence = 6 + +[[assertion]] +id = "REC-CORRECT-001" +statement = "Recovery responses match independently generated vectors, and a blinded client recovers its original shared point." +min_evidence = 2 + +[[assertion]] +id = "REQ-REJECT-001" +statement = "Malformed or invalid recovery requests (bad JSON, wrong curve or key type, off-curve points, private-key operations) return a typed error, never a response or a panic." +min_evidence = 2 + +[[assertion]] +id = "KEY-LOAD-001" +statement = "Invalid, incomplete or unreadable key material fails initialisation; no key is silently skipped." +min_evidence = 3 + +[[assertion]] +id = "KEY-STORE-001" +statement = "Generated keys persist with stable identities across restarts, and existing key files are never overwritten or modified, including by failed initialisation; leftover temporary files are ignored." +min_evidence = 3 + +[[assertion]] +id = "KEY-PERM-001" +statement = "On Unix, key files are restricted from creation, have final mode 0440 (owner and group read), and never require changing the process umask." +min_evidence = 1 +required_reports = ["linux"] + +[[assertion]] +id = "KEY-ID-001" +statement = "Key IDs are RFC 7638 thumbprints that match independently computed values and ignore optional metadata." +min_evidence = 1 + +[[assertion]] +id = "KEY-GEN-001" +statement = "Key generation produces a usable P-521 ES512 signing key and ECMR recovery key, and fails closed when the entropy source fails." +min_evidence = 2 + +[[assertion]] +id = "ERR-DIAG-001" +statement = "Errors identify the failing key, path or operation and preserve the underlying cause." +min_evidence = 4 + +[[assertion]] +id = "INTEROP-001" +statement = "A stock Clevis client can encrypt against the library's advertisements and decrypt through its recovery endpoint, with one or two signing keys." +min_evidence = 1 + +[[assertion]] +id = "KEY-VALID-001" +statement = "Active and rotated stored keys receive curve, key type, scalar/public-point, algorithm and required-operation validation." +min_evidence = 2 + +[[assertion]] +id = "KEY-DUP-001" +statement = "Every duplicate identity fails with both origins, including identical copies and conflicting metadata across active and rotated sets." +min_evidence = 2 + +[[assertion]] +id = "KEY-PATH-001" +statement = "Like Tang, store roots and key files are read through symlinks and junctions, and key entries must resolve to regular files." +min_evidence = 2 +required_reports = ["linux", "windows"] + +[[assertion]] +id = "KEY-PUBLISH-001" +statement = "Initialization succeeds only after both keys are persisted; failures remove files created by that call, and crashes leave only complete key files." +min_evidence = 3 + +[[assertion]] +id = "KEY-ROTATE-001" +statement = "Rotated keys remain recoverable, stay unadvertised and sign the active payload only when selected." +min_evidence = 3 + +[[assertion]] +id = "KEY-PERM-WINDOWS-001" +statement = "Generated Windows key files inherit the secured directory ACL." +min_evidence = 1 +required_reports = ["windows"] + +[[assertion]] +id = "INTEROP-ROTATE-001" +statement = "Stock Clevis decrypts pre-rotation bindings and enrolls with the current thumbprint; JOSE verifies an old signer on the active payload, while Clevis rejects an absent old thumbprint." +min_evidence = 1 +required_reports = ["interop"] + +[[assertion]] +id = "ADV-OPS-001" +statement = "Advertisements expose only verify for signing keys and deriveKey for recovery keys, omitting recovery use metadata even when stored permissions include extra operations or use sig; stock Clevis can enroll and recover with those keys." +min_evidence = 1 +required_reports = ["linux", "interop"] + +[[assertion]] +id = "KEY-PERM-ROLLBACK-001" +statement = "On Unix, failure to set either new key file's permissions removes files created by that initialization, preserves existing files and the original error, and allows a clean retry." +min_evidence = 1 +required_reports = ["linux"] + +[[assertion]] +id = "INTEROP-PREFIX-001" +statement = "The local tool installer creates private directories, reuses safe caller-owned installations, and rejects unsafe ownership, writable paths, symlink traversal and unsafe contents before executing package tools." +min_evidence = 12 +required_reports = ["installer"] + +[[assertion]] +id = "INTEROP-PACKAGES-001" +statement = "The local tool installer extracts only RPMs from the successful download attempt, excluding cached packages and partial downloads before an archive retry." +min_evidence = 1 +required_reports = ["installer"] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..e02cd78 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,633 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom", + "hybrid-array", + "num-traits", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom", + "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctutils" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common", + "digest", + "ff", + "group", + "hybrid-array", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tangy-lib" +version = "1.0.0" +dependencies = [ + "base64ct", + "getrandom", + "p521", + "rand_chacha", + "serde", + "serde_json", + "sha2", + "thiserror", + "zeroize", +] + +[[package]] +name = "tangy-lib-fuzz" +version = "1.0.0" +dependencies = [ + "base64ct", + "libfuzzer-sys", + "serde_json", + "tangy-lib", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "serde", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..f53e426 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tangy-lib-fuzz" +version = "1.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +base64ct = { version = "1.8", features = ["alloc"] } +serde_json = "1.0" +tangy-lib = { path = ".." } + +# Keep nightly fuzz tooling separate from the library's MSRV dependency graph. +[workspace] +members = ["."] + +[[bin]] +name = "recovery_request" +path = "fuzz_targets/recovery_request.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "jwk_conversion" +path = "fuzz_targets/jwk_conversion.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..4ea0a2a --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,51 @@ +# Bounded input fuzzing + +The independent Cargo workspace keeps `libfuzzer-sys` and nightly tooling out of +the library's stable/MSRV dependency graph. Commit `fuzz/Cargo.lock` alongside +dependency updates. The commands follow the +[cargo-fuzz guide](https://rust-fuzz.github.io/book/cargo-fuzz.html). + +Install nightly Rust and cargo-fuzz, then run from the repository root: + +```sh +rustup toolchain install nightly +cargo install cargo-fuzz --locked +bash scripts/fuzz-smoke.sh +``` + +The script explicitly targets the installed nightly compiler's host platform +(`x86_64-unknown-linux-gnu` on the Ubuntu CI runner). This avoids +[cargo-fuzz's default to its own build platform](https://github.com/rust-fuzz/cargo-fuzz/blob/master/src/utils.rs), +which can select musl when using a prebuilt binary and fail with static-libc +sanitizer errors or a missing musl standard library. + +Each target runs for 30 seconds with AddressSanitizer, at most 4096 input bytes, +a five-second per-input timeout, and a 1024 MiB RSS limit. CI also sets a job +timeout. `recovery_request` uses a cached fixture-backed server to fuzz the +actual `rec` parser and point recovery, checking error classification and public +responses. `jwk_conversion` exercises JSON deserialization, typed public/private +conversion, public projection and thumbprints. It also creates JWKs with mutated +encoded coordinates/scalars so invalid JSON cannot dominate conversion coverage. + +`seeds/` holds immutable starting inputs derived from the documented +`tests/fixtures/` keys and recovery vectors, plus malformed JSON, incorrect +metadata, scalar/coordinate length and encoding failures. These are public test +keys; never add deployment keys. Mutated corpora go in ignored `corpus/` +directories. The script fetches locked dependencies, runs offline and checks +that neither lockfile changed. Failures propagate to CI; crash/timeout inputs +and the working corpora are uploaded for reproduction. + +To reproduce a saved failure, download the CI artifact and run: + +```sh +cargo +nightly fuzz run --target x86_64-unknown-linux-gnu \ + recovery_request fuzz/artifacts/recovery_request/ +``` + +The command above matches the Linux CI target; use your nightly compiler's host +target on other platforms. Use `jwk_conversion` and its artifact path for that +target. A crash becomes a normal regression test before fixing the behavior. +To run longer, invoke the +target directly with a larger `-max_total_time` using the same seed directories. +These short runs supplement the protocol and validation tests; they do not +establish exhaustive coverage or replace platform assurance reports. diff --git a/fuzz/fuzz_targets/jwk_conversion.rs b/fuzz/fuzz_targets/jwk_conversion.rs new file mode 100644 index 0000000..0d51558 --- /dev/null +++ b/fuzz/fuzz_targets/jwk_conversion.rs @@ -0,0 +1,41 @@ +#![no_main] + +use base64ct::{Base64UrlUnpadded, Encoding}; +use libfuzzer_sys::fuzz_target; +use tangy_lib::MyJwkEcKey; + +fn check(key: MyJwkEcKey) { + let public = key.to_p521_public_key(); + if let Ok(secret) = key.to_p521_secret_key() { + assert_eq!( + public.as_ref().expect("valid private key has public point"), + &secret.public_key() + ); + } + let projected = key.to_public_key(); + assert!(projected.d.is_none()); + assert_eq!(key.thumbprint().unwrap(), projected.thumbprint().unwrap()); + let mut altered = key; + altered.d = Some("invalid private scalar".into()); + assert_eq!(public.ok(), altered.to_p521_public_key().ok()); +} + +fuzz_target!(|data: &[u8]| { + if let Ok(key) = serde_json::from_slice::(data) { + check(key); + } + // Also reach conversion with structurally valid JSON on every iteration. + // Variable lengths exercise both fixed-width decoding and scalar validation. + let mut key: MyJwkEcKey = + serde_json::from_str(include_str!("../../tests/fixtures/es512.json")).unwrap(); + match data.first().copied().unwrap_or_default() % 3 { + 0 => key.x = Base64UrlUnpadded::encode_string(data.get(1..).unwrap_or_default()), + 1 => key.y = Base64UrlUnpadded::encode_string(data.get(1..).unwrap_or_default()), + _ => { + key.d = Some(Base64UrlUnpadded::encode_string( + data.get(1..).unwrap_or_default(), + )) + } + } + check(key); +}); diff --git a/fuzz/fuzz_targets/recovery_request.rs b/fuzz/fuzz_targets/recovery_request.rs new file mode 100644 index 0000000..b663ecd --- /dev/null +++ b/fuzz/fuzz_targets/recovery_request.rs @@ -0,0 +1,36 @@ +#![no_main] + +use std::sync::OnceLock; + +use libfuzzer_sys::fuzz_target; +use tangy_lib::{Error, KeySource, MyJwkEcKey, TangyLib}; + +fn server() -> &'static (TangyLib, String) { + static SERVER: OnceLock<(TangyLib, String)> = OnceLock::new(); + SERVER.get_or_init(|| { + let signing = include_str!("../../tests/fixtures/es512.json"); + let recovery = include_str!("../../tests/fixtures/ecmr.json"); + let key: MyJwkEcKey = serde_json::from_str(recovery).expect("valid test fixture"); + ( + TangyLib::init(KeySource::Vector(&[signing, recovery])).expect("valid test key set"), + key.thumbprint().expect("fixture thumbprint"), + ) + }) +} + +fuzz_target!(|data: &[u8]| { + if let Ok(request) = std::str::from_utf8(data) { + let (server, kid) = server(); + match server.rec(kid, request) { + Ok(response) => { + let key: MyJwkEcKey = serde_json::from_str(&response).expect("valid response JSON"); + assert!(key.d.is_none()); + assert_eq!(key.alg.as_deref(), Some("ECMR")); + assert_eq!(key.key_ops.as_deref(), Some(&["deriveKey".into()][..])); + assert!(key.to_p521_public_key().is_ok()); + } + // A known recovery key and in-memory response leave only client input errors. + Err(error) => assert!(matches!(error, Error::InvalidRequest { .. })), + } + } +}); diff --git a/fuzz/seeds/jwk_conversion/ecmr.json b/fuzz/seeds/jwk_conversion/ecmr.json new file mode 100644 index 0000000..42fa862 --- /dev/null +++ b/fuzz/seeds/jwk_conversion/ecmr.json @@ -0,0 +1,11 @@ +{ + "kty": "EC", + "alg": "ECMR", + "crv": "P-521", + "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", + "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", + "d": "AQTm4JamDPZufHlRCC12Ssjh6xTwu630neCLr7EUtUuZoFHk9zga-kzwaGajH1MQb8ffc3CeV-7InHKmR8HvytTE", + "key_ops": [ + "deriveKey" + ] +} diff --git a/fuzz/seeds/jwk_conversion/empty-object b/fuzz/seeds/jwk_conversion/empty-object new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/fuzz/seeds/jwk_conversion/empty-object @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/fuzz/seeds/jwk_conversion/es512.json b/fuzz/seeds/jwk_conversion/es512.json new file mode 100644 index 0000000..ddf4026 --- /dev/null +++ b/fuzz/seeds/jwk_conversion/es512.json @@ -0,0 +1,12 @@ +{ + "kty": "EC", + "alg": "ES512", + "crv": "P-521", + "x": "AX5mUTAH1qr3YSSwuMV_HV0yupJhMIAqwly710a7qLbXR6up3flnaPsJbaSVATrIF6QcXc9PPyFW1IQHmDOWGSPj", + "y": "ADT1K8Q-O1Q5lyU3StXnPMQwgnYWS8hnTRGjjcFssitZy_tUWSuhUPFhzaUJKhXRNbcyELeDX-kPCMbBKX1vb8Lq", + "d": "AbDO5xCtQHUbHld-Fq61sSCvyjr9EpNj3_sklNmo54xmKeYu_cW_s7fzQxm6SsqFwrTmiiFz2OaD1ODsXI-DdoKt", + "key_ops": [ + "sign", + "verify" + ] +} diff --git a/fuzz/seeds/jwk_conversion/invalid-d-3.json b/fuzz/seeds/jwk_conversion/invalid-d-3.json new file mode 100644 index 0000000..c72dd7c --- /dev/null +++ b/fuzz/seeds/jwk_conversion/invalid-d-3.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ES512", "crv": "P-521", "x": "AX5mUTAH1qr3YSSwuMV_HV0yupJhMIAqwly710a7qLbXR6up3flnaPsJbaSVATrIF6QcXc9PPyFW1IQHmDOWGSPj", "y": "ADT1K8Q-O1Q5lyU3StXnPMQwgnYWS8hnTRGjjcFssitZy_tUWSuhUPFhzaUJKhXRNbcyELeDX-kPCMbBKX1vb8Lq", "d": "%%%", "key_ops": ["sign", "verify"]} \ No newline at end of file diff --git a/fuzz/seeds/jwk_conversion/invalid-d-88.json b/fuzz/seeds/jwk_conversion/invalid-d-88.json new file mode 100644 index 0000000..077c6bf --- /dev/null +++ b/fuzz/seeds/jwk_conversion/invalid-d-88.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ES512", "crv": "P-521", "x": "AX5mUTAH1qr3YSSwuMV_HV0yupJhMIAqwly710a7qLbXR6up3flnaPsJbaSVATrIF6QcXc9PPyFW1IQHmDOWGSPj", "y": "ADT1K8Q-O1Q5lyU3StXnPMQwgnYWS8hnTRGjjcFssitZy_tUWSuhUPFhzaUJKhXRNbcyELeDX-kPCMbBKX1vb8Lq", "d": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "key_ops": ["sign", "verify"]} \ No newline at end of file diff --git a/fuzz/seeds/jwk_conversion/invalid-x-2.json b/fuzz/seeds/jwk_conversion/invalid-x-2.json new file mode 100644 index 0000000..bf0af3f --- /dev/null +++ b/fuzz/seeds/jwk_conversion/invalid-x-2.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ES512", "crv": "P-521", "x": "AA", "y": "ADT1K8Q-O1Q5lyU3StXnPMQwgnYWS8hnTRGjjcFssitZy_tUWSuhUPFhzaUJKhXRNbcyELeDX-kPCMbBKX1vb8Lq", "d": "AbDO5xCtQHUbHld-Fq61sSCvyjr9EpNj3_sklNmo54xmKeYu_cW_s7fzQxm6SsqFwrTmiiFz2OaD1ODsXI-DdoKt", "key_ops": ["sign", "verify"]} \ No newline at end of file diff --git a/fuzz/seeds/jwk_conversion/invalid-y-88.json b/fuzz/seeds/jwk_conversion/invalid-y-88.json new file mode 100644 index 0000000..510934e --- /dev/null +++ b/fuzz/seeds/jwk_conversion/invalid-y-88.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ES512", "crv": "P-521", "x": "AX5mUTAH1qr3YSSwuMV_HV0yupJhMIAqwly710a7qLbXR6up3flnaPsJbaSVATrIF6QcXc9PPyFW1IQHmDOWGSPj", "y": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "d": "AbDO5xCtQHUbHld-Fq61sSCvyjr9EpNj3_sklNmo54xmKeYu_cW_s7fzQxm6SsqFwrTmiiFz2OaD1ODsXI-DdoKt", "key_ops": ["sign", "verify"]} \ No newline at end of file diff --git a/fuzz/seeds/jwk_conversion/malformed b/fuzz/seeds/jwk_conversion/malformed new file mode 100644 index 0000000..81750b9 --- /dev/null +++ b/fuzz/seeds/jwk_conversion/malformed @@ -0,0 +1 @@ +{ \ No newline at end of file diff --git a/fuzz/seeds/jwk_conversion/wrong-types b/fuzz/seeds/jwk_conversion/wrong-types new file mode 100644 index 0000000..6c208f3 --- /dev/null +++ b/fuzz/seeds/jwk_conversion/wrong-types @@ -0,0 +1 @@ +{"crv":[],"kty":null,"x":1,"y":true,"d":{}} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/empty-object b/fuzz/seeds/recovery_request/empty-object new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/fuzz/seeds/recovery_request/empty-object @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/invalid-alg.json b/fuzz/seeds/recovery_request/invalid-alg.json new file mode 100644 index 0000000..c8c7a0b --- /dev/null +++ b/fuzz/seeds/recovery_request/invalid-alg.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ES512", "crv": "P-521", "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/invalid-crv.json b/fuzz/seeds/recovery_request/invalid-crv.json new file mode 100644 index 0000000..228aed0 --- /dev/null +++ b/fuzz/seeds/recovery_request/invalid-crv.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ECMR", "crv": "P-384", "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/invalid-key_ops.json b/fuzz/seeds/recovery_request/invalid-key_ops.json new file mode 100644 index 0000000..391b842 --- /dev/null +++ b/fuzz/seeds/recovery_request/invalid-key_ops.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ECMR", "crv": "P-521", "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", "key_ops": ["sign"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/invalid-x.json b/fuzz/seeds/recovery_request/invalid-x.json new file mode 100644 index 0000000..bbeb854 --- /dev/null +++ b/fuzz/seeds/recovery_request/invalid-x.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ECMR", "crv": "P-521", "x": "%%%", "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/invalid-y.json b/fuzz/seeds/recovery_request/invalid-y.json new file mode 100644 index 0000000..20bb25e --- /dev/null +++ b/fuzz/seeds/recovery_request/invalid-y.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ECMR", "crv": "P-521", "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", "y": "AA", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/malformed b/fuzz/seeds/recovery_request/malformed new file mode 100644 index 0000000..81750b9 --- /dev/null +++ b/fuzz/seeds/recovery_request/malformed @@ -0,0 +1 @@ +{ \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/valid.json b/fuzz/seeds/recovery_request/valid.json new file mode 100644 index 0000000..68f1f2a --- /dev/null +++ b/fuzz/seeds/recovery_request/valid.json @@ -0,0 +1 @@ +{"kty": "EC", "alg": "ECMR", "crv": "P-521", "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/vector-0.json b/fuzz/seeds/recovery_request/vector-0.json new file mode 100644 index 0000000..d3b08e8 --- /dev/null +++ b/fuzz/seeds/recovery_request/vector-0.json @@ -0,0 +1 @@ +{"kty": "EC", "crv": "P-521", "x": "AMaFjga3BATpzZ4-y2YjlbRCnGSBOQU_tSH4KK9ga009uqFLXnfv51ko_h3BJ6L_qN4zSLPBhWpCm_l-fjHC5b1m", "y": "ARg5KWp4mjvABFyKX7QsfRvZmPVESVebRGgXr70XJz5mLJfucple9CZAxVC5AT-tB2E1PHCGonLCQIi-lHaf0WZQ", "alg": "ECMR", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/vector-1.json b/fuzz/seeds/recovery_request/vector-1.json new file mode 100644 index 0000000..ce79aab --- /dev/null +++ b/fuzz/seeds/recovery_request/vector-1.json @@ -0,0 +1 @@ +{"kty": "EC", "crv": "P-521", "x": "ABAOZHCCQQzldWZk6fhnr81CTjb9aHCjjEJpBzvFVqfSNZDipzE3ApgXzk0VQ6fE1VoDmRSPsyiz2fLy9M9VhPrV", "y": "AQnCV6dw2ojuA0TLANHitiSuyDTo7iaEWAHXhV6G7i-YZruEF3-3J-xg9s2rdAcqd6Xd80JUG5pl2wv3LoowpyRX", "alg": "ECMR", "key_ops": ["deriveKey"]} \ No newline at end of file diff --git a/fuzz/seeds/recovery_request/wrong-types b/fuzz/seeds/recovery_request/wrong-types new file mode 100644 index 0000000..6c208f3 --- /dev/null +++ b/fuzz/seeds/recovery_request/wrong-types @@ -0,0 +1 @@ +{"crv":[],"kty":null,"x":1,"y":true,"d":{}} \ No newline at end of file diff --git a/readme.md b/readme.md deleted file mode 100644 index 780b177..0000000 --- a/readme.md +++ /dev/null @@ -1,80 +0,0 @@ -# Tangy Lib - -## Description - -Tangy-lib is a library implementation of the Tang server, written in rust. - -The Tang protocol allows clients to store secrets which can only be recovered when they have access to the Tang server. For example, the Clevis tools allows the automated decryption of LUKS partitions when the encrypted device is connected to the local network that Tang is accessible on. - -See the original Tang project for a complete description: https://github.com/latchset/tang - -Fraser Tweedale's 2020 Linux Conference Australia talk on "Clevis and Tang: securing your secrets at rest" is a great resource: - -[![Clevis and Tang: securing your secrets at rest](https://img.youtube.com/vi/Dk6ZuydQt9I/0.jpg)](https://www.youtube.com/watch?v=Dk6ZuydQt9I) - - -## Installation - -Installation via cargo: - -``` bash -cargo add tangy-lib -``` - -Or directly using in the dependencies section of Cargo.toml: - -``` toml -[dependencies] -tangy-lib = "0.1" -``` - -## Usage - -Tangy-lib has an initialization method which can take a local directory or vector of JWK string as input: - -``` rust -use tangy_lib::{KeySource, TangyLib}; -let mut tangy = TangyLib::init(KeySource::LocalDir(&dir_path)).unwrap(); -// or -let mut tangy = TangyLib::init(KeySource::Vector(&vec_of_keys)).unwrap(); -``` - -If the `LocalDir` does not contain a key set, a new key set is generated and saved to that folder. - -The local directory and vector load methods will process the keys and generate errors if a JWK cannot be loaded or if some keys are missing an `std::io::Error` is returned with kind set to `std::io::ErrorKind::Unsupported` or `std::io::ErrorKind::NotFound`. - -If `init` returns `Ok` then everything else should work. - -Tang uses `advertise` and `recovery` stages, to generate an adversise response and then recovery response: - -``` rust -use tangy_lib::{KeySource, TangyLib}; -let mut tangy = TangyLib::init(KeySource::LocalDir(&dir_path)); -let adv = tangy.adv(None).unwrap(); -let rec = tangy.rec(&kid, &data).unwrap(); -``` - -Where the parameter passed to `adv` is a thumbprint of which signing key to use, often set to None which will use all signing keys. If the signing key is set but not found `adv` will return `Err(std::io::Error)` with ErrorKind set to std::io::ErrorKind::NotFound. - -For recovery the thumbprint of the Elliptic Curve Message Recovery (ECMR) key, and the data is public key generated by the client in JWK format. - -A key creation mechanism is provided: - -``` rust -let keys : Vec = tangy_lib::create_new_key_set(); -``` - -It creates ES512 and ECMR keys. - -## Credits - -The original authors of [Tang](https://github.com/latchset/tang) are [Latchset](https://github.com/latchset/). Tang is based on the protocol described by Nathaniel McCallum and Robert Relyea (https://marc.info/?m=144173814525805). - - -## License - -Apache-2.0 or MIT - you decide! - -## How to Contribute - -Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed (Apache-2.0 and MIT), without any additional terms or conditions. \ No newline at end of file diff --git a/scripts/check-assurance-local.sh b/scripts/check-assurance-local.sh new file mode 100644 index 0000000..4fb227c --- /dev/null +++ b/scripts/check-assurance-local.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run the same evidence pipeline as the CI assurance job: the main suite, the +# JOSE fixture check and the Clevis interop test, then check every assertion. +# Requires cargo-nextest, plus clevis and jose on PATH (on Fedora without +# installing system packages: scripts/install-interop-tools.sh, then source its env.sh). +# Full assurance also needs an authentic Windows JUnit report from the same revision. +# Usage: bash scripts/check-assurance-local.sh /path/to/windows-junit.xml +set -euo pipefail + +cd "$(dirname "$0")/.." +windows_report=${1:-} +if [[ -z "$windows_report" || ! -f "$windows_report" ]]; then + echo "Supply a Windows nextest JUnit report from this revision as the first argument." >&2 + echo "Unix permissions require linux evidence; ACL inheritance and reparse tests require windows evidence." >&2 + exit 1 +fi +for tool in cargo-nextest clevis jose python3; do + if ! command -v "$tool" >/dev/null; then + echo "Missing prerequisite: $tool" >&2 + exit 1 + fi +done + +reports=$(mktemp -d) +trap 'rm -rf "$reports"' EXIT + +python3 scripts/run-python-tests.py scripts/test-prepare-interop-prefix.py --junit "$reports/installer.xml" +cargo nextest run --locked --profile ci +cp target/nextest/ci/junit.xml "$reports/linux.xml" +python3 scripts/check-fixtures.py +cargo nextest run --locked --profile ci --features interop --test clevis +cp target/nextest/ci/junit.xml "$reports/interop.xml" + +python3 scripts/check-assurance.py \ + --junit linux="$reports/linux.xml" \ + --junit interop="$reports/interop.xml" \ + --junit installer="$reports/installer.xml" \ + --junit windows="$windows_report" \ + --out target/evidence.json diff --git a/scripts/check-assurance.py b/scripts/check-assurance.py new file mode 100644 index 0000000..54044b1 --- /dev/null +++ b/scripts/check-assurance.py @@ -0,0 +1,247 @@ +"""Check that every assertion in assurance/assertions.toml is supported by passing tests. + +Tests claim assertions with a doc comment directly above them: + + /// Assures: KEY-PERM-001, KEY-STORE-001 + #[test] + fn ... + +Python unittest methods use a # Assures: comment before the method/decorators. +Results come from nextest or run-python-tests.py JUnit reports, so only tests that actually ran and +passed count as evidence. Usage: + + check-assurance.py --junit linux=target/nextest/ci/junit.xml [--junit LABEL=PATH ...] + [--out evidence.json] +""" + +import argparse +import ast +from datetime import datetime, timezone +import hashlib +import io +import json +from pathlib import Path +import re +import subprocess +import sys +import tomllib +import tokenize +import xml.etree.ElementTree as ET + +root = Path(__file__).resolve().parent.parent +TAG = re.compile(r"^\s*///\s*Assures:\s*(.+?)\s*$") +PY_TAG = re.compile(r"^\s*#\s*Assures:\s*(.+?)\s*$") +FN = re.compile(r"^\s*(?:pub\s+)?fn\s+(\w+)") +ID = re.compile(r"^[A-Z]+(?:-[A-Z]+)*-\d{3}$") +errors = [] + + +def binary_id(path, crate): + """The nextest binary ID for tests defined in a source file.""" + rel = path.relative_to(root) + return crate if rel.parts[0] == "src" else f"{crate}::{rel.stem}" + + +def tagged_tests(crate): + """Map (binary, fn name) to the assertion IDs its tag claims.""" + tags = {} + for path in sorted([*root.glob("src/**/*.rs"), *root.glob("tests/*.rs")]): + lines = path.read_text().splitlines() + for number, line in enumerate(lines): + match = TAG.match(line) + if not match: + continue + where = f"{path.relative_to(root)}:{number + 1}" + ids = [i.strip() for i in match.group(1).split(",")] + is_test, name = False, None + for following in lines[number + 1:]: + stripped = following.strip() + if stripped == "#[test]": + is_test = True + elif stripped.startswith(("///", "#[")) or not stripped: + continue + else: + name = FN.match(following) and FN.match(following).group(1) + break + if not (is_test and name): + errors.append(f"{where}: Assures tag is not attached to a #[test] fn") + continue + key = (binary_id(path, crate), name) + if key in tags: + errors.append(f"{where}: duplicate test name {name} in {key[0]}") + tags[key] = ids + tags.update(python_tagged_tests()) + return tags + + +def python_tagged_tests(): + """Match Python comment tags to unittest methods and their JUnit identities.""" + tags = {} + for path in sorted(root.glob("scripts/test-*.py")): + source = path.read_text() + lines = source.splitlines() + comments = {token.start[0]: match.group(1) + for token in tokenize.generate_tokens(io.StringIO(source).readline) + if token.type == tokenize.COMMENT + and (match := PY_TAG.match(lines[token.start[0] - 1]))} + attached = set() + + def visit(body, classes=()): + for node in body: + if isinstance(node, ast.ClassDef): + visit(node.body, (*classes, node.name)) + elif isinstance(node, ast.FunctionDef) and classes and node.name.startswith("test_"): + start = min([node.lineno, *(d.lineno for d in node.decorator_list)]) + previous = start - 1 + while previous > 0 and not lines[previous - 1].strip(): + previous -= 1 + if previous in comments: + attached.add(previous) + binary = ".".join((path.stem, *classes)) + key = (binary, node.name) + if key in tags: + errors.append(f"{path.relative_to(root)}:{previous}: duplicate test {key}") + tags[key] = [id.strip() for id in comments[previous].split(",")] + + visit(ast.parse(source, filename=str(path)).body) + for number in comments.keys() - attached: + errors.append(f"{path.relative_to(root)}:{number}: Assures tag is not attached to a unittest test method") + return tags + + +def junit_results(label, path): + """Map (binary, test name) to passed/failed/skipped for one report.""" + results = {} + for case in ET.parse(path).getroot().iter("testcase"): + if case.find("skipped") is not None: + outcome = "skipped" + elif case.find("failure") is not None or case.find("error") is not None: + outcome = "failed" + else: + outcome = "passed" + results[(case.get("classname"), case.get("name"))] = outcome + if not results: + errors.append(f"{label}: {path} contains no test cases") + return results + + +def run_matches(results, binary, name): + """Outcomes for a test, whose JUnit name may carry a module path.""" + return [ + outcome for (suite, case), outcome in results.items() + if suite == binary and (case == name or case.endswith(f"::{name}")) + ] + + +def command(*args): + try: + return subprocess.check_output(args, cwd=root, text=True, timeout=30).strip() + except (OSError, subprocess.SubprocessError): + return None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--junit", action="append", required=True, metavar="LABEL=PATH") + parser.add_argument("--registry", type=Path, default=root / "assurance" / "assertions.toml") + parser.add_argument("--out", type=Path) + args = parser.parse_args() + + crate = tomllib.loads((root / "Cargo.toml").read_text())["package"]["name"] + assertions = tomllib.loads(args.registry.read_text())["assertion"] + known = {} + for assertion in assertions: + if not ID.match(assertion["id"]): + errors.append(f"registry: malformed assertion ID {assertion['id']}") + if assertion["id"] in known: + errors.append(f"registry: duplicate assertion ID {assertion['id']}") + required = assertion.get("required_reports", []) + if (not isinstance(required, list) or any(not isinstance(label, str) or not label for label in required) + or len(set(required)) != len(required)): + errors.append(f"registry: invalid required_reports for {assertion['id']}") + known[assertion["id"]] = assertion + + runs = {} + for spec in args.junit: + label, _, path = spec.partition("=") + if not path: + parser.error(f"--junit expects LABEL=PATH, got {spec}") + if label in runs: + parser.error(f"duplicate JUnit report label {label}") + runs[label] = junit_results(label, Path(path)) + + tags = tagged_tests(crate) + evidence = {id: [] for id in known} + for (binary, name), ids in sorted(tags.items()): + outcomes = {label: run_matches(results, binary, name) for label, results in runs.items()} + ran = {label: found for label, found in outcomes.items() if found} + if not ran: + errors.append(f"{binary}::{name} is tagged but absent from every report") + if any(len(found) > 1 for found in ran.values()): + errors.append(f"{binary}::{name} matches several reported tests") + if any("failed" in found for found in ran.values()): + errors.append(f"{binary}::{name} failed") + if any("skipped" in found for found in ran.values()): + errors.append(f"{binary}::{name} was skipped") + record = { + "test": f"{binary}::{name}", + "runs": {label: found[0] for label, found in ran.items()}, + } + record["passed"] = bool(ran) and all(o == "passed" for o in record["runs"].values()) + for id in ids: + if id in evidence: + evidence[id].append(record) + else: + errors.append(f"{binary}::{name} claims unknown assertion {id}") + + report = [] + for id, assertion in known.items(): + passing = sum(record["passed"] for record in evidence[id]) + supported = passing >= assertion["min_evidence"] + if not supported: + errors.append(f"{id}: {passing} of {assertion['min_evidence']} required passing tests") + by_report = {} + for label in assertion.get("required_reports", []): + count = sum(record["runs"].get(label) == "passed" for record in evidence[id]) + by_report[label] = count + if label not in runs: + errors.append(f"{id}: required report {label} is missing") + supported = False + elif count < assertion["min_evidence"]: + errors.append(f"{id}: {label} has {count} of {assertion['min_evidence']} required passing tests") + supported = False + report.append({**assertion, "passing": passing, "passing_by_report": by_report, + "supported": supported, "evidence": evidence[id]}) + print(f"{'ok ' if supported else 'GAP'} {id:<16} {passing}/{assertion['min_evidence']}" + f" {assertion['statement']}") + + reported = {key for results in runs.values() for key in results} + untagged = [f"{suite}::{case}" for suite, case in sorted(reported) + if not any(suite == b and (case == n or case.endswith(f"::{n}")) for b, n in tags)] + if untagged: + print(f"\n{len(untagged)} reported tests support no assertion:") + print("".join(f" {name}\n" for name in untagged), end="") + + if args.out: + lock = root / "Cargo.lock" + args.out.write_text(json.dumps({ + "generated": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "commit": command("git", "rev-parse", "HEAD"), + "dirty": bool(command("git", "status", "--porcelain")), + "cargo_lock_sha256": hashlib.sha256(lock.read_bytes()).hexdigest() if lock.exists() else None, + "reports": {spec.partition("=")[0]: spec.partition("=")[2] for spec in args.junit}, + "supported": not errors, + "errors": errors, + "assertions": report, + "untagged_tests": untagged, + }, indent=2) + "\n") + + if errors: + print("\nAssurance check failed:", *errors, sep="\n ", file=sys.stderr) + return 1 + print(f"\nAll {len(known)} assertions are supported by passing tests") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-fixtures.py b/scripts/check-fixtures.py new file mode 100644 index 0000000..38c6cd4 --- /dev/null +++ b/scripts/check-fixtures.py @@ -0,0 +1,27 @@ +"""Independently check committed vectors with Latchset JOSE (no Rust code).""" + +import json +from pathlib import Path +import subprocess + +fixtures = Path(__file__).resolve().parent.parent / "tests" / "fixtures" + + +def jose(*args, input=None): + return subprocess.check_output(["jose", *args], input=input, text=True, timeout=10).strip() + + +for name, expected in json.loads((fixtures / "thumbprints.json").read_text()).items(): + assert jose("jwk", "thp", "-i", str(fixtures / name), "-a", "S256") == expected + +for vector in json.loads((fixtures / "recovery.json").read_text()): + response = json.loads(jose( + "jwk", "exc", "-i", '{"alg":"ECMR"}', + "-l", str(fixtures / "ecmr.json"), "-r", "-", input=json.dumps(vector["request"]), + )) + assert "d" not in response + # tangd adds these two metadata fields after jose_jwk_exc(). + response.update(alg="ECMR", key_ops=["deriveKey"]) + assert response == vector["response"], "Recovery vector differs from JOSE" + +print("JOSE verified all thumbprints and recovery vectors") diff --git a/scripts/clevis-rotation.py b/scripts/clevis-rotation.py new file mode 100644 index 0000000..f6f6ccc --- /dev/null +++ b/scripts/clevis-rotation.py @@ -0,0 +1,72 @@ +"""Exercise stock Clevis recovery/enrollment and independently verify the old signer. + +Clevis thp trust checks require a matching signing key inside the payload; Tang's +active-only rotated advertisement cannot meet that check for an old thumbprint. +""" +import base64 +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import urllib.request + +url, thumbprint, rotate_request, rotated_ready = sys.argv[1:] +plaintext = b"Tangy rotation interoperability\x00\xff\n" +env = {**os.environ, "NO_PROXY": "127.0.0.1", "no_proxy": "127.0.0.1", "LC_ALL": "C"} +http = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def run(args, data): + return subprocess.run(args, input=data, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=env, timeout=30) + + +def successful(args, data): + result = run(args, data) + if result.returncode: + raise RuntimeError(result.stderr.decode(errors="replace")) + return result.stdout + + +def advertisement(kid): + with http.open(f"{url}/adv/{kid}", timeout=10) as response: + return json.load(response) + + +def keys(adv): + return json.loads(base64.urlsafe_b64decode(adv["payload"] + "=="))["keys"] + + +def key_id(key): + return successful(["jose", "jwk", "thp", "-i-"], json.dumps(key).encode()).decode().strip() + + +old_signer = next(key for key in keys(advertisement(thumbprint)) if key_id(key) == thumbprint) +config = json.dumps({"url": url, "thp": thumbprint}) +original = successful(["clevis", "encrypt", "tang", config], plaintext) +Path(rotate_request).write_text("rotate") +deadline = time.monotonic() + 20 +while not Path(rotated_ready).exists(): + if time.monotonic() > deadline: + raise TimeoutError("Server did not rotate") + time.sleep(0.01) +assert successful(["clevis", "decrypt"], original) == plaintext, "Pre-rotation binding no longer recovers" + +selected = advertisement(thumbprint) +active = keys(selected) +assert all(key_id(key) != thumbprint for key in active), "Rotated key was advertised" +# JOSE verifies an actual old-key signature over the new active payload. +successful(["jose", "jws", "ver", "-i", json.dumps(selected), "-k-"], json.dumps(old_signer).encode()) +# A retired thp cannot pass stock Clevis's payload-based trust check, even with +# that valid signature. Pin the limitation so we never claim false compatibility. +rejected = run(["clevis", "encrypt", "tang", config], plaintext) +assert rejected.returncode != 0, "Clevis unexpectedly accepted an absent old signer" +assert f"Trusted JWK '{thumbprint}' did not sign the advertisement!" in rejected.stderr.decode(), rejected.stderr.decode() + +current = next(key_id(key) for key in active if key["alg"] == "ES512") +new_config = json.dumps({"url": url, "thp": current}) +new = successful(["clevis", "encrypt", "tang", new_config], plaintext) +assert successful(["clevis", "decrypt"], new) == plaintext, "New binding with current signing trust failed" +print("Clevis recovered the old binding and enrolled with the current thumbprint; JOSE verified the old signature") diff --git a/scripts/clevis-round-trip.py b/scripts/clevis-round-trip.py new file mode 100644 index 0000000..c3de162 --- /dev/null +++ b/scripts/clevis-round-trip.py @@ -0,0 +1,28 @@ +"""Exercise both general and explicitly selected advertisements with real Clevis.""" + +import json +import os +import subprocess +import sys + +url, thumbprint = sys.argv[1:] +plaintext = b"Tangy interoperability regression\x00\xff\n" +env = {**os.environ, "NO_PROXY": "127.0.0.1", "no_proxy": "127.0.0.1"} + +for config, flags in [({"url": url}, ["-y"]), ({"url": url, "thp": thumbprint}, [])]: + encrypted = subprocess.run( + ["clevis", "encrypt", "tang", json.dumps(config), *flags], + input=plaintext, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=env, timeout=30, + ) + if encrypted.returncode: + raise RuntimeError(f"Encrypt with {config}: {encrypted.stderr.decode(errors='replace')}") + decrypted = subprocess.run( + ["clevis", "decrypt"], input=encrypted.stdout, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=30, + ) + if decrypted.returncode: + raise RuntimeError(decrypted.stderr.decode(errors="replace")) + assert decrypted.stdout == plaintext, "Recovered plaintext differs" + +print("Clevis round trips passed with default and explicitly selected signing keys") diff --git a/scripts/fuzz-smoke.sh b/scripts/fuzz-smoke.sh new file mode 100644 index 0000000..2a4e6cc --- /dev/null +++ b/scripts/fuzz-smoke.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Requires nightly Rust and cargo-fuzz. Keep seed fixtures immutable. +set -euo pipefail +cd "$(dirname "$0")/.." + +# Prebuilt cargo-fuzz binaries can default to their own build platform (musl). +# Build and run against the installed compiler's host and standard library. +fuzz_host=$(rustc +nightly -vV | sed -n 's/^host: //p') +fuzz_lock_hashes=$(sha256sum Cargo.lock fuzz/Cargo.lock) +# Refuse dependency drift before cargo-fuzz invokes Cargo internally. +cargo +nightly fetch --locked --manifest-path fuzz/Cargo.toml --target "$fuzz_host" +export CARGO_NET_OFFLINE=true +for target in recovery_request jwk_conversion; do + mkdir -p "fuzz/corpus/$target" + cargo +nightly fuzz run --target "$fuzz_host" \ + "$target" "fuzz/corpus/$target" "fuzz/seeds/$target" -- \ + -max_total_time=30 -timeout=5 -max_len=4096 -rss_limit_mb=1024 \ + -verbosity=0 -print_final_stats=1 +done +if [[ "$fuzz_lock_hashes" != "$(sha256sum Cargo.lock fuzz/Cargo.lock)" ]]; then + echo "Fuzzing changed a dependency lockfile." >&2 + exit 1 +fi diff --git a/scripts/install-interop-tools.sh b/scripts/install-interop-tools.sh new file mode 100755 index 0000000..c5a7675 --- /dev/null +++ b/scripts/install-interop-tools.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Extract Fedora's JOSE and Clevis packages into a local prefix without installing +# system packages. Use only for the Tang interoperability tests, not LUKS setup. +set -euo pipefail + +if [[ $# -gt 1 || ${1:-} == --help ]]; then + echo "Usage: bash scripts/install-interop-tools.sh [PREFIX]" + echo "Default prefix: /tmp/tangy-lib-interop-$UID" + exit 0 +fi + +# Keep new cache files and activation output private to this installer process. +umask 077 + +for tool in dnf rpm rpm2cpio cpio curl jq python3 timeout; do + if ! command -v "$tool" >/dev/null; then + echo "Missing prerequisite: $tool. This installer requires Fedora with DNF download support." >&2 + exit 1 + fi +done + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +prefix=$(python3 "$script_dir/prepare-interop-prefix.py" "${1:-/tmp/tangy-lib-interop-$UID}") +# Isolate this run from cached RPMs, and each attempt from partial downloads. +package_staging=$(mktemp -d "$prefix/packages/run.XXXXXXXX") +trap 'rm -rf -- "$package_staging"' EXIT +package_dir="$package_staging/primary" +mkdir "$package_dir" +arch=$(rpm --eval '%{_arch}') +dnf_options=( + --setopt="cachedir=$prefix/cache" + --setopt="system_cachedir=$prefix/cache" + --setopt="persistdir=$prefix/state" + --setopt="logdir=$prefix/log" + --setopt=optional_metadata_types= + --setopt=zchunk=False + --setopt=timeout=15 + --setopt=retries=1 +) +if ! timeout 45 dnf "${dnf_options[@]}" --repo=fedora --repo=updates \ + download --arch="$arch" --destdir="$package_dir" jose libjose clevis; then + # Older Fedora releases move to the official archive. Retry there instead + # of depending on the archived release's remaining third-party mirrors. + release=$(rpm --eval '%fedora') + archive=https://archives.fedoraproject.org/pub/archive/fedora/linux + package_dir="$package_staging/archive" + mkdir "$package_dir" + timeout 180 dnf "${dnf_options[@]}" \ + --repofrompath="tangy-fedora-archive,$archive/releases/$release/Everything/$arch/os/" \ + --repofrompath="tangy-updates-archive,$archive/updates/$release/Everything/$arch/" \ + --repo=tangy-fedora-archive --repo=tangy-updates-archive \ + download --arch="$arch" --destdir="$package_dir" jose libjose clevis +fi + +for package in "$package_dir"/*.rpm; do + [[ $package == *."$arch".rpm || $package == *.noarch.rpm ]] || continue + rpm --checksig "$package" + (cd "$prefix/root" && rpm2cpio "$package" | cpio --extract --make-directories --unconditional --quiet) +done + +# Validate extracted entries before writing or sourcing the activation file. +python3 "$script_dir/prepare-interop-prefix.py" "$prefix" >/dev/null + +# Keep both command dispatch and the extracted shared libraries local. +{ + printf 'export PATH=%q:"$PATH"\n' "$prefix/root/usr/bin:$prefix/root/usr/libexec" + printf 'export LD_LIBRARY_PATH=%q${LD_LIBRARY_PATH:+:"$LD_LIBRARY_PATH"}\n' \ + "$prefix/root/usr/lib64:$prefix/root/usr/lib" +} > "$prefix/env.sh" + +# shellcheck disable=SC1091 +source "$prefix/env.sh" +jose jwk gen -i '{"alg":"ES512"}' -o /dev/null +clevis encrypt tang --summary +printf '\nTools extracted. Activate them with:\n source %q\n' "$prefix/env.sh" diff --git a/scripts/prepare-interop-prefix.py b/scripts/prepare-interop-prefix.py new file mode 100644 index 0000000..7277111 --- /dev/null +++ b/scripts/prepare-interop-prefix.py @@ -0,0 +1,87 @@ +"""Create and validate a local tool prefix before downloading or executing tools. + +Parent directories must be owned by the caller or root and protected against +other users' writes. Sticky shared directories such as /tmp are allowed as +parents. Directory handles and O_NOFOLLOW prevent traversal through symlinks. +Existing contents must be caller-owned and protected from group/other writes; +package symlinks may point only inside the prefix. +""" + +import os +from pathlib import Path +import stat +import sys + + +def check_directory(info, path, *, prefix=False): + owners = {os.getuid()} if prefix else {0, os.getuid()} + shared_sticky = not prefix and info.st_mode & stat.S_ISVTX + if info.st_uid not in owners or (info.st_mode & 0o022 and not shared_sticky): + raise ValueError(f"Unsafe directory ownership or write permissions: {path}") + + +def open_directory(parent, name, path, *, prefix=False): + try: + os.mkdir(name, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + try: + check_directory(os.fstat(fd), path, prefix=prefix) + except BaseException: + os.close(fd) + raise + return fd + + +def check_contents(prefix): + def unreadable(error): + raise error + + for directory, dirs, files in os.walk(prefix, followlinks=False, onerror=unreadable): + for name in dirs + files: + path = Path(directory) / name + info = path.lstat() + if info.st_uid != os.getuid(): + raise ValueError(f"Prefix entry belongs to another user: {path}") + if stat.S_ISLNK(info.st_mode): + if name == "env.sh" and path.parent == prefix: + raise ValueError(f"Activation file must not be a symlink: {path}") + if not path.resolve(strict=True).is_relative_to(prefix): + raise ValueError(f"Prefix symlink points outside the installation: {path}") + elif not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)): + raise ValueError(f"Prefix entry is not a file or directory: {path}") + elif info.st_mode & 0o022: + raise ValueError(f"Prefix entry is writable by other users: {path}") + + +def prepare_prefix(value): + prefix = Path(os.path.abspath(value)) + if prefix == Path("/"): + raise ValueError("The installation prefix must not be the filesystem root") + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + check_directory(os.fstat(fd), Path("/")) + current = Path("/") + for part in prefix.parts[1:]: + current /= part + next_fd = open_directory(fd, part, current, prefix=current == prefix) + os.close(fd) + fd = next_fd + check_contents(prefix) + # Safe existing 0755 prefixes may be reused, but keep new work private. + os.fchmod(fd, 0o700) + for name in ["cache", "state", "log", "packages", "root"]: + child = open_directory(fd, name, prefix / name, prefix=True) + os.close(child) + finally: + os.close(fd) + return prefix + + +if __name__ == "__main__": + try: + print(prepare_prefix(sys.argv[1])) + except (OSError, RuntimeError, ValueError) as error: + print(f"Unsafe interop prefix: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/run-python-tests.py b/scripts/run-python-tests.py new file mode 100644 index 0000000..6b40a74 --- /dev/null +++ b/scripts/run-python-tests.py @@ -0,0 +1,79 @@ +"""Run a Python unittest file and record actual outcomes as JUnit evidence.""" + +import argparse +import importlib.util +from pathlib import Path +import sys +import unittest +import xml.etree.ElementTree as ET + +sys.dont_write_bytecode = True + + +class JUnitResult(unittest.TextTestResult): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.suite = ET.Element("testsuite") + self.cases = {} + + def startTest(self, test): + super().startTest(test) + self.case(test) + + def case(self, test): + if test not in self.cases: + self.cases[test] = ET.SubElement( + self.suite, "testcase", + classname=f"{test.__class__.__module__}.{test.__class__.__qualname__}", + name=getattr(test, "_testMethodName", test.id()), + ) + return self.cases[test] + + def addFailure(self, test, err): + super().addFailure(test, err) + ET.SubElement(self.case(test), "failure").text = self._exc_info_to_string(err, test) + + def addError(self, test, err): + super().addError(test, err) + ET.SubElement(self.case(test), "error").text = self._exc_info_to_string(err, test) + + def addSkip(self, test, reason): + super().addSkip(test, reason) + # unittest reports subtest skips separately; evidence belongs to the method. + test = getattr(test, "test_case", test) + ET.SubElement(self.case(test), "skipped", message=reason) + + def addExpectedFailure(self, test, err): + super().addExpectedFailure(test, err) + ET.SubElement(self.case(test), "skipped", message="expected failure") + + def addUnexpectedSuccess(self, test): + super().addUnexpectedSuccess(test) + ET.SubElement(self.case(test), "failure", message="unexpected success") + + def addSubTest(self, test, subtest, err): + super().addSubTest(test, subtest, err) + if err is not None: + outcome = "failure" if issubclass(err[0], test.failureException) else "error" + ET.SubElement(self.case(test), outcome).text = self._exc_info_to_string(err, subtest) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("test_file", type=Path) + parser.add_argument("--junit", required=True, type=Path) + args = parser.parse_args() + + spec = importlib.util.spec_from_file_location(args.test_file.stem, args.test_file) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + tests = unittest.defaultTestLoader.loadTestsFromModule(module) + result = unittest.TextTestRunner(resultclass=JUnitResult, verbosity=2).run(tests) + args.junit.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(result.suite).write(args.junit, encoding="utf-8", xml_declaration=True) + return 0 if result.wasSuccessful() and result.testsRun else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-check-assurance.py b/scripts/test-check-assurance.py new file mode 100644 index 0000000..228c67c --- /dev/null +++ b/scripts/test-check-assurance.py @@ -0,0 +1,191 @@ +"""Regression tests for platform-specific evidence requirements (no external tools).""" +import contextlib +import importlib.util +import io +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +sys.dont_write_bytecode = True + +spec = importlib.util.spec_from_file_location("check_assurance", Path(__file__).with_name("check-assurance.py")) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class EvidenceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + (self.root / "src").mkdir() + (self.root / "assurance").mkdir() + (self.root / "Cargo.toml").write_text('[package]\nname = "example"\n') + (self.root / "src/lib.rs").write_text(''.join( + f'/// Assures: KEY-TEST-001\n#[test]\nfn test_{i}() {{}}\n' for i in range(2))) + (self.root / "assurance/assertions.toml").write_text( + '[[assertion]]\nid = "KEY-TEST-001"\nstatement = "test"\n' + 'min_evidence = 2\nrequired_reports = ["linux", "windows"]\n') + + def tearDown(self): + self.temp.cleanup() + + def check(self, reports): + args = ["check-assurance.py"] + for label, cases in reports.items(): + path = self.root / f"{label}.xml" + path.write_text('' + ''.join( + f'{outcome}' + for i, outcome in cases.items()) + '') + args.extend(["--junit", f"{label}={path}"]) + checker.errors.clear() + with patch.object(checker, "root", self.root), patch.object(sys, "argv", args), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + result = checker.main() + return result, checker.errors.copy() + + def test_each_required_report_must_meet_the_minimum(self): + self.assertEqual(self.check({"linux": {0: "", 1: ""}, "windows": {0: "", 1: ""}})[0], 0) + result, errors = self.check({"linux": {0: "", 1: ""}, "windows": {0: ""}}) + self.assertEqual(result, 1) + self.assertTrue(any("windows has 1 of 2" in error for error in errors)) + + def test_platform_evidence_cannot_be_pooled(self): + result, errors = self.check({"linux": {0: ""}, "windows": {1: ""}}) + self.assertEqual(result, 1) + self.assertTrue(any("linux has 1 of 2" in error for error in errors)) + self.assertTrue(any("windows has 1 of 2" in error for error in errors)) + + def test_missing_required_report_is_an_error(self): + result, errors = self.check({"linux": {0: "", 1: ""}}) + self.assertEqual(result, 1) + self.assertTrue(any("required report windows is missing" in error for error in errors)) + + def test_skipped_and_failed_tests_do_not_support_an_assertion(self): + for outcome in ['', '']: + result, errors = self.check({"linux": {0: "", 1: outcome}, "windows": {0: "", 1: ""}}) + self.assertEqual(result, 1) + self.assertTrue(any("linux has 1 of 2" in error for error in errors)) + + def test_python_evidence_uses_actual_outcomes(self): + scripts = self.root / "scripts" + scripts.mkdir() + source = scripts / "test-installer.py" + source.write_text(textwrap.dedent('''\ + import unittest + class InstallerTests(unittest.TestCase): + # Assures: TOOLS-TEST-001 + def test_pass(self): pass + # Assures: TOOLS-TEST-001 + def test_fail(self): self.fail("regression") + # Assures: TOOLS-TEST-001 + def test_error(self): raise ValueError("regression") + # Assures: TOOLS-TEST-001 + @unittest.skip("unavailable") + def test_skip(self): pass + # Assures: TOOLS-TEST-001 + def test_subtest(self): + with self.subTest(case=1): self.fail("regression") + # Assures: TOOLS-TEST-001 + def test_subtest_skip(self): + with self.subTest(case=1): self.skipTest("unavailable") + # Assures: TOOLS-TEST-001 + def test_nested_subtest_skip(self): + with self.subTest(outer=1): + with self.subTest(inner=1): self.skipTest("unavailable") + # Assures: TOOLS-TEST-001 + def test_partial_subtest_skip(self): + with self.subTest(case=1): self.assertTrue(True) + with self.subTest(case=2): self.skipTest("unavailable") + # Assures: TOOLS-TEST-001 + @unittest.expectedFailure + def test_expected_failure(self): self.fail("regression") + # Assures: TOOLS-TEST-001 + @unittest.expectedFailure + def test_unexpected_success(self): pass + ''')) + report = self.root / "installer.xml" + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("run-python-tests.py")), + str(source), "--junit", str(report)], capture_output=True, text=True) + self.assertEqual(result.returncode, 1, result.stderr) + checker.errors.clear() + with patch.object(checker, "root", self.root): + tags = checker.tagged_tests("example") + results = checker.junit_results("installer", report) + binary = "test-installer.InstallerTests" + self.assertEqual(len([key for key in tags if key[0] == binary]), 10) + expected = {"pass": "passed", "fail": "failed", "error": "failed", + "skip": "skipped", "subtest": "failed", "subtest_skip": "skipped", + "nested_subtest_skip": "skipped", "partial_subtest_skip": "skipped", + "expected_failure": "skipped", + "unexpected_success": "failed"} + self.assertEqual(len(results), len(expected)) + for name, outcome in expected.items(): + self.assertEqual(results[binary, f"test_{name}"], outcome) + self.assertEqual(checker.errors, []) + + (self.root / "assurance/assertions.toml").write_text( + '[[assertion]]\nid = "TOOLS-TEST-001"\nstatement = "test"\n' + 'min_evidence = 2\nrequired_reports = ["installer"]\n') + args = ["check-assurance.py", "--junit", f"installer={report}"] + checker.errors.clear() + with patch.object(checker, "root", self.root), patch.object(sys, "argv", args), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(checker.main(), 1) + self.assertTrue(any("installer has 1 of 2" in error for error in checker.errors)) + + def test_python_tags_must_be_attached_to_test_methods(self): + scripts = self.root / "scripts" + scripts.mkdir() + (scripts / "test-invalid.py").write_text( + '# Assures: KEY-TEST-001\ndef helper(): pass\n' + 'example = """\n# Assures: NOT-A-TAG-001\n"""\n') + checker.errors.clear() + with patch.object(checker, "root", self.root): + self.assertEqual(checker.python_tagged_tests(), {}) + self.assertEqual(len(checker.errors), 1) + self.assertIn("not attached to a unittest test method", checker.errors[0]) + + def test_python_assertion_is_supported_only_when_the_test_is_reported(self): + (self.root / "src/lib.rs").unlink() + scripts = self.root / "scripts" + scripts.mkdir() + source = scripts / "test-installer.py" + source.write_text( + 'import unittest\nclass InstallerTests(unittest.TestCase):\n' + ' # Assures: TOOLS-TEST-001\n def test_safe(self): pass\n') + (self.root / "assurance/assertions.toml").write_text( + '[[assertion]]\nid = "TOOLS-TEST-001"\nstatement = "test"\n' + 'min_evidence = 1\nrequired_reports = ["installer"]\n') + report = self.root / "installer.xml" + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("run-python-tests.py")), + str(source), "--junit", str(report)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + args = ["check-assurance.py", "--junit", f"installer={report}"] + for expected in [0, 1]: + checker.errors.clear() + with patch.object(checker, "root", self.root), patch.object(sys, "argv", args), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(checker.main(), expected) + report.write_text('') + self.assertTrue(any("tagged but absent" in error for error in checker.errors)) + + def test_python_fixture_errors_are_reported(self): + source = self.root / "test-broken-fixture.py" + source.write_text( + 'import unittest\nclass BrokenTests(unittest.TestCase):\n' + ' @classmethod\n def setUpClass(cls): raise ValueError("broken fixture")\n' + ' def test_unreached(self): pass\n') + report = self.root / "fixture.xml" + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("run-python-tests.py")), + str(source), "--junit", str(report)], capture_output=True, text=True) + self.assertEqual(result.returncode, 1, result.stderr) + results = checker.junit_results("installer", report) + self.assertEqual(list(results.values()), ["failed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-prepare-interop-prefix.py b/scripts/test-prepare-interop-prefix.py new file mode 100644 index 0000000..c7dcd4c --- /dev/null +++ b/scripts/test-prepare-interop-prefix.py @@ -0,0 +1,226 @@ +"""Exercise installer prefix safety without downloading or running package tools.""" + +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import patch + +sys.dont_write_bytecode = True +script = Path(__file__).with_name("prepare-interop-prefix.py") +spec = importlib.util.spec_from_file_location("interop_prefix", script) +helper = importlib.util.module_from_spec(spec) +spec.loader.exec_module(helper) + + +class PrefixTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="tangy-prefix-test-", dir="/tmp") + self.root = Path(self.temp.name) + + def tearDown(self): + self.temp.cleanup() + + # Assures: INTEROP-PREFIX-001 + def test_creation_is_private_even_with_a_permissive_umask(self): + prefix = self.root / "new-parent" / "prefix" + result = subprocess.run( + [sys.executable, str(script), str(prefix)], umask=0, + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(prefix)) + for path in [prefix.parent, prefix, *(prefix / name for name in ["cache", "state", "log", "packages", "root"])]: + self.assertEqual(path.stat().st_mode & 0o777, 0o700) + + # Assures: INTEROP-PREFIX-001 + def test_safe_existing_installation_is_reusable(self): + prefix = self.root / "prefix" + prefix.mkdir(mode=0o755) + (prefix / "env.sh").write_text("# trusted activation\n") + self.assertEqual(helper.prepare_prefix(prefix), prefix) + self.assertEqual(prefix.stat().st_mode & 0o777, 0o700) + self.assertEqual(helper.prepare_prefix(prefix), prefix) + + # Assures: INTEROP-PREFIX-001 + def test_symlinked_prefix_and_parent_are_rejected_without_touching_target(self): + target = self.root / "target" + target.mkdir() + (target / "sentinel").write_text("unchanged") + alias = self.root / "alias" + alias.symlink_to(target, target_is_directory=True) + for path in [alias, alias / "prefix"]: + with self.subTest(path=path), self.assertRaises(OSError): + helper.prepare_prefix(path) + self.assertEqual(list(target.iterdir()), [target / "sentinel"]) + + # Assures: INTEROP-PREFIX-001 + def test_group_or_world_writable_prefixes_are_rejected_without_chmod(self): + prefix = self.root / "prefix" + prefix.mkdir() + for mode in [0o777, 0o770, 0o707]: + prefix.chmod(mode) + with self.subTest(mode=mode), self.assertRaises(ValueError): + helper.prepare_prefix(prefix) + self.assertEqual(prefix.stat().st_mode & 0o777, mode) + + # Assures: INTEROP-PREFIX-001 + def test_writable_nonsticky_parent_is_rejected(self): + parent = self.root / "shared" + parent.mkdir() + parent.chmod(0o777) + with self.assertRaises(ValueError): + helper.prepare_prefix(parent / "prefix") + self.assertFalse((parent / "prefix").exists()) + + # Assures: INTEROP-PREFIX-001 + def test_sticky_shared_parent_is_allowed(self): + parent = self.root / "shared" + parent.mkdir() + parent.chmod(0o1777) + prefix = parent / "prefix" + self.assertEqual(helper.prepare_prefix(prefix), prefix) + self.assertEqual(prefix.stat().st_mode & 0o777, 0o700) + + # Assures: INTEROP-PREFIX-001 + def test_foreign_owned_prefix_is_rejected(self): + # The prefix is directly below root-owned /tmp, so the ownership check + # applies to this prefix rather than an intermediate test directory. + with patch.object(helper.os, "getuid", return_value=os.getuid() + 1): + with self.assertRaises(ValueError): + helper.prepare_prefix(self.root) + self.assertEqual(list(self.root.iterdir()), []) + + # Assures: INTEROP-PREFIX-001 + def test_unsafe_existing_contents_are_rejected(self): + prefix = self.root / "prefix" + prefix.mkdir() + unsafe = prefix / "env.sh" + unsafe.write_text("untrusted activation") + unsafe.chmod(0o666) + with self.assertRaises(ValueError): + helper.prepare_prefix(prefix) + unsafe.unlink() + outside = self.root / "outside" + outside.write_text("unchanged") + unsafe.symlink_to(outside) + with self.assertRaises(ValueError): + helper.prepare_prefix(prefix) + self.assertEqual(outside.read_text(), "unchanged") + + # Assures: INTEROP-PREFIX-001 + def test_external_package_links_and_symlinked_work_directories_are_rejected(self): + prefix = self.root / "prefix" + prefix.mkdir() + outside = self.root / "outside" + outside.mkdir() + link = prefix / "library.so" + link.symlink_to(outside, target_is_directory=True) + with self.assertRaises(ValueError): + helper.prepare_prefix(prefix) + link.unlink() + (prefix / "inside").mkdir() + (prefix / "cache").symlink_to(prefix / "inside", target_is_directory=True) + with self.assertRaises(OSError): + helper.prepare_prefix(prefix) + + # Assures: INTEROP-PREFIX-001 + def test_contained_package_library_symlinks_are_supported(self): + prefix = self.root / "prefix" + prefix.mkdir() + library = prefix / "libjose.so.0.0.0" + library.write_text("library") + (prefix / "libjose.so.0").symlink_to(library.name) + helper.prepare_prefix(prefix) + + # Assures: INTEROP-PREFIX-001 + @unittest.skipIf(os.getuid() == 0, "root can read directories regardless of their mode") + def test_unreadable_existing_contents_are_rejected(self): + prefix = self.root / "prefix" + prefix.mkdir() + unreadable = prefix / "unreadable" + unreadable.mkdir() + unreadable.chmod(0) + try: + with self.assertRaises(PermissionError): + helper.prepare_prefix(prefix) + finally: + unreadable.chmod(0o700) + + # Assures: INTEROP-PREFIX-001 + def test_installer_refuses_unsafe_prefix_before_executing_tools(self): + bin_dir = self.root / "bin" + bin_dir.mkdir() + marker = self.root / "tool-executed" + for name in ["dnf", "rpm", "rpm2cpio", "cpio", "curl", "jq", "timeout"]: + tool = bin_dir / name + tool.write_text('#!/bin/sh\nprintf executed > "$TOOL_MARKER"\nexit 1\n') + tool.chmod(0o700) + target = self.root / "target" + target.mkdir() + alias = self.root / "alias" + alias.symlink_to(target, target_is_directory=True) + result = subprocess.run( + ["bash", str(script.with_name("install-interop-tools.sh")), str(alias)], + env={**os.environ, "PATH": f'{bin_dir}:{os.environ["PATH"]}', "TOOL_MARKER": str(marker)}, + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsafe interop prefix", result.stderr) + self.assertFalse(marker.exists()) + self.assertEqual(list(target.iterdir()), []) + + # Assures: INTEROP-PACKAGES-001 + def test_installer_extracts_only_packages_from_the_successful_download(self): + bin_dir = self.root / "bin" + bin_dir.mkdir() + stub = f"#!{sys.executable}\n" + textwrap.dedent('''\ + import os + from pathlib import Path + import sys + + tool = Path(sys.argv[0]).name + if tool == "timeout": + os.execvp(sys.argv[2], sys.argv[2:]) + elif tool == "rpm" and sys.argv[1] == "--eval": + print("x86_64" if sys.argv[2] == "%{_arch}" else "42") + elif tool == "dnf": + destination = Path(next(arg.split("=", 1)[1] for arg in sys.argv + if arg.startswith("--destdir="))) + fail = os.environ["FAIL_PRIMARY"] == "1" and "--repo=fedora" in sys.argv + release = "9" if fail else "10" + (destination / f"jose-1-{release}.x86_64.rpm").write_text("package") + sys.exit(1 if fail else 0) + elif tool == "rpm2cpio": + print(Path(sys.argv[1]).name) + elif tool == "cpio": + with open(os.environ["EXTRACTED_PACKAGES"], "a") as output: + output.write(sys.stdin.read()) + ''') + for name in ["dnf", "rpm", "rpm2cpio", "cpio", "curl", "jq", "timeout", "jose", "clevis"]: + tool = bin_dir / name + tool.write_text(stub) + tool.chmod(0o700) + for fail_primary in [False, True]: + with self.subTest(fail_primary=fail_primary): + prefix = self.root / f"prefix-{fail_primary}" + helper.prepare_prefix(prefix) + stale = prefix / "packages" / "jose-1-9.x86_64.rpm" + stale.write_text("old package") + extracted = self.root / f"extracted-{fail_primary}" + result = subprocess.run( + ["bash", str(script.with_name("install-interop-tools.sh")), str(prefix)], + env={**os.environ, "PATH": f'{bin_dir}:{os.environ["PATH"]}', + "FAIL_PRIMARY": str(int(fail_primary)), "EXTRACTED_PACKAGES": str(extracted)}, + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(extracted.read_text().splitlines(), ["jose-1-10.x86_64.rpm"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..456f263 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::{fmt, io, path::PathBuf}; + +/// A fallible Tang operation. +pub type Result = std::result::Result; + +/// A supported server-key algorithm, used for generation and operation requirements. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Algorithm { + Es512, + Ecmr, +} + +impl Algorithm { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Es512 => "ES512", + Self::Ecmr => "ECMR", + } + } +} + +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The JWK operation required of a server key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum KeyOperation { + Sign, + Verify, + DeriveKey, +} + +impl KeyOperation { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Sign => "sign", + Self::Verify => "verify", + Self::DeriveKey => "deriveKey", + } + } +} + +impl fmt::Display for KeyOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The persistence operation that failed while creating and publishing key files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum WriteStep { + /// Create a temporary key file and apply its initial permissions. + Create, + /// Write the private key JSON to its temporary file. + Write, + /// Flush file contents or the containing directory to persistent storage. + Synchronize, + /// Rename a complete temporary key file to its final filename. + Publish, +} + +impl fmt::Display for WriteStep { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Create => "create", + Self::Write => "write", + Self::Synchronize => "synchronize", + Self::Publish => "publish", + }) + } +} + +/// The operation for which randomness could not be obtained. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RandomPurpose { + KeyGeneration, + AdvertisementSigning, +} + +impl fmt::Display for RandomPurpose { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::KeyGeneration => "key generation", + Self::AdvertisementSigning => "advertisement signing", + }) + } +} + +/// The origin of a stored key. Active and rotated input indices are zero-based. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum KeyLocation { + File(PathBuf), + Input(usize), + RotatedInput(usize), +} + +impl fmt::Display for KeyLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File(path) => write!(f, "{}", path.display()), + Self::Input(index) => write!(f, "input index {index}"), + Self::RotatedInput(index) => write!(f, "rotated input index {index}"), + } + } +} + +/// A JSON decoding failure with diagnostics that never echo input values. +/// +/// Serde's ordinary type errors can quote private values. This wrapper retains +/// the original error but exposes only its category and position when formatted. +/// It deliberately terminates the source chain to prevent accidental disclosure. +pub struct JsonError(pub(crate) serde_json::Error); + +impl JsonError { + pub fn category(&self) -> serde_json::error::Category { + self.0.classify() + } + + pub fn line(&self) -> usize { + self.0.line() + } + + pub fn column(&self) -> usize { + self.0.column() + } +} + +impl fmt::Display for JsonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "JSON {:?} error at line {}, column {}", + self.category(), + self.line(), + self.column() + ) + } +} + +impl fmt::Debug for JsonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl std::error::Error for JsonError {} + +/// Errors distinguish client inputs, server key configuration, and persistence. +/// Private key values are never included in library-generated diagnostics. +/// Validation reasons and serialization contexts are diagnostic text, not +/// stable values for matching. Match variants and operation enums instead. +/// +/// Stored-key validation causes can be matched directly without downcasting: +/// +/// ``` +/// use tangy_lib::{Algorithm, Error, KeyLocation, KeyOperation}; +/// +/// let error = Error::InvalidStoredKey { +/// location: KeyLocation::Input(2), +/// source: Box::new(Error::ForbiddenOperation { +/// kid: "key-id".into(), +/// algorithm: Algorithm::Es512, +/// operation: KeyOperation::Sign, +/// }), +/// }; +/// let missing_permission = match &error { +/// Error::InvalidStoredKey { source, .. } => match source.as_ref() { +/// Error::ForbiddenOperation { operation, .. } => Some(*operation), +/// _ => None, +/// }, +/// _ => None, +/// }; +/// assert_eq!(missing_permission, Some(KeyOperation::Sign)); +/// ``` +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + #[error("key {kid} was not found")] + KeyNotFound { kid: String }, + + /// Each flag is true when that key role is missing. + #[error("incomplete key set (missing signing: {signing}, missing recovery: {recovery})")] + MissingKeys { signing: bool, recovery: bool }, + + #[error("duplicate key {kid} at {first} and {second}")] + DuplicateKey { + kid: String, + first: KeyLocation, + second: KeyLocation, + }, + + #[error("key entry {} is not a regular file", path.display())] + NotAFile { path: PathBuf }, + + #[error("invalid stored key at {location}")] + InvalidStoredKey { + location: KeyLocation, + #[source] + source: Box, + }, + + #[error("invalid key: {reason}")] + InvalidKey { + reason: &'static str, + #[source] + source: Option>, + }, + + #[error("invalid recovery request: {reason}")] + InvalidRequest { + reason: &'static str, + #[source] + source: Option>, + }, + + #[error("key {kid} does not permit {operation} with {algorithm}")] + ForbiddenOperation { + kid: String, + algorithm: Algorithm, + operation: KeyOperation, + }, + + #[error("unable to enumerate key directory {}", path.display())] + KeyDirectory { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("unable to read key file {}", path.display())] + KeyRead { + path: PathBuf, + #[source] + source: io::Error, + }, + + #[error("unable to {operation} key file {}", path.display())] + KeyWrite { + path: PathBuf, + operation: WriteStep, + #[source] + source: io::Error, + }, + + #[error("unable to serialize {context}")] + Serialization { + /// Diagnostic text describing the serialized value; not a matching API. + context: &'static str, + #[source] + source: serde_json::Error, + }, + + #[error("unable to sign advertisement with key {kid}")] + Signing { + kid: String, + #[source] + source: Box, + }, + + #[error("unable to obtain randomness for {operation}")] + Randomness { + operation: RandomPurpose, + #[source] + source: Box, + }, +} + +impl Error { + pub(crate) fn invalid_key(reason: &'static str) -> Self { + Self::InvalidKey { + reason, + source: None, + } + } + + pub(crate) fn key_cause( + reason: &'static str, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::InvalidKey { + reason, + source: Some(Box::new(source)), + } + } + + pub(crate) fn request(reason: &'static str) -> Self { + Self::InvalidRequest { + reason, + source: None, + } + } + + pub(crate) fn request_cause( + reason: &'static str, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::InvalidRequest { + reason, + source: Some(Box::new(source)), + } + } +} diff --git a/src/jwk.rs b/src/jwk.rs new file mode 100644 index 0000000..4f037ce --- /dev/null +++ b/src/jwk.rs @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Translate between JSON Web Keys (JWKs) and typed P-521 cryptographic keys. +//! +//! Tang stores keys and exchanges public points as JavaScript Object Notation +//! (JSON). This module owns that representation, its field encoding and its key +//! identities, so key storage and protocol responses use the same conversions. +//! File loading and persistence belong to the key-store module; advertisement +//! signing and recovery calculations belong to the protocol module. +//! +//! [`MyJwkEcKey`] holds public coordinates (`x` and `y`), an optional private +//! scalar (`d`) and metadata describing the key. Deserialization checks the JSON +//! structure. The typed conversion methods check the cryptographic material: +//! `kty` must be `EC`, `crv` must be `P-521`, and coordinates must be valid curve +//! points encoded as exactly 66 bytes each using URL-safe Base64 without padding. +//! Public conversion ignores `d`. Private conversion also requires a 66-byte, +//! nonzero scalar below the curve order whose public point matches `x` and `y`. +//! Key-role rules for `alg` and `key_ops` are enforced by the key-store and +//! protocol modules when they accept keys for a particular operation. +//! +//! [`create_new_key_set`] generates two private JWK strings: an `ES512` signing +//! key permitting `sign` and `verify`, and an elliptic-curve message recovery +//! (`ECMR`) key permitting `deriveKey`. It obtains randomness through the shared +//! fallible seeding helper and returns the strings for callers to store or load. +//! +//! [`MyJwkEcKey::thumbprint`] computes the key identity used throughout the +//! library. Following RFC 7638, it serializes only `crv`, `kty`, `x` and `y` in +//! that order, hashes the JSON with SHA-256 and encodes the digest as URL-safe +//! Base64 without padding. Private material and optional metadata therefore do +//! not change the identity. Thumbprinting hashes the supplied fields; key +//! validation happens through the typed conversions described above. +//! +//! [`MyJwkEcKey::to_public_key`] copies public fields and metadata while omitting +//! `d`, and debug formatting redacts `d`. Internal parsing, decoding and generation +//! use zeroizing wrappers for private scalar temporaries. The public struct and +//! generated strings remain caller-owned; those values are not automatically +//! erased when dropped. + +use base64ct::{Base64UrlUnpadded, Encoding}; +use p521::elliptic_curve::{sec1::ToSec1Point, Generate}; +use rand_chacha::ChaCha20Rng; +use serde::Serialize; +use sha2::Digest; +use zeroize::{Zeroize, Zeroizing}; + +use crate::{ + support::{crypto_rng, serialize, serialize_string}, + Algorithm, Error, KeyOperation, RandomPurpose, Result, +}; + +/// Generate private signing and recovery JWKs without writing files. +/// Operating-system entropy and serialization failures are returned to the caller. +pub fn create_new_key_set() -> Result> { + create_new_key_set_with_rng(&mut getrandom::fill) +} + +pub(crate) fn create_new_key_set_with_rng( + entropy: &mut impl FnMut(&mut [u8]) -> std::result::Result<(), E>, +) -> Result> { + let mut rng = crypto_rng(entropy, RandomPurpose::KeyGeneration)?; + let mut signing = Zeroizing::new(create_new_jwk(Algorithm::Es512, &mut rng)?); + let mut recovery = Zeroizing::new(create_new_jwk(Algorithm::Ecmr, &mut rng)?); + Ok(vec![ + std::mem::take(&mut *signing), + std::mem::take(&mut *recovery), + ]) +} + +fn create_new_jwk(algorithm: Algorithm, rng: &mut ChaCha20Rng) -> Result { + let key_ops: &[KeyOperation] = match algorithm { + Algorithm::Es512 => &[KeyOperation::Sign, KeyOperation::Verify], + Algorithm::Ecmr => &[KeyOperation::DeriveKey], + }; + let secret = p521::SecretKey::generate_from_rng(rng); + let mut jwk = Zeroizing::new(format_public_key(&secret.public_key())?); + jwk.d = Some(Base64UrlUnpadded::encode_string(&Zeroizing::new( + secret.to_bytes(), + ))); + jwk.alg = Some(algorithm.as_str().into()); + jwk.key_ops = Some(key_ops.iter().map(|op| op.as_str().into()).collect()); + serialize_string(&*jwk, "generated private key") +} + +pub(crate) fn format_public_key(public: &p521::PublicKey) -> Result { + let point = public.to_sec1_point(false); + let x = point + .x() + .ok_or_else(|| Error::invalid_key("public point has no x coordinate"))?; + let y = point + .y() + .ok_or_else(|| Error::invalid_key("public point has no y coordinate"))?; + Ok(MyJwkEcKey { + alg: None, + kty: "EC".into(), + crv: "P-521".into(), + x: Base64UrlUnpadded::encode_string(x), + y: Base64UrlUnpadded::encode_string(y), + d: None, + key_ops: None, + use_: None, + kid: None, + x5u: None, + x5c: None, + x5t: None, + x5t_s256: None, + }) +} + +/// An elliptic-curve JSON Web Key. Deserialization alone does not validate key material. +/// Use the typed conversion methods to validate P-521 coordinates and scalars. +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub struct MyJwkEcKey { + /// The `crv` parameter which identifies a particular elliptic curve + /// as defined in RFC 7518 Section 6.2.1.1: + /// + pub crv: String, + + /// The x-coordinate of the elliptic curve point which is the public key + /// value associated with this JWK as defined in RFC 7518 6.2.1.2: + /// + pub x: String, + + /// The y-coordinate of the elliptic curve point which is the public key + /// value associated with this JWK as defined in RFC 7518 6.2.1.3: + /// + pub y: String, + + /// The `d` ECC private key parameter as described in RFC 7518 6.2.2.1: + /// + /// + /// Value is optional and if omitted, this JWK represents a public key. + /// + /// Inner value is encoded according to the `Integer-to-Octet-String` + /// conversion as defined in SEC1 section 2.3.7: + /// + #[serde(skip_serializing_if = "Option::is_none")] + pub d: Option, + + /// Required Key Type (must be "EC") as described in RFC 7517 4.1: + /// + /// + /// Required for deserialization and P-521 conversion. + pub kty: String, + + /// The Public Key Use as described in RFC 7517 4.2: + /// + /// + /// Value is optional and not used by this crate. + #[serde(skip_serializing_if = "Option::is_none", rename = "use")] + pub use_: Option, + + /// The Key Operations as described in RFC 7517 4.3: + /// + /// + /// Stored keys require appropriate operations. Recovery requests may omit + /// this field, but explicit operations must permit derivation. + #[serde(skip_serializing_if = "Option::is_none")] + pub key_ops: Option>, + + /// The Algorithm as described in RFC 7517 4.4: + /// + /// + /// Stored keys must use ES512 or ECMR. Recovery requests may omit this field, + /// but an explicit algorithm must be ECMR. + #[serde(skip_serializing_if = "Option::is_none")] + pub alg: Option, + + /// The Key ID as described in RFC 7517 4.5: + /// + /// + /// Value is optional and not used by this crate. + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, + + /// The X.509 URL as described in RFC 7517 4.6: + /// + /// + /// Value is optional and not used by this crate. + #[serde(skip_serializing_if = "Option::is_none")] + pub x5u: Option, + + /// The X.509 Certificate Chain as described in RFC 7517 4.7: + /// + /// + /// Value is optional and not used by this crate. + #[serde(skip_serializing_if = "Option::is_none")] + pub x5c: Option, + + /// The X.509 Certificate SHA-1 Thumbprint as described in RFC 7517 4.8: + /// + /// + /// Value is optional and not used by this crate. + #[serde(skip_serializing_if = "Option::is_none")] + pub x5t: Option, + + /// The X.509 Certificate SHA-256 Thumbprint as described in RFC 7517 4.9: + /// + /// + /// Value is optional and not used by this crate. + #[serde(skip_serializing_if = "Option::is_none")] + pub x5t_s256: Option, +} + +impl std::fmt::Debug for MyJwkEcKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MyJwkEcKey") + .field("crv", &self.crv) + .field("kty", &self.kty) + .field("x", &self.x) + .field("y", &self.y) + .field("alg", &self.alg) + .field("key_ops", &self.key_ops) + .field("use", &self.use_) + .field("kid", &self.kid) + .field("x5u", &self.x5u) + .field("x5c", &self.x5c) + .field("x5t", &self.x5t) + .field("x5t_s256", &self.x5t_s256) + .field("d", &self.d.as_ref().map(|_| "[REDACTED]")) + .finish() + } +} + +impl Zeroize for MyJwkEcKey { + fn zeroize(&mut self) { + self.d.zeroize(); + } +} + +pub(crate) fn parse_private_jwk( + json: &str, +) -> std::result::Result, serde_json::Error> { + #[derive(serde::Deserialize)] + struct Fields { + crv: String, + x: String, + y: String, + kty: String, + d: Option>, + #[serde(rename = "use")] + use_: Option, + key_ops: Option>, + alg: Option, + kid: Option, + x5u: Option, + x5c: Option, + x5t: Option, + x5t_s256: Option, + } + let mut fields: Fields = serde_json::from_str(json)?; + Ok(Zeroizing::new(MyJwkEcKey { + crv: fields.crv, + x: fields.x, + y: fields.y, + kty: fields.kty, + d: fields.d.as_mut().map(|d| std::mem::take(&mut **d)), + use_: fields.use_, + key_ops: fields.key_ops, + alg: fields.alg, + kid: fields.kid, + x5u: fields.x5u, + x5c: fields.x5c, + x5t: fields.x5t, + x5t_s256: fields.x5t_s256, + })) +} + +// Fixed-size decoding bounds allocations and zeroizes partial private output +// on every failure path. Never attach input values to diagnostics. +fn decode_jwk_field(value: &str) -> Result> { + let mut bytes = Zeroizing::new([0u8; 66]); + let decoded = Base64UrlUnpadded::decode(value, bytes.as_mut()) + .map_err(|source| Error::key_cause("invalid JWK field encoding", source))?; + if decoded.len() != 66 { + return Err(Error::invalid_key( + "JWK fields must encode exactly 66 bytes", + )); + } + Ok(bytes) +} + +impl MyJwkEcKey { + /// Validate and convert the public coordinates of a P-521 JWK. + /// Coordinates must be unpadded Base64URL encoding exactly 66 bytes each. + /// The private scalar, if present, is ignored. + pub fn to_p521_public_key(&self) -> Result { + if self.kty != "EC" { + return Err(Error::invalid_key("key type must be EC")); + } + if self.crv != "P-521" { + return Err(Error::invalid_key("curve must be P-521")); + } + let x = decode_jwk_field(&self.x)?; + let y = decode_jwk_field(&self.y)?; + let encoded: Vec = std::iter::once(4) + .chain(x.iter().copied()) + .chain(y.iter().copied()) + .collect(); + p521::PublicKey::from_sec1_bytes(&encoded) + .map_err(|source| Error::key_cause("invalid public point", source)) + } + + /// Validate and convert a P-521 private JWK, including its public point. + /// The private scalar must encode exactly 66 bytes in unpadded Base64URL, + /// be nonzero and below the curve order, and match the public coordinates. + /// + /// ``` + /// use tangy_lib::{create_new_key_set, MyJwkEcKey}; + /// + /// for json in create_new_key_set()? { + /// let jwk: MyJwkEcKey = serde_json::from_str(&json)?; + /// let secret = jwk.to_p521_secret_key()?; + /// let public = jwk.to_p521_public_key()?; + /// assert_eq!(secret.public_key(), public); + /// } + /// # Ok::<(), Box>(()) + /// ``` + pub fn to_p521_secret_key(&self) -> Result { + let public = self.to_p521_public_key()?; + let d = self + .d + .as_deref() + .ok_or_else(|| Error::invalid_key("private scalar is missing"))?; + let bytes = decode_jwk_field(d)?; + let secret = p521::SecretKey::from_slice(bytes.as_ref()) + .map_err(|source| Error::key_cause("invalid private scalar", source))?; + if secret.public_key() != public { + return Err(Error::invalid_key( + "private scalar does not match public point", + )); + } + Ok(secret) + } + + /// Copy the JWK fields while omitting the private scalar. + /// This projection does not validate the key or change its operation metadata. + pub fn to_public_key(&self) -> MyJwkEcKey { + Self { + crv: self.crv.clone(), + x: self.x.clone(), + y: self.y.clone(), + d: None, + kty: self.kty.clone(), + use_: self.use_.clone(), + key_ops: self.key_ops.clone(), + alg: self.alg.clone(), + kid: self.kid.clone(), + x5u: self.x5u.clone(), + x5c: self.x5c.clone(), + x5t: self.x5t.clone(), + x5t_s256: self.x5t_s256.clone(), + } + } + + /// Compute a SHA-256 RFC 7638 thumbprint from the required public fields. + /// Private material and optional metadata do not affect the identity. + /// This hashes the supplied fields without validating their key material. + pub fn thumbprint(&self) -> Result { + #[derive(Serialize)] + struct Required<'a> { + crv: &'a str, + kty: &'a str, + x: &'a str, + y: &'a str, + } + let fields = Required { + crv: &self.crv, + kty: &self.kty, + x: &self.x, + y: &self.y, + }; + Ok(Base64UrlUnpadded::encode_string(&sha2::Sha256::digest( + serialize(&fields, "key thumbprint")?, + ))) + } +} diff --git a/src/key_store.rs b/src/key_store.rs new file mode 100644 index 0000000..4938639 --- /dev/null +++ b/src/key_store.rs @@ -0,0 +1,638 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Load, validate and persist the private keys used by a Tang server. +//! +//! This module turns [`KeySource`] inputs into a key set for [`TangyLib`]. The +//! JWK module handles JSON Web Key representation and cryptographic conversion; +//! this module applies server-key roles, tracks active and rotated keys, rejects +//! duplicate identities, and manages directory storage. +//! +//! [`KeySource::LocalDir`] reads an existing directory in sorted path order for +//! deterministic diagnostics. Filenames ending in `.jwk` are key entries; a +//! leading `.` marks a rotated key. Other entries, including temporary `.tmp` +//! files left by interrupted publication, are ignored. Directory roots and key +//! paths follow symbolic links, and each key entry must resolve to a regular +//! file. Enumeration and read failures propagate with their paths. +//! +//! [`KeySource::Vector`] treats every supplied key as active, while +//! [`KeySource::KeySets`] accepts separate active and rotated slices. Both load +//! directly from strings without generating or writing keys. Every input passes +//! the same validation: a P-521 private key must match its public coordinates; +//! signing keys require `ES512` with `sign` and `verify`; recovery keys require +//! elliptic-curve message recovery (`ECMR`) with `deriveKey`. Repeated public-key +//! thumbprints fail even for identical copies or keys in different sets. +//! [`Error::InvalidStoredKey`] identifies the file or input index and retains +//! its validation cause; [`Error::DuplicateKey`] identifies both origins. +//! +//! Validated entries retain typed private keys, public metadata and their active +//! status. Advertised operations are `verify` for signing keys and `deriveKey` +//! for recovery keys; recovery keys omit `use` so it cannot add verification +//! permission. Public metadata omits the private scalar; +//! debug output redacts typed private material. Private key strings read from +//! disk and parsed scalar temporaries use zeroizing wrappers. The protocol +//! module uses these entries for advertisements and recovery, and requires both +//! an active signing key and an active recovery key. Rotated keys remain +//! available to protocol operations but do not satisfy those active-role checks. +//! +//! Directory initialization generates a new pair only when there are no active +//! or rotated `.jwk` entries. Corrupt, unreadable, incomplete and rotated-only +//! stores return errors without generating replacements. Loading an existing +//! valid set requires no writes. Rotation is supplied through filenames or +//! explicit input sets; this module does not rotate keys automatically. +//! +//! For an empty directory, generation, validation and construction of the default +//! advertisement finish before writing starts. Both private keys are written to +//! newly created `.tangy-.tmp` files and synchronized before either is +//! renamed to `.jwk`. On Unix, the directory is then synchronized. +//! Initialization succeeds only after these steps finish. A reported publication +//! failure triggers best-effort removal of files created by that call and returns +//! the original error, including its [`WriteStep`]. +//! +//! Publication makes each key file complete or absent; the pair is not one atomic +//! transaction. A crash before the first rename leaves ignored temporary files +//! and permits generation on restart. A crash between renames can leave an +//! incomplete store that reports [`Error::MissingKeys`] until repaired. Like +//! Tang, initialization assumes one process controls the store: simultaneous +//! first starts and concurrent operator edits are unsupported, and no lock is used. +//! +//! Unix files are created with restrictive permissions and set to mode `0440` +//! through their open handles, allowing owner and group reads independently of +//! the process's file-creation mask. Windows files inherit directory access +//! control lists, so callers must secure the directory before initialization. +//! File contents are synchronized on both platforms; Windows omits directory +//! synchronization and has weaker durability for filename changes after a crash. + +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, +}; + +use p521::ecdsa::SigningKey; +use zeroize::Zeroizing; + +use crate::{ + create_new_key_set, jwk::parse_private_jwk, Algorithm, Error, JsonError, KeyLocation, + KeyOperation, MyJwkEcKey, Result, TangyLib, WriteStep, +}; + +// Only public JSON is retained; private material is parsed once at init. +#[derive(Debug)] +pub(crate) struct StoredKey { + pub(crate) public: MyJwkEcKey, + pub(crate) material: KeyMaterial, + pub(crate) active: bool, +} + +// Avoid formatting crypto private keys, including through TangyLib's Debug. +pub(crate) enum KeyMaterial { + Signing(SigningKey), + Recovery(p521::SecretKey), +} + +impl std::fmt::Debug for KeyMaterial { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Signing(_) => "Signing([REDACTED])", + Self::Recovery(_) => "Recovery([REDACTED])", + }) + } +} + +/// Private key inputs. Every set must have active signing and recovery keys. +#[derive(PartialEq)] +pub enum KeySource<'a> { + /// Load `.jwk` entries; dot-prefixed filenames identify rotated keys. + /// A genuinely empty store generates and persists a new pair. + /// Only one process may initialize an empty store at a time. + LocalDir(&'a Path), + /// Load an entirely active slice without generating or writing keys. + Vector(&'a [&'a str]), + /// Load explicit active and rotated slices without persistence or generation. + KeySets { + active: &'a [&'a str], + rotated: &'a [&'a str], + }, +} + +fn require_operation( + key: &MyJwkEcKey, + kid: &str, + algorithm: Algorithm, + operation: KeyOperation, +) -> Result<()> { + if key.alg.as_deref() != Some(algorithm.as_str()) + || !key + .key_ops + .as_ref() + .is_some_and(|ops| ops.iter().any(|op| op == operation.as_str())) + { + return Err(Error::ForbiddenOperation { + kid: kid.into(), + algorithm, + operation, + }); + } + Ok(()) +} + +pub(crate) fn parse_stored_key(json: &str) -> Result> { + parse_private_jwk(json).map_err(|source| Error::key_cause("invalid JSON", JsonError(source))) +} + +fn validate_stored_key(jwk: Zeroizing, kid: &str) -> Result { + match jwk.alg.as_deref() { + Some("ES512") => { + require_operation(&jwk, kid, Algorithm::Es512, KeyOperation::Sign)?; + require_operation(&jwk, kid, Algorithm::Es512, KeyOperation::Verify)?; + } + Some("ECMR") => require_operation(&jwk, kid, Algorithm::Ecmr, KeyOperation::DeriveKey)?, + _ => return Err(Error::invalid_key("algorithm must be ES512 or ECMR")), + } + let secret = jwk.to_p521_secret_key()?; + let material = if jwk.alg.as_deref() == Some("ES512") { + KeyMaterial::Signing(SigningKey::from(&secret)) + } else { + KeyMaterial::Recovery(secret) + }; + let mut public = jwk.to_public_key(); + // Clevis/JOSE select permissions from either key_ops or use, so recovery + // keys must not retain use metadata that could grant verification. + let public_operation = match &material { + KeyMaterial::Signing(_) => KeyOperation::Verify, + KeyMaterial::Recovery(_) => { + public.use_ = None; + KeyOperation::DeriveKey + } + }; + public.key_ops = Some(vec![public_operation.as_str().into()]); + Ok(StoredKey { + public, + material, + active: true, + }) +} + +type Keys = BTreeMap; + +pub(super) fn initialize(source: KeySource<'_>) -> Result { + match source { + KeySource::LocalDir(dir) => { + let keys = load_directory(dir)?; + if keys.is_empty() { + publish(dir) + } else { + TangyLib::from_keys(keys) + } + } + KeySource::Vector(active) => TangyLib::from_keys(load_inputs(active, &[])?), + KeySource::KeySets { active, rotated } => { + TangyLib::from_keys(load_inputs(active, rotated)?) + } + } +} + +// Every identity must be unique across active and rotated keys. +fn load_keys<'a>(entries: impl IntoIterator) -> Result { + let mut keys = Keys::new(); + let mut origins = BTreeMap::new(); + for (location, active, json) in entries { + let invalid = |source| Error::InvalidStoredKey { + location: location.clone(), + source: Box::new(source), + }; + let jwk = parse_stored_key(json).map_err(invalid)?; + let kid = jwk.thumbprint().map_err(invalid)?; + // Identity conflicts fail even when the second copy changes policy metadata. + if let Some(first) = origins.insert(kid.clone(), location.clone()) { + return Err(Error::DuplicateKey { + kid, + first, + second: location.clone(), + }); + } + let mut key = validate_stored_key(jwk, &kid).map_err(invalid)?; + key.active = active; + keys.insert(kid, key); + } + Ok(keys) +} + +fn load_inputs(active: &[&str], rotated: &[&str]) -> Result { + let active = active.iter().enumerate(); + let rotated = rotated.iter().enumerate(); + load_keys( + active + .map(|(index, json)| (KeyLocation::Input(index), true, *json)) + .chain(rotated.map(|(index, json)| (KeyLocation::RotatedInput(index), false, *json))), + ) +} + +fn read_key(path: &Path) -> Result> { + let read_error = |source| Error::KeyRead { + path: path.into(), + source, + }; + // Like Tang, follow symlinks. Reject non-files before opening so directories + // and FIFOs fail alike everywhere; a dangling link is a read error. + if !fs::metadata(path).map_err(read_error)?.is_file() { + return Err(Error::NotAFile { path: path.into() }); + } + let mut json = Zeroizing::new(String::new()); + File::open(path) + .and_then(|mut file| file.read_to_string(&mut json)) + .map_err(read_error)?; + Ok(json) +} + +fn load_directory(dir: &Path) -> Result { + let mut paths = fs::read_dir(dir) + .and_then(|entries| { + entries + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>() + }) + .map_err(|source| Error::KeyDirectory { + path: dir.into(), + source, + })?; + paths.sort(); + let mut files = Vec::new(); + for path in paths { + let name = path.file_name().unwrap_or_default().as_encoded_bytes(); + // Like Tang, `.jwk` names are keys and a leading `.` marks rotation. + // Unrelated files, including interrupted `.tmp` writes, are ignored. + if name.ends_with(b".jwk") { + let active = !name.starts_with(b"."); + let json = read_key(&path)?; + files.push((KeyLocation::File(path), active, json)); + } + } + load_keys( + files + .iter() + .map(|(location, active, json)| (location.clone(), *active, json.as_str())), + ) +} + +fn create_key_file(path: &Path) -> io::Result { + #[cfg(unix)] + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + // Unix: restricted from creation, then set explicitly so the umask cannot + // change the final mode. Windows keeps the directory's inherited ACLs. + #[cfg(unix)] + options.mode(0o440); + let file = options.open(path)?; + #[cfg(unix)] + if let Err(error) = fault_point("permissions") + .and_then(|()| file.set_permissions(fs::Permissions::from_mode(0o440))) + { + // write_keys cannot track the path until this function returns a file. + // Close the handle before attempting removal and preserve the cause. + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) +} + +fn sync_directory(dir: &Path) -> io::Result<()> { + #[cfg(unix)] + File::open(dir)?.sync_all()?; + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +fn write_error(path: &Path, operation: WriteStep) -> impl FnOnce(io::Error) -> Error + '_ { + move |source| Error::KeyWrite { + path: path.into(), + operation, + source, + } +} + +// Generate a key set and persist it. Both keys are written and flushed to +// temporary files before either is renamed into place, so key files are complete +// or absent after a crash; leftover `.tmp` files are ignored on load. Names are +// fresh thumbprints, so rename replaces nothing in practice. +fn publish(dir: &Path) -> Result { + let generated = Zeroizing::new(create_new_key_set()?); + let inputs: Vec<&str> = generated.iter().map(String::as_str).collect(); + // Validate and build the default advertisement before writing anything. + let library = TangyLib::from_keys(load_inputs(&inputs, &[])?)?; + let mut created = Vec::new(); + let result = write_keys(dir, &generated, &mut created); + if result.is_err() { + // These keys were never returned or served, so remove everything this + // call created and report the original failure. Removal is best effort. + for path in &created { + let _ = fs::remove_file(path); + } + } + result.map(|()| library) +} + +fn write_keys(dir: &Path, generated: &[String], created: &mut Vec) -> Result<()> { + let mut renames = Vec::new(); + for json in generated { + let kid = parse_stored_key(json)?.thumbprint()?; + let temporary = dir.join(format!(".tangy-{kid}.tmp")); + let mut file = + create_key_file(&temporary).map_err(write_error(&temporary, WriteStep::Create))?; + created.push(temporary.clone()); + fault_point("write") + .and_then(|()| file.write_all(json.as_bytes())) + .map_err(write_error(&temporary, WriteStep::Write))?; + file.sync_all() + .map_err(write_error(&temporary, WriteStep::Synchronize))?; + renames.push((temporary, dir.join(format!("{kid}.jwk")))); + } + for (temporary, destination) in renames { + fault_point("rename") + .and_then(|()| fs::rename(&temporary, &destination)) + .map_err(write_error(&destination, WriteStep::Publish))?; + created.push(destination); + } + fault_point("sync-dir") + .and_then(|()| sync_directory(dir)) + .map_err(write_error(dir, WriteStep::Synchronize)) +} + +/// Test-only fault point for child test processes. `TANGY_TEST_FAULT=:` +/// fails the nth hit of a point; `TANGY_TEST_PAUSE=:` writes the file +/// named by `TANGY_TEST_READY` and blocks there so the parent can kill it. +#[cfg(test)] +fn fault_point(point: &'static str) -> io::Result<()> { + use std::sync::Mutex; + static HITS: Mutex> = Mutex::new(BTreeMap::new()); + let hit = match HITS.lock() { + Ok(mut hits) => { + let count = hits.entry(point).or_default(); + *count += 1; + *count + } + Err(_) => return Err(io::Error::other("fault point state poisoned")), + }; + let selected = |name| { + std::env::var(name) + .is_ok_and(|value| value.split_once(':') == Some((point, &hit.to_string()))) + }; + if selected("TANGY_TEST_FAULT") { + return Err(io::Error::other("injected failure")); + } + if selected("TANGY_TEST_PAUSE") { + if let Some(ready) = std::env::var_os("TANGY_TEST_READY") { + fs::write(ready, b"ready")?; + } + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + Ok(()) +} + +#[cfg(not(test))] +fn fault_point(_: &'static str) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::*; + use std::{ + process::{Child, Command}, + thread, + time::{Duration, Instant}, + }; + + fn names(dir: &Path, suffix: &str) -> Vec { + let mut paths: Vec<_> = fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.file_name() + .unwrap() + .to_string_lossy() + .ends_with(suffix) + }) + .collect(); + paths.sort(); + paths + } + + fn snapshot(dir: &Path) -> BTreeMap> { + fs::read_dir(dir) + .unwrap() + .map(|entry| { + let path = entry.unwrap().path(); + let bytes = fs::read(&path).unwrap(); + (path, bytes) + }) + .collect() + } + + struct RunningChild(Child); + impl Drop for RunningChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + // Fault points read process-global environment, so they are only set in + // child processes running `child_process`, never in the parent test. + fn child(action: &str, dir: &Path, env: &[(&str, &str)]) -> RunningChild { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", "key_store::tests::child_process", "--nocapture"]) + .env("TANGY_TEST_ACTION", action) + .env("TANGY_TEST_DIR", dir); + for (name, value) in env { + command.env(name, value); + } + RunningChild(command.spawn().unwrap()) + } + + fn wait_for(path: &Path) { + let start = Instant::now(); + while !path.exists() { + assert!( + start.elapsed() < Duration::from_secs(20), + "child did not reach {}", + path.display() + ); + thread::sleep(Duration::from_millis(10)); + } + } + + // A test harness child, run only by the tests below. + #[test] + fn child_process() { + let Ok(action) = std::env::var("TANGY_TEST_ACTION") else { + return; + }; + let dir = PathBuf::from(std::env::var_os("TANGY_TEST_DIR").unwrap()); + match action.as_str() { + "init" => { + TangyLib::init(KeySource::LocalDir(&dir)).unwrap(); + } + "expect-failure" => { + let expected = std::env::var("TANGY_TEST_EXPECT").unwrap(); + let error = TangyLib::init(KeySource::LocalDir(&dir)).unwrap_err(); + assert!( + matches!(error, Error::KeyWrite { operation, ref source, .. } if format!("{operation:?}") == expected && source.to_string() == "injected failure"), + "unexpected error: {error:?}" + ); + } + #[cfg(unix)] + "umask" => { + use std::os::unix::fs::PermissionsExt; + // Only this subprocess changes its own umask. + unsafe { + libc::umask(0); + } + TangyLib::init(KeySource::LocalDir(&dir)).unwrap(); + let keys = names(&dir, ".jwk"); + assert_eq!(keys.len(), 2); + for path in keys { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o440 + ); + } + assert_eq!( + unsafe { libc::umask(0) }, + 0, + "library changed process umask" + ); + } + _ => panic!("unknown child action"), + } + } + + /// Assures: KEY-PUBLISH-001, KEY-STORE-001, ERR-DIAG-001, KEY-PERM-ROLLBACK-001 + #[test] + fn injected_failures_remove_created_files_and_retry_cleanly() { + // Failing the second rename removes the first published key; failing + // the directory sync removes both. + for (fault, expected) in [ + #[cfg(unix)] + ("permissions:1", "Create"), + #[cfg(unix)] + ("permissions:2", "Create"), + ("write:1", "Write"), + ("write:2", "Write"), + ("rename:1", "Publish"), + ("rename:2", "Publish"), + ("sync-dir:1", "Synchronize"), + ] { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("unrelated"), b"preserve me").unwrap(); + let mut process = child( + "expect-failure", + dir.path(), + &[("TANGY_TEST_FAULT", fault), ("TANGY_TEST_EXPECT", expected)], + ); + assert!(process.0.wait().unwrap().success(), "{fault}"); + assert_eq!( + snapshot(dir.path()), + BTreeMap::from([(dir.path().join("unrelated"), b"preserve me".to_vec())]), + "{fault}" + ); + TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + assert_eq!(names(dir.path(), ".jwk").len(), 2); + assert!(names(dir.path(), ".tmp").is_empty()); + } + } + + /// Assures: KEY-PUBLISH-001, KEY-STORE-001 + #[test] + fn process_death_leaves_complete_files_and_ignored_temporaries() { + for renamed in 0..=1 { + let dir = tempfile::tempdir().unwrap(); + let signals = tempfile::tempdir().unwrap(); + let ready = signals.path().join("ready"); + let pause = format!("rename:{}", renamed + 1); + let mut process = child( + "init", + dir.path(), + &[ + ("TANGY_TEST_PAUSE", &pause), + ("TANGY_TEST_READY", ready.to_str().unwrap()), + ], + ); + wait_for(&ready); + process.0.kill().unwrap(); + process.0.wait().unwrap(); + // Every visible key is complete; unfinished keys remain temporary. + let published = names(dir.path(), ".jwk"); + let temporaries = names(dir.path(), ".tmp"); + assert_eq!(published.len(), renamed); + assert_eq!(temporaries.len(), 2 - renamed); + for path in &published { + validate_stored_key( + parse_stored_key(&fs::read_to_string(path).unwrap()).unwrap(), + "published", + ) + .unwrap(); + } + let before = snapshot(dir.path()); + let result = TangyLib::init(KeySource::LocalDir(dir.path())); + if renamed == 0 { + // Nothing was published, so the store is still empty. + result.unwrap(); + assert_eq!(names(dir.path(), ".jwk").len(), 2); + for path in temporaries { + assert_eq!(fs::read(&path).unwrap(), before[&path]); + } + } else { + // A lone published key is an incomplete store, as in Tang. + assert!(matches!(result, Err(Error::MissingKeys { .. }))); + assert_eq!(before, snapshot(dir.path())); + } + } + } + + /// Assures: KEY-PERM-001 + #[cfg(unix)] + #[test] + fn permissive_umask_subprocess_produces_final_mode_0440() { + let dir = tempfile::tempdir().unwrap(); + let mut process = child("umask", dir.path(), &[]); + assert!(process.0.wait().unwrap().success()); + } + + /// Assures: KEY-STORE-001 + #[cfg(unix)] + #[test] + fn complete_store_loads_without_directory_write_access() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("sign.jwk"), + include_str!("../tests/fixtures/es512.json"), + ) + .unwrap(); + fs::write( + dir.path().join("rec.jwk"), + include_str!("../tests/fixtures/ecmr.json"), + ) + .unwrap(); + let before = snapshot(dir.path()); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o550)).unwrap(); + let result = TangyLib::init(KeySource::LocalDir(dir.path())); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + result.unwrap(); + assert_eq!(before, snapshot(dir.path())); + } +} diff --git a/src/lib.rs b/src/lib.rs index 56b0dc5..86ea871 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,726 +1,41 @@ -use std::{ - collections::HashMap, - fs::File, - io::{Read, Write}, - path::{Path, PathBuf}, - str::FromStr, +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Tang advertisements and P-521 message recovery with typed errors. +//! +//! ``` +//! use tangy_lib::{create_new_key_set, KeySource, Result, TangyLib}; +//! +//! fn initialize() -> Result { +//! let generated = create_new_key_set()?; +//! let keys: Vec<&str> = generated.iter().map(String::as_str).collect(); +//! let server = TangyLib::init(KeySource::Vector(&keys))?; +//! let _advertisement = server.adv(None)?; +//! Ok(server) +//! } +//! # initialize()?; +//! # Ok::<(), tangy_lib::Error>(()) +//! ``` + +// Rustdoc uses these targets before the guides' repository-relative link definitions. +#![doc = "[key-storage-guide]: crate::key_storage\n"] +#![doc = include_str!("../README.md")] + +mod error; +mod jwk; +mod key_store; +mod protocol; +mod support; + +pub use error::{ + Algorithm, Error, JsonError, KeyLocation, KeyOperation, RandomPurpose, Result, WriteStep, }; +pub use jwk::{create_new_key_set, MyJwkEcKey}; +pub use key_store::KeySource; +pub use protocol::TangyLib; -use base64ct::Encoding; -use ecdsa::SigningKey; -use elliptic_curve::{rand_core::OsRng, JwkEcKey}; -use p521::ecdsa; -use serde::Serialize; -use sha2::Digest; - -#[derive(Debug)] -pub struct TangyLib { - keys: std::collections::HashMap, - signing_keys: Vec, - default_adv: String, -} - -#[derive(PartialEq)] -pub enum KeySource<'a> { - LocalDir(&'a Path), - Vector(&'a Vec<&'a str>), -} - -impl TangyLib { - pub fn init(source: KeySource) -> Result { - let mut loaded_keys = match source { - KeySource::LocalDir(dir) => load_keys_from_dir(dir)?, - KeySource::Vector(keys) => load_keys_from_vec(keys)?, - }; - - let ecmr_exists = loaded_keys - .iter() - .any(|(_, v)| v.alg.is_some() && v.alg.as_ref().unwrap() == "ECMR"); - let es512_exists = loaded_keys - .iter() - .any(|(_, v)| v.alg.is_some() && v.alg.as_ref().unwrap() == "ES512"); - - if (!ecmr_exists && es512_exists) || (ecmr_exists && !es512_exists) { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Key loading file error", - )); - } - - if !ecmr_exists && !es512_exists { - match source { - KeySource::LocalDir(dir) => { - let keys = create_new_key_set(); - for k in keys.iter() { - let jwk: MyJwkEcKey = serde_json::from_str(k).map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Unable to create new JWK: {e}"), - ) - })?; - let thumbprint = jwk.thumbprint(); - if let Ok(mut file) = - std::fs::File::create(dir.join(format!("{}.jwk", thumbprint))) - { - file.write_all(k.as_bytes())?; - set_file_permissions(&file)?; - } - loaded_keys.insert(thumbprint, jwk); - } - } - KeySource::Vector(_) => { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "ES512 and ECMR keys not present in input vector", - )); - } - } - } - - // Extract a signing key from the JWK, it shouldn't really be generated using a - // from_bytes call from the secret key... perhaps the SigningKey From trait is - // missing for the secret key. - let signing_keys: Vec = loaded_keys - .iter() - .filter_map(|(_, v)| { - if let Some(alg) = v.alg.as_ref() { - if alg == "ES512" { - return Some(v.clone()); - } - } - None - }) - .collect(); - - if signing_keys.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Signing key not found", - )); - } - - let mut tangy = Self { - keys: loaded_keys, - signing_keys, - default_adv: "".into(), - }; - - tangy.default_adv = tangy.adv_internal(None)?; - - Ok(tangy) - } - - pub fn adv(&self, skid: Option<&str>) -> Result { - if skid.is_none() { - return Ok(self.default_adv.to_owned()); - } - self.adv_internal(skid) - } - - pub fn adv_internal(&self, skid: Option<&str>) -> Result { - #[derive(serde::Serialize)] - struct Siguature { - protected: String, - signature: String, - } - #[derive(serde::Serialize)] - struct Advertise { - payload: String, - #[serde(skip_serializing_if = "Option::is_none")] - protected: Option, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - signatures: Option>, - } - - #[derive(serde::Serialize)] - struct Payload { - keys: Vec, - } - - let keys: Vec<&MyJwkEcKey> = self.keys.values().collect(); - - let signing_keys = if let Some(kid) = skid { - let key = self.keys.get(kid); - if key.is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Requested signing key {} not found", kid), - )); - } - if key.unwrap().key_ops.is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Requested signing key {} cannot be used for signing", kid), - )); - } - if !key - .as_ref() - .unwrap() - .key_ops - .as_ref() - .unwrap() - .contains(&"sign".to_string()) - { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Requested signing key {} cannot be used for signing", kid), - )); - } - vec![key.unwrap().clone()] - } else { - self.signing_keys.to_vec() - }; - - let payload = base64ct::Base64Url::encode_string( - serde_json::to_string(&Payload { - keys: keys - .iter() - .map(|v| { - let mut k = v.to_public_key(); - let mut ops = k.key_ops.take(); - if let Some(ops) = &mut ops { - ops.retain(|v| *v != "sign"); - } - k.key_ops = ops; - k - }) - .collect(), - }) - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Unable to encode adv message: {e}"), - ) - })? - .as_bytes(), - ); - - // As per tang reference implementation - let protected = base64ct::Base64Url::encode_string( - r#"{"alg":"ES512","cty":"jwk-set+json"}"#.as_bytes(), - ); - - let mut signing_keys: Vec = signing_keys - .iter() - .map(|k| { - SigningKey::from_bytes( - &k.to_jwk_ec_key(false) - .to_secret_key::() - .unwrap() - .to_bytes(), - ) - .unwrap() - }) - .collect(); - - // The protected and payload fields are signed, as base64url encoded string - // joined with a period. - let to_sign = format!("{}.{}", &protected, &payload); - - let signatures: Vec<_> = signing_keys - .iter_mut() - .map(|k| ecdsa::signature::SignerMut::sign(k, to_sign.as_bytes())) - .collect(); - - let mut buf = [0; 1024]; - - if signatures.len() == 1 { - Ok(serde_json::to_string(&Advertise { - payload, - protected: Some(protected), - signature: Some( - base64ct::Base64Url::encode(&signatures[0].to_bytes(), &mut buf) - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Unable to encode string to base64: {e}"), - ) - })? - .to_string(), - ), - signatures: None, - }) - .unwrap()) - } else { - Ok(serde_json::to_string(&Advertise { - payload, - protected: None, - signature: None, - signatures: Some( - signatures - .iter() - .map(|s| Siguature { - protected: protected.to_owned(), - signature: base64ct::Base64Url::encode(&s.to_bytes(), &mut buf) - .unwrap() - .to_string(), - }) - .collect(), - ), - }) - .unwrap()) - } - } - - pub fn rec(&self, kid: &str, request: &str) -> Result { - let key = self.keys.iter().find_map(|(k, v)| { - if k == kid { - return Some(v); - } - None - }); - - if key.is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Requested key not found".to_string(), - )); - } - - let request_key: MyJwkEcKey = serde_json::from_str(request).unwrap(); - let request_key = request_key - .to_jwk_ec_key(true) - .to_public_key::() - .unwrap(); - - let p = diffie_hellman_public_key( - &key.as_ref() - .unwrap() - .to_jwk_ec_key(false) - .to_secret_key::() - .unwrap() - .to_nonzero_scalar(), - request_key.as_affine(), - ); - - Ok(serde_json::to_string(&p).unwrap()) - } -} - -pub fn create_new_key_set() -> Vec { - let es512_jwk = create_new_jwk("ES512", &["sign", "verify"]); - let ecmr_jwk = create_new_jwk("ECMR", &["deriveKey"]); - vec![es512_jwk, ecmr_jwk] -} - -#[cfg(target_os = "linux")] -fn set_file_permissions(file: &File) -> Result<(), std::io::Error> { - use std::os::unix::fs::PermissionsExt; - let mut perms = file.metadata()?.permissions(); - perms.set_mode(0o440); // Set read only, and access for owner only - file.set_permissions(perms) -} - -#[cfg(not(target_os = "linux"))] -fn set_file_permissions(file: &File) -> Result<(), std::io::Error> { - Ok(()) -} - -fn load_keys_from_dir(db_path: &Path) -> Result, std::io::Error> { - if !db_path.exists() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "Key database \"{}\" does not exist", - db_path.to_string_lossy() - ), - )); - } - - if !db_path.is_dir() { - return Err(std::io::Error::new( - std::io::ErrorKind::Unsupported, - format!( - "Key database \"{}\" is not a directory", - db_path.to_string_lossy() - ), - )); - } - - let jwk_files: Vec = db_path - .read_dir()? - .filter_map(|f| f.ok()) - .map(|e| e.path()) - .filter(|f| f.extension() == Some(std::ffi::OsStr::new("jwk"))) - .collect(); - - let keys: Vec = jwk_files - .iter() - .filter_map(|j| { - let mut file_content = String::new(); - match std::fs::File::open(j) { - Ok(mut f) => { - if f.read_to_string(&mut file_content).is_err() { - return None; - } - } - Err(_) => return None, - }; - Some(file_content) - }) - .collect(); - - load_keys_from_vec(&keys) -} - -fn load_keys_from_vec>( - keys: &[T], -) -> Result, std::io::Error> { - Ok(keys - .iter() - .filter_map(|key| { - let jwk: MyJwkEcKey = if let Ok(jwk) = serde_json::from_str(key.as_ref()) { - jwk - } else { - return None; - }; - - let thumbprint = thumprint(&jwk.crv, &jwk.kty, &jwk.x, &jwk.y); - - Some((thumbprint, jwk)) - }) - .collect()) -} - -// For future directory monitoring -//fn scan_folder(dir: &Path) -> String { -// let mut sha256 = sha2::Sha256::new(); -// dir.read_dir() -// .unwrap() -// .filter_map(|f| f.ok()) -// .map(|e| e.path()) -// .filter(|f| f.extension() == Some(std::ffi::OsStr::new("jwk"))) -// .for_each(|filename| { -// let mut file = std::fs::File::open(&filename).unwrap(); -// let filesize = filesize::file_real_size(&filename).unwrap_or(1024 * 10); -// if filesize < 1024 * 10 { -// let mut content = String::new(); -// file.read_to_string(&mut content).unwrap(); -// sha256.update(content.as_bytes()); -// } -// }); -// -// base64ct::Base64UrlUnpadded::encode_string(&sha256.finalize()) -//} - -fn create_new_jwk(alg: &str, key_ops: &[&str]) -> String { - let priv_key = elliptic_curve::SecretKey::::random(&mut OsRng); - let jwk = priv_key.to_jwk(); - let encoded_point = jwk.to_encoded_point::().unwrap(); - let mut buf = [0; 1000]; - - let x = base64ct::Base64Url::encode(encoded_point.x().unwrap(), &mut buf) - .unwrap() - .to_string(); - - let y = base64ct::Base64Url::encode(encoded_point.y().unwrap(), &mut buf) - .unwrap() - .to_string(); - - serde_json::to_string(&MyJwkEcKey { - alg: Some(alg.into()), - kty: "EC".into(), - crv: jwk.crv().into(), - x: x.to_owned(), - y: y.to_owned(), - d: Some( - base64ct::Base64Url::encode(priv_key.to_bytes().as_slice(), &mut buf) - .unwrap() - .to_string(), - ), - key_ops: Some(key_ops.iter().map(|k| k.to_string()).collect()), - use_: None, - kid: None, - x5u: None, - x5c: None, - x5t: None, - x5t_s256: None, - }) - .unwrap() -} - -fn thumprint(crv: &str, kty: &str, x: &str, y: &str) -> String { - #[derive(Serialize)] - struct Required { - crv: String, - kty: String, - x: String, - y: String, - } - - let required_fields = Required { - crv: crv.to_owned(), - kty: kty.to_owned(), - x: x.to_owned(), - y: y.to_owned(), - }; - - let mut hasher = sha2::Sha256::new(); - hasher.update(serde_json::to_string(&required_fields).unwrap().as_bytes()); - base64ct::Base64UrlUnpadded::encode_string(&hasher.finalize()) -} - -fn diffie_hellman_public_key( - secret_key: &elliptic_curve::NonZeroScalar, - public_key: &elliptic_curve::AffinePoint, -) -> MyJwkEcKey { - let public_point = elliptic_curve::ProjectivePoint::::from(*public_key); - let secret_point = (public_point * secret_key.as_ref()).to_affine(); - let generated_public_key = p521::PublicKey::from_affine(secret_point).unwrap(); - - let mut formatted_public_key: MyJwkEcKey = - serde_json::from_str(generated_public_key.to_jwk_string().as_str()).unwrap(); - - formatted_public_key.alg = Some("ECMR".into()); - formatted_public_key.crv = "P-521".into(); - formatted_public_key.kty = "EC".into(); - formatted_public_key.key_ops = Some(vec!["deriveKey".into()]); - - formatted_public_key -} - -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] -pub struct MyJwkEcKey { - /// The `crv` parameter which identifies a particular elliptic curve - /// as defined in RFC 7518 Section 6.2.1.1: - /// - pub crv: String, - - /// The x-coordinate of the elliptic curve point which is the public key - /// value associated with this JWK as defined in RFC 7518 6.2.1.2: - /// - pub x: String, - - /// The y-coordinate of the elliptic curve point which is the public key - /// value associated with this JWK as defined in RFC 7518 6.2.1.3: - /// - pub y: String, - - /// The `d` ECC private key parameter as described in RFC 7518 6.2.2.1: - /// - /// - /// Value is optional and if omitted, this JWK represents a private key. - /// - /// Inner value is encoded according to the `Integer-to-Octet-String` - /// conversion as defined in SEC1 section 2.3.7: - /// - #[serde(skip_serializing_if = "Option::is_none")] - pub d: Option, - - /// Key Type (must be "EC" if present) as described in RFC 7517 4.1: - /// - /// - /// Value is optional. - /// - /// For Elliptic-Curve the value must be "EC". - pub kty: String, - - /// The Public Key Use as described in RFC 7517 4.2: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none", rename = "use")] - pub use_: Option, - - /// The Key Operations as described in RFC 7517 4.3: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub key_ops: Option>, - - /// The Algorithm as described in RFC 7517 4.4: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub alg: Option, - - /// The Key ID as described in RFC 7517 4.5: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub kid: Option, - - /// The X.509 URL as described in RFC 7517 4.6: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub x5u: Option, - - /// The X.509 Certificate Chain as described in RFC 7517 4.7: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub x5c: Option, - - /// The X.509 Certificate SHA-1 Thumbprint as described in RFC 7517 4.8: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub x5t: Option, - - /// The X.509 Certificate SHA-256 as described in RFC 7517 4.9: - /// - /// - /// Value is optional and not used by this crate. - #[serde(skip_serializing_if = "Option::is_none")] - pub x5t_s256: Option, -} - -impl MyJwkEcKey { - pub fn to_jwk_ec_key(&self, public: bool) -> JwkEcKey { - let mut content: HashMap = HashMap::new(); - content.insert("crv".into(), self.crv.to_owned()); - content.insert("kty".into(), self.kty.to_owned()); - content.insert("x".into(), self.x.to_owned()); - content.insert("y".into(), self.y.to_owned()); - if self.d.is_some() && !public { - content.insert("d".into(), self.d.as_ref().unwrap().to_owned()); - } - JwkEcKey::from_str(&serde_json::to_string(&content).unwrap()).unwrap() - } - - pub fn to_public_key(&self) -> MyJwkEcKey { - let mut ret = self.clone(); - ret.d = None; - ret - } - - pub fn thumbprint(&self) -> String { - // For EC type the following fields are required to be - // present and in lexicographic order - #[derive(Serialize)] - struct Required { - crv: String, - kty: String, - x: String, - y: String, - } - - let required_fields = Required { - crv: self.crv.to_owned(), - kty: self.kty.to_owned(), - x: self.x.to_owned(), - y: self.y.to_owned(), - }; - - let mut hasher = sha2::Sha256::new(); - hasher.update(serde_json::to_string(&required_fields).unwrap().as_bytes()); - base64ct::Base64UrlUnpadded::encode_string(&hasher.finalize()) - } -} +#[doc = "[loading-keys]: crate#loading-keys\n"] +#[doc = include_str!("../KEY_STORAGE.md")] +pub mod key_storage {} #[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - - const JWK_ES512: &str = r#" - { - "kty": "EC", - "alg": "ES512", - "crv": "P-521", - "x": "AX5mUTAH1qr3YSSwuMV_HV0yupJhMIAqwly710a7qLbXR6up3flnaPsJbaSVATrIF6QcXc9PPyFW1IQHmDOWGSPj", - "y": "ADT1K8Q-O1Q5lyU3StXnPMQwgnYWS8hnTRGjjcFssitZy_tUWSuhUPFhzaUJKhXRNbcyELeDX-kPCMbBKX1vb8Lq", - "d": "AbDO5xCtQHUbHld-Fq61sSCvyjr9EpNj3_sklNmo54xmKeYu_cW_s7fzQxm6SsqFwrTmiiFz2OaD1ODsXI-DdoKt", - "key_ops": ["sign","verify"] - } - "#; - - const JWK_ES512_THUMBPRINT: &str = "tpUdnaei02Z6bSS3_rKEU0BDPl8tyZFy16CKCTWNlbA"; - - const JWK_ECMR: &str = r#" - { - "kty": "EC", - "alg": "ECMR", - "crv": "P-521", - "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", - "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", - "d": "AQTm4JamDPZufHlRCC12Ssjh6xTwu630neCLr7EUtUuZoFHk9zga-kzwaGajH1MQb8ffc3CeV-7InHKmR8HvytTE", - "key_ops":["deriveKey"] - } - "#; - - const JWK_ECMR_THUMBPRINT: &str = "UFgqx9-PLx_h6h4hd6sysNHMC6cDyjBQOYZHFvObLbo"; - - #[test] - fn source_local_dir() { - let tmp_dir = tempfile::tempdir().unwrap(); - let t = TangyLib::init(KeySource::LocalDir(tmp_dir.path())); - assert!(t.is_ok()); - } - - #[test] - fn source_vector() { - let v = vec![JWK_ES512, JWK_ECMR]; - let t = TangyLib::init(KeySource::Vector(&v)); - assert!(t.is_ok()); - } - - #[test] - fn adv() { - let v = vec![JWK_ES512, JWK_ECMR]; - let mut t = TangyLib::init(KeySource::Vector(&v)).unwrap(); - let advertisment = t.adv(None).unwrap(); - - #[derive(Deserialize)] - struct Adv { - payload: String, - } - - #[derive(Deserialize)] - struct Key { - kty: String, - crv: String, - x: String, - y: String, - } - - #[derive(Deserialize)] - struct Payload { - keys: Vec, - } - - let actual_adv: Adv = serde_json::from_str(&advertisment).unwrap(); - let payload_json = base64ct::Base64Unpadded::decode_vec(&actual_adv.payload).unwrap(); - let payload: Payload = serde_json::from_slice(&payload_json).unwrap(); - assert_eq!(payload.keys.len(), 2); - } - - #[test] - fn adv_skid() { - let v = vec![JWK_ES512, JWK_ECMR]; - let mut t = TangyLib::init(KeySource::Vector(&v)).unwrap(); - let advertisment = t.adv(Some(JWK_ES512_THUMBPRINT.into())).unwrap(); - - #[derive(Deserialize)] - struct Adv { - payload: String, - } - - #[derive(Deserialize)] - struct Key { - kty: String, - crv: String, - x: String, - y: String, - } - - #[derive(Deserialize)] - struct Payload { - keys: Vec, - } - - let actual_adv: Adv = serde_json::from_str(&advertisment).unwrap(); - let payload_json = base64ct::Base64Unpadded::decode_vec(&actual_adv.payload).unwrap(); - let payload: Payload = serde_json::from_slice(&payload_json).unwrap(); - assert_eq!(payload.keys.len(), 2); - } -} +mod tests; diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..2d3aa09 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Build Tang advertisements and recovery responses from a validated server key set. +//! +//! [`TangyLib`] connects key storage and cryptographic key conversion to the two +//! Tang protocol operations: advertising public keys and helping clients recover +//! secrets. Callers supply key identifiers and request bodies, then send the +//! returned JavaScript Object Notation (JSON) strings through their transport. +//! +//! [`TangyLib::init`] delegates loading and validation to the key-store module. +//! Construction requires at least one active signing key and one active recovery +//! key, retains their typed private material, and builds the default advertisement. +//! An instance holds this key-set snapshot; loading a subsequent rotation requires +//! initializing a new instance. +//! +//! [`TangyLib::adv`] advertises the active public keys as a JSON Web Key (JWK) set +//! inside a JSON Web Signature (JWS). Every active signing key signs the payload +//! using P-521 with SHA-512 (`ES512`). A single signature uses the flattened JWS +//! representation; multiple signatures use the general representation with a +//! `signatures` array. The protected header, payload and signatures use URL-safe +//! Base64 without padding. Private scalars are omitted from the advertised keys. +//! +//! `adv(None)` returns the advertisement cached during initialization and needs +//! no fresh randomness. `adv(Some(kid))` requires a signing-key thumbprint and +//! constructs a fresh advertisement signed by all active signers. Selecting a +//! rotated signer adds its signature over the same active payload, allowing a +//! client holding its old public key to verify the current set. Rotated keys +//! remain absent from the payload, and selecting an active signer includes its +//! signature once. Fresh signing obtains randomness through the shared fallible +//! seeding helper. +//! +//! [`TangyLib::rec`] performs elliptic-curve message recovery (`ECMR`). Its `kid` +//! selects an active or rotated server recovery key by thumbprint; its request +//! body supplies a P-521 public point as a JWK. The JWK module validates the +//! coordinates. Optional `alg` and `key_ops` fields must, when supplied, specify +//! `ECMR` and permit `deriveKey`. Recovery multiplies the client's point by the +//! server's private scalar and returns the full resulting public point as a JWK +//! with those algorithm and operation fields. This full-point response preserves +//! Tang compatibility and keeps rotated recovery keys usable by existing clients. +//! +//! Unknown identifiers return [`Error::KeyNotFound`], selecting a server key with +//! the wrong role returns [`Error::ForbiddenOperation`], and malformed or invalid +//! recovery input returns [`Error::InvalidRequest`]. Signing, randomness and +//! serialization failures retain their operation context through typed errors. + +use base64ct::{Base64UrlUnpadded, Encoding}; +use p521::{ + ecdsa::{self, signature::RandomizedSigner}, + elliptic_curve, +}; +use serde::Serialize; +use std::collections::BTreeMap; + +use crate::{ + jwk::{format_public_key, parse_private_jwk}, + key_store::{self, KeyMaterial, StoredKey}, + support::{crypto_rng, serialize, serialize_string}, + Algorithm, Error, JsonError, KeyOperation, KeySource, MyJwkEcKey, RandomPurpose, Result, +}; + +/// A validated active/rotated key set serving Tang protocol JSON responses. +/// Private key material is redacted from debug output. +#[derive(Debug)] +pub struct TangyLib { + keys: BTreeMap, + default_adv: String, +} + +impl TangyLib { + /// Load and validate private keys, generating only for an empty directory. + /// Invalid, duplicate or incomplete existing stores return typed errors. + pub fn init(source: KeySource) -> Result { + key_store::initialize(source) + } + + pub(crate) fn from_keys(keys: BTreeMap) -> Result { + let signing = !keys + .values() + .any(|key| key.active && matches!(key.material, KeyMaterial::Signing(_))); + let recovery = !keys + .values() + .any(|key| key.active && matches!(key.material, KeyMaterial::Recovery(_))); + if signing || recovery { + return Err(Error::MissingKeys { signing, recovery }); + } + let mut tangy = Self { + keys, + default_adv: String::new(), + }; + tangy.default_adv = tangy.build_advertisement(None)?; + Ok(tangy) + } + + /// Return a signed advertisement of active public keys. + /// `None` serves the cached advertisement signed by all active signers. + /// Selecting a rotated signing thumbprint adds its signature to the active set. + pub fn adv(&self, skid: Option<&str>) -> Result { + match skid { + None => Ok(self.default_adv.clone()), + Some(_) => self.build_advertisement(skid), + } + } + + fn build_advertisement(&self, skid: Option<&str>) -> Result { + self.adv_with_rng(skid, &mut getrandom::fill) + } + + pub(crate) fn adv_with_rng( + &self, + skid: Option<&str>, + entropy: &mut impl FnMut(&mut [u8]) -> std::result::Result<(), E>, + ) -> Result { + #[derive(Serialize)] + struct Signature { + protected: String, + signature: String, + } + #[derive(Serialize)] + struct Advertise { + payload: String, + #[serde(skip_serializing_if = "Option::is_none")] + protected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + signatures: Option>, + } + #[derive(Serialize)] + struct Payload<'a> { + keys: Vec<&'a MyJwkEcKey>, + } + + if let Some(kid) = skid { + let key = self + .keys + .get(kid) + .ok_or_else(|| Error::KeyNotFound { kid: kid.into() })?; + if !matches!(key.material, KeyMaterial::Signing(_)) { + return Err(Error::ForbiddenOperation { + kid: kid.into(), + algorithm: Algorithm::Es512, + operation: KeyOperation::Sign, + }); + } + } + + let payload = Base64UrlUnpadded::encode_string(&serialize( + &Payload { + keys: self + .keys + .values() + .filter(|key| key.active) + .map(|key| &key.public) + .collect(), + }, + "advertisement payload", + )?); + let protected = + Base64UrlUnpadded::encode_string(r#"{"alg":"ES512","cty":"jwk-set+json"}"#.as_bytes()); + let to_sign = format!("{protected}.{payload}"); + let mut rng = crypto_rng(entropy, RandomPurpose::AdvertisementSigning)?; + // Like Tang, retain signatures for every advertised verification key. + let mut signatures = Vec::new(); + for (kid, key) in &self.keys { + if !key.active && skid != Some(kid.as_str()) { + continue; + } + if let KeyMaterial::Signing(signing) = &key.material { + let signature: ecdsa::Signature = signing + .try_sign_with_rng(&mut rng, to_sign.as_bytes()) + .map_err(|source| Error::Signing { + kid: kid.clone(), + source: Box::new(source), + })?; + signatures.push(Signature { + protected: protected.clone(), + signature: Base64UrlUnpadded::encode_string(&signature.to_bytes()), + }); + } + } + // Initialization guarantees at least one signing key. + let advertisement = match signatures.as_slice() { + [signature] => Advertise { + payload, + protected: Some(signature.protected.clone()), + signature: Some(signature.signature.clone()), + signatures: None, + }, + _ => Advertise { + payload, + protected: None, + signature: None, + signatures: Some(signatures), + }, + }; + serialize_string(&advertisement, "advertisement") + } + + /// Recover a client P-521 point using an active or rotated ECMR key. + /// `kid` is the server key thumbprint; `request` is a public JWK JSON string. + /// Invalid client material returns [`Error::InvalidRequest`]. + pub fn rec(&self, kid: &str, request: &str) -> Result { + let key = self + .keys + .get(kid) + .ok_or_else(|| Error::KeyNotFound { kid: kid.into() })?; + let secret = match &key.material { + KeyMaterial::Recovery(secret) => secret, + KeyMaterial::Signing(_) => { + return Err(Error::ForbiddenOperation { + kid: kid.into(), + algorithm: Algorithm::Ecmr, + operation: KeyOperation::DeriveKey, + }) + } + }; + let request_key = parse_private_jwk(request) + .map_err(|source| Error::request_cause("invalid JSON", JsonError(source)))?; + if request_key.alg.as_deref().is_some_and(|alg| alg != "ECMR") { + return Err(Error::request("algorithm must be ECMR")); + } + if request_key + .key_ops + .as_ref() + .is_some_and(|ops| !ops.iter().any(|op| op == "deriveKey")) + { + return Err(Error::request("key operations must permit deriveKey")); + } + let request_key = request_key + .to_p521_public_key() + .map_err(|source| Error::request_cause("invalid public key", source))?; + let response = + diffie_hellman_public_key(&secret.to_nonzero_scalar(), request_key.as_affine())?; + serialize_string(&response, "recovery response") + } +} + +fn diffie_hellman_public_key( + secret_key: &elliptic_curve::NonZeroScalar, + public_key: &elliptic_curve::AffinePoint, +) -> Result { + let public_point = p521::ProjectivePoint::from(*public_key); + let secret_point = (public_point * secret_key.as_ref()).to_affine(); + // A validated nonidentity point times a nonzero scalar on the prime-order + // P-521 curve cannot be the identity. Retain a request-classified error at + // this fallible API boundary in case that invariant ever changes. + let public = p521::PublicKey::from_affine(secret_point) + .map_err(|source| Error::request_cause("invalid recovery result point", source))?; + let mut jwk = format_public_key(&public)?; + jwk.alg = Some("ECMR".into()); + jwk.key_ops = Some(vec!["deriveKey".into()]); + Ok(jwk) +} diff --git a/src/support.rs b/src/support.rs new file mode 100644 index 0000000..e1eb8b1 --- /dev/null +++ b/src/support.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Shared helpers for operations that can fail during key generation and protocol responses. +//! +//! Key generation and advertisement signing both need a fresh random seed from +//! the operating system. This module obtains that seed through a fallible call, +//! so an unavailable randomness source returns an error identifying the operation. +//! It then seeds a cryptographically secure pseudorandom number generator for +//! the cryptographic routines, whose random-byte interface is infallible. +//! +//! Key thumbprints, generated keys and protocol responses also need JavaScript +//! Object Notation (JSON) serialization. The helpers here attach a description +//! of the value being serialized to any error. Keeping these policies together +//! lets the key and protocol modules share error handling without depending on +//! each other's implementation. + +use p521::elliptic_curve::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; +use serde::Serialize; +use zeroize::Zeroizing; + +use crate::{Error, RandomPurpose, Result}; + +// Seed a fresh cryptographically secure pseudorandom number generator through +// a fallible operating-system call before cryptographic operations. +// ChaCha20 fills cannot fail; entropy failures retain their original source and +// operation context, including before randomized signing. +pub(crate) fn crypto_rng( + entropy: &mut impl FnMut(&mut [u8]) -> std::result::Result<(), E>, + operation: RandomPurpose, +) -> Result { + let mut seed = Zeroizing::new([0u8; 32]); + entropy(seed.as_mut()).map_err(|source| Error::Randomness { + operation, + source: Box::new(source), + })?; + Ok(ChaCha20Rng::from_seed(*seed)) +} + +pub(crate) fn serialize(value: &T, context: &'static str) -> Result> { + serde_json::to_vec(value).map_err(|source| Error::Serialization { context, source }) +} + +pub(crate) fn serialize_string(value: &T, context: &'static str) -> Result { + serde_json::to_string(value).map_err(|source| Error::Serialization { context, source }) +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..2a18190 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::*; +use crate::{ + jwk::create_new_key_set_with_rng, + key_store::parse_stored_key, + support::{serialize, serialize_string}, +}; +use serde::Serialize; +use std::{error::Error as _, io}; + +fn failing_entropy(_dest: &mut [u8]) -> std::result::Result<(), io::Error> { + Err(io::Error::other("injected entropy failure")) +} + +/// Assures: KEY-GEN-001 +#[test] +fn entropy_failures_return_errors_before_key_generation_or_signing() { + let error = create_new_key_set_with_rng(&mut failing_entropy).unwrap_err(); + assert!(matches!( + error, + Error::Randomness { + operation: RandomPurpose::KeyGeneration, + .. + } + )); + assert!(error.source().unwrap().is::()); + assert_eq!( + error.source().unwrap().to_string(), + "injected entropy failure" + ); + + let keys = vec![ + include_str!("../tests/fixtures/es512.json"), + include_str!("../tests/fixtures/ecmr.json"), + ]; + let library = TangyLib::init(KeySource::Vector(&keys)).unwrap(); + for selection in [None, Some(keys[0])] + .map(|key| key.map(|key| parse_stored_key(key).unwrap().thumbprint().unwrap())) + { + let error = library + .adv_with_rng(selection.as_deref(), &mut failing_entropy) + .unwrap_err(); + assert!(matches!( + error, + Error::Randomness { + operation: RandomPurpose::AdvertisementSigning, + .. + } + )); + assert!(error.source().unwrap().is::()); + assert_eq!( + error.source().unwrap().to_string(), + "injected entropy failure" + ); + } + // Serving the cached advertisement requires no new entropy. + assert!(!library.adv(None).unwrap().is_empty()); +} + +/// Assures: ERR-DIAG-001 +#[test] +fn serialization_failures_keep_their_context_and_cause() { + struct Unserializable; + impl Serialize for Unserializable { + fn serialize( + &self, + _serializer: S, + ) -> std::result::Result { + Err(serde::ser::Error::custom("injected serialization failure")) + } + } + for error in [ + serialize(&Unserializable, "test payload").unwrap_err(), + serialize_string(&Unserializable, "test response").unwrap_err(), + ] { + assert!(matches!(error, Error::Serialization { .. })); + assert_eq!( + error.source().unwrap().to_string(), + "injected serialization failure" + ); + } +} diff --git a/tests/clevis.rs b/tests/clevis.rs new file mode 100644 index 0000000..d1e8acf --- /dev/null +++ b/tests/clevis.rs @@ -0,0 +1,233 @@ +mod common; + +use std::{ + io::{self, BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + path::PathBuf, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use common::*; +use tangy_lib::{KeySource, TangyLib}; + +// A bounded, loopback-only adapter for the external client test, not a server API. +struct Server { + url: String, + stop: Arc, + worker: Option>>>, +} + +impl Server { + fn start(lib: TangyLib) -> Self { + Self::start_with_rotation(lib, None) + } + + fn start_with_rotation( + mut lib: TangyLib, + rotation: Option<(PathBuf, PathBuf, TangyLib)>, + ) -> Self { + let mut rotation = rotation; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let stop = Arc::new(AtomicBool::new(false)); + let stopping = Arc::clone(&stop); + let worker = thread::spawn(move || { + let mut paths = Vec::new(); + while !stopping.load(Ordering::SeqCst) { + if rotation + .as_ref() + .is_some_and(|(request, _, _)| request.exists()) + { + let (_, ready, rotated) = rotation + .take() + .ok_or_else(|| io::Error::other("missing test rotation"))?; + lib = rotated; + std::fs::write(ready, "rotated")?; + } + match listener.accept() { + Ok((stream, _)) => paths.push(serve(&lib, stream)?), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error), + } + } + Ok(paths) + }); + Self { + url, + stop, + worker: Some(worker), + } + } + + fn finish(mut self) -> Vec { + self.stop.store(true, Ordering::SeqCst); + self.worker.take().unwrap().join().unwrap().unwrap() + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +fn serve(lib: &TangyLib, mut stream: TcpStream) -> io::Result { + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + let mut reader = BufReader::new(stream.try_clone()?); + let mut request = String::new(); + reader.by_ref().take(8192).read_line(&mut request)?; + let mut parts = request.split_whitespace(); + let method = parts.next().unwrap_or(""); + let path = parts.next().unwrap_or("").to_owned(); + let mut length = 0; + let mut header_bytes = request.len(); + loop { + let mut line = String::new(); + let read = reader.by_ref().take(8192).read_line(&mut line)?; + header_bytes += read; + if read == 0 || header_bytes > 8192 { + return Err(io::Error::other("Invalid test HTTP headers")); + } + if line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse::().map_err(io::Error::other)?; + } + } + } + if length > 8192 { + return Err(io::Error::other("Test request too large")); + } + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + let body = String::from_utf8(body).map_err(io::Error::other)?; + let (content_type, result) = match (method, path.as_str()) { + ("GET", "/adv" | "/adv/") => ("application/jose+json", lib.adv(None)), + ("GET", path) if path.starts_with("/adv/") => { + ("application/jose+json", lib.adv(Some(&path[5..]))) + } + ("POST", path) if path.starts_with("/rec/") => { + ("application/jwk+json", lib.rec(&path[5..], &body)) + } + _ => return Err(io::Error::other("Unexpected test HTTP route")), + }; + let (status, response) = match result { + Ok(body) => ("200 OK", body), + Err(error) => ("400 Bad Request", error.to_string()), + }; + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len())?; + Ok(path) +} + +/// Assures: INTEROP-001, ADV-OPS-001 +#[test] +fn clevis_encrypts_and_recovers_using_library_endpoints() { + // Also accept extra stored permissions without advertising roles the server + // does not serve: Clevis requires all advertised verify keys to have signed. + let mut extra: serde_json::Value = serde_json::from_str(RECOVERY).unwrap(); + extra["key_ops"] = serde_json::json!(["deriveKey", "verify", "sign"]); + let mut sig_use: serde_json::Value = serde_json::from_str(RECOVERY).unwrap(); + sig_use["use"] = serde_json::json!("sig"); + let sig_use = sig_use.to_string(); + extra["use"] = serde_json::json!("sig"); + let extra = extra.to_string(); + for (signing, recovery) in [ + (vec![SIGNING], RECOVERY), + (vec![SIGNING, SECOND_SIGNING], RECOVERY), + (vec![SIGNING], sig_use.as_str()), + (vec![SIGNING, SECOND_SIGNING], sig_use.as_str()), + (vec![SIGNING], extra.as_str()), + (vec![SIGNING, SECOND_SIGNING], extra.as_str()), + ] { + let mut keys = signing.clone(); + let selected = thumbprint(signing.last().unwrap()); + keys.push(recovery); + let server = Server::start(library(&keys)); + let output = Command::new("python3") + .arg("scripts/clevis-round-trip.py") + .arg(&server.url) + .arg(&selected) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("Python 3 is required for interop tests"); + assert!( + output.status.success(), + "Clevis failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let paths = server.finish(); + assert!(paths.iter().any(|p| p == "/adv/" || p == "/adv")); + assert!(paths.contains(&format!("/adv/{selected}"))); + assert_eq!( + paths + .iter() + .filter(|p| **p == format!("/rec/{}", thumbprint(RECOVERY))) + .count(), + 2 + ); + } +} + +/// Assures: INTEROP-ROTATE-001 +#[test] +fn clevis_recovers_old_bindings_enrolls_with_current_thumbprint_and_jose_verifies_old_signer() { + let generated = tangy_lib::create_new_key_set().unwrap(); + let active: Vec<_> = generated.iter().map(String::as_str).collect(); + let new_recovery = thumbprint(&generated[1]); + let rotated = TangyLib::init(KeySource::KeySets { + active: &active, + rotated: &[SIGNING, RECOVERY], + }) + .unwrap(); + let signals = tempfile::tempdir().unwrap(); + let request = signals.path().join("rotate"); + let ready = signals.path().join("ready"); + let server = Server::start_with_rotation( + library(&[SIGNING, RECOVERY]), + Some((request.clone(), ready.clone(), rotated)), + ); + let selected = thumbprint(SIGNING); + let output = Command::new("python3") + .arg("scripts/clevis-rotation.py") + .arg(&server.url) + .arg(&selected) + .arg(request) + .arg(ready) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .stdin(Stdio::null()) + .output() + .expect("Python 3 required for interoperability"); + assert!( + output.status.success(), + "Clevis rotation failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let paths = server.finish(); + assert_eq!( + paths + .iter() + .filter(|p| **p == format!("/adv/{selected}")) + .count(), + 4 + ); + assert!(paths.contains(&format!("/adv/{}", thumbprint(&generated[0])))); + assert!(paths.contains(&format!("/rec/{}", thumbprint(RECOVERY)))); + assert!(paths.contains(&format!("/rec/{new_recovery}"))); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..e74e18f --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,48 @@ +#![allow(dead_code)] + +use base64ct::{Base64UrlUnpadded, Encoding}; +use serde_json::Value; +use tangy_lib::{KeySource, MyJwkEcKey, TangyLib}; + +pub const SIGNING: &str = include_str!("../fixtures/es512.json"); +pub const SECOND_SIGNING: &str = include_str!("../fixtures/es512-second.json"); +pub const RECOVERY: &str = include_str!("../fixtures/ecmr.json"); + +pub fn library(keys: &[&str]) -> TangyLib { + TangyLib::init(KeySource::Vector(keys)).unwrap() +} + +pub fn fixture_key(json: &str) -> MyJwkEcKey { + serde_json::from_str(json).unwrap() +} + +pub fn thumbprint(json: &str) -> String { + fixture_key(json).thumbprint().unwrap() +} + +pub fn vectors() -> Vec { + serde_json::from_str(include_str!("../fixtures/recovery.json")).unwrap() +} + +pub fn decode(field: &Value) -> Vec { + let encoded = field.as_str().unwrap(); + assert!(!encoded.contains(['=', '+', '/']), "not unpadded Base64URL"); + Base64UrlUnpadded::decode_vec(encoded).unwrap() +} + +pub fn payload(adv: &Value) -> Value { + serde_json::from_slice(&decode(&adv["payload"])).unwrap() +} + +pub fn assert_public(value: &Value) { + match value { + Value::Object(fields) => { + assert!(!fields.contains_key("d"), "private key in response"); + for value in fields.values() { + assert_public(value); + } + } + Value::Array(values) => values.iter().for_each(assert_public), + _ => {} + } +} diff --git a/tests/errors.rs b/tests/errors.rs new file mode 100644 index 0000000..02f7e9d --- /dev/null +++ b/tests/errors.rs @@ -0,0 +1,382 @@ +mod common; + +use std::error::Error as StdError; + +use base64ct::Encoding; +use common::*; +use serde_json::{json, Value}; +use tangy_lib::{ + Algorithm, Error, JsonError, KeyLocation, KeyOperation, KeySource, RandomPurpose, TangyLib, + WriteStep, +}; + +fn diagnostics(error: &dyn StdError) -> String { + let mut messages = format!("{error} {error:?}"); + let mut source = error.source(); + while let Some(error) = source { + messages.push_str(&format!(" {error} {error:?}")); + source = error.source(); + } + messages +} + +/// Assures: KEY-LOAD-001, ERR-DIAG-001 +#[test] +fn incomplete_key_sets_identify_the_missing_roles() { + for (keys, expected) in [ + (vec![], (true, true)), + (vec![SIGNING], (false, true)), + (vec![RECOVERY], (true, false)), + ] { + let error = TangyLib::init(KeySource::Vector(&keys)).unwrap_err(); + match error { + Error::MissingKeys { signing, recovery } => assert_eq!((signing, recovery), expected), + _ => panic!("unexpected error: {error:?}"), + } + } +} + +/// Assures: ERR-DIAG-001 +#[test] +fn invalid_stored_keys_include_their_origin_and_safe_source_chain() { + let error = TangyLib::init(KeySource::Vector(&[SIGNING, RECOVERY, "{"])).unwrap_err(); + assert!(matches!( + &error, + Error::InvalidStoredKey { + location: KeyLocation::Input(2), + .. + } + )); + let Error::InvalidStoredKey { source, .. } = &error else { + panic!("unexpected error: {error:?}"); + }; + let invalid = source.as_ref(); + assert!(matches!(invalid, Error::InvalidKey { .. })); + let json = invalid + .source() + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!(json.category(), serde_json::error::Category::Eof); + assert_eq!(json.line(), 1); + assert!(json.column() > 0); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("broken.jwk"); + std::fs::write(&path, "{").unwrap(); + let error = TangyLib::init(KeySource::LocalDir(dir.path())).unwrap_err(); + assert!( + matches!(&error, Error::InvalidStoredKey { location: KeyLocation::File(actual), .. } if actual == &path) + ); + assert!(error.source().is_some()); +} + +/// Assures: ERR-DIAG-001 +#[test] +fn stored_policy_and_crypto_errors_retain_validation_causes() { + let mut key: Value = serde_json::from_str(SIGNING).unwrap(); + key["key_ops"] = json!(["verify"]); + let invalid = key.to_string(); + let error = TangyLib::init(KeySource::Vector(&[RECOVERY, &invalid])).unwrap_err(); + assert!(matches!( + &error, + Error::InvalidStoredKey { + location: KeyLocation::Input(1), + .. + } + )); + let Error::InvalidStoredKey { source, .. } = &error else { + panic!("unexpected error: {error:?}"); + }; + assert!(matches!( + source.as_ref(), + Error::ForbiddenOperation { kid, algorithm: Algorithm::Es512, operation: KeyOperation::Sign } if kid == &thumbprint(SIGNING) + )); + + key["key_ops"] = json!(["sign", "verify"]); + key["x"] = json!(base64ct::Base64UrlUnpadded::encode_string(&[0xff; 66])); + let invalid = key.to_string(); + let error = TangyLib::init(KeySource::Vector(&[RECOVERY, &invalid])).unwrap_err(); + let Error::InvalidStoredKey { source, .. } = &error else { + panic!("unexpected error: {error:?}"); + }; + let validation = source.as_ref(); + assert!(matches!(validation, Error::InvalidKey { .. })); + assert!(validation + .source() + .unwrap() + .is::()); +} + +/// Assures: ERR-DIAG-001 +#[test] +fn stored_policy_errors_identify_every_required_operation() { + for (fixture, operations, algorithm, operation) in [ + ( + SIGNING, + json!(["verify"]), + Algorithm::Es512, + KeyOperation::Sign, + ), + ( + SIGNING, + json!(["sign"]), + Algorithm::Es512, + KeyOperation::Verify, + ), + ( + RECOVERY, + json!([]), + Algorithm::Ecmr, + KeyOperation::DeriveKey, + ), + ] { + let mut key: Value = serde_json::from_str(fixture).unwrap(); + key["key_ops"] = operations; + let invalid = key.to_string(); + let error = TangyLib::init(KeySource::Vector(&[invalid.as_str()])).unwrap_err(); + let Error::InvalidStoredKey { location, source } = &error else { + panic!("unexpected error: {error:?}"); + }; + assert_eq!(location, &KeyLocation::Input(0)); + assert!(matches!(source.as_ref(), Error::ForbiddenOperation { + kid, algorithm: actual_algorithm, operation: actual_operation, + } if kid == &thumbprint(fixture) && *actual_algorithm == algorithm && *actual_operation == operation)); + } +} + +/// Assures: ERR-DIAG-001 +#[test] +fn operation_displays_preserve_protocol_and_diagnostic_wording() { + for (actual, expected) in [ + (Algorithm::Es512.to_string(), "ES512"), + (Algorithm::Ecmr.to_string(), "ECMR"), + (KeyOperation::Sign.to_string(), "sign"), + (KeyOperation::Verify.to_string(), "verify"), + (KeyOperation::DeriveKey.to_string(), "deriveKey"), + (WriteStep::Create.to_string(), "create"), + (WriteStep::Write.to_string(), "write"), + (WriteStep::Synchronize.to_string(), "synchronize"), + (WriteStep::Publish.to_string(), "publish"), + ( + KeyLocation::RotatedInput(2).to_string(), + "rotated input index 2", + ), + (RandomPurpose::KeyGeneration.to_string(), "key generation"), + ( + RandomPurpose::AdvertisementSigning.to_string(), + "advertisement signing", + ), + (KeyLocation::Input(2).to_string(), "input index 2"), + ] { + assert_eq!(actual, expected); + } +} + +/// Assures: ERR-DIAG-001 +#[test] +fn top_level_messages_exclude_sources_and_chains_report_each_cause_once() { + let cause = || std::io::Error::other("source-marker"); + let errors = [ + Error::InvalidStoredKey { + location: KeyLocation::Input(2), + source: Box::new(Error::InvalidKey { + reason: "validation-marker", + source: Some(Box::new(cause())), + }), + }, + Error::InvalidKey { + reason: "bad key", + source: Some(Box::new(cause())), + }, + Error::InvalidRequest { + reason: "bad request", + source: Some(Box::new(cause())), + }, + Error::KeyDirectory { + path: "keys".into(), + source: cause(), + }, + Error::KeyRead { + path: "keys/key.jwk".into(), + source: cause(), + }, + Error::KeyWrite { + path: "keys/key.jwk".into(), + operation: WriteStep::Write, + source: cause(), + }, + Error::Serialization { + context: "test payload", + source: ::custom("source-marker"), + }, + Error::Signing { + kid: "key-id".into(), + source: Box::new(cause()), + }, + Error::Randomness { + operation: RandomPurpose::KeyGeneration, + source: Box::new(cause()), + }, + ]; + for error in errors { + let top_level = error.to_string(); + let mut chain = top_level.clone(); + let mut source = error.source(); + while let Some(cause) = source { + let message = cause.to_string(); + assert!(!top_level.contains(&message), "source repeated in {error}"); + chain.push_str(&format!("\ncaused by: {cause}")); + source = cause.source(); + } + assert_eq!(chain.matches("source-marker").count(), 1, "{chain}"); + if matches!(error, Error::InvalidStoredKey { .. }) { + assert_eq!(top_level, "invalid stored key at input index 2"); + assert_eq!(chain.matches("validation-marker").count(), 1, "{chain}"); + } + } +} + +/// Assures: ERR-DIAG-001 +#[test] +fn signing_failures_retain_the_key_id_and_opaque_crypto_cause() { + let error = Error::Signing { + kid: "signing-key-id".into(), + source: Box::new(p521::ecdsa::signature::Error::new()), + }; + assert!(matches!(&error, Error::Signing { kid, .. } if kid == "signing-key-id")); + assert!(error + .source() + .unwrap() + .is::()); +} + +/// Assures: ERR-DIAG-001 +#[test] +fn persistence_errors_include_paths_and_original_io_errors() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("missing"); + let error = TangyLib::init(KeySource::LocalDir(&path)).unwrap_err(); + assert!(matches!(&error, Error::KeyDirectory { path: actual, .. } if actual == &path)); + assert_eq!( + error + .source() + .unwrap() + .downcast_ref::() + .unwrap() + .kind(), + std::io::ErrorKind::NotFound + ); + + let path = dir.path().join("invalid.jwk"); + std::fs::write(&path, [0xff]).unwrap(); + let error = TangyLib::init(KeySource::LocalDir(dir.path())).unwrap_err(); + assert!(matches!(&error, Error::KeyRead { path: actual, .. } if actual == &path)); + assert_eq!( + error + .source() + .unwrap() + .downcast_ref::() + .unwrap() + .kind(), + std::io::ErrorKind::InvalidData + ); +} + +/// Assures: REQ-REJECT-001, ERR-DIAG-001 +#[test] +fn protocol_errors_distinguish_missing_keys_forbidden_operations_and_requests() { + let library = library(&[SIGNING, RECOVERY]); + let id = thumbprint(RECOVERY); + let signing_id = thumbprint(SIGNING); + let request = vectors()[1]["request"].to_string(); + for error in [ + library.adv(Some("unknown")).unwrap_err(), + library.rec("unknown", &request).unwrap_err(), + ] { + assert!(matches!(error, Error::KeyNotFound { kid } if kid == "unknown")); + } + assert!( + matches!(library.adv(Some(&id)), Err(Error::ForbiddenOperation { kid, algorithm: Algorithm::Es512, operation: KeyOperation::Sign }) if kid == id) + ); + assert!( + matches!(library.rec(&signing_id, &request), Err(Error::ForbiddenOperation { kid, algorithm: Algorithm::Ecmr, operation: KeyOperation::DeriveKey }) if kid == signing_id) + ); + + for request in ["{", "null", "{}"] { + let error = library.rec(&id, request).unwrap_err(); + assert!(matches!(error, Error::InvalidRequest { .. })); + assert!(error.source().unwrap().is::()); + } + for (field, value, has_cause) in [ + ("x", json!("%%%"), true), + ("crv", json!("P-256"), true), + ("alg", json!("ES512"), false), + ("key_ops", json!(["sign"]), false), + ] { + let mut request = vectors()[1]["request"].clone(); + request[field] = value; + let error = library.rec(&id, &request.to_string()).unwrap_err(); + assert!(matches!(error, Error::InvalidRequest { .. })); + assert_eq!(error.source().is_some(), has_cause); + } +} + +/// Assures: KEY-PRIV-001 +#[test] +fn invalid_conversion_helpers_return_typed_errors_without_private_values() { + let secret = fixture_key(SIGNING).d.unwrap(); + for (field, value) in [ + ("x", json!("%%%")), + ("crv", json!("P-256")), + ("kty", json!("RSA")), + ("d", Value::Null), + ("d", json!("private-scalar-marker")), + ("d", fixture_key(SECOND_SIGNING).d.unwrap().into()), + ] { + let mut key: Value = serde_json::from_str(SIGNING).unwrap(); + key[field] = value; + let jwk = fixture_key(&key.to_string()); + let error = jwk.to_p521_secret_key().unwrap_err(); + assert!(matches!(error, Error::InvalidKey { .. })); + let messages = diagnostics(&error); + assert!(!messages.contains(&secret)); + assert!(!messages.contains("private-scalar-marker")); + if field != "d" { + assert!(matches!( + jwk.to_p521_public_key(), + Err(Error::InvalidKey { .. }) + )); + } + } + let mut public = fixture_key(SIGNING).to_public_key(); + public.d = Some("ignored-private-scalar".into()); + assert_eq!( + public.to_p521_public_key().unwrap(), + fixture_key(SIGNING).to_p521_public_key().unwrap() + ); +} + +/// Assures: KEY-PRIV-001 +#[test] +fn error_formatting_and_sources_do_not_echo_private_json_values() { + let library = library(&[SIGNING, RECOVERY]); + let marker = "private-scalar-marker"; + // Serde normally quotes this value in its error: key_ops expects a list. + let mut key: Value = serde_json::from_str(SIGNING).unwrap(); + key["key_ops"] = json!(marker); + let json = key.to_string(); + for error in [ + TangyLib::init(KeySource::Vector(&[&json, RECOVERY])).unwrap_err(), + library.rec(&thumbprint(RECOVERY), &json).unwrap_err(), + ] { + let messages = diagnostics(&error); + assert!(!messages.contains(marker)); + assert!(!messages.contains(key["d"].as_str().unwrap())); + assert!(messages.contains("JSON Data error at line")); + } + let formatted = format!("{library:?}"); + for source in [SIGNING, RECOVERY] { + assert!(!formatted.contains(fixture_key(source).d.as_deref().unwrap())); + } +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..8f2e38f --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,32 @@ +# Protocol fixtures + +All private keys here are public test data. Never use them outside tests. + +`es512.json` and `ecmr.json` preserve the original crate's test keys. +`es512-second.json` is a P-521 signing key derived from the fixed scalar +`0x123456789abcdef0123456789abcdef` using Python cryptography 44.0.0. + +`recovery.json` contains requests whose public points are `r*G`, with client +scalars `1` and `0xabcdef0123456789fedcba987654321`. Responses were computed +independently using cryptography's `ec.derive_private_key((d*r) % n, +ec.SECP521R1()).public_key().public_numbers()`, where `d` is the recovery key's +private scalar and `n` is the P-521 group order. Coordinates use 66-byte, +big-endian, unpadded Base64URL encoding. The first vector tests multiplication +by the generator; the second tests a nontrivial input point. + +`thumbprints.json` records SHA-256 hashes computed with Python hashlib over +compact JSON containing only `crv`, `kty`, `x`, and `y`, in that order, encoded +with unpadded Base64URL. These include the two original thumbprint constants. + +Both the thumbprints and full recovery responses are independently checked +against [Latchset JOSE](https://github.com/latchset/jose) with: + +```sh +python3 scripts/check-fixtures.py +``` + +The script invokes `jose jwk thp` and `jose jwk exc` and compares their output +with the committed expected values. As in +[Tang's recovery handler](https://github.com/latchset/tang/blob/master/src/tangd.c), +it adds `alg: ECMR` and `key_ops: [deriveKey]` to the exchange result. Ordinary +Rust tests use only the committed fixtures, without Python or JOSE dependencies. diff --git a/tests/fixtures/ecmr.json b/tests/fixtures/ecmr.json new file mode 100644 index 0000000..42fa862 --- /dev/null +++ b/tests/fixtures/ecmr.json @@ -0,0 +1,11 @@ +{ + "kty": "EC", + "alg": "ECMR", + "crv": "P-521", + "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", + "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", + "d": "AQTm4JamDPZufHlRCC12Ssjh6xTwu630neCLr7EUtUuZoFHk9zga-kzwaGajH1MQb8ffc3CeV-7InHKmR8HvytTE", + "key_ops": [ + "deriveKey" + ] +} diff --git a/tests/fixtures/es512-second.json b/tests/fixtures/es512-second.json new file mode 100644 index 0000000..24f962a --- /dev/null +++ b/tests/fixtures/es512-second.json @@ -0,0 +1,12 @@ +{ + "kty": "EC", + "crv": "P-521", + "x": "AdAC-Zz4Wlb44ZNaFnAXn-VhEl_VZa_rIF0N6ZRZV4r-S-gANVyj_zWl6D2j1K-OKDymQqTrGsbVTBQGQ1ky1_mQ", + "y": "AA-OAKP_K0aLnhLZE5PgB4UpTfmB13gpmCSc76T1ahGJjqxNfVgT6XgbxVcc8_qDwIQ0WfOt0tDhuMrl6wSCDP6d", + "d": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABI0VniavN7wEjRWeJq83v", + "alg": "ES512", + "key_ops": [ + "sign", + "verify" + ] +} diff --git a/tests/fixtures/es512.json b/tests/fixtures/es512.json new file mode 100644 index 0000000..ddf4026 --- /dev/null +++ b/tests/fixtures/es512.json @@ -0,0 +1,12 @@ +{ + "kty": "EC", + "alg": "ES512", + "crv": "P-521", + "x": "AX5mUTAH1qr3YSSwuMV_HV0yupJhMIAqwly710a7qLbXR6up3flnaPsJbaSVATrIF6QcXc9PPyFW1IQHmDOWGSPj", + "y": "ADT1K8Q-O1Q5lyU3StXnPMQwgnYWS8hnTRGjjcFssitZy_tUWSuhUPFhzaUJKhXRNbcyELeDX-kPCMbBKX1vb8Lq", + "d": "AbDO5xCtQHUbHld-Fq61sSCvyjr9EpNj3_sklNmo54xmKeYu_cW_s7fzQxm6SsqFwrTmiiFz2OaD1ODsXI-DdoKt", + "key_ops": [ + "sign", + "verify" + ] +} diff --git a/tests/fixtures/recovery.json b/tests/fixtures/recovery.json new file mode 100644 index 0000000..abe4b10 --- /dev/null +++ b/tests/fixtures/recovery.json @@ -0,0 +1,46 @@ +[ + { + "request": { + "kty": "EC", + "crv": "P-521", + "x": "AMaFjga3BATpzZ4-y2YjlbRCnGSBOQU_tSH4KK9ga009uqFLXnfv51ko_h3BJ6L_qN4zSLPBhWpCm_l-fjHC5b1m", + "y": "ARg5KWp4mjvABFyKX7QsfRvZmPVESVebRGgXr70XJz5mLJfucple9CZAxVC5AT-tB2E1PHCGonLCQIi-lHaf0WZQ", + "alg": "ECMR", + "key_ops": [ + "deriveKey" + ] + }, + "response": { + "kty": "EC", + "crv": "P-521", + "x": "ASa1DOpfB9-Qe1zkbG6HAZ_DC2FNUBeR6e3kgLgHF8xC8JZM1EsiGjkvTRk0paH_Oat8OSGSRPD0-PsXFAvNuXCd", + "y": "AaO_WH8pzC__37gCuCJdgtIbO6IK4XLfyjAjuJovvfksoMigvFwpyLKwWhIfE8lQqPR7CMxG2LRLXJIubFjSDMDH", + "alg": "ECMR", + "key_ops": [ + "deriveKey" + ] + } + }, + { + "request": { + "kty": "EC", + "crv": "P-521", + "x": "ABAOZHCCQQzldWZk6fhnr81CTjb9aHCjjEJpBzvFVqfSNZDipzE3ApgXzk0VQ6fE1VoDmRSPsyiz2fLy9M9VhPrV", + "y": "AQnCV6dw2ojuA0TLANHitiSuyDTo7iaEWAHXhV6G7i-YZruEF3-3J-xg9s2rdAcqd6Xd80JUG5pl2wv3LoowpyRX", + "alg": "ECMR", + "key_ops": [ + "deriveKey" + ] + }, + "response": { + "kty": "EC", + "crv": "P-521", + "x": "AVWvdTySzFHCzvO4_LwI1JEuVB4fS5qtn74_y3Uu4LybsVBT5ZEkDp39KosyaUxDdvmwGpVQcCwQPCMtLeopwxFz", + "y": "ADd8jwv3sga66ayIY5IrmWAYS4NHEUXVNZVivZJYNnbmltP95Z7bkqcZ6wPhG0ZuymFvqm8jDUsm2jC1pGshHAsu", + "alg": "ECMR", + "key_ops": [ + "deriveKey" + ] + } + } +] diff --git a/tests/fixtures/thumbprints.json b/tests/fixtures/thumbprints.json new file mode 100644 index 0000000..799db6d --- /dev/null +++ b/tests/fixtures/thumbprints.json @@ -0,0 +1,5 @@ +{ + "es512.json": "tpUdnaei02Z6bSS3_rKEU0BDPl8tyZFy16CKCTWNlbA", + "ecmr.json": "UFgqx9-PLx_h6h4hd6sysNHMC6cDyjBQOYZHFvObLbo", + "es512-second.json": "wcVUDUFnijC6UyhrbnRhLvC8hGoqOMRfYg-XEir_jLY" +} diff --git a/tests/keys.rs b/tests/keys.rs new file mode 100644 index 0000000..69a912d --- /dev/null +++ b/tests/keys.rs @@ -0,0 +1,280 @@ +mod common; + +use base64ct::{Base64UrlUnpadded, Encoding}; +use common::*; +use p521::elliptic_curve::sec1::ToSec1Point; +use serde_json::{json, Value}; +use tangy_lib::{create_new_key_set, KeySource, MyJwkEcKey, TangyLib}; + +/// Assures: KEY-VALID-001 +#[test] +fn typed_conversion_rejects_invalid_field_encodings_and_lengths() { + for field in ["x", "y", "d"] { + for encoded in [ + String::new(), + "%%%".into(), + "+".repeat(88), + "/".repeat(88), + format!("{}=", "A".repeat(88)), + Base64UrlUnpadded::encode_string(&[1; 65]), + Base64UrlUnpadded::encode_string(&[1; 67]), + ] { + let mut value: Value = serde_json::from_str(SIGNING).unwrap(); + value[field] = json!(encoded); + let key = fixture_key(&value.to_string()); + assert!(matches!( + key.to_p521_secret_key(), + Err(tangy_lib::Error::InvalidKey { .. }) + )); + if field == "d" { + assert_eq!( + key.to_p521_public_key().unwrap(), + fixture_key(SIGNING).to_p521_public_key().unwrap() + ); + } else { + assert!(matches!( + key.to_p521_public_key(), + Err(tangy_lib::Error::InvalidKey { .. }) + )); + } + } + } +} + +/// Assures: KEY-VALID-001 +#[test] +fn typed_conversion_checks_scalar_boundaries_and_public_points() { + // SEC 2's P-521 group order, encoded as exactly 66 big-endian bytes. + let order_hex = "01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409"; + let order: Vec = (0..order_hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&order_hex[i..i + 2], 16).unwrap()) + .collect(); + assert_eq!(order.len(), 66); + for bytes in [vec![0; 66], order.clone(), vec![0xff; 66]] { + let mut key = fixture_key(SIGNING); + key.d = Some(Base64UrlUnpadded::encode_string(&bytes)); + assert!(matches!( + key.to_p521_secret_key(), + Err(tangy_lib::Error::InvalidKey { .. }) + )); + } + let mut one = vec![0; 66]; + one[65] = 1; + let mut largest = order; + largest[65] -= 1; + for bytes in [one, largest] { + let secret = p521::SecretKey::from_slice(&bytes).unwrap(); + let point = secret.public_key().to_sec1_point(false); + let mut key = fixture_key(SIGNING); + key.x = Base64UrlUnpadded::encode_string(point.x().unwrap()); + key.y = Base64UrlUnpadded::encode_string(point.y().unwrap()); + key.d = Some(Base64UrlUnpadded::encode_string(&bytes)); + assert_eq!( + key.to_p521_secret_key().unwrap().to_bytes(), + secret.to_bytes() + ); + } + for bytes in [[0; 66], [0xff; 66]] { + let mut key = fixture_key(SIGNING); + key.x = Base64UrlUnpadded::encode_string(&bytes); + key.y = Base64UrlUnpadded::encode_string(&bytes); + assert!(matches!( + key.to_p521_public_key(), + Err(tangy_lib::Error::InvalidKey { .. }) + )); + } +} + +/// Assures: KEY-ID-001 +#[test] +fn thumbprints_match_independently_computed_values_and_ignore_metadata() { + let expected: Value = serde_json::from_str(include_str!("fixtures/thumbprints.json")).unwrap(); + for (name, source) in [ + ("es512.json", SIGNING), + ("ecmr.json", RECOVERY), + ("es512-second.json", SECOND_SIGNING), + ] { + let mut key = fixture_key(source); + assert_eq!(key.thumbprint().unwrap(), expected[name]); + assert_eq!(key.to_public_key().thumbprint().unwrap(), expected[name]); + key.alg = None; + key.key_ops = None; + key.kid = Some("arbitrary metadata".into()); + key.use_ = Some("sig".into()); + assert_eq!(key.thumbprint().unwrap(), expected[name]); + } +} + +/// Assures: KEY-GEN-001 +#[test] +fn generated_keys_and_serialization_round_trips_are_usable() { + let generated = create_new_key_set().unwrap(); + assert_eq!(generated.len(), 2); + let lib = library(&generated.iter().map(String::as_str).collect::>()); + let mut algorithms = Vec::new(); + for source in generated { + let key = fixture_key(&source); + algorithms.push(key.alg.clone().unwrap()); + assert_eq!(key.kty, "EC"); + assert_eq!(key.crv, "P-521"); + for field in [&key.x, &key.y, key.d.as_ref().unwrap()] { + assert!(!field.contains('=')); + assert_eq!(Base64UrlUnpadded::decode_vec(field).unwrap().len(), 66); + } + let encoded = serde_json::to_string(&key).unwrap(); + let round_trip: MyJwkEcKey = serde_json::from_str(&encoded).unwrap(); + assert_eq!( + serde_json::to_value(&key).unwrap(), + serde_json::to_value(&round_trip).unwrap() + ); + let secret = round_trip.to_p521_secret_key().unwrap(); + let public = key.to_public_key(); + assert_eq!(public.to_p521_public_key().unwrap(), secret.public_key()); + let public_json = serde_json::to_value(&public).unwrap(); + assert_public(&public_json); + assert_eq!( + fixture_key(&public_json.to_string()).thumbprint().unwrap(), + key.thumbprint().unwrap() + ); + match key.alg.as_deref().unwrap() { + "ES512" => { + assert_eq!(key.key_ops, Some(vec!["sign".into(), "verify".into()])); + lib.adv(Some(&key.thumbprint().unwrap())).unwrap(); + } + "ECMR" => { + assert_eq!(key.key_ops, Some(vec!["deriveKey".into()])); + lib.rec( + &key.thumbprint().unwrap(), + &vectors()[0]["request"].to_string(), + ) + .unwrap(); + } + _ => panic!("unexpected algorithm"), + } + } + algorithms.sort(); + assert_eq!(algorithms, ["ECMR", "ES512"]); +} + +/// Assures: KEY-LOAD-001 +#[test] +fn malformed_or_incomplete_vectors_return_errors() { + for keys in [ + vec![], + vec!["{"], + vec![SIGNING], + vec![RECOVERY], + vec![SIGNING, RECOVERY, "{"], + vec![SIGNING, RECOVERY, "{}"], + ] { + assert!(TangyLib::init(KeySource::Vector(&keys)).is_err()); + } +} + +/// Assures: KEY-LOAD-001, KEY-VALID-001 +#[test] +fn invalid_stored_keys_return_errors() { + for source in [SIGNING, RECOVERY] { + let mutations = [ + ("x", json!("%%%")), + ("y", json!("AA")), + ("crv", json!("P-384")), + ("kty", json!("RSA")), + ("d", Value::Null), + ("d", json!("%%%")), + ("d", json!(Base64UrlUnpadded::encode_string(&[0; 66]))), + ( + "d", + serde_json::from_str::(SECOND_SIGNING).unwrap()["d"].clone(), + ), + ("alg", Value::Null), + ("alg", json!("unsupported")), + ("key_ops", Value::Null), + ("key_ops", json!([])), + ("key_ops", json!(["sign"])), + ("key_ops", json!(["verify"])), + ]; + for (field, value) in mutations { + let mut key: Value = serde_json::from_str(source).unwrap(); + key[field] = value; + let bad = key.to_string(); + let good = if source == SIGNING { RECOVERY } else { SIGNING }; + assert!( + TangyLib::init(KeySource::Vector(&[good, &bad])).is_err(), + "accepted invalid {field}" + ); + } + let mut key: Value = serde_json::from_str(source).unwrap(); + key.as_object_mut().unwrap().remove("d"); + let bad = key.to_string(); + assert!(TangyLib::init(KeySource::Vector(&[SIGNING, RECOVERY, &bad])).is_err()); + } +} + +/// Assures: KEY-PRIV-001 +#[test] +fn direct_jwk_debug_redacts_the_scalar_and_public_conversion_preserves_metadata() { + for json in [SIGNING, RECOVERY] { + let key = fixture_key(json); + for output in [format!("{key:?}"), format!("{key:#?}")] { + assert!(!output.contains(key.d.as_ref().unwrap())); + assert!(output.contains("[REDACTED]")); + assert!(output.contains(&key.x)); + } + let public = key.to_public_key(); + assert!(public.d.is_none()); + assert_eq!(key.d, fixture_key(json).d); + let mut expected = serde_json::to_value(&key).unwrap(); + expected.as_object_mut().unwrap().remove("d"); + assert_eq!(serde_json::to_value(public).unwrap(), expected); + } +} + +/// Assures: KEY-PRIV-001 +#[test] +fn library_debug_redacts_active_and_rotated_private_keys() { + let generated = create_new_key_set().unwrap(); + let active: Vec<_> = generated.iter().map(String::as_str).collect(); + let lib = TangyLib::init(KeySource::KeySets { + active: &active, + rotated: &[SIGNING, RECOVERY], + }) + .unwrap(); + for output in [format!("{lib:?}"), format!("{lib:#?}")] { + assert!(output.contains("[REDACTED]")); + for json in active.iter().copied().chain([SIGNING, RECOVERY]) { + assert!(!output.contains(fixture_key(json).d.as_ref().unwrap())); + } + } +} + +/// Assures: KEY-VALID-001 +#[test] +fn optional_metadata_and_extra_operations_do_not_add_restrictions() { + let mut signing: Value = serde_json::from_str(SIGNING).unwrap(); + signing["use"] = json!("arbitrary optional use"); + signing["kid"] = json!("not the thumbprint"); + signing["key_ops"] = json!(["sign", "verify", "extra"]); + signing["x5c"] = json!("opaque certificate metadata"); + let signing = signing.to_string(); + let active = library(&[&signing, RECOVERY]); + let rotated = TangyLib::init(KeySource::KeySets { + active: &[SECOND_SIGNING, RECOVERY], + rotated: &[&signing], + }) + .unwrap(); + active.adv(Some(&thumbprint(SIGNING))).unwrap(); + rotated.adv(Some(&thumbprint(SIGNING))).unwrap(); + let adv: Value = serde_json::from_str(&active.adv(None).unwrap()).unwrap(); + let body = payload(&adv); + let key = body["keys"] + .as_array() + .unwrap() + .iter() + .find(|key| key["alg"] == "ES512") + .unwrap(); + assert_eq!(key["use"], "arbitrary optional use"); + assert_eq!(key["kid"], "not the thumbprint"); + assert_eq!(key["key_ops"], json!(["verify"])); +} diff --git a/tests/persistence.rs b/tests/persistence.rs new file mode 100644 index 0000000..7fadcbc --- /dev/null +++ b/tests/persistence.rs @@ -0,0 +1,369 @@ +mod common; + +use std::{collections::BTreeMap, fs, path::Path}; + +use common::*; +use serde_json::Value; +use tangy_lib::{KeySource, TangyLib}; + +fn snapshot(dir: &Path) -> BTreeMap> { + fs::read_dir(dir) + .unwrap() + .filter_map(|entry| { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|ext| ext != "jwk") { + return None; + } + Some(( + path.file_name().unwrap().to_string_lossy().into_owned(), + fs::read(path).unwrap(), + )) + }) + .collect() +} + +/// Assures: KEY-STORE-001 +#[test] +fn generated_keys_are_persisted_and_stable_across_restart() { + let dir = tempfile::tempdir().unwrap(); + let first = TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + let before = snapshot(dir.path()); + assert_eq!(before.len(), 2); + let mut ids = Vec::new(); + let mut recovery_id = None; + for (file, bytes) in &before { + let key = fixture_key(std::str::from_utf8(bytes).unwrap()); + let id = key.thumbprint().unwrap(); + assert_eq!(file, &format!("{id}.jwk")); + key.to_p521_secret_key().unwrap(); + if key.alg.as_deref() == Some("ECMR") { + recovery_id = Some(id.clone()); + } + ids.push(id); + } + ids.sort(); + let request = vectors()[1]["request"].to_string(); + let response = first + .rec(recovery_id.as_deref().unwrap(), &request) + .unwrap(); + drop(first); + let reopened = TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + assert_eq!(before, snapshot(dir.path())); + let adv: Value = serde_json::from_str(&reopened.adv(None).unwrap()).unwrap(); + let mut advertised: Vec<_> = payload(&adv)["keys"] + .as_array() + .unwrap() + .iter() + .map(|k| thumbprint(&k.to_string())) + .collect(); + advertised.sort(); + assert_eq!(ids, advertised); + assert_eq!( + response, + reopened + .rec(recovery_id.as_deref().unwrap(), &request) + .unwrap() + ); +} + +/// Assures: KEY-LOAD-001, KEY-STORE-001 +#[test] +fn invalid_existing_stores_fail_without_changing_files() { + for files in [ + vec![("bad.jwk", "{")], + vec![("sign.jwk", SIGNING)], + vec![("rec.jwk", RECOVERY)], + vec![ + ("sign.jwk", SIGNING), + ("rec.jwk", RECOVERY), + ("bad.jwk", "{"), + ], + vec![( + "bad.jwk", + r#"{"kty":"EC","crv":"P-521","x":"AA","y":"AA","alg":"unsupported"}"#, + )], + ] { + let dir = tempfile::tempdir().unwrap(); + for (name, content) in files { + fs::write(dir.path().join(name), content).unwrap(); + } + let before = snapshot(dir.path()); + assert!(TangyLib::init(KeySource::LocalDir(dir.path())).is_err()); + assert_eq!(before, snapshot(dir.path())); + } +} + +/// Assures: KEY-LOAD-001 +#[test] +fn unreadable_key_entry_and_invalid_utf8_are_not_skipped() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("sign.jwk"), SIGNING).unwrap(); + fs::write(dir.path().join("rec.jwk"), RECOVERY).unwrap(); + // A directory cannot be read as a key file even when running as root. + fs::create_dir(dir.path().join("unreadable.jwk")).unwrap(); + assert!(TangyLib::init(KeySource::LocalDir(dir.path())).is_err()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 3); + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("invalid.jwk"), [0xff, 0xfe]).unwrap(); + let before = snapshot(dir.path()); + assert!(TangyLib::init(KeySource::LocalDir(dir.path())).is_err()); + assert_eq!(before, snapshot(dir.path())); +} + +/// Assures: KEY-LOAD-001 +#[test] +fn nonexistent_directory_and_regular_file_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + assert!(TangyLib::init(KeySource::LocalDir(&dir.path().join("missing"))).is_err()); + let path = dir.path().join("file"); + fs::write(&path, "").unwrap(); + assert!(TangyLib::init(KeySource::LocalDir(&path)).is_err()); +} + +/// Assures: KEY-PERM-001 +#[cfg(unix)] +#[test] +fn generated_key_files_are_read_only_for_owner_and_group() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + let modes: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .filter(|entry| { + entry + .as_ref() + .unwrap() + .path() + .extension() + .is_some_and(|ext| ext == "jwk") + }) + .map(|entry| entry.unwrap().metadata().unwrap().permissions().mode() & 0o777) + .collect(); + assert_eq!(modes, [0o440, 0o440]); +} + +/// Assures: KEY-PUBLISH-001, ERR-DIAG-001 +#[cfg(unix)] +#[test] +fn read_only_empty_store_reports_the_create_failure_and_stays_unchanged() { + use std::os::unix::fs::PermissionsExt; + use tangy_lib::{Error, WriteStep}; + // Root ignores directory permissions, so this failure cannot occur there. + if unsafe { libc::geteuid() } == 0 { + return; + } + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("notes.txt"), "not a key").unwrap(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap(); + let result = TangyLib::init(KeySource::LocalDir(dir.path())); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(matches!( + result, + Err(Error::KeyWrite { + operation: WriteStep::Create, + ref path, + ref source, + }) if path.parent() == Some(dir.path()) + && source.kind() == std::io::ErrorKind::PermissionDenied + )); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); +} + +/// Assures: KEY-PATH-001 +#[test] +fn non_file_key_entries_are_rejected() { + use tangy_lib::Error; + for name in ["bad.jwk", ".bad.jwk"] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(name); + fs::create_dir(&path).unwrap(); + assert!( + matches!(TangyLib::init(KeySource::LocalDir(dir.path())), Err(Error::NotAFile { path: actual }) if actual == path) + ); + } +} + +/// Assures: KEY-PATH-001, KEY-STORE-001 +#[cfg(unix)] +#[test] +fn symlinked_roots_and_keys_are_followed_like_tang() { + use std::os::unix::fs::symlink; + use tangy_lib::Error; + let advertised = |lib: &TangyLib| { + let adv: Value = serde_json::from_str(&lib.adv(None).unwrap()).unwrap(); + payload(&adv)["keys"] + .as_array() + .unwrap() + .iter() + .map(|key| thumbprint(&key.to_string())) + .collect::>() + }; + + // A symlinked root, including spellings that resolve through the link. + let parent = tempfile::tempdir().unwrap(); + let real = parent.path().join("real"); + fs::create_dir(&real).unwrap(); + let link = parent.path().join("link"); + symlink(&real, &link).unwrap(); + let generated = advertised(&TangyLib::init(KeySource::LocalDir(&link)).unwrap()); + assert_eq!(snapshot(&real).len(), 2); + for root in [link.join("."), format!("{}/", link.display()).into()] { + assert_eq!( + advertised(&TangyLib::init(KeySource::LocalDir(&root)).unwrap()), + generated + ); + } + + // A Kubernetes Secret volume: every key is a symlink through `..data`. + let mount = tempfile::tempdir().unwrap(); + let version = mount.path().join("..2026_10_10"); + fs::create_dir(&version).unwrap(); + fs::write(version.join("sign.jwk"), SIGNING).unwrap(); + fs::write(version.join("rec.jwk"), RECOVERY).unwrap(); + fs::write(version.join(".old.jwk"), SECOND_SIGNING).unwrap(); + symlink("..2026_10_10", mount.path().join("..data")).unwrap(); + for name in ["sign.jwk", "rec.jwk", ".old.jwk"] { + symlink(format!("..data/{name}"), mount.path().join(name)).unwrap(); + } + let mounted = TangyLib::init(KeySource::LocalDir(mount.path())).unwrap(); + assert_eq!( + advertised(&mounted), + [thumbprint(SIGNING), thumbprint(RECOVERY)] + .into_iter() + .collect() + ); + mounted.adv(Some(&thumbprint(SECOND_SIGNING))).unwrap(); + + // Dangling key links fail to read rather than being skipped; links to + // directories and FIFOs fail before opening or blocking. + let dir = tempfile::tempdir().unwrap(); + let dangling = dir.path().join("dangling.jwk"); + symlink(parent.path().join("missing"), &dangling).unwrap(); + assert!( + matches!(TangyLib::init(KeySource::LocalDir(dir.path())), Err(Error::KeyRead { path, .. }) if path == dangling) + ); + let dir = tempfile::tempdir().unwrap(); + let to_directory = dir.path().join("directory.jwk"); + symlink(&real, &to_directory).unwrap(); + assert!( + matches!(TangyLib::init(KeySource::LocalDir(dir.path())), Err(Error::NotAFile { path }) if path == to_directory) + ); + let dir = tempfile::tempdir().unwrap(); + let fifo = dir.path().join("fifo.jwk"); + use std::os::unix::ffi::OsStrExt; + let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + assert!( + matches!(TangyLib::init(KeySource::LocalDir(dir.path())), Err(Error::NotAFile { path }) if path == fifo) + ); +} + +/// Assures: KEY-PATH-001 +#[cfg(windows)] +#[test] +fn windows_junctions_are_followed_like_symlinks() { + use std::process::Command; + use tangy_lib::Error; + let junction = |link: &Path, target: &Path| { + assert!(Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .status() + .unwrap() + .success()); + }; + let parent = tempfile::tempdir().unwrap(); + let real = parent.path().join("real"); + fs::create_dir(&real).unwrap(); + let root = parent.path().join("root"); + junction(&root, &real); + TangyLib::init(KeySource::LocalDir(&root)).unwrap(); + assert_eq!(snapshot(&real).len(), 2); + TangyLib::init(KeySource::LocalDir(&root)).unwrap(); + fs::remove_dir(root).unwrap(); + + // A key junction resolves to a directory, which is not a key file. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bad.jwk"); + junction(&path, &real); + assert!( + matches!(TangyLib::init(KeySource::LocalDir(dir.path())), Err(Error::NotAFile { path: actual }) if actual == path) + ); + fs::remove_dir(path).unwrap(); +} + +/// Assures: KEY-PERM-WINDOWS-001 +#[cfg(windows)] +#[test] +fn windows_generated_keys_inherit_the_secured_directory_acl() { + use std::process::Command; + let dir = tempfile::tempdir().unwrap(); + let powershell = |script: &str| { + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + // Cargo/nextest can inherit PowerShell 7's module paths from CI. + // Let Windows PowerShell rebuild its own paths so Set-Acl/Get-Acl + // load the compatible Microsoft.PowerShell.Security module. + .env_remove("PSModulePath") + .env("TANGY_ACL_TEST_DIR", dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + powershell( + r#" + $ErrorActionPreference = 'Stop' + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object System.Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) + Set-Acl -LiteralPath $env:TANGY_ACL_TEST_DIR -AclObject $acl + "#, + ); + TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + powershell( + r#" + $ErrorActionPreference = 'Stop' + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $keys = @(Get-ChildItem -LiteralPath $env:TANGY_ACL_TEST_DIR -Filter '*.jwk') + if ($keys.Count -ne 2) { throw 'Expected two keys' } + foreach ($key in $keys) { + $acl = Get-Acl -LiteralPath $key.FullName + if ($acl.AreAccessRulesProtected) { throw 'Key inheritance disabled' } + $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) + if ($rules.Count -ne 1) { throw 'Unexpected ACL entries' } + if (-not $rules[0].IsInherited -or $rules[0].IdentityReference.Value -ne $sid -or $rules[0].FileSystemRights -ne 'FullControl') { throw 'Key did not inherit secured directory ACL' } + } + "#, + ); +} + +/// Assures: KEY-STORE-001 +#[test] +fn unrelated_files_are_preserved_when_an_empty_store_generates() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("notes.txt"), "not a key").unwrap(); + fs::write(dir.path().join("ignored.JWK"), "not a key").unwrap(); + fs::write(dir.path().join(".tangy-interrupted.tmp"), "partial").unwrap(); + TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + assert_eq!(snapshot(dir.path()).len(), 2); + assert_eq!( + fs::read_to_string(dir.path().join("notes.txt")).unwrap(), + "not a key" + ); + assert_eq!( + fs::read_to_string(dir.path().join("ignored.JWK")).unwrap(), + "not a key" + ); + assert_eq!( + fs::read_to_string(dir.path().join(".tangy-interrupted.tmp")).unwrap(), + "partial" + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 5); +} diff --git a/tests/protocol.rs b/tests/protocol.rs new file mode 100644 index 0000000..aa1ef09 --- /dev/null +++ b/tests/protocol.rs @@ -0,0 +1,267 @@ +mod common; + +use std::collections::BTreeSet; + +use base64ct::{Base64UrlUnpadded, Encoding}; +use common::*; +use p521::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use p521::elliptic_curve::sec1::ToSec1Point; +use serde_json::{json, Value}; +use tangy_lib::MyJwkEcKey; + +// Verify the exact wire signing input, without reserializing either JWS field. +fn verified_signers(adv: &Value) -> Vec> { + let payload = payload(adv); + let keys: Vec = serde_json::from_value(payload["keys"].clone()).unwrap(); + let signatures = match adv.get("signatures") { + Some(signatures) => { + assert!(adv.get("protected").is_none()); + assert!(adv.get("signature").is_none()); + signatures.as_array().unwrap().iter().collect::>() + } + None => vec![adv], + }; + signatures + .into_iter() + .map(|sig| { + let protected: Value = serde_json::from_slice(&decode(&sig["protected"])).unwrap(); + assert_eq!(protected, json!({"alg":"ES512", "cty":"jwk-set+json"})); + let bytes = decode(&sig["signature"]); + assert_eq!(bytes.len(), 132, "ES512 uses fixed-width R || S"); + let signature = Signature::from_slice(&bytes).unwrap(); + let input = format!( + "{}.{}", + sig["protected"].as_str().unwrap(), + adv["payload"].as_str().unwrap() + ); + let matches: Vec<_> = keys + .iter() + .filter(|key| key.alg.as_deref() == Some("ES512")) + .filter(|key| { + let point = key.to_p521_public_key().unwrap().to_sec1_point(false); + VerifyingKey::from_sec1_bytes(point.as_bytes()) + .unwrap() + .verify(input.as_bytes(), &signature) + .is_ok() + }) + .map(|key| key.thumbprint().unwrap()) + .collect(); + assert!(matches.len() <= 1); + matches.into_iter().next() + }) + .collect() +} + +/// Assures: ADV-SIG-001, KEY-PRIV-001 +#[test] +fn advertisements_verify_and_publish_only_public_keys() { + for signing_keys in [vec![SIGNING], vec![SIGNING, SECOND_SIGNING]] { + let mut keys = signing_keys.clone(); + keys.push(RECOVERY); + let lib = library(&keys); + let mut selections = vec![None]; + selections.extend(signing_keys.iter().map(|key| Some(thumbprint(key)))); + for selection in selections { + let wire = lib.adv(selection.as_deref()).unwrap(); + let adv: Value = serde_json::from_str(&wire).unwrap(); + assert_public(&adv); + let payload = payload(&adv); + assert_public(&payload); + let advertised = payload["keys"].as_array().unwrap(); + assert_eq!(advertised.len(), keys.len()); + let advertised_ids: BTreeSet<_> = advertised + .iter() + .map(|k| { + let ops = k["key_ops"].as_array().unwrap(); + assert!(!ops.contains(&json!("sign"))); + assert!(ops.contains(&json!(if k["alg"] == "ES512" { + "verify" + } else { + "deriveKey" + }))); + thumbprint(&k.to_string()) + }) + .collect(); + assert_eq!(advertised_ids, keys.iter().map(|k| thumbprint(k)).collect()); + // Clevis requires every advertised verification key to have signed, + // including when the request names a particular trusted key. + let expected: BTreeSet<_> = signing_keys.iter().map(|k| thumbprint(k)).collect(); + let signers = verified_signers(&adv); + assert_eq!(signers.len(), expected.len()); + assert_eq!( + signers + .into_iter() + .map(Option::unwrap) + .collect::>(), + expected + ); + assert_eq!(adv.get("signatures").is_some(), signing_keys.len() > 1); + } + assert_eq!( + lib.adv(None).unwrap(), + lib.adv(None).unwrap(), + "default advertisement is cached" + ); + } +} + +/// Assures: ADV-SIG-001, KEY-VALID-001, ADV-OPS-001 +#[test] +fn advertised_operations_match_the_roles_served_by_each_key() { + let with_extra_operations = |source| { + let mut key: Value = serde_json::from_str(source).unwrap(); + key["key_ops"] = json!(["sign", "verify", "deriveKey", "encrypt", "extra"]); + key["use"] = json!("sig"); + key.to_string() + }; + let signing = with_extra_operations(SIGNING); + let recovery = with_extra_operations(RECOVERY); + let lib = library(&[&signing, &recovery]); + for selection in [None, Some(thumbprint(SIGNING))] { + let adv: Value = serde_json::from_str(&lib.adv(selection.as_deref()).unwrap()).unwrap(); + let body = payload(&adv); + for key in body["keys"].as_array().unwrap() { + let expected = if key["alg"] == "ES512" { + "verify" + } else { + "deriveKey" + }; + assert_eq!(key["key_ops"], json!([expected])); + if key["alg"] == "ECMR" { + assert!(key.get("use").is_none()); + } + } + // Every key advertised for verification must actually sign, as Clevis requires. + assert_eq!(verified_signers(&adv), [Some(thumbprint(SIGNING))]); + } + assert!(lib + .rec(&thumbprint(RECOVERY), &vectors()[0]["request"].to_string()) + .is_ok()); +} + +/// Assures: ADV-SIG-001 +#[test] +fn advertisement_payload_is_unpadded_for_all_length_remainders() { + for metadata in ["a", "ab", "abc"] { + let mut key: Value = serde_json::from_str(SIGNING).unwrap(); + key["kid"] = json!(metadata); + let key = key.to_string(); + let lib = library(&[&key, RECOVERY]); + let adv: Value = serde_json::from_str(&lib.adv(None).unwrap()).unwrap(); + assert_eq!(verified_signers(&adv), [Some(thumbprint(SIGNING))]); + } +} + +/// Assures: ADV-SIG-001 +#[test] +fn altered_payload_and_signature_do_not_verify() { + let lib = library(&[SIGNING, RECOVERY]); + let adv: Value = serde_json::from_str(&lib.adv(None).unwrap()).unwrap(); + let mut changed = adv.clone(); + let mut body = payload(&adv); + body["tampered"] = json!(true); + changed["payload"] = json!(Base64UrlUnpadded::encode_string( + &serde_json::to_vec(&body).unwrap() + )); + assert_eq!(verified_signers(&changed), [None]); + let mut changed = adv.clone(); + let mut bytes = decode(&adv["signature"]); + bytes[65] ^= 1; + changed["signature"] = json!(Base64UrlUnpadded::encode_string(&bytes)); + assert_eq!(verified_signers(&changed), [None]); +} + +/// Assures: REC-CORRECT-001, KEY-PRIV-001 +#[test] +fn recovery_matches_independent_vectors() { + let lib = library(&[SIGNING, RECOVERY]); + for vector in vectors() { + let wire = lib + .rec(&thumbprint(RECOVERY), &vector["request"].to_string()) + .unwrap(); + let response: Value = serde_json::from_str(&wire).unwrap(); + assert_eq!(response, vector["response"]); + assert_public(&response); + fixture_key(&wire).to_p521_public_key().unwrap(); + } +} + +/// Assures: REC-CORRECT-001 +#[test] +fn blinded_client_recovers_its_original_shared_point() { + let lib = library(&[SIGNING, RECOVERY]); + let server = fixture_key(RECOVERY).to_p521_public_key().unwrap(); + // Use fixed valid scalars without depending on the server's private key. + let client = p521::SecretKey::from_slice(&[7; 32]).unwrap(); + let blinder = p521::SecretKey::from_slice(&[11; 32]).unwrap(); + let blinded = p521::ProjectivePoint::from(*client.public_key().as_affine()) + + p521::ProjectivePoint::from(*blinder.public_key().as_affine()); + let point = p521::PublicKey::from_affine(blinded.to_affine()) + .unwrap() + .to_sec1_point(false); + let request = json!({ + "kty": "EC", "crv": "P-521", + "x": Base64UrlUnpadded::encode_string(point.x().unwrap()), + "y": Base64UrlUnpadded::encode_string(point.y().unwrap()), + }) + .to_string(); + let response = lib.rec(&thumbprint(RECOVERY), &request).unwrap(); + let response = fixture_key(&response).to_p521_public_key().unwrap(); + let server_point = p521::ProjectivePoint::from(*server.as_affine()); + let unblinded = p521::ProjectivePoint::from(*response.as_affine()) + - server_point * blinder.to_nonzero_scalar().as_ref(); + let expected = server_point * client.to_nonzero_scalar().as_ref(); + assert_eq!(unblinded, expected); +} + +/// Assures: REC-CORRECT-001 +#[test] +fn recovery_accepts_public_requests_without_optional_metadata() { + let lib = library(&[SIGNING, RECOVERY]); + let vector = &vectors()[1]; + let mut request = vector["request"].clone(); + request.as_object_mut().unwrap().remove("alg"); + request.as_object_mut().unwrap().remove("key_ops"); + assert_eq!( + serde_json::from_str::( + &lib.rec(&thumbprint(RECOVERY), &request.to_string()) + .unwrap() + ) + .unwrap(), + vector["response"] + ); +} + +/// Assures: REQ-REJECT-001 +#[test] +fn malformed_recovery_requests_return_errors() { + let lib = library(&[SIGNING, RECOVERY]); + let id = thumbprint(RECOVERY); + for request in ["", "{", "null", "[]", "{}", r#"{"kty":12}"#] { + assert!(lib.rec(&id, request).is_err(), "accepted {request}"); + } + let mutations = [ + ("x", json!("%%%")), + ("x", json!("AA")), + ("y", json!("")), + ("x", json!(Base64UrlUnpadded::encode_string(&[0xff; 66]))), + ("crv", json!("P-256")), + ("crv", json!("unknown")), + ("kty", json!("RSA")), + ("alg", json!("ES512")), + ("key_ops", json!([])), + ("key_ops", json!(["sign"])), + ]; + for (field, value) in mutations { + let mut request = vectors()[1]["request"].clone(); + request[field] = value; + assert!( + lib.rec(&id, &request.to_string()).is_err(), + "accepted invalid {field}" + ); + } + let mut off_curve = vectors()[1]["request"].clone(); + off_curve["x"] = json!(Base64UrlUnpadded::encode_string(&[0; 66])); + off_curve["y"] = off_curve["x"].clone(); + assert!(lib.rec(&id, &off_curve.to_string()).is_err()); +} diff --git a/tests/rotation.rs b/tests/rotation.rs new file mode 100644 index 0000000..d76590b --- /dev/null +++ b/tests/rotation.rs @@ -0,0 +1,317 @@ +mod common; + +use std::{collections::BTreeSet, fs}; + +use common::*; +use p521::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use p521::elliptic_curve::sec1::ToSec1Point; +use serde_json::{json, Value}; +use tangy_lib::{Error, KeyLocation, KeySource, TangyLib}; + +fn signers(adv: &Value, candidates: &[&str]) -> BTreeSet { + let signatures = adv + .get("signatures") + .map_or_else(|| vec![adv], |s| s.as_array().unwrap().iter().collect()); + let mut result = BTreeSet::new(); + for signature in signatures { + let protected: Value = serde_json::from_slice(&decode(&signature["protected"])).unwrap(); + assert_eq!(protected, json!({"alg":"ES512", "cty":"jwk-set+json"})); + let bytes = decode(&signature["signature"]); + assert_eq!(bytes.len(), 132); + let signature_bytes = Signature::from_slice(&bytes).unwrap(); + let input = format!( + "{}.{}", + signature["protected"].as_str().unwrap(), + adv["payload"].as_str().unwrap() + ); + let matching: Vec<_> = candidates + .iter() + .filter(|json| { + let point = fixture_key(json) + .to_p521_public_key() + .unwrap() + .to_sec1_point(false); + VerifyingKey::from_sec1_bytes(point.as_bytes()) + .unwrap() + .verify(input.as_bytes(), &signature_bytes) + .is_ok() + }) + .map(|json| thumbprint(json)) + .collect(); + assert_eq!(matching.len(), 1); + assert!(result.insert(matching[0].clone()), "duplicate signature"); + } + result +} + +fn verify_rotation(lib: &TangyLib) { + for (selected, expected) in [ + (None, vec![SECOND_SIGNING]), + (Some(thumbprint(SECOND_SIGNING)), vec![SECOND_SIGNING]), + (Some(thumbprint(SIGNING)), vec![SECOND_SIGNING, SIGNING]), + ] { + let adv: Value = serde_json::from_str(&lib.adv(selected.as_deref()).unwrap()).unwrap(); + let body = payload(&adv); + assert_public(&body); + let ids: BTreeSet<_> = body["keys"] + .as_array() + .unwrap() + .iter() + .map(|k| thumbprint(&k.to_string())) + .collect(); + assert_eq!( + ids, + [thumbprint(SECOND_SIGNING), thumbprint(RECOVERY)] + .into_iter() + .collect() + ); + assert_eq!( + signers(&adv, &[SIGNING, SECOND_SIGNING]), + expected.iter().map(|k| thumbprint(k)).collect() + ); + } +} + +/// Assures: KEY-ROTATE-001, ADV-SIG-001 +#[test] +fn slices_advertise_active_keys_and_sign_with_old_keys_only_when_selected() { + let lib = TangyLib::init(KeySource::KeySets { + active: &[SECOND_SIGNING, RECOVERY], + rotated: &[SIGNING], + }) + .unwrap(); + verify_rotation(&lib); + // Vector remains entirely active; all advertised signers sign by default. + let lib = TangyLib::init(KeySource::Vector(&[SIGNING, SECOND_SIGNING, RECOVERY])).unwrap(); + let adv: Value = serde_json::from_str(&lib.adv(None).unwrap()).unwrap(); + assert_eq!(payload(&adv)["keys"].as_array().unwrap().len(), 3); + assert_eq!(signers(&adv, &[SIGNING, SECOND_SIGNING]).len(), 2); +} + +/// Assures: KEY-ROTATE-001 +#[test] +fn directory_dot_prefix_marks_rotated_keys_including_literal_dot_jwk() { + for rotated_name in [".old.jwk", ".jwk"] { + let dir = tempfile::tempdir().unwrap(); + for (name, key) in [ + (".old.jwk", SIGNING), + ("sign.jwk", SECOND_SIGNING), + ("rec.jwk", RECOVERY), + ] { + fs::write( + dir.path().join(if name == ".old.jwk" { + rotated_name + } else { + name + }), + key, + ) + .unwrap(); + } + fs::write(dir.path().join("ignore.txt"), "not JSON").unwrap(); + verify_rotation(&TangyLib::init(KeySource::LocalDir(dir.path())).unwrap()); + } +} + +/// Assures: KEY-ROTATE-001, REC-CORRECT-001 +#[test] +fn rotated_recovery_keys_still_recover_pre_rotation_requests() { + let generated = tangy_lib::create_new_key_set().unwrap(); + let active: Vec<_> = generated.iter().map(String::as_str).collect(); + let slice = TangyLib::init(KeySource::KeySets { + active: &active, + rotated: &[SIGNING, RECOVERY], + }) + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + for (i, json) in active.iter().enumerate() { + fs::write(dir.path().join(format!("new-{i}.jwk")), json).unwrap(); + } + fs::write(dir.path().join(".sign.jwk"), SIGNING).unwrap(); + fs::write(dir.path().join(".rec.jwk"), RECOVERY).unwrap(); + let directory = TangyLib::init(KeySource::LocalDir(dir.path())).unwrap(); + for lib in [slice, directory] { + for vector in vectors() { + assert_eq!( + serde_json::from_str::( + &lib.rec(&thumbprint(RECOVERY), &vector["request"].to_string()) + .unwrap() + ) + .unwrap(), + vector["response"] + ); + } + let adv: Value = serde_json::from_str(&lib.adv(None).unwrap()).unwrap(); + assert!(!payload(&adv)["keys"] + .as_array() + .unwrap() + .iter() + .any(|key| thumbprint(&key.to_string()) == thumbprint(RECOVERY))); + assert!(matches!( + lib.adv(Some(&thumbprint(RECOVERY))), + Err(Error::ForbiddenOperation { .. }) + )); + } +} + +/// Assures: KEY-ROTATE-001, KEY-STORE-001 +#[test] +fn rotated_keys_never_satisfy_missing_active_roles_or_trigger_generation() { + for (active, missing) in [ + (vec![], (true, true)), + (vec![SECOND_SIGNING], (false, true)), + (vec![RECOVERY], (true, false)), + ] { + // Use a fresh rotated pair to avoid any duplicate active identities. + let old = tangy_lib::create_new_key_set().unwrap(); + let rotated: Vec<_> = old.iter().map(String::as_str).collect(); + let result = TangyLib::init(KeySource::KeySets { + active: &active, + rotated: &rotated, + }); + assert!( + matches!(result, Err(Error::MissingKeys { signing, recovery }) if (signing, recovery) == missing) + ); + let dir = tempfile::tempdir().unwrap(); + for (i, json) in active.iter().enumerate() { + fs::write(dir.path().join(format!("active-{i}.jwk")), json).unwrap(); + } + for (i, json) in rotated.iter().enumerate() { + fs::write(dir.path().join(format!(".rotated-{i}.jwk")), json).unwrap(); + } + let before: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + assert!( + matches!(TangyLib::init(KeySource::LocalDir(dir.path())), Err(Error::MissingKeys { signing, recovery }) if (signing, recovery) == missing) + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), before.len()); + for path in before { + assert!(path.exists()); + } + } +} + +/// Assures: KEY-DUP-001, ERR-DIAG-001 +#[test] +fn duplicate_slice_identities_report_both_origins_even_with_conflicting_metadata() { + let mut conflict: Value = serde_json::from_str(SIGNING).unwrap(); + conflict["alg"] = json!("ECMR"); + conflict["key_ops"] = json!(["deriveKey"]); + conflict["kid"] = json!("metadata does not change identity"); + let conflict = conflict.to_string(); + for copy in [SIGNING, conflict.as_str()] { + for rotated in [false, true] { + let error = if rotated { + TangyLib::init(KeySource::KeySets { + active: &[SIGNING, RECOVERY], + rotated: &[copy], + }) + } else { + TangyLib::init(KeySource::Vector(&[SIGNING, RECOVERY, copy])) + } + .unwrap_err(); + assert!( + matches!(&error, Error::DuplicateKey { kid, first: KeyLocation::Input(0), second } if kid == &thumbprint(SIGNING) && second == &if rotated { KeyLocation::RotatedInput(0) } else { KeyLocation::Input(2) }) + ); + for json in [SIGNING, RECOVERY] { + assert!( + !format!("{error} {error:?}").contains(fixture_key(json).d.as_ref().unwrap()) + ); + } + } + } + assert!(matches!( + TangyLib::init(KeySource::KeySets { + active: &[SECOND_SIGNING, RECOVERY], + rotated: &[SIGNING, SIGNING] + }), + Err(Error::DuplicateKey { + first: KeyLocation::RotatedInput(0), + second: KeyLocation::RotatedInput(1), + .. + }) + )); +} + +/// Assures: KEY-DUP-001, KEY-STORE-001 +#[test] +fn duplicate_directory_diagnostics_are_sorted_and_do_not_modify_keys() { + for names in [["z.jwk", "a.jwk"], ["active.jwk", ".old.jwk"]] { + let dir = tempfile::tempdir().unwrap(); + for name in names { + fs::write(dir.path().join(name), SIGNING).unwrap(); + } + fs::write(dir.path().join("rec.jwk"), RECOVERY).unwrap(); + let mut ordered = names; + ordered.sort(); + let error = TangyLib::init(KeySource::LocalDir(dir.path())).unwrap_err(); + assert!( + matches!(error, Error::DuplicateKey { kid, first: KeyLocation::File(first), second: KeyLocation::File(second) } if kid == thumbprint(SIGNING) && first == dir.path().join(ordered[0]) && second == dir.path().join(ordered[1])) + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 3); + for name in names { + assert_eq!(fs::read_to_string(dir.path().join(name)).unwrap(), SIGNING); + } + } +} + +/// Assures: KEY-VALID-001, KEY-PRIV-001 +#[test] +fn rotated_keys_receive_identical_validation_and_safe_diagnostic_chains() { + use std::error::Error as _; + for source in [SIGNING, RECOVERY] { + for (field, value) in [ + ("crv", json!("P-256")), + ("kty", json!("RSA")), + ("x", json!("AA")), + ("d", json!("AA")), + ( + "d", + serde_json::from_str::(SECOND_SIGNING).unwrap()["d"].clone(), + ), + ("alg", json!("unsupported")), + ("key_ops", json!([])), + ("d", json!([fixture_key(source).d.unwrap()])), + ] { + let mut bad: Value = serde_json::from_str(source).unwrap(); + bad[field] = value; + let bad = bad.to_string(); + let generated = tangy_lib::create_new_key_set().unwrap(); + let active: Vec<_> = generated.iter().map(String::as_str).collect(); + let validation = TangyLib::init(KeySource::KeySets { + active: &active, + rotated: &[&bad], + }) + .unwrap_err(); + assert!(matches!( + &validation, + Error::InvalidStoredKey { + location: KeyLocation::RotatedInput(0), + .. + } + )); + let mut chain = format!("{validation} {validation:?}"); + let mut cause = validation.source(); + while let Some(source) = cause { + chain.push_str(&format!("{source} {source:?}")); + cause = source.source(); + } + assert!(!chain.contains(fixture_key(source).d.as_ref().unwrap())); + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(".bad.jwk"), &bad).unwrap(); + assert!(matches!( + TangyLib::init(KeySource::LocalDir(dir.path())), + Err(Error::InvalidStoredKey { + location: KeyLocation::File(_), + .. + }) + )); + assert_eq!( + fs::read_to_string(dir.path().join(".bad.jwk")).unwrap(), + bad + ); + } + } +}